diff --git a/backend/locales/en.json b/backend/locales/en.json index e6af813f5..8880ae8e9 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1725,16 +1725,20 @@ "editor.markup.blockquoteSuccess": "Success Blockquote", "editor.markup.blockquoteWarning": "Warning Blockquote", "editor.markup.bold": "Bold", + "editor.markup.definitionListDefinition": "Definition", + "editor.markup.definitionListTerm": "Term", "editor.markup.distractionFreeMode": "Distraction Free Mode", "editor.markup.editBlock": "Edit Block Parameters", "editor.markup.editTable": "Edit in Table Editor", "editor.markup.header": "Header", "editor.markup.headerLevel": "Header {level}", "editor.markup.heading": "Heading {level}", + "editor.markup.highlight": "Highlight", "editor.markup.inlineCode": "Inline Code", "editor.markup.insertAssets": "Insert Assets", "editor.markup.insertBlock": "Insert Block", "editor.markup.insertCodeBlock": "Insert Code Block", + "editor.markup.insertDefinitionList": "Insert Definition List", "editor.markup.insertDiagram": "Insert Diagram", "editor.markup.insertEmoji": "Insert Emoji", "editor.markup.insertFootnote": "Insert Footnote", diff --git a/backend/models/rendering.ts b/backend/models/rendering.ts index cf3f7d45e..e5259f3cd 100644 --- a/backend/models/rendering.ts +++ b/backend/models/rendering.ts @@ -327,6 +327,26 @@ export function slugifyHeading(text: string): string { ) } +/** + * The child block a tab is, named here because `anchorHeadings` has to ask about it by tag. + * + * The one block this file knows about by name. Everything else it does to blocks is a question about + * the manifest — which are installed, which are children — whereas a tab standing in for a heading is + * a property of that block's own markup. + */ +const TAB_TAG = 'block-tab' + +/** + * The heading level a tab asked to be listed at, or null for one that is not a heading. + * + * Strict about the value: `header` reaches this as whatever an author typed, and a level is one digit + * from 1 to 6. Anything else — a typo, `0`, `7`, `2px` — means an ordinary tab rather than an error, + * since the page has to render either way and a tab is perfectly usable without being in the contents. + */ +export function tabHeadingLevel(value: string | undefined): number | null { + return /^[1-6]$/.test((value ?? '').trim()) ? Number.parseInt(value!.trim(), 10) : null +} + class Rendering { /** * Clean up a render that came from a client, and pull out what is derived from it. @@ -690,15 +710,37 @@ class Rendering { * * The markdown renderer does not emit heading anchors, so this is where a page becomes deep * linkable — and the ids have to exist before the contents tree can point at them. + * + * **A tab can be a heading too.** `::block-tab{label="Foo" header="2"}` asks for its label to be + * listed as an h2 would be, which nothing else can do for it: the label is an attribute rather than + * text, so there is no heading element in the render to find, and the panel is not showing unless + * it is the open tab. The anchor therefore goes on the `block-tab` element itself, and the reader + * who clicks that row is taken there by the same path as a heading inside a closed tab — the app + * asks whatever is above the target to reveal it, and `block-tabs` answers by opening the panel. + * + * They are matched in one pass rather than two, because a heading written inside a panel has to + * nest under the tab it is in, and that is only true if both arrive in document order. */ private anchorHeadings($: cheerio.CheerioAPI): TocNode[] { const used = new Map() const flat: { level: number; node: TocNode }[] = [] - $('h1, h2, h3, h4, h5, h6').each((_, el) => { - const heading = $(el) - const label = heading.text().trim() - let key = heading.attr('id') || slugifyHeading(label) + $(`h1, h2, h3, h4, h5, h6, ${TAB_TAG}[header]`).each((_, el) => { + const element = $(el) + const isTab = el.tagName === TAB_TAG + const level = isTab + ? tabHeadingLevel(element.attr('header')) + : Number.parseInt(el.tagName.slice(1), 10) + const label = (isTab ? element.attr('label') : element.text())?.trim() ?? '' + /* + A tab that asked for a level it cannot have, or that has nothing to be called, stays an + ordinary tab. Both are the author's mistakes rather than the page's, and a page has to render + either way — an unlabelled tab is drawn as "Tab 2" by the block, which is not a section title. + */ + if (!level || (isTab && !label)) { + return + } + let key = element.attr('id') || slugifyHeading(label) // -> Two headings can legitimately read the same; the second one becomes `-1`, as anchors // generally do, so that both remain addressable @@ -708,8 +750,7 @@ class Rendering { key = `${key}-${seen}` } - heading.attr('id', key) - const level = Number.parseInt(el.tagName.slice(1), 10) + element.attr('id', key) flat.push({ level, node: { key: `#${key}`, label, level, children: [] } diff --git a/blocks/block-tab/component.js b/blocks/block-tab/component.js index e1d485e10..be3baf1be 100644 --- a/blocks/block-tab/component.js +++ b/blocks/block-tab/component.js @@ -5,6 +5,12 @@ * `icon`, builds the strip from them and shows or hides it. Its content is ordinary page content, * left in the light DOM so the article's own stylesheet reaches it. * + * `header` is read by neither of them. A tab's label is an attribute rather than text, so there is no + * heading in the render for a contents list to find, and the server closes that gap when the page is + * saved: it anchors this element and lists the label at the level asked for (`anchorHeadings` in + * `models/rendering.ts`). A reader clicking that row is sent here, and the panel is opened on the way + * by the `block-reveal` every anchor already asks for. + * * It is registered as an element of its own so that the page view, which fetches a component for * every undefined element it finds in a page, has something to fetch. */ @@ -37,6 +43,12 @@ export class BlockTabElement extends HTMLElement { type: 'string', label: 'Icon', hint: 'Iconify reference drawn to the left of the label, e.g. mdi:language-python.' + }, + { + name: 'header', + type: 'number', + label: 'Header Level', + hint: 'A level from 1 to 6 lists this tab in the page contents under its label, and a reader clicking it there opens the tab. Empty for an ordinary tab.' } ] } diff --git a/blocks/block-tabs/component.js b/blocks/block-tabs/component.js index 2df493d0f..c515cb612 100644 --- a/blocks/block-tabs/component.js +++ b/blocks/block-tabs/component.js @@ -245,6 +245,13 @@ Content of the second tab. } const margin = `${strip.offsetHeight + 20}px` for (const { panel } of this._tabs) { + /* + The panel as well as what is in it. A tab whose label is a page heading — `header` on + `block-tab` — is anchored on the panel element itself, since the label is an attribute and + there is no heading in the page to carry the anchor, so the panel is what a contents click + scrolls to and it needs the same margin as any heading in it. + */ + panel.style.setProperty('scroll-margin-top', margin) for (const child of panel.children) { child.style.setProperty('scroll-margin-top', margin) } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9a12e2906..b316bfeff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "markdown-it-abbr": "2.0.0", "markdown-it-attrs": "5.0.1", "markdown-it-decorate": "1.2.2", + "markdown-it-deflist": "4.0.0", "markdown-it-emoji": "3.1.0", "markdown-it-expand-tabs": "1.0.13", "markdown-it-footnote": "4.0.0", @@ -3743,6 +3744,22 @@ "integrity": "sha512-7BFWJ97KBXgkaPVjKHISQnhSW8RWQ7yRNXpr8pPUV2Rw4GHvGrgb6CelKCM+GSijP0uSLCAVfc/knWIz+2v/Sw==", "license": "MIT" }, + "node_modules/markdown-it-deflist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-deflist/-/markdown-it-deflist-4.0.0.tgz", + "integrity": "sha512-06186ZWDwNLg638THD70MxBeFrDMF0+nL7IWU/qwCGHGOcUIm4MTYXbfjpv0ooWnVjkSnAjW8msKaOQmNRfY6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT" + }, "node_modules/markdown-it-emoji": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-3.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 78a86ffc5..a33b945ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "markdown-it-abbr": "2.0.0", "markdown-it-attrs": "5.0.1", "markdown-it-decorate": "1.2.2", + "markdown-it-deflist": "4.0.0", "markdown-it-emoji": "3.1.0", "markdown-it-expand-tabs": "1.0.13", "markdown-it-footnote": "4.0.0", diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 27e701b7e..4a3cf6065 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or removing an icon; `check-icons.mjs` fails the build if this drifts. - 266 icons. + 268 icons. */ export const BUNDLED_ICONS = { "la:angle-right": {"body":"","width":32,"height":32}, @@ -196,6 +196,7 @@ export const BUNDLED_ICONS = { "mdi:format-align-right": {"body":"","width":24,"height":24}, "mdi:format-bold": {"body":"","width":24,"height":24}, "mdi:format-clear": {"body":"","width":24,"height":24}, + "mdi:format-color-highlight": {"body":"","width":24,"height":24}, "mdi:format-font": {"body":"","width":24,"height":24}, "mdi:format-header-1": {"body":"","width":24,"height":24}, "mdi:format-header-2": {"body":"","width":24,"height":24}, @@ -209,6 +210,7 @@ export const BUNDLED_ICONS = { "mdi:format-italic": {"body":"","width":24,"height":24}, "mdi:format-list-bulleted": {"body":"","width":24,"height":24}, "mdi:format-list-checks": {"body":"","width":24,"height":24}, + "mdi:format-list-group-plus": {"body":"","width":24,"height":24}, "mdi:format-list-numbered": {"body":"","width":24,"height":24}, "mdi:format-page-break": {"body":"","width":24,"height":24}, "mdi:format-paragraph": {"body":"","width":24,"height":24}, diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index d7f2367be..55a023773 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -39,6 +39,11 @@ t('editor.markup.insertBlock') }} + + {{ + t('editor.markup.insertDefinitionList') + }} + {{ t('editor.markup.insertFootnote') @@ -96,6 +101,15 @@ t('editor.markup.strikethrough') }} + + {{ + t('editor.markup.highlight') + }} + {{ t('editor.markup.header') @@ -574,7 +588,8 @@ async function insertTabset() { }) return } - insertBlockClb(blockMarkdown(tabs)) + const markdown = blockMarkdown(tabs) + selectFirstTabLabel(markdown, insertBlockClb(markdown)) } catch (err) { notify({ type: 'negative', @@ -596,6 +611,44 @@ function insertBlockClb(markdown) { 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}` }) + /* + Where the markup itself begins, for a caller that wants to put the cursor inside what it just + inserted. Two lines below the cursor when it had to break out of a sentence first; otherwise on + the cursor's own line, starting at the cursor's own column — which is column 1 only if nothing at + all, indentation included, came before it. + */ + return { + lineNumber: position.lineNumber + (before ? 2 : 0), + column: before ? 1 : position.column + } +} + +/** + * Select the first tab's label in a tabset that has just been inserted. + * + * The tabset arrives with two tabs called "First tab" and "Second tab" (the block's own starter body, + * see `block-tabs`), and naming them is the first thing anybody does — so the first of those is left + * selected, to be typed over rather than hunted down and cleaned up. + * + * Located in the markup rather than in the document: the string is what this function was handed and + * knows the shape of, whereas searching the model would find whichever copy came first — a page may + * already hold a tabset whose first tab is still called "First tab". `d` is what makes the match + * report where the label VALUE sits rather than where `label="…"` starts. + * + * Does nothing for a starter body with no label in it, which is a block template's to decide. + */ +function selectFirstTabLabel(markdown, start) { + const match = markdown.match(/label="([^"]*)"/d) + if (!match) { + return + } + const [from, to] = match.indices[1] + const lines = markdown.slice(0, from).split('\n') + const lineNumber = start.lineNumber + lines.length - 1 + // -> Only the first line of the insert begins at the cursor's column; every later one begins at 1 + const column = (lines.length > 1 ? 1 : start.column) + lines.at(-1).length + editor.setSelection(new Range(lineNumber, column, lineNumber, column + (to - from))) + editor.revealLineInCenterIfOutsideViewport(lineNumber) } function insertTable() { @@ -906,6 +959,35 @@ function insertHorizontalBar() { insertAfter({ content: '---', newLine: true }) } +/** + * A definition list skeleton, on lines of its own. + * + * Two entries rather than one, because the blank line BETWEEN them is the part of the notation nobody + * guesses: a term is only a term when the next line starts with `: `, and two entries with no blank + * line between them collapse into one term with two definitions. + * + * Placeholder words rather than empty lines for a related reason — an empty term and an empty + * definition render as nothing at all, so the button would look like it had done nothing — and the + * first of them is left selected, so the skeleton is typed over rather than cleaned up. + */ +function insertDefinitionList() { + const term = t('editor.markup.definitionListTerm') + const definition = t('editor.markup.definitionListDefinition') + const skeleton = `${term}\n: ${definition}\n\n${term}\n: ${definition}` + + const model = editor.getModel() + const position = editor.getPosition() + const line = model.getLineContent(position.lineNumber) + // -> A term has to start its own line, so a cursor mid-sentence breaks out of it first — the same + // rule the table and the blocks follow + 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}${skeleton}${after}` }) + + const firstTermLine = position.lineNumber + (before ? 2 : 0) + editor.setSelection(new Range(firstTermLine, 1, firstTermLine, term.length + 1)) +} + /** * Toggle Markup at selection */ diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss index f59b0c087..965ad2606 100644 --- a/frontend/src/css/_page-contents.scss +++ b/frontend/src/css/_page-contents.scss @@ -888,6 +888,54 @@ } } + /* + Definition lists, from the `term` / `: definition` notation (`markdown-it-deflist`). + + Drawn as a term in the heading weight with its definitions indented under it, which is the + treatment every documentation platform has settled on -- and deliberately NOT with a rule down + the left of the definition: in this stylesheet a left bar means a quote, and the links-list + description a few rules up gives up its own rule on a phone for exactly that reason. + + The indent is the one `ul` and `ol` use and the spacing is `li`'s, so a definition list sitting + between two ordinary lists lines up with them rather than reading as a different kind of thing. + */ + dl { + margin: 0 0 1.15em; + } + + dt { + margin-top: 1em; + font-weight: 600; + + /* -> The first term opens the list; the space above it belongs to whatever came before */ + &:first-child { + margin-top: 0; + } + } + + dd { + /* -> Also what separates two definitions of the same term, which markdown allows */ + margin: 0.35em 0 0 1.6em; + + /* + A one-paragraph definition is tight; a multi-paragraph one spaces its own paragraphs. Markdown + produces either shape depending on whether the list is "loose", exactly as it does for `li`. + */ + > p { + margin: 0; + + + p { + margin-top: 0.6em; + } + } + + /* A list inside a definition belongs to it, so it sits closer than a sibling would */ + > ul, + > ol { + margin: 0.35em 0 0; + } + } + // --------------------------------------------------------------------------- // QUOTES AND ADMONITIONS // --------------------------------------------------------------------------- diff --git a/frontend/src/helpers/anchors.js b/frontend/src/helpers/anchors.js index 652c92420..9ae464476 100644 --- a/frontend/src/helpers/anchors.js +++ b/frontend/src/helpers/anchors.js @@ -139,12 +139,29 @@ function whenScrollEnded(scroller) { }) } +/** Mark where the reader is being sent and take them there, if it is on the page to be taken to. */ +function land(el, smooth) { + if (!isVisible(el)) { + return false + } + markLanded(el) + scrollTo(el, smooth) + return true +} + /** * Scroll a heading into view, asking whatever is above it to reveal it first. * * For a page that is already settled — a click on the contents list, say. See * `scrollToAnchorWhenReady` for one that has only just been rendered. * + * A block asked to reveal something does not do it in this tick: `block-tabs` sets which panel is + * open and Lit draws that on its own update cycle, so the target still has no box to scroll to by the + * time the event handler returns. Hence the second attempt a frame later — by then the panel is + * showing — and hence the answer being yes before it has happened: the caller is asking whether the + * reader is being taken somewhere, which decides whether it claims the click, and a target that had + * to be revealed is still somewhere to go. + * * @returns Whether there was a heading to scroll to */ export function scrollToAnchor(hash, { smooth = false } = {}) { @@ -153,11 +170,9 @@ export function scrollToAnchor(hash, { smooth = false } = {}) { return false } reveal(target) - if (!isVisible(target)) { - return false + if (!land(target, smooth)) { + requestAnimationFrame(() => land(target, smooth)) } - markLanded(target) - scrollTo(target, smooth) return true } diff --git a/frontend/src/renderers/markdown.js b/frontend/src/renderers/markdown.js index 875557e2a..f43b2f7fc 100644 --- a/frontend/src/renderers/markdown.js +++ b/frontend/src/renderers/markdown.js @@ -8,6 +8,7 @@ import mdAbbr from 'markdown-it-abbr' import mdSup from 'markdown-it-sup' import mdSub from 'markdown-it-sub' import mdMark from 'markdown-it-mark' +import mdDeflist from 'markdown-it-deflist' import mdMultiTable from 'markdown-it-multimd-table' import mdFootnote from 'markdown-it-footnote' import mdMdc from 'markdown-it-mdc' @@ -275,6 +276,7 @@ export class MarkdownRenderer { .use(mdSup) .use(mdSub) .use(mdMark) + .use(mdDeflist) .use(mdFootnote) .use(mdImsize) .use(mdGithubAlerts)