Skip to content

Inline text editing on the canvas (double-click to edit)#25

Merged
DavidBabinec merged 18 commits into
mainfrom
feat/inline-text-editing
Jun 11, 2026
Merged

Inline text editing on the canvas (double-click to edit)#25
DavidBabinec merged 18 commits into
mainfrom
feat/inline-text-editing

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

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.

  • Module contract: ModuleDefinition.inlineTextEdit?: { prop, multiline? } — declared by base.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.
  • Session slice: activeInlineEdit in the editor store with startInlineEdit / applyInlineEditValue / endInlineEdit / cancelInlineEdit. Every keystroke commits live through updateNodeProps with the existing props:<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.
  • Overlay: InlineTextEditOverlay mounts per breakpoint frame (BreakpointSelectionOverlay portal + RAF pattern), tracks the node rect zoom-aware via measureCanvasElementRect, and mirrors the node's typography from the iframe realm (scaleCssLength / mirrorInlineEditTypography).
  • Semantics: Enter (and Cmd/Ctrl+Enter) commits; Shift+Enter inserts a newline in multiline; blur commits; Escape cancels via a single 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: transparent chrome rule) so glyphs never double; other frames keep rendering the live value.
  • Dynamic-bound props and link-with-children are excluded at the gate; spec + plan committed under docs/superpowers/; docs/features/canvas-iframe-per-frame.md + docs/editor.md updated.

Verification

  • bun test 5080 pass / 0 fail, bun run build and bun run lint exit 0 (re-run independently by an adversarial reviewer per task; every task of the implementation plan passed review with zero must-fix issues).
  • Browser smoke test against the disposable E2E stack: double-click opens the focused, select-all overlay exactly over the node (dx=0/dy=0 while panned and zoomed); typing live-updates mobile/tablet/desktop frames simultaneously while the session frame hides its doubled text; blur commits; one Undo click reverts the whole burst; Redo restores; Escape cancels and closes. Caveat: the session was opened by dispatching a native dblclick MouseEvent on 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

DavidBabinec and others added 18 commits June 10, 2026 22:46
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>
@DavidBabinec
DavidBabinec merged commit c99ea70 into main Jun 11, 2026
6 checks passed
@DavidBabinec
DavidBabinec deleted the feat/inline-text-editing branch June 11, 2026 17:07
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