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)
)
loadMathJax().then(async MathJax => {
await MathJax.typesetPromise([renderDiv.current])
if (renderDiv.current) {
await MathJax.typesetPromise([renderDiv.current])
}
})
}
}, [cellData.content, editing])

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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