diff --git a/backend/locales/en.json b/backend/locales/en.json index 4e4a87a38..c5bc6153b 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1763,6 +1763,7 @@ "editor.markup.insertHorizontalBar": "Insert Horizontal Bar", "editor.markup.insertLink": "Insert Link", "editor.markup.insertMathExpression": "Insert Math Expression", + "editor.markup.editTable": "Edit Table", "editor.markup.insertTable": "Insert Table", "editor.markup.insertTabset": "Insert Tabset", "editor.markup.insertVideoAudio": "Insert Video / Audio", @@ -1912,6 +1913,7 @@ "editor.tableEditor.alignLeft": "Left", "editor.tableEditor.alignRight": "Right", "editor.tableEditor.bodyCell": "Row {row}, column {column}", + "editor.tableEditor.compact": "Compact", "editor.tableEditor.headerCell": "Heading of column {column}", "editor.tableEditor.markdown": "Markdown", "editor.tableEditor.pasteHint": "Paste a table from a spreadsheet into any cell to fill the grid.", diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index 25d23c190..ce8f584a6 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -314,6 +314,7 @@ import { dialog } from '@/composables/dialog' import { notify } from '@/composables/notify' import { assetPath } from '@/helpers/assets' import { blockMarkdown } from '@/helpers/blocks' +import { findEditableTables } from '@/helpers/markdownTable' import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue' import EditorEmojiMenu from '@/components/EditorEmojiMenu.vue' @@ -369,6 +370,8 @@ let editor let md /** Where the paste listener ended up, so it can be taken off the same node. See the note in onMounted. */ let pasteCaptureNode = null +/** The "Edit Table" lens provider, which is registered against the language rather than this editor. */ +let tableLensProvider = null const monacoRef = ref(null) const editorPreviewContainerRef = ref(null) @@ -532,20 +535,66 @@ function insertBlockClb(markdown) { function insertTable() { siteStore.$patch({ - overlay: 'TableEditor' + overlay: 'TableEditor', + overlayOpts: {} }) } /** - * The table the overlay built, at the cursor. + * The same overlay, over a table already in the page — what the "Edit Table" lens above one does. * - * Kept on its own line: a table only parses as one when its first row starts a line, so inserting into - * the middle of a sentence has to break out of it. The blank line after is what separates it from - * whatever the cursor was sitting in front of. + * The lens carries only the line it was drawn on, and the table is looked up again here rather than + * taken from the lens: a lens is provided once and then moves with the text, so its argument is a line + * number from whenever the document last settled. Reading the table back out of the model at the moment + * of the click is what keeps the range and the source it hands over describing the same thing. */ -function insertTableClb(markdown) { +function editTable(line) { + const tables = findEditableTables(editor.getModel().getValue()) + const table = tables.find((entry) => entry.startLine <= line && line <= entry.endLine) + if (!table) { + return + } + siteStore.$patch({ + overlay: 'TableEditor', + overlayOpts: { + source: table.source, + startLine: table.startLine, + endLine: table.endLine + } + }) +} + +/** + * The table the overlay built: over the lines it was read from, or at the cursor when it is a new one. + * + * A new table is kept on its own line — a table only parses as one when its first row starts a line, so + * inserting into the middle of a sentence has to break out of it, and the blank line after is what + * separates it from whatever the cursor was sitting in front of. + * + * An edited one replaces exactly the lines it occupied, so nothing around it moves and one undo takes + * the whole table back. The cursor lands at the top of it rather than staying wherever it was, which may + * be inside the text that was just replaced. + */ +function insertTableClb({ markdown, replace = null }) { + const model = editor.getModel() + if (replace) { + editor.executeEdits('table', [ + { + range: new Range( + replace.startLine, + 1, + replace.endLine, + model.getLineMaxColumn(replace.endLine) + ), + text: markdown + } + ]) + editor.setPosition(new Position(replace.startLine, 1)) + editor.focus() + return + } const position = editor.getPosition() - const line = editor.getModel().getLineContent(position.lineNumber) + const line = model.getLineContent(position.lineNumber) const before = line.slice(0, position.column - 1).trim().length > 0 ? '\n\n' : '' const after = line.slice(position.column - 1).trim().length > 0 ? '\n\n' : '\n' insertAtCursor({ content: `${before}${markdown}${after}` }) @@ -1037,6 +1086,36 @@ onMounted(async () => { // TODO: For debugging, remove at some point... window.edInstance = editor + /* + "Edit Table" over every table in the page, which opens the table editor on that table. + + A code lens rather than a context-menu action: the offer has to be visible to be found, and a table + in markdown source is exactly the thing an author does not want to edit by hand. It appears only over + the tables the overlay can actually hold -- `findEditableTables` says which -- because offering it + over a table with a multi-line cell or a rowspan would be offering to flatten it. + + The command is registered on this editor rather than globally (`monaco.editor.registerCommand`), + which is what gives `editor.addCommand` an id to hand the lens. The PROVIDER is per-language and + process-wide, so it has to be disposed with the component or a second visit to the editor would draw + every lens twice. + */ + const editTableCommand = editor.addCommand(0, (_accessor, line) => editTable(line)) + tableLensProvider = monaco.languages.registerCodeLensProvider('markdown', { + provideCodeLenses(model) { + return { + lenses: findEditableTables(model.getValue()).map((table) => ({ + range: new Range(table.startLine, 1, table.startLine, 1), + command: { + id: editTableCommand, + title: t('editor.markup.editTable'), + arguments: [table.startLine] + } + })), + dispose() {} + } + } + }) + // -> Define Formatting Actions editor.addAction({ contextMenuGroupId: 'markdown.extension.editing', @@ -1269,6 +1348,8 @@ onBeforeUnmount(() => { pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true) monacoRef.value?.removeEventListener('dragover', onEditorDragOver) monacoRef.value?.removeEventListener('drop', onEditorDrop) + // -> Registered against the markdown language, not this editor, so nothing else takes it down + tableLensProvider?.dispose() // -> Before the editor goes: the binding is holding the model, and leaving the room is what takes // this author's avatar out of everyone else's header stopCollabSession() diff --git a/frontend/src/components/TableEditorOverlay.vue b/frontend/src/components/TableEditorOverlay.vue index 5c0124990..41736f781 100644 --- a/frontend/src/components/TableEditorOverlay.vue +++ b/frontend/src/components/TableEditorOverlay.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss index 2abaae8ce..b092d6fec 100644 --- a/frontend/src/css/_page-contents.scss +++ b/frontend/src/css/_page-contents.scss @@ -21,8 +21,8 @@ `block-plantuml` -- and a block styles itself inside its own shadow root, which nothing here reaches. The measurements follow what the documentation platforms have converged on -- 16px body text, headings - at 600 with far more space above than below, ruled h1/h2, tinted code and table headers, a left-ruled - quote -- because those conventions are what makes a long page scannable, not because any one of them + at 600 with far more space above than below, ruled h1/h2, tinted code, a table headed by a dark bar + in a panel of its own, a left-ruled quote -- because those conventions are what makes a long page scannable, not because any one of them is copied. Colours go through custom properties declared on the root block, for three reasons: dark mode is @@ -69,11 +69,50 @@ /* Two surfaces. `code` is the panel behind a code block -- opaque, because the preview pane sits on grey and a translucent tint would disappear into it -- and `alt` is the wash behind an inline code - span or a table header, translucent so it works on either. + span or a row header inside a table body, translucent so it works on either. The head of a table is + no longer one of them; see `--content-table-head` below. */ --content-surface-code: #f4f6f8; --content-surface-alt: rgba(0, 0, 0, 0.04); + /* + The head of a table, drawn as a title bar: the graded near-black the app's own dialog headers use + (`.card-header` in `_base.scss`), so a table reads as a panel with a bar on top rather than as a + grid with tinted text in its first row. Literal values, because this file takes no SCSS palette. + + The near end of the grade -- the one the radial starts from, at bottom right -- is a step up the + app's dark ramp from what `.card-header` starts at: `$dark-2` here against its `$dark-3`. Against a + white page the whole bar reads darker than it measures, and at `$dark-3` the grade had nowhere to go + but flat. On a dark page it does not need the help, so the dark block puts `$dark-3` back. + + The far end and the ink are the same in both themes, because a bar this dark is already the chrome + it is meant to be on either. + */ + --content-table-head: #292f39; + --content-table-head-grade: #0d1117; + /* + Dimmed rather than pure white: the head labels the columns, and at full strength white on + near-black it reads louder than the content it introduces. + */ + --content-table-head-ink: rgba(255, 255, 255, 0.82); + /* -> The grid lines INSIDE the bar, which `--content-rule` cannot draw: a 12%-black hairline is + invisible on near-black */ + --content-table-head-rule: rgba(255, 255, 255, 0.12); + /* + The body's two row colours. The plain row is tinted too, faintly: on the white article column an + untinted row IS the page, and a table whose rows are half page and half band reads as stripes drawn + on the article rather than as a panel of its own. Washes rather than fixed greys, so the pair holds + together on the editor's grey preview pane as well as on a white page. + */ + --content-table-row: rgba(0, 0, 0, 0.03); + --content-table-row-alt: rgba(0, 0, 0, 0.06); + /* + And a shadow under the whole thing, so the panel sits ON the page rather than being drawn into it. + Two layers: a tight one that reads as the edge lifting, and a wide soft one that reads as the drop. + Kept faint -- a table is content, not a dialog, and it appears as often as every few paragraphs. + */ + --content-table-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 3px 10px rgba(0, 0, 0, 0.05); + --content-mark: #fdf1a0; /* The box behind the tick of a done task-list item; the tick itself is white in both themes */ @@ -156,6 +195,22 @@ --content-surface-code: #161b22; --content-surface-alt: rgba(255, 255, 255, 0.07); + /* + The head keeps its colours -- see the light block -- but the shadow under the table cannot: a 5% + black drop is invisible on a dark page. Deepened, and no wider, so it still reads as the panel + being lifted rather than as a glow around it. + */ + --content-table-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 3px 10px rgba(0, 0, 0, 0.32); + + /* -> Lifted off the page rather than sunk into it, which is the same intent as the light theme's + pair: the dark page is already darker than anything a row could be tinted towards */ + --content-table-row: rgba(255, 255, 255, 0.02); + --content-table-row-alt: rgba(255, 255, 255, 0.055); + + /* -> Back to `.card-header`'s own near end: on a dark page the bar reads as dark as it measures, + and the extra step the light theme needs would leave the head sitting above the page */ + --content-table-head: #1e232a; + --content-mark: #6b5d13; /* @@ -1087,39 +1142,145 @@ `display: block` is what makes that possible -- a table box cannot scroll -- and the rows and cells still lay out as a table inside it through anonymous table boxes. `width: max-content` keeps the table its natural width up to the column's, so a narrow table does not stretch. + + That block box is also what lets the table round its corners at all: `border-radius` is defined to + have no effect on a table with collapsed borders, but this is a block that HAPPENS to contain a + table, so the radius applies to it -- and the scrolling already makes it a clipping box, so the + head's fill and the last row are cut to the arc with nothing else to arrange. */ table { display: block; width: max-content; max-width: 100%; margin: 1.5em 0; + /* -> The perimeter, so the corners can be round; the cells draw only the grid inside it */ + border: 1px solid var(--content-rule); + border-radius: 8px; border-collapse: collapse; + box-shadow: var(--content-table-shadow); font-size: 0.9375em; overflow-x: auto; } + /* + Interior rules only. A cell border all the way round would double the table's own at the perimeter, + and at the corners it would be a straight line cut off by the radius -- a notch rather than a curve. + */ th, td { padding: 0.5em 0.8em; - border: 1px solid var(--content-rule); + border-right: 1px solid var(--content-rule); + border-bottom: 1px solid var(--content-rule); text-align: left; vertical-align: top; } + tr > :last-child { + border-right: 0; + } + + /* -> `tbody:last-child`, because a MultiMarkdown table can have several: only the bottom of the last + one is the bottom of the table, and the others need their rule to keep the grid closed */ + tbody:last-child tr:last-child > * { + border-bottom: 0; + } + + /* + A multi-line cell holds block content -- its text arrives wrapped in a paragraph, and may be a list + or a fence -- and the outer margins of that content are the cell's padding's business, exactly as + they are the container's at the top level. Without this a multi-line row stands 1.15em taller than + the single-line rows around it. + */ + th > :first-child, + td > :first-child { + margin-top: 0; + } + th > :last-child, + td > :last-child { + margin-bottom: 0; + } + th { background-color: var(--content-surface-alt); font-weight: 600; } - /* Banding, faint enough to guide the eye across a wide row without striping the page */ - tbody tr:nth-child(even) > td { - background-color: rgba(0, 0, 0, 0.02); + /* + The head as a title bar. The gradient goes on the row group, not on each cell: `at bottom right` of + a cell is a wash per column, which reads as banding across the head instead of one bar under the + heading. Cells then have to give up the `th` wash above, or they would tint over it. + + Only `thead` -- a row header inside the body (author HTML; markdown has no syntax for one) keeps + the plain wash, since a dark bar down the side of a table is a different thing altogether. + */ + thead { + background-color: var(--content-table-head); + background-image: radial-gradient( + at bottom right, + var(--content-table-head), + var(--content-table-head-grade) + ); + } + + thead th { + border-right-color: var(--content-table-head-rule); + border-bottom-color: var(--content-table-head-rule); + background-color: transparent; + color: var(--content-table-head-ink); } - @at-root .body--dark & { - tbody tr:nth-child(even) > td { - background-color: rgba(255, 255, 255, 0.03); - } + /* + Banding: BOTH row colours are stated, so the table is a tinted panel with a band in it rather than + a white page with every other row tinted. On a white article column an untinted row was the page + itself, which left the table's own extent to the border alone. + + Faint enough that the band still guides the eye across a wide row without striping the page, and + the two are a step apart rather than a contrast -- the head is what the eye lands on. + */ + tbody > tr > td { + background-color: var(--content-table-row); + } + + tbody > tr:nth-child(even) > td { + background-color: var(--content-table-row-alt); + } + + /* + `{.table-leading-col}`, written on the line under a table -- which is where `markdown-it-attrs` reads + a block's attributes from -- for a table whose first column says what its row IS rather than holding + one of its values: a setting name, a key, a term. It is set in the head's own weight, so the column + reads as the label for its row that it is. + + Inline code comes with it and needs no rule of its own: `code` declares no weight, so a `` `key` `` + in that column inherits this one -- which matters, because a label like that is usually written as + code in the first place. + */ + table.table-leading-col > tbody > tr > :first-child { + font-weight: 600; + } + + /* + `{.table-code-nohighlight}`, attached the same way, for a table where nearly every cell holds inline + code -- a table of settings, of keys, of tag names. The wash is there to pick a code span out of a + sentence; a column of them is not a sentence, and forty chips in a grid read as noise over the row + colours rather than as emphasis. The monospace face is what says "code" there. + + The padding goes with the wash, as it does inside a heading: it exists to keep the tint off the + glyphs, and left behind on its own it is a gap in the middle of a phrase with nothing to explain it. + */ + table.table-code-nohighlight code { + padding: 0; + background: none; + } + + /* + `{.table-vertical-middle}`, again on the line under the table. Cells top-align by default, which is + what a row of prose wants -- the first lines of each cell line up and the row reads across. A row of + single lines next to one tall cell does not: an image, a `^^` rowspan or one long wrapped sentence + leaves the short cells hanging at the top of the row with the gap under them. + */ + table.table-vertical-middle > tbody > tr > * { + vertical-align: middle; } caption { @@ -1293,6 +1454,19 @@ --content-ink-muted: #333; --content-surface-code: #fff; --content-surface-alt: #fff; + /* + Which goes double for a table's head: a solid near-black bar is the most ink anything in content + asks for, and on paper it buys nothing -- the bold text and the rules already say "head". Its + lines go back to the hairline every other cell prints, and the shadow goes entirely. + */ + --content-table-head: #fff; + --content-table-head-grade: #fff; + --content-table-head-ink: #000; + --content-table-head-rule: var(--content-rule); + --content-table-shadow: none; + /* -> And the banding, which on paper is grey ink over every other row for no gain */ + --content-table-row: transparent; + --content-table-row-alt: transparent; font-size: 11pt; line-height: 1.55; diff --git a/frontend/src/helpers/markdownTable.js b/frontend/src/helpers/markdownTable.js new file mode 100644 index 000000000..a98a327c9 --- /dev/null +++ b/frontend/src/helpers/markdownTable.js @@ -0,0 +1,248 @@ +/** + * Markdown tables, written and read back. + * + * Both directions live here because they have to agree on one shape: `TableEditorOverlay` writes a table + * with `buildTable`, the markdown editor finds one with `findEditableTables` so it can offer to edit it, + * and the overlay reads it back with `parseTable`. Two definitions of "what a table looks like" would + * mean a table that came back out of the editor differing from the one that went in, in whitespace + * nobody asked to change. + * + * A table is a grid of one-line strings plus an alignment per column, which is the only formatting the + * syntax carries. Everything else a MultiMarkdown table can do -- a multi-line cell, a `^^` rowspan, a + * second body, no header at all -- has nowhere to go in that model, which is what `findEditableTables` + * is for: it offers only the tables that survive the round trip. + */ + +/** Narrowest a delimiter cell can be and still show its colons: `:-:`. */ +const MIN_WIDTH = 3 + +/** Cycled through by the editor's per-column button, in this order. */ +export const ALIGNMENTS = ['left', 'center', 'right'] + +/** A delimiter row's cell, and nothing else: dashes, with a colon at either end or both. */ +const DELIMITER_CELL = /^:?-+:?$/ + +/** The opening or closing line of a fenced block, indented up to the three spaces markdown allows. */ +const FENCE = /^ {0,3}(`{3,}|~{3,})/ + +/** + * A cell as it is written into a row. + * + * Its own `|` is escaped, and a newline -- which only a paste can produce -- becomes a space, because + * there is no way to write either into a table row. + */ +export function escapeCell(value) { + return (value ?? '').replaceAll('|', '\\|').replaceAll(/\s+/g, ' ').trim() +} + +/** + * The table as markdown. + * + * `compact` writes each cell as it is; without it every column is padded to its widest cell, which + * lines the columns up under their headers and costs a rewrite of the whole block on every edit. Either + * way the delimiter row is as wide as the column, so the two stay in step. + */ +export function buildTable({ align, rows }, { compact = true } = {}) { + const cells = rows.map((row) => align.map((_, colIndex) => escapeCell(row[colIndex]))) + const widths = align.map((_, colIndex) => + compact ? MIN_WIDTH : Math.max(MIN_WIDTH, ...cells.map((row) => row[colIndex].length)) + ) + const line = (row) => + `| ${row.map((cell, i) => (compact ? cell : cell.padEnd(widths[i]))).join(' | ')} |` + const delimiters = align.map((value, i) => { + const dashes = '-'.repeat(widths[i] - (value === 'center' ? 2 : 1)) + switch (value) { + case 'center': { + return `:${dashes}:` + } + case 'right': { + return `${dashes}:` + } + default: { + return `:${dashes}` + } + } + }) + return [line(cells[0]), line(delimiters), ...cells.slice(1).map((row) => line(row))].join('\n') +} + +/** + * The raw text between one row's pipes, unsplit and untrimmed -- what `isCompact` measures. + * + * An escaped `\|` is not a separator, and the outer pipes markdown allows on each side leave an empty + * segment at each end which is not a column. + */ +function rawCells(line) { + const cells = [] + let cell = '' + for (let index = 0; index < line.length; index++) { + if (line[index] === '\\' && line[index + 1] === '|') { + cell += '|' + index++ + continue + } + if (line[index] === '|') { + cells.push(cell) + cell = '' + continue + } + cell += line[index] + } + cells.push(cell) + const trimmed = line.trim() + if (trimmed.startsWith('|') && cells[0].trim() === '') { + cells.shift() + } + if (trimmed.endsWith('|') && cells.length > 0 && cells.at(-1).trim() === '') { + cells.pop() + } + return cells +} + +/** One row's cells, as the values they hold. */ +function splitRow(line) { + return rawCells(line).map((cell) => cell.trim()) +} + +/** + * The alignments a delimiter row states, or `null` if the line is not one. + * + * This is also what tells a table's header row from a paragraph that happens to hold a pipe: a table is + * a line followed by one of these. + * + * A cell with no colon means no alignment, which renders as left and is stored as `left` -- the editor + * has no fourth state to keep it in, so writing such a table back states the colon it left out. + */ +function parseDelimiters(line) { + if (!line?.includes('|') && !/^ {0,3}:?-+:?$/.test(line ?? '')) { + return null + } + const cells = splitRow(line) + if (cells.length === 0 || !cells.every((cell) => DELIMITER_CELL.test(cell))) { + return null + } + return cells.map((cell) => { + if (cell.startsWith(':') && cell.endsWith(':')) { + return 'center' + } + return cell.endsWith(':') ? 'right' : 'left' + }) +} + +/** + * Whether the source was written compact, so that reading a table and writing it back does not reformat + * it on the author's behalf. + * + * Measured on the whitespace rather than by building the table both ways and comparing: a hand-written + * table is compact whether it was written `|a|b|` or `| a | b |`, and neither is what either branch of + * `buildTable` emits exactly. What padding looks like is a cell holding spaces beyond the single one + * that keeps the text off the pipe -- and, in a delimiter row, a run of dashes longer than the three a + * compact table ever needs. + */ +function isCompact(lines) { + const unpadded = (raw) => { + const value = raw.trim() + return value === '' ? raw.length <= 2 : raw === value || raw === ` ${value} ` + } + return lines.every((line, index) => + rawCells(line).every((raw) => unpadded(raw) && (index !== 1 || raw.trim().length <= MIN_WIDTH)) + ) +} + +/** + * A table's source as the editor's own state: `rows[0]` is the header, one alignment per column. + * + * The column count is the widest row rather than the delimiter row's, so a body row carrying more cells + * than the header -- which markdown itself drops on the floor -- arrives as a column the author can see + * and deal with, instead of being deleted by opening the editor. + */ +export function parseTable(source) { + const lines = source.split('\n').filter((line) => line.trim() !== '') + const align = parseDelimiters(lines[1]) ?? [] + const rows = [splitRow(lines[0] ?? ''), ...lines.slice(2).map(splitRow)] + const columns = Math.max(1, align.length, ...rows.map((row) => row.length)) + return { + align: Array.from({ length: columns }, (_, i) => align[i] ?? 'left'), + rows: rows.map((row) => Array.from({ length: columns }, (_, i) => row[i] ?? '')), + compact: isCompact(lines) + } +} + +/** + * Whether the table ending at `last` carries on below it. + * + * A MultiMarkdown table may have a second body, separated from the first by one blank line, and it is + * part of the same table -- so a lens over the first half would offer to replace a piece of a table and + * leave the rest of it stranded. Told apart from an ordinary table that merely follows this one by + * whether that next row brings a delimiter row of its own; a paragraph holding a pipe reads as a + * continuation too, and costs only the offer to edit. + */ +function continuesBelow(lines, last) { + if ((lines[last + 1] ?? '').trim() !== '' || !lines[last + 2]?.includes('|')) { + return false + } + return !parseDelimiters(lines[last + 3]) +} + +/** + * Every table in the source that the table editor can hold, in the order they appear. + * + * Line numbers are 1-based, to be handed straight to the editor. + * + * What is deliberately left out: a table inside a fenced block, which is a code sample and not a table; + * a headerless table, whose first line is already the delimiter row; and a table using a multi-line + * cell, a `^^` rowspan or a second body. The editor's model has no place to keep any of those, so + * offering to edit one would be offering to throw it away. + */ +export function findEditableTables(text) { + const lines = text.split('\n') + const tables = [] + let fence = null + + for (let index = 0; index < lines.length; index++) { + const edge = FENCE.exec(lines[index]) + if (fence) { + if (edge && edge[1][0] === fence[0] && edge[1].length >= fence.length) { + fence = null + } + continue + } + if (edge) { + fence = edge[1] + continue + } + + // -> A header row and then a delimiter row. A delimiter row FIRST is a headerless table + if (!lines[index].includes('|') || parseDelimiters(lines[index])) { + continue + } + if (!parseDelimiters(lines[index + 1])) { + continue + } + + const start = index + let last = index + 1 + while ((lines[last + 1] ?? '').trim() !== '' && lines[last + 1].includes('|')) { + last++ + } + // -> Whatever this block turns out to be, no line of it starts another table + index = last + + const body = lines.slice(start + 2, last + 1) + if ( + body.some((line) => line.trimEnd().endsWith('\\')) || + body.some((line) => splitRow(line).includes('^^')) || + continuesBelow(lines, last) + ) { + continue + } + + tables.push({ + startLine: start + 1, + endLine: last + 1, + source: lines.slice(start, last + 1).join('\n') + }) + } + + return tables +} diff --git a/frontend/src/renderers/markdown.js b/frontend/src/renderers/markdown.js index 7e8f48431..81cfd2bc0 100644 --- a/frontend/src/renderers/markdown.js +++ b/frontend/src/renderers/markdown.js @@ -284,7 +284,26 @@ export class MarkdownRenderer { this.md.use(mdUnderline) } - if (config.mdmultiTable) { + /* + MultiMarkdown tables: multi-line cells, `^^` rowspans, and a table with no header row. + + `multimdTable` is the name the setting has everywhere else -- `base.yml`, `models/sites.ts`, the + editor's config overlay -- and this read it as `mdmultiTable`, so the plugin was never installed + and none of those three features has ever worked. + + The shim is what makes fixing that safe. `markdown-it-multimd-table` merges its options with + `md.utils.assign`, which markdown-it dropped in 14; on 15 the `use()` call throws + `md.utils.assign is not a function`, out of the CONSTRUCTOR -- so with the name corrected and + nothing else, every render in the app would have died instead. 4.2.3 is the last release of the + plugin (Aug 2023) and there is no fixed version to move to. + + `md.utils` is one object shared by every markdown-it instance, so this restores the helper + process-wide rather than for this renderer. That is as narrow as it can be made and it is benign: + the removed helper WAS this, minus a guard against non-object sources that the one call site + cannot hit. + */ + if (config.multimdTable) { + this.md.utils.assign ??= Object.assign this.md.use(mdMultiTable, { multiline: true, rowspan: true, headerless: true }) }