A high-performance rich-text editor for Flutter, forked from AppFlowy Editor (MPL 2.0). This document describes how the system is wired together: the data model, the mutation pipeline, the rendering path, and the feature subsystems.
Companion docs: AGENTS.md (structure & conventions),
CONTRIBUTING.md (workflow), documentation/ (feature
guides).
Everything in the editor is built around three objects:
Document → the tree of nodes (paragraphs, headings, tables…) holding
rich-text Deltas and attributes. Pure data, serializable.
EditorState → the single mutable state: document, selection, styles,
services (undo, spell check, word count). Created once,
passed to the editor widget.
NovidentEditor → the widget. Renders the document, forwards input, and
reflects every change back into the EditorState.
All mutations flow through EditorState.apply(Transaction). Direct
document mutation is possible but discouraged — it bypasses undo/redo,
selection updates, delta-change emission, and collaborative-editing hooks.
The original editor was split into layered packages under packages/, each
published independently to pub.dev. The root novident_editor package
consumes them via version constraints (^1.0.x), not path deps — the
local packages/ source is only what gets published.
flowchart TB
subgraph published["Published packages (packages/)"]
doc["novident_editor_document<br/>nodes · delta · paths · attributes"]
core["novident_editor_core<br/>Position · Selection · SelectableMixin"]
styles["novident_editor_styles<br/>style registry · font provider"]
sel["novident_editor_selection<br/>cursor · block selection · painters"]
rt["novident_editor_rich_text<br/>NovidentRichText · 6-phase pipeline"]
sc["novident_editor_spell_check_interface<br/>pure-Dart contract"]
end
root["novident_editor (root)<br/>block components · services · plugins ·<br/>toolbar · vim/zen/typewriter · find & replace"]
doc --> core
doc --> styles
core --> styles
doc --> sel
core --> sel
doc --> rt
core --> rt
styles --> rt
sel --> rt
doc --> root
core --> root
styles --> root
sel --> root
rt --> root
sc -. "implemented by any engine" .-> root
Layering rules (strict):
novident_editor_documentknows nothing about selection, styles, or rendering — only the node tree, rich-text Delta, paths, and attributes.novident_editor_coredepends only on document;stylesandselectiondepend only on document + core;rich_textdepends on document + core + styles + selection.- The root is the only place for block components, editor services, plugins, toolbar, vim/zen/typewriter, and find & replace.
novident_editor_spell_check_interfacemust stay pure Dart — noflutterimport, ever.- Dependencies only flow upward; a package must never import the root or a sibling it doesn't already depend on.
A Document is a tree of Nodes rooted at a page node. Each Node
carries:
type— a string key that selects whichBlockComponentBuilderrenders it (e.g.paragraph,heading,table).attributes— aMap<String, Object>of node-level data. Rich text lives in thedeltaattribute; other attributes drive block behavior (alignment, indentation, subtype…).children— aLinkedList<Node>for O(1) sibling insert/remove.
Node extends ChangeNotifier and LinkedListEntry<Node>; every mutation
notifies listeners so the render tree rebuilds. Nodes are addressed by
Path (a list of child indices from the root, e.g. [0, 2]).
Performance-critical caches (all invalidated on mutation):
- Children cache —
childrenis materialized to aListon first read; invalidated on every structural change. - Index-in-parent cache —
pathis amortized O(1): the first read after a mutation re-indexes all siblings in one sweep (_reindexChildren), subsequent reads are cache hits. - Delta parse cache — the
deltagetter parses the raw attribute list once and caches by identity of the raw value;updateAttributesalways composes a new map, so edits refresh the cache naturally.
Text content is a Quill-style Delta (insert/retain/delete operations with
optional attributes) stored in the node's delta attribute. The delta is
the single source of truth for text and inline formatting (bold, italic,
color, links…). TextDocument/DocumentTree were tried and reverted —
storage is pure Delta by design.
Document.listenDeltaChanges provides per-node DeltaChangeEvents
(start/end/shift metadata) emitted after a transaction applies. This is
the hook spell check and other consumers use to react to edits. Direct
attribute writes never emit — only transactions do.
sequenceDiagram
participant U as User / Command / IME
participant T as Transaction
participant S as EditorState
participant D as Document
participant H as UndoManager
U->>T: build ops (insertText, formatText, insertNode…)
T->>T: transform ops against prior ops, compose deltas
U->>S: apply(transaction)
S->>S: broadcast (before) on transactionStream
S->>D: apply operations (insert / update / delete / updateText)
D->>D: emit delta changes to listeners
S->>S: broadcast (after) on transactionStream
S->>S: update selection from transaction.afterSelection
S->>H: record operations into undo history item
H-->>S: undo()/redo() → inverted transaction → apply()
Four operation types, each invertible (undo/redo is operation inversion):
| Operation | Effect | Invert |
|---|---|---|
InsertOperation |
insert nodes at a path | delete them |
DeleteOperation |
delete nodes at a path | re-insert them |
UpdateOperation |
merge attributes into a node | apply inverted attributes |
UpdateTextOperation |
compose a delta into a node's text | compose the inverted delta |
A Transaction is a list of operations plus selection metadata
(beforeSelection, afterSelection, customSelectionType, reason).
Key behaviors:
- Operation merging — consecutive
UpdateTextOperations on the same node compose into one. - Path transformation —
add()transforms each new operation against the already-queued ones so paths stay valid (insert-before shifts). - Delta compose map — text edits (
insertText,formatText,deleteText,replaceText,mergeText) accumulate per-node deltas that are composed and flushed as a singleUpdateTextOperationon first read ofoperations(lazycompose()). - Delta-change capture — each text edit records a
DeltaChange(start/end/shift) at the point where the index is known; the editor emits these to document listeners after apply.
EditorState.apply(transaction) is the single entry point for mutation:
- Guards on
editable/isDisposed. - Broadcasts
(before, transaction, options)on the sync and async observer streams (collaborative-editing hooks). - Applies each operation to the document (
_applyTransactionInLocal), collecting changed nodes. - Emits captured delta changes + empty changes for nodes whose delta changed without local metadata (undo/redo, paste, remote).
- Broadcasts
(after, …). - Records the operations into the undo history item (debounced seal).
- Updates the selection from
transaction.afterSelection.
Remote transactions (isRemote: true) take a separate path that also
shifts the local selection to compensate for remote insert/delete
before the cursor.
HistoryItemcollects operations + before/after selection; it can be sealed (no more ops appended) — sealing is debounced (minHistoryItemDuration, default 50 ms) so a burst of typing becomes one undo step.FixedSizeStack(default 200 items) backs both stacks.undo()pops the undo stack, inverts the operations (reverse order), and applies withrecordUndo: false, recordRedo: true.redo()pops the redo stack and applies normally.
NovidentEditor (in lib/src/editor/editor_component/service/editor.dart)
is a StatefulWidget that composes the services around the renderer:
flowchart TB
NE["NovidentEditor (StatefulWidget)"]
NE --> P["Provider.value(editorState)"]
P --> FS["FocusScope"]
FS --> OV["Overlay"]
OV --> SVC["services (built once, cached)"]
SVC --> KS["KeyboardServiceWidget<br/>(keyboardStrategies + IME)"]
KS --> SS["SelectionServiceWidget<br/>(gestures, cursor, context menu)"]
SS --> SC["ScrollServiceWidget<br/>(editorScrollController)"]
SC --> R["renderer.build(root node)<br/>BlockComponentRenderer"]
R --> BC["block components (one per node type)"]
The widget writes configuration into the EditorState during
initState/didUpdateWidget (_updateValues): editorStyle, styles,
fontProvider, editable, documentRules, and the spell-check service
lifecycle. The renderer service is rebuilt from blockComponentBuilders
and assigned to editorState.renderer.
Each node type maps to a BlockComponentBuilder
(lib/src/editor/editor_component/service/renderer/block_component_service.dart)
that builds a BlockComponent widget. The standard map lives in
standard_block_components.dart; consumers extend or override it via the
blockComponentBuilders parameter. Block components are composable:
shared behavior lives in base_component/ mixins and commands
(align, indent, outdent, text direction, convert-to-paragraph,
insert-newline-in-type, auto-complete…).
NovidentRichText renders a node's delta through an abstract
NovidentTextSpanPipeline with exactly six phases:
per TextInsert: 1 resolveStyle → 2 transformText → 3 emitSpans
per node: 4 paintSelectionContrast (over the emitted spans)
placeholder: 5 buildPlaceholder
per node: 6 adjustSpan (over the root span)
- Phase 1 converts attributes →
TextStyle(over the base style). It also receives thenodeandbuildContext(optional) so pipelines can react to the node's surroundings (e.g. zen-mode color dimming). - Phase 2 applies textual transforms (caps/smallCaps) — must not change UTF-16 length, since document offsets are measured on the original text.
- Phase 3 emits spans — may split text (spell-check marks, syntax highlighting); returned spans must cover the text exactly.
- Phase 4 applies selection contrast over emitted spans.
- Phase 5 builds the placeholder of an empty node.
- Phase 6 final root-span adjustment (caret-height workaround).
Implementations must be stateless w.r.t. the phases (any phase may run any
number of times per build). DefaultNovidentTextSpanPipeline reproduces
legacy behavior; SpellCheckSpanPipeline plugs in at phase 3 to underline
misspelled words.
Pipeline composition: EditorState.spanPipeline is the single
composition point (every block component passes editorConfig: editorState).
Optional modes register a wrapper via setSpanPipelineWrapper that composes
over the effective pipeline (spell check or default) — e.g. ZenSpanPipeline
wraps it as a delegate and only overrides resolveStyle for the dimming,
keeping the modes decoupled.
SelectionRenderer is the full control surface for cursor and selection
appearance and movement behavior (build cursor, paint selection rects,
movement callbacks, lifecycle events). The deprecated head-cursor methods
(buildExpandedHeadCursor, paintExpandedHeadCursor,
expandedHeadPosition) are replaced by computing the head and injecting it
into the rects in onSelectionRectsMeasured (see VimSelectionRenderer).
EditorStyle.selectionRenderer selects the implementation; the default is
DefaultSelectionRenderer.
Physical-keyboard interpretation is a list of KeyboardStrategy policies
consulted in order — the first one that does not return ignored wins.
DefaultEditorStrategy wraps the classic character/command shortcut event
lists; VimStrategy intercepts keys outside insert mode. The deprecated
characterShortcutEvents/commandShortcutEvents widget parameters are
bridged into a DefaultEditorStrategy when keyboardStrategies is empty.
Text input from soft keyboards / IMEs goes through the IME services:
DeltaInputService (delta-driven input), NonDeltaInputService
(plain-text fallback), TextDiff (diffing composed text), and the
delta_input_on_* implementations (insert, delete, replace, non-text
update, action update, floating cursor). This layer is what makes Korean/
Japanese IME composition work.
SelectionGesture (desktop) and MobileSelectionGesture (touch) translate
pointer events into selection updates through updateSelectionWithReason,
which routes through the active SelectionRenderer and completes after the
frame for UI events.
EditorState.service is an EditorService holding GlobalKeys into the
widget tree's service widgets:
| Service | Key | Role |
|---|---|---|
rendererService |
— (assigned) | BlockComponentRenderer — maps node type → builder |
selectionService |
selectionServiceKey |
selection state, gestures, context menu |
keyboardService |
keyboardServiceKey |
keyboard strategies + IME input |
scrollService |
scrollServiceKey |
scrolling, auto-scroll |
Services are resolved lazily through their GlobalKeys (asserting the widget is mounted); the renderer is assigned directly by the editor widget.
VimModeController coordinates vim emulation: it owns the mode
(normal/insert/visual), the pending operator buffer, and a configuration
that can be rebound at runtime. VimStrategy (a KeyboardStrategy) blocks
IME input outside insert mode — the mode is the strategy's hardware↔IME
correlation. VimSelectionRenderer paints the block cursor and injects the
selection head into measured rects. vimKeyboardStrategies(controller)
returns the strategy list to pass to the editor.
ZenModeController (a ValueNotifier<ZenModeConfiguration>) dims unfocused
blocks without touching the document: text/block colors are neutralized
through the span pipeline (ZenSpanPipeline, which wraps the effective
pipeline as a delegate) and the block background decorator. ZenModeBlock +
ZenModeScope provide the per-block dimmed state with O(1) rebuilds:
each wrapper listens to the controller's focused top-level range and only
rebuilds when its own dimmed state flips — typing inside a block rebuilds
nothing. Only non-text node types (e.g. images, filterable via
wrapNonTextTypes) get a cheap static Opacity wrapper, since they have no
text spans to style. Typewriter scrolling is a separate feature — see below.
TypewriterScrollStrategy (a ScrollStrategy) keeps the cursor vertically
centered by driving the EditorScrollController on selection changes. It is
independent from zen mode (usable with or without it) and is passed to
NovidentEditor.scrollStrategies. It centers instantly for collapsed
selections and delegates to the default edge-follow for expanded ones.
The scroll system is decomposed into reusable pieces under
service/scroll/: ScrollTargetResolver (edge policy), ScrollVelocity
(velocity profile) and ScrollDriver (the follow loop), composed by
AutoScroller. ScrollStrategy is the full-control seam (like
KeyboardStrategy): the editor delegates, the strategy decides.
Out-of-band, Word-like analysis:
SpellCheckServicelistens to document delta changes and marks affected nodes pending.- A single global inactivity timer (debounce, default 600 ms) restarts on every change; pending nodes accumulate while typing.
- On expiry, each pending node is re-analyzed entirely and
proofState: 'error'marks are injected into its delta.
Marks are written directly via node.updateAttributes — never through
transactions and never through emitChanges — so analysis can never loop
on itself. The checker itself implements the pure-Dart
NovidentSpellChecker contract (novident_editor_spell_check_interface);
the reference Hunspell en_US implementation lives in example/lib/spell_check/
with parsing/affix/suggestions in a worker isolate.
NovidentStyleRegistry holds NovidentStyleDefinitions with basedOn
inheritance; resolution is a three-tier fallback (explicit style → type
default → global default). Base styles require fontSize, fontFamily,
and textColor. NovidentFontProvider supplies available font families
and a guaranteed non-null default. Toolbar items resolve the current value
through the same chain.
Tables are a weight-based column layout filling the available width, with
reusable TableStyleDefinitions (zebra striping, colored headers,
borderless). TableNode.fromList builds the node tree; table_commands.dart
provides the keyboard commands; table_action_menu.dart the row/column
actions. See documentation/tables.md and
documentation/components/table-architecture.md.
Search over the document with match highlighting (search results drive
selection via SelectionUpdateReason.searchHighlight) and replace
operations applied through transactions.
| Format | Decoder | Encoder |
|---|---|---|
| HTML | html_document_decoder.dart |
html/encoder/ (per-node parsers) |
| Markdown | markdown/decoder/ (parser + custom syntaxes) |
markdown/encoder/ (per-node parsers) |
| Quill Delta | — | quill_delta_encoder.dart |
| Word count | — | word_counter_service.dart |
Each format follows the same shape: decoder/ and encoder/ subdirs with
per-node parsers, exposed as Codec<Document, String> implementations
(NovidentEditorHTMLCodec, NovidentEditorMarkdownCodec).
- Sources: ARB files in
lib/l10n/intl_*.arb(23 locales). - Generated:
lib/src/l10n/intl/messages_*.dartviaflutter pub run intl_utils:generate— analyzer-excluded, never hand-edited. - Runtime strings:
NovidentEditorL10n(extends the generatedNovidentEditorLocalizations); the public delegate isNovidentEditorLocalizations.delegate.
- Path resolution is amortized O(1) via the per-parent index cache (one re-index sweep per mutation, then cache hits) — paths are resolved constantly (selections, rendering, transactions).
- Delta parsing is cached per raw attribute identity — the hot
node.deltagetter never re-parses an unchanged attribute list. - Children lists are cached and invalidated on structural change.
- Spell check never runs on the typing hot path — whole-node re-analysis only after the idle timeout, O(1) per word against a Trie.
- Selection rects are computed fresh per call — global coordinates depend on scroll offset, so caching across frames would return stale positions during scroll animations.
- Zen dimming is O(1) per focus change — each block's
ZenModeBlockonly rebuilds when its own dimmed state flips; typing inside a block rebuilds no wrapper. Config refreshes notify only the visible nodes (getVisibleNodes), with a platform-sized fallback window when the visible range is not initialized. shrinkWrap: false(default) uses a ListView for virtualized rendering;shrinkWrap: truefalls back to SingleChildScrollView + Column (documented as poor performance for large documents).
flowchart LR
subgraph Input["Input"]
KB["KeyboardStrategy<br/>(VimStrategy, DefaultEditorStrategy)"]
IME["IME services<br/>(delta_input_*, non_delta_input)"]
GEST["Selection gestures"]
end
subgraph State["State (SSOT)"]
ES["EditorState"]
DOC["Document (node tree + deltas)"]
TX["Transaction (operations + selection)"]
UNDO["UndoManager (history items)"]
end
subgraph Output["Output"]
REND["BlockComponentRenderer"]
PIPE["6-phase span pipeline"]
SELR["SelectionRenderer"]
end
KB --> TX
IME --> TX
GEST --> ES
TX --> ES
ES --> DOC
ES --> UNDO
UNDO --> TX
DOC --> REND
REND --> PIPE
ES --> SELR
PIPE --> UI["Widget tree"]
SELR --> UI
The invariant: every mutation enters through EditorState.apply as a
Transaction; the document is the source of truth; the widget tree is a
pure function of EditorState + Document; and every change is observable
through the transaction streams and delta-change events for collaboration
and analysis features.