Web components basics¶
The @sciflow/editor-start package registers two custom elements:
| Element | Purpose |
|---|---|
<sciflow-editor> |
The full ProseMirror-powered editor surface. |
<sciflow-formatbar> |
Optional toolbar that issues editor commands. |
This chapter covers loading the bundle, understanding attributes and events, wiring the toolbar, and applying theme tokens.
Loading the bundle¶
<!-- In a bundler: import '@sciflow/editor-start/bundle'; -->
<script type="module" src="/node_modules/@sciflow/editor-start/dist/bundle/sciflow-editor.js"></script>
Self-host or CDN
For production builds you can copy the emitted bundle to your own CDN. The file is an ES module and can be tree-shaken by modern bundlers.
Core properties¶
| Property | Type | Description |
|---|---|---|
doc |
SciFlowDocJSON \| null |
Sets the full document snapshot, or pass { doc, files, references, selection, version } to update related data atomically. |
initialContent |
ProseMirrorNode \| null |
Alternative way to seed the document with a ProseMirror node. |
features |
Feature[] \| null |
List of editor features. Can include custom features with plugins. |
sync |
SyncStrategy \| null |
Your own load/persist/collaboration implementation. Unset means the built-in in-memory strategy — see below. |
docId (doc-id) |
string \| null |
Stable document identifier handed to the sync strategy. Defaults to a fresh random id per mount. |
partId (part-id) |
string \| null |
The part of the document this editor is bound to, for backends whose concurrency unit is narrower than a document. Forwarded to the strategy untouched. |
clientID (client-id) |
string \| number \| null |
Stable per-client identifier. Setting it activates step-based concurrency, so remote changes rebase local edits. |
placeholder |
string |
Placeholder string shown when the document is empty. |
headingText |
string |
Display heading for the built-in default document template. |
shadowStyles |
string \| string[] \| CSSStyleSheet \| CSSStyleSheet[] |
CSS injected into the shadow root for chrome overrides (border, focus ring, :host vars). To style editor content (.ProseMirror, decorations), use global CSS targeting .sf-editable-surface .ProseMirror — the editable is in the light DOM. |
commands (getter) |
CommandRunner \| null |
High-level commands for editing operations. |
document (getter) |
ProseMirrorNode \| null |
Read-only document structure for traversal. |
positions (getter) |
PositionAPI |
Position utilities for coordinate mapping. |
plugins (getter) |
PluginAPI |
Plugin state management. |
dom (getter) |
DomAPI |
DOM utilities for events and positioning. |
Advanced: direct ProseMirror access¶
The editorView getter provides direct access to ProseMirror internals:
⚠️ This is an advanced escape hatch for use cases not covered by the stable APIs above. Direct ProseMirror access may change between major versions. Prefer the high-level APIs when possible.
Binding the element to your own sync strategy¶
By default <sciflow-editor> runs on a built-in in-memory sync strategy: the
document you hand it stays in the page, nothing is persisted, and no connection
is opened. That default does not change — leave sync unset and the element
behaves exactly as before.
To put the element on your own backend, set sync to your
SyncStrategy implementation and tell it what
it is bound to. The element opens no connections itself; it only forwards these
four values to the editor runtime.
<!-- In a bundler: import '@sciflow/editor-start/bundle'; -->
<script type="module" src="/node_modules/@sciflow/editor-start/dist/bundle/sciflow-editor.js"></script>
<sciflow-editor id="editor" doc-id="doc-123" part-id="chapter-2" client-id="tab-7"></sciflow-editor>
<script type="module">
const editor = document.getElementById('editor');
editor.sync = {
// Optional. Called once, before the first load, with the binding this
// editor was created with — the only place a strategy reliably learns
// `partId` and `clientID`.
bind({ docId, partId, clientID }) {
this.endpoint = `/api/documents/${docId}/parts/${partId}`;
this.clientID = clientID;
},
// Called when the element has no `doc`/`initialContent` of its own.
// `docId` is passed here too, for a strategy that skips `bind()`.
async load(docId) {
const response = await fetch(this.endpoint);
return response.json(); // { doc, version, selection?, files?, references? }
},
applyLocal(ops) {
// send local changes to your backend
},
applyExternal(ops) {
// apply changes arriving from your backend
},
dispose() {
// close sockets, cancel timers
},
};
</script>
client-id is what activates step-based concurrency (prosemirror-collab) in
the runtime: with it set, changes arriving from your backend rebase the local
edits instead of replacing the document, and the undo stack survives. Keep it
stable across reloads of the same client. Leave it unset for a plain
load/save strategy.
These four are mount-time identity
sync, docId, partId and clientID are fixed for the life of an
editor instance. Changing any of them re-creates the editor against the new
binding: the previous document is dropped — the new instance sources its
own from doc/initialContent, or from the new strategy's load() when
neither is set — and the previous strategy's dispose() is called. Supply
a fresh strategy whenever you change the binding, and set all four in the
same turn so the editor is re-created once.
Events¶
Naming convention¶
Custom events follow two naming tiers:
| Tier | Pattern | When to use | Examples |
|---|---|---|---|
| Element lifecycle | editor-<verb> |
Events owned by <sciflow-editor> that describe its internal state changes (document, selection, readiness). No vendor prefix — the element tag already scopes them. |
editor-change, editor-selection-change, editor-ready |
| Cross-component actions | sciflow-<noun>-<verb> |
Action-request or notification events that bubble out of companion components. The sciflow- prefix prevents collisions with other libraries in the host application. |
sciflow-insert-citation, sciflow-insert-cross-reference, sciflow-outline-navigate, sciflow-locale-change |
The change event on <sciflow-widget-slot> intentionally mirrors native <input> semantics and is the only exception.
Editor element events¶
| Event | Fired when | Detail payload |
|---|---|---|
editor-ready |
Editor has mounted and commands/views become available. | — |
editor-change |
The document changed (tr.docChanged) or references were updated. It does not fire for every transaction. See the note below the table for the effect on operations[]. |
{ doc: SciFlowDocJSON, operations: Operation[], files: SnapshotFile[], references: SnapshotReference[] } |
editor-selection-change |
Selection/cursor changes. | { anchor: number, head: number } |
Companion component events¶
| Event | Dispatched by | Detail payload |
|---|---|---|
sciflow-insert-citation |
<sciflow-reference-list> |
{ reference } |
sciflow-insert-cross-reference |
<sciflow-outline> |
{ type, refType, id, href, text } |
sciflow-outline-navigate |
<sciflow-outline> |
{ heading, behavior, selectionPosition } |
sciflow-locale-change |
setLocale() (document-level) |
{ locale } |
All events use bubbles: true, composed: true so they cross shadow DOM boundaries.
editor-change can report operations from multiple transactions. Every ProseMirror transaction produces an internal operation, but the DOM event fires only when tr.docChanged || referencesUpdated is true. A selection-only or other zero-step transaction does not fire editor-change. Its operation remains queued until the next transaction that meets the event condition. As a result, event.detail.operations can include zero-step operations from earlier transactions. Do not assume operations.length === 1. If you only need document edits, filter for operations with steps (op.steps?.length > 0).
editor.addEventListener('editor-change', (event) => {
const { doc, files, references } = event.detail;
saveDraft({ doc, files, references });
});
Connecting the format bar¶
1. Declarative (for attribute)¶
The toolbar auto-discovers the editor with that id once both elements are in the DOM.
2. Programmatic¶
const toolbar = document.querySelector('sciflow-formatbar');
const editor = document.querySelector('sciflow-editor');
editor.addEventListener('editor-ready', () => {
toolbar.editor = editor;
}, { once: true });
Math equations (optional)
The default feature set includes citations, cross references, footnotes, inline formatting, headings, figures, tables, and math. To render equations as SVG, add the MathJax script to your page:
See Troubleshooting if equations show as placeholder.Icons are built in
The toolbar icons (Material Symbols Rounded) are bundled as inline SVGs — no external font or CDN link is needed. See Icons for details on using or extending the icon set.
Issuing commands¶
const runner = editor.commands;
if (runner) {
runner.commands.focus();
runner.commands.insertText('Hello, world!');
runner.flow().toggleMark('strong').insertText(' bold text').run();
}
Use runner.available() to check capabilities before toggling marks or nodes.
Lists & blockquotes¶
The list and blockquote features expose familiar commands that mirror the ProseMirror examples. Once those features are enabled (they are toggled on in the demo), you can drive them like any other command:
const commands = editor.commands?.commands;
// Toggle bullet/ordered lists
commands?.toggleBulletList?.();
commands?.toggleOrderedList?.();
// Nest or lift list items
commands?.sinkListItem?.();
commands?.liftListItem?.();
// Wrap/unwrap blockquotes
commands?.toggleBlockquote?.();
Each command returns true on success, so you can wire them into toolbars or keyboard shortcuts as needed.
Footnotes¶
When the footnote feature is enabled (it is on by default), the command surface exposes insertFootnote and the format bar shows an Insert footnote button.
const commands = editor.commands?.commands;
// Insert an empty footnote at the cursor
commands?.insertFootnote?.();
// Insert with explicit content
commands?.insertFootnote?.({ text: 'Additional detail.' });
If text is selected when you run insertFootnote, the selected text is moved into the new footnote body.
Styling & layout¶
This section explains the three layers of styling you can control:
- Host element styles (outer container): size, margins, fonts, and CSS variables you set on
<sciflow-editor>. - Editor content styles (the editable text area):
.ProseMirrorstyles and decoration classes — these live in the light DOM and are reachable by ordinary global CSS. - Chrome styles (shadow root): format bar, popovers, focus ring, border — managed via
shadowStyles/setShadowStyles()or CSS custom properties. - Global theme styles (all SciFlow components): shared tokens applied via
setSciFlowThemeStyles().
CSS custom properties¶
The editor exposes CSS custom properties such as --sciflow-editor-border and --sciflow-editor-focus-ring. Override them on the host element to match your design system.
Styling editor content (light DOM)¶
The ProseMirror contenteditable (view.dom) is mounted on a light-DOM child of the host element (class sf-editable-surface). Because this element lives in the regular page DOM — not inside any shadow root — your global stylesheets reach it directly:
/* Any global stylesheet */
.sf-editable-surface .ProseMirror {
line-height: 1.6;
}
.sf-editable-surface .ProseMirror h1 {
font-family: “Merriweather”, serif;
}
This light-DOM placement also makes the editable discoverable by browser proofreading extensions. See External Proofreading & Writing Tools below.
Decoration styles — CSS classes added to the editable by ProseMirror plugins — follow the same rule:
// Add a <style> to document.head (or use your stylesheet)
const style = document.createElement(‘style’);
style.textContent = `
.sf-editable-surface .my-annotation {
background: rgba(255, 200, 0, 0.3);
border-bottom: 2px solid orange;
}
.sf-editable-surface .my-highlight { background: yellow; }
`;
document.head.appendChild(style);
!!! note “Migration from shadow-scoped .ProseMirror rules”
If you previously targeted .ProseMirror via shadowStyles or setShadowStyles(), move those rules to a global stylesheet and change the selector to .sf-editable-surface .ProseMirror { … }. The shadow API is unchanged and continues to work for chrome overrides.
Chrome styles (shadow root)¶
The shadowStyles property and setShadowStyles() method inject CSS into the shadow root and are still the correct API for overriding chrome — the editor border, focus ring, and layout. They accept strings, arrays of strings, or CSSStyleSheet objects:
Property assignment (applies asynchronously via Lit lifecycle):
Method call (applies immediately, recommended for dynamic updates):
Dynamic injection for shadow-chrome overrides (for example, after adding a plugin):
// Initialize editor first
editor.doc = { doc: myDocument, files: [], references: [] };
// Later: update chrome styles dynamically
editor.setShadowStyles([`
:host { --sciflow-editor-focus-ring: #e11d48; }
`]);
Both approaches work before or after initialization. Use setShadowStyles() when you need immediate application.
External proofreading & writing tools¶
Because the editable surface (contenteditable) is in the light DOM, browser extensions that walk the page’s document tree for [contenteditable] elements — such as Grammarly and the LanguageTool browser add-on — can discover it and underline text automatically. No extra configuration is required; install the extension and it will work.
!!! info “Third-party extensions”
Grammarly, LanguageTool, and similar tools are browser extensions installed independently by the user — they are not bundled with SciFlow. These extensions mutate the contenteditable DOM to insert underline spans and popover anchors. ProseMirror reconciles those mutations on the next keystroke, which is the standard behavior for any contenteditable-based editor that supports these tools.
Global theme injection¶
Use this to define shared tokens across all SciFlow web components (editor, format bar, selection editor, reference list). This is the easiest way to keep colors and borders consistent across the entire UI.
To apply a theme across all components, call setSciFlowThemeStyles() once:
import { setSciFlowThemeStyles } from '@sciflow/editor-start';
setSciFlowThemeStyles(`
:host {
--sciflow-accent: #0ea5e9;
--sciflow-surface: #ffffff;
}
.reference-item {
border-color: var(--sciflow-accent);
}
`);
Layout¶
By default, <sciflow-editor> behaves like a block element and stretches to the width of its parent. You can control the size using normal CSS on the host element, for example:
sciflow-editor {
display: block;
max-width: 820px;
min-height: 60vh;
margin: 24px auto;
border: 1px solid var(--sciflow-editor-border, #e5e7eb);
}
If you place the editor inside a grid or flex layout, it will size like any other block-level element. Use the host styles for outer spacing, global CSS (.sf-editable-surface .ProseMirror) for the editable content, and shadowStyles for chrome overrides.
Keyboard focus
Call runner.commands.focus() after programmatic updates (for example, clicking entries in your own outline) so screen readers and caret navigation keep working.
Custom events & integrations¶
The demo shows how to:
- Mirror the selection into a reference sidebar via
editor-selection-change. - Dispatch navigation requests (outline clicks →
commands.setSelection). - Drive custom insertions (
commands.insertFigure,commands.insertCitation).
Refer to the Customization Recipes for full walk-throughs.