diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx index 6fcef882f8..35c20337db 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx +++ b/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(null) + const cellRef = useRef(null) const { cellData: editingCellData, updateCellData: update, @@ -64,7 +65,15 @@ export const Cell: FC<{ return input.replaceAll(/(? { + 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} diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx index c25f82635c..3e982d57e6 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/editing-context.tsx +++ b/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) diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx index 443805d14d..6f7b36d5d8 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/contexts/selection-context.tsx +++ b/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< diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx index 6b4bc003c1..527575eaf7 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx +++ b/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(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} > diff --git a/services/web/frontend/stylesheets/app/editor/table-generator.less b/services/web/frontend/stylesheets/app/editor/table-generator.less index 5d61d0c58b..8f330a2a9e 100644 --- a/services/web/frontend/stylesheets/app/editor/table-generator.less +++ b/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