Skip to content

feat(webapp): edit a synced HTML page by clicking its text - #220

Merged
ssowonny merged 2 commits into
mainfrom
feat/html-inline-editing
Sep 16, 2026
Merged

ssowonny merged 2 commits into
mainfrom
feat/html-inline-editing

Conversation

@ssowonny

Copy link
Copy Markdown
Contributor

TL;DR

  • Click any text on a synced HTML page and edit it in place — bold, italic and undo work, and it saves itself.
  • Only the paragraph you touched is rewritten: indentation, comments, <script> and <style> come back byte-for-byte, so History shows one line instead of a whole-file reformat.
  • Inline markup the editor doesn't model (spans, links, <sup>) is carried through untouched rather than making the paragraph read-only.
  • Also fixes a pre-existing collab bug where a dead seed claim left a file permanently uneditable — and would have let the source editor blank it.
  • Known gaps: <b>/<i> and a <br> inside a paragraph still refuse to open (Edit source covers them), and mobile is untested.

Why

An agent writes a good report with one wrong number. Re-prompting it to regenerate the whole file to fix six words is slow and churns every other line. This makes the small fix small.

The hard part was never the editor — it's that the file has to stay the file the agent wrote, or the next agent to read it sees a different document.

How it works

              Y.Text  =  the .html source            ← one CRDT, two views
                 │                    │
        CodeMirror                iframe ?edit=1
        (Edit source)             (click-to-edit)
                                       │
                                  postMessage
                                  {srcRange, html}
                                       │
                                  splice Y.Text

Server (internal/webapp/htmledit.go) — ?edit=1 marks the innermost text-bearing block with its inner content's byte range, shaped exactly like the existing ?print=1 view because HTML's parser accepts content appended after the stored bytes. Gated on write permission for the path, never served on /s/* or a download.

Client — ProseMirror mounted onto the page's own element ({mount}) inside the same sandboxed iframe reading already uses, shipped as a separate IIFE bundle that only loads in edit mode. Editing buys no capability the reading view didn't have. Patches splice into the shared Y.Text the source editor already binds to, so the two surfaces co-edit each other.

Nothing re-serializes the document. The iframe reports one element's inner HTML and the range it belongs to; everything outside that range is untouched.

Four things the design got wrong, found by building it

Phrasing content is content, not a region. Stamping the innermost text-bearing element made the only editable thing in "See the <a>notes</a> for the method" the link text. Real prose almost always contains a link or an emphasis, so almost nothing was editable.

Offsets must be UTF-16 code units. The browser resolves them against a Y.Text, which is indexed like every JS string. Counting bytes agrees for ASCII — so every fixture in the repo passed — and splices over a neighbour's markup on the first accented character.

TipTap's Link extension rewrites anchors. It re-renders with its own target="_blank" rel="noopener…" that the file never had, so the round trip failed and every paragraph containing a link was refused. Dropped; the passthrough mark carries <a> verbatim and the bundle lost 28 KB.

An empty CRDT reported as healthy. The anchoring guard read a helper whose seed fallback returns the file text when the shared document is empty — the precise state it existed to catch. Every anchor collapsed to from=0,to=end and one edit replaced an entire file. It reads the CRDT itself now, plus a guard that refuses any patch which would swallow a document the stamped range was only part of.

Then three more, found by actually using it

Every edited paragraph vanished off screen. ProseMirror's destroy() ends with dom.textContent = "" when {mount} was used — it assumes the owner re-renders, and nothing re-renders the page's own markup. The file was correct throughout, which made it worse: it looked exactly like the editor eating the document.

Typing and pressing Done lost the text. The iframe debounced its patch by 700 ms; for that window the edit existed only inside the iframe, and Done tears the iframe down. Every test in the file waited before asserting, which is precisely why this shipped. Removed — a patch is a message inside the same tab, and the save timer that batches writes to the hub is elsewhere.

The rendered page never reloaded. An iframe loads once, and the read view mounts before the save lands. It follows the change stream now, which also fixes a quieter case: a teammate's edit used to never appear in a page you were looking at.

Collab fix (pre-existing)

leave() releases a seed claim when the last subscriber goes — but a stream that is never cleanly torn down leaves a phantom subscriber, and the room stays claimed and empty forever. Everyone after is told they aren't the seeder and gets a blank document: the visual editor silently refuses to save, and the source editor would mount a blank buffer and snapshot that blankness over the file. A claim that produces nothing now expires; the client retries once past that grace before saying anything.

Tests

  • 16 e2e tests (inline-edit.spec.ts), each mutation-tested — the rule was broken on purpose to confirm a test dies.
  • Go tables for the stamper: range/source correspondence, UTF-16 offsets (accents, CJK, emoji — emoji rules out "count runes instead"), inline-as-content, malformed markup, permission gating, the oversized-file put-back.
  • Full hub e2e: 241 passed, 1 failedshell.spec.ts:10, which fails identically on a clean main worktree.
  • go test ./... green.

The spec works in a project of its own and removes what it creates: adding one file to the shared seed destabilised eight unrelated specs.

Architecture changes

architecture/webapp-server.mdeditView is a new class beside printView (the ?edit=1 render and its stamping rules); collabRoom gained a claimed timestamp so an abandoned seed claim can expire, and its note now explains why.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    sandboxInline["<div style='text-align:left'><b>sandboxInline</b><br/>CSP sandbox on stored bytes</div>"]
    printView["<div style='text-align:left'><b>printView</b><br/>&lt;&lt;serveBlob + handleBlob, ?print=1&gt;&gt;<br/>CSP + allow-modals</div>"]
    editView["<div style='text-align:left'><b>editView</b><br/>&lt;&lt;serveBlob, ?edit=1&gt;&gt;<br/>text/html only, PermWrite on the path<br/>stampEditable marks innermost text blocks<br/>data-bd-src offsets in UTF-16 units<br/>own ETag; declines past maxEditableHTML</div>"]
    collabHub["<b>collabHub</b>"]
    collabRoom["<div style='text-align:left'><b>collabRoom</b><br/>-updates opaque Yjs updates<br/>-seeded claimed at join<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -claimed when, so a dead claim can expire</span><br/>+join(sub) log, first<br/>+post(update, from) ok</div>"]
    sandboxInline -. "relaxes, by allow-modals alone" .-> printView
    sandboxInline -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ relaxes nothing — same sandbox as reading</span>" .-> editView
    collabHub -- "owns" --> collabRoom
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    class editView added
    linkStyle 1 stroke:#22c55e,stroke-width:2px
Loading

architecture/webapp-frontend.mdcomponents gained VisualEdit; lib gained sharedfile.ts, which both editing surfaces now sit on.

flowchart TB
    Browser["<b>Browser</b>"]
    components["<div style='text-align:left'><b>components</b><br/>FileView FolderListing FileTree<br/>HistoryView VersionBanner ConflictBanner<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ PresenceBar Editor VisualEdit</span><br/>Palette shell AccountBar ...</div>"]
    lib["<div style='text-align:left'><b>lib</b><br/>+collab.ts CollabDoc peerCount<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +sharedfile.ts openSharedFile SharedFile</span><br/>+scroll.ts +secrets.ts +csv.ts</div>"]
    Note["Editor binds CodeMirror to the Y.Text,<br/>VisualEdit splices ranges into the same one —<br/>so the two surfaces co-edit each other"]
    Browser --> components
    components -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ openSharedFile (one room, two surfaces)</span>" --> lib
    lib -.- Note
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class Note noteBox
    linkStyle 1 stroke:#22c55e,stroke-width:2px
Loading

Known limits

  • No structural edits. Enter doesn't split a paragraph, Backspace doesn't merge two. Adding a section or repairing a tag is what Edit source is for.
  • <b>/<i> and <br> refuse to open. Bold/Italic parse them and render <strong>/<em>, so the round trip fails and the gate correctly declines. Agents write the semantic tags, so this hasn't bitten.
  • The edited element's inner markup may come back normalized (entity form, attribute quoting). Everything outside it is byte-identical. Documented rather than hidden.
  • Mobile untested — not a verified non-goal, just one never exercised.
  • The admin e2e specs are flaky (234/7 twice, then 241/1 with no relevant change). Pre-existing; worth its own ticket, not a guess from here.

Full record, including the two process mistakes that cost real time, is in docs/html-inline-editing-prd.md.

🤖 Generated with Claude Code

ssowonny and others added 2 commits September 16, 2026 15:42
Open an .html file in the viewer, press Edit, click a paragraph and type.
Cmd+B/I/Z work. It saves itself and co-edits with the source editor.

The hard part is what happens to the FILE. Only the edited element's byte
range is replaced, so indentation, comments, <script> and <style> come back
byte-for-byte and the history diff is one line instead of a whole-file
rewrite — which is what keeps an agent-written report re-readable by the
agent that wrote it.

Server (htmledit.go): ?edit=1 marks the innermost text-bearing block with
its inner content's range, shaped like the existing ?print=1 view. Phrasing
content is content, not a region. Offsets are UTF-16 code units because the
browser resolves them against a Y.Text.

Client: ProseMirror mounted onto the page's own element inside the SAME
sandboxed iframe reading uses, as a separate IIFE bundle — editing buys no
capability the reading view did not have. Patches splice into the shared
Y.Text the source editor already binds to, so the two co-edit each other.

Also fixes a pre-existing collab hazard: a seed claim held by a stream that
was never cleanly torn down left the room claimed and empty forever, and
the source editor would snapshot that blankness over the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	internal/webapp/static/assets/index-BVg85aTJ.js
#	internal/webapp/static/assets/index-BavBGLxi.js
#	internal/webapp/static/assets/index-DmbFW-3I.js
#	internal/webapp/static/index.html
@ssowonny
ssowonny merged commit 6a5399f into main Sep 16, 2026
2 of 3 checks passed
@ssowonny
ssowonny deleted the feat/html-inline-editing branch September 16, 2026 22:58
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