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): add InlineTextEditOverlay, mounted per breakpoint frame
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>
  • Loading branch information
DavidBabinec and claude committed Jun 11, 2026
commit 6bad4b7a248bf8ac6139106762b1a16073c1dd09
139 changes: 139 additions & 0 deletions src/__tests__/canvas/inlineTextEditOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* InlineTextEditOverlay component tests — per-frame render gating, field
* variant (textarea vs input), live commit through the store, and the
* Enter / Shift+Enter / Escape / blur / unmount end-of-session semantics.
*
* iframeElement is null in these tests: positioning + typography mirroring
* need a live iframe (covered by the manual smoke test); the session
* semantics under test are iframe-independent.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import React from 'react'
import { render, fireEvent, cleanup } from '@testing-library/react'
import { useEditorStore } from '@site/store/store'
import { InlineTextEditOverlay } from '@site/canvas/InlineTextEditOverlay'
import '@modules/base/text'
import '@modules/base/button'

const FIELD_SELECTOR = '[data-testid="canvas-inline-edit-field"]'

function seedSession(multiline = true): { nodeId: string } {
const store = useEditorStore.getState()
const site = store.createSite('Overlay Test Site')
const rootId = site.pages[0].rootNodeId
const nodeId = multiline
? useEditorStore.getState().insertNode('base.text', { text: 'Hello' }, rootId)
: useEditorStore.getState().insertNode('base.button', { label: 'Hello' }, rootId)
useEditorStore.getState().startInlineEdit(nodeId, 'bp-a')
return { nodeId }
}

function storedValue(nodeId: string): unknown {
const node = useEditorStore.getState().site!.pages[0].nodes[nodeId]
return node.props.text ?? node.props.label
}

function queryField(): HTMLTextAreaElement | HTMLInputElement | null {
return document.querySelector<HTMLTextAreaElement | HTMLInputElement>(FIELD_SELECTOR)
}

beforeEach(() => {
useEditorStore.setState({
site: null,
activePageId: null,
activeDocument: null,
activeInlineEdit: null,
_historyPast: [],
_historyFuture: [],
_historyCoalesceKey: null,
canUndo: false,
canRedo: false,
selectedNodeId: null,
selectedNodeIds: [],
hoveredNodeId: null,
hasUnsavedChanges: false,
})
})

afterEach(() => {
cleanup()
})

describe('InlineTextEditOverlay', () => {
it('renders nothing without a session', () => {
render(<InlineTextEditOverlay breakpointId="bp-a" iframeElement={null} />)
expect(queryField()).toBeNull()
})

it('renders nothing for a session owned by another frame', () => {
seedSession()
render(<InlineTextEditOverlay breakpointId="bp-b" iframeElement={null} />)
expect(queryField()).toBeNull()
})

it('renders a textarea seeded with the current value for multiline sessions', () => {
seedSession(true)
render(<InlineTextEditOverlay breakpointId="bp-a" iframeElement={null} />)
const field = queryField()
expect(field?.tagName).toBe('TEXTAREA')
expect(field?.value).toBe('Hello')
})

it('renders an input for single-line sessions', () => {
seedSession(false)
render(<InlineTextEditOverlay breakpointId="bp-a" iframeElement={null} />)
expect(queryField()?.tagName).toBe('INPUT')
})

it('live-commits every change through the store', () => {
const { nodeId } = seedSession(true)
render(<InlineTextEditOverlay breakpointId="bp-a" iframeElement={null} />)
fireEvent.change(queryField()!, { target: { value: 'Hello world' } })
expect(storedValue(nodeId)).toBe('Hello world')
expect(useEditorStore.getState().activeInlineEdit?.committed).toBe(true)
})

it('Enter commits and closes (multiline included — base.text renders no newlines)', () => {
const { nodeId } = seedSession(true)
render(<InlineTextEditOverlay breakpointId="bp-a" iframeElement={null} />)
const field = queryField()!
fireEvent.change(field, { target: { value: 'Edited' } })
fireEvent.keyDown(field, { key: 'Enter' })
expect(useEditorStore.getState().activeInlineEdit).toBeNull()
expect(storedValue(nodeId)).toBe('Edited')
})

it('Shift+Enter keeps a multiline session open (native newline)', () => {
seedSession(true)
render(<InlineTextEditOverlay breakpointId="bp-a" iframeElement={null} />)
fireEvent.keyDown(queryField()!, { key: 'Enter', shiftKey: true })
expect(useEditorStore.getState().activeInlineEdit).not.toBeNull()
})

it('Escape cancels and restores the initial value', () => {
const { nodeId } = seedSession(true)
render(<InlineTextEditOverlay breakpointId="bp-a" iframeElement={null} />)
const field = queryField()!
fireEvent.change(field, { target: { value: 'Mangled' } })
fireEvent.keyDown(field, { key: 'Escape' })
expect(useEditorStore.getState().activeInlineEdit).toBeNull()
expect(storedValue(nodeId)).toBe('Hello')
})

it('blur commits and closes', () => {
const { nodeId } = seedSession(true)
render(<InlineTextEditOverlay breakpointId="bp-a" iframeElement={null} />)
const field = queryField()!
fireEvent.change(field, { target: { value: 'Blurred' } })
fireEvent.blur(field)
expect(useEditorStore.getState().activeInlineEdit).toBeNull()
expect(storedValue(nodeId)).toBe('Blurred')
})

it('force-closes the session when the frame unmounts mid-session', () => {
seedSession(true)
const { unmount } = render(<InlineTextEditOverlay breakpointId="bp-a" iframeElement={null} />)
unmount()
expect(useEditorStore.getState().activeInlineEdit).toBeNull()
})
})
8 changes: 8 additions & 0 deletions src/admin/pages/site/canvas/BreakpointFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { Page, Breakpoint } from '@core/page-tree'
import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings'
import { CanvasComposedTree } from './CanvasComposedTree'
import { BreakpointSelectionOverlay } from './BreakpointSelectionOverlay'
import { InlineTextEditOverlay } from './InlineTextEditOverlay'
import { CanvasBreakpointContext, CanvasTemplateContext } from './CanvasContexts'
import { IframeFrameSurface, type IframeFrameSurfaceHandle } from './IframeFrameSurface'
import { CanvasFrameSkeleton } from '@admin/shared/CanvasFrameSkeleton'
Expand Down Expand Up @@ -258,6 +259,13 @@ export function BreakpointFrame({
viewportRef={viewportRef}
iframeElement={iframeEl}
/>
{/* Inline text editor — parent-doc field floated over the edited
node in THIS frame. Renders only while this frame owns the
active inline-edit session. */}
<InlineTextEditOverlay
breakpointId={breakpoint.id}
iframeElement={iframeEl}
/>
<CursorTooltip
content={`Click to activate ${breakpoint.label} breakpoint`}
point={inactiveFrameActivates ? activationHintPoint : null}
Expand Down
45 changes: 45 additions & 0 deletions src/admin/pages/site/canvas/InlineTextEditOverlay.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* InlineTextEditOverlay — parent-document inline text editor positioned over
* the edited node inside a breakpoint iframe.
*
* The layer is portaled into the canvas root (same pattern as the rings in
* BreakpointSelectionOverlay.module.css) so it escapes the transform layer's
* scale; the field is positioned in screen-px via transform by the RAF tick.
* Typography is mirrored inline (style attribute) from the live element, so
* this file only owns chrome: transparent background, no border, focus ring
* from the canvas selection affordance token.
*/

.layer {
position: absolute;
inset: 0;
pointer-events: none;
/* Interactive editor chrome: above the selection rings (51), same range
* as the selection toolbar's drag handle (52) so it stacks predictably
* below sidebars (55), dialogs (95+), and editor toolbars (200+). */
z-index: 52;
}

/* Fallback when no canvas root is wired in (tests, transient mount race) —
portaled to document.body, mirrors the rings' fixed-position fallback. */
.layer[data-canvas-inline-edit-mode="fixed"] {
position: fixed;
}

.field {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
display: block;
margin: 0;
padding: 0;
border: none;
border-radius: 1px;
background: transparent;
outline: none;
overflow: hidden;
resize: none;
pointer-events: auto;
box-shadow: var(--canvas-selection-ring);
}
Loading