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

[visual] move selection on keyboard buttons

GitOrigin-RevId: 617be9188880a98c1803033c406501ac02083bbc
This commit is contained in:
Mathias Jakobsen 2023-08-08 13:14:42 +01:00 committed by Copybot
parent c332a65eb0
commit 2e944a6230
5 changed files with 187 additions and 15 deletions

View file

@ -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}

View file

@ -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)

View file

@ -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<

View file

@ -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>

View file

@ -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