FLESHNOTE / DOCS / CORE EDITOR

Editor Architecture & Syntax

CORE SYSTEM

The ProseMirror/TipTap writing canvas, custom inline mark extensions, round-trip markdown serialization, and the nested bracket avoidance engine for time gutter overrides.

TipTap & ProseMirror Canvas Architecture #

FleshNote's editor is built on TipTap v2 (a headless ProseMirror framework for React). Rather than operating on unstructured plain text, the editor represents manuscripts as an immutable abstract document tree where characters, locations, time overrides, and annotations are first-class marks and decorations.

ProseMirror Extension Schema (src/renderer/src/extensions/)
├── EntityLinkMark.js       // Handles characters, locations, lore concepts, groups
├── TimeLinkMark.js         // Paragraph-level time gutter override spans
├── TwistLinkMark.js        // Foreshadowing clues and plot reveal anchors
├── KnowledgeLinkMark.js    // In-text character knowledge discovery tags
├── RelationshipLinkMark.js // Dynamic interpersonal turning point anchors
├── TodoHighlighter.js      // Zero-width space #TODO inline decoration plugin
├── SearchAndReplace.js     // Debounced full-text search with regex support
└── mentionSuggestion.js    // Autocomplete menu triggered by '@' character

Inline Tag Serialization Matrix #

To maintain human-readable files on disk, chapter content is saved as standard Markdown files in md/ch_*.md. At the load/save boundary (in backend/routes/chapters.py), the backend converts TipTap HTML DOM marks into tokenized markdown markers:

Entity / Feature TipTap HTML (Runtime DOM) Markdown File (On-Disk Syntax)
Character Link <span data-entity-type="character" data-entity-id="UUID" class="entity-link character">Alice</span> {{char:UUID|Alice}}
Location Link <span data-entity-type="location" data-entity-id="UUID" class="entity-link location">Winterfell</span> {{loc:UUID|Winterfell}}
Lore / Item Link <span data-entity-type="lore" data-entity-id="UUID" class="entity-link lore">Sun Blade</span> {{item:UUID|Sun Blade}}
Foreshadowing Clue <span data-twist-type="foreshadow" data-twist-id="UUID" class="twist-link foreshadow">a faint poison scent</span> {{foreshadow:UUID|a faint poison scent}}
Plot Twist Reveal <span data-twist-type="twist" data-twist-id="UUID" class="twist-link twist">he was the assassin</span> {{twist:UUID|he was the assassin}}
Knowledge Anchor <span data-knowledge-id="ID" data-character-id="UUID" class="knowledge-link">text</span> {{knowledge:ID:UUID|text}}
Relationship Anchor <span data-relationship-id="ID" data-character-id="UUID" class="relationship-link">text</span> {{relationship:ID:UUID|text}}
Time Gutter Override <span data-time-id="ID" data-color-index="0" class="time-link">text</span> {{time:ID:0|text}}
Inline #TODO Tag <span class="todo-highlight">#TODO refine dialogue \u200B</span> #TODO refine dialogue \u200B

The Nested Bracket Avoidance Quirk #

A critical architectural detail in FleshNote is how the serializer handles entity marks inside a Time Gutter override.

The Nested Bracket Problem: If a paragraph is marked with a time override (e.g. {{time:5:0|...}}) and contains an entity link (e.g. {{char:2|Sophia}}), serializing both to curly braces produces nested tokens: {{time:5:0|...{{char:2|Sophia}}...}}. Standard non-greedy regular expressions ([^}]+) terminate at the first closing }}, corrupting the document tree upon reload.

The Raw HTML Span Fallback Solution

To prevent parser ambiguity without adding a heavy AST parser, FleshNote uses an ingenious, zero-overhead workaround in backend/routes/chapters.py:

  1. During chapter save, _time_html_to_md uses the regex <span[^>]*?data-time-id="([^"]+)"[^>]*?data-color-index="([^"]+)"[^>]*?>([^<]*)</span>.
  2. Because ([^<]*) stops before any nested HTML opening tag <, inner entity marks are deliberately preserved as raw HTML spans on disk.
  3. On reload, TipTap parses these raw HTML spans natively into ProseMirror marks with zero data loss.

The Recursive Export Unfolding Engine

When exporting to clean formats (DOCX, PDF, EPUB, TXT), backend/export/strip.py uses _normalize_raw_spans():

backend/export/strip.py — Innermost-First Normalization
# Inner capture matches anything except a nested span tag
_INNER = r'((?:(?!]*?data-entity-type="([^"]+)"[^>]*?data-entity-id="([^"]+)"[^>]*?>' + _INNER + r'', re.DOTALL)

def _normalize_raw_spans(text: str) -> str:
    """Fold raw-span fallback forms back into {{marker}} form innermost-first."""
    for _ in range(5):  # bounded by realistic span nesting depth
        new = _RAW_TIME_SPAN.sub(lambda m: f'{{{{time:{m.group(1)}:{m.group(2)}|{m.group(3)}}}}}', text)
        new = _RAW_ENTITY_SPAN.sub(lambda m: f'{{{{{_ENTITY_TYPE_TO_SHORT.get(m.group(1), m.group(1))}:{m.group(2)}|{m.group(3)}}}}}', new)
        if new == text:
            return new
        text = new
    return text

This runs up to 5 iterations from the innermost nested span outward, unfolding every HTML tag into a marker before the clean export stripping pipeline runs.

Deep-Dive Subsystem Guides #

➔ Time Gutter Overrides

Multi-paragraph bounding box geometry, calendar date overrides, flashback reactivity on knowledge, and SQLite deletion cascades.

➔ Sprint Modes & Flow

Word-goal locked sprints: Rewarding modes (Momentum runes, Combo, Zen tree) and Punishing modes (Kamikaze, Hemingway, Fog).

➔ Annotations & #TODO Engine

Zero-width space boundary tokens (\u200B), QuickNotes semantic note types, and export warning diagnostics.

➔ Pentimento Op Coalescing

Real-time transaction step listening, op coalescing runs (insert, paste, delete, pause), and 12s disk batching.

On This Page