162 lines
5.6 KiB
Markdown
162 lines
5.6 KiB
Markdown
|
|
# 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">> </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`.
|