feat(webapp): edit a synced HTML page by clicking its text - #220
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
<script>and<style>come back byte-for-byte, so History shows one line instead of a whole-file reformat.<sup>) is carried through untouched rather than making the paragraph read-only.<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
Server (
internal/webapp/htmledit.go) —?edit=1marks the innermost text-bearing block with its inner content's byte range, shaped exactly like the existing?print=1view 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 sharedY.Textthe 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
Linkextension rewrites anchors. It re-renders with its owntarget="_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=endand 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 withdom.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
inline-edit.spec.ts), each mutation-tested — the rule was broken on purpose to confirm a test dies.shell.spec.ts:10, which fails identically on a cleanmainworktree.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.md—editViewis a new class besideprintView(the?edit=1render and its stamping rules);collabRoomgained aclaimedtimestamp so an abandoned seed claim can expire, and its note now explains why.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/><<serveBlob + handleBlob, ?print=1>><br/>CSP + allow-modals</div>"] editView["<div style='text-align:left'><b>editView</b><br/><<serveBlob, ?edit=1>><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:2pxarchitecture/webapp-frontend.md—componentsgainedVisualEdit;libgainedsharedfile.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:2pxKnown limits
<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.Full record, including the two process mistakes that cost real time, is in
docs/html-inline-editing-prd.md.🤖 Generated with Claude Code