mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-09 07:05:59 +00:00
Merge pull request #14340 from overleaf/mj-table-col-span
[visual] Support table multicolumn GitOrigin-RevId: dd9cd2d5686d72dc9f53beb502a724a36f9c0bcf
This commit is contained in:
parent
a834e02cd5
commit
b987e59d60
14 changed files with 846 additions and 254 deletions
|
@ -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}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -388,3 +388,10 @@
|
|||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-generator-filler-row {
|
||||
border: none !important;
|
||||
td {
|
||||
min-width: 40px;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue