mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
Merge pull request #14105 from overleaf/mj-table-generator
[visual] Initial table generator GitOrigin-RevId: 5c1a9cad898c988d21390358872a6c2eaf1c32fc
This commit is contained in:
parent
b9444a8805
commit
c332a65eb0
30 changed files with 2077 additions and 31 deletions
|
@ -683,6 +683,12 @@ const ProjectController = {
|
|||
cb()
|
||||
})
|
||||
},
|
||||
tableGeneratorAssignment(cb) {
|
||||
SplitTestHandler.getAssignment(req, res, 'table-generator', () => {
|
||||
// We'll pick up the assignment from the res.locals assignment.
|
||||
cb()
|
||||
})
|
||||
},
|
||||
sourceEditorToolbarAssigment(cb) {
|
||||
SplitTestHandler.getAssignment(
|
||||
req,
|
||||
|
|
|
@ -1077,6 +1077,8 @@
|
|||
"toolbar_insert_table": "",
|
||||
"toolbar_numbered_list": "",
|
||||
"toolbar_redo": "",
|
||||
"toolbar_table_insert_size_table": "",
|
||||
"toolbar_table_insert_table_lowercase": "",
|
||||
"toolbar_toggle_symbol_palette": "",
|
||||
"toolbar_undo": "",
|
||||
"total_per_month": "",
|
||||
|
|
|
@ -54,7 +54,7 @@ function CodeMirrorEditor() {
|
|||
|
||||
return (
|
||||
<CodeMirrorStateContext.Provider value={state}>
|
||||
<CodeMirrorViewContext.Provider value={viewRef.current}>
|
||||
<CodeMirrorViewContextProvider value={viewRef.current}>
|
||||
<CodemirrorOutline />
|
||||
<CodeMirrorView />
|
||||
<FigureModal />
|
||||
|
@ -66,7 +66,7 @@ function CodeMirrorEditor() {
|
|||
<Component key={path} />
|
||||
)
|
||||
)}
|
||||
</CodeMirrorViewContext.Provider>
|
||||
</CodeMirrorViewContextProvider>
|
||||
</CodeMirrorStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
@ -89,12 +89,14 @@ export const useCodeMirrorStateContext = (): EditorState => {
|
|||
|
||||
const CodeMirrorViewContext = createContext<EditorView | undefined>(undefined)
|
||||
|
||||
export const CodeMirrorViewContextProvider = CodeMirrorViewContext.Provider
|
||||
|
||||
export const useCodeMirrorViewContext = (): EditorView => {
|
||||
const context = useContext(CodeMirrorViewContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useCodeMirrorViewContext is only available inside CodeMirrorEditor'
|
||||
'useCodeMirrorViewContext is only available inside CodeMirrorViewContextProvider'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
import { FC, MouseEventHandler, useCallback, useEffect, useRef } from 'react'
|
||||
import { CellData, ColumnDefinition, RowData } from './tabular'
|
||||
import classNames from 'classnames'
|
||||
import {
|
||||
TableSelection,
|
||||
useSelectionContext,
|
||||
} from './contexts/selection-context'
|
||||
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'
|
||||
|
||||
export const Cell: FC<{
|
||||
cellData: CellData
|
||||
columnSpecification: ColumnDefinition
|
||||
rowIndex: number
|
||||
columnIndex: number
|
||||
row: RowData
|
||||
}> = ({ cellData, columnSpecification, rowIndex, columnIndex, row }) => {
|
||||
const { selection, setSelection } = useSelectionContext()
|
||||
const renderDiv = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
cellData: editingCellData,
|
||||
updateCellData: update,
|
||||
startEditing,
|
||||
} = useEditingContext()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const editing =
|
||||
editingCellData?.rowIndex === rowIndex &&
|
||||
editingCellData?.cellIndex === columnIndex
|
||||
|
||||
const onMouseDown: MouseEventHandler = useCallback(
|
||||
event => {
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
event.stopPropagation()
|
||||
setSelection(current => {
|
||||
if (event.shiftKey && current) {
|
||||
return new TableSelection(current.from, {
|
||||
row: rowIndex,
|
||||
cell: columnIndex,
|
||||
})
|
||||
} else {
|
||||
return new TableSelection(
|
||||
{ row: rowIndex, cell: columnIndex },
|
||||
{ row: rowIndex, cell: columnIndex }
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
[setSelection, rowIndex, columnIndex]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const filterInput = (input: string) => {
|
||||
// TODO: Are there situations where we don't want to filter the input?
|
||||
return input.replaceAll(/(?<!\\)&/g, '\\&').replaceAll('\\\\', '')
|
||||
}
|
||||
|
||||
const hasFocus = selection?.contains({ row: rowIndex, cell: columnIndex })
|
||||
useEffect(() => {
|
||||
const toDisplay = cellData.content.trim()
|
||||
if (renderDiv.current && !editing) {
|
||||
const tree = parser.parse(toDisplay)
|
||||
const node = tree.topNode
|
||||
|
||||
typesetNodeIntoElement(
|
||||
node,
|
||||
renderDiv.current,
|
||||
toDisplay.substring.bind(toDisplay)
|
||||
)
|
||||
loadMathJax().then(async MathJax => {
|
||||
await MathJax.typesetPromise([renderDiv.current])
|
||||
})
|
||||
}
|
||||
}, [cellData.content, editing])
|
||||
|
||||
let body = <div ref={renderDiv} />
|
||||
if (editing) {
|
||||
body = (
|
||||
<input
|
||||
className="table-generator-cell-input"
|
||||
ref={inputRef}
|
||||
value={editingCellData.content}
|
||||
style={{ width: `inherit` }}
|
||||
onChange={e => {
|
||||
update(filterInput(e.target.value))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const onDoubleClick = useCallback(() => {
|
||||
startEditing(rowIndex, columnIndex, cellData.content.trim())
|
||||
}, [columnIndex, rowIndex, cellData, startEditing])
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
||||
<td
|
||||
onDoubleClick={onDoubleClick}
|
||||
tabIndex={row.cells.length * rowIndex + columnIndex + 1}
|
||||
onMouseDown={onMouseDown}
|
||||
className={classNames('table-generator-cell', {
|
||||
'table-generator-cell-border-left': columnSpecification.borderLeft > 0,
|
||||
'table-generator-cell-border-right':
|
||||
columnSpecification.borderRight > 0,
|
||||
'table-generator-row-border-top': row.borderTop > 0,
|
||||
'table-generator-row-border-bottom': row.borderBottom > 0,
|
||||
'alignment-left': columnSpecification.alignment === 'left',
|
||||
'alignment-center': columnSpecification.alignment === 'center',
|
||||
'alignment-right': columnSpecification.alignment === 'right',
|
||||
'alignment-paragraph': columnSpecification.alignment === 'paragraph',
|
||||
focused: hasFocus,
|
||||
'selection-edge-top': hasFocus && selection?.bordersTop(rowIndex),
|
||||
'selection-edge-bottom': hasFocus && selection?.bordersBottom(rowIndex),
|
||||
'selection-edge-left': hasFocus && selection?.bordersLeft(columnIndex),
|
||||
'selection-edge-right':
|
||||
hasFocus && selection?.bordersRight(columnIndex),
|
||||
})}
|
||||
>
|
||||
{body}
|
||||
</td>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
import { FC, createContext, useCallback, useContext, useState } from 'react'
|
||||
import { useCodeMirrorViewContext } from '../../codemirror-editor'
|
||||
import { useTableContext } from './table-context'
|
||||
|
||||
type EditingContextData = {
|
||||
rowIndex: number
|
||||
cellIndex: number
|
||||
content: string
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
const EditingContext = createContext<
|
||||
| {
|
||||
cellData: EditingContextData | null
|
||||
updateCellData: (content: string) => void
|
||||
cancelEditing: () => void
|
||||
commitCellData: () => void
|
||||
startEditing: (
|
||||
rowIndex: number,
|
||||
cellIndex: number,
|
||||
content: string
|
||||
) => void
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const useEditingContext = () => {
|
||||
const context = useContext(EditingContext)
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useEditingContext is only available inside EditingContext.Provider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export const EditingContextProvider: FC = ({ children }) => {
|
||||
const { cellPositions } = useTableContext()
|
||||
const [cellData, setCellData] = useState<EditingContextData | null>(null)
|
||||
const view = useCodeMirrorViewContext()
|
||||
const write = useCallback(
|
||||
(rowIndex: number, cellIndex: number, content: string) => {
|
||||
const { from, to } = cellPositions[rowIndex][cellIndex]
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: content },
|
||||
})
|
||||
setCellData(null)
|
||||
},
|
||||
[view, cellPositions]
|
||||
)
|
||||
|
||||
const commitCellData = useCallback(() => {
|
||||
if (!cellData) {
|
||||
return
|
||||
}
|
||||
const { rowIndex, cellIndex, content } = cellData
|
||||
write(rowIndex, cellIndex, content)
|
||||
setCellData(null)
|
||||
}, [setCellData, cellData, write])
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
setCellData(null)
|
||||
}, [setCellData])
|
||||
|
||||
const startEditing = useCallback(
|
||||
(rowIndex: number, cellIndex: number, content: string) => {
|
||||
if (cellData?.dirty) {
|
||||
// We're already editing something else
|
||||
commitCellData()
|
||||
}
|
||||
setCellData({ cellIndex, rowIndex, content, dirty: false })
|
||||
},
|
||||
[setCellData, cellData, commitCellData]
|
||||
)
|
||||
|
||||
const updateCellData = useCallback(
|
||||
(content: string) => {
|
||||
setCellData(prev => prev && { ...prev, content, dirty: true })
|
||||
},
|
||||
[setCellData]
|
||||
)
|
||||
return (
|
||||
<EditingContext.Provider
|
||||
value={{
|
||||
cellData,
|
||||
updateCellData,
|
||||
cancelEditing,
|
||||
commitCellData,
|
||||
startEditing,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EditingContext.Provider>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
import {
|
||||
Dispatch,
|
||||
FC,
|
||||
SetStateAction,
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
type TableCoordinate = {
|
||||
row: number
|
||||
cell: number
|
||||
}
|
||||
|
||||
export class TableSelection {
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(public from: TableCoordinate, public to: TableCoordinate) {}
|
||||
contains(point: TableCoordinate) {
|
||||
const { minX, maxX, minY, maxY } = this.normalized()
|
||||
|
||||
return (
|
||||
point.cell >= minX &&
|
||||
point.cell <= maxX &&
|
||||
point.row >= minY &&
|
||||
point.row <= maxY
|
||||
)
|
||||
}
|
||||
|
||||
normalized() {
|
||||
const minX = Math.min(this.from.cell, this.to.cell)
|
||||
const maxX = Math.max(this.from.cell, this.to.cell)
|
||||
const minY = Math.min(this.from.row, this.to.row)
|
||||
const maxY = Math.max(this.from.row, this.to.row)
|
||||
|
||||
return { minX, maxX, minY, maxY }
|
||||
}
|
||||
|
||||
bordersLeft(x: number) {
|
||||
const { minX } = this.normalized()
|
||||
return minX === x
|
||||
}
|
||||
|
||||
bordersRight(x: number) {
|
||||
const { maxX } = this.normalized()
|
||||
return maxX === x
|
||||
}
|
||||
|
||||
bordersTop(y: number) {
|
||||
const { minY } = this.normalized()
|
||||
return minY === y
|
||||
}
|
||||
|
||||
bordersBottom(y: number) {
|
||||
const { maxY } = this.normalized()
|
||||
return maxY === y
|
||||
}
|
||||
|
||||
isRowSelected(row: number, totalColumns: number) {
|
||||
const { minX, maxX, minY, maxY } = this.normalized()
|
||||
return row >= minY && row <= maxY && minX === 0 && maxX === totalColumns - 1
|
||||
}
|
||||
|
||||
isColumnSelected(cell: number, totalRows: number) {
|
||||
const { minX, maxX, minY, maxY } = this.normalized()
|
||||
return cell >= minX && cell <= maxX && minY === 0 && maxY === totalRows - 1
|
||||
}
|
||||
}
|
||||
|
||||
const SelectionContext = createContext<
|
||||
| {
|
||||
selection: TableSelection | null
|
||||
setSelection: Dispatch<SetStateAction<TableSelection | null>>
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const useSelectionContext = () => {
|
||||
const context = useContext(SelectionContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useSelectionContext is only available inside SelectionContext.Provider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export const SelectionContextProvider: FC = ({ children }) => {
|
||||
const [selection, setSelection] = useState<TableSelection | null>(null)
|
||||
return (
|
||||
<SelectionContext.Provider value={{ selection, setSelection }}>
|
||||
{children}
|
||||
</SelectionContext.Provider>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import { FC, createContext, useContext } from 'react'
|
||||
import { Positions, TableData } from '../tabular'
|
||||
import {
|
||||
CellPosition,
|
||||
RowPosition,
|
||||
RowSeparator,
|
||||
generateTable,
|
||||
} from '../utils'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
|
||||
const TableContext = createContext<
|
||||
| {
|
||||
table: TableData
|
||||
cellPositions: CellPosition[][]
|
||||
specification: { from: number; to: number }
|
||||
rowPositions: RowPosition[]
|
||||
rowSeparators: RowSeparator[]
|
||||
positions: Positions
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const TableProvider: FC<{
|
||||
tabularNode: SyntaxNode
|
||||
view: EditorView
|
||||
}> = ({ tabularNode, view, children }) => {
|
||||
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) {
|
||||
throw new Error("Table doesn't match column definition")
|
||||
}
|
||||
}
|
||||
|
||||
const positions: Positions = {
|
||||
cells: tableData.cellPositions,
|
||||
columnDeclarations: tableData.specification,
|
||||
rowPositions: tableData.rowPositions,
|
||||
}
|
||||
return (
|
||||
<TableContext.Provider value={{ ...tableData, positions }}>
|
||||
{children}
|
||||
</TableContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTableContext = () => {
|
||||
const context = useContext(TableContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useTableContext must be used within a TableProvider')
|
||||
}
|
||||
return context
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { FC } from 'react'
|
||||
import { ColumnDefinition, RowData } from './tabular'
|
||||
import { Cell } from './cell'
|
||||
import { RowSelector } from './selectors'
|
||||
|
||||
export const Row: FC<{
|
||||
rowIndex: number
|
||||
row: RowData
|
||||
columnSpecifications: ColumnDefinition[]
|
||||
}> = ({ columnSpecifications, row, rowIndex }) => {
|
||||
return (
|
||||
<tr>
|
||||
<RowSelector index={rowIndex} columns={row.cells.length} />
|
||||
{row.cells.map((cell, cellIndex) => (
|
||||
<Cell
|
||||
key={cellIndex}
|
||||
cellData={cell}
|
||||
rowIndex={rowIndex}
|
||||
row={row}
|
||||
columnIndex={cellIndex}
|
||||
columnSpecification={columnSpecifications[cellIndex]}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { useCallback } from 'react'
|
||||
import {
|
||||
TableSelection,
|
||||
useSelectionContext,
|
||||
} from './contexts/selection-context'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export const ColumnSelector = ({
|
||||
index,
|
||||
rows,
|
||||
}: {
|
||||
index: number
|
||||
rows: number
|
||||
}) => {
|
||||
const { selection, setSelection } = useSelectionContext()
|
||||
const onColumnSelect = useCallback(() => {
|
||||
setSelection(
|
||||
new TableSelection(
|
||||
{ row: 0, cell: index },
|
||||
{ row: rows - 1, cell: index }
|
||||
)
|
||||
)
|
||||
}, [rows, index, setSelection])
|
||||
const fullySelected = selection?.isColumnSelected(index, rows)
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
<td
|
||||
onMouseDown={onColumnSelect}
|
||||
className={classNames('table-generator-selector-cell column-selector', {
|
||||
'fully-selected': fullySelected,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const RowSelector = ({
|
||||
index,
|
||||
columns,
|
||||
}: {
|
||||
index: number
|
||||
columns: number
|
||||
}) => {
|
||||
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)
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
<td
|
||||
onMouseDown={onSelect}
|
||||
className={classNames('table-generator-selector-cell row-selector', {
|
||||
'fully-selected': fullySelected,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import { FC, KeyboardEventHandler, useCallback } from 'react'
|
||||
import { Row } from './row'
|
||||
import { ColumnSelector } from './selectors'
|
||||
import { useSelectionContext } from './contexts/selection-context'
|
||||
import { useEditingContext } from './contexts/editing-context'
|
||||
import { useTableContext } from './contexts/table-context'
|
||||
|
||||
export const Table: FC = () => {
|
||||
const { selection, setSelection } = useSelectionContext()
|
||||
const { cellData, cancelEditing, startEditing } = useEditingContext()
|
||||
const { table: tableData } = useTableContext()
|
||||
const onKeyDown: KeyboardEventHandler = useCallback(
|
||||
event => {
|
||||
if (event.code === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
const cell =
|
||||
tableData.rows[selection.from.row].cells[selection.from.cell]
|
||||
startEditing(selection.from.row, selection.from.cell, cell.content)
|
||||
} else if (event.code === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!cellData) {
|
||||
setSelection(null)
|
||||
} else {
|
||||
cancelEditing()
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
selection,
|
||||
tableData.rows,
|
||||
cellData,
|
||||
setSelection,
|
||||
cancelEditing,
|
||||
startEditing,
|
||||
]
|
||||
)
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
<table
|
||||
className="table-generator-table"
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<td />
|
||||
{tableData.columns.map((_, columnIndex) => (
|
||||
<ColumnSelector
|
||||
index={columnIndex}
|
||||
key={columnIndex}
|
||||
rows={tableData.rows.length}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.rows.map((row, rowIndex) => (
|
||||
<Row
|
||||
row={row}
|
||||
rowIndex={rowIndex}
|
||||
key={rowIndex}
|
||||
columnSpecifications={tableData.columns}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import { SyntaxNode } from '@lezer/common'
|
||||
import { FC } from 'react'
|
||||
import { CellPosition, RowPosition } from './utils'
|
||||
import { Toolbar } from './toolbar/toolbar'
|
||||
import { Table } from './table'
|
||||
import { SelectionContextProvider } from './contexts/selection-context'
|
||||
import { EditingContextProvider } from './contexts/editing-context'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { Alert, Button } from 'react-bootstrap'
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
import { CodeMirrorViewContextProvider } from '../codemirror-editor'
|
||||
import { TableProvider } from './contexts/table-context'
|
||||
|
||||
export type CellData = {
|
||||
// TODO: Add columnSpan
|
||||
content: string
|
||||
}
|
||||
|
||||
export type RowData = {
|
||||
cells: CellData[]
|
||||
borderTop: number
|
||||
borderBottom: number
|
||||
}
|
||||
|
||||
export type ColumnDefinition = {
|
||||
alignment: 'left' | 'center' | 'right' | 'paragraph'
|
||||
borderLeft: number
|
||||
borderRight: number
|
||||
}
|
||||
|
||||
export type TableData = {
|
||||
rows: RowData[]
|
||||
columns: ColumnDefinition[]
|
||||
}
|
||||
|
||||
export type Positions = {
|
||||
cells: CellPosition[][]
|
||||
columnDeclarations: { from: number; to: number }
|
||||
rowPositions: RowPosition[]
|
||||
}
|
||||
|
||||
export const FallbackComponent: FC<{ view: EditorView; node: SyntaxNode }> = ({
|
||||
view,
|
||||
node,
|
||||
}) => {
|
||||
return (
|
||||
<Alert bsStyle="warning" style={{ marginBottom: 0 }}>
|
||||
Table rendering error{' '}
|
||||
<Button
|
||||
onClick={() =>
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(node.from),
|
||||
})
|
||||
}
|
||||
>
|
||||
View code
|
||||
</Button>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export const Tabular: FC<{
|
||||
tabularNode: SyntaxNode
|
||||
view: EditorView
|
||||
}> = ({ tabularNode, view }) => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallbackRender={() => (
|
||||
<FallbackComponent view={view} node={tabularNode} />
|
||||
)}
|
||||
onError={(error, componentStack) => console.error(error, componentStack)}
|
||||
>
|
||||
<CodeMirrorViewContextProvider value={view}>
|
||||
<TableProvider tabularNode={tabularNode} view={view}>
|
||||
<SelectionContextProvider>
|
||||
<EditingContextProvider>
|
||||
<div className="table-generator">
|
||||
<Toolbar />
|
||||
<Table />
|
||||
</div>
|
||||
</EditingContextProvider>
|
||||
</SelectionContextProvider>
|
||||
</TableProvider>
|
||||
</CodeMirrorViewContextProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
import { EditorView } from '@codemirror/view'
|
||||
import { Positions } from '../tabular'
|
||||
import { ChangeSpec } from '@codemirror/state'
|
||||
import { RowSeparator } from '../utils'
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum BorderTheme {
|
||||
NO_BORDERS = 0,
|
||||
FULLY_BORDERED = 1,
|
||||
}
|
||||
/* eslint-enable no-unused-vars */
|
||||
export const setBorders = (
|
||||
view: EditorView,
|
||||
theme: BorderTheme,
|
||||
positions: Positions,
|
||||
rowSeparators: RowSeparator[]
|
||||
) => {
|
||||
const specification = view.state.sliceDoc(
|
||||
positions.columnDeclarations.from,
|
||||
positions.columnDeclarations.to
|
||||
)
|
||||
if (theme === BorderTheme.NO_BORDERS) {
|
||||
const removeColumnBorders = view.state.changes({
|
||||
from: positions.columnDeclarations.from,
|
||||
to: positions.columnDeclarations.to,
|
||||
insert: specification.replace(/\|/g, ''),
|
||||
})
|
||||
const removeHlines: ChangeSpec[] = []
|
||||
for (const row of positions.rowPositions) {
|
||||
for (const hline of row.hlines) {
|
||||
removeHlines.push({
|
||||
from: hline.from,
|
||||
to: hline.to,
|
||||
insert: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
view.dispatch({
|
||||
changes: [removeColumnBorders, ...removeHlines],
|
||||
})
|
||||
} 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 insertColumns = view.state.changes({
|
||||
from: positions.columnDeclarations.from,
|
||||
to: positions.columnDeclarations.to,
|
||||
insert: newSpec,
|
||||
})
|
||||
|
||||
const insertHlines: ChangeSpec[] = []
|
||||
for (const row of positions.rowPositions) {
|
||||
if (row.hlines.length === 0) {
|
||||
insertHlines.push(
|
||||
view.state.changes({
|
||||
from: row.from,
|
||||
to: row.from,
|
||||
insert: ' \\hline ',
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
const lastRow = positions.rowPositions[positions.rowPositions.length - 1]
|
||||
if (lastRow.hlines.length < 2) {
|
||||
let toInsert = ' \\hline'
|
||||
if (rowSeparators.length < positions.rowPositions.length) {
|
||||
// We need a trailing \\
|
||||
toInsert = ` \\\\${toInsert}`
|
||||
}
|
||||
insertHlines.push(
|
||||
view.state.changes({
|
||||
from: lastRow.to,
|
||||
to: lastRow.to,
|
||||
insert: toInsert,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: [insertColumns, ...insertHlines],
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import { FC, memo, useRef } from 'react'
|
||||
import useDropdown from '../../../../../shared/hooks/use-dropdown'
|
||||
import { Button, ListGroup, Overlay, Popover } from 'react-bootstrap'
|
||||
import Tooltip from '../../../../../shared/components/tooltip'
|
||||
import MaterialIcon from '../../../../../shared/components/material-icon'
|
||||
import { useCodeMirrorViewContext } from '../../codemirror-editor'
|
||||
|
||||
export const ToolbarButtonMenu: FC<{
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
disabled?: boolean
|
||||
}> = memo(function ButtonMenu({ icon, id, label, children, disabled }) {
|
||||
const target = useRef<any>(null)
|
||||
const { open, onToggle, ref } = useDropdown()
|
||||
const view = useCodeMirrorViewContext()
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
className="table-generator-toolbar-button table-generator-toolbar-button-menu"
|
||||
aria-label={label}
|
||||
bsStyle={null}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={event => {
|
||||
onToggle(!open)
|
||||
}}
|
||||
disabled={disabled}
|
||||
ref={target}
|
||||
>
|
||||
<MaterialIcon type={icon} />
|
||||
<MaterialIcon type="expand_more" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
const overlay = (
|
||||
<Overlay
|
||||
show={open}
|
||||
target={target.current}
|
||||
placement="bottom"
|
||||
container={view.dom}
|
||||
containerPadding={0}
|
||||
animation
|
||||
onHide={() => onToggle(false)}
|
||||
>
|
||||
<Popover
|
||||
id={`${id}-menu`}
|
||||
ref={ref}
|
||||
className="table-generator-button-menu-popover"
|
||||
>
|
||||
<ListGroup
|
||||
role="menu"
|
||||
onClick={() => {
|
||||
onToggle(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ListGroup>
|
||||
</Popover>
|
||||
</Overlay>
|
||||
)
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
{overlay}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
hidden={open}
|
||||
id={id}
|
||||
description={<div>{label}</div>}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
{overlay}
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,83 @@
|
|||
import { EditorView } from '@codemirror/view'
|
||||
import classNames from 'classnames'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import Tooltip from '../../../../../shared/components/tooltip'
|
||||
import MaterialIcon from '../../../../../shared/components/material-icon'
|
||||
import { useCodeMirrorViewContext } from '../../codemirror-editor'
|
||||
|
||||
export const ToolbarButton = memo<{
|
||||
id: string
|
||||
className?: string
|
||||
label: string
|
||||
command?: (view: EditorView) => void
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
icon: string
|
||||
hidden?: boolean
|
||||
shortcut?: string
|
||||
}>(function ToolbarButton({
|
||||
id,
|
||||
className,
|
||||
label,
|
||||
command,
|
||||
active = false,
|
||||
disabled,
|
||||
icon,
|
||||
hidden = false,
|
||||
shortcut,
|
||||
}) {
|
||||
const view = useCodeMirrorViewContext()
|
||||
const handleMouseDown = useCallback(event => {
|
||||
event.preventDefault()
|
||||
}, [])
|
||||
|
||||
const handleClick = useCallback(
|
||||
event => {
|
||||
if (command) {
|
||||
event.preventDefault()
|
||||
command(view)
|
||||
view.focus()
|
||||
}
|
||||
},
|
||||
[command, view]
|
||||
)
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
className={classNames('table-generator-toolbar-button', className, {
|
||||
hidden,
|
||||
})}
|
||||
aria-label={label}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={!disabled ? handleClick : undefined}
|
||||
bsStyle={null}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
<MaterialIcon type={icon} />
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (!label) {
|
||||
return button
|
||||
}
|
||||
|
||||
const description = (
|
||||
<>
|
||||
<div>{label}</div>
|
||||
{shortcut && <div>{shortcut}</div>}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
id={id}
|
||||
description={description}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,93 @@
|
|||
import { FC, useRef } from 'react'
|
||||
import useDropdown from '../../../../../shared/hooks/use-dropdown'
|
||||
import { Overlay, Popover } from 'react-bootstrap'
|
||||
import MaterialIcon from '../../../../../shared/components/material-icon'
|
||||
import Tooltip from '../../../../../shared/components/tooltip'
|
||||
import { useCodeMirrorViewContext } from '../../codemirror-editor'
|
||||
|
||||
export const ToolbarDropdown: FC<{
|
||||
id: string
|
||||
label?: string
|
||||
btnClassName?: string
|
||||
icon?: string
|
||||
tooltip?: string
|
||||
disabled?: boolean
|
||||
}> = ({
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
btnClassName = 'table-generator-toolbar-dropdown-toggle',
|
||||
icon = 'expand_more',
|
||||
tooltip,
|
||||
disabled,
|
||||
}) => {
|
||||
const { open, onToggle } = useDropdown()
|
||||
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
const view = useCodeMirrorViewContext()
|
||||
|
||||
const button = (
|
||||
<button
|
||||
ref={toggleButtonRef}
|
||||
type="button"
|
||||
id={id}
|
||||
aria-haspopup="true"
|
||||
className={btnClassName}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => onToggle(!open)}
|
||||
aria-label={tooltip}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{label && <span>{label}</span>}
|
||||
<MaterialIcon type={icon} />
|
||||
</button>
|
||||
)
|
||||
const overlay = open && (
|
||||
<Overlay
|
||||
show
|
||||
onHide={() => onToggle(false)}
|
||||
animation={false}
|
||||
container={view.dom}
|
||||
containerPadding={0}
|
||||
placement="bottom"
|
||||
rootClose
|
||||
target={toggleButtonRef.current ?? undefined}
|
||||
>
|
||||
<Popover
|
||||
id={`${id}-popover`}
|
||||
className="table-generator-toolbar-dropdown-popover"
|
||||
>
|
||||
<div
|
||||
className="table-generator-toolbar-dropdown-menu"
|
||||
id={`${id}-menu`}
|
||||
role="menu"
|
||||
aria-labelledby={id}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Popover>
|
||||
</Overlay>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
{overlay}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
id={`${id}-tooltip`}
|
||||
description={tooltip}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
{overlay}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,181 @@
|
|||
import { memo } from 'react'
|
||||
import { useSelectionContext } from '../contexts/selection-context'
|
||||
import { ToolbarButton } from './toolbar-button'
|
||||
import { ToolbarButtonMenu } from './toolbar-button-menu'
|
||||
import { ToolbarDropdown } from './toolbar-dropdown'
|
||||
import MaterialIcon from '../../../../../shared/components/material-icon'
|
||||
import { BorderTheme, setBorders } from './commands'
|
||||
import { useCodeMirrorViewContext } from '../../codemirror-editor'
|
||||
import { useTableContext } from '../contexts/table-context'
|
||||
|
||||
export const Toolbar = memo(function Toolbar() {
|
||||
const { selection } = useSelectionContext()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const { positions, rowSeparators } = useTableContext()
|
||||
if (!selection) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="table-generator-floating-toolbar">
|
||||
<ToolbarDropdown
|
||||
id="table-generator-caption-dropdown"
|
||||
label="Caption below"
|
||||
disabled
|
||||
>
|
||||
<button
|
||||
className="ol-cm-toolbar-menu-item"
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
No caption
|
||||
</button>
|
||||
<button
|
||||
className="ol-cm-toolbar-menu-item"
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
Caption above
|
||||
</button>
|
||||
<button
|
||||
className="ol-cm-toolbar-menu-item"
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
Caption below
|
||||
</button>
|
||||
</ToolbarDropdown>
|
||||
<ToolbarDropdown
|
||||
id="table-generator-borders-dropdown"
|
||||
label="All borders"
|
||||
>
|
||||
<button
|
||||
className="ol-cm-toolbar-menu-item"
|
||||
role="menuitem"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBorders(
|
||||
view,
|
||||
BorderTheme.FULLY_BORDERED,
|
||||
positions,
|
||||
rowSeparators
|
||||
)
|
||||
}}
|
||||
>
|
||||
<MaterialIcon type="border_all" />
|
||||
<span className="table-generator-button-label">All borders</span>
|
||||
</button>
|
||||
<button
|
||||
className="ol-cm-toolbar-menu-item"
|
||||
role="menuitem"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBorders(view, BorderTheme.NO_BORDERS, positions, rowSeparators)
|
||||
}}
|
||||
>
|
||||
<MaterialIcon type="border_clear" />
|
||||
<span className="table-generator-button-label">No borders</span>
|
||||
</button>
|
||||
<div className="table-generator-border-options-coming-soon">
|
||||
<div className="info-icon">
|
||||
<MaterialIcon type="info" />
|
||||
</div>
|
||||
More options for border settings coming soon.
|
||||
</div>
|
||||
</ToolbarDropdown>
|
||||
<div className="table-generator-button-group">
|
||||
<ToolbarButtonMenu
|
||||
label="Alignment"
|
||||
icon="format_align_left"
|
||||
id="table-generator-align-dropdown"
|
||||
disabled
|
||||
>
|
||||
<ToolbarButton
|
||||
icon="format_align_left"
|
||||
id="table-generator-align-left"
|
||||
label="Left"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon="format_align_center"
|
||||
id="table-generator-align-center"
|
||||
label="Center"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon="format_align_right"
|
||||
id="table-generator-align-right"
|
||||
label="Right"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon="format_align_justify"
|
||||
id="table-generator-align-justify"
|
||||
label="Justify"
|
||||
/>
|
||||
</ToolbarButtonMenu>
|
||||
<ToolbarButton
|
||||
icon="cell_merge"
|
||||
id="table-generator-merge-cells"
|
||||
label="Merge cells"
|
||||
disabled
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon="delete"
|
||||
id="table-generator-remove-column-row"
|
||||
label="Remove row or column"
|
||||
disabled
|
||||
/>
|
||||
<ToolbarDropdown
|
||||
id="table-generator-add-dropdown"
|
||||
btnClassName="table-generator-toolbar-button"
|
||||
icon="add"
|
||||
tooltip="Insert"
|
||||
disabled
|
||||
>
|
||||
<button
|
||||
className="ol-cm-toolbar-menu-item"
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<span className="table-generator-button-label">
|
||||
Insert column left
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="ol-cm-toolbar-menu-item"
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<span className="table-generator-button-label">
|
||||
Insert column right
|
||||
</span>
|
||||
</button>
|
||||
<hr />
|
||||
<button
|
||||
className="ol-cm-toolbar-menu-item"
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<span className="table-generator-button-label">
|
||||
Insert row above
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="ol-cm-toolbar-menu-item"
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<span className="table-generator-button-label">
|
||||
Insert row below
|
||||
</span>
|
||||
</button>
|
||||
</ToolbarDropdown>
|
||||
</div>
|
||||
<div className="table-generator-button-group">
|
||||
<ToolbarButton
|
||||
icon="delete_forever"
|
||||
id="table-generator-remove-table"
|
||||
label="Remove table"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,262 @@
|
|||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import { ColumnDefinition, TableData } from './tabular'
|
||||
|
||||
const ALIGNMENT_CHARACTERS = ['c', 'l', 'r', 'p']
|
||||
|
||||
export type CellPosition = { from: number; to: number }
|
||||
export type RowPosition = {
|
||||
from: number
|
||||
to: number
|
||||
hlines: { from: number; to: number }[]
|
||||
}
|
||||
|
||||
function parseColumnSpecifications(specification: string): ColumnDefinition[] {
|
||||
const columns: ColumnDefinition[] = []
|
||||
let currentAlignment: ColumnDefinition['alignment'] | undefined
|
||||
let currentBorderLeft = 0
|
||||
let currentBorderRight = 0
|
||||
function maybeCommit() {
|
||||
if (currentAlignment !== undefined) {
|
||||
columns.push({
|
||||
alignment: currentAlignment,
|
||||
borderLeft: currentBorderLeft,
|
||||
borderRight: currentBorderRight,
|
||||
})
|
||||
currentAlignment = undefined
|
||||
currentBorderLeft = 0
|
||||
currentBorderRight = 0
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < specification.length; i++) {
|
||||
if (ALIGNMENT_CHARACTERS.includes(specification.charAt(i))) {
|
||||
maybeCommit()
|
||||
}
|
||||
const hasAlignment = currentAlignment !== undefined
|
||||
const char = specification.charAt(i)
|
||||
switch (char) {
|
||||
case '|': {
|
||||
if (hasAlignment) {
|
||||
currentBorderRight++
|
||||
} else {
|
||||
currentBorderLeft++
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'c':
|
||||
currentAlignment = 'center'
|
||||
break
|
||||
case 'l':
|
||||
currentAlignment = 'left'
|
||||
break
|
||||
case 'r':
|
||||
currentAlignment = 'right'
|
||||
break
|
||||
case 'p': {
|
||||
currentAlignment = 'paragraph'
|
||||
// TODO: Parse these details
|
||||
while (i < specification.length && specification.charAt(i) !== '}') {
|
||||
i++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
maybeCommit()
|
||||
return columns
|
||||
}
|
||||
|
||||
const isRowSeparator = (node: SyntaxNode, state: EditorState) =>
|
||||
node.type.is('Command') && state.sliceDoc(node.from, node.to) === '\\\\'
|
||||
|
||||
const isHLine = (node: SyntaxNode) =>
|
||||
node.type.is('Command') &&
|
||||
Boolean(node.getChild('KnownCommand')?.getChild('HLine'))
|
||||
|
||||
type Position = {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
type HLineData = {
|
||||
position: Position
|
||||
atBottom: boolean
|
||||
}
|
||||
|
||||
type ParsedCell = {
|
||||
content: string
|
||||
position: Position
|
||||
}
|
||||
|
||||
type CellSeparator = Position
|
||||
export type RowSeparator = Position
|
||||
|
||||
type ParsedRow = {
|
||||
position: Position
|
||||
cells: ParsedCell[]
|
||||
cellSeparators: CellSeparator[]
|
||||
hlines: HLineData[]
|
||||
}
|
||||
|
||||
type ParsedTableBody = {
|
||||
rows: ParsedRow[]
|
||||
rowSeparators: RowSeparator[]
|
||||
}
|
||||
|
||||
function parseTabularBody(
|
||||
node: SyntaxNode,
|
||||
state: EditorState
|
||||
): ParsedTableBody {
|
||||
const body: ParsedTableBody = {
|
||||
rows: [
|
||||
{
|
||||
cells: [],
|
||||
hlines: [],
|
||||
cellSeparators: [],
|
||||
position: { from: node.from, to: node.from },
|
||||
},
|
||||
],
|
||||
rowSeparators: [],
|
||||
}
|
||||
getLastRow().cells.push({
|
||||
content: '',
|
||||
position: { from: node.from, to: node.from },
|
||||
})
|
||||
function getLastRow() {
|
||||
return body.rows[body.rows.length - 1]
|
||||
}
|
||||
function getLastCell(): ParsedCell | undefined {
|
||||
return getLastRow().cells[getLastRow().cells.length - 1]
|
||||
}
|
||||
for (
|
||||
let currentChild: SyntaxNode | null = node;
|
||||
currentChild;
|
||||
currentChild = currentChild.nextSibling
|
||||
) {
|
||||
if (isRowSeparator(currentChild, state)) {
|
||||
const lastRow = getLastRow()
|
||||
body.rows.push({
|
||||
cells: [],
|
||||
hlines: [],
|
||||
cellSeparators: [],
|
||||
position: { from: currentChild.to, to: currentChild.to },
|
||||
})
|
||||
lastRow.position.to = currentChild.to
|
||||
body.rowSeparators.push({ from: currentChild.from, to: currentChild.to })
|
||||
getLastRow().cells.push({
|
||||
content: '',
|
||||
position: { from: currentChild.to, to: currentChild.to },
|
||||
})
|
||||
continue
|
||||
} else if (currentChild.type.is('Ampersand')) {
|
||||
// Add another cell
|
||||
getLastRow().cells.push({
|
||||
content: '',
|
||||
position: { from: currentChild.to, to: currentChild.to },
|
||||
})
|
||||
getLastRow().cellSeparators.push({
|
||||
from: currentChild.from,
|
||||
to: currentChild.to,
|
||||
})
|
||||
} 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
|
||||
}
|
||||
}
|
||||
// Try to preserve whitespace by skipping past it when locating cells
|
||||
} else if (isHLine(currentChild)) {
|
||||
const lastCell = getLastCell()
|
||||
if (lastCell?.content) {
|
||||
throw new Error('\\hline must be at the start of a row')
|
||||
}
|
||||
const lastRow = getLastRow()
|
||||
lastRow.hlines.push({
|
||||
position: { from: currentChild.from, to: currentChild.to },
|
||||
// They will always be at the top, we patch the bottom border later.
|
||||
atBottom: false,
|
||||
})
|
||||
} else {
|
||||
// Add to the last cell
|
||||
if (!getLastCell()) {
|
||||
getLastRow().cells.push({
|
||||
content: '',
|
||||
position: { from: currentChild.from, to: currentChild.from },
|
||||
})
|
||||
}
|
||||
const lastCell = getLastCell()!
|
||||
lastCell.content += state.sliceDoc(currentChild.from, currentChild.to)
|
||||
lastCell.position.to = currentChild.to
|
||||
}
|
||||
getLastRow().position.to = currentChild.to
|
||||
}
|
||||
const lastRow = getLastRow()
|
||||
if (lastRow.cells.length === 1 && lastRow.cells[0].content.trim() === '') {
|
||||
// Remove the last row if it's empty, but move hlines up to previous row
|
||||
const hlines = lastRow.hlines.map(hline => ({ ...hline, atBottom: true }))
|
||||
body.rows.pop()
|
||||
getLastRow().hlines.push(...hlines)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export function generateTable(
|
||||
node: SyntaxNode,
|
||||
state: EditorState
|
||||
): {
|
||||
table: TableData
|
||||
cellPositions: CellPosition[][]
|
||||
specification: { from: number; to: number }
|
||||
rowPositions: RowPosition[]
|
||||
rowSeparators: RowSeparator[]
|
||||
} {
|
||||
const specification = node
|
||||
.getChild('BeginEnv')
|
||||
?.getChild('TextArgument')
|
||||
?.getChild('LongArg')
|
||||
|
||||
if (!specification) {
|
||||
throw new Error('Missing column specification')
|
||||
}
|
||||
const columns = parseColumnSpecifications(
|
||||
state.sliceDoc(specification.from, specification.to)
|
||||
)
|
||||
const body = node.getChild('Content')?.getChild('TabularContent')?.firstChild
|
||||
if (!body) {
|
||||
throw new Error('Missing table body')
|
||||
}
|
||||
const tableData = parseTabularBody(body, state)
|
||||
const cellPositions = tableData.rows.map(row =>
|
||||
row.cells.map(cell => cell.position)
|
||||
)
|
||||
const rowPositions = tableData.rows.map(row => ({
|
||||
...row.position,
|
||||
hlines: row.hlines.map(hline => hline.position),
|
||||
}))
|
||||
const rows = tableData.rows.map(row => ({
|
||||
cells: row.cells.map(cell => ({
|
||||
content: cell.content,
|
||||
})),
|
||||
borderTop: row.hlines.filter(hline => !hline.atBottom).length,
|
||||
borderBottom: row.hlines.filter(hline => hline.atBottom).length,
|
||||
}))
|
||||
const table = {
|
||||
rows,
|
||||
columns,
|
||||
}
|
||||
return {
|
||||
table,
|
||||
cellPositions,
|
||||
specification,
|
||||
rowPositions,
|
||||
rowSeparators: tableData.rowSeparators,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import { FC, useCallback, useRef, useState } from 'react'
|
||||
import * as commands from '../../extensions/toolbar/commands'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDropdown from '../../../../shared/hooks/use-dropdown'
|
||||
import { Button, Overlay, Popover } from 'react-bootstrap'
|
||||
import { useCodeMirrorViewContext } from '../codemirror-editor'
|
||||
import Tooltip from '../../../../shared/components/tooltip'
|
||||
import MaterialIcon from '../../../../shared/components/material-icon'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export const TableInserterDropdown: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { open, onToggle, ref } = useDropdown()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const target = useRef<any>(null)
|
||||
|
||||
const onSizeSelected = useCallback(
|
||||
(sizeX: number, sizeY: number) => {
|
||||
onToggle(false)
|
||||
commands.insertTable(view, sizeX, sizeY)
|
||||
view.focus()
|
||||
},
|
||||
[view, onToggle]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
hidden={open}
|
||||
id="toolbar-table"
|
||||
description={<div>{t('toolbar_insert_table')}</div>}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
className="ol-cm-toolbar-button"
|
||||
aria-label={t('toolbar_insert_table')}
|
||||
bsStyle={null}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={() => {
|
||||
onToggle(!open)
|
||||
}}
|
||||
ref={target}
|
||||
>
|
||||
<MaterialIcon type="table" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Overlay
|
||||
show={open}
|
||||
target={target.current}
|
||||
placement="bottom"
|
||||
container={view.dom}
|
||||
containerPadding={0}
|
||||
animation
|
||||
onHide={() => onToggle(false)}
|
||||
>
|
||||
<Popover
|
||||
id="toolbar-table-menu"
|
||||
ref={ref}
|
||||
className="ol-cm-toolbar-button-menu-popover ol-cm-toolbar-table-grid-popover"
|
||||
>
|
||||
<SizeGrid sizeX={10} sizeY={10} onSizeSelected={onSizeSelected} />
|
||||
</Popover>
|
||||
</Overlay>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const range = (start: number, end: number) =>
|
||||
Array.from({ length: end - start + 1 }, (v, k) => k + start)
|
||||
|
||||
const SizeGrid: FC<{
|
||||
sizeX: number
|
||||
sizeY: number
|
||||
onSizeSelected: (sizeX: number, sizeY: number) => void
|
||||
}> = ({ sizeX, sizeY, onSizeSelected }) => {
|
||||
const [currentSize, setCurrentSize] = useState<{
|
||||
sizeX: number
|
||||
sizeY: number
|
||||
}>({ sizeX: 0, sizeY: 0 })
|
||||
const { t } = useTranslation()
|
||||
let label = t('toolbar_table_insert_table_lowercase')
|
||||
if (currentSize.sizeX > 0 && currentSize.sizeY > 0) {
|
||||
label = t('toolbar_table_insert_size_table', {
|
||||
size: `${currentSize.sizeY}×${currentSize.sizeX}`,
|
||||
})
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="ol-cm-toolbar-table-size-label">{label}</div>
|
||||
<table
|
||||
className="ol-cm-toolbar-table-grid"
|
||||
onMouseLeave={() => {
|
||||
setCurrentSize({ sizeX: 0, sizeY: 0 })
|
||||
}}
|
||||
>
|
||||
<tbody>
|
||||
{range(1, sizeY).map(y => (
|
||||
<tr key={y}>
|
||||
{range(1, sizeX).map(x => (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
<td
|
||||
className={classNames('ol-cm-toolbar-table-cell', {
|
||||
active: currentSize.sizeX >= x && currentSize.sizeY >= y,
|
||||
})}
|
||||
key={x}
|
||||
onMouseEnter={() => {
|
||||
setCurrentSize({ sizeX: x, sizeY: y })
|
||||
}}
|
||||
onMouseDown={() => onSizeSelected(x, y)}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -14,6 +14,7 @@ import getMeta from '../../../../utils/meta'
|
|||
import { InsertFigureDropdown } from './insert-figure-dropdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MathDropdown } from './math-dropdown'
|
||||
import { TableInserterDropdown } from './table-inserter-dropdown'
|
||||
|
||||
const isMac = /Mac/.test(window.navigator?.platform)
|
||||
|
||||
|
@ -52,6 +53,7 @@ export const ToolbarItems: FC<{
|
|||
)
|
||||
|
||||
const showFigureModal = splitTestVariants['figure-modal'] === 'enabled'
|
||||
const showTableGenerator = splitTestVariants['table-generator'] === 'enabled'
|
||||
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
|
||||
const showGroup = (group: string) => !overflowed || overflowed.has(group)
|
||||
|
||||
|
@ -165,13 +167,7 @@ export const ToolbarItems: FC<{
|
|||
icon="picture-o"
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton
|
||||
id="toolbar-table"
|
||||
label={t('toolbar_insert_table')}
|
||||
command={commands.insertTable}
|
||||
icon="table"
|
||||
hidden
|
||||
/>
|
||||
{showTableGenerator && <TableInserterDropdown />}
|
||||
</div>
|
||||
)}
|
||||
{showGroup('group-list') && (
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
|
||||
import { Command } from '@codemirror/view'
|
||||
import { Command, EditorView } from '@codemirror/view'
|
||||
import {
|
||||
closeSearchPanel,
|
||||
openSearchPanel,
|
||||
|
@ -53,10 +53,16 @@ export const insertFigure: Command = view => {
|
|||
return true
|
||||
}
|
||||
|
||||
export const insertTable: Command = view => {
|
||||
export const insertTable = (view: EditorView, sizeX: number, sizeY: number) => {
|
||||
const { state, dispatch } = view
|
||||
const { pos, suffix } = ensureEmptyLine(state, state.selection.main)
|
||||
const template = `\n${snippets.table}\n${suffix}`
|
||||
const template = `\n\\begin{table}{#{}}
|
||||
\t\\centering
|
||||
\\begin{tabular}{${'c'.repeat(sizeX)}}
|
||||
${('\t\t' + '#{} & #{}'.repeat(sizeX - 1) + '\\\\\n').repeat(
|
||||
sizeY
|
||||
)}\\end{tabular}
|
||||
\\end{table}${suffix}`
|
||||
snippet(template)({ state, dispatch }, { label: 'Table' }, pos, pos)
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -255,5 +255,30 @@ export const toolbarPanel = () => [
|
|||
},
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-table-grid': {
|
||||
borderCollapse: 'separate',
|
||||
tableLayout: 'fixed',
|
||||
fontSize: '6px',
|
||||
cursor: 'pointer',
|
||||
'& td': {
|
||||
outline: '1px solid #E7E9EE',
|
||||
outlineOffset: '-2px',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
|
||||
'&.active': {
|
||||
outlineColor: '#3265B2',
|
||||
background: '#F1F4F9',
|
||||
},
|
||||
},
|
||||
},
|
||||
'.ol-cm-toolbar-table-size-label': {
|
||||
maxWidth: '160px',
|
||||
fontFamily: 'Lato, sans-serif',
|
||||
fontSize: '12px',
|
||||
},
|
||||
'.ol-cm-toolbar-table-grid-popover': {
|
||||
padding: '8px',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
|
|
@ -58,6 +58,7 @@ import { TildeWidget } from './visual-widgets/tilde'
|
|||
import { BeginTheoremWidget } from './visual-widgets/begin-theorem'
|
||||
import { parseTheoremArguments } from '../../utils/tree-operations/theorems'
|
||||
import { IndicatorWidget } from './visual-widgets/indicator'
|
||||
import { TabularWidget } from './visual-widgets/tabular'
|
||||
|
||||
type Options = {
|
||||
fileTreeManager: {
|
||||
|
@ -129,6 +130,8 @@ const hasClosingBrace = (node: SyntaxNode) =>
|
|||
export const atomicDecorations = (options: Options) => {
|
||||
const splitTestVariants = getMeta('ol-splitTestVariants', {})
|
||||
const figureModalEnabled = splitTestVariants['figure-modal'] === 'enabled'
|
||||
const tableGeneratorEnabled =
|
||||
splitTestVariants['table-generator'] === 'enabled'
|
||||
|
||||
const getPreviewByPath = (path: string) =>
|
||||
options.fileTreeManager.getPreviewByPath(path)
|
||||
|
@ -300,6 +303,22 @@ export const atomicDecorations = (options: Options) => {
|
|||
)
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
tableGeneratorEnabled &&
|
||||
nodeRef.type.is('TabularEnvironment')
|
||||
) {
|
||||
if (shouldDecorate(state, nodeRef)) {
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new TabularWidget(
|
||||
nodeRef.node,
|
||||
state.doc.sliceString(nodeRef.from, nodeRef.to)
|
||||
),
|
||||
block: true,
|
||||
}).range(nodeRef.from, nodeRef.to)
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else if (nodeRef.type.is('BeginEnv')) {
|
||||
// the beginning of an environment, with an environment name argument
|
||||
|
|
|
@ -1,8 +1,23 @@
|
|||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import { isUnknownCommandWithName } from '../../../utils/tree-query'
|
||||
import { LineBreakCtrlSym } from '../../../lezer-latex/latex.terms.mjs'
|
||||
|
||||
const isUnknownCommandWithName = (
|
||||
node: SyntaxNode,
|
||||
command: string,
|
||||
getText: (from: number, to: number) => string
|
||||
): boolean => {
|
||||
if (!node.type.is('UnknownCommand')) {
|
||||
return false
|
||||
}
|
||||
const commandNameNode = node.getChild('CtrlSeq')
|
||||
if (!commandNameNode) {
|
||||
return false
|
||||
}
|
||||
const commandName = getText(commandNameNode.from, commandNameNode.to)
|
||||
return commandName === command
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a small amount of typesetting of LaTeX content into a DOM element.
|
||||
* Does **not** typeset math, you **must manually** invoke MathJax after this
|
||||
|
@ -14,8 +29,14 @@ import { LineBreakCtrlSym } from '../../../lezer-latex/latex.terms.mjs'
|
|||
export function typesetNodeIntoElement(
|
||||
node: SyntaxNode,
|
||||
element: HTMLElement,
|
||||
state: EditorState
|
||||
state: EditorState | ((from: number, to: number) => string)
|
||||
) {
|
||||
let getText: (from: number, to: number) => string
|
||||
if (typeof state === 'function') {
|
||||
getText = state
|
||||
} else {
|
||||
getText = state!.sliceDoc.bind(state!)
|
||||
}
|
||||
// If we're a TextArgument node, we should skip the braces
|
||||
const argument = node.getChild('LongArg')
|
||||
if (argument) {
|
||||
|
@ -34,39 +55,39 @@ export function typesetNodeIntoElement(
|
|||
const childNode = childNodeRef.node
|
||||
if (from < childNode.from) {
|
||||
ancestor().append(
|
||||
document.createTextNode(state.sliceDoc(from, childNode.from))
|
||||
document.createTextNode(getText(from, childNode.from))
|
||||
)
|
||||
|
||||
from = childNode.from
|
||||
}
|
||||
if (isUnknownCommandWithName(childNode, '\\textit', state)) {
|
||||
if (isUnknownCommandWithName(childNode, '\\textit', getText)) {
|
||||
pushAncestor(document.createElement('i'))
|
||||
const textArgument = childNode.getChild('TextArgument')
|
||||
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
|
||||
} else if (isUnknownCommandWithName(childNode, '\\textbf', state)) {
|
||||
} else if (isUnknownCommandWithName(childNode, '\\textbf', getText)) {
|
||||
pushAncestor(document.createElement('b'))
|
||||
const textArgument = childNode.getChild('TextArgument')
|
||||
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
|
||||
} else if (isUnknownCommandWithName(childNode, '\\emph', state)) {
|
||||
} else if (isUnknownCommandWithName(childNode, '\\emph', getText)) {
|
||||
pushAncestor(document.createElement('em'))
|
||||
const textArgument = childNode.getChild('TextArgument')
|
||||
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
|
||||
} else if (isUnknownCommandWithName(childNode, '\\texttt', state)) {
|
||||
} else if (isUnknownCommandWithName(childNode, '\\texttt', getText)) {
|
||||
const spanElement = document.createElement('span')
|
||||
spanElement.classList.add('ol-cm-command-texttt')
|
||||
pushAncestor(spanElement)
|
||||
const textArgument = childNode.getChild('TextArgument')
|
||||
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
|
||||
} else if (isUnknownCommandWithName(childNode, '\\and', state)) {
|
||||
} else if (isUnknownCommandWithName(childNode, '\\and', getText)) {
|
||||
const spanElement = document.createElement('span')
|
||||
spanElement.classList.add('ol-cm-command-and')
|
||||
pushAncestor(spanElement)
|
||||
const textArgument = childNode.getChild('TextArgument')
|
||||
from = textArgument?.getChild('LongArg')?.from ?? childNode.to
|
||||
} else if (
|
||||
isUnknownCommandWithName(childNode, '\\corref', state) ||
|
||||
isUnknownCommandWithName(childNode, '\\fnref', state) ||
|
||||
isUnknownCommandWithName(childNode, '\\thanks', state)
|
||||
isUnknownCommandWithName(childNode, '\\corref', getText) ||
|
||||
isUnknownCommandWithName(childNode, '\\fnref', getText) ||
|
||||
isUnknownCommandWithName(childNode, '\\thanks', getText)
|
||||
) {
|
||||
// ignoring these commands
|
||||
from = childNode.to
|
||||
|
@ -79,11 +100,11 @@ export function typesetNodeIntoElement(
|
|||
function leave(childNodeRef) {
|
||||
const childNode = childNodeRef.node
|
||||
if (
|
||||
isUnknownCommandWithName(childNode, '\\and', state) ||
|
||||
isUnknownCommandWithName(childNode, '\\textit', state) ||
|
||||
isUnknownCommandWithName(childNode, '\\textbf', state) ||
|
||||
isUnknownCommandWithName(childNode, '\\emph', state) ||
|
||||
isUnknownCommandWithName(childNode, '\\texttt', state)
|
||||
isUnknownCommandWithName(childNode, '\\and', getText) ||
|
||||
isUnknownCommandWithName(childNode, '\\textit', getText) ||
|
||||
isUnknownCommandWithName(childNode, '\\textbf', getText) ||
|
||||
isUnknownCommandWithName(childNode, '\\emph', getText) ||
|
||||
isUnknownCommandWithName(childNode, '\\texttt', getText)
|
||||
) {
|
||||
const typeSetElement = popAncestor()
|
||||
ancestor().appendChild(typeSetElement)
|
||||
|
@ -96,7 +117,7 @@ export function typesetNodeIntoElement(
|
|||
}
|
||||
)
|
||||
if (from < node.to) {
|
||||
ancestor().append(document.createTextNode(state.sliceDoc(from, node.to)))
|
||||
ancestor().append(document.createTextNode(getText(from, node.to)))
|
||||
}
|
||||
|
||||
return element
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
import { Tabular } from '../../../components/table-generator/tabular'
|
||||
|
||||
export class TabularWidget extends WidgetType {
|
||||
private element: HTMLElement | undefined
|
||||
|
||||
constructor(private node: SyntaxNode, private content: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
this.element = document.createElement('div')
|
||||
this.element.classList.add('ol-cm-tabular')
|
||||
this.element.style.backgroundColor = 'rgba(125, 125, 125, 0.05)'
|
||||
ReactDOM.render(
|
||||
<Tabular view={view} tabularNode={this.node} />,
|
||||
this.element
|
||||
)
|
||||
return this.element
|
||||
}
|
||||
|
||||
eq(widget: TabularWidget): boolean {
|
||||
return (
|
||||
this.node.from === widget.node.from && this.content === widget.content
|
||||
)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
console.debug('destroying tabular widget')
|
||||
if (this.element) {
|
||||
ReactDOM.unmountComponentAtNode(this.element)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -90,6 +90,7 @@
|
|||
MaketitleCtrlSeq,
|
||||
TextColorCtrlSeq,
|
||||
ColorBoxCtrlSeq
|
||||
HLineCtrlSeq
|
||||
}
|
||||
|
||||
@external specialize {EnvName} specializeEnvName from "./tokens.mjs" {
|
||||
|
@ -332,6 +333,9 @@ KnownCommand {
|
|||
} |
|
||||
Maketitle {
|
||||
MaketitleCtrlSeq optionalWhitespace?
|
||||
} |
|
||||
HLine {
|
||||
HLineCtrlSeq optionalWhitespace?
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -71,6 +71,7 @@ import {
|
|||
MaketitleCtrlSeq,
|
||||
TextColorCtrlSeq,
|
||||
ColorBoxCtrlSeq,
|
||||
HLineCtrlSeq,
|
||||
} from './latex.terms.mjs'
|
||||
|
||||
function nameChar(ch) {
|
||||
|
@ -618,6 +619,7 @@ const otherKnowncommands = {
|
|||
'\\maketitle': MaketitleCtrlSeq,
|
||||
'\\textcolor': TextColorCtrlSeq,
|
||||
'\\colorbox': ColorBoxCtrlSeq,
|
||||
'\\hline': HLineCtrlSeq,
|
||||
}
|
||||
// specializer for control sequences
|
||||
// return new tokens for specific control sequences
|
||||
|
|
|
@ -97,7 +97,10 @@ export const Visual = (args: any, { globals: { theme } }: any) => {
|
|||
useMeta({
|
||||
'ol-showSymbolPalette': true,
|
||||
'ol-mathJax3Path': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js',
|
||||
'ol-splitTestVariants': { 'figure-modal': 'enabled' },
|
||||
'ol-splitTestVariants': {
|
||||
'figure-modal': 'enabled',
|
||||
'table-generator': 'enabled',
|
||||
},
|
||||
})
|
||||
|
||||
return <SourceEditor />
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
@import './editor/dictionary.less';
|
||||
@import './editor/compile-button.less';
|
||||
@import './editor/figure-modal.less';
|
||||
@import './editor/table-generator.less';
|
||||
|
||||
@ui-layout-toggler-def-height: 50px;
|
||||
@ui-resizer-size: 7px;
|
||||
|
|
|
@ -0,0 +1,368 @@
|
|||
@table-generator-active-border-color: #666;
|
||||
@table-generator-inactive-border-color: #dedede;
|
||||
@table-generator-focus-border-width: 2px;
|
||||
@table-generator-focus-negative-border-width: -2px;
|
||||
@table-generator-focus-border-color: #97b6e5;
|
||||
@table-generator-selector-hover-color: #3265b2;
|
||||
@table-generator-selector-handle-buffer: 12px;
|
||||
@table-generator-toolbar-shadow-color: #1e253029;
|
||||
@table-generator-toolbar-background: #fff;
|
||||
@table-generator-inactive-border-width: 1px;
|
||||
@table-generator-active-border-width: 1px;
|
||||
|
||||
.table-generator-cell {
|
||||
border: @table-generator-inactive-border-width dashed
|
||||
@table-generator-inactive-border-color;
|
||||
min-width: 40px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.table-generator-cell-border-left {
|
||||
border-left-style: solid;
|
||||
border-left-color: @table-generator-active-border-color;
|
||||
border-left-width: @table-generator-active-border-width;
|
||||
}
|
||||
|
||||
.table-generator-cell-border-right {
|
||||
border-right-style: solid;
|
||||
border-right-color: @table-generator-active-border-color;
|
||||
border-right-width: @table-generator-active-border-width;
|
||||
}
|
||||
|
||||
.table-generator-row-border-top {
|
||||
border-top-style: solid;
|
||||
border-top-color: @table-generator-active-border-color;
|
||||
border-top-width: @table-generator-active-border-width;
|
||||
}
|
||||
|
||||
.table-generator-row-border-bottom {
|
||||
border-bottom-style: solid;
|
||||
border-bottom-color: @table-generator-active-border-color;
|
||||
border-bottom-width: @table-generator-active-border-width;
|
||||
}
|
||||
|
||||
.table-generator-cell.focused {
|
||||
background-color: @blue-10;
|
||||
}
|
||||
|
||||
.table-generator-cell {
|
||||
&.selection-edge-top {
|
||||
--shadow-top: 0 @table-generator-focus-negative-border-width 0
|
||||
@table-generator-focus-border-color;
|
||||
}
|
||||
&.selection-edge-bottom {
|
||||
--shadow-bottom: 0 @table-generator-focus-border-width 0
|
||||
@table-generator-focus-border-color;
|
||||
}
|
||||
&.selection-edge-left {
|
||||
--shadow-left: @table-generator-focus-negative-border-width 0 0
|
||||
@table-generator-focus-border-color;
|
||||
}
|
||||
&.selection-edge-right {
|
||||
--shadow-right: @table-generator-focus-border-width 0 0
|
||||
@table-generator-focus-border-color;
|
||||
}
|
||||
box-shadow: var(--shadow-top, 0 0 0 transparent),
|
||||
var(--shadow-bottom, 0 0 0 transparent),
|
||||
var(--shadow-left, 0 0 0 transparent),
|
||||
var(--shadow-right, 0 0 0 transparent);
|
||||
}
|
||||
|
||||
.table-generator-table {
|
||||
table-layout: fixed;
|
||||
max-width: 80%;
|
||||
margin: 0 auto;
|
||||
cursor: default;
|
||||
|
||||
& td {
|
||||
padding: 0 0.25em;
|
||||
max-width: 200px;
|
||||
|
||||
&.alignment-left {
|
||||
text-align: left;
|
||||
}
|
||||
&.alignment-right {
|
||||
text-align: right;
|
||||
}
|
||||
&.alignment-center {
|
||||
text-align: center;
|
||||
}
|
||||
&.alignment-paragraph {
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
|
||||
.table-generator-selector-cell {
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&.row-selector {
|
||||
width: @table-generator-selector-handle-buffer + 8px;
|
||||
|
||||
&::after {
|
||||
width: 4px;
|
||||
height: calc(100% - 8px);
|
||||
}
|
||||
}
|
||||
|
||||
&.column-selector {
|
||||
height: @table-generator-selector-handle-buffer + 8px;
|
||||
|
||||
&::after {
|
||||
width: calc(100% - 8px);
|
||||
height: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
width: calc(100% - 8px);
|
||||
height: calc(100% - 8px);
|
||||
background-color: @neutral-30;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
background-color: @neutral-40;
|
||||
}
|
||||
|
||||
&.fully-selected::after {
|
||||
background-color: @table-generator-selector-hover-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-generator {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-generator-floating-toolbar {
|
||||
position: absolute;
|
||||
top: -36px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
z-index: 1;
|
||||
border-radius: 4px;
|
||||
width: max-content;
|
||||
background-color: @table-generator-toolbar-background;
|
||||
box-shadow: 0px 2px 4px 0px @table-generator-toolbar-shadow-color;
|
||||
padding: 4px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.table-generator-toolbar-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
color: @neutral-70;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 4px;
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
& > span {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: rgba(47, 58, 76, 0.08);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
background-color: rgba(47, 58, 76, 0.16);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active,
|
||||
&.active {
|
||||
color: inherit;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active,
|
||||
&.active {
|
||||
background-color: transparent;
|
||||
}
|
||||
color: @neutral-40;
|
||||
}
|
||||
}
|
||||
|
||||
.table-generator-button-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
&:not(:first-child) {
|
||||
border-left: 1px solid @neutral-20;
|
||||
padding-left: 8px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-generator-button-menu-popover {
|
||||
.popover-content {
|
||||
padding: 4px;
|
||||
}
|
||||
.list-group {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
& > .arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.table-generator-cell-input {
|
||||
max-width: calc(200px - 0.5em);
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
border: 1px solid @table-generator-toolbar-shadow-color;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-generator-border-options-coming-soon {
|
||||
display: flex;
|
||||
margin: 4px;
|
||||
font-size: 12px;
|
||||
background: @neutral-10;
|
||||
color: @neutral-70;
|
||||
padding: 8px;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
max-width: 240px;
|
||||
|
||||
& .info-icon {
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-generator-toolbar-dropdown-toggle {
|
||||
border: 1px solid @neutral-60;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
min-width: 120px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: @font-family-sans-serif;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
background-color: #f2f2f2;
|
||||
color: @neutral-40;
|
||||
}
|
||||
}
|
||||
|
||||
.table-generator-toolbar-dropdown-popover {
|
||||
max-width: 300px;
|
||||
|
||||
.popover-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
& > .arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.table-generator-toolbar-dropdown-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 200px;
|
||||
|
||||
& > button {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
column-gap: 8px;
|
||||
align-self: stretch;
|
||||
padding: 12px 8px;
|
||||
font-family: @font-family-sans-serif;
|
||||
|
||||
.table-generator-button-label {
|
||||
align-self: stretch;
|
||||
flex: 1 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: rgba(47, 58, 76, 0.08);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
background-color: rgba(47, 58, 76, 0.16);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active,
|
||||
&.active {
|
||||
color: inherit;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active,
|
||||
&.active {
|
||||
background-color: transparent;
|
||||
}
|
||||
color: @neutral-40;
|
||||
}
|
||||
}
|
||||
|
||||
& > hr {
|
||||
background: @neutral-20;
|
||||
margin: 2px 8px;
|
||||
display: block;
|
||||
box-sizing: content-box;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
|
@ -1711,6 +1711,8 @@
|
|||
"toolbar_insert_table": "Insert Table",
|
||||
"toolbar_numbered_list": "Numbered List",
|
||||
"toolbar_redo": "Redo",
|
||||
"toolbar_table_insert_size_table": "Insert __size__ table",
|
||||
"toolbar_table_insert_table_lowercase": "Insert table",
|
||||
"toolbar_toggle_symbol_palette": "Toggle Symbol Palette",
|
||||
"toolbar_undo": "Undo",
|
||||
"tooltip_hide_filetree": "Click to hide the file-tree",
|
||||
|
|
Loading…
Reference in a new issue