Formatting, blocks and markdown input for inline editing - #18
Formatting, blocks and markdown input for inline editing#18sjmiller609 wants to merge 2 commits into
Conversation
Inline editing could change text and nothing else. This adds the rest of a
small editor on top of it: marks and links, block types, new blocks, deletes,
drag reordering, list nesting, tables, code blocks, and markdown as an input
method — plus the history and recovery those writes need.
Two write paths, chosen by what changed. Typing stays a text patch to /edits:
nothing reloads, the caret never moves, comment anchors ride the patch's offset
map. Formatting and structure go to a new /ops, where each op names an element
by the id written into the start tags of the overlay copy of the document and
says what it should become. The server turns that into one splice of that
element's byte range, so everything outside it is untouched down to the byte —
the same guarantee the text path gives, and the reason /d/:slug/raw can serve
the author's bytes.
Ops carry intent, never markup: a block is {tag, runs}, every tag comes from a
literal in block-render.ts and author text is always escaped, so there is no
html to sanitize. Element ids are indexes into the bytes they were served with,
so base_version is effectively required; a stale one is a 409 and the shell
replays once, which is safe because text ops carry the text they expect to
replace and a real collision comes back as a 422.
After an ops write the iframe reloads against the stored bytes and the caret and
scroll are restored, rather than the overlay re-rendering locally. A second
markup renderer in the sandbox would have to agree with the server's forever,
and the moment it drifted the rendered document would stop being the stored one.
Two things fall out of writing this often. Consecutive patches by one author
inside a five-minute window now replace the previous snapshot instead of adding
one, so a session of writing no longer exhausts the 100-version cap; the
document's version still increments on every write, so stale-write detection is
unaffected. And POST /versions/:n/restore (with a button on the history page)
writes an old version's content forward as a new one, so undoing a bad edit is
no longer database surgery.
Editing a phrase that appears twice also works now: when the text engine reports
it can't place a match uniquely, the shell retries positionally as a setRuns op,
which names the exact text node instead of searching for its content.
htmlparser2 was already in the tree; it is now a direct dependency, used for its
source offsets. Its non-spec tree building doesn't matter here because the
browser learns an element's id from an attribute, not from tree position, and
text ops are verified against the text they expect before anything is written.
Verified against a real Chromium over CDP and a local Postgres: 17 browser
checks (typing, ⌘B, markdown inline and pasted, block shortcuts, Enter, the
block menu, entity and script preservation, escaping, the toolbar, ⌘K, word
count, rename) and 21 API checks (list nesting, move, tables, delete, refusals,
the access boundary from anonymous through commenter to editor, coalescing,
restore). 231 unit tests, tsc, spec:check and next build pass; npm run lint is
not configured in this repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Enter had three ways of not doing what it says. In a block holding markup the
run model can't describe — a <span>, a <sup> — it silently appended an empty
paragraph at the end instead of splitting, because the old implementation had to
be able to re-render both halves. In a block containing markdown (a bare URL is
enough) the inline-markdown pass ran first and swallowed the keystroke. And
pressing it at the start of a block left the caret in the new empty block above
rather than with the text.
Enter is now one op. splitAt CUTS the caret's text node at a byte offset: both
halves keep their bytes exactly, and the inline elements the caret sits inside
are closed before the block ends and reopened inside the new one. Nothing is
re-rendered, so it works in any block whatever markup it holds. At either end of
a block the same op leaves an empty half, which is what pressing Enter there
should give you, so the start/end special cases — and the misplaced caret that
came with them — are gone.
Markdown no longer competes for the keystroke. It resolves when the block is
left, and when there IS markdown before the caret the split carries it: splitAt's
other form replaces the text node with the two halves' content, which is also how
a split saves typing that hasn't been committed yet. Other runs of the same block
that were typed into travel along as their own text ops, so nothing is lost.
The split is applied to the DOM before the write returns. Enter is the most-
pressed structural key in a document and a round trip of dead air on every one of
them is the difference between writing and waiting; extractContents splits the
text node and clones the inline ancestors exactly the way splitAt does, so the
reload lands on an identical DOM.
Two bugs found while verifying this, both older than the change:
- Closing a block removed contenteditable while the block was still the open
one, so the resulting focusout was read as a click-away and saved it a second
time. On a block containing markdown that second save fired a markup write
the author never asked for.
- A reload leaves the outgoing document alive until the new one commits, and it
answers the shell's readiness ping first — so the restored caret was handed to
a page about to be discarded. jh:ready now carries the ?r= of the load that is
answering, and the shell only restores into the matching one. This is why the
caret appeared to be lost after every structural edit.
Verified against a real Chromium: 18 checks reading back the STORED bytes —
mid-text, start, end, inside <strong>, in a heading, in a block with a <span>, in
a block with a URL, caret position after each, markdown on blur and markdown
carried into a split, plus regressions on typing, ⌘B, '- ' and markdown paste.
245 unit tests, tsc, spec:check and next build pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Follow-up pushed: Make Enter always split the block at the caret (b3a4bd1). Enter had three ways of not doing what it says:
Enter is now one Markdown no longer competes for the keystroke: it resolves when the block is left, and when there is markdown before the caret the split carries it (the op's other form replaces the text node with both halves' content, which is also how a split saves typing that hasn't been committed yet). The split is applied to the DOM before the write returns, so Enter doesn't wait on a round trip. Two older bugs surfaced while verifying this, both fixed here:
Verified against a real Chromium: 18 checks reading back the stored bytes — mid-text, start, end, inside |
tldr
Inline editing could change text and nothing else. This adds the rest of a small editor on top of it — marks, links, block types, new blocks, deletes, drag reordering, list nesting, tables, code blocks, markdown-as-input — plus the version history and recovery that writing this often requires.
Two write paths
Typing stays a text patch to
/edits. Nothing reloads, the caret never moves, and comment anchors ride the patch's offset map.Formatting and structure go to a new
POST /ops. Each op names an element by thedata-jh-srcid written into the start tags of the overlay copy of the document (lib/docs/html-source.ts; a direct/rawfetch stays byte-pristine) and says what it should become.lib/docs/doc-ops.tsturns that into one splice of that element's byte range, so everything outside it is untouched down to the byte — the same guarantee the text path gives, and the reason/d/:slug/rawcan serve the author's bytes.Ops carry intent, never markup: a block is
{tag, runs}, every tag comes from a literal inlib/docs/block-render.ts, and author text is always escaped. There is no html crossing the wire, so there is no sanitizer to get wrong.Element ids are indexes into the bytes they were served with, so
base_versionis effectively required. A stale one is a 409 and the shell replays once — safe rather than hopeful, because a text op carries the text it expects to replace and a genuine collision comes back as a 422.Why an ops write reloads the iframe
It reloads against the stored bytes and restores the caret and scroll, rather than the overlay re-rendering the change locally. The alternative is a second markup renderer inside the sandbox that has to agree with the server's forever; the moment it drifts, the rendered document stops being the stored one. Typing does not reload.
Markdown is an input method, not a storage format
**bold**,`code`,[t](u), bare URLs,##/-/>/ ``` /---at a block start, and a multi-line paste all parse to runs and blocks that the server renders. Nothing round-trips back to asterisks: re-editing a bolded phrase shows bold text. The parsers live in `lib/docs/markdown-input.ts` and are injected into the sandboxed overlay, so the rules the viewer types against are the ones the tests cover.Keys
⌘B / ⌘I / ⌘E / ⌘⇧X / ⌘K · ⌘⌥1-3 headings · ⌘⇧7 / ⌘⇧8 lists · ⌘⇧. quote · Tab / ⇧Tab list indent and table-cell movement · Enter splits a block · Backspace on an empty block deletes it ·
/opens the block menu · Esc cancels. Blocks drag to reorder by the gutter grip; long-press opens a block on touch.Two things that fall out of writing this often
VERSION_COALESCE_MSreplace the previous snapshot instead of adding one, so a session of writing no longer exhaustsMAX_VERSIONS_PER_DOC. The document's version still increments on every write, so stale-write detection is unaffected.POST /versions/:n/restore, plus a button on the history page, writes an old version's content forward as a new one. Undoing a bad edit was previously database surgery.Also fixed
Editing a phrase that appears twice. When the text engine reports it can't place a match uniquely, the shell retries positionally as a
setRunsop, which names the exact text node instead of searching for its content. That failure used to be "text repeats — edit via your agent".What it refuses
A block holding markup the run model can't describe (an unknown inline element) is refused rather than reformatted.
<script>,<style>and<head>are never reachable through/ops, whatever id is passed. Converting a list item out of a multi-item list is refused rather than guessed at.Dependency
htmlparser2was already in the tree transitively; it is now a direct dependency, used for its source offsets. Its non-spec tree building doesn't matter here: the browser learns an element's id from an attribute rather than from tree position, and text ops are verified against the text they expect before anything is written.Verification
Driven against a real Chromium over CDP with a local Postgres, reading the stored bytes back after each action.
17 browser checks: typing a typo with the rest of the document byte-identical; ⌘B; markdown typed inline and pasted as blocks;
##and/; Enter splitting a block;&/ /<script>/<style>preserved; typed<b>stored escaped; editing one of two identical list items; the format toolbar, ⌘K field, word count and rename.21 API checks: list indent, move, table insert + row, delete; refusals (script, moved text, stale version); the access boundary — anonymous 401, read link 401, signed-in non-grantee 404, commenter 404, editor 200 with no
view_tokenin the response; coalescing (four writes → version 5, two retained snapshots); restore, re-restore refused, anonymous restore 401.231 unit tests,
tsc,spec:checkandnext buildpass.npm run lintisn't configured in this repo, so I didn't run it.Limits, on purpose
No images (there is no blob store; documents are
TEXTin Postgres). No column operations on tables. No syntax highlighting in code blocks. Conflicts are detected and replayed, not merged.Left as draft since pushes to
mainhere auto-deploy to production.🤖 Generated with Claude Code