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

Merge pull request #14305 from overleaf/mj-table-buttons

[visual] Add toolbar actions to delete table and manage captions

GitOrigin-RevId: 7a6aefd77fc4a66a1b78ae0727d4ece962fdd040
Mathias Jakobsen 3 лет назад
Родитель
Сommit
583222d5a5

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

@@ -125,7 +125,9 @@ export const Cell: FC<{
         toDisplay.substring.bind(toDisplay)
       )
       loadMathJax().then(async MathJax => {
-        await MathJax.typesetPromise([renderDiv.current])
+        if (renderDiv.current) {
+          await MathJax.typesetPromise([renderDiv.current])
+        }
       })
     }
   }, [cellData.content, editing])

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

@@ -6,10 +6,17 @@ import {
   RowPosition,
   RowSeparator,
   generateTable,
+  parseTableEnvironment,
 } from '../utils'
 import { EditorView } from '@codemirror/view'
 import { SyntaxNode } from '@lezer/common'
 
+export type TableEnvironmentData = {
+  table: { from: number; to: number }
+  caption?: { from: number; to: number }
+  label?: { from: number; to: number }
+}
+
 const TableContext = createContext<
   | {
       table: TableData
@@ -19,6 +26,7 @@ const TableContext = createContext<
       rowSeparators: RowSeparator[]
       cellSeparators: CellSeparator[][]
       positions: Positions
+      tableEnvironment?: TableEnvironmentData
     }
   | undefined
 >(undefined)
@@ -26,7 +34,8 @@ const TableContext = createContext<
 export const TableProvider: FC<{
   tabularNode: SyntaxNode
   view: EditorView
-}> = ({ tabularNode, view, children }) => {
+  tableNode: SyntaxNode | null
+}> = ({ tabularNode, view, children, tableNode }) => {
   const tableData = generateTable(tabularNode, view.state)
 
   // TODO: Validate better that the table matches the column definition
@@ -40,9 +49,21 @@ export const TableProvider: FC<{
     cells: tableData.cellPositions,
     columnDeclarations: tableData.specification,
     rowPositions: tableData.rowPositions,
+    tabular: { from: tabularNode.from, to: tabularNode.to },
   }
+
+  const tableEnvironment = tableNode
+    ? parseTableEnvironment(tableNode)
+    : undefined
+
   return (
-    <TableContext.Provider value={{ ...tableData, positions }}>
+    <TableContext.Provider
+      value={{
+        ...tableData,
+        positions,
+        tableEnvironment,
+      }}
+    >
       {children}
     </TableContext.Provider>
   )

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

@@ -47,6 +47,7 @@ export type Positions = {
   cells: CellPosition[][]
   columnDeclarations: { from: number; to: number }
   rowPositions: RowPosition[]
+  tabular: { from: number; to: number }
 }
 
 export const TableRenderingError: FC<{
@@ -79,7 +80,8 @@ export const TableRenderingError: FC<{
 export const Tabular: FC<{
   tabularNode: SyntaxNode
   view: EditorView
-}> = ({ tabularNode, view }) => {
+  tableNode: SyntaxNode | null
+}> = ({ tabularNode, view, tableNode }) => {
   return (
     <ErrorBoundary
       fallbackRender={() => (
@@ -88,7 +90,11 @@ export const Tabular: FC<{
     >
       <CodeMirrorViewContextProvider value={view}>
         <TabularProvider>
-          <TableProvider tabularNode={tabularNode} view={view}>
+          <TableProvider
+            tabularNode={tabularNode}
+            view={view}
+            tableNode={tableNode}
+          >
             <SelectionContextProvider>
               <EditingContextProvider>
                 <TabularWrapper />

+ 139 - 1
services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts

@@ -1,12 +1,18 @@
 import { EditorView } from '@codemirror/view'
 import { ColumnDefinition, Positions } from '../tabular'
-import { ChangeSpec } from '@codemirror/state'
+import { ChangeSpec, EditorSelection } from '@codemirror/state'
 import {
   CellSeparator,
   RowSeparator,
   parseColumnSpecifications,
 } from '../utils'
 import { TableSelection } from '../contexts/selection-context'
+import { ensureEmptyLine } from '../../../extensions/toolbar/commands'
+import { TableEnvironmentData } from '../contexts/table-context'
+import {
+  extendBackwardsOverEmptyLines,
+  extendForwardsOverEmptyLines,
+} from '../../../extensions/visual/selection'
 
 /* eslint-disable no-unused-vars */
 export enum BorderTheme {
@@ -295,3 +301,135 @@ export const insertColumn = (
   })
   view.dispatch({ changes })
 }
+
+export const removeNodes = (
+  view: EditorView,
+  ...nodes: ({ from: number; to: number } | undefined)[]
+) => {
+  const changes: ChangeSpec[] = []
+  for (const node of nodes) {
+    if (node !== undefined) {
+      changes.push({ from: node.from, to: node.to, insert: '' })
+    }
+  }
+  view.dispatch({
+    changes,
+  })
+}
+
+const contains = (
+  { from: outerFrom, to: outerTo }: { from: number; to: number },
+  { from: innerFrom, to: innerTo }: { from: number; to: number }
+) => {
+  return outerFrom <= innerFrom && outerTo >= innerTo
+}
+
+export const moveCaption = (
+  view: EditorView,
+  positions: Positions,
+  target: 'above' | 'below',
+  tableEnvironment?: TableEnvironmentData
+) => {
+  const changes: ChangeSpec[] = []
+  const position =
+    target === 'above' ? positions.tabular.from : positions.tabular.to
+  const cursor = EditorSelection.cursor(position)
+
+  if (tableEnvironment?.caption) {
+    const { caption: existingCaption } = tableEnvironment
+    if (
+      (existingCaption.from < positions.tabular.from && target === 'above') ||
+      (existingCaption.from > positions.tabular.to && target === 'below')
+    ) {
+      // It's already in the right place
+      return
+    }
+  }
+
+  const { pos, prefix, suffix } = ensureEmptyLine(view.state, cursor, target)
+
+  if (!tableEnvironment?.caption) {
+    let labelText = '\\label{tab:my_table}'
+    if (tableEnvironment?.label) {
+      // We have a label, but no caption. Move the label after our caption
+      changes.push({
+        from: tableEnvironment.label.from,
+        to: tableEnvironment.label.to,
+        insert: '',
+      })
+      labelText = view.state.sliceDoc(
+        tableEnvironment.label.from,
+        tableEnvironment.label.to
+      )
+    }
+    changes.push({
+      ...gobbleEmptyLines(view, pos, 2, target),
+      insert: `${prefix}\\caption{Caption}\n${labelText}${suffix}`,
+    })
+  } else {
+    const { caption: existingCaption, label: existingLabel } = tableEnvironment
+    // We have a caption, and we need to move it
+    let currentCaption = view.state.sliceDoc(
+      existingCaption.from,
+      existingCaption.to
+    )
+    if (existingLabel && !contains(existingCaption, existingLabel)) {
+      // Move label with it
+      const labelText = view.state.sliceDoc(
+        existingLabel.from,
+        existingLabel.to
+      )
+      currentCaption += `\n${labelText}`
+      changes.push({
+        from: existingLabel.from,
+        to: existingLabel.to,
+        insert: '',
+      })
+    }
+    changes.push({
+      ...gobbleEmptyLines(view, pos, 2, target),
+      insert: `${prefix}${currentCaption}${suffix}`,
+    })
+    // remove exsisting caption
+    changes.push({
+      from: existingCaption.from,
+      to: existingCaption.to,
+      insert: '',
+    })
+  }
+  view.dispatch({ changes })
+}
+
+export const removeCaption = (
+  view: EditorView,
+  tableEnvironment?: TableEnvironmentData
+) => {
+  if (tableEnvironment?.caption && tableEnvironment.label) {
+    if (contains(tableEnvironment.caption, tableEnvironment.label)) {
+      return removeNodes(view, tableEnvironment.caption)
+    }
+  }
+  return removeNodes(view, tableEnvironment?.caption, tableEnvironment?.label)
+}
+
+const gobbleEmptyLines = (
+  view: EditorView,
+  pos: number,
+  lines: number,
+  target: 'above' | 'below'
+) => {
+  const line = view.state.doc.lineAt(pos)
+  if (line.length !== 0) {
+    return { from: pos, to: pos }
+  }
+  if (target === 'above') {
+    return {
+      from: extendBackwardsOverEmptyLines(view.state.doc, line, lines),
+      to: pos,
+    }
+  }
+  return {
+    from: pos,
+    to: extendForwardsOverEmptyLines(view.state.doc, line, lines),
+  }
+}

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

@@ -8,6 +8,9 @@ import {
   BorderTheme,
   insertColumn,
   insertRow,
+  moveCaption,
+  removeCaption,
+  removeNodes,
   removeRowOrColumns,
   setAlignment,
   setBorders,
@@ -18,7 +21,8 @@ import { useTableContext } from '../contexts/table-context'
 export const Toolbar = memo(function Toolbar() {
   const { selection } = useSelectionContext()
   const view = useCodeMirrorViewContext()
-  const { positions, rowSeparators, cellSeparators } = useTableContext()
+  const { positions, rowSeparators, cellSeparators, tableEnvironment } =
+    useTableContext()
   if (!selection) {
     return null
   }
@@ -27,12 +31,15 @@ export const Toolbar = memo(function Toolbar() {
       <ToolbarDropdown
         id="table-generator-caption-dropdown"
         label="Caption below"
-        disabled
+        disabled={!tableEnvironment}
       >
         <button
           className="ol-cm-toolbar-menu-item"
           role="menuitem"
           type="button"
+          onClick={() => {
+            removeCaption(view, tableEnvironment)
+          }}
         >
           No caption
         </button>
@@ -40,6 +47,9 @@ export const Toolbar = memo(function Toolbar() {
           className="ol-cm-toolbar-menu-item"
           role="menuitem"
           type="button"
+          onClick={() => {
+            moveCaption(view, positions, 'above', tableEnvironment)
+          }}
         >
           Caption above
         </button>
@@ -47,6 +57,9 @@ export const Toolbar = memo(function Toolbar() {
           className="ol-cm-toolbar-menu-item"
           role="menuitem"
           type="button"
+          onClick={() => {
+            moveCaption(view, positions, 'below', tableEnvironment)
+          }}
         >
           Caption below
         </button>
@@ -212,7 +225,9 @@ export const Toolbar = memo(function Toolbar() {
           icon="delete_forever"
           id="table-generator-remove-table"
           label="Delete table"
-          disabled
+          command={() => {
+            removeNodes(view, tableEnvironment?.table ?? positions.tabular)
+          }}
         />
       </div>
     </div>

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

@@ -1,6 +1,7 @@
 import { EditorState } from '@codemirror/state'
 import { SyntaxNode } from '@lezer/common'
 import { ColumnDefinition, TableData } from './tabular'
+import { TableEnvironmentData } from './contexts/table-context'
 
 const ALIGNMENT_CHARACTERS = ['c', 'l', 'r', 'p']
 
@@ -279,3 +280,21 @@ export function generateTable(
     cellSeparators,
   }
 }
+
+export function parseTableEnvironment(tableNode: SyntaxNode) {
+  const tableEnvironment: TableEnvironmentData = {
+    table: { from: tableNode.from, to: tableNode.to },
+  }
+  tableNode.cursor().iterate(({ type, from, to }) => {
+    if (tableEnvironment.caption && tableEnvironment.label) {
+      // Stop looking once we've found both caption and label
+      return false
+    }
+    if (type.is('Caption')) {
+      tableEnvironment.caption = { from, to }
+    } else if (type.is('Label')) {
+      tableEnvironment.label = { from, to }
+    }
+  })
+  return tableEnvironment
+}

+ 18 - 5
services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts

@@ -32,21 +32,32 @@ export const toggleNumberedList = toggleListForRanges('enumerate')
 export const wrapInInlineMath = wrapRanges('\\(', '\\)')
 export const wrapInDisplayMath = wrapRanges('\n\\[', '\\]\n')
 
-export const ensureEmptyLine = (state: EditorState, range: SelectionRange) => {
+export const ensureEmptyLine = (
+  state: EditorState,
+  range: SelectionRange,
+  direction: 'above' | 'below' = 'below'
+) => {
   let pos = range.anchor
   let suffix = ''
+  let prefix = ''
 
   const line = state.doc.lineAt(pos)
 
   if (line.text.trim().length) {
-    pos = Math.min(line.to + 1, state.doc.length)
-    const nextLine = state.doc.lineAt(pos)
+    if (direction === 'below') {
+      pos = Math.min(line.to + 1, state.doc.length)
+    } else {
+      pos = Math.max(line.from - 1, 0)
+    }
+    const neighbouringLine = state.doc.lineAt(pos)
 
-    if (nextLine.length) {
+    if (neighbouringLine.length && direction === 'below') {
       suffix = '\n'
+    } else if (neighbouringLine.length && direction === 'above') {
+      prefix = '\n'
     }
   }
-  return { pos, suffix }
+  return { pos, suffix, prefix }
 }
 
 export const insertFigure: Command = view => {
@@ -66,6 +77,8 @@ export const insertTable = (view: EditorView, sizeX: number, sizeY: number) => {
 ${('\t\t' + '#{} & #{}'.repeat(sizeX - 1) + '\\\\\n').repeat(
   sizeY
 )}\\end{tabular}
+\t\\caption{Caption}
+\t\\label{tab:my_label}
 \\end{table}${suffix}`
   snippet(template)({ state, dispatch }, { label: 'Table' }, pos, pos)
   return true

+ 13 - 2
services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts

@@ -42,7 +42,10 @@ import { DividerWidget } from './visual-widgets/divider'
 import { PreambleWidget } from './visual-widgets/preamble'
 import { EndDocumentWidget } from './visual-widgets/end-document'
 import { EnvironmentLineWidget } from './visual-widgets/environment-line'
-import { ListEnvironmentName } from '../../utils/tree-operations/ancestors'
+import {
+  ListEnvironmentName,
+  ancestorOfNodeWithType,
+} from '../../utils/tree-operations/ancestors'
 import { InlineGraphicsWidget } from './visual-widgets/inline-graphics'
 import getMeta from '../../../../utils/meta'
 import { EditableGraphicsWidget } from './visual-widgets/editable-graphics'
@@ -310,11 +313,19 @@ export const atomicDecorations = (options: Options) => {
             nodeRef.type.is('TabularEnvironment')
           ) {
             if (shouldDecorate(state, nodeRef)) {
+              const tableNode = ancestorOfNodeWithType(
+                nodeRef.node,
+                'TableEnvironment'
+              )
               decorations.push(
                 Decoration.replace({
                   widget: new TabularWidget(
                     nodeRef.node,
-                    state.doc.sliceString(nodeRef.from, nodeRef.to)
+                    state.doc.sliceString(
+                      (tableNode ?? nodeRef).from,
+                      (tableNode ?? nodeRef).to
+                    ),
+                    tableNode
                   ),
                   block: true,
                 }).range(nodeRef.from, nodeRef.to)

+ 20 - 4
services/web/frontend/js/features/source-editor/extensions/visual/selection.ts

@@ -35,9 +35,17 @@ export const placeSelectionInsideBlock = (
   return { selection, effects: EditorView.scrollIntoView(line.to) }
 }
 
-export const extendBackwardsOverEmptyLines = (doc: Text, line: Line) => {
+export const extendBackwardsOverEmptyLines = (
+  doc: Text,
+  line: Line,
+  limit: number = Number.POSITIVE_INFINITY
+) => {
   let { number, from } = line
-  for (let lineNumber = number - 1; lineNumber > 0; lineNumber--) {
+  for (
+    let lineNumber = number - 1;
+    lineNumber > 0 && number - lineNumber <= limit;
+    lineNumber--
+  ) {
     const line = doc.line(lineNumber)
     if (line.text.trim().length > 0) {
       break
@@ -47,9 +55,17 @@ export const extendBackwardsOverEmptyLines = (doc: Text, line: Line) => {
   return from
 }
 
-export const extendForwardsOverEmptyLines = (doc: Text, line: Line) => {
+export const extendForwardsOverEmptyLines = (
+  doc: Text,
+  line: Line,
+  limit: number = Number.POSITIVE_INFINITY
+) => {
   let { number, to } = line
-  for (let lineNumber = number + 1; lineNumber <= doc.lines; lineNumber++) {
+  for (
+    let lineNumber = number + 1;
+    lineNumber <= doc.lines && lineNumber - number <= limit;
+    lineNumber++
+  ) {
     const line = doc.line(lineNumber)
     if (line.text.trim().length > 0) {
       break

+ 14 - 3
services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tabular.tsx

@@ -6,7 +6,11 @@ import { Tabular } from '../../../components/table-generator/tabular'
 export class TabularWidget extends WidgetType {
   private element: HTMLElement | undefined
 
-  constructor(private node: SyntaxNode, private content: string) {
+  constructor(
+    private tabularNode: SyntaxNode,
+    private content: string,
+    private tableNode: SyntaxNode | null
+  ) {
     super()
   }
 
@@ -15,7 +19,11 @@ export class TabularWidget extends WidgetType {
     this.element.classList.add('ol-cm-tabular')
     this.element.style.backgroundColor = 'rgba(125, 125, 125, 0.05)'
     ReactDOM.render(
-      <Tabular view={view} tabularNode={this.node} />,
+      <Tabular
+        view={view}
+        tabularNode={this.tabularNode}
+        tableNode={this.tableNode}
+      />,
       this.element
     )
     return this.element
@@ -23,7 +31,10 @@ export class TabularWidget extends WidgetType {
 
   eq(widget: TabularWidget): boolean {
     return (
-      this.node.from === widget.node.from && this.content === widget.content
+      this.tabularNode.from === widget.tabularNode.from &&
+      this.tableNode?.from === widget.tableNode?.from &&
+      this.tableNode?.to === widget.tableNode?.to &&
+      this.content === widget.content
     )
   }
 

+ 9 - 1
services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar

@@ -105,7 +105,8 @@
     VerbatimEnvName,
     TikzPictureEnvName,
     FigureEnvName,
-    ListEnvName
+    ListEnvName,
+    TableEnvName
 }
 
 @external specialize {CtrlSym} specializeCtrlSym from "./tokens.mjs" {
@@ -521,6 +522,7 @@ KnownEnvironment {
     | TikzPictureEnvironment
     | FigureEnvironment
     | ListEnvironment
+    | TableEnvironment
     )
 }
 
@@ -551,6 +553,12 @@ TabularEnvironment[@isGroup="$Environment"] {
     EndEnv<TabularEnvName>
 }
 
+TableEnvironment[@isGroup="$Environment"] {
+    BeginEnv<TableEnvName>
+    Content<Text>
+    EndEnv<TableEnvName>
+}
+
 EquationEnvironment[@isGroup="$Environment"] {
     BeginEnv<EquationEnvName>
     Content<Math?>

+ 2 - 0
services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs

@@ -76,6 +76,7 @@ import {
   TopRuleCtrlSeq,
   MidRuleCtrlSeq,
   BottomRuleCtrlSeq,
+  TableEnvName,
 } from './latex.terms.mjs'
 
 function nameChar(ch) {
@@ -736,6 +737,7 @@ const otherKnownEnvNames = {
   subfigure: FigureEnvName,
   enumerate: ListEnvName,
   itemize: ListEnvName,
+  table: TableEnvName,
 }
 
 export const specializeEnvName = (name, terms) => {