feat: improve table editor + sidebar fixes

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

@ -1763,7 +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.editTable": "Edit in Table Editor",
"editor.markup.insertTable": "Insert Table",
"editor.markup.insertTabset": "Insert Tabset",
"editor.markup.insertVideoAudio": "Insert Video / Audio",
@ -1913,12 +1913,17 @@
"editor.tableEditor.alignLeft": "Left",
"editor.tableEditor.alignRight": "Right",
"editor.tableEditor.bodyCell": "Row {row}, column {column}",
"editor.tableEditor.compact": "Compact",
"editor.tableEditor.compact": "Compact Output",
"editor.tableEditor.headerCell": "Heading of column {column}",
"editor.tableEditor.headerless": "Headerless",
"editor.tableEditor.markdown": "Markdown",
"editor.tableEditor.pasteHint": "Paste a table from a spreadsheet into any cell to fill the grid.",
"editor.tableEditor.removeColumn": "Remove Column",
"editor.tableEditor.removeRow": "Remove Row",
"editor.tableEditor.styleCodeNoHighlight": "No Inline Code Highlight",
"editor.tableEditor.styleLeadingCol": "Bold Leading Column",
"editor.tableEditor.styleVerticalMiddle": "Middle Vertical Align",
"editor.tableEditor.styling": "Styling",
"editor.tableEditor.title": "Table Editor",
"editor.togglePreviewPane": "Toggle Preview Pane",
"editor.toggleScrollSync": "Toggle Scroll Sync",

@ -93,74 +93,97 @@ watch(
margin-bottom: 10px;
}
/*
A first item that is a link -- on its own or as a group with children -- needs the space a first
header brings with it. A dense row's padding is 2px, so its label started hard against the rule under
the site header; a header's own `p-4` already stands it 16px clear, which is why this is only for the
two link shapes and not for every first child.
*/
&-list > .w-item:first-child,
&-list > .w-expansion-item:first-child {
margin-top: 10px;
}
.w-list {
.w-separator + .w-item-label {
padding-top: 10px;
}
/* -> Full white, like the icons and labels this sidebar sets by hand: the chevron is what says the
row opens, so it is not the secondary content a trailing section is dimmed for. Set on the icon
rather than on its section, which is what makes it beat the inherited dimmed colour. */
.w-expansion-item__arrow {
color: #fff;
}
.w-item-section--avatar {
min-width: auto;
}
.q-expansion-item > .q-expansion-item__container {
> .w-item {
&::before {
content: '';
display: block;
position: absolute;
bottom: 0;
left: 0px;
width: 10px;
height: 10px;
border-style: solid;
border-color: transparent transparent rgba(255, 255, 255, 0.25) rgba(255, 255, 255, 0.25);
transition: all 0.4s ease;
}
}
/*
An open group's children, marked the way `NavEditOverlay` marks a nested nav item: a 10px rule down
the side of the run, with an elbow at each end turning it out of the row above and closing it under
the last child. The same three pieces and the same 10px, so the two views of one navigation tree
look like the same tree.
The rules this replaces addressed `.q-expansion-item__container` and `.q-expansion-item--expanded`,
which is markup `WExpansionItem` has never emitted -- it renders `__header` and `__content` and
keeps its state in `aria-expanded`. So none of them matched, and an open group had no line at all.
No expanded/collapsed state needed here: the content is `v-show`n, so when the group is closed this
box is `display: none` and takes its border and both elbows with it.
*/
.w-expansion-item__content {
position: relative;
border-left: 10px solid rgba(255, 255, 255, 0.25);
/*
And a step DOWN from the sidebar rather than up, which is the one place this parts company with
`NavEditOverlay`: there the nested rows lift off a near-black panel, here they sit in a coloured
one, and a lighter wash on a mid-tone blue reads as a highlight -- as if the whole group were
selected.
A translucent black, not a colour: the sidebar's own is the site's to choose (`--q-sidebar`,
rewritten at runtime for per-site theming), so anything fixed would be right for the default blue
and wrong for every other site.
`padding-box` keeps that wash off the border area. The rule there is 25% white, and with the
default `border-box` clip the darkened wash behind it would leave the rule a different colour
along the children than at the two elbows, which have nothing behind them.
*/
background-color: rgb(0 0 0 / 0.12);
background-clip: padding-box;
/*
Each elbow is one 10px box showing two of its borders: the mitre between them is the angle. Set
10px outside the content on the appropriate side, so the vertical stroke lines up with the rule
and continues it. `left: -10px` is the rule's own left edge -- an absolute offset here is
measured from the padding box, which starts where the border ends.
*/
&::before,
&::after {
content: '';
display: block;
position: absolute;
bottom: -20px;
left: 0;
left: -10px;
width: 10px;
height: 10px;
border-style: solid;
border-color: rgba(255, 255, 255, 0.25) transparent transparent rgba(255, 255, 255, 0.25);
transition: all 0.4s ease;
}
}
.q-expansion-item--collapsed > .q-expansion-item__container {
> .w-item {
&::before {
border-width: 0 0 0 0;
}
}
&::after {
bottom: 0px;
border-width: 0 0 0 0;
}
}
.q-expansion-item--expanded > .q-expansion-item__container {
> .w-item {
&::before {
border-width: 0 10px 10px 0;
}
/* -> Out of the parent row: the rule's top end, turning right into the row above it */
&::before {
top: -10px;
border-width: 0 10px 10px 0;
border-color: transparent transparent rgba(255, 255, 255, 0.25) rgba(255, 255, 255, 0.25);
}
/* -> And closed under the last child, turning right again */
&::after {
bottom: -20px;
top: 100%;
border-width: 10px 10px 10px 0;
border-color: rgba(255, 255, 255, 0.25) transparent transparent rgba(255, 255, 255, 0.25);
}
}
.q-expansion-item__content {
border-left: 10px solid rgba(255, 255, 255, 0.25);
}
}
&-header {

@ -49,11 +49,10 @@
-->
<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 -->
<!-- -> `push`, like the overlay's own header buttons and the Styling menu at the far end of
this strip: solid, with the ledge that collapses under the press -->
<w-btn
class="acrylic-btn"
flat
push
no-caps
icon="la:plus"
color="primary"
@ -61,14 +60,64 @@
:label="t(`editor.tableEditor.addRow`)"
@click="addRow" />
<w-btn
class="acrylic-btn"
flat
push
no-caps
icon="la:plus"
color="primary"
padding="xs sm"
:label="t(`editor.tableEditor.addColumn`)"
@click="addColumn" />
<!--
Three groups in one strip, ruled apart: what the table holds, what its markdown looks like,
and what the page does with it. A rule rather than more space -- at this density the gap that
would read as a break is wide enough to look like a missing control.
The two options here both change the markdown under the grid as they are ticked, which is the
only feedback either of them has. Headerless comes first, since it changes the grid as well.
Margins on all three sides, past the row's own gap: a checkbox sitting at the bare gap from a
rule reads as attached to it, and two of them that close together read as one control with two
boxes. The pair ends up breathing inside its group rather than filling it.
-->
<w-separator vertical />
<w-checkbox
v-model="state.headerless"
class="ml-2"
:label="t('editor.tableEditor.headerless')" />
<w-checkbox
v-model="state.compact"
class="mx-2"
:label="t('editor.tableEditor.compact')" />
<!--
The classes the content stylesheet gives a table, which go under it as a `markdown-it-attrs`
line see `css/_page-contents.scss`, where each of the three is defined. Last in the strip
and in its own colour: the only control here that opens something rather than doing something.
A menu of checkboxes rather than a `w-select`: these are not one choice from a list, they are
three independent switches, and a select would read as "pick a style" and then have to explain
why two are ticked. `WCheckbox` binds a value within an array, which is exactly this shape, and
`WMenu` does not close on a click inside itself, so all three can be set in one visit.
-->
<w-separator vertical />
<w-btn
push
no-caps
icon="mdi:palette"
color="secondary"
padding="xs sm"
:label="t(`editor.tableEditor.styling`)">
<w-icon name="mdi:menu-down" />
<w-menu anchor="bottom left" self="top left" :offset="[0, 4]">
<div class="flex flex-col gap-3 p-4">
<w-checkbox
v-for="option of STYLE_CLASSES"
:key="option.value"
v-model="state.classes"
:val="option.value"
:label="t(option.label)" />
</div>
</w-menu>
</w-btn>
<w-space />
<div class="text-caption text-black/60 dark:text-white/70">
{{ t('editor.tableEditor.pasteHint') }}
@ -117,12 +166,15 @@
<!-- -> Matches the row-tools column below, so the grid stays square -->
<th class="table-editor-rowtools" />
</tr>
<tr>
<!-- -> Gone entirely when the table is headerless, rather than emptied: `rows[0]` is a
body row in that case, and it is shown as one below -->
<tr v-if="!state.headerless">
<th v-for="(_, colIndex) of state.rows[0]" :key="`head-${colIndex}`">
<input
v-model="state.rows[0][colIndex]"
class="table-editor-cell table-editor-cell--head"
type="text"
:style="{ textAlign: state.align[colIndex] }"
:aria-label="t(`editor.tableEditor.headerCell`, { column: colIndex + 1 })"
@paste="onCellPaste(0, colIndex, $event)" />
</th>
@ -132,14 +184,17 @@
<tbody>
<tr v-for="(row, rowIndex) of bodyRows" :key="`row-${rowIndex}`">
<td v-for="(_, colIndex) of row" :key="`cell-${rowIndex}-${colIndex}`">
<!-- -> Set the way the column is set: the alignment is the one thing about a table
the syntax can carry, so the grid may as well show it rather than describe it -->
<input
v-model="state.rows[rowIndex + 1][colIndex]"
v-model="state.rows[rowIndex + rowOffset][colIndex]"
class="table-editor-cell"
type="text"
:style="{ textAlign: state.align[colIndex] }"
:aria-label="
t(`editor.tableEditor.bodyCell`, { row: rowIndex + 1, column: colIndex + 1 })
"
@paste="onCellPaste(rowIndex + 1, colIndex, $event)" />
@paste="onCellPaste(rowIndex + rowOffset, colIndex, $event)" />
</td>
<td class="table-editor-rowtools">
<w-btn
@ -151,7 +206,7 @@
icon="la:times"
:disabled="bodyRows.length < 2"
:aria-label="t(`editor.tableEditor.removeRow`)"
@click="removeRow(rowIndex + 1)">
@click="removeRow(rowIndex + rowOffset)">
<w-tooltip>{{ t('editor.tableEditor.removeRow') }}</w-tooltip>
</w-btn>
</td>
@ -163,24 +218,11 @@
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.
`-mx-4` gives back the page's own padding, so the band `w-section-header` trails 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>
<div class="w-section-header -mx-4 mt-6">{{ t('editor.tableEditor.markdown') }}</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,
@ -235,6 +277,20 @@ const ALIGN_ICONS = {
right: 'mdi:format-align-right'
}
/*
The classes the Styling menu offers, and what each one does to a table. Every one of them is defined in
`css/_page-contents.scss` -- this list is the UI for those rules, so a class added there needs a line
here to be reachable, and a line here naming a class that is not there does nothing at all.
A class an author wrote by hand that is not in this list is left alone rather than stripped: it stays in
`state.classes` and goes back out with the table, it simply has no box of its own to tick.
*/
const STYLE_CLASSES = [
{ value: 'table-vertical-middle', label: 'editor.tableEditor.styleVerticalMiddle' },
{ value: 'table-leading-col', label: 'editor.tableEditor.styleLeadingCol' },
{ value: 'table-code-nohighlight', label: 'editor.tableEditor.styleCodeNoHighlight' }
]
/* -> Spelled out rather than built from the value: a key assembled at runtime is invisible to the
translation tooling, the same way a concatenated icon name is invisible to the icon scanner. */
const ALIGN_LABELS = {
@ -246,8 +302,10 @@ const ALIGN_LABELS = {
// 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.
`rows[0]` is the header, unless `headerless` is set -- in which case it is simply the first row, and
every row is a body row. Keeping the header 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, and it is also what makes
the Headerless tick reversible: nothing is thrown away, the first row just stops being a heading.
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`.
@ -266,6 +324,9 @@ const state = reactive(
editing ?? {
align: ['left', 'left', 'left'],
compact: true,
headerless: false,
classes: [],
otherAttrs: [],
rows: [
['Column 1', 'Column 2', 'Column 3'],
['', '', ''],
@ -277,7 +338,10 @@ const state = reactive(
// COMPUTED
const bodyRows = computed(() => state.rows.slice(1))
/** Where the body starts in `rows`: after the header, or at the top when there is none. */
const rowOffset = computed(() => (state.headerless ? 0 : 1))
const bodyRows = computed(() => state.rows.slice(rowOffset.value))
/* -> 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 */

@ -1,7 +1,12 @@
<template>
<div class="w-expansion-item">
<!--
`select-none`, as `WBtn` carries it: this row's whole job is to be pressed, and pressing it twice
quickly -- opening a group, seeing it is the wrong one, closing it again -- is a double click, which
otherwise leaves the label text selected behind the group that just shut.
-->
<w-item
class="w-expansion-item__header"
class="w-expansion-item__header select-none"
:dense="dense"
clickable
:aria-expanded="String(isOpen)"

@ -70,7 +70,14 @@ const classes = computed(() => [
}
.w-list--dark :deep(.w-item-label--caption),
.w-list--dark :deep(.w-item-label--header) {
.w-list--dark :deep(.w-item-label--header),
/*
And a trailing section, for the same reason and with the same fix. `WItemSection` dims a `side`
section with `text-black/54 dark:text-white/70`, so on a dark list in LIGHT mode -- the nav sidebar
on a light page -- whatever it holds came out black on the sidebar's colour. An avatar section keeps
full contrast there and keeps it here.
*/
.w-list--dark :deep(.w-item-section--side:not(.w-item-section--avatar)) {
color: rgb(255 255 255 / 0.7);
}

@ -7,10 +7,14 @@
* 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.
* A table is a grid of one-line strings, an alignment per column -- the only formatting the syntax
* carries -- and whether it has a header row at all. What a MultiMarkdown table can do beyond that, a
* multi-line cell or a `^^` rowspan or a second body, has nowhere to go in that model, which is what
* `findEditableTables` is for: it offers only the tables that survive the round trip.
*
* A headerless table is MultiMarkdown syntax -- the delimiter row comes first and there is no header
* above it -- so one renders as a table only while the site has the MultiMarkdown Table option on. It is
* on by default; the editor's preview pane is where an author would see it if it were not.
*/
/** Narrowest a delimiter cell can be and still show its colons: `:-:`. */
@ -25,6 +29,12 @@ 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 `markdown-it-attrs` line: `{.some-class}` under a block, which is how a table carries the classes
* the content stylesheet styles it by. It belongs to the table above it, so it belongs to the table.
*/
const ATTRS_LINE = /^ {0,3}\{([^}]*)\}\s*$/
/**
* A cell as it is written into a row.
*
@ -41,8 +51,18 @@ export function escapeCell(value) {
* `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.
*
* `headerless` puts the delimiter row first and no row above it, which is how MultiMarkdown says a table
* has no header: every row of `rows` is then a body row rather than `rows[0]` being the head.
*
* `classes` and `otherAttrs` become the `markdown-it-attrs` line under the table, if either holds
* anything. `otherAttrs` is whatever the author had in there that is not a class -- an `#id`, say -- kept
* verbatim so that reading a table and writing it back does not quietly drop half of its attributes.
*/
export function buildTable({ align, rows }, { compact = true } = {}) {
export function buildTable(
{ align, rows, headerless = false, classes = [], otherAttrs = [] },
{ 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))
@ -63,7 +83,13 @@ export function buildTable({ align, rows }, { compact = true } = {}) {
}
}
})
return [line(cells[0]), line(delimiters), ...cells.slice(1).map((row) => line(row))].join('\n')
const body = headerless
? [line(delimiters), ...cells.map((row) => line(row))]
: [line(cells[0]), line(delimiters), ...cells.slice(1).map((row) => line(row))]
const attrs = [...classes.map((name) => `.${name}`), ...otherAttrs]
// -> Directly under the last row, with no blank line: that is the only place it attaches to the table
return (attrs.length > 0 ? [...body, `{${attrs.join(' ')}}`] : body).join('\n')
}
/**
@ -139,32 +165,51 @@ function parseDelimiters(line) {
* 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) {
function isCompact(lines, delimiterAt) {
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))
rawCells(line).every(
(raw) => unpadded(raw) && (index !== delimiterAt || raw.trim().length <= MIN_WIDTH)
)
)
}
/**
* A table's source as the editor's own state: `rows[0]` is the header, one alignment per column.
* A table's source as the editor's own state: one alignment per column, and `rows[0]` as the header
* unless the table is headerless, in which case every row is a body row.
*
* Which of the two it is comes from where the delimiter row is -- first line, or second.
*
* 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 all = source.split('\n').filter((line) => line.trim() !== '')
/*
The attrs line, if the table has one, comes off before anything else looks at the rows -- it is the
only line of the block that is not one. Its tokens are split into the classes, which the editor
offers, and everything else, which it carries through untouched.
*/
const attrs = ATTRS_LINE.exec(all.at(-1) ?? '')
const tokens = attrs ? attrs[1].trim().split(/\s+/).filter(Boolean) : []
const lines = attrs ? all.slice(0, -1) : all
const headerless = Boolean(parseDelimiters(lines[0]))
const delimiterAt = headerless ? 0 : 1
const align = parseDelimiters(lines[delimiterAt]) ?? []
const body = lines.slice(delimiterAt + 1).map(splitRow)
const rows = headerless ? body : [splitRow(lines[0] ?? ''), ...body]
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)
compact: isCompact(lines, delimiterAt),
headerless,
classes: tokens.filter((token) => token.startsWith('.')).map((token) => token.slice(1)),
otherAttrs: tokens.filter((token) => !token.startsWith('.'))
}
}
@ -173,15 +218,23 @@ export function parseTable(source) {
*
* 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.
* leave the rest of it stranded.
*
* Four things have to hold for that reading, because everything else that can sit under a table is far
* more common than a second body: one blank line, then a row that opens with a pipe, carries this
* table's number of columns, is not a delimiter row (that would start a headerless table of its own) and
* is not followed by one (that would make it the header of an ordinary one). A paragraph that happens to
* hold a pipe fails the first of those, which is what keeps it from costing the table above its lens.
*/
function continuesBelow(lines, last) {
if ((lines[last + 1] ?? '').trim() !== '' || !lines[last + 2]?.includes('|')) {
function continuesBelow(lines, last, columns) {
const next = lines[last + 2]
if ((lines[last + 1] ?? '').trim() !== '' || !/^ {0,3}\|/.test(next ?? '')) {
return false
}
if (parseDelimiters(next) || parseDelimiters(lines[last + 3])) {
return false
}
return !parseDelimiters(lines[last + 3])
return splitRow(next).length === columns
}
/**
@ -189,10 +242,9 @@ function continuesBelow(lines, last) {
*
* 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.
* What is deliberately left out: a table inside a fenced block, which is a code sample and not a table,
* 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')
@ -212,27 +264,44 @@ export function findEditableTables(text) {
continue
}
// -> A header row and then a delimiter row. A delimiter row FIRST is a headerless table
if (!lines[index].includes('|') || parseDelimiters(lines[index])) {
if (!lines[index].includes('|')) {
continue
}
if (!parseDelimiters(lines[index + 1])) {
/*
Two ways a table starts: a delimiter row on its own, which is a headerless table, or a header row
with the delimiter row under it. The delimiter row of an ordinary table is never mistaken for the
first kind, because the loop skips past every line of a block it has already been through.
*/
const headerless = Boolean(parseDelimiters(lines[index]))
const align = headerless ? parseDelimiters(lines[index]) : parseDelimiters(lines[index + 1])
if (!align) {
continue
}
const start = index
let last = index + 1
let last = headerless ? index : index + 1
while ((lines[last + 1] ?? '').trim() !== '' && lines[last + 1].includes('|')) {
last++
}
const bodyEnd = last
/*
A `{.class}` line under the last row is part of the table as far as anyone editing it is concerned:
it is where the table's styling lives, and leaving it out of the range would have an update write a
second one under the first.
*/
if (ATTRS_LINE.test(lines[last + 1] ?? '')) {
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)
const body = lines.slice(headerless ? start + 1 : start + 2, bodyEnd + 1)
if (
// -> A lone delimiter row is not a headerless table, it is a line of dashes and pipes
(headerless && body.length === 0) ||
body.some((line) => line.trimEnd().endsWith('\\')) ||
body.some((line) => splitRow(line).includes('^^')) ||
continuesBelow(lines, last)
continuesBelow(lines, last, align.length)
) {
continue
}

Loading…
Cancel
Save