Skip to Content
API Referencecorecore/sub-editor

@stesura/core/sub-editor

Shared machinery behind nested editors (footnotes, endnotes, headers/footers, text boxes). One SubEditorKindConfig per kind drives the inner schema (buildSubEditorSchema), the inner state (createSubEditorState) and the main-to-inner binding plugin (createSubEditorBindingPlugin). Edits flow back to the main editor through forwardInnerSteps.

Functions & values

buildSubEditorSchema

Derives a sub-editor schema from the main one: doc > <wrapperName> > section_content > blocks.

  • section_content is kept so section-scoped styling and plugins work.
  • The wrapper reuses the main section spec so its CSS vars style the panel like a page area.
  • filterOutNodes strips what must not appear inside the region.

The content expression is READ from regionNodeName, not restated: a hard-coded “block+” went stale when tables left the “block” group, and headers holding a table opened empty and desynced. assertContentParity makes any remaining gap a construction-time throw.

Parameters:
NameTypeDefault
baseSchema<any, any>
configPick<SubEditorKindConfig<K>, "kind" | "wrapperName" | "regionNodeName" | "contentExpr" | "filterOutNodes">
Returns:
Schema<any, any>

createSubEditorBindingPlugin

Main→inner sync plugin for one sub-editor kind, mounted on the MAIN editor. (Inner→main is forwardInnerSteps, called by useSubEditor in editor-react.)

For every registered sub-editor of the kind (several can be open at once), the region’s position is tracked through each step’s mapping and steps inside it are offset into inner coordinates. Remote collab steps take the same path.

Deferred inner dispatch: appendTransaction runs inside the main editor’s React setState updater, where dispatching a sub-editor transaction would be a setState-during-render. Inner transactions are queued and flushed in a microtask; if the inner state moved since build time (dispatch would throw “mismatched transaction”), the flush resyncs the whole content instead.

Convergence: after every flushed sync the inner content is compared with the region and resynced on mismatch, so a misbehaving plugin causes at worst a logged resync, never silent drift.

Parameters:
NameTypeDefault
configSubEditorKindConfig<K>
Returns:
Plugin<any>

createSubEditorRegistry

Builds the registry of mounted sub-editors of one kind.

Entries are identified by mainEditorId + keyId(key): the registry is page-global, so two editors open on the same note or header would otherwise overwrite each other’s entry and cross-write documents.

Parameters:
NameTypeDefault
keyId(key: K) => string
Returns:
SubEditorRegistry<K>

createSubEditorState

Creates the inner EditorState: doc > wrapper(wrapperAttrs) > section_content > blocks. Returns null and logs when the region cannot be edited; keep the panel closed.

Parameters:
NameTypeDefault
pluginsPlugin<any>[]
schemaSchema<any, any>
configCreateDocConfig
wrapperAttrsAttrs
blockContentNode[] | Fragment
Returns:
EditorState | null

describeSubEditorFailure

Human-readable form of a build failure, for logs and toasts.

Parameters:
NameTypeDefault
failureSubEditorBuildFailure
Returns:
string

findUnrepresentableNodeTypes

Deduplicated names of node types in content the inner schema lacks, e.g. filterOutNodes entries that reached the region by paste, import or a remote edit. Empty when the content fits.

Parameters:
NameTypeDefault
contentFragment
innerSchema<any, any>
Returns:
string[]

forwardInnerSteps

Applies a batch of inner transactions to outerTr, offset into main-doc coordinates. Wrapper-scaffolding steps are skipped; doc-global attr steps pass through unmapped.

Returns false when a step could not be mapped or applied (the docs have drifted). The caller must then NOT dispatch outerTr and should call recoverSubEditorAfterFailedForward.

Parameters:
NameTypeDefault
__0{ outerTr: Transaction; outerSchema: Schema<any, any>; transactions: readonly Transaction[]; regionPos: number; logContext: string; }
Returns:
boolean

getAllSubEditors

Every sub-editor currently mounted under mainEditorId, across all kinds. Order is registry-creation order, then registration order within a kind.

Parameters:
NameTypeDefault
mainEditorIdstring
Returns:
AnySubEditor[]

recoverSubEditorAfterFailedForward

Resyncs a sub-editor whose edit could NOT be forwarded (forwardInnerSteps returned false): it is now ahead of the region by a rejected step, and every later edit would fail the same way. Needed because the plugin’s convergence path only runs on OUTER transactions, and a dropped forward produces none. Deferred to a microtask, as the caller is inside a React dispatch.

Parameters:
NameTypeDefault
configSubEditorKindConfig<K>
editorActiveSubEditor<K>
outerStateEditorState
Returns:
void

subscribeSubEditors

Notifies whenever any sub-editor mounts or unmounts, in any kind.

Parameters:
NameTypeDefault
listener() => void
Returns:
() => void

Types

ActiveSubEditor

One mounted sub-editor. key identifies which region of the main doc it is bound to (e.g. { sectionId, target, variant } for headers/footers, { noteRef } for notes).

NameTypeDefault
viewEditorView
keyK
mainEditorIdstring

The main editor this sub-editor belongs to. Registries are page-global, so without it two editors showing the same note/section would collide and a binding plugin would push its edits into the other editor’s panel.

AnySubEditor

A mounted sub-editor, with its kind-specific key erased.

NameTypeDefault
viewEditorView
mainEditorIdstring

SubEditorBuildFailure

Why a sub-editor could not be built, for the caller to surface to the user.

NameTypeDefault
reason"schema" | "unrepresentable"

SubEditorDocResult

NameTypeDefault
okboolean

SubEditorKindConfig

Everything that varies between sub-editor kinds (header/footer, footnote, endnote). One config drives ALL the shared machinery: the derived schema, the inner doc/state construction, the outer→inner binding plugin, and the React hooks (inner→outer forwarding, search-match sync).

The shared invariants the machinery relies on:

  • Inner doc shape: doc > wrapper > section_content > blocks (blocks at pos 2).
  • Main doc region: … > regionNode > blocks (blocks at region.pos + 1).
  • Offset between the two coordinate spaces = region.pos − 1.
  • Inner positions below 2 are wrapper scaffolding and NEVER cross the boundary.
NameTypeDefault
kindstring

Human-readable kind name, used in log messages (“headerFooter”, “footnote”, …).

pluginKeyPluginKey<any>

The binding plugin’s key. MUST keep its existing string label — sub-editor plugin exclusion lists and host plugin filtering match on it.

registrySubEditorRegistry<K>

Registry of currently mounted sub-editors of this kind.

wrapperNamestring

Inner wrapper node name; its spec is the main section’s, so section CSS vars and plugins keep working. E.g. “section_footer_header”, “section”.

regionNodeNamestring

The main-doc node this kind binds to (“header_footer_content”, “footnote_body”, …). The inner section_content takes ITS content expression, so the two can never drift; buildSubEditorSchema also asserts the derived schema can hold everything this node allows.

contentExprstring | ((base: Schema<any, any>) => string)

Override for the inner section_content expression. Leave unset (no built-in kind sets it): the region node’s own expression keeps the schemas in step. An override must be at least as strict as the region node, or inner edits produce steps the main doc rejects.

filterOutNodesstring[]

Node names stripped from the base schema when deriving the inner schema.

validateContentAgainststring

Inner node whose spec validates the initial content in createSubEditorState (default “section_content”). Notes use their body node type.

findRegion(state: EditorState, key: K) => SubEditorRegion | null

Locate the bound region in a main-editor state.

wrapperAttrs(state: EditorState, key: K) => Attrs | undefined

The inner wrapper’s attrs for a key, as useSubEditor seeds them (the owning section’s, for headers and footers). When set, the binding re-seeds an open sub-editor whose wrapper no longer matches, so commands that size against the wrapper (insertTable) see a page setup change made while it is open.

isRegionNode(node: Node, key: K) => boolean

Recognize the region node of a given key among removed nodes, so the binding can react when the region disappears (e.g. a remote user deletes the note whose body is being edited). Omit when removal is handled elsewhere (the header/footer panel watches the main state itself).

onRegionRemoved(key: K, state: EditorState) => void

Called (outside the render cycle) after the binding unregisters an editor whose region was removed. Feature-level cleanup: close the panel, toast.

onForwardRegionMissing(key: K) => void

Called when inner→outer forwarding can’t find the region in the main doc (defensive — normally the binding’s removal path fires first).

prepareOuterTr(tr: Transaction) => void

Stamp kind-specific metas on the outer transaction built from inner steps (headers/footers add enableHeaderFooterEdits for the guard plugin). fromSubEditor and skipTrackChanges are always set by the machinery.

extraExcludedPluginsstring[]

Plugin key labels to exclude from the sub-editor ON TOP of the shared base list.

extraPlugins(schema: Schema<any, any>) => Plugin<any>[]

Kind-specific plugins, placed AHEAD of the host’s filtered plugins so they win for the same key binding — headers/footers use this to suppress document-wide keys (Mod-A, Mod-F).

editorId(key: K, mainEditorId: string) => string

The sub-editor’s editorId (store scoping, toolbar routing). MUST include mainEditorId: the store registry is page-global, so a bare key would cross-wire two editors open on the same note.

matchesSearchRegion(region: SearchMatchRegion, key: K) => boolean

True when a classified search-match region belongs to the sub-editor key.

viewSpec((view: EditorView) => PluginView) | undefined

Plugin view spec for the binding plugin on the MAIN editor. Notes use it to turn a NodeSelection into a TextSelection when a panel opens (the anchor may be deleted concurrently).

SubEditorRegion

A bound region of the main document (header/footer content node, note body node).

NameTypeDefault
posnumber
nodeNode

SubEditorRegistry

The mounted sub-editors of one kind. An ARRAY, since several can be open at once (a header and a footer editor). SubEditorRegister (editor-react) registers on mount and unregisters on unmount.

NameTypeDefault
atomWritableAtom<ActiveSubEditor<K>[]>
keyId(key: K) => string

Stable identity for a key within one main editor.

register(entry: ActiveSubEditor<K>) => void
unregister(mainEditorId: string, key: K) => void
getAll(mainEditorId: string) => ActiveSubEditor<K>[]

Entries belonging to mainEditorId only.

get(mainEditorId: string, key: K) => ActiveSubEditor<K> | undefined

The registered editor bound to key under mainEditorId, if mounted.

Other exports

ExportTypeDescription
SUB_EDITOR_EXCLUDED_PLUGINSstring[]Plugin key labels stripped from the main editor’s plugins before mounting any sub-editor. Kinds add more via SubEditorKindConfig.extraExcludedPlugins.
Last updated on