Custom document outline¶
Use this guide to wire a table-of-contents sidebar that follows the document structure and supports click-to-jump.
When to derive the outline¶
| Context | Argument |
|---|---|
| Web component | editor.document (ProseMirror) or editor-change payload (detail.doc) |
Core Editor instance |
editor.getDoc() |
Use the JSON snapshot from editor-change to derive heading data. For click-to-jump behavior, pair the heading positions with the stable position helpers exposed on the web component (editor.positions) or core editor APIs.
Rendering your own panel¶
Do not hand-roll the traversal. @sciflow/editor-start exports the same collector
<sciflow-outline> uses, so your own panel gets identical text and positions:
import { collectDocumentOutline } from '@sciflow/editor-start';
function renderOutline(docJson, container) {
// Pass the live ProseMirror document as the second argument whenever you have one:
// it is the only path that yields real `position` / `end` offsets. With the JSON
// snapshot alone both are `null` and click-to-jump is not possible.
const outline = collectDocumentOutline(docJson, editor.document);
container.innerHTML = '';
outline.headings.forEach((heading) => {
const item = document.createElement('li');
item.textContent = heading.text || 'Untitled section';
item.dataset.level = String(heading.level ?? 1);
if (heading.position != null) {
item.addEventListener('click', () => jumpTo(heading.position));
}
container.appendChild(item);
});
}
editor.addEventListener('editor-change', (event) => {
renderOutline(event.detail.doc, outlineContainer);
});
collectDocumentOutline(doc, pmDoc?) returns { headings, citations, figures }. Each heading is
{ text, level, id, position, end }. It walks the whole document, so headings nested inside
part containers are included, and it concatenates every child of a heading rather than only the
first one.
Headings containing math¶
Headings admit math children. The collector reduces each one with texToHeadingText so a
heading never renders empty or truncated in a plain-text panel:
{
"type": "heading",
"attrs": { "id": "h-2", "level": 2 },
"content": [
{ "type": "text", "text": "Convergence of " },
{ "type": "math", "attrs": { "tex": "\\mathbf{V}_{\\mathbf{set}}", "style": "inline" } },
{ "type": "text", "text": " under load" }
]
}
produces text: "Convergence of V_set under load". Commands the reduction cannot safely
approximate are left verbatim (\alpha stays \alpha), and for some inputs the reduction is
empty — the heading.text || 'Untitled section' fallback above is what handles that case.
A footnote child contributes nothing to text, deliberately: a footnote is content about
the heading, not part of its title. Any other non-text child (a bookmark, for instance) also
contributes nothing.
Scrolling into view¶
The outline handler can call commands.setSelection(position, { scroll: false }) followed by commands.scrollIntoView() and commands.focus() to keep behavior consistent with the editor’s native navigation.
function jumpTo(position) {
const runner = editor.commands;
if (runner?.commands?.setSelection) {
const didSet = runner.commands.setSelection(position, { scroll: false });
if (didSet && runner.commands.scrollIntoView) {
runner.commands.scrollIntoView();
}
runner.commands.focus?.();
}
}
Re-render triggers¶
editor-change– update the outline whenever the document structure changes.- Feature toggles – if you enable/disable heading support on the fly, re-run
renderOutlineafter callingeditor.configureFeatures(...).
Position helpers
Use the positions API to map document offsets to screen coordinates (coordsAtPos) and to resolve positional context (resolve). See Web Components Basics for the full position API surface.
Using the sciflow-outline web component¶
<sciflow-outline> is a ready-made outline panel shipped by @sciflow/editor-start. It handles document syncing, heading/figure display, click-to-jump navigation, drag-and-drop cross references, and Insert ref buttons automatically.
<sciflow-outline for="my-editor"></sciflow-outline>
<sciflow-editor id="my-editor"></sciflow-editor>
Or pass the editor reference directly:
const outline = document.querySelector('sciflow-outline');
outline.editor = document.querySelector('sciflow-editor');
Insert ref button¶
Each heading and figure row with an id attribute shows an Insert ref button. It is enabled only when the editor has an active cursor (the component tracks this internally via editor-selection-change).
Clicking the button fires a sciflow-insert-cross-reference event (bubbles, composed). Wire it to the insertCrossReference command:
outline.addEventListener('sciflow-insert-cross-reference', (event) => {
const { id, href, text, refType } = event.detail;
editor.commands?.commands?.insertCrossReference?.({ id, href, text, refType });
editor.commands?.commands?.focus?.();
});
insertCrossReference is provided by crossReferenceFeature from @sciflow/editor-core. Make sure it is included in your feature list.
Ghost cursor
The Insert ref button calls event.preventDefault() on mousedown to keep the editor cursor alive while the user interacts with the sidebar. Enable ghostCursorFeature so the user can see the insertion point. See Reference Integration for setup details.
Events¶
| Event | When fired | detail |
|---|---|---|
sciflow-insert-cross-reference |
User clicks Insert ref on a heading or figure row | { type, refType, id, href, text } |
sciflow-outline-navigate |
User activates a heading or a figure to jump to it (click, or Enter/Space) | { heading, kind, figure?, behavior, selectionPosition } |
kind is 'heading' or 'figure', and figure is present only for the latter. heading is on
every event whatever the kind — for a figure it carries that figure's text, id and position — so a
listener written before figures were navigable keeps working unchanged.
Navigation needs the live ProseMirror document. collectDocumentOutline records positions only when
it is given one; called with plain document JSON it returns position: null for headings and
figures alike, and rows with no position are rendered as plain list items rather than as buttons.
Styling sciflow-outline typography¶
<sciflow-outline> renders in shadow DOM, so ordinary page CSS can't reach its internals, and
its default type sizes are set in rem — root-relative, so setting font-size on the host
element has no effect (rem always resolves against the document root, not the host's
inherited value). To retheme its text sizes, set CSS custom properties on the host, the same
way you'd override --sciflow-editor-* chrome vars (see Web Component
API):
const outline = document.querySelector('sciflow-outline');
outline.setAttribute('style', `
--sciflow-outline-item-size: 0.875rem;
--sciflow-outline-meta-size: 0.75rem;
--sciflow-outline-badge-size: 0.7rem;
`);
Or inject them into the shadow root directly — a plain inline style attribute or a global
stylesheet rule targeting the element both work, since custom properties inherit through the
shadow boundary from the host.
| Property | Applies to | Default (unset) |
|---|---|---|
--sciflow-outline-item-size |
Outline heading text (all levels) and the empty-state placeholder | 0.875rem (H1/placeholder) · 0.845rem (H2) · 0.82rem (H3) |
--sciflow-outline-meta-size |
Outline row metadata — the ID chip, Insert ref button, and drag handle | 0.68rem / 0.65rem / 0.62rem respectively |
--sciflow-outline-badge-size |
The level badge (H1/H2/H3/figure) | 0.62rem |
Why one property covers multiple font sizes
Each heading level (H1/H2/H3) previously had its own hand-tuned literal. Setting
--sciflow-outline-item-size overrides all of the text rules that reference it — H1/H2/H3
outline text becomes the same size (heading level is still visible via indentation and badge
color). If you need the original stepped-size defaults back, don't set the property.
For the equivalent properties on <sciflow-reference-list>, see its Styling
section.