Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 9 additions & 4 deletions docs/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The frontend is a single React 19 + Vite SPA mounted at `/admin`. Inside it, two
- **Router:** `src/admin/lib/routing/` — in-house router replacing `react-router-dom`. 10 routes, all wrapped in a per-route `<ErrorBoundary>` and `<Suspense>`.
- **Cold path:** entry chunk is tiny. `AuthenticatedAdmin` is `React.lazy` and only loads post-login. Each workspace page is wrapped in `prewarmedLazy(...)`: the active page fires its import at module evaluation; the remaining pages pre-warm via `requestIdleCallback` after first paint so subsequent nav is synchronous (no Suspense flicker).
- **Workspaces:** `dashboard`, `site` (the editor), `content`, `data`, `media`, `plugins`, `users`, `ai`, `account`, `pluginPage`. Capability-gated by `canAccessWorkspace`.
- **Editor store** lives at `src/admin/pages/site/store/`. Zustand + Mutative (`zustand-mutative`) + `subscribeWithSelector`. 11 slices, one source of truth for the page tree. Undo/redo uses patch-based history (O(change) per step, not O(site)).
- **Editor store** lives at `src/admin/pages/site/store/`. Zustand + Mutative (`zustand-mutative`) + `subscribeWithSelector`. 12 slices, one source of truth for the page tree. Undo/redo uses patch-based history (O(change) per step, not O(site)).
- **Active tree routing:** `mutateActiveTree(fn)` in `siteSlice` is the **only** place that branches on page-mode vs. VC-mode. The 11 named mutation actions are one-liners that delegate to it.
- **Canvas:** `src/admin/pages/site/canvas/` renders the page tree into per-breakpoint `IframeFrameSurface` iframes. Two views: **design** (multiple breakpoints side-by-side with pan/zoom) and **live** (single real-size editable frame with normal scrolling). Design mode paints iframe shells with detailed skeletons first, mounts the active breakpoint's node tree after the first paint, then fills inactive breakpoint frames on idle time. Three canvas ring tokens: `--canvas-selection-ring` (neon green, selected node), `--canvas-hover-ring` (neon pink, hovered node), `--canvas-selector-ring` (neon orange, selector-panel match sweep).
- **Spotlight:** Cmd+K palette at `src/admin/spotlight/`. Always available across workspaces. Owns its own command registry, providers, and scopes.
Expand Down Expand Up @@ -254,7 +254,7 @@ src/admin/
The editor is a self-contained app inside the admin shell. It owns:

- A canvas that renders the page tree into per-breakpoint iframes.
- A heavy Zustand store with 11 slices.
- A heavy Zustand store with 12 slices.
- Left and right sidebars with collapsible panels.
- A toolbar with publish / save / zoom / the module inserter.
- Property controls bound to selected nodes.
Expand Down Expand Up @@ -325,7 +325,7 @@ Organization is persisted in `site.explorer` on the site shell. Folders are deco

**Undo/redo** uses patch-based history: every undoable mutation captures Mutative `[next, forward, inverse]` patch pairs scoped to the `SiteDocument`. Undo applies `entry.inverse`, redo applies `entry.forward` — O(change) in both time and memory, not O(site). A 50-deep history holds kilobytes of patches instead of hundreds of megabytes of full-site clones. See [`docs/reference/editor-history.md`](../reference/editor-history.md).

The store is composed of **11 slices**, each created by a factory in `store/slices/`:
The store is composed of **12 slices**, each created by a factory in `store/slices/`:

| Slice | Owns |
|------------------------|----------------------------------------------------------------------------|
Expand All @@ -340,6 +340,7 @@ The store is composed of **11 slices**, each created by a factory in `store/slic
| `agentSlice` | AI Agent Panel state + streaming |
| `sitePanelSlice` | Dependency manifest + site runtime settings |
| `clipboardSlice` | Copy / cut / paste of layer subtrees, persisted editor-wide |
| `inlineEditSlice` | `activeInlineEdit` — the canvas inline text-edit session (double-click to edit) |

The combined `EditorStore` type lives at `store/types.ts` so each slice can import it without going through `store.ts` (this eliminates the historical store ↔ slice cycles).

Expand Down Expand Up @@ -410,6 +411,10 @@ Ring and toolbar positions are computed on each animation frame via a RAF loop (

Each tick is split into a read phase and a write phase to keep the loop cheap at 60fps. The read phase resolves tracked elements through a `CanvasNodeElementCache` (`canvasNodeLookup.ts` — cached until the element disconnects or the iframe swaps documents, so no per-frame `querySelector` document scans), snapshots the shared iframe/canvas-root geometry once per tick via `createCanvasOverlayMeasureSession` (`canvasOverlayGeometry.ts`), and measures every rect — the toolbar anchors to the union of the ring rects already measured, never a second query/measure pass. The write phase then applies styles, skipping any write whose rect is already applied. Steady-state frames are therefore a few cached-layout reads with zero writes, and because no write lands between reads, changing rects never force per-ring reflows. **Keep new overlay work inside this read-then-write structure.**

### Inline text editing (double-click)

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

Each iframe `<head>` receives five `<style>` elements (three from `ClassStyleInjector`, one each from the others), in this order:
Expand Down Expand Up @@ -483,7 +488,7 @@ 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 |
| `CanvasInsertModuleButton.tsx` | "Insert module" button in the canvas selection toolbar — opens `ModuleInserterDialog` |
Expand Down
31 changes: 24 additions & 7 deletions docs/features/canvas-iframe-per-frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Five `<style>` elements are injected per iframe (three from `ClassStyleInjector`

| Injector | `id` attribute | Cascade layer | Purpose |
|---|---|---|---|
| `EditorChromeInjector` | `instatic-editor-chrome` | **unlayered** | Editor chrome: placeholder, slot-instance, unknown-module styles. Copies design tokens (`--editor-*`) from parent `:root` onto iframe `:root`. |
| `EditorChromeInjector` | `instatic-editor-chrome` | **unlayered** | Editor chrome: placeholder, slot-instance, unknown-module styles. Copies design tokens (`--editor-*`) from parent `:root` onto iframe `:root`. The editor UI font is forwarded as a **chrome-namespaced** `--editor-chrome-font-sans` (NOT `--font-sans`): because the injector is unlayered it would otherwise clobber the site's own `--font-sans` and render all canvas content in the editor font. |
| `ClassStyleInjector` | `mc-classes` | `@layer user-authored` | Publisher reset + framework CSS + class registry CSS |
| `ClassStyleInjector` | `mc-classes-preview` | `@layer user-authored` | Higher-specificity preview rule (doubled selector) while a property control is hovered. Empty for state-pseudo rules — those use `mc-classes-force-state` instead. |
| `ClassStyleInjector` | `mc-classes-force-state` | `@layer user-authored` | Force-paints the active state-pseudo rule (`.btn:hover`, `.card:focus`, etc.) onto the selected node via a doubled `[data-node-id]` selector so the state is visible/editable without physically triggering it. Mirrors the full `contextStyles` emission per breakpoint and condition. |
Expand Down Expand Up @@ -151,19 +151,36 @@ Live frames skip wheel/pointer/keyboard forwarding — they scroll natively, hav

---

## Plugin module sandboxing (`ModuleSandboxFrame`)
## Inline text editing (in-place `contentEditable`)

Plugin canvas modules render inside `ModuleSandboxFrame.tsx`, a separate component that is NOT `IframeFrameSurface`. Plugin modules run in a `sandbox="allow-scripts"` iframe with no `allow-same-origin` — they communicate with the host via `postMessage`. This is distinct from the page tree iframes described above.
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 publishes — 100% 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.
- **The element IS the editor, and React must NOT own its content:** 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) plus the three live-edit handlers, and crucially **no `dangerouslySetInnerHTML` and no children**. React 19 re-applies `dangerouslySetInnerHTML` on *every* commit of an element (it does not skip an unchanged `__html`), and the live-commit re-render fires one commit per keystroke — so a React-owned content prop would overwrite the user's typing and collapse the caret to the start every keystroke. Instead the canvas seeds the element's content **imperatively** once via `seedInlineEditableContent(el, initialValue)` (which sets `el.innerHTML = rawTextToBreakHtml(initialValue)` — HTML-escaped first, so the only markup is the `<br>`s, never user HTML), and React leaves the contentEditable DOM untouched for the rest of the session.
- **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` seeds the content, 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 reach the parent two ways — they bubble through React to the canvas-root handler, and `IframeFrameSurface` re-dispatches a clone on the parent `document` so native parent shortcuts work. Both paths must stand down mid-edit:

- **React path:** `useCanvasKeyboardShortcuts` bails at the top (`if (useEditorStore.getState().activeInlineEdit) return`), so Delete/Cmd+D/clipboard shortcuts never fire.
- **Forwarded path:** `IframeFrameSurface`'s `onKeyDown` returns early during a session (same guard) and never forwards the clone. This is the source fix for every native `document`/`window` listener at once — the forwarded clone's `target` is the `document`, not the cross-realm editing element, so each handler's own `target.isContentEditable` guard can't see it. Without this the spacebar would start a pan (eaten), and — the real hazard — **Cmd+Z** would run the store `undo()` (reverting the whole coalesced session) while the contentEditable DOM keeps the text, diverging store from DOM. Standing the forward layer down lets the spacebar type and Cmd+Z be the element's own native text undo.

The element's own React `onKeyDown` owns Escape (cancel) and Enter (commit / break).

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

---

## Known limitations
## Plugin module sandboxing (`ModuleSandboxFrame`)

### Inline text editing removed
Plugin canvas modules render inside `ModuleSandboxFrame.tsx`, a separate component that is NOT `IframeFrameSurface`. Plugin modules run in a `sandbox="allow-scripts"` iframe with no `allow-same-origin` — they communicate with the host via `postMessage`. This is distinct from the page tree iframes described above.

Double-click to edit text/button content in-place was removed when the iframe move landed. The cross-frame focus model (iframe needs system focus, body competes, React StrictMode double-mount races) made every fix fragile. Text and button content is edited through the Properties panel.
---

When revisited, the shape worth considering is a parent-doc overlay positioned over the iframe element — a real `<input>`/`<textarea>` in the parent doc, no iframe focus negotiation needed.
## Known limitations

### Test environment — iframe globals

Expand Down
Loading