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
fix(canvas): stop React clobbering the inline-edit caret every keystroke
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>
  • Loading branch information
DavidBabinec and claude committed Jun 11, 2026
commit 84b59ea6424359cd148ab9968e3821852691d604
43 changes: 32 additions & 11 deletions src/__tests__/modules/inlineEditableElementProps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@
* element so the element BECOMES the inline editor.
*
* The element is made `contentEditable=plaintext-only` (no rich formatting /
* pasted markup), the three live-edit handlers are wired through, and the
* content is seeded ONCE from the binding's `initialValue` via
* `dangerouslySetInnerHTML` — escaped first, so `\n` → `<br>` is the ONLY
* markup that can reach the DOM (never user-injected HTML).
* pasted markup) and the three live-edit handlers are wired through.
*
* It intentionally provides NO `dangerouslySetInnerHTML` and NO children:
* React must not own the contentEditable element's content. React 19 re-applies
* `dangerouslySetInnerHTML` on every commit of an element, and the live-commit
* re-renders fire one commit per keystroke — a React-owned content prop would
* overwrite the user's typing and reset the caret to the start every keystroke.
* The canvas seeds the content imperatively instead, via
* `seedInlineEditableContent`.
*/
import { describe, it, expect } from 'bun:test'
import { createRef } from 'react'
import { inlineEditableElementProps } from '@modules/base/shared/inlineText'
import {
inlineEditableElementProps,
seedInlineEditableContent,
} from '@modules/base/shared/inlineText'
import type { InlineEditBinding } from '@core/module-engine'

function makeBinding(initialValue: string): InlineEditBinding {
Expand Down Expand Up @@ -39,14 +47,27 @@ describe('inlineEditableElementProps', () => {
expect(props.onBlur).toBe(binding.onBlur)
})

it('seeds the content from initialValue with newlines as <br>', () => {
const props = inlineEditableElementProps(makeBinding('A\nB'))
expect(props.dangerouslySetInnerHTML.__html).toBe('A<br>B')
it('does NOT hand React the content (no dangerouslySetInnerHTML, no children)', () => {
// Regression guard: React owning the content re-applies it every keystroke,
// wiping the edit and collapsing the caret. The content is seeded
// imperatively instead.
const props = inlineEditableElementProps(makeBinding('A\nB')) as Record<string, unknown>
expect('dangerouslySetInnerHTML' in props).toBe(false)
expect('children' in props).toBe(false)
})
})

describe('seedInlineEditableContent', () => {
it('seeds the element from initialValue with newlines as <br>', () => {
const el = document.createElement('h1')
seedInlineEditableContent(el, 'A\nB')
expect(el.innerHTML).toBe('A<br>B')
})

it('escapes HTML special chars in the seeded content (no injection)', () => {
const props = inlineEditableElementProps(makeBinding('<b>x</b>\n&y'))
expect(props.dangerouslySetInnerHTML.__html).toBe('&lt;b&gt;x&lt;/b&gt;<br>&amp;y')
expect(props.dangerouslySetInnerHTML.__html).not.toContain('<b>')
const el = document.createElement('h1')
seedInlineEditableContent(el, '<b>x</b>\n&y')
expect(el.innerHTML).toBe('&lt;b&gt;x&lt;/b&gt;<br>&amp;y')
expect(el.querySelector('b')).toBeNull()
})
})
16 changes: 10 additions & 6 deletions src/admin/pages/site/canvas/NodeRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

import { memo, use, useLayoutEffect, useRef, useSyncExternalStore } from 'react'
import type { InlineEditBinding } from '@core/module-engine'
import { readInlineEditableText } from '@modules/base/shared/inlineText'
import { readInlineEditableText, seedInlineEditableContent } from '@modules/base/shared/inlineText'
import { useEditorStore, selectActiveCanvasPage } from '@site/store/store'
import { resolveProps } from '@core/page-tree'
import { registry } from '@core/module-engine'
Expand Down Expand Up @@ -166,14 +166,18 @@ export const NodeRenderer = memo(function NodeRenderer({ nodeId }: NodeRendererP
registry.generation.bind(registry),
)

// On session start, focus the now-editable element and drop the caret at the
// end. Layout effect → runs before paint, so the editor is live on the first
// frame. The element lives in the breakpoint iframe (same-origin); focusing
// it focuses the iframe in the parent — no cross-frame negotiation needed.
// On session start, seed the editable element's content imperatively (React
// does NOT own it — see inlineEditableElementProps), then focus and drop the
// caret at the end. Layout effect → runs before paint, so the editor is live
// on the first frame. The element lives in the breakpoint iframe
// (same-origin); focusing it focuses the iframe in the parent — no
// cross-frame negotiation needed. Deps are constant for the whole session, so
// this runs once per session (never mid-edit, which would wipe the edits).
useLayoutEffect(() => {
if (!isInlineEditing) return
const el = editableRef.current
if (!el) return
seedInlineEditableContent(el, inlineEditInitialValue ?? '')
el.focus()
const doc = el.ownerDocument
const sel = doc.defaultView?.getSelection()
Expand All @@ -183,7 +187,7 @@ export const NodeRenderer = memo(function NodeRenderer({ nodeId }: NodeRendererP
range.collapse(false)
sel.removeAllRanges()
sel.addRange(range)
}, [isInlineEditing])
}, [isInlineEditing, inlineEditInitialValue])

if (!node) return null
if (node.hidden) return null
Expand Down
29 changes: 21 additions & 8 deletions src/modules/base/shared/inlineText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,18 @@ export function readInlineEditableText(el: HTMLElement): string {
/**
* Props a text module spreads onto its element to BECOME the inline editor.
* The element is `contentEditable` (plaintext-only — no rich formatting or
* pasted markup) and seeded once via `dangerouslySetInnerHTML`. That HTML is
* `rawTextToBreakHtml` output: the value is HTML-escaped first, so the only
* markup is the `<br>`s we insert — never user-injected HTML.
* pasted markup) and carries the live-edit handlers + ref.
*
* Spread LAST so it overrides the element's normal `dangerouslySetInnerHTML`
* and key/blur handlers; do not pass React children alongside it.
* Critically it provides NEITHER `dangerouslySetInnerHTML` NOR children: React
* must not own the element's content while it is being edited. React 19
* re-applies `dangerouslySetInnerHTML` on EVERY commit of an element (it does
* not skip on an unchanged `__html`), and the live-commit re-renders fire 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 (see
* `seedInlineEditableContent`) and React leaves the contentEditable DOM alone.
*
* The module must render NO children alongside these props.
*/
export function inlineEditableElementProps(binding: InlineEditBinding) {
return {
Expand All @@ -58,8 +64,15 @@ export function inlineEditableElementProps(binding: InlineEditBinding) {
onInput: binding.onInput,
onKeyDown: binding.onKeyDown,
onBlur: binding.onBlur,
// Escaped value (only the inserted <br>s are markup) seeded once; constant
// for the session so React never resets the caret. Safe — see rawTextToBreakHtml.
dangerouslySetInnerHTML: { __html: rawTextToBreakHtml(binding.initialValue) },
}
}

/**
* Seed the inline-editor element's content from the session's initial value,
* imperatively (NOT through React). The value is HTML-escaped, so the only
* markup is the `<br>`s we insert from newlines — never user HTML. Call once,
* when the session opens, before placing the caret.
*/
export function seedInlineEditableContent(el: HTMLElement, initialValue: string): void {
el.innerHTML = rawTextToBreakHtml(initialValue)
}
Loading