feat: add highlight, def lists and tab as headers in markdown editor

scarlett
NGPixel 3 weeks ago
parent 8ce6b6e62e
commit aae079731b
No known key found for this signature in database

@ -1725,16 +1725,20 @@
"editor.markup.blockquoteSuccess": "Success Blockquote", "editor.markup.blockquoteSuccess": "Success Blockquote",
"editor.markup.blockquoteWarning": "Warning Blockquote", "editor.markup.blockquoteWarning": "Warning Blockquote",
"editor.markup.bold": "Bold", "editor.markup.bold": "Bold",
"editor.markup.definitionListDefinition": "Definition",
"editor.markup.definitionListTerm": "Term",
"editor.markup.distractionFreeMode": "Distraction Free Mode", "editor.markup.distractionFreeMode": "Distraction Free Mode",
"editor.markup.editBlock": "Edit Block Parameters", "editor.markup.editBlock": "Edit Block Parameters",
"editor.markup.editTable": "Edit in Table Editor", "editor.markup.editTable": "Edit in Table Editor",
"editor.markup.header": "Header", "editor.markup.header": "Header",
"editor.markup.headerLevel": "Header {level}", "editor.markup.headerLevel": "Header {level}",
"editor.markup.heading": "Heading {level}", "editor.markup.heading": "Heading {level}",
"editor.markup.highlight": "Highlight",
"editor.markup.inlineCode": "Inline Code", "editor.markup.inlineCode": "Inline Code",
"editor.markup.insertAssets": "Insert Assets", "editor.markup.insertAssets": "Insert Assets",
"editor.markup.insertBlock": "Insert Block", "editor.markup.insertBlock": "Insert Block",
"editor.markup.insertCodeBlock": "Insert Code Block", "editor.markup.insertCodeBlock": "Insert Code Block",
"editor.markup.insertDefinitionList": "Insert Definition List",
"editor.markup.insertDiagram": "Insert Diagram", "editor.markup.insertDiagram": "Insert Diagram",
"editor.markup.insertEmoji": "Insert Emoji", "editor.markup.insertEmoji": "Insert Emoji",
"editor.markup.insertFootnote": "Insert Footnote", "editor.markup.insertFootnote": "Insert Footnote",

@ -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 { class Rendering {
/** /**
* Clean up a render that came from a client, and pull out what is derived from it. * 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 * 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. * 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[] { private anchorHeadings($: cheerio.CheerioAPI): TocNode[] {
const used = new Map<string, number>() const used = new Map<string, number>()
const flat: { level: number; node: TocNode }[] = [] const flat: { level: number; node: TocNode }[] = []
$('h1, h2, h3, h4, h5, h6').each((_, el) => { $(`h1, h2, h3, h4, h5, h6, ${TAB_TAG}[header]`).each((_, el) => {
const heading = $(el) const element = $(el)
const label = heading.text().trim() const isTab = el.tagName === TAB_TAG
let key = heading.attr('id') || slugifyHeading(label) 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 // -> Two headings can legitimately read the same; the second one becomes `-1`, as anchors
// generally do, so that both remain addressable // generally do, so that both remain addressable
@ -708,8 +750,7 @@ class Rendering {
key = `${key}-${seen}` key = `${key}-${seen}`
} }
heading.attr('id', key) element.attr('id', key)
const level = Number.parseInt(el.tagName.slice(1), 10)
flat.push({ flat.push({
level, level,
node: { key: `#${key}`, label, level, children: [] } node: { key: `#${key}`, label, level, children: [] }

@ -5,6 +5,12 @@
* `icon`, builds the strip from them and shows or hides it. Its content is ordinary page content, * `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. * 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 * 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. * every undefined element it finds in a page, has something to fetch.
*/ */
@ -37,6 +43,12 @@ export class BlockTabElement extends HTMLElement {
type: 'string', type: 'string',
label: 'Icon', label: 'Icon',
hint: 'Iconify reference drawn to the left of the label, e.g. mdi:language-python.' 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.'
} }
] ]
} }

@ -245,6 +245,13 @@ Content of the second tab.
} }
const margin = `${strip.offsetHeight + 20}px` const margin = `${strip.offsetHeight + 20}px`
for (const { panel } of this._tabs) { 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) { for (const child of panel.children) {
child.style.setProperty('scroll-margin-top', margin) child.style.setProperty('scroll-margin-top', margin)
} }

@ -27,6 +27,7 @@
"markdown-it-abbr": "2.0.0", "markdown-it-abbr": "2.0.0",
"markdown-it-attrs": "5.0.1", "markdown-it-attrs": "5.0.1",
"markdown-it-decorate": "1.2.2", "markdown-it-decorate": "1.2.2",
"markdown-it-deflist": "4.0.0",
"markdown-it-emoji": "3.1.0", "markdown-it-emoji": "3.1.0",
"markdown-it-expand-tabs": "1.0.13", "markdown-it-expand-tabs": "1.0.13",
"markdown-it-footnote": "4.0.0", "markdown-it-footnote": "4.0.0",
@ -3743,6 +3744,22 @@
"integrity": "sha512-7BFWJ97KBXgkaPVjKHISQnhSW8RWQ7yRNXpr8pPUV2Rw4GHvGrgb6CelKCM+GSijP0uSLCAVfc/knWIz+2v/Sw==", "integrity": "sha512-7BFWJ97KBXgkaPVjKHISQnhSW8RWQ7yRNXpr8pPUV2Rw4GHvGrgb6CelKCM+GSijP0uSLCAVfc/knWIz+2v/Sw==",
"license": "MIT" "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": { "node_modules/markdown-it-emoji": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-3.1.0.tgz", "resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-3.1.0.tgz",

@ -36,6 +36,7 @@
"markdown-it-abbr": "2.0.0", "markdown-it-abbr": "2.0.0",
"markdown-it-attrs": "5.0.1", "markdown-it-attrs": "5.0.1",
"markdown-it-decorate": "1.2.2", "markdown-it-decorate": "1.2.2",
"markdown-it-deflist": "4.0.0",
"markdown-it-emoji": "3.1.0", "markdown-it-emoji": "3.1.0",
"markdown-it-expand-tabs": "1.0.13", "markdown-it-expand-tabs": "1.0.13",
"markdown-it-footnote": "4.0.0", "markdown-it-footnote": "4.0.0",

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or 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. removing an icon; `check-icons.mjs` fails the build if this drifts.
266 icons. 268 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32}, "la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -196,6 +196,7 @@ export const BUNDLED_ICONS = {
"mdi:format-align-right": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm6 4h12v2H9zm-6 4h18v2H3zm6 4h12v2H9zm-6 4h18v2H3z\"/>","width":24,"height":24}, "mdi:format-align-right": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm6 4h12v2H9zm-6 4h18v2H3zm6 4h12v2H9zm-6 4h18v2H3z\"/>","width":24,"height":24},
"mdi:format-bold": {"body":"<path fill=\"currentColor\" d=\"M13.5 15.5H10v-3h3.5A1.5 1.5 0 0 1 15 14a1.5 1.5 0 0 1-1.5 1.5m-3.5-9h3A1.5 1.5 0 0 1 14.5 8A1.5 1.5 0 0 1 13 9.5h-3m5.6 1.29c.97-.68 1.65-1.79 1.65-2.79c0-2.26-1.75-4-4-4H7v14h7.04c2.1 0 3.71-1.7 3.71-3.79c0-1.52-.86-2.82-2.15-3.42\"/>","width":24,"height":24}, "mdi:format-bold": {"body":"<path fill=\"currentColor\" d=\"M13.5 15.5H10v-3h3.5A1.5 1.5 0 0 1 15 14a1.5 1.5 0 0 1-1.5 1.5m-3.5-9h3A1.5 1.5 0 0 1 14.5 8A1.5 1.5 0 0 1 13 9.5h-3m5.6 1.29c.97-.68 1.65-1.79 1.65-2.79c0-2.26-1.75-4-4-4H7v14h7.04c2.1 0 3.71-1.7 3.71-3.79c0-1.52-.86-2.82-2.15-3.42\"/>","width":24,"height":24},
"mdi:format-clear": {"body":"<path fill=\"currentColor\" d=\"M6 5v.18L8.82 8h2.4l-.72 1.68l2.1 2.1L14.21 8H20V5zM3.27 5L2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21L18 19.73L3.55 5.27z\"/>","width":24,"height":24}, "mdi:format-clear": {"body":"<path fill=\"currentColor\" d=\"M6 5v.18L8.82 8h2.4l-.72 1.68l2.1 2.1L14.21 8H20V5zM3.27 5L2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21L18 19.73L3.55 5.27z\"/>","width":24,"height":24},
"mdi:format-color-highlight": {"body":"<path fill=\"currentColor\" d=\"m4 17l2.75-2.75l-.03-.02c-.58-.59-.58-1.54 0-2.12l4.74-4.74l4.24 4.24l-4.74 4.74c-.57.58-1.5.58-2.09.02l-.63.63zM15.91 2.91c.59-.58 1.54-.58 2.12 0l2.13 2.12c.58.59.58 1.54 0 2.13l-3.3 3.29l-4.24-4.24z\"/>","width":24,"height":24},
"mdi:format-font": {"body":"<path fill=\"currentColor\" d=\"M17 8h3v12h1v1h-4v-1h1v-3h-4l-1.5 3H14v1h-4v-1h1zm1 1l-3.5 7H18zM5 3h5c1.11 0 2 .89 2 2v11H9v-5H6v5H3V5c0-1.11.89-2 2-2m1 2v4h3V5z\"/>","width":24,"height":24}, "mdi:format-font": {"body":"<path fill=\"currentColor\" d=\"M17 8h3v12h1v1h-4v-1h1v-3h-4l-1.5 3H14v1h-4v-1h1zm1 1l-3.5 7H18zM5 3h5c1.11 0 2 .89 2 2v11H9v-5H6v5H3V5c0-1.11.89-2 2-2m1 2v4h3V5z\"/>","width":24,"height":24},
"mdi:format-header-1": {"body":"<path fill=\"currentColor\" d=\"M3 4h2v6h4V4h2v14H9v-6H5v6H3zm11 14v-2h2V6.31l-2.5 1.44V5.44L16 4h2v12h2v2z\"/>","width":24,"height":24}, "mdi:format-header-1": {"body":"<path fill=\"currentColor\" d=\"M3 4h2v6h4V4h2v14H9v-6H5v6H3zm11 14v-2h2V6.31l-2.5 1.44V5.44L16 4h2v12h2v2z\"/>","width":24,"height":24},
"mdi:format-header-2": {"body":"<path fill=\"currentColor\" d=\"M3 4h2v6h4V4h2v14H9v-6H5v6H3zm18 14h-6a2 2 0 0 1-2-2c0-.53.2-1 .54-1.36l4.87-5.23c.37-.36.59-.86.59-1.41a2 2 0 0 0-2-2a2 2 0 0 0-2 2h-2a4 4 0 0 1 4-4a4 4 0 0 1 4 4c0 1.1-.45 2.1-1.17 2.83L15 16h6z\"/>","width":24,"height":24}, "mdi:format-header-2": {"body":"<path fill=\"currentColor\" d=\"M3 4h2v6h4V4h2v14H9v-6H5v6H3zm18 14h-6a2 2 0 0 1-2-2c0-.53.2-1 .54-1.36l4.87-5.23c.37-.36.59-.86.59-1.41a2 2 0 0 0-2-2a2 2 0 0 0-2 2h-2a4 4 0 0 1 4-4a4 4 0 0 1 4 4c0 1.1-.45 2.1-1.17 2.83L15 16h6z\"/>","width":24,"height":24},
@ -209,6 +210,7 @@ export const BUNDLED_ICONS = {
"mdi:format-italic": {"body":"<path fill=\"currentColor\" d=\"M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z\"/>","width":24,"height":24}, "mdi:format-italic": {"body":"<path fill=\"currentColor\" d=\"M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z\"/>","width":24,"height":24},
"mdi:format-list-bulleted": {"body":"<path fill=\"currentColor\" d=\"M7 5h14v2H7zm0 8v-2h14v2zM4 4.5A1.5 1.5 0 0 1 5.5 6A1.5 1.5 0 0 1 4 7.5A1.5 1.5 0 0 1 2.5 6A1.5 1.5 0 0 1 4 4.5m0 6A1.5 1.5 0 0 1 5.5 12A1.5 1.5 0 0 1 4 13.5A1.5 1.5 0 0 1 2.5 12A1.5 1.5 0 0 1 4 10.5M7 19v-2h14v2zm-3-2.5A1.5 1.5 0 0 1 5.5 18A1.5 1.5 0 0 1 4 19.5A1.5 1.5 0 0 1 2.5 18A1.5 1.5 0 0 1 4 16.5\"/>","width":24,"height":24}, "mdi:format-list-bulleted": {"body":"<path fill=\"currentColor\" d=\"M7 5h14v2H7zm0 8v-2h14v2zM4 4.5A1.5 1.5 0 0 1 5.5 6A1.5 1.5 0 0 1 4 7.5A1.5 1.5 0 0 1 2.5 6A1.5 1.5 0 0 1 4 4.5m0 6A1.5 1.5 0 0 1 5.5 12A1.5 1.5 0 0 1 4 13.5A1.5 1.5 0 0 1 2.5 12A1.5 1.5 0 0 1 4 10.5M7 19v-2h14v2zm-3-2.5A1.5 1.5 0 0 1 5.5 18A1.5 1.5 0 0 1 4 19.5A1.5 1.5 0 0 1 2.5 18A1.5 1.5 0 0 1 4 16.5\"/>","width":24,"height":24},
"mdi:format-list-checks": {"body":"<path fill=\"currentColor\" d=\"M3 5h6v6H3zm2 2v2h2V7zm6 0h10v2H11zm0 8h10v2H11zm-6 5l-3.5-3.5l1.41-1.41L5 17.17l4.59-4.58L11 14z\"/>","width":24,"height":24}, "mdi:format-list-checks": {"body":"<path fill=\"currentColor\" d=\"M3 5h6v6H3zm2 2v2h2V7zm6 0h10v2H11zm0 8h10v2H11zm-6 5l-3.5-3.5l1.41-1.41L5 17.17l4.59-4.58L11 14z\"/>","width":24,"height":24},
"mdi:format-list-group-plus": {"body":"<path fill=\"currentColor\" d=\"M17 14v3h-3v2h3v3h2v-3h3v-2h-3v-3m1-3v1.3c-.6-.2-1.3-.3-2-.3c-1.2 0-2.4.4-3.3 1H7v-2zm-7.9 6H7v-2h5.8c-.3.6-.6 1.3-.7 2M7 7h13v2H7zM5 19h2v2H3V3h4v2H5z\"/>","width":24,"height":24},
"mdi:format-list-numbered": {"body":"<path fill=\"currentColor\" d=\"M7 13v-2h14v2zm0 6v-2h14v2zM7 7V5h14v2zM3 8V5H2V4h2v4zm-1 9v-1h3v4H2v-1h2v-.5H3v-1h1V17zm2.25-7a.75.75 0 0 1 .75.75c0 .2-.08.39-.21.52L3.12 13H5v1H2v-.92L4 11H2v-1z\"/>","width":24,"height":24}, "mdi:format-list-numbered": {"body":"<path fill=\"currentColor\" d=\"M7 13v-2h14v2zm0 6v-2h14v2zM7 7V5h14v2zM3 8V5H2V4h2v4zm-1 9v-1h3v4H2v-1h2v-.5H3v-1h1V17zm2.25-7a.75.75 0 0 1 .75.75c0 .2-.08.39-.21.52L3.12 13H5v1H2v-.92L4 11H2v-1z\"/>","width":24,"height":24},
"mdi:format-page-break": {"body":"<path fill=\"currentColor\" d=\"M18 20H6v-2H4v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2h-2zM14 2H6a2 2 0 0 0-2 2v8h2V4h8v4h4v4h2V8zm-3 14H8v-2h3zm5 0h-3v-2h3zM3 14h3v2H3zm18 2h-3v-2h3z\"/>","width":24,"height":24}, "mdi:format-page-break": {"body":"<path fill=\"currentColor\" d=\"M18 20H6v-2H4v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2h-2zM14 2H6a2 2 0 0 0-2 2v8h2V4h8v4h4v4h2V8zm-3 14H8v-2h3zm5 0h-3v-2h3zM3 14h3v2H3zm18 2h-3v-2h3z\"/>","width":24,"height":24},
"mdi:format-paragraph": {"body":"<path fill=\"currentColor\" d=\"M13 4a4 4 0 0 1 4 4a4 4 0 0 1-4 4h-2v6H9V4zm0 6a2 2 0 0 0 2-2a2 2 0 0 0-2-2h-2v4z\"/>","width":24,"height":24}, "mdi:format-paragraph": {"body":"<path fill=\"currentColor\" d=\"M13 4a4 4 0 0 1 4 4a4 4 0 0 1-4 4h-2v6H9V4zm0 6a2 2 0 0 0 2-2a2 2 0 0 0-2-2h-2v4z\"/>","width":24,"height":24},

@ -39,6 +39,11 @@
t('editor.markup.insertBlock') t('editor.markup.insertBlock')
}}</w-tooltip> }}</w-tooltip>
</w-btn> </w-btn>
<w-btn icon="mdi:format-list-group-plus" padding="sm sm" flat @click="insertDefinitionList">
<w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertDefinitionList')
}}</w-tooltip>
</w-btn>
<w-btn icon="mdi:book-plus" padding="sm sm" flat @click="insertFootnote"> <w-btn icon="mdi:book-plus" padding="sm sm" flat @click="insertFootnote">
<w-tooltip anchor="center right" self="center left">{{ <w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertFootnote') t('editor.markup.insertFootnote')
@ -96,6 +101,15 @@
t('editor.markup.strikethrough') t('editor.markup.strikethrough')
}}</w-tooltip> }}</w-tooltip>
</w-btn> </w-btn>
<w-btn
icon="mdi:format-color-highlight"
padding="xs sm"
flat
@click="toggleMarkup({ start: `==` })">
<w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.highlight')
}}</w-tooltip>
</w-btn>
<w-btn icon="mdi:format-header-pound" padding="xs sm" flat> <w-btn icon="mdi:format-header-pound" padding="xs sm" flat>
<w-tooltip anchor="top middle" self="bottom middle">{{ <w-tooltip anchor="top middle" self="bottom middle">{{
t('editor.markup.header') t('editor.markup.header')
@ -574,7 +588,8 @@ async function insertTabset() {
}) })
return return
} }
insertBlockClb(blockMarkdown(tabs)) const markdown = blockMarkdown(tabs)
selectFirstTabLabel(markdown, insertBlockClb(markdown))
} catch (err) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
@ -596,6 +611,44 @@ function insertBlockClb(markdown) {
const before = line.slice(0, position.column - 1).trim().length > 0 ? '\n\n' : '' 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' const after = line.slice(position.column - 1).trim().length > 0 ? '\n\n' : '\n'
insertAtCursor({ content: `${before}${markdown}${after}` }) 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() { function insertTable() {
@ -906,6 +959,35 @@ function insertHorizontalBar() {
insertAfter({ content: '---', newLine: true }) 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 * Toggle Markup at selection
*/ */

@ -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 // QUOTES AND ADMONITIONS
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

@ -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. * 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 * For a page that is already settled a click on the contents list, say. See
* `scrollToAnchorWhenReady` for one that has only just been rendered. * `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 * @returns Whether there was a heading to scroll to
*/ */
export function scrollToAnchor(hash, { smooth = false } = {}) { export function scrollToAnchor(hash, { smooth = false } = {}) {
@ -153,11 +170,9 @@ export function scrollToAnchor(hash, { smooth = false } = {}) {
return false return false
} }
reveal(target) reveal(target)
if (!isVisible(target)) { if (!land(target, smooth)) {
return false requestAnimationFrame(() => land(target, smooth))
} }
markLanded(target)
scrollTo(target, smooth)
return true return true
} }

@ -8,6 +8,7 @@ import mdAbbr from 'markdown-it-abbr'
import mdSup from 'markdown-it-sup' import mdSup from 'markdown-it-sup'
import mdSub from 'markdown-it-sub' import mdSub from 'markdown-it-sub'
import mdMark from 'markdown-it-mark' import mdMark from 'markdown-it-mark'
import mdDeflist from 'markdown-it-deflist'
import mdMultiTable from 'markdown-it-multimd-table' import mdMultiTable from 'markdown-it-multimd-table'
import mdFootnote from 'markdown-it-footnote' import mdFootnote from 'markdown-it-footnote'
import mdMdc from 'markdown-it-mdc' import mdMdc from 'markdown-it-mdc'
@ -275,6 +276,7 @@ export class MarkdownRenderer {
.use(mdSup) .use(mdSup)
.use(mdSub) .use(mdSub)
.use(mdMark) .use(mdMark)
.use(mdDeflist)
.use(mdFootnote) .use(mdFootnote)
.use(mdImsize) .use(mdImsize)
.use(mdGithubAlerts) .use(mdGithubAlerts)

Loading…
Cancel
Save