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

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

[visual] Table generator tweaks

GitOrigin-RevId: 80ec32d024d185861a3635d5cc6d77d6a7031b64
Mathias Jakobsen 3 лет назад
Родитель
Сommit
0b91a2052a

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

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

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

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

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

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

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

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

+ 48 - 3
services/web/frontend/js/features/source-editor/components/table-generator/table.tsx

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

+ 48 - 13
services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx

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

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

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

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

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

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

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

+ 2 - 0
services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts

@@ -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',
     },
   }),
 ]