Skip to content

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:

const view = editor.editorView; // EditorView | null

⚠️ 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)

<sciflow-formatbar for="editor"></sciflow-formatbar>
<sciflow-editor id="editor"></sciflow-editor>

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:

<script defer src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-svg.js"></script>
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): .ProseMirror styles 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):

editor.shadowStyles = `
  :host { --sciflow-editor-border: #4f46e5; }
`;

Method call (applies immediately, recommended for dynamic updates):

editor.setShadowStyles(`
  :host { --sciflow-editor-border: #4f46e5; }
`);

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.