Design document for replacing the WYSIWYG innerHTML rebuild approach with a styled-source model where the editor always contains markdown text with CSS styling. No mode conversions during editing, no innerHTML rebuild, no round-trip bugs.
5.6 KiB
Styled Source Editor — Design Plan
Core Concept
The editor is always a markdown text editor. There is no separate "WYSIWYG mode" —
the user edits markdown directly, but the editor applies CSS styling that makes it
look like rendered output. Delimiters (**, *, `, etc.) are hidden when the
cursor is outside the element and revealed when the cursor enters it.
Two CSS States (not modes)
- Editing:
contentEditable="true", delimiters revealed on cursor focus - Viewing:
contentEditable="false", all delimiters hidden
No content transformation on state switch. The DOM is identical in both states — only CSS changes. This eliminates all conversion-during-editing bugs.
DOM Structure
The editor contains markdown text wrapped in styled spans:
<div id="ribbit">
<div class="md-heading" data-level="2">
<span class="md-delim">## </span>Hello World
</div>
<div class="md-paragraph">
Some <span class="md-bold">
<span class="md-delim">**</span>bold<span class="md-delim">**</span>
</span> and <span class="md-italic">
<span class="md-delim">*</span>italic<span class="md-delim">*</span>
</span> text.
</div>
<div class="md-list-item">
<span class="md-delim">- </span>First item
</div>
<div class="md-blockquote">
<span class="md-delim">> </span>Quoted text
</div>
</div>
CSS handles all visual rendering:
.md-delim { display: none; color: #999; font-weight: normal; }
.md-bold.editing .md-delim,
.md-italic.editing .md-delim { display: inline; }
.md-bold { font-weight: bold; }
.md-italic { font-style: italic; }
.md-heading[data-level="1"] { font-size: 2em; font-weight: bold; }
.md-list-item { display: list-item; margin-left: 1.5em; }
.md-blockquote { border-left: 3px solid #ccc; padding-left: 1em; }
.md-code { font-family: monospace; background: #f5f5f5; }
Per-Keystroke Pipeline
- User types a character → browser inserts it into the DOM (contentEditable)
inputevent fires- Parser scans the current line only (the block element containing the cursor)
- If the span structure needs updating (e.g. user just typed the closing
**):- Wrap/unwrap the affected text range using targeted DOM operations
- No innerHTML rebuild, no full-document re-parse
- If a block pattern is detected (e.g.
#at start of line):- Update the block element's class and data attributes
- Move the delimiter text into a
.md-delimspan
Key Operations
Inline formatting detection
When the user types a delimiter character, scan backward in the current text node for a matching opener. If found, wrap the range:
Before: <span class="md-paragraph">hello **world**</span>
After: <span class="md-paragraph">hello <span class="md-bold">
<span class="md-delim">**</span>world<span class="md-delim">**</span>
</span></span>
Use Range and surroundContents for the wrap — no innerHTML.
Block detection
When the user types a space after #, >, -, 1., etc. at the start
of a line, update the block element:
Before: <div class="md-paragraph"># Title</div>
After: <div class="md-heading" data-level="1">
<span class="md-delim"># </span>Title
</div>
Cursor focus tracking
On selectionchange, find the nearest formatting span and add an
.editing class so CSS reveals its delimiters. Remove .editing
from the previous span.
getMarkdown()
Read textContent from the editor element. The delimiter spans contain
the actual delimiter characters, so textContent produces valid markdown.
No conversion needed.
getHTML()
Run the existing tokenizer + toHTML pipeline on the markdown string
from getMarkdown(). This is only called on demand (export, save, API),
never during editing.
Macros
Macros are rendered as contentEditable="false" islands within the
editable text. The macro source (@user) is stored in a data-source
attribute. The rendered output is displayed inside the island. On focus,
the island could expand to show the source for editing.
For toMarkdown, macro islands emit their data-source value.
Initial Load
Markdown → styled source DOM is a one-time conversion on editor init:
- Parse markdown using the existing tokenizer (produces token stream)
- Walk the token stream, creating the span structure described above
- Set the editor's innerHTML once
This replaces the current toHTML → innerHTML path.
What This Eliminates
transformInlineand its innerHTML rebuildblockToMarkdown/nodeToMarkdown(DOM → markdown string → DOM)- The flatten-rebuild pipeline and all its escaping bugs
- The
<br>+ ZWS cursor anchor workarounds - The sentinel marker system for preserved HTML elements
- Mode switch conversions (WYSIWYG ↔ view ↔ edit)
What This Keeps
- The tokenizer (for initial load and
getHTML()) - The serializer (for
getHTML()viatoMarkdown→toHTML) - Tag definitions (for block pattern matching and toolbar buttons)
- The
BaseTagkeyboard dispatch system - The collaboration transport layer
- The macro system
Implementation Order
- Build the markdown → styled DOM renderer (replaces
toHTMLfor editor init) - Build the per-line parser that updates span structure on keystroke
- Build the inline delimiter detection (wrap/unwrap via Range)
- Wire up cursor focus tracking for delimiter reveal
- Implement
getMarkdown()astextContentread - Remove
transformInline,blockToMarkdown, and the rebuild pipeline - Update tests
Branch
Work on the styled-source branch, branched from current main.