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.
├── 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.
{{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:
- During chapter save,
_time_html_to_mduses the regex<span[^>]*?data-time-id="([^"]+)"[^>]*?data-color-index="([^"]+)"[^>]*?>([^<]*)</span>. - Because
([^<]*)stops before any nested HTML opening tag<, inner entity marks are deliberately preserved as raw HTML spans on disk. - 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():
# Inner capture matches anything except a nested span tag
_INNER = r'((?:(?!?span).)*?)'
_RAW_ENTITY_SPAN = re.compile(
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.