Merge pull request #14233 from overleaf/mj-table-generator-skip-hlines

[visual] Table generator tweaks

GitOrigin-RevId: 80ec32d024d185861a3635d5cc6d77d6a7031b64
This commit is contained in:
Mathias Jakobsen 2023-08-14 09:16:14 +01:00 committed by Copybot
parent 63b09c3da3
commit 0b91a2052a
10 changed files with 204 additions and 34 deletions

View file

@ -17,7 +17,8 @@ export const Cell: FC<{
columnIndex: number
row: RowData
}> = ({ cellData, columnSpecification, rowIndex, columnIndex, row }) => {
const { selection, setSelection } = useSelectionContext()
const { selection, setSelection, dragging, setDragging } =
useSelectionContext()
const renderDiv = useRef<HTMLDivElement>(null)
const cellRef = useRef<HTMLTableCellElement>(null)
const {
@ -36,22 +37,56 @@ export const Cell: FC<{
if (event.button !== 0) {
return
}
event.stopPropagation()
setDragging(true)
document.getSelection()?.empty()
setSelection(current => {
if (event.shiftKey && current) {
return new TableSelection(current.from, {
row: rowIndex,
cell: columnIndex,
row: rowIndex,
})
} else {
return new TableSelection(
{ row: rowIndex, cell: columnIndex },
{ row: rowIndex, cell: columnIndex }
)
}
return new TableSelection({ cell: columnIndex, row: rowIndex })
})
},
[setSelection, rowIndex, columnIndex]
[setDragging, columnIndex, rowIndex, setSelection]
)
const onMouseUp = useCallback(() => {
if (dragging) {
setDragging(false)
}
}, [setDragging, dragging])
const onMouseMove: MouseEventHandler = useCallback(
event => {
if (dragging) {
if (event.buttons !== 1) {
setDragging(false)
return
}
document.getSelection()?.empty()
if (
selection?.to.cell === columnIndex &&
selection?.to.row === rowIndex
) {
// Do nothing if selection has remained the same
return
}
event.stopPropagation()
setSelection(current => {
if (current) {
return new TableSelection(current.from, {
row: rowIndex,
cell: columnIndex,
})
} else {
return new TableSelection({ row: rowIndex, cell: columnIndex })
}
})
}
},
[dragging, columnIndex, rowIndex, setSelection, selection, setDragging]
)
useEffect(() => {
@ -122,6 +157,8 @@ export const Cell: FC<{
onDoubleClick={onDoubleClick}
tabIndex={row.cells.length * rowIndex + columnIndex + 1}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
ref={cellRef}
className={classNames('table-generator-cell', {
'table-generator-cell-border-left': columnSpecification.borderLeft > 0,

View file

@ -1,6 +1,7 @@
import { FC, createContext, useCallback, useContext, useState } from 'react'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
import { useTableContext } from './table-context'
import { TableSelection } from './selection-context'
type EditingContextData = {
rowIndex: number
@ -15,6 +16,7 @@ const EditingContext = createContext<
updateCellData: (content: string) => void
cancelEditing: () => void
commitCellData: () => void
clearCells: (selection: TableSelection) => void
startEditing: (
rowIndex: number,
cellIndex: number,
@ -84,6 +86,22 @@ export const EditingContextProvider: FC = ({ children }) => {
},
[setCellData]
)
const clearCells = useCallback(
(selection: TableSelection) => {
const changes: { from: number; to: number; insert: '' }[] = []
const { minX, minY, maxX, maxY } = selection.normalized()
for (let row = minY; row <= maxY; row++) {
for (let cell = minX; cell <= maxX; cell++) {
const { from, to } = cellPositions[row][cell]
changes.push({ from, to, insert: '' })
}
}
view.dispatch({ changes })
},
[view, cellPositions]
)
return (
<EditingContext.Provider
value={{
@ -92,6 +110,7 @@ export const EditingContextProvider: FC = ({ children }) => {
cancelEditing,
commitCellData,
startEditing,
clearCells,
}}
>
{children}

View file

@ -152,6 +152,8 @@ const SelectionContext = createContext<
| {
selection: TableSelection | null
setSelection: Dispatch<SetStateAction<TableSelection | null>>
dragging: boolean
setDragging: Dispatch<SetStateAction<boolean>>
}
| undefined
>(undefined)
@ -170,8 +172,11 @@ export const useSelectionContext = () => {
export const SelectionContextProvider: FC = ({ children }) => {
const [selection, setSelection] = useState<TableSelection | null>(null)
const [dragging, setDragging] = useState(false)
return (
<SelectionContext.Provider value={{ selection, setSelection }}>
<SelectionContext.Provider
value={{ selection, setSelection, dragging, setDragging }}
>
{children}
</SelectionContext.Provider>
)

View file

@ -0,0 +1,22 @@
import { FC, RefObject, createContext, useContext, useRef } from 'react'
const TabularContext = createContext<
{ ref: RefObject<HTMLDivElement> } | undefined
>(undefined)
export const TabularProvider: FC = ({ children }) => {
const ref = useRef<HTMLDivElement>(null)
return (
<TabularContext.Provider value={{ ref }}>
{children}
</TabularContext.Provider>
)
}
export const useTabularContext = () => {
const tabularContext = useContext(TabularContext)
if (!tabularContext) {
throw new Error('TabularContext must be used within TabularProvider')
}
return tabularContext
}

View file

@ -1,4 +1,11 @@
import { FC, KeyboardEventHandler, useCallback, useMemo, useRef } from 'react'
import {
FC,
KeyboardEvent,
KeyboardEventHandler,
useCallback,
useMemo,
useRef,
} from 'react'
import { Row } from './row'
import { ColumnSelector } from './selectors'
import {
@ -23,8 +30,14 @@ type NavigationMap = {
export const Table: FC = () => {
const { selection, setSelection } = useSelectionContext()
const { cellData, cancelEditing, startEditing, commitCellData } =
useEditingContext()
const {
cellData,
cancelEditing,
startEditing,
commitCellData,
clearCells,
updateCellData,
} = useEditingContext()
const { table: tableData } = useTableContext()
const tableRef = useRef<HTMLTableElement>(null)
const view = useCodeMirrorViewContext()
@ -50,6 +63,15 @@ export const Table: FC = () => {
[selection, tableData.columns.length, tableData.rows.length]
)
const isCharacterInput = useCallback((event: KeyboardEvent) => {
return (
event.key?.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
)
}, [])
const onKeyDown: KeyboardEventHandler = useCallback(
event => {
if (event.code === 'Enter') {
@ -74,6 +96,16 @@ export const Table: FC = () => {
} else {
cancelEditing()
}
} else if (event.code === 'Delete' || event.code === 'Backspace') {
if (cellData) {
return
}
if (!selection) {
return
}
event.preventDefault()
event.stopPropagation()
clearCells(selection)
} else if (Object.prototype.hasOwnProperty.call(navigation, event.code)) {
const [defaultNavigation, shiftNavigation] =
navigation[event.code as NavigationKey]
@ -92,6 +124,16 @@ export const Table: FC = () => {
} else {
setSelection(defaultNavigation())
}
} else if (isCharacterInput(event) && !cellData) {
event.preventDefault()
event.stopPropagation()
if (!selection) {
return
}
const cell = tableData.rows[selection.to.row].cells[selection.to.cell]
startEditing(selection.to.row, selection.to.cell, cell.content)
updateCellData(event.key)
setSelection(new TableSelection(selection.to, selection.to))
}
},
[
@ -104,6 +146,9 @@ export const Table: FC = () => {
commitCellData,
navigation,
view,
clearCells,
updateCellData,
isCharacterInput,
]
)
return (

View file

@ -1,16 +1,23 @@
import { SyntaxNode } from '@lezer/common'
import { FC } from 'react'
import { FC, useEffect } from 'react'
import { CellPosition, RowPosition } from './utils'
import { Toolbar } from './toolbar/toolbar'
import { Table } from './table'
import { SelectionContextProvider } from './contexts/selection-context'
import { EditingContextProvider } from './contexts/editing-context'
import {
SelectionContextProvider,
useSelectionContext,
} from './contexts/selection-context'
import {
EditingContextProvider,
useEditingContext,
} from './contexts/editing-context'
import { EditorView } from '@codemirror/view'
import { ErrorBoundary } from 'react-error-boundary'
import { Alert, Button } from 'react-bootstrap'
import { EditorSelection } from '@codemirror/state'
import { CodeMirrorViewContextProvider } from '../codemirror-editor'
import { TableProvider } from './contexts/table-context'
import { TabularProvider, useTabularContext } from './contexts/tabular-context'
export type CellData = {
// TODO: Add columnSpan
@ -72,17 +79,45 @@ export const Tabular: FC<{
onError={(error, componentStack) => console.error(error, componentStack)}
>
<CodeMirrorViewContextProvider value={view}>
<TableProvider tabularNode={tabularNode} view={view}>
<SelectionContextProvider>
<EditingContextProvider>
<div className="table-generator">
<Toolbar />
<Table />
</div>
</EditingContextProvider>
</SelectionContextProvider>
</TableProvider>
<TabularProvider>
<TableProvider tabularNode={tabularNode} view={view}>
<SelectionContextProvider>
<EditingContextProvider>
<TabularWrapper />
</EditingContextProvider>
</SelectionContextProvider>
</TableProvider>
</TabularProvider>
</CodeMirrorViewContextProvider>
</ErrorBoundary>
)
}
const TabularWrapper: FC = () => {
const { setSelection, selection } = useSelectionContext()
const { commitCellData, cellData } = useEditingContext()
const { ref } = useTabularContext()
useEffect(() => {
const listener: (event: MouseEvent) => void = event => {
if (!ref.current?.contains(event.target as Node)) {
if (selection) {
setSelection(null)
}
if (cellData) {
commitCellData()
}
}
}
window.addEventListener('mousedown', listener)
return () => {
window.removeEventListener('mousedown', listener)
}
}, [cellData, commitCellData, selection, setSelection, ref])
return (
<div className="table-generator" ref={ref}>
<Toolbar />
<Table />
</div>
)
}

View file

@ -3,7 +3,7 @@ import useDropdown from '../../../../../shared/hooks/use-dropdown'
import { Button, ListGroup, Overlay, Popover } from 'react-bootstrap'
import Tooltip from '../../../../../shared/components/tooltip'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
import { useTabularContext } from '../contexts/tabular-context'
export const ToolbarButtonMenu: FC<{
id: string
@ -13,7 +13,7 @@ export const ToolbarButtonMenu: FC<{
}> = memo(function ButtonMenu({ icon, id, label, children, disabled }) {
const target = useRef<any>(null)
const { open, onToggle, ref } = useDropdown()
const view = useCodeMirrorViewContext()
const { ref: tableContainerRef } = useTabularContext()
const button = (
<Button
@ -36,12 +36,12 @@ export const ToolbarButtonMenu: FC<{
</Button>
)
const overlay = (
const overlay = tableContainerRef.current && (
<Overlay
show={open}
target={target.current}
placement="bottom"
container={view.dom}
container={tableContainerRef.current}
containerPadding={0}
animation
onHide={() => onToggle(false)}

View file

@ -3,7 +3,7 @@ import useDropdown from '../../../../../shared/hooks/use-dropdown'
import { Overlay, Popover } from 'react-bootstrap'
import MaterialIcon from '../../../../../shared/components/material-icon'
import Tooltip from '../../../../../shared/components/tooltip'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
import { useTabularContext } from '../contexts/tabular-context'
export const ToolbarDropdown: FC<{
id: string
@ -23,7 +23,7 @@ export const ToolbarDropdown: FC<{
}) => {
const { open, onToggle } = useDropdown()
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
const view = useCodeMirrorViewContext()
const { ref: tabularRef } = useTabularContext()
const button = (
<button
@ -42,12 +42,12 @@ export const ToolbarDropdown: FC<{
<MaterialIcon type={icon} />
</button>
)
const overlay = open && (
const overlay = open && tabularRef.current && (
<Overlay
show
onHide={() => onToggle(false)}
animation={false}
container={view.dom}
container={tabularRef.current}
containerPadding={0}
placement="bottom"
rootClose

View file

@ -178,6 +178,11 @@ function parseTabularBody(
if (lastCell?.content) {
throw new Error('\\hline must be at the start of a row')
}
// push start of cell past the hline
if (lastCell) {
lastCell.position.from = currentChild.to
lastCell.position.to = currentChild.to
}
const lastRow = getLastRow()
lastRow.hlines.push({
position: { from: currentChild.from, to: currentChild.to },

View file

@ -260,6 +260,7 @@ export const toolbarPanel = () => [
tableLayout: 'fixed',
fontSize: '6px',
cursor: 'pointer',
width: '160px',
'& td': {
outline: '1px solid #E7E9EE',
outlineOffset: '-2px',
@ -279,6 +280,7 @@ export const toolbarPanel = () => [
},
'.ol-cm-toolbar-table-grid-popover': {
padding: '8px',
marginLeft: '80px',
},
}),
]