mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 17:13:38 -05:00
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:
parent
8caa1f8e14
commit
583222d5a5
12 changed files with 286 additions and 24 deletions
|
@ -125,7 +125,9 @@ export const Cell: FC<{
|
|||
toDisplay.substring.bind(toDisplay)
|
||||
)
|
||||
loadMathJax().then(async MathJax => {
|
||||
if (renderDiv.current) {
|
||||
await MathJax.typesetPromise([renderDiv.current])
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [cellData.content, editing])
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
if (direction === 'below') {
|
||||
pos = Math.min(line.to + 1, state.doc.length)
|
||||
const nextLine = state.doc.lineAt(pos)
|
||||
} 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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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?>
|
||||
|
|
|
@ -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) => {
|
||||
|
|
Loading…
Reference in a new issue