ribbit/STYLED_SOURCE_DESIGN.md

162 lines
5.6 KiB
Markdown
Raw Normal View History

# 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:
```html
<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:
```css
.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 `toMarkdown``toHTML`)
- 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`.