Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
629270d
docs: add approved inline text editing design spec
DavidBabinec Jun 10, 2026
f975e9a
feat(module-engine): add inlineTextEdit contract, declare on text/but…
DavidBabinec Jun 10, 2026
a0a7f24
feat(editor-store): add inline text edit session slice with coalesced…
DavidBabinec Jun 10, 2026
6bfcbf6
feat(editor-store): force-close inline edit sessions on deletion and …
DavidBabinec Jun 11, 2026
20edb1d
feat(canvas): add inline-edit typography mirroring helpers
DavidBabinec Jun 11, 2026
6bad4b7
feat(canvas): add InlineTextEditOverlay, mounted per breakpoint frame
DavidBabinec Jun 11, 2026
ee59e9f
feat(canvas): wire double-click inline editing and hide doubled text
DavidBabinec Jun 11, 2026
41b2575
docs: document canvas inline text editing (parent-doc overlay)
DavidBabinec Jun 11, 2026
a617084
docs: fix remaining stale slice counts in editor.md (11 -> 12)
DavidBabinec Jun 11, 2026
83bf384
docs: fix stale slice count in adminUi header comment (11 -> 12)
DavidBabinec Jun 11, 2026
7447335
Merge origin/main into feat/inline-text-editing
DavidBabinec Jun 11, 2026
6cfeb1a
fix(canvas): render canvas + inline-edit field in the site's fonts, n…
DavidBabinec Jun 11, 2026
61b3457
fix(canvas): stop inline-edit field scroll-shifting multi-line text
DavidBabinec Jun 11, 2026
b7b265a
fix(canvas): remove inline-edit open flash by positioning in a layout…
DavidBabinec Jun 11, 2026
a23853d
feat(canvas): rewrite inline text editing to edit the real element in…
DavidBabinec Jun 11, 2026
84b59ea
fix(canvas): stop React clobbering the inline-edit caret every keystroke
DavidBabinec Jun 11, 2026
4fe6f20
fix(canvas): let the spacebar type a space during inline editing
DavidBabinec Jun 11, 2026
3486ff0
fix(canvas): finalize inline editing — keyboard-forward guard, select…
DavidBabinec Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(canvas): rewrite inline text editing to edit the real element in…
… 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>
  • Loading branch information
DavidBabinec and claude committed Jun 11, 2026
commit a23853df238fd1c22acdfdc4aaeabbe3837b15a4
5 changes: 2 additions & 3 deletions docs/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ Each tick is split into a read phase and a write phase to keep the loop cheap at

### Inline text editing (double-click)

Double-clicking a node whose module declares `inlineTextEdit` (`base.text`, `base.button`, childless `base.link`) opens `InlineTextEditOverlay` — a real `<textarea>`/`<input>` in the parent document, portaled into the canvas root and RAF-tracked over the node's rect inside the breakpoint iframe (same portal + RAF pattern as the selection rings). Typography is mirrored from the live element via the iframe's `getComputedStyle`, scaled by the iframe zoom factor. Every keystroke commits live through `updateNodeProps`, so all breakpoint frames preview the edit while the session's own frame hides the node's text (`data-instatic-inline-editing` + a canvas-chrome rule). The whole burst coalesces into one undo entry; Enter/blur commit + close, Escape reverts via a single `undo()`. Session state is `activeInlineEdit` in `inlineEditSlice`. Full design: [`docs/features/canvas-iframe-per-frame.md`](features/canvas-iframe-per-frame.md) → "Inline text editing (parent-doc overlay)".
Double-clicking a node whose module declares `inlineTextEdit` (`base.text`, `base.button`, childless `base.link`) edits the text **in place**: the node's own element inside the breakpoint iframe becomes the editor. `NodeRenderer` builds an `InlineEditBinding` and the module spreads `inlineEditableElementProps(binding)` onto its real root element, making it `contentEditable="plaintext-only"` (seeded once via `dangerouslySetInnerHTML` from the escaped initial value, with `\n` → `<br>`). There is no overlay and no typography mirroring — the author edits the actual published element, so the editing surface is byte-identical to what publishes. Every keystroke reads the text back with `readInlineEditableText(el)` (`el.innerText`) and commits live through `updateNodeProps`, so all breakpoint frames preview the edit; the burst coalesces into one undo entry. For single-line modules Enter commits + closes; for multiline `base.text`, Enter inserts a hard break (stored as `\n`, rendered as `<br>` everywhere) and Cmd/Ctrl+Enter commits. Blur commits + closes; Escape reverts via a single `undo()`. Canvas shortcuts (Delete/Cmd+D) are suppressed mid-edit by the `activeInlineEdit` guard in `useCanvasKeyboardShortcuts`. Session state is `activeInlineEdit` in `inlineEditSlice`. Full design: [`docs/features/canvas-iframe-per-frame.md`](features/canvas-iframe-per-frame.md) → "Inline text editing (in-place `contentEditable`)".

### CSS injection into the iframe

Expand Down Expand Up @@ -488,10 +488,9 @@ Canvas-internal values are not CSS tokens — they are raw integers intentionall
| `CanvasLayerContextMenu.tsx` | Right-click on a layer |
| `canvasDnd.ts` | Drag-and-drop (insert / move / wrap) |
| `canvasDomGeometry.ts` | Cross-iframe DOM measurement; `panToCenterBreakpointFrame` viewport centering geometry |
| `canvasOverlayGeometry.ts` | Cross-iframe element rect → canvas-root coords; CSS attribute value escaping |
| `canvasOverlayGeometry.ts` | Cross-iframe element rect → canvas-root coords; overlay rect union |
| `canvasSelectionUtils.ts` | Selection helpers |
| `BreakpointSelectionOverlay.tsx`| Selection / hover rings, selection toolbar, inspect ladder integration |
| `InlineTextEditOverlay.tsx` | Parent-doc inline text editor floated over a node in the breakpoint iframe (double-click to edit) |
| `CanvasInsertModuleButton.tsx` | "Insert module" button in the canvas selection toolbar — opens `ModuleInserterDialog` |
| `canvasTreeLadder.ts` | Alt/Option inspect ladder tree model |
| `CanvasTreeLadderOverlay.tsx` | `useCanvasTreeLadderOverlay` — wires the ladder model to canvas events and portal |
Expand Down
21 changes: 10 additions & 11 deletions docs/features/canvas-iframe-per-frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,20 +151,19 @@ Live frames skip wheel/pointer/keyboard forwarding — they scroll natively, hav

---

## Inline text editing (parent-doc overlay)
## Inline text editing (in-place `contentEditable`)

Double-click a text-bearing node to edit its text in place. The editor is a real `<textarea>`/`<input>` in the **parent document** (`InlineTextEditOverlay.tsx`), portaled into the canvas root and positioned over the node inside the breakpoint iframe with the same RAF tracking the selection rings use — no cross-frame focus negotiation, which is what made the old in-iframe `contentEditable` editor unshippable.
Double-click a text-bearing node to edit its text **in place**: the node's own element becomes the editor. There is no overlay and no parent-document field — `NodeRenderer` hands the module an `InlineEditBinding`, and the module spreads `inlineEditableElementProps(binding)` onto its real root element, making it `contentEditable="plaintext-only"`. Because the author edits the actual published element inside the breakpoint iframe, the editing surface is byte-identical to what publishes100% fidelity, with no typography mirroring, no font injection, and no doubled/hidden text to reconcile. (This superseded the earlier parent-document `<textarea>`/`<input>` overlay, which had to mirror computed typography and inject site fonts into the parent doc just to approximate the real element.)

- **Module contract:** `ModuleDefinition.inlineTextEdit?: { prop: string; multiline?: boolean }`. Declared by `base.text` (`text`, multiline), `base.button` (`label`), and `base.link` (`text`). Modules without the field keep the no-op double-click; the canvas has no per-module branches. A node with children never starts a session (`base.link` renders `text` only when childless), and dynamically-bound props are not literal-editable.
- **Session state:** `activeInlineEdit { nodeId, prop, breakpointId, multiline, initialValue, committed }` in `store/slices/inlineEditSlice.ts`. One session globally, owned by the frame that was double-clicked. Design mode only.
- **Live commit:** every keystroke calls `updateNodeProps(nodeId, { [prop]: value })`. Single-field patches coalesce under `props:<nodeId>:<prop>`, so the whole burst is ONE undo entry and every other frame previews the edit live. `startInlineEdit`/`endInlineEdit` reset `_historyCoalesceKey` so the session burst never folds into a Properties-panel burst for the same prop.
- **Hidden doubled text:** the edited node — in the session's frame only — carries `data-instatic-inline-editing`, and the canvas-chrome CSS paints it with `-webkit-text-fill-color: transparent` (NOT `color: transparent`: the overlay mirrors the element's computed `color` for the field's own text).
- **Typography mirroring:** `mirrorInlineEditTypography` (canvasOverlayGeometry.ts) copies font family/weight/style, color, text-align, and text-transform from `iframe.contentWindow.getComputedStyle(el)` and scales font-size / line-height / letter-spacing by the iframe zoom factor on every RAF tick.
- **Font parity:** the field lives in the parent document, so for the mirrored `font-family` to render the SAME typeface as the canvas, the parent needs the site's font files too. `ParentDocumentSiteFontsInjector` (mounted in `CanvasRoot`) injects the site's `@font-face` rules — via `generateSiteFontsCss`, **faces only, no `--font-*` token variables** so the admin UI's own `--font-sans` is untouched — into the parent `<head>`. Without it the field falls back to a different typeface than the published text it covers (custom/Google site fonts are loaded only inside the iframe).
- **End:** Enter or Cmd/Ctrl+Enter commits + closes — plain Enter commits even in multiline mode because `base.text` renders raw text into HTML where newlines collapse; Shift+Enter inserts a native newline for authors who add `white-space: pre-wrap` themselves. Blur commits + closes. Escape cancels: a single `undo()` iff the session committed anything.
- **Force-close:** node deleted (pruned in `pruneCanvasSelectionDraft`), document/page switch (`clearCanvasSelectionDraft`), frame unmount (breakpoint collapsed, live-mode switch), or an unmeasurable rect mid-session.

Keyboard interplay: the field lives in the parent document, so the existing `isTextInputTarget` guards already keep Delete/clipboard shortcuts out, and the field stops propagation of every keystroke so the canvas-root handler (Escape-clears-selection, duplicate, zoom keys) never sees them.
- **The element IS the editor:** when `inlineEdit` is set on the component props, the module renders NO children and spreads `inlineEditableElementProps(inlineEdit)` (`src/modules/base/shared/inlineText.ts`) onto its element — `contentEditable="plaintext-only"` (no rich formatting / pasted markup), the three live-edit handlers, and a one-time content seed via `dangerouslySetInnerHTML`. The seed is `rawTextToBreakHtml(initialValue)`: the stored value is HTML-escaped first, so the only markup is the `<br>`s, never user-injected HTML. The seed is constant for the whole session, so React never resets the caret while edits flow to the other frames.
- **Session state:** `activeInlineEdit { nodeId, prop, breakpointId, multiline, initialValue, committed }` in `store/slices/inlineEditSlice.ts`. One session globally, owned by the frame that was double-clicked (`isInlineEditing` is true only when `activeInlineEdit.breakpointId === breakpointId`). Design mode only. On session start a `useLayoutEffect` in `NodeRenderer` focuses the element and drops the caret at the end before paint.
- **Live commit:** `onInput` reads the edited text back with `readInlineEditableText(el)` (`el.innerText`, which resolves `<br>` and block boundaries to `\n`) and calls `applyInlineEditValue` → `updateNodeProps(nodeId, { [prop]: value })`. Single-field patches coalesce under `props:<nodeId>:<prop>`, so the whole burst is ONE undo entry and every OTHER frame previews the edit live. `startInlineEdit`/`endInlineEdit` reset `_historyCoalesceKey` so the session burst never folds into a Properties-panel burst for the same prop.
- **Line breaks stored as `\n`, rendered as `<br>`:** the stored value keeps newlines as `\n`. Both render surfaces turn each `\n` into a `<br>` so a hard break shows live in the canvas AND survives publish — `base.text` render emits `textToBreakHtml(text)` (text is pre-escaped by `escapeProps`; DOMPurify's richtext config allows `<br>`), and the canvas display path uses `rawTextToBreakHtml`. A break the author types is a break everywhere.
- **End:** for single-line modules (`base.button`, `base.link`) plain Enter commits + closes; for multiline `base.text`, plain Enter falls through so the browser inserts a hard break (stored as `\n`), and Cmd/Ctrl+Enter commits + closes. Blur commits + closes. Escape cancels: a single `undo()` iff the session committed anything.
- **Force-close:** node deleted, document/page switch, or frame unmount (breakpoint collapsed, live-mode switch) clear `activeInlineEdit` through the slice's existing guards.

Keyboard interplay: the editable element lives inside the breakpoint iframe, and its keystrokes bubble through React to the canvas-root handler. `useCanvasKeyboardShortcuts` bails at the top (`if (useEditorStore.getState().activeInlineEdit) return`), so Delete/Cmd+D/clipboard shortcuts never fire mid-edit; the element's own `onKeyDown` owns Escape (cancel) and Enter (commit / break).

Design doc: `docs/superpowers/specs/2026-06-10-inline-text-editing-design.md`.

Expand Down
15 changes: 14 additions & 1 deletion docs/superpowers/specs/2026-06-10-inline-text-editing-design.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
# Inline text editing on the canvas (double-click to edit) — Design

Date: 2026-06-10
Status: approved (approach 1A: parent-doc overlay editor)
Status: **superseded in implementation** — approach 1A (parent-doc overlay) was replaced by in-place `contentEditable` editing of the real element. The module contract, the editor-store session (`activeInlineEdit` in `inlineEditSlice`), the live-commit-coalescing-into-one-undo model, and Escape-cancel-via-one-`undo()` below are all still accurate. Only the *editing surface* changed.

> ## Superseding note (implementation)
>
> The parent-document overlay editor (a `<textarea>`/`<input>` floated over the node, with computed typography mirrored from the iframe and the site's fonts injected into the parent doc) was dropped in favour of editing the **real node element in place**. `NodeRenderer` builds an `InlineEditBinding`; the module spreads `inlineEditableElementProps(binding)` (`src/modules/base/shared/inlineText.ts`) onto its own root element, making it `contentEditable="plaintext-only"`, seeded once via `dangerouslySetInnerHTML` from the escaped initial value. Because the author edits the actual published element, fidelity is 100% — no overlay, no typography mirroring, no parent-doc font injection, no hidden/doubled text.
>
> Key facts that differ from the body below:
> - **No `InlineTextEditOverlay`, no `data-instatic-inline-editing` attribute, no canvas-chrome hide rule, no `mirrorInlineEditTypography` / `ParentDocumentSiteFontsInjector`** — all removed.
> - **Line breaks** (user's decision): the value stores newlines as `\n` and both render surfaces map each `\n` → `<br>` (`textToBreakHtml` publisher path, `rawTextToBreakHtml` canvas path; DOMPurify allows `<br>`). For multiline `base.text`, plain Enter inserts a hard break and Cmd/Ctrl+Enter commits; single-line modules commit on Enter.
> - **Read-back:** `readInlineEditableText(el)` returns `el.innerText` (resolves `<br>`/block boundaries to `\n`), fed to `applyInlineEditValue` on every `onInput`.
> - **Focus:** a `useLayoutEffect` in `NodeRenderer` focuses the element and collapses the caret to the end on session start.
> - **Keyboard:** `useCanvasKeyboardShortcuts` bails on `activeInlineEdit`, so Delete/Cmd+D never fire mid-edit; the element's own `onKeyDown` owns Escape/Enter.
>
> Current behaviour lives in `docs/features/canvas-iframe-per-frame.md` → "Inline text editing (in-place `contentEditable`)".

## Goal

Expand Down
48 changes: 33 additions & 15 deletions src/__tests__/canvas/canvasOverlayGeometry.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,45 @@
/**
* canvasOverlayGeometry — pure-function tests for the inline-edit typography
* scaling. (measureCanvasElementRect is exercised indirectly by the existing
* overlay integration paths; the scaler is the new, directly-testable logic.)
* canvasOverlayGeometry — pure-function tests for the overlay coordinate
* helpers. `unionCanvasOverlayRects` is fully deterministic and directly
* testable; `measureCanvasElementRect`'s null/zero-size guards are too. The
* scaled-measurement path needs a live, transformed iframe (covered by the
* canvas integration paths), so only the guards are asserted here.
*/
import { describe, it, expect } from 'bun:test'
import { scaleCssLength } from '@site/canvas/canvasOverlayGeometry'
import {
measureCanvasElementRect,
unionCanvasOverlayRects,
type CanvasOverlayRect,
} from '@site/canvas/canvasOverlayGeometry'

describe('scaleCssLength', () => {
it('scales px lengths by the iframe scale factor', () => {
expect(scaleCssLength('32px', 0.5)).toBe('16px')
describe('unionCanvasOverlayRects', () => {
const a: CanvasOverlayRect = { x: 0, y: 0, width: 10, height: 10 }

it('returns b unchanged when a is null', () => {
const b: CanvasOverlayRect = { x: 5, y: 5, width: 4, height: 4 }
expect(unionCanvasOverlayRects(null, b)).toEqual(b)
})

it('produces the smallest rect containing both inputs', () => {
const b: CanvasOverlayRect = { x: 20, y: 30, width: 10, height: 10 }
expect(unionCanvasOverlayRects(a, b)).toEqual({ x: 0, y: 0, width: 30, height: 40 })
})

it('passes keywords through untouched', () => {
expect(scaleCssLength('normal', 0.5)).toBe('normal')
it('handles a fully-contained rect (union equals the outer rect)', () => {
const inner: CanvasOverlayRect = { x: 2, y: 2, width: 4, height: 4 }
expect(unionCanvasOverlayRects(a, inner)).toEqual(a)
})

it('handles fractional and negative px values', () => {
expect(scaleCssLength('1.5px', 2)).toBe('3px')
expect(scaleCssLength('-0.5px', 2)).toBe('-1px')
it('handles negative origins', () => {
const neg: CanvasOverlayRect = { x: -5, y: -5, width: 2, height: 2 }
expect(unionCanvasOverlayRects(a, neg)).toEqual({ x: -5, y: -5, width: 15, height: 15 })
})
})

it('leaves non-px values untouched (unitless line-height, em)', () => {
expect(scaleCssLength('1.4', 0.5)).toBe('1.4')
expect(scaleCssLength('0.02em', 0.5)).toBe('0.02em')
describe('measureCanvasElementRect', () => {
it('returns null for a null target without touching the iframe', () => {
// iframe is never read on the null-target fast path, so a bare object is fine.
const fakeIframe = {} as unknown as HTMLIFrameElement
expect(measureCanvasElementRect(null, fakeIframe, null)).toBeNull()
})
})
Loading