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

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

GitOrigin-RevId: 7a6aefd77fc4a66a1b78ae0727d4ece962fdd040
This commit is contained in:
Mathias Jakobsen 2023-08-17 09:08:41 +01:00 committed by Copybot
parent 8caa1f8e14
commit 583222d5a5
12 changed files with 286 additions and 24 deletions

View file

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

View file

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

View file

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

View file

@ -1,12 +1,18 @@
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { ColumnDefinition, Positions } from '../tabular' import { ColumnDefinition, Positions } from '../tabular'
import { ChangeSpec } from '@codemirror/state' import { ChangeSpec, EditorSelection } from '@codemirror/state'
import { import {
CellSeparator, CellSeparator,
RowSeparator, RowSeparator,
parseColumnSpecifications, parseColumnSpecifications,
} from '../utils' } from '../utils'
import { TableSelection } from '../contexts/selection-context' 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 */ /* eslint-disable no-unused-vars */
export enum BorderTheme { export enum BorderTheme {
@ -295,3 +301,135 @@ export const insertColumn = (
}) })
view.dispatch({ changes }) 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),
}
}

View file

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

View file

@ -1,6 +1,7 @@
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common' import { SyntaxNode } from '@lezer/common'
import { ColumnDefinition, TableData } from './tabular' import { ColumnDefinition, TableData } from './tabular'
import { TableEnvironmentData } from './contexts/table-context'
const ALIGNMENT_CHARACTERS = ['c', 'l', 'r', 'p'] const ALIGNMENT_CHARACTERS = ['c', 'l', 'r', 'p']
@ -279,3 +280,21 @@ export function generateTable(
cellSeparators, 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
}

View file

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

View file

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

View file

@ -35,9 +35,17 @@ export const placeSelectionInsideBlock = (
return { selection, effects: EditorView.scrollIntoView(line.to) } 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 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) const line = doc.line(lineNumber)
if (line.text.trim().length > 0) { if (line.text.trim().length > 0) {
break break
@ -47,9 +55,17 @@ export const extendBackwardsOverEmptyLines = (doc: Text, line: Line) => {
return from 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 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) const line = doc.line(lineNumber)
if (line.text.trim().length > 0) { if (line.text.trim().length > 0) {
break break

View file

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

View file

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

View file

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