Inline text editing on the canvas (double-click to edit)#25
Merged
Conversation
Approach 1A — parent-doc overlay editor over the breakpoint iframe. Source of truth for the inline-text-editing implementation tasks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ton/link
Optional ModuleDefinition.inlineTextEdit { prop, multiline? } is the
extension point for canvas double-click inline editing — no per-module
branches in canvas code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… live commit activeInlineEdit + startInlineEdit/applyInlineEditValue/endInlineEdit/ cancelInlineEdit. Live commits ride updateNodeProps' props:<nodeId>:<prop> coalescing so a whole session is one undo entry; start/end reset _historyCoalesceKey to isolate the burst; cancel undoes once iff committed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…doc switch clearCanvasSelectionDraft (page/VC/document switches) and pruneCanvasSelectionDraft (node deletion, incl. swept descendants) now clear activeInlineEdit, funnelling every stale-session path through the same helpers the selection already uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scaleCssLength + mirrorInlineEditTypography — computed styles read through iframe.contentWindow, px lengths scaled by the iframe zoom factor so the parent-doc field matches the text it floats over at every zoom level. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Parent-document textarea/input portaled into the canvas root and RAF-tracked over the node rect (BreakpointSelectionOverlay pattern), with live commit per keystroke, select-all on entry, Enter/blur commit, Shift+Enter newline in multiline, Escape cancel, and force-close on node unmount / frame unmount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
onNodeDoubleClick carries the originating breakpoint and starts a session (design mode, content-edit permission); NodeRenderer flags the edited node in the session's frame; CANVAS_CHROME_CSS paints it transparent via -webkit-text-fill-color so the overlay can still mirror computed color. Extracting CANVAS_CHROME_CSS + applyIframeBodyReset (and the IframeInteraction type) into canvas/iframeBodyReset.ts keeps IframeFrameSurface.tsx under the 700-line module ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the "inline text editing removed" known-limitation with the implemented design; adds the inlineEditSlice and InlineTextEditOverlay to the editor reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 8 updated the slice table heading to 12 slices (inlineEditSlice joined the store) but lines 15 and 257 still said 11, leaving the doc internally contradictory. store.ts composes 12 slice factories. The "11 named mutation actions" lines are untouched - they count tree mutations, not slices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The editor store gained inlineEditSlice on this branch; the adminUi header comment still said it carries 11 slices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/editor.md: keep both same-spot additions — main's overlay read/write-phase paragraph (continuing the RAF-loop section) followed by this branch's inline text editing section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ot the editor's Two font-fidelity bugs the inline text editor exposed: 1. EditorChromeInjector copied the admin app's --font-sans (Inter) straight onto the iframe :root. The injector is unlayered, so it beat the site's own --font-sans (which lives in @layer user-authored) — every canvas element silently rendered in the editor font instead of the site's configured font (e.g. PP Right Grotesk), and Inter isn't even loaded in the iframe so it fell back to Helvetica. Forward the editor font under a chrome-namespaced --editor-chrome-font-sans instead; the site's --font-sans is left alone and now wins for content. Admin chrome is unchanged (its own --font-sans still drives the UI). 2. The inline-edit field is a parent-document overlay, but the site's font @font-face rules were injected only into the canvas iframe. Mirroring a node's font-family onto the field couldn't render it — the parent fell back to a different typeface than the published text the field covers. New ParentDocumentSiteFontsInjector (mounted in CanvasRoot) injects the site @font-face into the parent <head> — faces only, no --font-* token variables, so the admin UI font is untouched. Verified in a real browser: the edited heading's text now measures byte-identical between the canvas and the overlay field (0px delta, both PP Right Grotesk), where it was ~6.7% off before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A <textarea> scrolls its own content to keep the caret visible whenever the text is taller than the box — which happens for any node with line-height < 1 (negative leading makes glyphs overflow the line boxes, exactly how the block node renders them with overflow:visible). Switching lines / wrapping made the whole heading jump. Grow the field to its full content height each RAF tick (measured after typography is mirrored) so there is nothing to scroll, and pin any residual offset. The selection-affordance ring moves to its own element bound to the node's box (matching the normal selection ring) so it no longer grows with the field — the field itself is now invisible chrome. Verified in a real browser: caret on line 2 keeps scrollTop 0 (overflowsBy 0), ring matches the node box exactly, text stays put across line switches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… effect The first position+focus tick ran in a useEffect (after paint), so the field painted once at the canvas origin in its default font before the tick moved and type-matched it — a visible flash on every double-click. Move the initial tick + focus into useLayoutEffect so the field is correct on its first painted frame. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… place The parent-document textarea overlay could never be 100% faithful — a textarea is a different rendering surface than the canvas node, so it diverged on font, scroll (negative line-heights), the glyph spill, and whitespace/newlines. Replace it with editing the REAL element: - Double-click makes the node's own element contentEditable='plaintext-only'. There is no overlay and no typography mirroring — the editing surface IS the published element, so font/wrapping/line-height/breaks are identical by construction. The font, scroll-shift, and open-flash bugs are gone because there is no second surface. - NodeRenderer builds an InlineEditBinding for the edited node+frame: the element is seeded once from the frozen initial value (dangerouslySetInnerHTML with a constant string, so React never resets the caret while live edits flow to the other frames), live edits read back via innerText and commit through the existing applyInlineEditValue (one coalesced undo entry), and a layout effect focuses + drops the caret on the first painted frame. - Hard line breaks now work and persist: base.text stores '\n' and renders it as <br> in the canvas AND the publisher (sanitizer already allows <br>). Enter inserts a break for multiline base.text; Enter commits for single-line base.button / base.link; Cmd/Ctrl+Enter always commits; Escape cancels via one undo; blur commits. - Canvas shortcuts (Delete/Cmd+D/copy/paste) are suppressed while a session is active so iframe keystrokes can't trigger them cross-realm. Removes the obsolete overlay path entirely: InlineTextEditOverlay (+ css), ParentDocumentSiteFontsInjector, mirrorInlineEditTypography/scaleCssLength, the hide-doubled-text rule and its data-instatic-inline-editing attribute. Keeps the EditorChromeInjector --font-sans fix (canvas renders the site font). Verified in a real browser: text/button/link edit in place; live commit propagates to other frames; hard breaks round-trip '\n' ↔ <br>; Escape reverts; Enter commits single-line / breaks multiline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Typing reset the caret to the start of the heading: each character was
written, then the caret jumped to offset 0 and the next character landed
there. A MutationObserver + innerHTML trap showed React itself re-applying
the element's content on every keystroke:
innerHTML.set <- frozen initial value (no typed char)
at setProp / updateProperties / commitUpdate (react-dom 19)
Root cause: React 19's setProp re-applies dangerouslySetInnerHTML on EVERY
commitUpdate of an element — it does not skip on an unchanged __html. The
live-commit re-render fires one commit per keystroke (the inline handlers are
fresh closures each render, so the element always has a prop update), so React
kept overwriting the user's edit with the stale seeded HTML and collapsing the
caret. A 'frozen' dangerouslySetInnerHTML cannot work: React owning a
contentEditable element's content is fundamentally incompatible with the
browser mutating it.
Fix: React no longer owns the editing element's content. inlineEditableElementProps
provides only contentEditable + the handlers + ref — no dangerouslySetInnerHTML,
no children — so there is no content prop for React to re-apply. The canvas
seeds the element imperatively once on session start (seedInlineEditableContent,
escaped value with \n -> <br>) in the same layout effect that focuses it.
React leaves the contentEditable DOM alone for the rest of the session.
Verified in a real browser: typing at the end accumulates and the caret stays
at the end; inserting mid-string lands at the caret and stays there; live
commit to the other breakpoint frames still works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pressing space mid-word did nothing. The edited element lives in a breakpoint iframe, which forwards its keystrokes to the parent document as a clone dispatched on `document` (so parent shortcuts like Cmd+K/Cmd+S work). The space-to-pan handler (useCanvas) listens on that document and guards only against `target.isContentEditable` — but the forwarded event's target IS the document, not the iframe's editing element, so the guard missed it, the handler called preventDefault(), and forwardKeyboard propagated that back to suppress the original spacebar. Net effect: every space was eaten. Guard the space-pan handler with the same activeInlineEdit check the other canvas shortcut handler uses: while a session is active the spacebar (and every non-Escape/Enter key) must reach the editing element untouched. Verified in a real browser: a Space keydown on the editing element went from defaultPrevented:true to defaultPrevented:false after the fix; contentEditable inserts the space normally (execCommand path already confirmed spaces work). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion, cleanup Adversarial pre-merge review of the feature surfaced six issues: 1. Cmd+Z mid-edit corrupted state (BUG). IframeFrameSurface forwards every iframe keystroke as a clone dispatched on the parent `document`, where native handlers (undo/redo, zoom reset, panel rail, space-pan) guard only on `e.target.isContentEditable` — but the clone's target is the document, not the cross-realm editing element, so they fired mid-edit. Worst case: Cmd+Z ran the store undo() (reverting the whole coalesced session) while the contentEditable DOM kept the text → store/DOM divergence. Fix at the source: the iframe key-forward layer stands down entirely during a session. This supersedes the earlier per-handler space-pan guard (now removed) and covers every native parent listener at once; Cmd+Z becomes the element's own native text undo. 2. user-select/cursor (BUG). Canvas frames force `user-select: none` / `cursor: default` on `*`; added a `[contenteditable]` exception so the author can select/double-click/drag text and gets an I-beam while editing. 3. Dead field. Removed the unused `InlineEditBinding.initialValue` (written by NodeRenderer, never read — seeding reads the session value directly). 4. A sandboxed (untrusted plugin) module declaring inlineTextEdit would open a stuck, invisible session (no contentEditable element in the sandbox frame). startInlineEdit now bails for sandboxed modules, mirroring NodeRenderer's shouldRenderSandbox check. 5/6. Doc/comment accuracy: corrected the stale `inlineTextEdit` field comment (described the dropped overlay), refreshed the design spec + feature doc + the superseded plan banner to the imperative-seed / source-guard reality, and noted the mid-session element-swap trade-off of imperative seeding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
Summary
Restores click-and-type on the canvas — the interaction removed in the iframe-per-frame refactor — using the replacement shape the design doc recommended: a real
<textarea>/<input>in the parent document, positioned over the node inside the breakpoint iframe, so there is no cross-frame focus negotiation at all.ModuleDefinition.inlineTextEdit?: { prop, multiline? }— declared bybase.text(text, multiline),base.button(label),base.link(text, childless only). No per-module special cases in canvas code; future modules opt in with one line.activeInlineEditin the editor store withstartInlineEdit/applyInlineEditValue/endInlineEdit/cancelInlineEdit. Every keystroke commits live throughupdateNodePropswith the existingprops:<nodeId>:<prop>coalesce key → one undo entry per editing burst, live preview in all breakpoint frames. The session's burst is isolated from in-flight Properties-panel bursts.InlineTextEditOverlaymounts per breakpoint frame (BreakpointSelectionOverlayportal + RAF pattern), tracks the node rect zoom-aware viameasureCanvasElementRect, and mirrors the node's typography from the iframe realm (scaleCssLength/mirrorInlineEditTypography).undo()iff the session changed anything; force-close on node deletion, document/page switch, and frame unmount. The session's frame hides its own text (data-instatic-inline-editing+-webkit-text-fill-color: transparentchrome rule) so glyphs never double; other frames keep rendering the live value.docs/superpowers/;docs/features/canvas-iframe-per-frame.md+docs/editor.mdupdated.Verification
bun test5080 pass / 0 fail,bun run buildandbun run lintexit 0 (re-run independently by an adversarial reviewer per task; every task of the implementation plan passed review with zero must-fix issues).dblclickMouseEventon the real element (the CLI driver cannot synthesize a trusted coordinate double-click); unit tests cover the React wiring from real double-click props.🤖 Generated with Claude Code