ribbit/STYLED_SOURCE_DESIGN.md
gsb 818ee418d5 docs: Styled source editor design plan
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.
2026-04-30 22:49:09 +00:00

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">&gt; </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

  1. User types a character → browser inserts it into the DOM (contentEditable)
  2. input event fires
  3. Parser scans the current line only (the block element containing the cursor)
  4. 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
  5. 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-delim span

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:

  1. Parse markdown using the existing tokenizer (produces token stream)
  2. Walk the token stream, creating the span structure described above
  3. Set the editor's innerHTML once

This replaces the current toHTML → innerHTML path.

What This Eliminates

  • transformInline and its innerHTML rebuild
  • blockToMarkdown / 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() via toMarkdowntoHTML)
  • Tag definitions (for block pattern matching and toolbar buttons)
  • The BaseTag keyboard dispatch system
  • The collaboration transport layer
  • The macro system

Implementation Order

  1. Build the markdown → styled DOM renderer (replaces toHTML for editor init)
  2. Build the per-line parser that updates span structure on keystroke
  3. Build the inline delimiter detection (wrap/unwrap via Range)
  4. Wire up cursor focus tracking for delimiter reveal
  5. Implement getMarkdown() as textContent read
  6. Remove transformInline, blockToMarkdown, and the rebuild pipeline
  7. Update tests

Branch

Work on the styled-source branch, branched from current main.