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(editor-store): add inline text edit session slice with coalesced…
… 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>
  • Loading branch information
DavidBabinec and claude committed Jun 10, 2026
commit a0a7f24102aeb2f2a74cc336ce8ce4bd03f2efbd
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const SLICE_FILES = [
'src/admin/pages/site/store/slices/styleRule/assignmentActions.ts',
'src/admin/pages/site/store/slices/clipboardSlice.ts',
'src/admin/pages/site/store/slices/sitePanelSlice.ts',
'src/admin/pages/site/store/slices/inlineEditSlice.ts',
]

describe('Centralized SiteDocument mutation history', () => {
Expand Down
213 changes: 213 additions & 0 deletions src/__tests__/editor-store/inlineEditSlice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* Inline text edit slice tests — session lifecycle, live-commit coalescing
* (one undo entry per burst), Escape-cancel via single undo, and start
* guards (non-editable modules, link-with-children, non-string props).
* Spec: docs/superpowers/specs/2026-06-10-inline-text-editing-design.md
*/
import { describe, it, expect, beforeEach, spyOn } from 'bun:test'
import { useEditorStore } from '@site/store/store'
// Side-effect imports: register the modules under test into the global registry.
import '@modules/base/text'
import '@modules/base/button'
import '@modules/base/link'
import '@modules/base/container'

function setupSiteWithTextNode(text = 'Hello'): { nodeId: string; rootId: string; pageId: string } {
const store = useEditorStore.getState()
const site = store.createSite('Inline Edit Test Site')
const pageId = site.pages[0].id
const rootId = site.pages[0].rootNodeId
const nodeId = useEditorStore.getState().insertNode('base.text', { text }, rootId)
return { nodeId, rootId, pageId }
}

function nodeText(nodeId: string): unknown {
const site = useEditorStore.getState().site!
for (const page of site.pages) {
if (page.nodes[nodeId]) return page.nodes[nodeId].props.text
}
return undefined
}

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,
})
})

describe('startInlineEdit', () => {
it('opens a multiline session for base.text on the text prop', () => {
const { nodeId } = setupSiteWithTextNode()
useEditorStore.getState().startInlineEdit(nodeId, 'bp-desktop')
expect(useEditorStore.getState().activeInlineEdit).toEqual({
nodeId,
prop: 'text',
breakpointId: 'bp-desktop',
multiline: true,
initialValue: 'Hello',
committed: false,
})
})

it('opens a single-line session for base.button on the label prop', () => {
const store = useEditorStore.getState()
const site = store.createSite('Inline Edit Test Site')
const rootId = site.pages[0].rootNodeId
const buttonId = useEditorStore.getState().insertNode('base.button', { label: 'Go' }, rootId)
useEditorStore.getState().startInlineEdit(buttonId, 'bp-mobile')
const session = useEditorStore.getState().activeInlineEdit
expect(session?.prop).toBe('label')
expect(session?.multiline).toBe(false)
expect(session?.initialValue).toBe('Go')
})

it('opens a session for a childless base.link on the text prop', () => {
const store = useEditorStore.getState()
const site = store.createSite('Inline Edit Test Site')
const rootId = site.pages[0].rootNodeId
const linkId = useEditorStore.getState().insertNode('base.link', {}, rootId)
useEditorStore.getState().startInlineEdit(linkId, 'bp-desktop')
expect(useEditorStore.getState().activeInlineEdit?.prop).toBe('text')
})

it('no-ops for modules without inlineTextEdit', () => {
const store = useEditorStore.getState()
const site = store.createSite('Inline Edit Test Site')
const rootId = site.pages[0].rootNodeId
const containerId = useEditorStore.getState().insertNode('base.container', {}, rootId)
useEditorStore.getState().startInlineEdit(containerId, 'bp-desktop')
expect(useEditorStore.getState().activeInlineEdit).toBeNull()
})

it('no-ops for base.link with children (text renders only childless)', () => {
const store = useEditorStore.getState()
const site = store.createSite('Inline Edit Test Site')
const rootId = site.pages[0].rootNodeId
const linkId = useEditorStore.getState().insertNode('base.link', {}, rootId)
useEditorStore.getState().insertNode('base.text', {}, linkId)
useEditorStore.getState().startInlineEdit(linkId, 'bp-desktop')
expect(useEditorStore.getState().activeInlineEdit).toBeNull()
})

it('no-ops with a [canvas] warning when the stored prop is not a string', () => {
const { nodeId } = setupSiteWithTextNode()
useEditorStore.getState().updateNodeProps(nodeId, { text: 42 })
const warn = spyOn(console, 'warn').mockImplementation(() => {})
useEditorStore.getState().startInlineEdit(nodeId, 'bp-desktop')
expect(useEditorStore.getState().activeInlineEdit).toBeNull()
expect(warn).toHaveBeenCalledTimes(1)
expect(String(warn.mock.calls[0][0])).toStartWith('[canvas]')
warn.mockRestore()
})

it('no-ops for unknown node ids', () => {
setupSiteWithTextNode()
useEditorStore.getState().startInlineEdit('does-not-exist', 'bp-desktop')
expect(useEditorStore.getState().activeInlineEdit).toBeNull()
})
})

describe('applyInlineEditValue — live commit, one undo entry per burst', () => {
it('commits every keystroke live and coalesces the burst into ONE history entry', () => {
const { nodeId } = setupSiteWithTextNode()
useEditorStore.getState().startInlineEdit(nodeId, 'bp')
const entriesBefore = useEditorStore.getState()._historyPast.length
useEditorStore.getState().applyInlineEditValue('HelloW')
useEditorStore.getState().applyInlineEditValue('HelloWo')
useEditorStore.getState().applyInlineEditValue('HelloWorld')
const state = useEditorStore.getState()
expect(nodeText(nodeId)).toBe('HelloWorld')
expect(state._historyPast.length).toBe(entriesBefore + 1)
expect(state.activeInlineEdit?.committed).toBe(true)
})

it('a single undo() reverts the whole burst to the initial value', () => {
const { nodeId } = setupSiteWithTextNode()
useEditorStore.getState().startInlineEdit(nodeId, 'bp')
useEditorStore.getState().applyInlineEditValue('A')
useEditorStore.getState().applyInlineEditValue('AB')
useEditorStore.getState().undo()
expect(nodeText(nodeId)).toBe('Hello')
})

it('does not flip committed when the applied value equals the stored value', () => {
const { nodeId } = setupSiteWithTextNode()
useEditorStore.getState().startInlineEdit(nodeId, 'bp')
const entriesBefore = useEditorStore.getState()._historyPast.length
useEditorStore.getState().applyInlineEditValue('Hello')
const state = useEditorStore.getState()
expect(state.activeInlineEdit?.committed).toBe(false)
expect(state._historyPast.length).toBe(entriesBefore)
})

it('isolates the session burst from a prior Properties-panel burst on the same prop', () => {
const { nodeId } = setupSiteWithTextNode()
// Simulate panel typing: same coalesce key the inline session will use.
useEditorStore.getState().updateNodeProps(nodeId, { text: 'PanelTyped' })
const entriesBefore = useEditorStore.getState()._historyPast.length
useEditorStore.getState().startInlineEdit(nodeId, 'bp')
useEditorStore.getState().applyInlineEditValue('PanelTypedX')
expect(useEditorStore.getState()._historyPast.length).toBe(entriesBefore + 1)
// Escape reverts ONLY the inline burst, not the panel typing.
useEditorStore.getState().cancelInlineEdit()
expect(nodeText(nodeId)).toBe('PanelTyped')
})

it('no-ops without an active session', () => {
const { nodeId } = setupSiteWithTextNode()
useEditorStore.getState().applyInlineEditValue('ignored')
expect(nodeText(nodeId)).toBe('Hello')
})
})

describe('endInlineEdit', () => {
it('closes the session and ends the burst so later edits undo separately', () => {
const { nodeId } = setupSiteWithTextNode()
useEditorStore.getState().startInlineEdit(nodeId, 'bp')
useEditorStore.getState().applyInlineEditValue('HelloA')
const entriesAfterBurst = useEditorStore.getState()._historyPast.length
useEditorStore.getState().endInlineEdit()
expect(useEditorStore.getState().activeInlineEdit).toBeNull()
expect(nodeText(nodeId)).toBe('HelloA')
// A later edit of the SAME prop starts a fresh undo entry.
useEditorStore.getState().updateNodeProps(nodeId, { text: 'HelloB' })
expect(useEditorStore.getState()._historyPast.length).toBe(entriesAfterBurst + 1)
})
})

describe('cancelInlineEdit', () => {
it('reverts a committed session with exactly one undo', () => {
const { nodeId } = setupSiteWithTextNode()
useEditorStore.getState().startInlineEdit(nodeId, 'bp')
useEditorStore.getState().applyInlineEditValue('Mangled')
const entriesBefore = useEditorStore.getState()._historyPast.length
useEditorStore.getState().cancelInlineEdit()
const state = useEditorStore.getState()
expect(state.activeInlineEdit).toBeNull()
expect(nodeText(nodeId)).toBe('Hello')
expect(state._historyPast.length).toBe(entriesBefore - 1)
})

it('does NOT undo for an uncommitted session', () => {
const { nodeId } = setupSiteWithTextNode()
useEditorStore.getState().startInlineEdit(nodeId, 'bp')
const entriesBefore = useEditorStore.getState()._historyPast.length
useEditorStore.getState().cancelInlineEdit()
const state = useEditorStore.getState()
expect(state.activeInlineEdit).toBeNull()
expect(state._historyPast.length).toBe(entriesBefore)
expect(nodeText(nodeId)).toBe('Hello')
})
})
131 changes: 131 additions & 0 deletions src/admin/pages/site/store/slices/inlineEditSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Inline text edit slice — ephemeral canvas UI state for the double-click
* inline text editor.
* Spec: docs/superpowers/specs/2026-06-10-inline-text-editing-design.md
*
* The session is UI-only state (never persisted, never itself part of undo
* history). Live commits route through `updateNodeProps`, whose single-field
* patches coalesce under `props:<nodeId>:<prop>` (see `coalesceKeyForPatch`
* in slices/site/nodeActions.ts) — the whole typing burst is ONE undo entry,
* which is what lets `cancelInlineEdit` revert with a single `undo()`.
*
* Burst isolation: `startInlineEdit` and `endInlineEdit` both reset
* `_historyCoalesceKey`, so the inline burst can never fold into a
* Properties-panel typing burst for the same prop (or vice versa) — Escape
* must revert exactly the inline session, nothing more.
*/
import { registry } from '@core/module-engine'
import type { EditorStoreSliceCreator } from '@site/store/types'
import { getActiveTree } from './selectionSlice'

export interface ActiveInlineEdit {
nodeId: string
/** The single string prop being edited (from ModuleDefinition.inlineTextEdit). */
prop: string
/** The breakpoint frame the user double-clicked in — owns the overlay. */
breakpointId: string
multiline: boolean
/** Prop value when the session started; cancel restores it via one undo(). */
initialValue: string
/** True once a keystroke produced a REAL history entry (a burst exists). */
committed: boolean
}

export interface InlineEditSlice {
activeInlineEdit: ActiveInlineEdit | null
/**
* Start a session for `nodeId` in `breakpointId`'s frame. No-ops when the
* module doesn't declare `inlineTextEdit`, the node has children
* (base.link renders children instead of `text`), the prop is dynamically
* bound, or the stored value isn't a string (corrupt tree → console.warn).
*/
startInlineEdit: (nodeId: string, breakpointId: string) => void
/** Live per-keystroke commit — one coalesced undo entry per session. */
applyInlineEditValue: (value: string) => void
/** Commit + close. Keystrokes already landed live; this ends session + burst. */
endInlineEdit: () => void
/** Revert + close: one undo() iff the session committed anything. */
cancelInlineEdit: () => void
}

// Contribute this slice's fields to the combined `EditorStore` type via TS
// module augmentation. See `../types.ts` for why we use this pattern.
declare module '@site/store/types' {
interface EditorStore extends InlineEditSlice {}
}

export const createInlineEditSlice: EditorStoreSliceCreator<InlineEditSlice> = (set, get) => ({
activeInlineEdit: null,

startInlineEdit: (nodeId, breakpointId) => {
const state = get()
const node = getActiveTree(state)?.nodes[nodeId]
if (!node) return
const spec = registry.get(node.moduleId)?.inlineTextEdit
if (!spec) return
// A node rendering children doesn't render its text prop (base.link).
if (node.children.length > 0) return
// A dynamically-bound prop isn't literal-editable — the binding would
// overwrite every keystroke in the canvas preview.
if (node.dynamicBindings?.[spec.prop]) return
const value = node.props[spec.prop]
if (typeof value !== 'string') {
console.warn(
`[canvas] inline edit aborted: prop "${spec.prop}" on node "${nodeId}" is not a string`,
)
return
}
set((s) => {
s.activeInlineEdit = {
nodeId,
prop: spec.prop,
breakpointId,
multiline: spec.multiline ?? false,
initialValue: value,
committed: false,
}
// Isolate the session's burst from any in-flight coalescing burst for
// the same key (e.g. Properties-panel typing on the same prop).
s._historyCoalesceKey = null
})
},

applyInlineEditValue: (value) => {
const state = get()
const session = state.activeInlineEdit
if (!session) return
const node = getActiveTree(state)?.nodes[session.nodeId]
if (!node) return
// `committed` flips only on a REAL change — updateNodeProps no-ops equal
// values (recordPatchChanges), and cancel must not undo() unless this
// session actually pushed a history entry.
const changed = !Object.is(node.props[session.prop], value)
state.updateNodeProps(session.nodeId, { [session.prop]: value })
if (changed && !session.committed) {
set((s) => {
if (s.activeInlineEdit) s.activeInlineEdit.committed = true
})
}
},

endInlineEdit: () => {
if (!get().activeInlineEdit) return
set((s) => {
s.activeInlineEdit = null
// End the burst: later edits of the same prop get a fresh undo entry.
s._historyCoalesceKey = null
})
},

cancelInlineEdit: () => {
const session = get().activeInlineEdit
if (!session) return
// The whole session is one coalesced entry — a single undo() restores
// the pre-session value. undo() also resets _historyCoalesceKey.
if (session.committed) get().undo()
set((s) => {
s.activeInlineEdit = null
s._historyCoalesceKey = null
})
},
})
3 changes: 2 additions & 1 deletion src/admin/pages/site/store/slices/selectionSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,9 @@ function sameTree(state: EditorStore, existingIds: string[], id: string): boolea
* `selectActiveCanvasPage` selector. Importing that selector would create a
* `selectionSlice ↔ store` cycle. The returned shape is `NodeTree<PageNode>`
* so the slice's helpers can use the page-tree selectors uniformly.
* Also consumed by `inlineEditSlice` (same no-cycle rationale).
*/
function getActiveTree(state: EditorStore): NodeTree<PageNode> | null {
export function getActiveTree(state: EditorStore): NodeTree<PageNode> | null {
if (!state.site) return null
const activeDocument = state.activeDocument
if (activeDocument?.kind === 'visualComponent') {
Expand Down
5 changes: 4 additions & 1 deletion src/admin/pages/site/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createSettingsSlice, bindSettingsBridgeStoreApi } from './slices/settin
import { createAgentSlice, siteAgentSliceConfig, setAgentStoreApi } from '@site/agent'
import { createSitePanelSlice } from './slices/sitePanelSlice'
import { createClipboardSlice } from './slices/clipboardSlice'
import { createInlineEditSlice } from './slices/inlineEditSlice'
import { bindPluginRuntimeStoreApi } from '@core/plugins/runtime'
import { useAdminUi } from '@admin/state/adminUi'
import { readWorkspaceLayout, workspaceFromPathname } from '@site/layout/panelLayoutStorage'
Expand All @@ -23,7 +24,7 @@ import { restoreStoredEditorLayout } from '@site/hooks/useEditorLayoutPersistenc
/**
* EditorStore — the central Zustand store for the visual editor.
*
* Composed of 11 slices (6 canonical Phase 0 + agentSlice + sitePanelSlice + filesSlice + visualComponentsSlice + clipboardSlice):
* Composed of 12 slices (6 canonical Phase 0 + agentSlice + sitePanelSlice + filesSlice + visualComponentsSlice + clipboardSlice + inlineEditSlice):
* - siteSlice: owns SiteDocument (pages, nodes, breakpoints, settings, classes, files)
* - selectionSlice: selectedNodeId, hoveredNodeId
* - canvasSlice: zoom, pan, activeBreakpointId, canvasMode (Constraint #317)
Expand All @@ -35,6 +36,7 @@ import { restoreStoredEditorLayout } from '@site/hooks/useEditorLayoutPersistenc
* - agentSlice: AI Agent Panel state + streaming (Phase D)
* - sitePanelSlice: dependency manifest + site runtime settings
* - clipboardSlice: copy / cut / paste of layer subtrees, persisted editor-wide
* - inlineEditSlice: canvas inline text edit session (double-click to edit)
*
* The combined `EditorStore` type lives in `./types` so each slice can import
* it without going through this module — that's how the historical
Expand Down Expand Up @@ -68,6 +70,7 @@ export const useEditorStore = create<EditorStore>()(
...createAgentSlice(siteAgentSliceConfig)(...args),
...createSitePanelSlice(...args),
...createClipboardSlice(...args),
...createInlineEditSlice(...args),
}),
{ enableAutoFreeze: true },
)
Expand Down