Merge pull request #14548 from overleaf/mj-early-exit-table-rendering

[visual] Show code when table generator fails rendering

GitOrigin-RevId: 6c1908b0c68cc965e445736f0c320f322d23c988
This commit is contained in:
Mathias Jakobsen 2023-09-04 09:02:31 +01:00 committed by Copybot
parent 601365bcc6
commit 8e6d6f8689
7 changed files with 125 additions and 56 deletions

View file

@ -3,9 +3,9 @@ import { Positions, TableData, TableRenderingError } from '../tabular'
import { import {
CellPosition, CellPosition,
CellSeparator, CellSeparator,
ParsedTableData,
RowPosition, RowPosition,
RowSeparator, RowSeparator,
generateTable,
parseTableEnvironment, parseTableEnvironment,
} from '../utils' } from '../utils'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
@ -34,34 +34,12 @@ const TableContext = createContext<
>(undefined) >(undefined)
export const TableProvider: FC<{ export const TableProvider: FC<{
tableData: ParsedTableData
tableNode: SyntaxNode | null
tabularNode: SyntaxNode tabularNode: SyntaxNode
view: EditorView view: EditorView
tableNode: SyntaxNode | null }> = ({ tableData, children, tableNode, tabularNode, view }) => {
}> = ({ tabularNode, view, children, tableNode }) => {
try { try {
const tableData = generateTable(tabularNode, view.state)
// 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 = { const positions: Positions = {
cells: tableData.cellPositions, cells: tableData.cellPositions,
columnDeclarations: tableData.specification, columnDeclarations: tableData.specification,

View file

@ -1,6 +1,6 @@
import { SyntaxNode } from '@lezer/common' import { SyntaxNode } from '@lezer/common'
import { FC, useEffect } from 'react' import { FC, useEffect } from 'react'
import { CellPosition, RowPosition } from './utils' import { CellPosition, ParsedTableData, RowPosition } from './utils'
import { Toolbar } from './toolbar/toolbar' import { Toolbar } from './toolbar/toolbar'
import { Table } from './table' import { Table } from './table'
import { import {
@ -182,7 +182,8 @@ export const Tabular: FC<{
tabularNode: SyntaxNode tabularNode: SyntaxNode
view: EditorView view: EditorView
tableNode: SyntaxNode | null tableNode: SyntaxNode | null
}> = ({ tabularNode, view, tableNode }) => { parsedTableData: ParsedTableData
}> = ({ tabularNode, view, tableNode, parsedTableData }) => {
return ( return (
<ErrorBoundary <ErrorBoundary
fallbackRender={() => ( fallbackRender={() => (
@ -193,8 +194,9 @@ export const Tabular: FC<{
<TabularProvider> <TabularProvider>
<TableProvider <TableProvider
tabularNode={tabularNode} tabularNode={tabularNode}
view={view} tableData={parsedTableData}
tableNode={tableNode} tableNode={tableNode}
view={view}
> >
<SelectionContextProvider> <SelectionContextProvider>
<EditingContextProvider> <EditingContextProvider>

View file

@ -315,17 +315,19 @@ function parseTabularBody(
return body return body
} }
export function generateTable( export type ParsedTableData = {
node: SyntaxNode,
state: EditorState
): {
table: TableData table: TableData
cellPositions: CellPosition[][] cellPositions: CellPosition[][]
specification: { from: number; to: number } specification: { from: number; to: number }
rowPositions: RowPosition[] rowPositions: RowPosition[]
rowSeparators: RowSeparator[] rowSeparators: RowSeparator[]
cellSeparators: CellSeparator[][] cellSeparators: CellSeparator[][]
} { }
export function generateTable(
node: SyntaxNode,
state: EditorState
): ParsedTableData {
const specification = node const specification = node
.getChild('BeginEnv') .getChild('BeginEnv')
?.getChild('TextArgument') ?.getChild('TextArgument')

View file

@ -65,6 +65,7 @@ import { IndicatorWidget } from './visual-widgets/indicator'
import { TabularWidget } from './visual-widgets/tabular' import { TabularWidget } from './visual-widgets/tabular'
import { nextSnippetField, pickedCompletion } from '@codemirror/autocomplete' import { nextSnippetField, pickedCompletion } from '@codemirror/autocomplete'
import { skipPreambleWithCursor } from './skip-preamble-cursor' import { skipPreambleWithCursor } from './skip-preamble-cursor'
import { TableRenderingErrorWidget } from './visual-widgets/table-rendering-error'
type Options = { type Options = {
fileTreeManager: { fileTreeManager: {
@ -323,20 +324,33 @@ export const atomicDecorations = (options: Options) => {
nodeRef.node, nodeRef.node,
'TableEnvironment' 'TableEnvironment'
) )
decorations.push( const tabularWidget = new TabularWidget(
Decoration.replace({ nodeRef.node,
widget: new TabularWidget( state.doc.sliceString(
nodeRef.node, (tableNode ?? nodeRef).from,
state.doc.sliceString( (tableNode ?? nodeRef).to
(tableNode ?? nodeRef).from, ),
(tableNode ?? nodeRef).to tableNode,
), state
tableNode
),
block: true,
}).range(nodeRef.from, nodeRef.to)
) )
return false
if (tabularWidget.isValid()) {
decorations.push(
Decoration.replace({
widget: tabularWidget,
block: true,
}).range(nodeRef.from, nodeRef.to)
)
return false
} else {
// Show error message
decorations.push(
Decoration.widget({
widget: new TableRenderingErrorWidget(tableNode),
block: true,
}).range(nodeRef.from, nodeRef.from)
)
}
} }
} }
} else if (nodeRef.type.is('BeginEnv')) { } else if (nodeRef.type.is('BeginEnv')) {

View file

@ -1,7 +1,7 @@
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
export const tableGeneratorTheme = EditorView.baseTheme({ export const tableGeneratorTheme = EditorView.baseTheme({
'&dark .table-generator': { '&dark .table-generator-container': {
'--table-generator-active-border-color': '#ccc', '--table-generator-active-border-color': '#ccc',
'--table-generator-coming-soon-background-color': '#41464f', '--table-generator-coming-soon-background-color': '#41464f',
'--table-generator-coming-soon-color': '#fff', '--table-generator-coming-soon-color': '#fff',
@ -23,6 +23,7 @@ export const tableGeneratorTheme = EditorView.baseTheme({
'rgba(125,125,125,0.3)', 'rgba(125,125,125,0.3)',
'--table-generator-toolbar-dropdown-disabled-color': '#999', '--table-generator-toolbar-dropdown-disabled-color': '#999',
'--table-generator-toolbar-shadow-color': '#1e253029', '--table-generator-toolbar-shadow-color': '#1e253029',
'--table-generator-error-background': '#F1F4F9',
}, },
'&light .table-generator': { '&light .table-generator': {
@ -46,6 +47,7 @@ export const tableGeneratorTheme = EditorView.baseTheme({
'--table-generator-toolbar-dropdown-disabled-background': '#f2f2f2', '--table-generator-toolbar-dropdown-disabled-background': '#f2f2f2',
'--table-generator-toolbar-dropdown-disabled-color': 'var(--neutral-40)', '--table-generator-toolbar-dropdown-disabled-color': 'var(--neutral-40)',
'--table-generator-toolbar-shadow-color': '#1e253029', '--table-generator-toolbar-shadow-color': '#1e253029',
'--table-generator-error-background': '#F1F4F9',
}, },
'.table-generator': { '.table-generator': {
@ -401,20 +403,24 @@ export const tableGeneratorTheme = EditorView.baseTheme({
}, },
}, },
'.ol-cm-environment-table.table-generator-error-container, .ol-cm-environment-table.ol-cm-tabular':
{
background: 'rgba(125, 125, 125, 0.05)',
},
'.table-generator-error': { '.table-generator-error': {
background: background: 'var(--table-generator-error-background)',
'linear-gradient(0deg, #f9f1f1, #f9f1f1), linear-gradient(0deg, #f5beba, #f5beba)',
display: 'flex', display: 'flex',
'justify-content': 'space-between', 'justify-content': 'space-between',
color: 'black', color: 'black',
border: '1px solid #f5beba', border: '1px solid #C3D0E3',
'font-family': 'Lato', 'font-family': 'Lato',
'margin-bottom': '0', margin: '0 16px 0 16px',
'& .table-generator-error-message': { '& .table-generator-error-message': {
flex: '1 0 auto', flex: '1 1 auto',
}, },
'& .table-generator-error-icon': { '& .table-generator-error-icon': {
color: '#b83a33', color: '#3265B2',
'margin-right': '12px', 'margin-right': '12px',
}, },
}, },

View file

@ -0,0 +1,34 @@
import { WidgetType } from '@codemirror/view'
import { SyntaxNode } from '@lezer/common'
export class TableRenderingErrorWidget extends WidgetType {
private hasTableNode: boolean
constructor(tableNode: SyntaxNode | null | undefined) {
super()
this.hasTableNode = Boolean(tableNode)
}
toDOM(): HTMLElement {
const warning = document.createElement('div')
warning.classList.add('table-generator-error', 'alert')
warning.role = 'alert'
const icon = document.createElement('span')
icon.classList.add('table-generator-error-icon')
const iconType = document.createElement('i')
iconType.classList.add('fa', 'fa-info-circle')
icon.appendChild(iconType)
warning.appendChild(icon)
const message = document.createElement('span')
message.classList.add('table-generator-error-message')
message.textContent =
'We couldnt render your table.\nThis could be because some features of this table are not supported in the table preview yet, or due to a LaTeX error in the table code.'
warning.appendChild(message)
const element = document.createElement('div')
element.classList.add('table-generator', 'table-generator-error-container')
element.appendChild(warning)
if (this.hasTableNode) {
element.classList.add('ol-cm-environment-table')
}
return element
}
}

View file

@ -1,27 +1,59 @@
import { EditorView, WidgetType } from '@codemirror/view' import { EditorView, WidgetType } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common' import { SyntaxNode } from '@lezer/common'
import * as ReactDOM from 'react-dom' import * as ReactDOM from 'react-dom'
import { Tabular } from '../../../components/table-generator/tabular' import { Tabular } from '../../../components/table-generator/tabular'
import {
ParsedTableData,
generateTable,
} from '../../../components/table-generator/utils'
export class TabularWidget extends WidgetType { export class TabularWidget extends WidgetType {
private element: HTMLElement | undefined private element: HTMLElement | undefined
private readonly parseResult: ParsedTableData
constructor( constructor(
private tabularNode: SyntaxNode, private tabularNode: SyntaxNode,
private content: string, private content: string,
private tableNode: SyntaxNode | null private tableNode: SyntaxNode | null,
state: EditorState
) { ) {
super() super()
this.parseResult = generateTable(tabularNode, state)
}
isValid() {
for (const row of this.parseResult.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
) {
return false
}
}
if (rowLength !== this.parseResult.table.columns.length) {
return false
}
}
return true
} }
toDOM(view: EditorView) { toDOM(view: EditorView) {
this.element = document.createElement('div') this.element = document.createElement('div')
this.element.classList.add('ol-cm-tabular') this.element.classList.add('ol-cm-tabular')
this.element.style.backgroundColor = 'rgba(125, 125, 125, 0.05)' if (this.tableNode) {
this.element.classList.add('ol-cm-environment-table')
}
ReactDOM.render( ReactDOM.render(
<Tabular <Tabular
view={view} view={view}
tabularNode={this.tabularNode} tabularNode={this.tabularNode}
parsedTableData={this.parseResult}
tableNode={this.tableNode} tableNode={this.tableNode}
/>, />,
this.element this.element
@ -44,6 +76,7 @@ export class TabularWidget extends WidgetType {
<Tabular <Tabular
view={view} view={view}
tabularNode={this.tabularNode} tabularNode={this.tabularNode}
parsedTableData={this.parseResult}
tableNode={this.tableNode} tableNode={this.tableNode}
/>, />,
this.element this.element