Pārlūkot izejas kodu

Merge pull request #14249 from overleaf/mj-delete-row-column

[visual] Enable buttons for deleting and adding columns or rows

GitOrigin-RevId: 5cb68091d79b46eab507510e03180852b97666b9
Mathias Jakobsen 3 gadi atpakaļ
vecāks
revīzija
5c5c5be594

+ 20 - 0
services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx

@@ -66,6 +66,16 @@ export class TableSelection {
     return row >= minY && row <= maxY && minX === 0 && maxX === totalColumns - 1
   }
 
+  isAnyRowSelected(totalColumns: number) {
+    const { minX, maxX } = this.normalized()
+    return minX === 0 && maxX === totalColumns - 1
+  }
+
+  isAnyColumnSelected(totalRows: number) {
+    const { minY, maxY } = this.normalized()
+    return minY === 0 && maxY === totalRows - 1
+  }
+
   isColumnSelected(cell: number, totalRows: number) {
     const { minX, maxX, minY, maxY } = this.normalized()
     return cell >= minX && cell <= maxX && minY === 0 && maxY === totalRows - 1
@@ -146,6 +156,16 @@ export class TableSelection {
       { row: newRow, cell: this.to.cell }
     )
   }
+
+  spansEntireTable(totalColumns: number, totalRows: number) {
+    const { minX, maxX, minY, maxY } = this.normalized()
+    return (
+      minX === 0 &&
+      maxX === totalColumns - 1 &&
+      minY === 0 &&
+      maxY === totalRows - 1
+    )
+  }
 }
 
 const SelectionContext = createContext<

+ 2 - 0
services/web/frontend/js/features/source-editor/components/table-generator/contexts/table-context.tsx

@@ -2,6 +2,7 @@ import { FC, createContext, useContext } from 'react'
 import { Positions, TableData } from '../tabular'
 import {
   CellPosition,
+  CellSeparator,
   RowPosition,
   RowSeparator,
   generateTable,
@@ -16,6 +17,7 @@ const TableContext = createContext<
       specification: { from: number; to: number }
       rowPositions: RowPosition[]
       rowSeparators: RowSeparator[]
+      cellSeparators: CellSeparator[][]
       positions: Positions
     }
   | undefined

+ 156 - 1
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts

@@ -1,7 +1,11 @@
 import { EditorView } from '@codemirror/view'
 import { ColumnDefinition, Positions } from '../tabular'
 import { ChangeSpec } from '@codemirror/state'
-import { RowSeparator, parseColumnSpecifications } from '../utils'
+import {
+  CellSeparator,
+  RowSeparator,
+  parseColumnSpecifications,
+} from '../utils'
 import { TableSelection } from '../contexts/selection-context'
 
 /* eslint-disable no-unused-vars */
@@ -140,3 +144,154 @@ const generateColumnSpecification = (columns: ColumnDefinition[]) => {
     )
     .join('')
 }
+
+export const removeRowOrColumns = (
+  view: EditorView,
+  selection: TableSelection,
+  positions: Positions,
+  cellSeparators: CellSeparator[][]
+) => {
+  const {
+    minX: startCell,
+    maxX: endCell,
+    minY: startRow,
+    maxY: endRow,
+  } = selection.normalized()
+  const changes: { from: number; to: number; insert: string }[] = []
+  const specification = view.state.sliceDoc(
+    positions.columnDeclarations.from,
+    positions.columnDeclarations.to
+  )
+  const columnSpecification = parseColumnSpecifications(specification)
+  const numberOfColumns = columnSpecification.length
+  const numberOfRows = positions.rowPositions.length
+
+  if (selection.spansEntireTable(numberOfColumns, numberOfRows)) {
+    return emptyTable(view, columnSpecification, positions)
+  }
+
+  for (let row = startRow; row <= endRow; row++) {
+    if (selection.isRowSelected(row, numberOfColumns)) {
+      const rowPosition = positions.rowPositions[row]
+      changes.push({
+        from: rowPosition.from,
+        to: rowPosition.to,
+        insert: '',
+      })
+    } else {
+      for (let cell = startCell; cell <= endCell; cell++) {
+        if (selection.isColumnSelected(cell, numberOfRows)) {
+          // FIXME: handle multicolumn
+          if (cell === 0 && cellSeparators[row].length > 0) {
+            // Remove the cell separator between the first and second cell
+            changes.push({
+              from: positions.cells[row][cell].from,
+              to: cellSeparators[row][0].to,
+              insert: '',
+            })
+          } else {
+            // Remove the cell separator between the cell before and this if possible
+            const cellPosition = positions.cells[row][cell]
+            const from =
+              cellSeparators[row][cell - 1]?.from ?? cellPosition.from
+            const to = cellPosition.to
+            changes.push({
+              from,
+              to,
+              insert: '',
+            })
+          }
+        }
+      }
+    }
+  }
+  const filteredColumns = columnSpecification.filter(
+    (_, i) => !selection.isColumnSelected(i, numberOfRows)
+  )
+  const newSpecification = generateColumnSpecification(filteredColumns)
+  changes.push({
+    from: positions.columnDeclarations.from,
+    to: positions.columnDeclarations.to,
+    insert: newSpecification,
+  })
+  view.dispatch({ changes })
+}
+
+const emptyTable = (
+  view: EditorView,
+  columnSpecification: ColumnDefinition[],
+  positions: Positions
+) => {
+  const newColumns = columnSpecification.slice(0, 1)
+  newColumns[0].borderLeft = 0
+  newColumns[0].borderRight = 0
+  const newSpecification = generateColumnSpecification(newColumns)
+  const changes: ChangeSpec[] = []
+  changes.push({
+    from: positions.columnDeclarations.from,
+    to: positions.columnDeclarations.to,
+    insert: newSpecification,
+  })
+  const from = positions.rowPositions[0].from
+  const to = positions.rowPositions[positions.rowPositions.length - 1].to
+  changes.push({
+    from,
+    to,
+    insert: '\\\\',
+  })
+  view.dispatch({ changes })
+}
+
+export const insertRow = (
+  view: EditorView,
+  selection: TableSelection,
+  positions: Positions,
+  below: boolean
+) => {
+  // TODO: Handle borders
+  const { maxY, minY } = selection.normalized()
+  const from = below
+    ? positions.rowPositions[maxY].to
+    : positions.rowPositions[minY].from
+  const numberOfColumns = positions.cells[maxY].length
+  const insert = `\n${' &'.repeat(numberOfColumns - 1)}\\\\`
+  view.dispatch({ changes: { from, to: from, insert } })
+}
+
+export const insertColumn = (
+  view: EditorView,
+  selection: TableSelection,
+  positions: Positions,
+  after: boolean
+) => {
+  // TODO: Handle borders
+  // FIXME: Handle multicolumn
+  const { maxX, minX } = selection.normalized()
+  const changes: ChangeSpec[] = []
+  for (const row of positions.cells) {
+    const from = after ? row[maxX].to : row[minX].from
+    changes.push({
+      from,
+      to: from,
+      insert: ' &',
+    })
+  }
+
+  const specification = view.state.sliceDoc(
+    positions.columnDeclarations.from,
+    positions.columnDeclarations.to
+  )
+  const columnSpecification = parseColumnSpecifications(specification)
+  columnSpecification.splice(after ? maxX + 1 : minX, 0, {
+    alignment: 'left',
+    borderLeft: 0,
+    borderRight: 0,
+    content: 'l',
+  })
+  changes.push({
+    from: positions.columnDeclarations.from,
+    to: positions.columnDeclarations.to,
+    insert: generateColumnSpecification(columnSpecification),
+  })
+  view.dispatch({ changes })
+}

+ 16 - 15
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx

@@ -1,6 +1,6 @@
 import { FC, memo, useRef } from 'react'
 import useDropdown from '../../../../../shared/hooks/use-dropdown'
-import { Button, ListGroup, Overlay, Popover } from 'react-bootstrap'
+import { ListGroup, Overlay, Popover } from 'react-bootstrap'
 import Tooltip from '../../../../../shared/components/tooltip'
 import MaterialIcon from '../../../../../shared/components/material-icon'
 import { useTabularContext } from '../contexts/tabular-context'
@@ -10,17 +10,24 @@ export const ToolbarButtonMenu: FC<{
   label: string
   icon: string
   disabled?: boolean
-}> = memo(function ButtonMenu({ icon, id, label, children, disabled }) {
+  disabledLabel?: string
+}> = memo(function ButtonMenu({
+  icon,
+  id,
+  label,
+  children,
+  disabled,
+  disabledLabel,
+}) {
   const target = useRef<any>(null)
   const { open, onToggle, ref } = useDropdown()
   const { ref: tableContainerRef } = useTabularContext()
 
   const button = (
-    <Button
+    <button
       type="button"
       className="table-generator-toolbar-button table-generator-toolbar-button-menu"
       aria-label={label}
-      bsStyle={null}
       onMouseDown={event => {
         event.preventDefault()
         event.stopPropagation()
@@ -29,11 +36,12 @@ export const ToolbarButtonMenu: FC<{
         onToggle(!open)
       }}
       disabled={disabled}
+      aria-disabled={disabled}
       ref={target}
     >
       <MaterialIcon type={icon} />
       <MaterialIcon type="expand_more" />
-    </Button>
+    </button>
   )
 
   const overlay = tableContainerRef.current && (
@@ -63,21 +71,14 @@ export const ToolbarButtonMenu: FC<{
     </Overlay>
   )
 
-  if (!label) {
-    return (
-      <>
-        {button}
-        {overlay}
-      </>
-    )
-  }
-
   return (
     <>
       <Tooltip
         hidden={open}
         id={id}
-        description={<div>{label}</div>}
+        description={
+          <div>{disabled && disabledLabel ? disabledLabel : label}</div>
+        }
         overlayProps={{ placement: 'bottom' }}
       >
         {button}

+ 8 - 17
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button.tsx

@@ -1,7 +1,6 @@
 import { EditorView } from '@codemirror/view'
 import classNames from 'classnames'
 import { memo, useCallback } from 'react'
-import { Button } from 'react-bootstrap'
 import Tooltip from '../../../../../shared/components/tooltip'
 import MaterialIcon from '../../../../../shared/components/material-icon'
 import { useCodeMirrorViewContext } from '../../codemirror-editor'
@@ -13,9 +12,9 @@ export const ToolbarButton = memo<{
   command?: (view: EditorView) => void
   active?: boolean
   disabled?: boolean
+  disabledLabel?: string
   icon: string
   hidden?: boolean
-  shortcut?: string
 }>(function ToolbarButton({
   id,
   className,
@@ -25,7 +24,7 @@ export const ToolbarButton = memo<{
   disabled,
   icon,
   hidden = false,
-  shortcut,
+  disabledLabel,
 }) {
   const view = useCodeMirrorViewContext()
   const handleMouseDown = useCallback(event => {
@@ -44,32 +43,24 @@ export const ToolbarButton = memo<{
   )
 
   const button = (
-    <Button
+    <button
       className={classNames('table-generator-toolbar-button', className, {
         hidden,
+        active,
       })}
       aria-label={label}
       onMouseDown={handleMouseDown}
       onClick={!disabled ? handleClick : undefined}
-      bsStyle={null}
-      active={active}
       disabled={disabled}
+      aria-disabled={disabled}
       type="button"
     >
       <MaterialIcon type={icon} />
-    </Button>
+    </button>
   )
 
-  if (!label) {
-    return button
-  }
-
-  const description = (
-    <>
-      <div>{label}</div>
-      {shortcut && <div>{shortcut}</div>}
-    </>
-  )
+  const description =
+    disabled && disabledLabel ? <div>{disabledLabel}</div> : <div>{label}</div>
 
   return (
     <Tooltip

+ 4 - 2
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-dropdown.tsx

@@ -12,6 +12,7 @@ export const ToolbarDropdown: FC<{
   icon?: string
   tooltip?: string
   disabled?: boolean
+  disabledTooltip?: string
 }> = ({
   id,
   label,
@@ -20,6 +21,7 @@ export const ToolbarDropdown: FC<{
   icon = 'expand_more',
   tooltip,
   disabled,
+  disabledTooltip,
 }) => {
   const { open, onToggle } = useDropdown()
   const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
@@ -81,8 +83,8 @@ export const ToolbarDropdown: FC<{
   return (
     <>
       <Tooltip
-        id={`${id}-tooltip`}
-        description={tooltip}
+        id={id}
+        description={disabled && disabledTooltip ? disabledTooltip : tooltip}
         overlayProps={{ placement: 'bottom' }}
       >
         {button}

+ 36 - 6
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx

@@ -4,14 +4,21 @@ import { ToolbarButton } from './toolbar-button'
 import { ToolbarButtonMenu } from './toolbar-button-menu'
 import { ToolbarDropdown } from './toolbar-dropdown'
 import MaterialIcon from '../../../../../shared/components/material-icon'
-import { BorderTheme, setAlignment, setBorders } from './commands'
+import {
+  BorderTheme,
+  insertColumn,
+  insertRow,
+  removeRowOrColumns,
+  setAlignment,
+  setBorders,
+} from './commands'
 import { useCodeMirrorViewContext } from '../../codemirror-editor'
 import { useTableContext } from '../contexts/table-context'
 
 export const Toolbar = memo(function Toolbar() {
   const { selection } = useSelectionContext()
   const view = useCodeMirrorViewContext()
-  const { positions, rowSeparators } = useTableContext()
+  const { positions, rowSeparators, cellSeparators } = useTableContext()
   if (!selection) {
     return null
   }
@@ -87,6 +94,7 @@ export const Toolbar = memo(function Toolbar() {
           label="Alignment"
           icon="format_align_left"
           id="table-generator-align-dropdown"
+          disabledLabel="Select a column or a merged cell to align"
           disabled={
             !selection.isColumnSelected(
               selection.from.cell,
@@ -124,24 +132,37 @@ export const Toolbar = memo(function Toolbar() {
           id="table-generator-merge-cells"
           label="Merge cells"
           disabled
+          disabledLabel="Select cells in a row to merge"
         />
         <ToolbarButton
           icon="delete"
           id="table-generator-remove-column-row"
-          label="Remove row or column"
-          disabled
+          label="Delete row or column"
+          disabledLabel="Select a row or a column to delete"
+          disabled={
+            !(
+              positions.cells.length &&
+              selection.isAnyRowSelected(positions.cells[0].length)
+            ) && !selection.isAnyColumnSelected(positions.rowPositions.length)
+          }
+          command={() =>
+            removeRowOrColumns(view, selection, positions, cellSeparators)
+          }
         />
         <ToolbarDropdown
           id="table-generator-add-dropdown"
           btnClassName="table-generator-toolbar-button"
           icon="add"
           tooltip="Insert"
-          disabled
+          disabled={!selection}
         >
           <button
             className="ol-cm-toolbar-menu-item"
             role="menuitem"
             type="button"
+            onClick={() => {
+              insertColumn(view, selection, positions, false)
+            }}
           >
             <span className="table-generator-button-label">
               Insert column left
@@ -151,6 +172,9 @@ export const Toolbar = memo(function Toolbar() {
             className="ol-cm-toolbar-menu-item"
             role="menuitem"
             type="button"
+            onClick={() => {
+              insertColumn(view, selection, positions, true)
+            }}
           >
             <span className="table-generator-button-label">
               Insert column right
@@ -161,6 +185,9 @@ export const Toolbar = memo(function Toolbar() {
             className="ol-cm-toolbar-menu-item"
             role="menuitem"
             type="button"
+            onClick={() => {
+              insertRow(view, selection, positions, false)
+            }}
           >
             <span className="table-generator-button-label">
               Insert row above
@@ -170,6 +197,9 @@ export const Toolbar = memo(function Toolbar() {
             className="ol-cm-toolbar-menu-item"
             role="menuitem"
             type="button"
+            onClick={() => {
+              insertRow(view, selection, positions, true)
+            }}
           >
             <span className="table-generator-button-label">
               Insert row below
@@ -181,7 +211,7 @@ export const Toolbar = memo(function Toolbar() {
         <ToolbarButton
           icon="delete_forever"
           id="table-generator-remove-table"
-          label="Remove table"
+          label="Delete table"
           disabled
         />
       </div>

+ 4 - 1
services/web/frontend/js/features/source-editor/components/table-generator/utils.ts

@@ -99,7 +99,7 @@ type ParsedCell = {
   position: Position
 }
 
-type CellSeparator = Position
+export type CellSeparator = Position
 export type RowSeparator = Position
 
 type ParsedRow = {
@@ -233,6 +233,7 @@ export function generateTable(
   specification: { from: number; to: number }
   rowPositions: RowPosition[]
   rowSeparators: RowSeparator[]
+  cellSeparators: CellSeparator[][]
 } {
   const specification = node
     .getChild('BeginEnv')
@@ -253,6 +254,7 @@ export function generateTable(
   const cellPositions = tableData.rows.map(row =>
     row.cells.map(cell => cell.position)
   )
+  const cellSeparators = tableData.rows.map(row => row.cellSeparators)
   const rowPositions = tableData.rows.map(row => ({
     ...row.position,
     hlines: row.hlines.map(hline => hline.position),
@@ -274,5 +276,6 @@ export function generateTable(
     specification,
     rowPositions,
     rowSeparators: tableData.rowSeparators,
+    cellSeparators,
   }
 }