Skip to content

Formatting, blocks and markdown input for inline editing - #18

Draft
sjmiller609 wants to merge 2 commits into
mainfrom
hypeship/rich-inline-editing
Draft

Formatting, blocks and markdown input for inline editing#18
sjmiller609 wants to merge 2 commits into
mainfrom
hypeship/rich-inline-editing

Conversation

@sjmiller609

Copy link
Copy Markdown
Contributor

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 the data-jh-src id written into the start tags of the overlay copy of the document (lib/docs/html-source.ts; a direct /raw fetch stays byte-pristine) and says what it should become. lib/docs/doc-ops.ts 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 lib/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_version is 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 coalescing. Consecutive patches by one author inside VERSION_COALESCE_MS replace the previous snapshot instead of adding one, so a session of writing no longer exhausts MAX_VERSIONS_PER_DOC. The document's version still increments on every write, so stale-write detection is unaffected.
  • Restore. 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 setRuns op, 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

htmlparser2 was 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; &amp;/&nbsp;/<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_token in the response; coalescing (four writes → version 5, two retained snapshots); restore, re-restore refused, anonymous restore 401.

231 unit tests, tsc, spec:check and next build pass. npm run lint isn't configured in this repo, so I didn't run it.

Limits, on purpose

No images (there is no blob store; documents are TEXT in 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 main here auto-deploy to production.

🤖 Generated with Claude Code

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>
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
justhtml Ready Ready Preview, Comment Jul 27, 2026 7:47pm

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>
@sjmiller609

Copy link
Copy Markdown
Contributor Author

Follow-up pushed: Make Enter always split the block at the caret (b3a4bd1).

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 — 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
  • 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 splitAt op that 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 the same op leaves an empty half, 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 (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. extractContents splits the text node and clones the inline ancestors exactly the way splitAt does, so the reload lands on an identical DOM.

Two older bugs surfaced while verifying this, both fixed here:

  • closing a block removed contenteditable while it was still the open block, 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 answering it 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant