Просмотр исходного кода

Merge pull request #14192 from overleaf/mj-table-keyboard-selection

[visual] move selection on keyboard buttons

GitOrigin-RevId: 617be9188880a98c1803033c406501ac02083bbc
Mathias Jakobsen 3 лет назад
Родитель
Сommit
2e944a6230

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

@@ -19,6 +19,7 @@ export const Cell: FC<{
 }> = ({ cellData, columnSpecification, rowIndex, columnIndex, row }) => {
   const { selection, setSelection } = useSelectionContext()
   const renderDiv = useRef<HTMLDivElement>(null)
+  const cellRef = useRef<HTMLTableCellElement>(null)
   const {
     cellData: editingCellData,
     updateCellData: update,
@@ -64,7 +65,15 @@ export const Cell: FC<{
     return input.replaceAll(/(?<!\\)&/g, '\\&').replaceAll('\\\\', '')
   }
 
-  const hasFocus = selection?.contains({ row: rowIndex, cell: columnIndex })
+  const isFocused =
+    selection?.to.cell === columnIndex && selection?.to.row === rowIndex
+
+  useEffect(() => {
+    if (isFocused && !editing && cellRef.current) {
+      cellRef.current.focus()
+    }
+  }, [isFocused, editing])
+
   useEffect(() => {
     const toDisplay = cellData.content.trim()
     if (renderDiv.current && !editing) {
@@ -97,6 +106,8 @@ export const Cell: FC<{
     )
   }
 
+  const inSelection = selection?.contains({ row: rowIndex, cell: columnIndex })
+
   const onDoubleClick = useCallback(() => {
     startEditing(rowIndex, columnIndex, cellData.content.trim())
   }, [columnIndex, rowIndex, cellData, startEditing])
@@ -107,6 +118,7 @@ export const Cell: FC<{
       onDoubleClick={onDoubleClick}
       tabIndex={row.cells.length * rowIndex + columnIndex + 1}
       onMouseDown={onMouseDown}
+      ref={cellRef}
       className={classNames('table-generator-cell', {
         'table-generator-cell-border-left': columnSpecification.borderLeft > 0,
         'table-generator-cell-border-right':
@@ -117,12 +129,14 @@ export const Cell: FC<{
         'alignment-center': columnSpecification.alignment === 'center',
         'alignment-right': columnSpecification.alignment === 'right',
         'alignment-paragraph': columnSpecification.alignment === 'paragraph',
-        focused: hasFocus,
-        'selection-edge-top': hasFocus && selection?.bordersTop(rowIndex),
-        'selection-edge-bottom': hasFocus && selection?.bordersBottom(rowIndex),
-        'selection-edge-left': hasFocus && selection?.bordersLeft(columnIndex),
+        selected: inSelection,
+        'selection-edge-top': inSelection && selection?.bordersTop(rowIndex),
+        'selection-edge-bottom':
+          inSelection && selection?.bordersBottom(rowIndex),
+        'selection-edge-left':
+          inSelection && selection?.bordersLeft(columnIndex),
         'selection-edge-right':
-          hasFocus && selection?.bordersRight(columnIndex),
+          inSelection && selection?.bordersRight(columnIndex),
       })}
     >
       {body}

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

@@ -54,6 +54,10 @@ export const EditingContextProvider: FC = ({ children }) => {
     if (!cellData) {
       return
     }
+    if (!cellData.dirty) {
+      setCellData(null)
+      return
+    }
     const { rowIndex, cellIndex, content } = cellData
     write(rowIndex, cellIndex, content)
     setCellData(null)

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

@@ -13,8 +13,14 @@ type TableCoordinate = {
 }
 
 export class TableSelection {
-  // eslint-disable-next-line no-useless-constructor
-  constructor(public from: TableCoordinate, public to: TableCoordinate) {}
+  public readonly from: TableCoordinate
+  public readonly to: TableCoordinate
+
+  constructor(from: TableCoordinate, to?: TableCoordinate) {
+    this.from = from
+    this.to = to ?? from
+  }
+
   contains(point: TableCoordinate) {
     const { minX, maxX, minY, maxY } = this.normalized()
 
@@ -64,6 +70,82 @@ export class TableSelection {
     const { minX, maxX, minY, maxY } = this.normalized()
     return cell >= minX && cell <= maxX && minY === 0 && maxY === totalRows - 1
   }
+
+  moveRight(totalColumns: number) {
+    const newColumn = Math.min(totalColumns - 1, this.to.cell + 1)
+    return new TableSelection({ row: this.to.row, cell: newColumn })
+  }
+
+  moveLeft() {
+    const newColumn = Math.max(0, this.to.cell - 1)
+    return new TableSelection({ row: this.to.row, cell: newColumn })
+  }
+
+  moveUp() {
+    const newRow = Math.max(0, this.to.row - 1)
+    return new TableSelection({ row: newRow, cell: this.to.cell })
+  }
+
+  moveDown(totalRows: number) {
+    const newRow = Math.min(totalRows - 1, this.to.row + 1)
+    return new TableSelection({ row: newRow, cell: this.to.cell })
+  }
+
+  moveNext(totalColumns: number, totalRows: number) {
+    const { row, cell } = this.to
+    if (cell === totalColumns - 1 && row === totalRows - 1) {
+      return new TableSelection(this.to)
+    }
+    if (cell === totalColumns - 1) {
+      return new TableSelection({ row: row + 1, cell: 0 })
+    }
+    return new TableSelection({ row, cell: cell + 1 })
+  }
+
+  movePrevious(totalColumns: number) {
+    if (this.to.cell === 0 && this.to.row === 0) {
+      return new TableSelection(this.to)
+    }
+    if (this.to.cell === 0) {
+      return new TableSelection({
+        row: this.to.row - 1,
+        cell: totalColumns - 1,
+      })
+    }
+    return new TableSelection({ row: this.to.row, cell: this.to.cell - 1 })
+  }
+
+  extendRight(totalColumns: number) {
+    const newColumn = Math.min(totalColumns - 1, this.to.cell + 1)
+    return new TableSelection(
+      { row: this.from.row, cell: this.from.cell },
+      { row: this.to.row, cell: newColumn }
+    )
+  }
+
+  extendLeft() {
+    const newColumn = Math.max(0, this.to.cell - 1)
+    return new TableSelection(
+      { row: this.from.row, cell: this.from.cell },
+      { row: this.to.row, cell: newColumn }
+    )
+  }
+
+  extendUp() {
+    const newRow = Math.max(0, this.to.row - 1)
+    return new TableSelection(
+      { row: this.from.row, cell: this.from.cell },
+      { row: newRow, cell: this.to.cell }
+    )
+  }
+
+  extendDown(totalRows: number) {
+    const newRow = Math.min(totalRows - 1, this.to.row + 1)
+    return new TableSelection(
+      { row: this.from.row, cell: this.from.cell },
+      { row: newRow, cell: this.to.cell }
+    )
+  }
 }
 
 const SelectionContext = createContext<

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

@@ -1,14 +1,55 @@
-import { FC, KeyboardEventHandler, useCallback } from 'react'
+import { FC, KeyboardEventHandler, useCallback, useMemo, useRef } from 'react'
 import { Row } from './row'
 import { ColumnSelector } from './selectors'
-import { useSelectionContext } from './contexts/selection-context'
+import {
+  TableSelection,
+  useSelectionContext,
+} from './contexts/selection-context'
 import { useEditingContext } from './contexts/editing-context'
 import { useTableContext } from './contexts/table-context'
+import { useCodeMirrorViewContext } from '../codemirror-editor'
+
+type NavigationKey =
+  | 'ArrowRight'
+  | 'ArrowLeft'
+  | 'ArrowUp'
+  | 'ArrowDown'
+  | 'Tab'
+
+type NavigationMap = {
+  // eslint-disable-next-line no-unused-vars
+  [key in NavigationKey]: [() => TableSelection, () => TableSelection]
+}
 
 export const Table: FC = () => {
   const { selection, setSelection } = useSelectionContext()
-  const { cellData, cancelEditing, startEditing } = useEditingContext()
+  const { cellData, cancelEditing, startEditing, commitCellData } =
+    useEditingContext()
   const { table: tableData } = useTableContext()
+  const tableRef = useRef<HTMLTableElement>(null)
+  const view = useCodeMirrorViewContext()
+
+  const navigation: NavigationMap = useMemo(
+    () => ({
+      ArrowRight: [
+        () => selection!.moveRight(tableData.columns.length),
+        () => selection!.extendRight(tableData.columns.length),
+      ],
+      ArrowLeft: [() => selection!.moveLeft(), () => selection!.extendLeft()],
+      ArrowUp: [() => selection!.moveUp(), () => selection!.extendUp()],
+      ArrowDown: [
+        () => selection!.moveDown(tableData.rows.length),
+        () => selection!.extendDown(tableData.rows.length),
+      ],
+      Tab: [
+        () =>
+          selection!.moveNext(tableData.columns.length, tableData.rows.length),
+        () => selection!.movePrevious(tableData.columns.length),
+      ],
+    }),
+    [selection, tableData.columns.length, tableData.rows.length]
+  )
+
   const onKeyDown: KeyboardEventHandler = useCallback(
     event => {
       if (event.code === 'Enter') {
@@ -17,17 +58,40 @@ export const Table: FC = () => {
         if (!selection) {
           return
         }
-        const cell =
-          tableData.rows[selection.from.row].cells[selection.from.cell]
-        startEditing(selection.from.row, selection.from.cell, cell.content)
+        if (cellData) {
+          commitCellData()
+          return
+        }
+        const cell = tableData.rows[selection.to.row].cells[selection.to.cell]
+        startEditing(selection.to.row, selection.to.cell, cell.content)
+        setSelection(new TableSelection(selection.to, selection.to))
       } else if (event.code === 'Escape') {
         event.preventDefault()
         event.stopPropagation()
         if (!cellData) {
           setSelection(null)
+          view.focus()
         } else {
           cancelEditing()
         }
+      } else if (Object.prototype.hasOwnProperty.call(navigation, event.code)) {
+        const [defaultNavigation, shiftNavigation] =
+          navigation[event.code as NavigationKey]
+        if (cellData) {
+          return
+        }
+        event.preventDefault()
+        if (!selection) {
+          setSelection(
+            new TableSelection({ row: 0, cell: 0 }, { row: 0, cell: 0 })
+          )
+          return
+        }
+        if (event.shiftKey) {
+          setSelection(shiftNavigation())
+        } else {
+          setSelection(defaultNavigation())
+        }
       }
     },
     [
@@ -37,6 +101,9 @@ export const Table: FC = () => {
       setSelection,
       cancelEditing,
       startEditing,
+      commitCellData,
+      navigation,
+      view,
     ]
   )
   return (
@@ -45,6 +112,7 @@ export const Table: FC = () => {
       className="table-generator-table"
       onKeyDown={onKeyDown}
       tabIndex={-1}
+      ref={tableRef}
     >
       <thead>
         <tr>

+ 5 - 1
services/web/frontend/stylesheets/app/editor/table-generator.less

@@ -41,10 +41,14 @@
   border-bottom-width: @table-generator-active-border-width;
 }
 
-.table-generator-cell.focused {
+.table-generator-cell.selected {
   background-color: @blue-10;
 }
 
+.table-generator-cell:focus-visible {
+  outline: 2px dotted @table-generator-focus-border-color;
+}
+
 .table-generator-cell {
   &.selection-edge-top {
     --shadow-top: 0 @table-generator-focus-negative-border-width 0