Skip to content

feat(html): a text editor that owns the edit - #875

Merged
andiwand merged 1 commit into
mainfrom
feat/text-edit-browser
Sep 10, 2026
Merged

feat(html): a text editor that owns the edit#875
andiwand merged 1 commit into
mainfrom
feat/text-edit-browser

Conversation

@andiwand

@andiwand andiwand commented Sep 10, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Fourth of five; #874 is merged, so this now sits on main. See
docs/design/document-editing.md decisions 6 and 6b.

What this one does

The editor cancels what the browser was about to do and splices the page
itself
, so the markup stays what the renderer wrote and every change has an
operation behind it.

Reading the run back afterwards only worked while an edit stayed inside one
run. Contenteditable's answer to a selection spanning two paragraphs is
browser-specific — a <div> wrapper here, a merged <b> there — and maps onto
nothing in the element tree.

So a reader can now:

  • type, replace and delete across runs and across paragraphs;
  • press Enter to split a paragraph, keeping the formatting on both sides;
  • press Backspace at the start of one to merge it into the paragraph before;
  • paste plain text over as many lines as it holds.

Undo and redo are the editor's

Cancelling every edit leaves the browser's own stack empty, so undo had to come
with this. Each step holds the operations it puts on the wire and the two
halves of taking it back, so canUndo and the chord agree at last and a host's
undo button is live. One beforeinput is one step.

Two things the browser does not always state

  • A delete whose range it left out is one character, in the direction the
    key names — or, at the start of a paragraph, the boundary itself, which
    merges and takes no character. A word or line delete is not guessed at:
    guessing where a word ends takes away text the reader did not name.
  • A composition cannot be cancelled, so the editor lets it finish and reads
    the run back on compositionend. With the page as the model there is nothing
    to reconcile — the run's text is the operation.

insertText gains a parent form for the paragraph Enter just made, which
holds no run to sit beside; Document::append_text is the same in C++, on the
document rather than on a handle like the other structural edits.

Checks

test/browser/text/tests.html — 48 checks, driving beforeinput and asserting
both the page and the log. defaultPrevented no longer says whether an edit
was taken, because the editor cancels either way, so the checks read the
refusal channel instead. Run headless:
test/browser/text/serve then Chrome --headless --dump-dom.

@andiwand
andiwand force-pushed the feat/text-edit-paragraphs branch from 4210894 to 5e1f351 Compare September 10, 2026 12:56
@andiwand
andiwand force-pushed the feat/text-edit-browser branch from 22f260f to 9ac0584 Compare September 10, 2026 12:56
@andiwand
andiwand force-pushed the feat/text-edit-paragraphs branch 2 times, most recently from 267399e to 7c538eb Compare September 10, 2026 13:44
Base automatically changed from feat/text-edit-paragraphs to main September 10, 2026 13:45
The editor cancels what the browser was about to do and splices the page
itself, so the markup stays what the renderer wrote and every change has
an operation behind it. Reading the run back afterwards only worked while
an edit stayed inside one run: contenteditable's answer to a selection
spanning two paragraphs is browser-specific and maps onto nothing in the
element tree.

So a reader can now type, replace and delete across runs and across
paragraphs, press Enter to split a paragraph and keep the formatting on
both sides, press Backspace at the start of one to merge it into the
paragraph before, and paste plain text over as many lines as it holds.

Undo and redo come with it, because cancelling every edit leaves the
browser's own stack empty. Each step holds the operations it puts on the
wire and the two halves of taking it back, so `canUndo` and the chord
agree at last and a host's undo button is live.

Two things the browser does not always state, and what the editor does
about them. A delete whose range it left out is one character, or the
paragraph boundary the caret stands at - but a word or line delete is
not guessed at, because guessing where a word ends takes away text the
reader did not name. And a composition cannot be cancelled at all, so the
editor lets it finish and reads the run back on `compositionend`.

`insertText` gains a `parent` form for the paragraph Enter just made,
which holds no run to sit beside; `Document::append_text` is the same in
C++.

The check page drives all of it; `defaultPrevented` no longer says whether
an edit was taken, because the editor cancels either way, so the checks
read the refusal channel instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW
@andiwand
andiwand force-pushed the feat/text-edit-browser branch from 9ac0584 to 5ec412a Compare September 10, 2026 13:52
@andiwand
andiwand merged commit 4821fb7 into main Sep 10, 2026
25 checks passed
@andiwand
andiwand deleted the feat/text-edit-browser branch September 10, 2026 13:56
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