diff --git a/backend/locales/en.json b/backend/locales/en.json index c5bc6153b..6c938a7e7 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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", diff --git a/frontend/src/components/NavSidebar.vue b/frontend/src/components/NavSidebar.vue index 0baa738ce..c986874f7 100644 --- a/frontend/src/components/NavSidebar.vue +++ b/frontend/src/components/NavSidebar.vue @@ -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 { diff --git a/frontend/src/components/TableEditorOverlay.vue b/frontend/src/components/TableEditorOverlay.vue index 41736f781..21175109c 100644 --- a/frontend/src/components/TableEditorOverlay.vue +++ b/frontend/src/components/TableEditorOverlay.vue @@ -49,11 +49,10 @@ -->
- + + + + + + + + + + +
+ +
+
+
{{ t('editor.tableEditor.pasteHint') }} @@ -117,12 +166,15 @@ - + + @@ -132,14 +184,17 @@ + + @paste="onCellPaste(rowIndex + rowOffset, colIndex, $event)" /> + @click="removeRow(rowIndex + rowOffset)"> {{ t('editor.tableEditor.removeRow') }} @@ -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. --> -
- {{ t('editor.tableEditor.markdown') }} - - -
+
{{ t('editor.tableEditor.markdown') }}
[ } .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); } diff --git a/frontend/src/helpers/markdownTable.js b/frontend/src/helpers/markdownTable.js index a98a327c9..2c4e6c684 100644 --- a/frontend/src/helpers/markdownTable.js +++ b/frontend/src/helpers/markdownTable.js @@ -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 }