Document structure
On this page
Five opt-in packages add structure and references to a document. Each is a separate plugin with its own commands, hooks and interface, so an editor only pays for the ones it installs. None is part of the standard preset.
| Package | Adds |
|---|---|
@better-rich-text/plugin-anchor |
Named anchors (bookmarks), heading anchors, links to both |
@better-rich-text/plugin-footnotes |
Numbered footnotes with a notes list |
@better-rich-text/plugin-toc |
A table of contents generated from headings |
@better-rich-text/plugin-details |
Expandable sections (<details>/<summary>) |
@better-rich-text/plugin-callout |
Info, success, warning, danger and note callouts |
The demo is apps/playground/structure.html.
Setup
import { createRichTextEditor, standardPreset } from '@better-rich-text/core';
import { anchorFeature, anchorPlugin } from '@better-rich-text/plugin-anchor';
import { footnotesFeature, footnotesPlugin } from '@better-rich-text/plugin-footnotes';
import { tocFeature, tocPlugin } from '@better-rich-text/plugin-toc';
import { detailsFeature, detailsPlugin } from '@better-rich-text/plugin-details';
import { calloutFeature, calloutPlugin } from '@better-rich-text/plugin-callout';
createRichTextEditor('#editor', {
plugins: [anchorPlugin, footnotesPlugin, tocPlugin, detailsPlugin, calloutPlugin],
features: [...standardPreset.features, anchorFeature, footnotesFeature, tocFeature, detailsFeature, calloutFeature],
toolbar: 'undo redo | blocks | bold italic | link anchor anchorLink | footnote toc details callout',
});Each plugin contributes its toolbar items, so the toolbar ids work without the features. The features add the parts that need the interface:
anchorFeatureadds an "Or jump to" picker to the link editor, and Enter on a selected anchor opens its dialog.footnotesFeatureopens the note editor when you click a reference or a note, or press Enter on a selected reference.tocFeatureopens the settings when you press Enter on a selected contents block.
Toolbar ids: anchor, anchorLink, footnote, toc, details, callout.
plugin-toc uses the outline helpers from plugin-anchor but does not need the anchor plugin installed. Linking to anchors and headings (anchor.link) needs plugin-link.
Anchors
An anchor is an inline node with an id. The id must start with a letter and contain only letters, digits, hyphens and underscores, and it must be unique in the document. Headings from plugin-blocks share the same id space through their anchor attribute.
| Command | Input | Result |
|---|---|---|
anchor.insert |
{ id } |
{ nodeId, id }. Fails with invalid-input when the id is malformed or taken. |
anchor.rename |
{ id?, newId, updateLinks? } |
{ id, links }. Renames an anchor or heading anchor (the selected anchor when id is omitted) and rewrites #old links. |
anchor.remove |
{ id? } |
{ id, danglingLinks }. Links to it stay and are counted. |
anchor.list |
none | { targets }: anchors and headings in document order. |
anchor.ensureHeading |
{ headingId } |
{ id }. Gives a heading a stable anchor from its text. |
anchor.link |
{ target: { id } | { headingId }, text? } |
{ id }. Links the selection, or inserts text as a link. |
Linking to a heading without an anchor creates one from its text first, in the same undo step. The anchor then stays the same when the heading text changes.
Pasted anchors and heading anchors that clash with an existing id get a fresh one (top-2), and links inside the pasted content follow the rename.
The anchor HTML is <a id="name"></a>. Import also reads <a name="name"></a>.
Footnotes
A footnote is an inline reference. Its note is stored on the reference itself as formatted text runs (content). Numbers are never stored: they come from document order.
| Command | Input | Result |
|---|---|---|
footnote.insert |
{ text? } or { content? } |
{ nodeId, number }. Inserted after the selection, which is kept. |
footnote.update |
{ nodeId?, text? | content? } |
{ nodeId, number }. The selected footnote when nodeId is omitted. |
footnote.remove |
{ nodeId? } |
{ nodeId } |
footnote.list |
none | { footnotes: [{ nodeId, number, label, text }] } |
content is a list of runs, { text, marks? }, where marks use the editor's own mark types (bold, italic, links and so on). Unknown marks, review marks and control characters are removed, and the note is capped at 10,000 characters.
In the editor, references show their number and the notes appear as a list under the editable area. That list is not part of the document. Each note has a button that selects its reference.
Why the note lives on the reference
Notes could also live in a footnotes section at the end of the document, with references pointing at them. Keeping the note on the reference works better for the three cases that matter most:
Copy and paste: copying a reference always copies its note, inside this editor and between documents. There is no section to keep in sync and no orphaned or missing notes.
Undo: inserting, editing or deleting a footnote changes one node, so it is always one undo step, and undoing a deletion brings the note back.
Collaboration: two people adding footnotes at the same time edit different nodes. Numbering is derived, so it cannot conflict.
The cost is that notes are edited in a dialog rather than in place at the end of the document, and two references cannot share one note.
HTML
<p>Text<sup class="footnote-ref"><a href="#fn-1" id="fnref-1" role="doc-noteref">1</a></sup></p>
<section class="footnotes" role="doc-endnotes" aria-label="Footnotes">
<ol><li id="fn-1">Note text <a href="#fnref-1" class="footnote-backref" role="doc-backlink" aria-label="Back to reference 1">↩</a></li></ol>
</section>The section follows whatever was serialized, so copied content carries its own notes, numbered from 1. Import reads this markup back, and also Pandoc, GitHub and TinyMCE footnotes. A <sup> link is only treated as a footnote when it is marked as one (footnote-ref, data-footnote-ref, role="doc-noteref" or mce-footnote).
Options: createFootnotesPlugin({ idPrefix, showList, sectionLabel }). idPrefix changes fn-1/fnref-1, showList: false hides the list under the editor.
Table of contents
A tableOfContents block lists the headings between minLevel and maxLevel. Its entries are generated from the current document every time, never stored, so they follow every heading edit, move, paste and undo.
| Command | Input | Result |
|---|---|---|
toc.insert |
{ title?, minLevel?, maxLevel?, numbered?, anchorHeadings? } |
{ nodeId } |
toc.update |
{ nodeId?, title?, minLevel?, maxLevel?, numbered? } |
{ nodeId } |
toc.remove |
{ nodeId? } |
{ nodeId } |
toc.entries |
{ nodeId? } |
{ entries: [{ nodeId, level, text, id }] } |
toc.goTo |
{ headingId } |
Moves the caret to a heading. |
Defaults: title "Contents", levels 1 to 3, bulleted. Without nodeId, commands use the selected contents block or the first one in the document.
Entries link to heading anchors. toc.insert gives the headings in range a stable anchor (turn this off with anchorHeadings: false). Headings added later get an id derived from their text when exported; these never collide with stored ids or with each other (notes, notes-2).
Clicking an entry in the editor moves the caret to that heading.
HTML
<nav class="toc" aria-label="Contents" data-toc-levels="1-3">
<p class="toc-title">Contents</p>
<ul><li><a href="#overview">Overview</a><ul><li><a href="#install">Install</a></li></ul></li></ul>
</nav>A skipped level (h1 then h3) nests one step. numbered: true writes <ol> and data-numbered="true". Import reads nav.toc and TinyMCE's div.mce-toc, keeping the title and levels and regenerating the entries.
Expandable sections
A details node holds a detailsSummary and one or more blocks.
| Command | Input | Result |
|---|---|---|
details.insert |
{ open? } |
Wraps the selected blocks; the caret moves to the empty summary. |
details.toggle |
none | Wraps the selection, or removes the section around the caret. |
details.unwrap |
{ nodeId? } |
The summary becomes a paragraph (dropped when empty). |
details.setOpen |
{ nodeId?, open } |
Sets the published default. |
details.toggleOpen |
{ nodeId? } |
Switches the published default. |
open is the authored default for published HTML. Collapsing a section while editing is local view state: it never changes the document or what other people see. Moving the caret into a collapsed body (search, undo, arrow keys) expands it again. The "Starts open/closed" button in the section switches the published default.
Keyboard
Enter in the summary moves into the body.
Enter in an empty last paragraph of the body leaves the section.
Backspace at the start of an empty summary removes the section and keeps the body.
Backspace at the start of a non-empty summary does nothing, so the summary is never merged away.
HTML is <details open><summary>…</summary>…</details>. Import also reads TinyMCE accordions.
Callouts
A callout block wraps other blocks and has a kind: info, success, warning, danger or note.
| Command | Input | Result |
|---|---|---|
callout.insert |
{ kind? } |
Wraps the selected blocks. |
callout.toggle |
{ kind? } |
Wraps, changes the kind, or removes the callout when it already has that kind. |
callout.setKind |
{ kind, nodeId? } |
|
callout.remove |
{ nodeId? } |
Replaces the callout with its content. |
callout.exit |
{ edge? } |
Moves an empty first or last paragraph out. Bound to Enter and Backspace. |
The editor shows an icon for each kind. Colours come from the skin tokens --brt-info (falling back to --brt-accent), --brt-success, --brt-warning, --brt-danger and --brt-text-muted.
HTML
<div class="callout callout-warning" data-callout="warning" role="note" aria-label="Warning"><p>…</p></div>The icon is not exported; style .callout-* in the host page. Import also reads GitHub alerts (markdown-alert-*) and Docusaurus-style admonitions, mapping tip to success and caution to warning.
Hooks
| Hook | Type | Arguments |
|---|---|---|
anchor.slug |
filter | (slug, text): slug for a new heading anchor |
anchor.targets |
filter | (targets, state): entries for anchor.list and the pickers |
anchor.renamed |
action | ({ from, to, links }) |
footnotes.number |
filter | (label, number, node): displayed label, for example *, † or roman numerals |
footnotes.section |
filter | (outputSpec, notes): the exported notes section |
toc.entries |
filter | (entries, tocNode): reorder, rename or drop entries |
link.editorOpened |
action | ({ ui, form, url }): lets features add fields to the link editor (declared in @better-rich-text/ui) |
Limitations
Footnote notes are edited in a dialog, not in place in the notes list.
Two references cannot share a note, and there are no endnotes or orphaned notes: deleting a reference deletes its note (undo restores it).
Footnote numbering is arabic in document order unless
footnotes.numberchanges it. In the editor, a number comes from the same filter.Cross-reference nodes (live "see section 3" text) and page-number references are not included. Use
anchor.linkfor a plain link.outline.moveSectionis not implemented.Heading anchors are created when something links to a heading or when a contents block is inserted. Other headings get derived ids in exported HTML only.
Accordion title validation for publishing (required non-empty summary) is not enforced; an empty summary shows a placeholder.
The footnote list is placed after the editable element. In inline mode that is the host page.
Callout icons are editor-only and are not written to HTML.