feat: edit markdown tables in editor + various fixes

scarlett
NGPixel 1 month ago
parent 8cd2f0de43
commit 21fac3a94e
No known key found for this signature in database

@ -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.",

@ -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()

@ -1,5 +1,5 @@
<template>
<w-layout view="hHh lpR fFf" container>
<w-layout class="table-editor" view="hHh lpR fFf" container>
<w-header class="card-header px-4 py-2">
<w-icon name="img:/_assets/icons/color-data-grid.svg" left size="md" />
<span>{{ t(`editor.tableEditor.title`) }}</span>
@ -23,21 +23,37 @@
:aria-label="t(`common.actions.cancel`)"
icon="la:times"
@click="close" />
<!-- -> "Update" when the overlay was opened over a table that is already in the page: the
button says what pressing it does, and what it does is replace that one -->
<w-btn
push
color="positive"
text-color="white"
:label="t(`common.actions.insert`)"
:aria-label="t(`common.actions.insert`)"
:label="t(submitLabel)"
:aria-label="t(submitLabel)"
icon="la:check"
@click="insert" />
</w-btn-group>
</w-header>
<w-page-container>
<w-page class="p-4">
<div class="flex flex-wrap items-center gap-2">
<!--
A tinted strip rather than a colour of its own: the overlay's panel is a gradient
($grey-3 -> $grey-4, $dark-4 -> $dark-3), so a fixed background would be a step apart from it
at one end of that gradient and level with it at the other. A translucent black -- white in
dark mode -- is a step darker than whatever it happens to sit on.
Bled out of the page's padding on three sides so it meets the header and both edges, which is
what makes it read as a toolbar under the title bar rather than as a panel floating in the
page; `px-4` then puts its contents back on the page's own inset.
-->
<div
class="-mx-4 -mt-4 flex flex-wrap items-center gap-2 bg-black/5 px-4 py-2 dark:bg-white/5">
<!-- -> `flat` + `acrylic-btn`, the pairing the admin toolbars use: the frosted wash is the
button's own colour at 10%, so it sits in the strip rather than being drawn on it -->
<w-btn
outline
class="acrylic-btn"
flat
no-caps
icon="la:plus"
color="primary"
@ -45,7 +61,8 @@
:label="t(`editor.tableEditor.addRow`)"
@click="addRow" />
<w-btn
outline
class="acrylic-btn"
flat
no-caps
icon="la:plus"
color="primary"
@ -142,19 +159,51 @@
</tbody>
</table>
</div>
<!-- -> The markdown itself, because that is what gets inserted and it is worth seeing before
it lands in the page -->
<div class="text-overline mt-6">{{ t('editor.tableEditor.markdown') }}</div>
<pre class="table-editor-output mt-2">{{ markdown }}</pre>
<!--
The markdown itself, because that is what gets inserted and it is worth seeing before it lands
in the page. Headed the way the block picker heads its own markdown.
The heading IS the flex row, rather than a heading beside a control: `w-section-header` draws
its wash and its hairline across its own width, so a heading sized to its text would trail a
band a third of the way across the panel.
`-mx-4` gives back the page's own padding, so that band reaches the panel's edges instead of
stopping short of them and the class's own 16px leaves the heading text at the same inset
the rest of the page keeps.
-->
<div class="w-section-header -mx-4 mt-6 flex flex-wrap items-center justify-between gap-2">
<span>{{ t('editor.tableEditor.markdown') }}</span>
<!-- -> Sits with the output rather than with the editing tools: it changes nothing about the
table, only how the syntax below is written. Body colour and weight rather than the
band's, since it is a control standing in the heading, not part of it. -->
<w-checkbox
v-model="state.compact"
class="font-normal text-grey-9 dark:text-white"
:label="t('editor.tableEditor.compact')" />
</div>
<!--
Drawn as the page will draw it: `page-contents` is the content stylesheet, so the preview is
a code block, not a panel of its own invention one that follows the site's own code surface,
in both themes, without this file restating any of it.
`mt-4` rather than the heading's own 10px, because the faint band the heading trails below
itself reaches 13px down and the block's background would paint over it. The `pre` is the only
child, which is what gives up the block margins content puts around a code block.
-->
<div class="page-contents mt-4">
<pre>{{ markdown }}</pre>
</div>
</w-page>
</w-page-container>
</w-layout>
</template>
<script setup>
import { computed, reactive } from 'vue'
import { computed, onBeforeUnmount, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { ALIGNMENTS, buildTable, parseTable } from '@/helpers/markdownTable'
import { useSiteStore } from '@/stores/site'
/**
@ -165,6 +214,10 @@ import { useSiteStore } from '@/stores/site'
* replaces (`tabulator-tables`) is a sortable, filterable, virtually-rendered spreadsheet whose model
* has no place to put that alignment. Every editing gesture here is a `splice`.
*
* Opened over a table that is already in the page from the "Edit Table" lens in the markdown editor,
* which passes its source and the lines it occupies the same grid edits that table instead, and the
* result goes back over those lines rather than in at the cursor.
*
* The editor receives the result over the event bus, the same way the File Manager hands back an asset.
*/
@ -176,9 +229,6 @@ const siteStore = useSiteStore()
const { t } = useI18n()
/** Cycled through by the per-column button, in this order. */
const ALIGNMENTS = ['left', 'center', 'right']
const ALIGN_ICONS = {
left: 'mdi:format-align-left',
center: 'mdi:format-align-center',
@ -193,66 +243,51 @@ const ALIGN_LABELS = {
right: 'editor.tableEditor.alignRight'
}
/** Narrowest a delimiter cell can be and still show its colons: `:-:`. */
const MIN_WIDTH = 3
// DATA
/*
`rows[0]` is the header. Keeping it in the same array as the body is what makes a column operation one
splice per row instead of two code paths that have to agree.
A starter table when there is nothing to edit; the table that was there when there is. `replace` holds
the lines it came from, and is what turns this from an insert into an update -- see `insert`.
*/
const state = reactive({
align: ['left', 'left', 'left'],
rows: [
['Column 1', 'Column 2', 'Column 3'],
['', '', ''],
['', '', '']
]
})
const editing = siteStore.overlayOpts?.source
? {
...parseTable(siteStore.overlayOpts.source),
replace: {
startLine: siteStore.overlayOpts.startLine,
endLine: siteStore.overlayOpts.endLine
}
}
: null
const state = reactive(
editing ?? {
align: ['left', 'left', 'left'],
compact: true,
rows: [
['Column 1', 'Column 2', 'Column 3'],
['', '', ''],
['', '', '']
],
replace: null
}
)
// COMPUTED
const bodyRows = computed(() => state.rows.slice(1))
/**
* The table as markdown.
*
* Cells are padded to the width of their column: it costs nothing and it is the difference between a
* source someone can read and a wall of pipes. A cell's 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.
*/
const markdown = computed(() => {
const widths = state.align.map((_, colIndex) =>
Math.max(MIN_WIDTH, ...state.rows.map((row) => escapeCell(row[colIndex]).length))
)
const line = (cells) => `| ${cells.map((cell, i) => cell.padEnd(widths[i])).join(' | ')} |`
const delimiters = state.align.map((align, i) => {
const dashes = '-'.repeat(widths[i] - (align === 'center' ? 2 : 1))
switch (align) {
case 'center': {
return `:${dashes}:`
}
case 'right': {
return `${dashes}:`
}
default: {
return `:${dashes}`
}
}
})
return [
line(state.rows[0].map(escapeCell)),
line(delimiters),
...bodyRows.value.map((row) => line(row.map(escapeCell)))
].join('\n')
})
/* -> Written by `helpers/markdownTable`, which is also what read the table being edited: the two
directions have to agree, or reopening a table would reformat it */
const markdown = computed(() => buildTable(state, { compact: state.compact }))
// METHODS
const submitLabel = computed(() =>
state.replace ? 'common.actions.update' : 'common.actions.insert'
)
function escapeCell(value) {
return (value ?? '').replaceAll('|', '\\|').replaceAll(/\s+/g, ' ').trim()
}
// METHODS
function cycleAlign(colIndex) {
const next = (ALIGNMENTS.indexOf(state.align[colIndex]) + 1) % ALIGNMENTS.length
@ -318,18 +353,42 @@ function onCellPaste(rowIndex, colIndex, event) {
})
}
/*
The result, and where it goes: over the lines the table came from, or in at the cursor when it came
from nowhere. The editor is the one holding the document, so it does the placing -- this only says
which of the two it is.
*/
function insert() {
EVENT_BUS.emit('insertTable', markdown.value)
EVENT_BUS.emit('insertTable', { markdown: markdown.value, replace: state.replace })
close()
}
function close() {
siteStore.$patch({ overlay: '' })
}
// -> Cleared here rather than in `close`, so it goes whichever way the overlay was left: a table left
// behind in the options would be edited again the next time the toolbar button opens this
onBeforeUnmount(() => {
siteStore.overlayOpts = {}
})
</script>
<style lang="scss">
.table-editor {
/*
Nothing here sits on a `w-card`, and that is where the app's dark text colour comes from -- so the
overlay has to state its own or everything that merely inherits `color` stays black on the dark
panel: the cell inputs (`color: inherit`, deliberately, so they follow the surface), the `Markdown`
heading and the Compact checkbox's label. Same reason `BlockPickerOverlay` states it.
*/
@at-root .body--light & {
color: $grey-9;
}
@at-root .body--dark & {
color: #fff;
}
&-grid {
overflow-x: auto;
@ -385,23 +444,5 @@ function close() {
font-weight: 600;
}
}
&-output {
padding: 12px;
border-radius: 4px;
font-family: 'Roboto Mono', Consolas, 'Liberation Mono', Courier, monospace;
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
@at-root .body--light & {
background-color: $grey-2;
color: $grey-9;
}
@at-root .body--dark & {
background-color: $dark-4;
color: #fff;
}
}
}
</style>

@ -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;

@ -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
}

@ -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 })
}

Loading…
Cancel
Save