Merge pull request #14340 from overleaf/mj-table-col-span

[visual] Support table multicolumn

GitOrigin-RevId: dd9cd2d5686d72dc9f53beb502a724a36f9c0bcf
This commit is contained in:
Mathias Jakobsen 2023-08-24 10:19:11 +01:00 committed by Copybot
parent a834e02cd5
commit b987e59d60
14 changed files with 846 additions and 254 deletions

View file

@ -9,6 +9,7 @@ import { useEditingContext } from './contexts/editing-context'
import { loadMathJax } from '../../../mathjax/load-mathjax'
import { typesetNodeIntoElement } from '../../extensions/visual/utils/typeset-content'
import { parser } from '../../lezer-latex/latex.mjs'
import { useTableContext } from './contexts/table-context'
export const Cell: FC<{
cellData: CellData
@ -16,9 +17,19 @@ export const Cell: FC<{
rowIndex: number
columnIndex: number
row: RowData
}> = ({ cellData, columnSpecification, rowIndex, columnIndex, row }) => {
}> = ({
cellData,
columnSpecification: columnSpecificationFromTabular,
rowIndex,
columnIndex,
row,
}) => {
const columnSpecification = cellData.multiColumn
? cellData.multiColumn.columns.specification[0]
: columnSpecificationFromTabular
const { selection, setSelection, dragging, setDragging } =
useSelectionContext()
const { table } = useTableContext()
const renderDiv = useRef<HTMLDivElement>(null)
const cellRef = useRef<HTMLTableCellElement>(null)
const {
@ -30,7 +41,9 @@ export const Cell: FC<{
const editing =
editingCellData?.rowIndex === rowIndex &&
editingCellData?.cellIndex === columnIndex
editingCellData?.cellIndex >= columnIndex &&
editingCellData?.cellIndex <
columnIndex + (cellData.multiColumn?.columnSpan ?? 1)
const onMouseDown: MouseEventHandler = useCallback(
event => {
@ -44,12 +57,14 @@ export const Cell: FC<{
return new TableSelection(current.from, {
cell: columnIndex,
row: rowIndex,
})
}).explode(table)
}
return new TableSelection({ cell: columnIndex, row: rowIndex })
return new TableSelection({ cell: columnIndex, row: rowIndex }).explode(
table
)
})
},
[setDragging, columnIndex, rowIndex, setSelection]
[setDragging, columnIndex, rowIndex, setSelection, table]
)
const onMouseUp = useCallback(() => {
@ -79,14 +94,25 @@ export const Cell: FC<{
return new TableSelection(current.from, {
row: rowIndex,
cell: columnIndex,
})
}).explode(table)
} else {
return new TableSelection({ row: rowIndex, cell: columnIndex })
return new TableSelection({
row: rowIndex,
cell: columnIndex,
}).explode(table)
}
})
}
},
[dragging, columnIndex, rowIndex, setSelection, selection, setDragging]
[
dragging,
columnIndex,
rowIndex,
setSelection,
selection,
setDragging,
table,
]
)
useEffect(() => {
@ -105,7 +131,9 @@ export const Cell: FC<{
}
const isFocused =
selection?.to.cell === columnIndex && selection?.to.row === rowIndex
selection?.to.row === rowIndex &&
selection?.to.cell >= columnIndex &&
selection?.to.cell < columnIndex + (cellData.multiColumn?.columnSpan ?? 1)
useEffect(() => {
if (isFocused && !editing && cellRef.current) {
@ -147,11 +175,17 @@ export const Cell: FC<{
)
}
const inSelection = selection?.contains({ row: rowIndex, cell: columnIndex })
const inSelection = selection?.contains(
{
row: rowIndex,
cell: columnIndex,
},
table
)
const onDoubleClick = useCallback(() => {
startEditing(rowIndex, columnIndex, cellData.content.trim())
}, [columnIndex, rowIndex, cellData, startEditing])
startEditing(rowIndex, columnIndex, cellData.content)
}, [columnIndex, rowIndex, startEditing, cellData.content])
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
@ -161,6 +195,7 @@ export const Cell: FC<{
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
colSpan={cellData.multiColumn?.columnSpan}
ref={cellRef}
className={classNames('table-generator-cell', {
'table-generator-cell-border-left': columnSpecification.borderLeft > 0,
@ -177,9 +212,9 @@ export const Cell: FC<{
'selection-edge-bottom':
inSelection && selection?.bordersBottom(rowIndex),
'selection-edge-left':
inSelection && selection?.bordersLeft(columnIndex),
inSelection && selection?.bordersLeft(rowIndex, columnIndex, table),
'selection-edge-right':
inSelection && selection?.bordersRight(columnIndex),
inSelection && selection?.bordersRight(rowIndex, columnIndex, table),
})}
>
{body}

View file

@ -20,7 +20,7 @@ const EditingContext = createContext<
startEditing: (
rowIndex: number,
cellIndex: number,
content: string
initialContent?: string
) => void
}
| undefined
@ -38,18 +38,28 @@ export const useEditingContext = () => {
}
export const EditingContextProvider: FC = ({ children }) => {
const { cellPositions } = useTableContext()
const { table } = useTableContext()
const [cellData, setCellData] = useState<EditingContextData | null>(null)
const [initialContent, setInitialContent] = useState<string | undefined>(
undefined
)
const view = useCodeMirrorViewContext()
const write = useCallback(
(rowIndex: number, cellIndex: number, content: string) => {
const { from, to } = cellPositions[rowIndex][cellIndex]
const { from, to } = table.getCell(rowIndex, cellIndex)
const currentText = view.state.sliceDoc(from, to)
if (currentText !== initialContent && initialContent !== undefined) {
// The cell has changed since we started editing, so we don't want to overwrite it
console.error('Cell has changed since editing started, not overwriting')
return
}
setInitialContent(undefined)
view.dispatch({
changes: { from, to, insert: content },
})
setCellData(null)
},
[view, cellPositions]
[view, table, initialContent]
)
const commitCellData = useCallback(() => {
@ -70,14 +80,21 @@ export const EditingContextProvider: FC = ({ children }) => {
}, [setCellData])
const startEditing = useCallback(
(rowIndex: number, cellIndex: number, content: string) => {
(rowIndex: number, cellIndex: number, initialContent = undefined) => {
if (cellData?.dirty) {
// We're already editing something else
commitCellData()
}
setCellData({ cellIndex, rowIndex, content, dirty: false })
setInitialContent(initialContent)
const content = table.getCell(rowIndex, cellIndex).content.trim()
setCellData({
cellIndex,
rowIndex,
content,
dirty: false,
})
},
[setCellData, cellData, commitCellData]
[setCellData, cellData, commitCellData, table]
)
const updateCellData = useCallback(
@ -93,13 +110,13 @@ export const EditingContextProvider: FC = ({ children }) => {
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]
const { from, to } = table.getCell(row, cell)
changes.push({ from, to, insert: '' })
}
}
view.dispatch({ changes })
},
[view, cellPositions]
[view, table]
)
return (

View file

@ -6,10 +6,11 @@ import {
useContext,
useState,
} from 'react'
import { TableData } from '../tabular'
type TableCoordinate = {
row: number
cell: number
readonly row: number
readonly cell: number
}
export class TableSelection {
@ -21,14 +22,18 @@ export class TableSelection {
this.to = to ?? from
}
contains(point: TableCoordinate) {
contains(anchor: TableCoordinate, table: TableData) {
const { minX, maxX, minY, maxY } = this.normalized()
const { from, to } = table.getCellBoundaries(anchor.row, anchor.cell)
return (
point.cell >= minX &&
point.cell <= maxX &&
point.row >= minY &&
point.row <= maxY
from >= minX && to <= maxX && anchor.row >= minY && anchor.row <= maxY
)
}
static selectRow(row: number, table: TableData) {
return new TableSelection(
{ row, cell: 0 },
{ row, cell: table.columns.length - 1 }
)
}
@ -41,14 +46,14 @@ export class TableSelection {
return { minX, maxX, minY, maxY }
}
bordersLeft(x: number) {
bordersLeft(row: number, cell: number, table: TableData) {
const { minX } = this.normalized()
return minX === x
return minX === table.getCellBoundaries(row, cell).from
}
bordersRight(x: number) {
bordersRight(row: number, cell: number, table: TableData) {
const { maxX } = this.normalized()
return maxX === x
return maxX === table.getCellBoundaries(row, cell).to
}
bordersTop(y: number) {
@ -61,103 +66,221 @@ export class TableSelection {
return maxY === y
}
isRowSelected(row: number, totalColumns: number) {
toString() {
return `TableSelection(${this.from.row}, ${this.from.cell}) -> (${this.to.row}, ${this.to.cell})`
}
isRowSelected(row: number, table: TableData) {
const { minX, maxX, minY, maxY } = this.normalized()
return row >= minY && row <= maxY && minX === 0 && maxX === totalColumns - 1
return (
row >= minY &&
row <= maxY &&
minX === 0 &&
maxX === table.columns.length - 1
)
}
isAnyRowSelected(totalColumns: number) {
const { minX, maxX } = this.normalized()
return minX === 0 && maxX === totalColumns - 1
isAnyRowSelected(table: TableData) {
for (let i = 0; i < table.rows.length; ++i) {
if (this.isRowSelected(i, table)) {
return true
}
}
return false
}
isAnyColumnSelected(totalRows: number) {
const { minY, maxY } = this.normalized()
return minY === 0 && maxY === totalRows - 1
isAnyColumnSelected(table: TableData) {
for (let i = 0; i < table.columns.length; ++i) {
if (this.isColumnSelected(i, table)) {
return true
}
}
return false
}
isColumnSelected(cell: number, totalRows: number) {
isColumnSelected(cell: number, table: TableData) {
const totalRows = table.rows.length
const { minX, maxX, minY, maxY } = this.normalized()
return cell >= minX && cell <= maxX && minY === 0 && maxY === totalRows - 1
}
moveRight(totalColumns: number) {
const newColumn = Math.min(totalColumns - 1, this.to.cell + 1)
return new TableSelection({ row: this.to.row, cell: newColumn })
public eq(other: TableSelection) {
return (
this.from.row === other.from.row &&
this.from.cell === other.from.cell &&
this.to.row === other.to.row &&
this.to.cell === other.to.cell
)
}
moveLeft() {
const newColumn = Math.max(0, this.to.cell - 1)
return new TableSelection({ row: this.to.row, cell: newColumn })
public explode(table: TableData) {
const expandOnce = (current: TableSelection) => {
if (
current.to.row >= table.rows.length ||
current.to.cell >= table.columns.length
) {
throw new Error("Can't expand selection outside of table")
}
const { minX, maxX, minY, maxY } = current.normalized()
for (let row = minY; row <= maxY; ++row) {
const cellBoundariesMinX = table.getCellBoundaries(row, minX)
const cellBoundariesMaxX = table.getCellBoundaries(row, maxX)
if (cellBoundariesMinX.from < minX) {
if (current.from.cell === minX) {
return new TableSelection(
{ row: current.from.row, cell: cellBoundariesMinX.from },
{ row: current.to.row, cell: current.to.cell }
)
} else {
return new TableSelection(
{ row: current.from.row, cell: current.from.cell },
{ row: current.to.row, cell: cellBoundariesMinX.from }
)
}
} else if (cellBoundariesMaxX.to > maxX) {
if (current.to.cell === maxX) {
return new TableSelection(
{ row: current.from.row, cell: current.from.cell },
{ row: current.to.row, cell: cellBoundariesMaxX.to }
)
} else {
return new TableSelection(
{ row: current.from.row, cell: cellBoundariesMaxX.to },
{ row: current.to.row, cell: current.to.cell }
)
}
}
}
return current
}
let last: TableSelection = this
for (
let current = expandOnce(last);
!current.eq(last);
current = expandOnce(last)
) {
last = current
}
return last
}
moveUp() {
moveRight(table: TableData) {
const totalColumns = table.columns.length
const newColumn = Math.min(
totalColumns - 1,
table.getCellBoundaries(this.to.row, this.to.cell).to + 1
)
return new TableSelection({
row: this.to.row,
cell: newColumn,
}).explode(table)
}
moveLeft(table: TableData) {
const row = this.to.row
const from = table.getCellBoundaries(row, this.to.cell).from
const newColumn = Math.max(0, from - 1)
return new TableSelection({ row: this.to.row, cell: newColumn }).explode(
table
)
}
moveUp(table: TableData) {
const newRow = Math.max(0, this.to.row - 1)
return new TableSelection({ row: newRow, cell: this.to.cell })
return new TableSelection({ row: newRow, cell: this.to.cell }).explode(
table
)
}
moveDown(totalRows: number) {
moveDown(table: TableData) {
const totalRows: number = table.rows.length
const newRow = Math.min(totalRows - 1, this.to.row + 1)
return new TableSelection({ row: newRow, cell: this.to.cell })
const cell = table.getCellBoundaries(this.to.row, this.to.cell).from
return new TableSelection({ row: newRow, cell }).explode(table)
}
moveNext(totalColumns: number, totalRows: number) {
moveNext(table: TableData) {
const totalRows = table.rows.length
const totalColumns = table.columns.length
const { row, cell } = this.to
if (cell === totalColumns - 1 && row === totalRows - 1) {
return new TableSelection(this.to)
const boundaries = table.getCellBoundaries(row, cell)
if (boundaries.to === totalColumns - 1 && row === totalRows - 1) {
return new TableSelection(this.to).explode(table)
}
if (cell === totalColumns - 1) {
return new TableSelection({ row: row + 1, cell: 0 })
if (boundaries.to === totalColumns - 1) {
return new TableSelection({ row: row + 1, cell: 0 }).explode(table)
}
return new TableSelection({ row, cell: cell + 1 })
return new TableSelection({ row, cell: boundaries.to + 1 }).explode(table)
}
movePrevious(totalColumns: number) {
if (this.to.cell === 0 && this.to.row === 0) {
return new TableSelection(this.to)
movePrevious(table: TableData) {
const totalColumns = table.columns.length
const { row, cell } = this.to
const boundaries = table.getCellBoundaries(row, cell)
if (boundaries.from === 0 && this.to.row === 0) {
return new TableSelection(this.to).explode(table)
}
if (this.to.cell === 0) {
if (boundaries.from === 0) {
return new TableSelection({
row: this.to.row - 1,
cell: totalColumns - 1,
})
}).explode(table)
}
return new TableSelection({ row: this.to.row, cell: this.to.cell - 1 })
return new TableSelection({
row: this.to.row,
cell: boundaries.from - 1,
})
}
extendRight(totalColumns: number) {
const newColumn = Math.min(totalColumns - 1, this.to.cell + 1)
extendRight(table: TableData) {
const totalColumns = table.columns.length
const { minY, maxY } = this.normalized()
let newColumn = this.to.cell
for (let row = minY; row <= maxY; ++row) {
const boundary = table.getCellBoundaries(row, this.to.cell).to + 1
newColumn = Math.max(newColumn, boundary)
}
newColumn = Math.min(totalColumns - 1, newColumn)
return new TableSelection(
{ row: this.from.row, cell: this.from.cell },
{ row: this.to.row, cell: newColumn }
)
).explode(table)
}
extendLeft() {
const newColumn = Math.max(0, this.to.cell - 1)
extendLeft(table: TableData) {
const { minY, maxY } = this.normalized()
let newColumn = this.to.cell
for (let row = minY; row <= maxY; ++row) {
const boundary = table.getCellBoundaries(row, this.to.cell).from - 1
newColumn = Math.min(newColumn, boundary)
}
newColumn = Math.max(0, newColumn)
return new TableSelection(
{ row: this.from.row, cell: this.from.cell },
{ row: this.to.row, cell: newColumn }
)
).explode(table)
}
extendUp() {
extendUp(table: TableData) {
const newRow = Math.max(0, this.to.row - 1)
return new TableSelection(
{ row: this.from.row, cell: this.from.cell },
{ row: newRow, cell: this.to.cell }
)
).explode(table)
}
extendDown(totalRows: number) {
extendDown(table: TableData) {
const totalRows = table.rows.length
const newRow = Math.min(totalRows - 1, this.to.row + 1)
return new TableSelection(
{ row: this.from.row, cell: this.from.cell },
{ row: newRow, cell: this.to.cell }
)
).explode(table)
}
spansEntireTable(totalColumns: number, totalRows: number) {
spansEntireTable(table: TableData) {
const totalRows = table.rows.length
const totalColumns = table.columns.length
const { minX, maxX, minY, maxY } = this.normalized()
return (
minX === 0 &&
@ -167,6 +290,40 @@ export class TableSelection {
)
}
isMergedCellSelected(table: TableData) {
if (this.from.row !== this.to.row) {
return false
}
const boundariesFrom = table.getCellBoundaries(
this.from.row,
this.from.cell
)
const boundariesTo = table.getCellBoundaries(this.to.row, this.to.cell)
if (boundariesFrom.from !== boundariesTo.from) {
// boundaries are for two different cells, so it's not a merged cell
return false
}
const cellData = table.getCell(this.from.row, boundariesFrom.from)
return cellData && Boolean(cellData.multiColumn)
}
isMergeableCells(table: TableData) {
const { minX, maxX, minY, maxY } = this.normalized()
if (minY !== maxY) {
return false
}
if (minX === maxX) {
return false
}
for (let cell = minX; cell <= maxX; ++cell) {
const cellData = table.getCell(this.from.row, cell)
if (cellData.multiColumn) {
return false
}
}
return true
}
width() {
const { minX, maxX } = this.normalized()
return maxX - minX + 1

View file

@ -27,6 +27,8 @@ const TableContext = createContext<
cellSeparators: CellSeparator[][]
positions: Positions
tableEnvironment?: TableEnvironmentData
rows: number
columns: number
}
| undefined
>(undefined)
@ -36,37 +38,57 @@ export const TableProvider: FC<{
view: EditorView
tableNode: SyntaxNode | null
}> = ({ tabularNode, view, children, tableNode }) => {
const tableData = generateTable(tabularNode, view.state)
try {
const tableData = generateTable(tabularNode, view.state)
// TODO: Validate better that the table matches the column definition
for (const row of tableData.table.rows) {
if (row.cells.length !== tableData.table.columns.length) {
return <TableRenderingError view={view} codePosition={tabularNode.from} />
// TODO: Validate better that the table matches the column definition
for (const row of tableData.table.rows) {
const rowLength = row.cells.reduce(
(acc, cell) => acc + (cell.multiColumn?.columnSpan ?? 1),
0
)
for (const cell of row.cells) {
if (
cell.multiColumn?.columns.specification &&
cell.multiColumn.columns.specification.length !== 1
) {
throw new Error(
'Multi-column cells must have exactly one column definition'
)
}
}
if (rowLength !== tableData.table.columns.length) {
throw new Error('Row length does not match column definition')
}
}
const positions: Positions = {
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,
tableEnvironment,
rows: tableData.table.rows.length,
columns: tableData.table.columns.length,
}}
>
{children}
</TableContext.Provider>
)
} catch {
return <TableRenderingError view={view} codePosition={tabularNode.from} />
}
const positions: Positions = {
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,
tableEnvironment,
}}
>
{children}
</TableContext.Provider>
)
}
export const useTableContext = () => {

View file

@ -3,6 +3,14 @@ import { ColumnDefinition, RowData } from './tabular'
import { Cell } from './cell'
import { RowSelector } from './selectors'
const normalizedCellIndex = (row: RowData, index: number) => {
let normalized = 0
for (let i = 0; i < index; ++i) {
normalized += row.cells[i].multiColumn?.columnSpan ?? 1
}
return normalized
}
export const Row: FC<{
rowIndex: number
row: RowData
@ -10,15 +18,17 @@ export const Row: FC<{
}> = ({ columnSpecifications, row, rowIndex }) => {
return (
<tr>
<RowSelector index={rowIndex} columns={row.cells.length} />
<RowSelector index={rowIndex} />
{row.cells.map((cell, cellIndex) => (
<Cell
key={cellIndex}
cellData={cell}
rowIndex={rowIndex}
row={row}
columnIndex={cellIndex}
columnSpecification={columnSpecifications[cellIndex]}
columnIndex={normalizedCellIndex(row, cellIndex)}
columnSpecification={
columnSpecifications[normalizedCellIndex(row, cellIndex)]
}
/>
))}
</tr>

View file

@ -4,24 +4,20 @@ import {
useSelectionContext,
} from './contexts/selection-context'
import classNames from 'classnames'
import { useTableContext } from './contexts/table-context'
export const ColumnSelector = ({
index,
rows,
}: {
index: number
rows: number
}) => {
export const ColumnSelector = ({ index }: { index: number }) => {
const { selection, setSelection } = useSelectionContext()
const { table } = useTableContext()
const onColumnSelect = useCallback(() => {
setSelection(
new TableSelection(
{ row: 0, cell: index },
{ row: rows - 1, cell: index }
{ row: table.rows.length - 1, cell: index }
)
)
}, [rows, index, setSelection])
const fullySelected = selection?.isColumnSelected(index, rows)
}, [table.rows.length, index, setSelection])
const fullySelected = selection?.isColumnSelected(index, table)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<td
@ -33,23 +29,13 @@ export const ColumnSelector = ({
)
}
export const RowSelector = ({
index,
columns,
}: {
index: number
columns: number
}) => {
export const RowSelector = ({ index }: { index: number }) => {
const { table } = useTableContext()
const { selection, setSelection } = useSelectionContext()
const onSelect = useCallback(() => {
setSelection(
new TableSelection(
{ row: index, cell: 0 },
{ row: index, cell: columns - 1 }
)
)
}, [index, setSelection, columns])
const fullySelected = selection?.isRowSelected(index, columns)
setSelection(TableSelection.selectRow(index, table))
}, [index, setSelection, table])
const fullySelected = selection?.isRowSelected(index, table)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<td

View file

@ -45,22 +45,27 @@ export const Table: FC = () => {
const navigation: NavigationMap = useMemo(
() => ({
ArrowRight: [
() => selection!.moveRight(tableData.columns.length),
() => selection!.extendRight(tableData.columns.length),
() => selection!.moveRight(tableData),
() => selection!.extendRight(tableData),
],
ArrowLeft: [
() => selection!.moveLeft(tableData),
() => selection!.extendLeft(tableData),
],
ArrowUp: [
() => selection!.moveUp(tableData),
() => selection!.extendUp(tableData),
],
ArrowLeft: [() => selection!.moveLeft(), () => selection!.extendLeft()],
ArrowUp: [() => selection!.moveUp(), () => selection!.extendUp()],
ArrowDown: [
() => selection!.moveDown(tableData.rows.length),
() => selection!.extendDown(tableData.rows.length),
() => selection!.moveDown(tableData),
() => selection!.extendDown(tableData),
],
Tab: [
() =>
selection!.moveNext(tableData.columns.length, tableData.rows.length),
() => selection!.movePrevious(tableData.columns.length),
() => selection!.moveNext(tableData),
() => selection!.movePrevious(tableData),
],
}),
[selection, tableData.columns.length, tableData.rows.length]
[selection, tableData]
)
const isCharacterInput = useCallback((event: KeyboardEvent) => {
@ -82,11 +87,16 @@ export const Table: FC = () => {
}
if (cellData) {
commitCellData()
return
} else {
const initialContent = tableData.getCell(
selection.to.row,
selection.to.cell
).content
startEditing(selection.to.row, selection.to.cell, initialContent)
}
const cell = tableData.rows[selection.to.row].cells[selection.to.cell]
startEditing(selection.to.row, selection.to.cell, cell.content)
setSelection(new TableSelection(selection.to, selection.to))
setSelection(
new TableSelection(selection.to, selection.to).explode(tableData)
)
} else if (event.code === 'Escape') {
event.preventDefault()
event.stopPropagation()
@ -115,7 +125,10 @@ export const Table: FC = () => {
event.preventDefault()
if (!selection) {
setSelection(
new TableSelection({ row: 0, cell: 0 }, { row: 0, cell: 0 })
new TableSelection(
{ row: 0, cell: 0 },
{ row: 0, cell: 0 }
).explode(tableData)
)
return
}
@ -130,15 +143,13 @@ export const Table: FC = () => {
if (!selection) {
return
}
const cell = tableData.rows[selection.to.row].cells[selection.to.cell]
startEditing(selection.to.row, selection.to.cell, cell.content)
startEditing(selection.to.row, selection.to.cell)
updateCellData(event.key)
setSelection(new TableSelection(selection.to, selection.to))
}
},
[
selection,
tableData.rows,
cellData,
setSelection,
cancelEditing,
@ -149,6 +160,7 @@ export const Table: FC = () => {
clearCells,
updateCellData,
isCharacterInput,
tableData,
]
)
return (
@ -163,11 +175,7 @@ export const Table: FC = () => {
<tr>
<td />
{tableData.columns.map((_, columnIndex) => (
<ColumnSelector
index={columnIndex}
key={columnIndex}
rows={tableData.rows.length}
/>
<ColumnSelector index={columnIndex} key={columnIndex} />
))}
</tr>
</thead>
@ -180,6 +188,15 @@ export const Table: FC = () => {
columnSpecifications={tableData.columns}
/>
))}
{/* A workaround for a chrome bug where it will not respect colspan
unless there is a row filled with cells without colspan */}
<tr className="table-generator-filler-row">
{/* A td for the row selector */}
<td />
{tableData.columns.map((_, columnIndex) => (
<td key={columnIndex} />
))}
</tr>
</tbody>
</table>
)

View file

@ -20,27 +20,77 @@ import { TableProvider } from './contexts/table-context'
import { TabularProvider, useTabularContext } from './contexts/tabular-context'
import Icon from '../../../../shared/components/icon'
export type CellData = {
// TODO: Add columnSpan
export type ColumnDefinition = {
alignment: 'left' | 'center' | 'right' | 'paragraph'
borderLeft: number
borderRight: number
content: string
}
export type CellData = {
content: string
from: number
to: number
multiColumn?: {
columnSpan: number
columns: {
specification: ColumnDefinition[]
from: number
to: number
}
from: number
to: number
preamble: {
from: number
to: number
}
postamble: {
from: number
to: number
}
}
}
export type RowData = {
cells: CellData[]
borderTop: number
borderBottom: number
}
export type ColumnDefinition = {
alignment: 'left' | 'center' | 'right' | 'paragraph'
borderLeft: number
borderRight: number
content: string
}
export class TableData {
// eslint-disable-next-line no-useless-constructor
constructor(
public readonly rows: RowData[],
public readonly columns: ColumnDefinition[]
) {}
export type TableData = {
rows: RowData[]
columns: ColumnDefinition[]
getCellIndex(row: number, column: number): number {
let cellIndex = 0
for (let i = 0; i < this.rows[row].cells.length; i++) {
cellIndex += this.rows[row].cells[i].multiColumn?.columnSpan ?? 1
if (column < cellIndex) {
return i
}
}
return this.rows[row].cells.length - 1
}
getCell(row: number, column: number): CellData {
return this.rows[row].cells[this.getCellIndex(row, column)]
}
getCellBoundaries(row: number, cell: number) {
let currentCellOffset = 0
for (let index = 0; index < this.rows[row].cells.length; ++index) {
const currentCell = this.rows[row].cells[index]
const skip = currentCell.multiColumn?.columnSpan ?? 1
if (currentCellOffset + skip > cell) {
return { from: currentCellOffset, to: currentCellOffset + skip - 1 }
}
currentCellOffset += skip
}
throw new Error("Couldn't find cell boundaries")
}
}
export type Positions = {

View file

@ -1,5 +1,5 @@
import { EditorView } from '@codemirror/view'
import { ColumnDefinition, Positions } from '../tabular'
import { ColumnDefinition, Positions, TableData } from '../tabular'
import { ChangeSpec, EditorSelection } from '@codemirror/state'
import {
CellSeparator,
@ -24,7 +24,8 @@ export const setBorders = (
view: EditorView,
theme: BorderTheme,
positions: Positions,
rowSeparators: RowSeparator[]
rowSeparators: RowSeparator[],
table: TableData
) => {
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
@ -46,27 +47,31 @@ export const setBorders = (
})
}
}
const removeMulticolumnBorders: ChangeSpec[] = []
for (const row of table.rows) {
for (const cell of row.cells) {
if (cell.multiColumn) {
const specification = view.state.sliceDoc(
cell.multiColumn.columns.from,
cell.multiColumn.columns.to
)
removeMulticolumnBorders.push({
from: cell.multiColumn.columns.from,
to: cell.multiColumn.columns.to,
insert: specification.replace(/\|/g, ''),
})
}
}
}
view.dispatch({
changes: [removeColumnBorders, ...removeHlines],
changes: [
removeColumnBorders,
...removeHlines,
...removeMulticolumnBorders,
],
})
} else if (theme === BorderTheme.FULLY_BORDERED) {
let newSpec = '|'
let consumingBrackets = 0
for (const char of specification) {
if (char === '{') {
consumingBrackets++
}
if (char === '}' && consumingBrackets > 0) {
consumingBrackets--
}
if (consumingBrackets) {
newSpec += char
}
if (char === '|') {
continue
}
newSpec += char + '|'
}
const newSpec = addColumnBordersToSpecification(specification)
const insertColumns = view.state.changes({
from: positions.columnDeclarations.from,
@ -101,19 +106,85 @@ export const setBorders = (
})
)
}
const addMulticolumnBorders: ChangeSpec[] = []
for (const row of table.rows) {
for (const cell of row.cells) {
if (cell.multiColumn) {
const specification = view.state.sliceDoc(
cell.multiColumn.columns.from,
cell.multiColumn.columns.to
)
addMulticolumnBorders.push({
from: cell.multiColumn.columns.from,
to: cell.multiColumn.columns.to,
insert: addColumnBordersToSpecification(specification),
})
}
}
}
view.dispatch({
changes: [insertColumns, ...insertHlines],
changes: [insertColumns, ...insertHlines, ...addMulticolumnBorders],
})
}
}
const addColumnBordersToSpecification = (specification: string) => {
let newSpec = '|'
let consumingBrackets = 0
for (const char of specification) {
if (char === '{') {
consumingBrackets++
}
if (char === '}' && consumingBrackets > 0) {
consumingBrackets--
}
if (consumingBrackets) {
newSpec += char
}
if (char === '|') {
continue
}
newSpec += char + '|'
}
return newSpec
}
export const setAlignment = (
view: EditorView,
selection: TableSelection,
alignment: 'left' | 'right' | 'center',
positions: Positions
positions: Positions,
table: TableData
) => {
if (selection.isMergedCellSelected(table)) {
// change for mergedColumn
const { minX, minY } = selection.normalized()
const cell = table.getCell(minY, minX)
if (!cell.multiColumn) {
return
}
const specification = view.state.sliceDoc(
cell.multiColumn.columns.from,
cell.multiColumn.columns.to
)
const columnSpecification = parseColumnSpecifications(specification)
for (const column of columnSpecification) {
if (column.alignment !== alignment) {
column.alignment = alignment
column.content = alignment[0]
}
}
const newSpec = generateColumnSpecification(columnSpecification)
view.dispatch({
changes: {
from: cell.multiColumn.columns.from,
to: cell.multiColumn.columns.to,
insert: newSpec,
},
})
return
}
const specification = view.state.sliceDoc(
positions.columnDeclarations.from,
positions.columnDeclarations.to
@ -121,7 +192,7 @@ export const setAlignment = (
const columnSpecification = parseColumnSpecifications(specification)
const { minX, maxX } = selection.normalized()
for (let i = minX; i <= maxX; i++) {
if (selection.isColumnSelected(i, positions.rowPositions.length)) {
if (selection.isColumnSelected(i, table)) {
if (columnSpecification[i].alignment === alignment) {
continue
}
@ -155,7 +226,8 @@ export const removeRowOrColumns = (
view: EditorView,
selection: TableSelection,
positions: Positions,
cellSeparators: CellSeparator[][]
cellSeparators: CellSeparator[][],
table: TableData
) => {
const {
minX: startCell,
@ -172,19 +244,17 @@ export const removeRowOrColumns = (
const numberOfColumns = columnSpecification.length
const numberOfRows = positions.rowPositions.length
if (selection.spansEntireTable(numberOfColumns, numberOfRows)) {
if (selection.spansEntireTable(table)) {
emptyTable(view, columnSpecification, positions)
return new TableSelection({ cell: 0, row: 0 })
}
const removedRows =
Number(selection.isRowSelected(startRow, numberOfColumns)) *
selection.height()
Number(selection.isRowSelected(startRow, table)) * selection.height()
const removedColumns =
Number(selection.isColumnSelected(startCell, numberOfRows)) *
selection.width()
Number(selection.isColumnSelected(startCell, table)) * selection.width()
for (let row = startRow; row <= endRow; row++) {
if (selection.isRowSelected(row, numberOfColumns)) {
if (selection.isRowSelected(row, table)) {
const rowPosition = positions.rowPositions[row]
changes.push({
from: rowPosition.from,
@ -192,10 +262,13 @@ export const removeRowOrColumns = (
insert: '',
})
} else {
for (let cell = startCell; cell <= endCell; cell++) {
if (selection.isColumnSelected(cell, numberOfRows)) {
// FIXME: handle multicolumn
if (cell === 0 && cellSeparators[row].length > 0) {
for (let cell = startCell; cell <= endCell; ) {
const cellIndex = table.getCellIndex(row, cell)
const cellPosition = positions.cells[row][cellIndex]
if (selection.isColumnSelected(cell, table)) {
// We should remove this column.
const boundaries = table.getCellBoundaries(row, cell)
if (boundaries.from === 0 && cellSeparators[row].length > 0) {
// Remove the cell separator between the first and second cell
changes.push({
from: positions.cells[row][cell].from,
@ -204,9 +277,8 @@ export const removeRowOrColumns = (
})
} else {
// Remove the cell separator between the cell before and this if possible
const cellPosition = positions.cells[row][cell]
const from =
cellSeparators[row][cell - 1]?.from ?? cellPosition.from
cellSeparators[row][cellIndex - 1]?.from ?? cellPosition.from
const to = cellPosition.to
changes.push({
from,
@ -215,11 +287,12 @@ export const removeRowOrColumns = (
})
}
}
cell += table.rows[row].cells[cellIndex].multiColumn?.columnSpan ?? 1
}
}
}
const filteredColumns = columnSpecification.filter(
(_, i) => !selection.isColumnSelected(i, numberOfRows)
(_, i) => !selection.isColumnSelected(i, table)
)
const newSpecification = generateColumnSpecification(filteredColumns)
changes.push({
@ -266,14 +339,15 @@ export const insertRow = (
view: EditorView,
selection: TableSelection,
positions: Positions,
below: boolean
below: boolean,
table: TableData
) => {
// TODO: Handle borders
const { maxY, minY } = selection.normalized()
const from = below
? positions.rowPositions[maxY].to
: positions.rowPositions[minY].from
const numberOfColumns = positions.cells[maxY].length
const numberOfColumns = table.columns.length
const insert = `\n${' &'.repeat(numberOfColumns - 1)}\\\\`
view.dispatch({ changes: { from, to: from, insert } })
if (!below) {
@ -287,19 +361,22 @@ export const insertRow = (
export const insertColumn = (
view: EditorView,
selection: TableSelection,
initialSelection: TableSelection,
positions: Positions,
after: boolean
after: boolean,
table: TableData
) => {
// TODO: Handle borders
// FIXME: Handle multicolumn
const selection = initialSelection.explode(table)
const { maxX, minX } = selection.normalized()
const changes: ChangeSpec[] = []
for (const row of positions.cells) {
const from = after ? row[maxX].to : row[minX].from
const targetColumn = after ? maxX : minX
for (let row = 0; row < positions.rowPositions.length; row++) {
const cell = table.getCell(row, targetColumn)
const target = cell.multiColumn ?? cell
const from = after ? target.to : target.from
changes.push({
from,
to: from,
insert: ' &',
})
}
@ -461,3 +538,59 @@ const gobbleEmptyLines = (
to: extendForwardsOverEmptyLines(view.state.doc, line, lines),
}
}
export const unmergeCells = (
view: EditorView,
selection: TableSelection,
table: TableData
) => {
const cell = table.getCell(selection.from.row, selection.from.cell)
if (!cell.multiColumn) {
return
}
view.dispatch({
changes: [
{
from: cell.multiColumn.preamble.from,
to: cell.multiColumn.preamble.to,
insert: '',
},
{
from: cell.multiColumn.postamble.from,
to: cell.multiColumn.postamble.to,
insert: '&'.repeat(cell.multiColumn.columnSpan - 1),
},
],
})
}
export const mergeCells = (
view: EditorView,
selection: TableSelection,
table: TableData
) => {
const { minX, maxX, minY, maxY } = selection.normalized()
if (minY !== maxY) {
return
}
if (minX === maxX) {
return
}
const cellContent = []
for (let i = minX; i <= maxX; i++) {
cellContent.push(table.getCell(minY, i).content)
}
const content = cellContent.join(' ').trim()
// TODO: respect border theme
const preamble = '\\multicolumn{' + (maxX - minX + 1) + '}{c}{'
const postamble = '}'
const { from } = table.getCell(minY, minX)
const { to } = table.getCell(minY, maxX)
view.dispatch({
changes: {
from,
to,
insert: `${preamble}${content}${postamble}`,
},
})
}

View file

@ -8,12 +8,14 @@ import {
BorderTheme,
insertColumn,
insertRow,
mergeCells,
moveCaption,
removeCaption,
removeNodes,
removeRowOrColumns,
setAlignment,
setBorders,
unmergeCells,
} from './commands'
import { useCodeMirrorViewContext } from '../../codemirror-editor'
import { useTableContext } from '../contexts/table-context'
@ -21,7 +23,7 @@ import { useTableContext } from '../contexts/table-context'
export const Toolbar = memo(function Toolbar() {
const { selection, setSelection } = useSelectionContext()
const view = useCodeMirrorViewContext()
const { positions, rowSeparators, cellSeparators, tableEnvironment } =
const { positions, rowSeparators, cellSeparators, tableEnvironment, table } =
useTableContext()
if (!selection) {
return null
@ -77,7 +79,8 @@ export const Toolbar = memo(function Toolbar() {
view,
BorderTheme.FULLY_BORDERED,
positions,
rowSeparators
rowSeparators,
table
)
}}
>
@ -89,7 +92,13 @@ export const Toolbar = memo(function Toolbar() {
role="menuitem"
type="button"
onClick={() => {
setBorders(view, BorderTheme.NO_BORDERS, positions, rowSeparators)
setBorders(
view,
BorderTheme.NO_BORDERS,
positions,
rowSeparators,
table
)
}}
>
<MaterialIcon type="border_clear" />
@ -109,10 +118,8 @@ export const Toolbar = memo(function Toolbar() {
id="table-generator-align-dropdown"
disabledLabel="Select a column or a merged cell to align"
disabled={
!selection.isColumnSelected(
selection.from.cell,
positions.rowPositions.length
)
!selection.isColumnSelected(selection.from.cell, table) &&
!selection.isMergedCellSelected(table)
}
>
<ToolbarButton
@ -120,7 +127,7 @@ export const Toolbar = memo(function Toolbar() {
id="table-generator-align-left"
label="Left"
command={() => {
setAlignment(view, selection, 'left', positions)
setAlignment(view, selection, 'left', positions, table)
}}
/>
<ToolbarButton
@ -128,7 +135,7 @@ export const Toolbar = memo(function Toolbar() {
id="table-generator-align-center"
label="Center"
command={() => {
setAlignment(view, selection, 'center', positions)
setAlignment(view, selection, 'center', positions, table)
}}
/>
<ToolbarButton
@ -136,16 +143,31 @@ export const Toolbar = memo(function Toolbar() {
id="table-generator-align-right"
label="Right"
command={() => {
setAlignment(view, selection, 'right', positions)
setAlignment(view, selection, 'right', positions, table)
}}
/>
</ToolbarButtonMenu>
<ToolbarButton
icon="cell_merge"
id="table-generator-merge-cells"
label="Merge cells"
disabled
label={
selection.isMergedCellSelected(table)
? 'Unmerge cells'
: 'Merge cells'
}
active={selection.isMergedCellSelected(table)}
disabled={
!selection.isMergedCellSelected(table) &&
!selection.isMergeableCells(table)
}
disabledLabel="Select cells in a row to merge"
command={() => {
if (selection.isMergedCellSelected(table)) {
unmergeCells(view, selection, table)
} else {
mergeCells(view, selection, table)
}
}}
/>
<ToolbarButton
icon="delete"
@ -153,14 +175,19 @@ export const Toolbar = memo(function Toolbar() {
label="Delete row or column"
disabledLabel="Select a row or a column to delete"
disabled={
!(
positions.cells.length &&
selection.isAnyRowSelected(positions.cells[0].length)
) && !selection.isAnyColumnSelected(positions.rowPositions.length)
(!selection.isAnyRowSelected(table) &&
!selection.isAnyColumnSelected(table)) ||
!selection.eq(selection.explode(table))
}
command={() =>
setSelection(
removeRowOrColumns(view, selection, positions, cellSeparators)
removeRowOrColumns(
view,
selection,
positions,
cellSeparators,
table
)
)
}
/>
@ -176,7 +203,9 @@ export const Toolbar = memo(function Toolbar() {
role="menuitem"
type="button"
onClick={() => {
setSelection(insertColumn(view, selection, positions, false))
setSelection(
insertColumn(view, selection, positions, false, table)
)
}}
>
<span className="table-generator-button-label">
@ -188,7 +217,9 @@ export const Toolbar = memo(function Toolbar() {
role="menuitem"
type="button"
onClick={() => {
setSelection(insertColumn(view, selection, positions, true))
setSelection(
insertColumn(view, selection, positions, true, table)
)
}}
>
<span className="table-generator-button-label">
@ -201,7 +232,7 @@ export const Toolbar = memo(function Toolbar() {
role="menuitem"
type="button"
onClick={() => {
setSelection(insertRow(view, selection, positions, false))
setSelection(insertRow(view, selection, positions, false, table))
}}
>
<span className="table-generator-button-label">
@ -213,7 +244,7 @@ export const Toolbar = memo(function Toolbar() {
role="menuitem"
type="button"
onClick={() => {
setSelection(insertRow(view, selection, positions, true))
setSelection(insertRow(view, selection, positions, true, table))
}}
>
<span className="table-generator-button-label">

View file

@ -1,6 +1,6 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
import { ColumnDefinition, TableData } from './tabular'
import { CellData, ColumnDefinition, TableData } from './tabular'
import { TableEnvironmentData } from './contexts/table-context'
const ALIGNMENT_CHARACTERS = ['c', 'l', 'r', 'p']
@ -85,6 +85,10 @@ const isHLine = (node: SyntaxNode) =>
node.type.is('Command') &&
Boolean(node.getChild('KnownCommand')?.getChild('HorizontalLine'))
const isMultiColumn = (node: SyntaxNode) =>
node.type.is('Command') &&
Boolean(node.getChild('KnownCommand')?.getChild('MultiColumn'))
type Position = {
from: number
to: number
@ -98,6 +102,16 @@ type HLineData = {
type ParsedCell = {
content: string
position: Position
multiColumn?: {
columnSpecification: {
position: Position
specification: ColumnDefinition[]
}
span: number
position: Position
preamble: Position
postamble: Position
}
}
export type CellSeparator = Position
@ -170,24 +184,100 @@ function parseTabularBody(
from: currentChild.from,
to: currentChild.to,
})
} else if (isMultiColumn(currentChild)) {
// do stuff
const multiColumn = currentChild
.getChild('KnownCommand')!
.getChild('MultiColumn')!
const columnArgument = multiColumn
.getChild('ColumnArgument')
?.getChild('ShortTextArgument')
?.getChild('ShortArg')
const spanArgument = multiColumn
.getChild('SpanArgument')
?.getChild('ShortTextArgument')
?.getChild('ShortArg')
const tabularArgument = multiColumn
.getChild('TabularArgument')
?.getChild('TabularContent')
if (!columnArgument) {
throw new Error(
'Invalid multicolumn definition: missing column specification argument'
)
}
if (!spanArgument) {
throw new Error(
'Invalid multicolumn definition: missing colspan argument'
)
}
if (!tabularArgument) {
throw new Error('Invalid multicolumn definition: missing cell content')
}
if (getLastCell()?.content) {
throw new Error(
'Invalid multicolumn definition: multicolumn must be at the start of a cell'
)
}
const columnSpecification = parseColumnSpecifications(
state.sliceDoc(columnArgument.from, columnArgument.to)
)
const span = parseInt(state.sliceDoc(spanArgument.from, spanArgument.to))
const cellContent = state.sliceDoc(
tabularArgument.from,
tabularArgument.to
)
if (!getLastCell()) {
getLastRow().cells.push({
content: '',
position: { from: currentChild.from, to: currentChild.from },
})
}
const lastCell = getLastCell()!
lastCell.multiColumn = {
columnSpecification: {
position: { from: columnArgument.from, to: columnArgument.to },
specification: columnSpecification,
},
span,
preamble: {
from: currentChild.from,
to: tabularArgument.from,
},
postamble: {
from: tabularArgument.to,
to: currentChild.to,
},
position: { from: currentChild.from, to: currentChild.to },
}
lastCell.content = cellContent
lastCell.position.from = tabularArgument.from
lastCell.position.to = tabularArgument.to
// Don't update position at the end of the loop
continue
} else if (
currentChild.type.is('NewLine') ||
currentChild.type.is('Whitespace')
) {
const lastCell = getLastCell()
if (lastCell) {
if (lastCell.content.trim() === '') {
lastCell.position.from = currentChild.to
lastCell.position.to = currentChild.to
} else {
lastCell.content += state.sliceDoc(currentChild.from, currentChild.to)
lastCell.position.to = currentChild.to
if (!lastCell?.multiColumn) {
if (lastCell) {
if (lastCell.content.trim() === '') {
lastCell.position.from = currentChild.to
lastCell.position.to = currentChild.to
} else {
lastCell.content += state.sliceDoc(
currentChild.from,
currentChild.to
)
lastCell.position.to = currentChild.to
}
}
}
// Try to preserve whitespace by skipping past it when locating cells
} else if (isHLine(currentChild)) {
const lastCell = getLastCell()
if (lastCell?.content) {
if (lastCell?.content.trim()) {
console.error(lastCell)
throw new Error('\\hline must be at the start of a row')
}
// push start of cell past the hline
@ -253,7 +343,7 @@ export function generateTable(
}
const tableData = parseTabularBody(body, state)
const cellPositions = tableData.rows.map(row =>
row.cells.map(cell => cell.position)
row.cells.map(cell => cell.multiColumn?.position ?? cell.position)
)
const cellSeparators = tableData.rows.map(row => row.cellSeparators)
const rowPositions = tableData.rows.map(row => ({
@ -261,16 +351,38 @@ export function generateTable(
hlines: row.hlines.map(hline => hline.position),
}))
const rows = tableData.rows.map(row => ({
cells: row.cells.map(cell => ({
content: cell.content,
})),
cells: row.cells.map(cell => {
const cellData: CellData = {
content: cell.content,
from: cell.position.from,
to: cell.position.to,
}
if (cell.multiColumn) {
cellData.multiColumn = {
columns: {
specification: cell.multiColumn.columnSpecification.specification,
from: cell.multiColumn.columnSpecification.position.from,
to: cell.multiColumn.columnSpecification.position.to,
},
columnSpan: cell.multiColumn.span,
from: cell.multiColumn.position.from,
to: cell.multiColumn.position.to,
preamble: {
from: cell.multiColumn.preamble.from,
to: cell.multiColumn.preamble.to,
},
postamble: {
from: cell.multiColumn.postamble.from,
to: cell.multiColumn.postamble.to,
},
}
}
return cellData
}),
borderTop: row.hlines.filter(hline => !hline.atBottom).length,
borderBottom: row.hlines.filter(hline => hline.atBottom).length,
}))
const table = {
rows,
columns,
}
const table = new TableData(rows, columns)
return {
table,
cellPositions,

View file

@ -94,7 +94,8 @@
HLineCtrlSeq,
TopRuleCtrlSeq,
MidRuleCtrlSeq,
BottomRuleCtrlSeq
BottomRuleCtrlSeq,
MultiColumnCtrlSeq
}
@external specialize {EnvName} specializeEnvName from "./tokens.mjs" {
@ -189,6 +190,10 @@ PackageArgument {
!argument ShortTextArgument
}
TabularArgument {
!argument OpenBrace TabularContent CloseBrace
}
UrlArgument {
OpenBrace LiteralArgContent CloseBrace
}
@ -348,6 +353,12 @@ KnownCommand {
} |
HorizontalLine {
(HLineCtrlSeq | TopRuleCtrlSeq | MidRuleCtrlSeq | BottomRuleCtrlSeq) optionalWhitespace?
} |
MultiColumn {
MultiColumnCtrlSeq
optionalWhitespace? SpanArgument { ShortTextArgument }
optionalWhitespace? ColumnArgument { ShortTextArgument }
optionalWhitespace? TabularArgument
}
}
@ -545,11 +556,13 @@ DocumentEnvironment[@isGroup="$Environment"] {
(TrailingWhitespaceOnly | TrailingContent)?
}
TabularContent {
(textWithGroupsEnvironmentsAndBlankLines)*
}
TabularEnvironment[@isGroup="$Environment"] {
BeginEnv<TabularEnvName>
Content<TabularContent {
(textWithGroupsEnvironmentsAndBlankLines)+
}>
Content<TabularContent>
EndEnv<TabularEnvName>
}

View file

@ -77,6 +77,7 @@ import {
MidRuleCtrlSeq,
BottomRuleCtrlSeq,
TableEnvName,
MultiColumnCtrlSeq,
} from './latex.terms.mjs'
function nameChar(ch) {
@ -629,6 +630,7 @@ const otherKnowncommands = {
'\\toprule': TopRuleCtrlSeq,
'\\midrule': MidRuleCtrlSeq,
'\\bottomrule': BottomRuleCtrlSeq,
'\\multicolumn': MultiColumnCtrlSeq,
}
// specializer for control sequences
// return new tokens for specific control sequences

View file

@ -388,3 +388,10 @@
margin-right: 12px;
}
}
.table-generator-filler-row {
border: none !important;
td {
min-width: 40px;
}
}