{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"cms-editor","type":"registry:ui","title":"Editor","description":"The WYSIWYG editor surface \u2014 canvas + layers + inspector over a Stages node tree.","package":"fancy-cms-ui","dependencies":["@particle-academy\/react-fancy"],"registryDependencies":["document","react"],"files":[{"path":"components\/fancy\/cms-editor\/Canvas.tsx","content":"import {\r\n  useLayoutEffect,\r\n  useRef,\r\n  useState,\r\n  type CSSProperties,\r\n  type MouseEvent as ReactMouseEvent,\r\n  type PointerEvent as ReactPointerEvent,\r\n  type ReactElement,\r\n} from \"react\";\r\nimport type { PageDoc } from \"..\/document\/types\";\r\nimport type { PageOp } from \"..\/document\/ops\";\r\nimport type { DataContext, ElementRegistry } from \"..\/react\/registry\";\r\nimport { CmsPage } from \"..\/react\/CmsPage\";\r\n\r\nexport interface CanvasProps {\r\n  doc: PageDoc;\r\n  selection: string | null;\r\n  onSelect: (id: string | null) => void;\r\n  apply: (op: PageOp) => void;\r\n  \/** Custom element registry so custom node types render on the canvas (not a blank placeholder). *\/\r\n  registry?: ElementRegistry;\r\n  \/** Data context for `{ $bind }` props + repeaters previewed on the canvas. *\/\r\n  data?: DataContext;\r\n}\r\n\r\ninterface Box {\r\n  x: number;\r\n  y: number;\r\n  w: number;\r\n  h: number;\r\n}\r\n\r\n\/**\r\n * The edit surface: renders the live page and overlays a selection box. Click to\r\n * select; drag the box to move (px constraints). Phase 1 cut: free-parent\r\n * move + resize handles, stack\/grid reordering, and snapping come next.\r\n *\/\r\nexport function Canvas({ doc, selection, onSelect, apply, registry, data }: CanvasProps): ReactElement {\r\n  const ref = useRef<HTMLDivElement>(null);\r\n  const [box, setBox] = useState<Box | null>(null);\r\n  const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null);\r\n\r\n  useLayoutEffect(() => {\r\n    const root = ref.current;\r\n    if (!root || !selection) {\r\n      setBox(null);\r\n      return;\r\n    }\r\n    const el = root.querySelector(`[data-cms=\"${cssEscape(selection)}\"]`);\r\n    if (!el) {\r\n      setBox(null);\r\n      return;\r\n    }\r\n    const r = el.getBoundingClientRect();\r\n    const rootRect = root.getBoundingClientRect();\r\n    setBox({\r\n      x: r.left - rootRect.left + root.scrollLeft,\r\n      y: r.top - rootRect.top + root.scrollTop,\r\n      w: r.width,\r\n      h: r.height,\r\n    });\r\n  }, [selection, doc]);\r\n\r\n  const handleClick = (e: ReactMouseEvent<HTMLDivElement>) => {\r\n    const target = (e.target as HTMLElement).closest(\"[data-cms]\");\r\n    onSelect(target ? target.getAttribute(\"data-cms\") : null);\r\n  };\r\n\r\n  const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\r\n    if (!selection) return;\r\n    const c = doc.nodes[selection]?.constraints?.base;\r\n    const left = c && c.left && typeof c.left === \"object\" ? c.left.value : 0;\r\n    const top = c && c.top && typeof c.top === \"object\" ? c.top.value : 0;\r\n    drag.current = { x: e.clientX, y: e.clientY, left, top };\r\n    e.currentTarget.setPointerCapture(e.pointerId);\r\n    e.stopPropagation();\r\n  };\r\n\r\n  const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {\r\n    const d = drag.current;\r\n    if (!d || !selection) return;\r\n    apply({\r\n      t: \"set_constraints\",\r\n      id: selection,\r\n      breakpoint: \"base\",\r\n      patch: {\r\n        left: { value: Math.round(d.left + (e.clientX - d.x)), unit: \"px\" },\r\n        top: { value: Math.round(d.top + (e.clientY - d.y)), unit: \"px\" },\r\n      },\r\n    });\r\n  };\r\n\r\n  const handlePointerUp = () => {\r\n    drag.current = null;\r\n  };\r\n\r\n  const surface: CSSProperties = {\r\n    position: \"relative\",\r\n    overflow: \"auto\",\r\n    height: \"100%\",\r\n    background: \"var(--fcms-canvas)\",\r\n  };\r\n  const overlay: CSSProperties = box\r\n    ? {\r\n        position: \"absolute\",\r\n        left: box.x,\r\n        top: box.y,\r\n        width: box.w,\r\n        height: box.h,\r\n        outline: \"2px solid var(--fcms-accent, #8b5cf6)\",\r\n        outlineOffset: -1,\r\n        cursor: \"move\",\r\n        touchAction: \"none\",\r\n      }\r\n    : { display: \"none\" };\r\n\r\n  return (\r\n    <div ref={ref} onClick={handleClick} style={surface}>\r\n      <CmsPage doc={doc} registry={registry} data={data} \/>\r\n      <div\r\n        onPointerDown={handlePointerDown}\r\n        onPointerMove={handlePointerMove}\r\n        onPointerUp={handlePointerUp}\r\n        style={overlay}\r\n      \/>\r\n    <\/div>\r\n  );\r\n}\r\n\r\nfunction cssEscape(value: string): string {\r\n  return value.replace(\/[\"\\\\]\/g, \"\\\\$&\");\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/Canvas.tsx"},{"path":"components\/fancy\/cms-editor\/EditablePage.tsx","content":"import {\r\n  useCallback,\r\n  useEffect,\r\n  useLayoutEffect,\r\n  useRef,\r\n  useState,\r\n  type CSSProperties,\r\n  type DragEvent as ReactDragEvent,\r\n  type MouseEvent as ReactMouseEvent,\r\n  type PointerEvent as ReactPointerEvent,\r\n  type ReactElement,\r\n  type ReactNode,\r\n} from \"react\";\r\nimport { ContextMenu } from \"@particle-academy\/react-fancy\";\r\nimport type { Json, LayoutMode, NodeId, PageDoc, StyleProps } from \"..\/document\/types\";\r\nimport type { PageOp } from \"..\/document\/ops\";\r\nimport { childrenOf } from \"..\/document\/reduce\";\r\nimport { keyBetween } from \"..\/document\/fractional\";\r\nimport { ADD_DEFAULTS, ADD_MENU, CONTAINER_TYPES, buildInsertOp, type AddKind } from \".\/insert\";\r\nimport { CmsPage } from \"..\/react\/CmsPage\";\r\nimport { defaultRegistry, type DataContext, type ElementRegistry } from \"..\/react\/registry\";\r\nimport { NodeInspector } from \".\/NodeInspector\";\r\nimport { duplicateOps, pasteOps, reorderOps, snapshotSubtree, wrapInBoxOps, type NodeMap, type ReorderDir } from \".\/editorOps\";\r\nimport { useEditor } from \".\/useEditor\";\r\nimport { useLatestRef } from \".\/useLatestRef\";\r\n\r\n\/** Animatable per-node transform (mirrors fancy-motion's NodeState, kept local to avoid a hard dep). *\/\r\nexport interface NodeTransform {\r\n  x?: number;\r\n  y?: number;\r\n  scale?: number;\r\n  opacity?: number;\r\n  rotate?: number;\r\n  \/** Explicit size in px (a true resize, distinct from `scale`). Absent = auto. *\/\r\n  w?: number;\r\n  h?: number;\r\n}\r\n\r\nexport interface EditablePageProps {\r\n  doc: PageDoc;\r\n  registry?: ElementRegistry;\r\n  \/** Data context for `{ $bind }` props + repeaters (server props, etc.). *\/\r\n  data?: DataContext;\r\n  \/** Slot for the fancy-motion timeline dock (rendered at the bottom in EditMode). *\/\r\n  timelineDock?: ReactNode;\r\n  \/** Per-node transforms applied to the page (e.g. the sampled keyframe snapshot). *\/\r\n  transforms?: Record<string, NodeTransform>;\r\n  \/** Fired when the user moves\/resizes a node \u2014 the host writes it into the current keyframe. *\/\r\n  onNodeTransform?: (id: string, transform: NodeTransform) => void;\r\n  \/** Notifies the host of the current selection (e.g. to sync the timeline). *\/\r\n  onSelect?: (id: string | null) => void;\r\n  \/** Scroll length in viewports \u2014 the page becomes a tall scroll canvas (extend via the timeline). *\/\r\n  frames?: number;\r\n  \/** Scroll progress 0..1 (scroll = the timeline playhead). The host maps it to transforms. *\/\r\n  onProgress?: (progress: number) => void;\r\n  \/**\r\n   * Pin the page into a scroll canvas (the `frames`\u00d7viewport spacer + sticky pin\r\n   * that turns scroll into the playhead). Default `true`. Set **false** for a\r\n   * full multi-section page that should scroll naturally \u2014 animation is then\r\n   * driven by the dock scrub + Play preview instead of raw scroll.\r\n   *\/\r\n  pinned?: boolean;\r\n}\r\n\r\ninterface Box {\r\n  x: number;\r\n  y: number;\r\n  w: number;\r\n  h: number;\r\n}\r\n\r\nconst Z = 2147483000;\r\n\/** Snap distance (px) for drag alignment to other elements' edges\/centers. *\/\r\nconst SNAP = 6;\r\n\r\n\/**\r\n * Inline EditMode over a live, seeded CMS page. Hold **Ctrl+Shift** to reveal\r\n * the Edit toggle. Editing is on the page: text is edited in place, a floating\r\n * toolbar styles it, and the selection can be **dragged to move** \/ **resized**\r\n * \u2014 committed as transforms the host captures into a timeline keyframe.\r\n *\/\r\nexport function EditablePage({\r\n  doc,\r\n  registry = defaultRegistry,\r\n  data,\r\n  timelineDock,\r\n  transforms,\r\n  onNodeTransform,\r\n  onSelect,\r\n  frames = 1,\r\n  onProgress,\r\n  pinned = true,\r\n}: EditablePageProps): ReactElement {\r\n  const ed = useEditor(doc);\r\n  const [editing, setEditing] = useState(false);\r\n  const [revealed, setRevealed] = useState(false);\r\n  const pageRef = useRef<HTMLDivElement>(null);\r\n  const spacerRef = useRef<HTMLDivElement>(null);\r\n  const [box, setBox] = useState<Box | null>(null);\r\n  const { selection } = ed.state;\r\n  const selNode = selection ? ed.state.doc.nodes[selection] : null;\r\n\r\n  \/\/ Notify effects depend only on the data, never on the callback prop's\r\n  \/\/ identity \u2014 inline-closure consumers would loop them otherwise (see #1).\r\n  const onSelectRef = useLatestRef(onSelect);\r\n  useEffect(() => onSelectRef.current?.(selection), [selection, onSelectRef]);\r\n\r\n  \/\/ Ctrl+Shift reveal gesture.\r\n  useEffect(() => {\r\n    const onKey = (e: KeyboardEvent) => setRevealed(e.ctrlKey && e.shiftKey);\r\n    const onBlur = () => setRevealed(false);\r\n    window.addEventListener(\"keydown\", onKey);\r\n    window.addEventListener(\"keyup\", onKey);\r\n    window.addEventListener(\"blur\", onBlur);\r\n    return () => {\r\n      window.removeEventListener(\"keydown\", onKey);\r\n      window.removeEventListener(\"keyup\", onKey);\r\n      window.removeEventListener(\"blur\", onBlur);\r\n    };\r\n  }, []);\r\n\r\n  \/\/ Apply host-provided transforms to the page elements.\r\n  useEffect(() => {\r\n    const root = pageRef.current;\r\n    if (!root) return;\r\n    root.querySelectorAll<HTMLElement>(\"[data-cms]\").forEach((el) => {\r\n      const id = el.dataset.cms;\r\n      const t = id ? transforms?.[id] : undefined;\r\n      applyTransform(el, t);\r\n    });\r\n  }, [transforms, ed.state.doc]);\r\n\r\n  \/\/ Scroll = the timeline playhead. Report progress (0..1) across the scroll\r\n  \/\/ canvas so the host can drive the morph from real scrolling (not the dock).\r\n  \/\/ Measure synchronously in the scroll handler \u2014 NOT via requestAnimationFrame.\r\n  \/\/ rAF is throttled or fully suspended in hidden\/background tabs, which silently\r\n  \/\/ freezes the playhead at 0; a direct getBoundingClientRect on one element per\r\n  \/\/ scroll event is cheap and always fires.\r\n  const onProgressRef = useLatestRef(onProgress);\r\n  const hasProgress = !!onProgress;\r\n  useEffect(() => {\r\n    const spacer = spacerRef.current;\r\n    if (!spacer || !hasProgress) return;\r\n    const compute = () => {\r\n      const len = spacer.offsetHeight - window.innerHeight;\r\n      const top = spacer.getBoundingClientRect().top;\r\n      onProgressRef.current?.(len > 0 ? Math.min(1, Math.max(0, -top \/ len)) : 0);\r\n    };\r\n    compute();\r\n    window.addEventListener(\"scroll\", compute, { passive: true });\r\n    window.addEventListener(\"resize\", compute);\r\n    return () => {\r\n      window.removeEventListener(\"scroll\", compute);\r\n      window.removeEventListener(\"resize\", compute);\r\n    };\r\n  }, [hasProgress, onProgressRef, frames]);\r\n\r\n  const measureBox = useCallback(() => {\r\n    if (!editing || !selection || !pageRef.current) return setBox(null);\r\n    const el = pageRef.current.querySelector(`[data-cms=\"${cssEscape(selection)}\"]`);\r\n    if (!el) return setBox(null);\r\n    const r = el.getBoundingClientRect();\r\n    setBox({ x: r.left, y: r.top, w: r.width, h: r.height });\r\n  }, [editing, selection]);\r\n\r\n  \/\/ Re-measure on selection \/ doc \/ scroll \/ resize \u2014 deliberately NOT on every\r\n  \/\/ `transforms` frame. A scrub or Play sweep changes transforms ~30\u00d7\/s; calling\r\n  \/\/ getBoundingClientRect each time forces a synchronous reflow of the whole page\r\n  \/\/ and saturates the main thread. Live drags re-measure through `onLive` instead.\r\n  useLayoutEffect(() => {\r\n    measureBox();\r\n    window.addEventListener(\"scroll\", measureBox, { passive: true });\r\n    window.addEventListener(\"resize\", measureBox);\r\n    return () => {\r\n      window.removeEventListener(\"scroll\", measureBox);\r\n      window.removeEventListener(\"resize\", measureBox);\r\n    };\r\n  }, [measureBox, ed.state.doc]);\r\n\r\n  \/\/ Inline, on-page contentEditable for text content \u2014 text\/heading (plain) and\r\n  \/\/ richtext (HTML). All content is edited directly on the page, never in a\r\n  \/\/ sidebar field. richtext edits the inner HTML container so formatting survives.\r\n  useEffect(() => {\r\n    const t = selNode?.type;\r\n    const editable = t === \"text\" || t === \"heading\" || t === \"richtext\";\r\n    if (!editing || !selection || !pageRef.current || !editable) return;\r\n    const wrapper = pageRef.current.querySelector<HTMLElement>(`[data-cms=\"${cssEscape(selection)}\"]`);\r\n    if (!wrapper) return;\r\n    const isRich = t === \"richtext\";\r\n    const el = (isRich ? (wrapper.firstElementChild as HTMLElement | null) : wrapper) ?? wrapper;\r\n    el.setAttribute(\"contenteditable\", \"true\");\r\n    el.style.outline = \"none\";\r\n    el.focus();\r\n    const commit = () => ed.apply({ t: \"set_props\", id: selection, patch: isRich ? { html: el.innerHTML } : { content: el.innerText } });\r\n    el.addEventListener(\"blur\", commit);\r\n    return () => {\r\n      el.removeEventListener(\"blur\", commit);\r\n      el.removeAttribute(\"contenteditable\");\r\n    };\r\n  }, [editing, selection, selNode?.type, ed]);\r\n\r\n  const onClickCapture = (e: ReactMouseEvent<HTMLDivElement>) => {\r\n    if (!editing) return;\r\n    const el = (e.target as HTMLElement).closest(\"[data-cms]\");\r\n    if (el) {\r\n      if (el.getAttribute(\"contenteditable\") === \"true\") return;\r\n      e.preventDefault();\r\n      e.stopPropagation();\r\n      ed.select(el.getAttribute(\"data-cms\"));\r\n    }\r\n  };\r\n\r\n  const addNode = useCallback(\r\n    (kind: AddKind) => {\r\n      \/\/ Drop into the selected container; else the selected node's parent; else\r\n      \/\/ the last section (or top level when the page is still empty).\r\n      const doc = ed.state.doc;\r\n      const parent = selNode\r\n        ? CONTAINER_TYPES.has(selNode.type)\r\n          ? selNode.id\r\n          : selNode.parent\r\n        : (doc.sections[doc.sections.length - 1] ?? null);\r\n      const siblings = childrenOf(doc, parent);\r\n      const order = keyBetween(siblings.length ? siblings[siblings.length - 1]!.order : null, null);\r\n      const id = `n${doc.seq + 1}-${Math.floor(performance.now())}`;\r\n      const def = ADD_DEFAULTS[kind];\r\n      ed.apply({ t: \"insert_node\", node: { id, type: def.type, parent, order, layout: def.layout, props: { ...def.props }, style: { base: { ...def.style } } } });\r\n      ed.select(id);\r\n    },\r\n    [ed, selNode],\r\n  );\r\n\r\n  \/\/ Insert an Element at an explicit target (the node under a drop point) rather\r\n  \/\/ than the current selection \u2014 used by the drag-and-drop palette.\r\n  const addNodeAt = useCallback(\r\n    (kind: AddKind, targetId: string | null) => {\r\n      const doc = ed.state.doc;\r\n      const target = targetId ? doc.nodes[targetId] : null;\r\n      const parent = target\r\n        ? CONTAINER_TYPES.has(target.type)\r\n          ? target.id\r\n          : target.parent\r\n        : (doc.sections[doc.sections.length - 1] ?? null);\r\n      const siblings = childrenOf(doc, parent);\r\n      const order = keyBetween(siblings.length ? siblings[siblings.length - 1]!.order : null, null);\r\n      const id = `n${doc.seq + 1}-${Math.floor(performance.now())}`;\r\n      const def = ADD_DEFAULTS[kind];\r\n      ed.apply({ t: \"insert_node\", node: { id, type: def.type, parent, order, layout: def.layout, props: { ...def.props }, style: { base: { ...def.style } } } });\r\n      ed.select(id);\r\n    },\r\n    [ed],\r\n  );\r\n\r\n  \/\/ Right-edge Element palette: reveals when the pointer nears the right screen\r\n  \/\/ edge, hides while an Element is being dragged onto the page.\r\n  const [paletteOpen, setPaletteOpen] = useState(false);\r\n  const [draggingEl, setDraggingEl] = useState(false);\r\n  useEffect(() => {\r\n    if (!editing) {\r\n      setPaletteOpen(false);\r\n      return;\r\n    }\r\n    const onMove = (e: MouseEvent) => {\r\n      if (draggingEl) return;\r\n      if (e.clientX >= window.innerWidth - 26) setPaletteOpen(true);\r\n      else if (e.clientX < window.innerWidth - 236) setPaletteOpen(false);\r\n    };\r\n    window.addEventListener(\"mousemove\", onMove);\r\n    return () => window.removeEventListener(\"mousemove\", onMove);\r\n  }, [editing, draggingEl]);\r\n\r\n  \/\/ Drop target + indicator. While dragging, we compute the precise insertion\r\n  \/\/ point (before\/after a leaf, or inside a container) under the cursor so the\r\n  \/\/ user can drop *anywhere* and see exactly where it lands.\r\n  type DropHint = { id: string | null; edge: \"before\" | \"after\" | \"inside\"; rect: { x: number; y: number; w: number; h: number } | null };\r\n  const [dropHint, setDropHint] = useState<DropHint | null>(null);\r\n\r\n  const computeDropTarget = (clientX: number, clientY: number): DropHint => {\r\n    const el = document.elementFromPoint(clientX, clientY)?.closest(\"[data-cms]\") as HTMLElement | null;\r\n    if (!el) return { id: null, edge: \"inside\", rect: null };\r\n    const id = el.getAttribute(\"data-cms\")!;\r\n    const node = ed.state.doc.nodes[id];\r\n    const r = el.getBoundingClientRect();\r\n    const rect = { x: r.left, y: r.top, w: r.width, h: r.height };\r\n    \/\/ Over a container's own area (not a child) \u2192 drop inside it.\r\n    if (node && CONTAINER_TYPES.has(node.type)) return { id, edge: \"inside\", rect };\r\n    return { id, edge: clientY < r.top + r.height \/ 2 ? \"before\" : \"after\", rect };\r\n  };\r\n\r\n  const onPageDragOver = (e: ReactDragEvent<HTMLDivElement>) => {\r\n    e.preventDefault();\r\n    e.dataTransfer.dropEffect = \"copy\";\r\n    setDropHint(computeDropTarget(e.clientX, e.clientY));\r\n  };\r\n\r\n  \/\/ Insert relative to a drop hint \u2014 before\/after a sibling, or appended inside\r\n  \/\/ a container (or the last section when dropped on empty page space).\r\n  const insertRelative = (kind: AddKind, hint: DropHint) => {\r\n    const doc = ed.state.doc;\r\n    let parent: string | null;\r\n    let order: string;\r\n    if (!hint.id || hint.edge === \"inside\") {\r\n      parent = hint.id ?? (doc.sections[doc.sections.length - 1] ?? null);\r\n      const sib = childrenOf(doc, parent);\r\n      order = keyBetween(sib.length ? sib[sib.length - 1]!.order : null, null);\r\n    } else {\r\n      const target = doc.nodes[hint.id]!;\r\n      parent = target.parent;\r\n      const sib = childrenOf(doc, parent);\r\n      const idx = sib.findIndex((n) => n.id === hint.id);\r\n      order =\r\n        hint.edge === \"before\"\r\n          ? keyBetween(idx > 0 ? sib[idx - 1]!.order : null, target.order)\r\n          : keyBetween(target.order, idx < sib.length - 1 ? sib[idx + 1]!.order : null);\r\n    }\r\n    const id = `n${doc.seq + 1}-${Math.floor(performance.now())}`;\r\n    const def = ADD_DEFAULTS[kind];\r\n    ed.apply({ t: \"insert_node\", node: { id, type: def.type, parent, order, layout: def.layout, props: { ...def.props }, style: { base: { ...def.style } } } });\r\n    ed.select(id);\r\n  };\r\n\r\n  const onPageDrop = (e: ReactDragEvent<HTMLDivElement>) => {\r\n    const kind = e.dataTransfer.getData(\"application\/x-cms-element\") as AddKind;\r\n    if (!kind) return;\r\n    e.preventDefault();\r\n    insertRelative(kind, computeDropTarget(e.clientX, e.clientY));\r\n    setDropHint(null);\r\n    setDraggingEl(false);\r\n  };\r\n\r\n  const setStyle = (patch: Partial<StyleProps>) =>\r\n    selection && ed.apply({ t: \"set_style\", id: selection, breakpoint: \"base\", patch });\r\n  const setProps = (patch: Record<string, unknown>) =>\r\n    selection && ed.apply({ t: \"set_props\", id: selection, patch });\r\n  const setLayout = (layout: LayoutMode | undefined) =>\r\n    selection && ed.apply({ t: \"set_layout\", id: selection, layout });\r\n  const removeSelected = () => {\r\n    if (!selection) return;\r\n    ed.apply({ t: \"remove_node\", id: selection });\r\n    ed.select(null);\r\n  };\r\n\r\n  \/\/ \u2500\u2500 Context-menu \/ shortcut commands (all expressed as op sequences) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n  const clip = useRef<{ rootId: NodeId; nodes: NodeMap } | null>(null);\r\n  const runOps = (result: { ops: PageOp[]; newRootId: NodeId | null }) => {\r\n    result.ops.forEach((op) => ed.apply(op));\r\n    if (result.newRootId) ed.select(result.newRootId);\r\n  };\r\n  const duplicate = (id: string | null) => id && runOps(duplicateOps(ed.state.doc, id));\r\n  const reorder = (id: string | null, dir: ReorderDir) => id && reorderOps(ed.state.doc, id, dir).forEach((op) => ed.apply(op));\r\n  const wrapInBox = (id: string | null) => id && runOps(wrapInBoxOps(ed.state.doc, id));\r\n  const copy = (id: string | null) => {\r\n    if (id) clip.current = snapshotSubtree(ed.state.doc, id);\r\n  };\r\n  const paste = (target: string | null) => {\r\n    if (clip.current) runOps(pasteOps(ed.state.doc, clip.current, target, CONTAINER_TYPES));\r\n  };\r\n  const startEditText = (id: string | null) => {\r\n    if (!id) return;\r\n    ed.select(id);\r\n    requestAnimationFrame(() => pageRef.current?.querySelector<HTMLElement>(`[data-cms=\"${cssEscape(id)}\"]`)?.focus());\r\n  };\r\n\r\n  \/\/ Latest transforms + commit fn for keyboard nudge \u2014 read via ref so the\r\n  \/\/ keydown handler never sees a stale snapshot (transforms change ~30\u00d7\/s).\r\n  const nudgeRef = useRef({ transforms, onNodeTransform });\r\n  nudgeRef.current = { transforms, onNodeTransform };\r\n\r\n  \/\/ Keyboard shortcuts in EditMode (ignored while typing in a field\/contentEditable).\r\n  useEffect(() => {\r\n    if (!editing) return;\r\n    const onKey = (e: KeyboardEvent) => {\r\n      const t = e.target as HTMLElement | null;\r\n      const typing = t && (t.isContentEditable || \/^(INPUT|TEXTAREA|SELECT)$\/.test(t.tagName));\r\n      if (e.key === \"Escape\") return void ed.select(null);\r\n      if (typing) return;\r\n      if ((e.key === \"Delete\" || e.key === \"Backspace\") && selection) {\r\n        e.preventDefault();\r\n        removeSelected();\r\n      } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"d\" && selection) {\r\n        e.preventDefault();\r\n        duplicate(selection);\r\n      } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"c\" && selection) {\r\n        copy(selection);\r\n      } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"v\") {\r\n        e.preventDefault();\r\n        paste(selection);\r\n      } else if (selection && \/^Arrow(Left|Right|Up|Down)$\/.test(e.key)) {\r\n        \/\/ Nudge: arrows move \u00b11px, Shift+arrows \u00b110px \u2014 committed as a transform.\r\n        const { transforms: tf, onNodeTransform: commit } = nudgeRef.current;\r\n        if (!commit) return;\r\n        e.preventDefault();\r\n        const step = e.shiftKey ? 10 : 1;\r\n        const dx = e.key === \"ArrowLeft\" ? -step : e.key === \"ArrowRight\" ? step : 0;\r\n        const dy = e.key === \"ArrowUp\" ? -step : e.key === \"ArrowDown\" ? step : 0;\r\n        const cur = tf?.[selection] ?? {};\r\n        commit(selection, { ...cur, x: (cur.x ?? 0) + dx, y: (cur.y ?? 0) + dy });\r\n      }\r\n    };\r\n    window.addEventListener(\"keydown\", onKey);\r\n    return () => window.removeEventListener(\"keydown\", onKey);\r\n  }, [editing, selection]); \/\/ eslint-disable-line react-hooks\/exhaustive-deps\r\n\r\n  const onContextMenuSelect = (e: ReactMouseEvent<HTMLDivElement>) => {\r\n    if (!editing) return;\r\n    const el = (e.target as HTMLElement).closest(\"[data-cms]\");\r\n    if (el) ed.select(el.getAttribute(\"data-cms\"));\r\n    else ed.select(null);\r\n  };\r\n\r\n  const page = (\r\n    <div\r\n      ref={pageRef}\r\n      onClickCapture={onClickCapture}\r\n      onContextMenu={onContextMenuSelect}\r\n      onDragOver={editing ? onPageDragOver : undefined}\r\n      onDragLeave={editing ? () => setDropHint(null) : undefined}\r\n      onDrop={editing ? onPageDrop : undefined}\r\n    >\r\n      <CmsPage doc={ed.state.doc} registry={registry} data={data} \/>\r\n    <\/div>\r\n  );\r\n  const pageShell = pinned ? (\r\n    <div ref={spacerRef} style={{ height: `${Math.max(1, frames) * 100}vh` }}>\r\n      <div style={{ position: \"sticky\", top: 0, height: \"100vh\", overflow: \"hidden\" }}>{page}<\/div>\r\n    <\/div>\r\n  ) : frames > 1 ? (\r\n    \/\/ Normal flow, but \"expanded\" \u2014 extra scroll length below the content so a\r\n    \/\/ page can be made longer than its content (the \uff0b Frame control).\r\n    <div style={{ minHeight: `${frames * 100}vh` }}>{page}<\/div>\r\n  ) : (\r\n    page\r\n  );\r\n\r\n  return (\r\n    <div style={{ position: \"relative\" }}>\r\n      {editing ? (\r\n        <ContextMenu>\r\n          <ContextMenu.Trigger>{pageShell}<\/ContextMenu.Trigger>\r\n          <ContextMenu.Content className=\"!z-[2147483646]\">\r\n            <EditMenuItems\r\n              node={selNode ?? null}\r\n              hasClipboard={Boolean(clip.current)}\r\n              isContainer={selNode ? CONTAINER_TYPES.has(selNode.type) : false}\r\n              onEditText={() => startEditText(selection)}\r\n              onAdd={addNode}\r\n              onDuplicate={() => duplicate(selection)}\r\n              onCopy={() => copy(selection)}\r\n              onPaste={() => paste(selection)}\r\n              onWrap={() => wrapInBox(selection)}\r\n              onReorder={(d) => reorder(selection, d)}\r\n              onDelete={removeSelected}\r\n            \/>\r\n          <\/ContextMenu.Content>\r\n        <\/ContextMenu>\r\n      ) : (\r\n        pageShell\r\n      )}\r\n\r\n      {editing && box && selection ? (\r\n        <SelectionOverlay\r\n          box={box}\r\n          base={transforms?.[selection] ?? {}}\r\n          movable={Boolean(onNodeTransform)}\r\n          getEl={() => pageRef.current?.querySelector<HTMLElement>(`[data-cms=\"${cssEscape(selection)}\"]`) ?? null}\r\n          onTransform={(t) => onNodeTransform?.(selection, t)}\r\n          onLive={measureBox}\r\n        \/>\r\n      ) : null}\r\n\r\n      {editing && box && selNode ? (\r\n        <FloatingToolbar box={box} isText={selNode.type === \"text\" || selNode.type === \"heading\"} base={selNode.style.base} setStyle={setStyle} \/>\r\n      ) : null}\r\n\r\n      {editing && selNode && box ? (\r\n        <NodeInspector\r\n          node={selNode}\r\n          transform={(selection ? transforms?.[selection] : undefined) ?? {}}\r\n          measured={{ w: box.w, h: box.h }}\r\n          onProps={setProps}\r\n          onStyle={setStyle}\r\n          onLayout={setLayout}\r\n          onTransform={(t) => selection && onNodeTransform?.(selection, t)}\r\n          onRemove={removeSelected}\r\n          onClose={() => ed.select(null)}\r\n        \/>\r\n      ) : null}\r\n\r\n      {revealed || editing ? (\r\n        <EditBar\r\n          editing={editing}\r\n          onToggle={() => {\r\n            setEditing((v) => !v);\r\n            ed.select(null);\r\n          }}\r\n          canUndo={ed.canUndo}\r\n          canRedo={ed.canRedo}\r\n          onUndo={ed.undo}\r\n          onRedo={ed.redo}\r\n          onAdd={addNode}\r\n        \/>\r\n      ) : null}\r\n\r\n      {editing && dropHint?.rect ? <DropIndicator hint={dropHint} \/> : null}\r\n\r\n      {editing ? <ElementPalette open={paletteOpen} onDragChange={setDraggingEl} \/> : null}\r\n\r\n      {editing && timelineDock ? <div style={{ position: \"fixed\", left: 0, right: 0, bottom: 0, zIndex: Z }}>{timelineDock}<\/div> : null}\r\n    <\/div>\r\n  );\r\n}\r\n\r\n\/** The live drop indicator: a line before\/after a sibling, or an outline inside a container. *\/\r\nfunction DropIndicator({\r\n  hint,\r\n}: {\r\n  hint: { edge: \"before\" | \"after\" | \"inside\"; rect: { x: number; y: number; w: number; h: number } | null };\r\n}): ReactElement | null {\r\n  const r = hint.rect;\r\n  if (!r) return null;\r\n  const color = \"#ec4899\";\r\n  if (hint.edge === \"inside\") {\r\n    return <div style={{ position: \"fixed\", left: r.x, top: r.y, width: r.w, height: r.h, outline: `2px dashed ${color}`, outlineOffset: -2, borderRadius: 6, pointerEvents: \"none\", zIndex: Z + 4 }} \/>;\r\n  }\r\n  const y = hint.edge === \"before\" ? r.y : r.y + r.h;\r\n  return <div style={{ position: \"fixed\", left: r.x, top: y - 1.5, width: r.w, height: 3, background: color, borderRadius: 2, boxShadow: \"0 0 0 1px #fff\", pointerEvents: \"none\", zIndex: Z + 4 }} \/>;\r\n}\r\n\r\n\/**\r\n * The Element palette \u2014 a panel docked to the right edge that slides in when the\r\n * pointer nears the edge (controlled by `open`) and hides while an Element is\r\n * being dragged. Each tile is draggable; dropping it on the page inserts that\r\n * Element (handled by EditablePage's `onPageDrop`).\r\n *\/\r\nfunction ElementPalette({ open, onDragChange }: { open: boolean; onDragChange: (dragging: boolean) => void }): ReactElement {\r\n  return (\r\n    <div\r\n      style={{\r\n        position: \"fixed\",\r\n        top: 64,\r\n        bottom: 96,\r\n        right: 0,\r\n        width: 216,\r\n        transform: open ? \"translateX(0)\" : \"translateX(100%)\",\r\n        transition: \"transform 0.18s ease\",\r\n        background: \"#0b1220\",\r\n        color: \"#e2e8f0\",\r\n        borderLeft: \"1px solid #1e293b\",\r\n        borderTopLeftRadius: 12,\r\n        borderBottomLeftRadius: 12,\r\n        boxShadow: \"-18px 0 48px -20px rgba(0,0,0,0.6)\",\r\n        zIndex: Z + 2,\r\n        fontFamily: \"system-ui, sans-serif\",\r\n        padding: 12,\r\n        overflowY: \"auto\",\r\n      }}\r\n    >\r\n      <div style={{ fontSize: 11, letterSpacing: \"0.06em\", textTransform: \"uppercase\", opacity: 0.5, marginBottom: 10 }}>Elements<\/div>\r\n      <div style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}>\r\n        {ADD_MENU.map((m) => (\r\n          <div\r\n            key={m.kind}\r\n            draggable\r\n            onDragStart={(e) => {\r\n              e.dataTransfer.setData(\"application\/x-cms-element\", m.kind);\r\n              e.dataTransfer.effectAllowed = \"copy\";\r\n              onDragChange(true);\r\n            }}\r\n            onDragEnd={() => onDragChange(false)}\r\n            title={`Drag \"${m.label}\" onto the page`}\r\n            style={{\r\n              display: \"flex\",\r\n              flexDirection: \"column\",\r\n              alignItems: \"center\",\r\n              justifyContent: \"center\",\r\n              gap: 4,\r\n              height: 56,\r\n              borderRadius: 8,\r\n              background: \"#0f172a\",\r\n              border: \"1px solid #1e293b\",\r\n              cursor: \"grab\",\r\n              fontSize: 12,\r\n              userSelect: \"none\",\r\n            }}\r\n          >\r\n            <span style={{ fontSize: 16, opacity: 0.7 }}>\uff0b<\/span>\r\n            {m.label}\r\n          <\/div>\r\n        ))}\r\n      <\/div>\r\n      <p style={{ fontSize: 10.5, opacity: 0.45, marginTop: 12, lineHeight: 1.4 }}>\r\n        Drag an Element onto the page. It drops into the container you release over.\r\n      <\/p>\r\n    <\/div>\r\n  );\r\n}\r\n\r\n\/\/ CONTAINER_TYPES \/ AddKind \/ ADD_DEFAULTS \/ ADD_MENU now live in .\/insert, so\r\n\/\/ the standalone <Editor> can offer the same palette (#3).\r\n\r\n\/** A resize grip: which edges it drags, its position, and its cursor. *\/\r\ninterface Grip {\r\n  key: string;\r\n  north?: boolean;\r\n  south?: boolean;\r\n  east?: boolean;\r\n  west?: boolean;\r\n  cursor: string;\r\n}\r\nconst GRIPS: Grip[] = [\r\n  { key: \"nw\", north: true, west: true, cursor: \"nwse-resize\" },\r\n  { key: \"n\", north: true, cursor: \"ns-resize\" },\r\n  { key: \"ne\", north: true, east: true, cursor: \"nesw-resize\" },\r\n  { key: \"e\", east: true, cursor: \"ew-resize\" },\r\n  { key: \"se\", south: true, east: true, cursor: \"nwse-resize\" },\r\n  { key: \"s\", south: true, cursor: \"ns-resize\" },\r\n  { key: \"sw\", south: true, west: true, cursor: \"nesw-resize\" },\r\n  { key: \"w\", west: true, cursor: \"ew-resize\" },\r\n];\r\n\r\nfunction SelectionOverlay({\r\n  box,\r\n  base,\r\n  movable,\r\n  getEl,\r\n  onTransform,\r\n  onLive,\r\n}: {\r\n  box: Box;\r\n  base: NodeTransform;\r\n  movable: boolean;\r\n  getEl: () => HTMLElement | null;\r\n  onTransform: (t: NodeTransform) => void;\r\n  onLive: () => void;\r\n}): ReactElement {\r\n  const drag = useRef<{ x: number; y: number; w: number; h: number; live: NodeTransform } | null>(null);\r\n  \/\/ Snap targets (other elements' edges\/centers, viewport coords) gathered at\r\n  \/\/ drag start, + the live alignment guides to draw.\r\n  const targets = useRef<{ vx: number[]; hy: number[] }>({ vx: [], hy: [] });\r\n  const [guides, setGuides] = useState<{ x: number | null; y: number | null }>({ x: null, y: null });\r\n\r\n  const apply = (t: NodeTransform) => {\r\n    const el = getEl();\r\n    if (el) applyTransform(el, t);\r\n  };\r\n\r\n  \/\/ Snap a set of candidate lines to the nearest target within SNAP px.\r\n  \/\/ Returns the adjustment to apply and the matched guide coordinate.\r\n  const snap = (cands: number[], lines: number[]): { delta: number; at: number | null } => {\r\n    let best = { delta: 0, at: null as number | null, dist: SNAP + 1 };\r\n    for (const line of lines) {\r\n      for (const c of cands) {\r\n        const diff = line - c;\r\n        if (Math.abs(diff) < best.dist) best = { delta: diff, at: line, dist: Math.abs(diff) };\r\n      }\r\n    }\r\n    return best.dist <= SNAP ? { delta: best.delta, at: best.at } : { delta: 0, at: null };\r\n  };\r\n\r\n  \/\/ Move drags translate; resize grips set an explicit width\/height (a true\r\n  \/\/ resize, not scale) and translate the opposite way when dragging a top\/left\r\n  \/\/ edge so the anchored edge stays put.\r\n  const startMove = (e: ReactPointerEvent<HTMLDivElement>) => {\r\n    e.stopPropagation();\r\n    e.preventDefault();\r\n    e.currentTarget.setPointerCapture(e.pointerId);\r\n    drag.current = { x: e.clientX, y: e.clientY, w: box.w, h: box.h, live: { ...base } };\r\n    \/\/ Gather snap targets: every other element's left\/center\/right + top\/middle\/\r\n    \/\/ bottom (skip the selection itself and its ancestors\/descendants).\r\n    const sel = getEl();\r\n    const vx = new Set<number>();\r\n    const hy = new Set<number>();\r\n    document.querySelectorAll<HTMLElement>(\"[data-cms]\").forEach((el) => {\r\n      if (sel && (el === sel || sel.contains(el) || el.contains(sel))) return;\r\n      const r = el.getBoundingClientRect();\r\n      vx.add(r.left).add(r.left + r.width \/ 2).add(r.right);\r\n      hy.add(r.top).add(r.top + r.height \/ 2).add(r.bottom);\r\n    });\r\n    targets.current = { vx: [...vx], hy: [...hy] };\r\n  };\r\n  const moveMove = (e: ReactPointerEvent<HTMLDivElement>) => {\r\n    const d = drag.current;\r\n    if (!d) return;\r\n    let dx = e.clientX - d.x;\r\n    let dy = e.clientY - d.y;\r\n    \/\/ Snap the moving box's edges\/center to nearby targets (hold Alt to bypass).\r\n    if (!e.altKey) {\r\n      const l = box.x + dx;\r\n      const t = box.y + dy;\r\n      const sx = snap([l, l + box.w \/ 2, l + box.w], targets.current.vx);\r\n      const sy = snap([t, t + box.h \/ 2, t + box.h], targets.current.hy);\r\n      dx += sx.delta;\r\n      dy += sy.delta;\r\n      setGuides({ x: sx.at, y: sy.at });\r\n    } else if (guides.x !== null || guides.y !== null) {\r\n      setGuides({ x: null, y: null });\r\n    }\r\n    d.live = { ...base, x: (base.x ?? 0) + dx, y: (base.y ?? 0) + dy };\r\n    apply(d.live);\r\n    onLive();\r\n  };\r\n\r\n  const startResize = (g: Grip) => (e: ReactPointerEvent<HTMLDivElement>) => {\r\n    e.stopPropagation();\r\n    e.preventDefault();\r\n    e.currentTarget.setPointerCapture(e.pointerId);\r\n    drag.current = { x: e.clientX, y: e.clientY, w: base.w ?? box.w, h: base.h ?? box.h, live: { ...base } };\r\n    (e.currentTarget as HTMLElement).dataset.grip = g.key;\r\n  };\r\n  const resizeMove = (g: Grip) => (e: ReactPointerEvent<HTMLDivElement>) => {\r\n    const d = drag.current;\r\n    if (!d) return;\r\n    const dx = e.clientX - d.x;\r\n    const dy = e.clientY - d.y;\r\n    const live: NodeTransform = { ...base };\r\n    if (g.east) live.w = Math.max(16, Math.round(d.w + dx));\r\n    if (g.west) {\r\n      live.w = Math.max(16, Math.round(d.w - dx));\r\n      live.x = (base.x ?? 0) + (d.w - live.w);\r\n    }\r\n    if (g.south) live.h = Math.max(16, Math.round(d.h + dy));\r\n    if (g.north) {\r\n      live.h = Math.max(16, Math.round(d.h - dy));\r\n      live.y = (base.y ?? 0) + (d.h - live.h);\r\n    }\r\n    d.live = live;\r\n    apply(live);\r\n    onLive();\r\n  };\r\n  const end = () => {\r\n    const d = drag.current;\r\n    if (d) onTransform(d.live);\r\n    drag.current = null;\r\n    setGuides({ x: null, y: null });\r\n  };\r\n\r\n  const outline: CSSProperties = {\r\n    position: \"fixed\",\r\n    left: box.x - 1,\r\n    top: box.y - 1,\r\n    width: box.w + 2,\r\n    height: box.h + 2,\r\n    outline: \"2px solid #8b5cf6\",\r\n    borderRadius: 4,\r\n    pointerEvents: \"none\",\r\n    zIndex: Z,\r\n  };\r\n  const handle: CSSProperties = {\r\n    position: \"fixed\",\r\n    width: 12,\r\n    height: 12,\r\n    background: \"#fff\",\r\n    border: \"2px solid #8b5cf6\",\r\n    borderRadius: 3,\r\n    zIndex: Z + 1,\r\n    touchAction: \"none\",\r\n    boxSizing: \"border-box\",\r\n  };\r\n  const gripPos = (g: Grip): CSSProperties => {\r\n    const cx = g.west ? box.x : g.east ? box.x + box.w : box.x + box.w \/ 2;\r\n    const cy = g.north ? box.y : g.south ? box.y + box.h : box.y + box.h \/ 2;\r\n    return { left: cx - 6, top: cy - 6, cursor: g.cursor };\r\n  };\r\n\r\n  return (\r\n    <>\r\n      {guides.x !== null ? <div style={{ position: \"fixed\", left: guides.x, top: 0, bottom: 0, width: 1, background: \"#ec4899\", zIndex: Z + 3, pointerEvents: \"none\" }} \/> : null}\r\n      {guides.y !== null ? <div style={{ position: \"fixed\", top: guides.y, left: 0, right: 0, height: 1, background: \"#ec4899\", zIndex: Z + 3, pointerEvents: \"none\" }} \/> : null}\r\n      <div style={outline} \/>\r\n      {movable ? (\r\n        <>\r\n          {\/* move grip \u2014 circular, top-left, offset clear of the corner handle *\/}\r\n          <div\r\n            title=\"Drag to move\"\r\n            onPointerDown={startMove}\r\n            onPointerMove={moveMove}\r\n            onPointerUp={end}\r\n            style={{ ...handle, width: 18, height: 18, borderRadius: 9, background: \"#8b5cf6\", border: \"2px solid #fff\", left: box.x - 22, top: box.y - 22, cursor: \"move\" }}\r\n          \/>\r\n          {GRIPS.map((g) => (\r\n            <div\r\n              key={g.key}\r\n              title=\"Drag to resize\"\r\n              onPointerDown={startResize(g)}\r\n              onPointerMove={resizeMove(g)}\r\n              onPointerUp={end}\r\n              style={{ ...handle, ...gripPos(g) }}\r\n            \/>\r\n          ))}\r\n        <\/>\r\n      ) : null}\r\n    <\/>\r\n  );\r\n}\r\n\r\nfunction FloatingToolbar({\r\n  box,\r\n  isText,\r\n  base,\r\n  setStyle,\r\n}: {\r\n  box: Box;\r\n  isText: boolean;\r\n  base: Partial<StyleProps>;\r\n  setStyle: (patch: Partial<StyleProps>) => void;\r\n}): ReactElement {\r\n  const wrap: CSSProperties = {\r\n    position: \"fixed\",\r\n    left: box.x,\r\n    top: Math.max(8, box.y - 46),\r\n    display: \"flex\",\r\n    alignItems: \"center\",\r\n    gap: 8,\r\n    padding: \"6px 10px\",\r\n    background: \"#0f172a\",\r\n    color: \"#fff\",\r\n    border: \"1px solid rgba(255,255,255,0.12)\",\r\n    borderRadius: 999,\r\n    boxShadow: \"0 8px 24px -8px rgba(0,0,0,0.5)\",\r\n    zIndex: Z + 2,\r\n    fontFamily: \"system-ui, sans-serif\",\r\n    fontSize: 12,\r\n  };\r\n  const swatch: CSSProperties = { width: 22, height: 22, border: \"none\", background: \"transparent\", padding: 0, cursor: \"pointer\" };\r\n  const num: CSSProperties = { width: 52, background: \"#1e293b\", color: \"#fff\", border: \"1px solid #334155\", borderRadius: 6, padding: \"3px 6px\", font: \"inherit\" };\r\n  return (\r\n    <div style={wrap} onMouseDown={(e) => e.preventDefault()}>\r\n      {isText ? (\r\n        <label title=\"Text color\" style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4 }}>\r\n          <span style={{ opacity: 0.7 }}>A<\/span>\r\n          <input type=\"color\" style={swatch} value={base.color ?? \"#0f172a\"} onChange={(e) => setStyle({ color: e.target.value })} \/>\r\n        <\/label>\r\n      ) : null}\r\n      {isText ? (\r\n        <input type=\"number\" title=\"Font size (px)\" style={num} placeholder=\"size\" value={base.fontSize?.value ?? \"\"} onChange={(e) => setStyle({ fontSize: { value: Number(e.target.value) || 0, unit: \"px\" } })} \/>\r\n      ) : null}\r\n      <label title=\"Background\" style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4 }}>\r\n        <span style={{ opacity: 0.7 }}>BG<\/span>\r\n        <input type=\"color\" style={swatch} value={normalizeColor(base.background)} onChange={(e) => setStyle({ background: e.target.value })} \/>\r\n      <\/label>\r\n    <\/div>\r\n  );\r\n}\r\n\r\n\r\nfunction EditBar({\r\n  editing,\r\n  onToggle,\r\n  canUndo,\r\n  canRedo,\r\n  onUndo,\r\n  onRedo,\r\n  onAdd,\r\n}: {\r\n  editing: boolean;\r\n  onToggle: () => void;\r\n  canUndo: boolean;\r\n  canRedo: boolean;\r\n  onUndo: () => void;\r\n  onRedo: () => void;\r\n  onAdd: (kind: AddKind) => void;\r\n}): ReactElement {\r\n  const [open, setOpen] = useState(false);\r\n  const bar: CSSProperties = { position: \"fixed\", top: 12, right: 12, display: \"flex\", gap: 6, alignItems: \"center\", padding: 6, background: \"#0f172a\", color: \"#fff\", borderRadius: 999, boxShadow: \"0 8px 24px -8px rgba(0,0,0,0.4)\", zIndex: Z + 3, fontFamily: \"system-ui, sans-serif\", fontSize: 12 };\r\n  const btn: CSSProperties = { font: \"inherit\", border: \"none\", background: \"transparent\", color: \"inherit\", cursor: \"pointer\", padding: \"4px 8px\", borderRadius: 999 };\r\n  return (\r\n    <div style={bar}>\r\n      <button type=\"button\" style={{ ...btn, background: editing ? \"#8b5cf6\" : \"transparent\" }} onClick={onToggle}>\r\n        {editing ? \"\u25cf Editing\" : \"Edit\"}\r\n      <\/button>\r\n      {editing ? (\r\n        <>\r\n          <div style={{ position: \"relative\" }}>\r\n            <button type=\"button\" style={{ ...btn, background: open ? \"#1e293b\" : \"transparent\" }} onClick={() => setOpen((v) => !v)} title=\"Add an Element (or drag from the right-edge palette)\">\uff0b Element \u25be<\/button>\r\n            {open ? (\r\n              <div style={{ position: \"absolute\", top: \"calc(100% + 6px)\", right: 0, background: \"#0f172a\", border: \"1px solid #1e293b\", borderRadius: 10, padding: 4, display: \"flex\", flexDirection: \"column\", minWidth: 132, boxShadow: \"0 12px 32px -10px rgba(0,0,0,0.6)\" }}>\r\n                {ADD_MENU.map((m) => (\r\n                  <button\r\n                    key={m.kind}\r\n                    type=\"button\"\r\n                    style={{ ...btn, textAlign: \"left\", borderRadius: 6, padding: \"6px 10px\" }}\r\n                    onClick={() => {\r\n                      onAdd(m.kind);\r\n                      setOpen(false);\r\n                    }}\r\n                  >\r\n                    {m.label}\r\n                  <\/button>\r\n                ))}\r\n              <\/div>\r\n            ) : null}\r\n          <\/div>\r\n          <button type=\"button\" style={{ ...btn, opacity: canUndo ? 1 : 0.4 }} disabled={!canUndo} onClick={onUndo}>\u21b6<\/button>\r\n          <button type=\"button\" style={{ ...btn, opacity: canRedo ? 1 : 0.4 }} disabled={!canRedo} onClick={onRedo}>\u21b7<\/button>\r\n        <\/>\r\n      ) : null}\r\n    <\/div>\r\n  );\r\n}\r\n\r\n\/** The right-click menu in EditMode \u2014 contextual to the node under the cursor. *\/\r\nfunction EditMenuItems({\r\n  node,\r\n  hasClipboard,\r\n  isContainer,\r\n  onEditText,\r\n  onAdd,\r\n  onDuplicate,\r\n  onCopy,\r\n  onPaste,\r\n  onWrap,\r\n  onReorder,\r\n  onDelete,\r\n}: {\r\n  node: { type: string; parent: NodeId | null } | null;\r\n  hasClipboard: boolean;\r\n  isContainer: boolean;\r\n  onEditText: () => void;\r\n  onAdd: (kind: AddKind) => void;\r\n  onDuplicate: () => void;\r\n  onCopy: () => void;\r\n  onPaste: () => void;\r\n  onWrap: () => void;\r\n  onReorder: (dir: ReorderDir) => void;\r\n  onDelete: () => void;\r\n}): ReactElement {\r\n  const editable = node && (node.type === \"text\" || node.type === \"heading\");\r\n  const nested = Boolean(node && node.parent !== null);\r\n  return (\r\n    <>\r\n      {editable ? <ContextMenu.Item onClick={onEditText}>Edit text<\/ContextMenu.Item> : null}\r\n      <ContextMenu.Sub>\r\n        <ContextMenu.SubTrigger>{isContainer ? \"Add Element inside\" : \"Add Element\"}<\/ContextMenu.SubTrigger>\r\n        <ContextMenu.SubContent className=\"!z-[2147483647]\">\r\n          {ADD_MENU.map((m) => (\r\n            <ContextMenu.Item key={m.kind} onClick={() => onAdd(m.kind)}>{m.label}<\/ContextMenu.Item>\r\n          ))}\r\n        <\/ContextMenu.SubContent>\r\n      <\/ContextMenu.Sub>\r\n      {node ? (\r\n        <>\r\n          <ContextMenu.Separator \/>\r\n          <ContextMenu.Item onClick={onDuplicate}>Duplicate<\/ContextMenu.Item>\r\n          <ContextMenu.Item onClick={onCopy}>Copy<\/ContextMenu.Item>\r\n          <ContextMenu.Item disabled={!hasClipboard} onClick={onPaste}>Paste<\/ContextMenu.Item>\r\n          {nested ? <ContextMenu.Item onClick={onWrap}>Wrap in Box<\/ContextMenu.Item> : null}\r\n          <ContextMenu.Separator \/>\r\n          <ContextMenu.Item onClick={() => onReorder(\"up\")}>Move up<\/ContextMenu.Item>\r\n          <ContextMenu.Item onClick={() => onReorder(\"down\")}>Move down<\/ContextMenu.Item>\r\n          {nested ? (\r\n            <>\r\n              <ContextMenu.Item onClick={() => onReorder(\"front\")}>Bring to front<\/ContextMenu.Item>\r\n              <ContextMenu.Item onClick={() => onReorder(\"back\")}>Send to back<\/ContextMenu.Item>\r\n            <\/>\r\n          ) : null}\r\n          <ContextMenu.Separator \/>\r\n          <ContextMenu.Item danger onClick={onDelete}>Delete<\/ContextMenu.Item>\r\n        <\/>\r\n      ) : (\r\n        <ContextMenu.Item disabled={!hasClipboard} onClick={onPaste}>Paste<\/ContextMenu.Item>\r\n      )}\r\n    <\/>\r\n  );\r\n}\r\n\r\n\/** Write a transform onto an element (or clear every transformed property). *\/\r\nfunction applyTransform(el: HTMLElement, t: NodeTransform | undefined): void {\r\n  if (t && (t.x != null || t.y != null || t.scale != null || t.rotate != null || t.opacity != null || t.w != null || t.h != null)) {\r\n    el.style.transform = `translate3d(${t.x ?? 0}px, ${t.y ?? 0}px, 0) scale(${t.scale ?? 1}) rotate(${t.rotate ?? 0}deg)`;\r\n    el.style.opacity = t.opacity != null ? String(t.opacity) : \"\";\r\n    el.style.width = t.w != null ? `${t.w}px` : \"\";\r\n    el.style.height = t.h != null ? `${t.h}px` : \"\";\r\n  } else {\r\n    el.style.transform = \"\";\r\n    el.style.opacity = \"\";\r\n    el.style.width = \"\";\r\n    el.style.height = \"\";\r\n  }\r\n}\r\n\r\nfunction normalizeColor(v: string | undefined): string {\r\n  return v && \/^#[0-9a-fA-F]{6}$\/.test(v) ? v : \"#ffffff\";\r\n}\r\n\r\nfunction cssEscape(value: string): string {\r\n  return value.replace(\/[\"\\\\]\/g, \"\\\\$&\");\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/EditablePage.tsx"},{"path":"components\/fancy\/cms-editor\/Editor.tsx","content":"import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactElement } from \"react\";\r\nimport type { PageDoc } from \"..\/document\/types\";\r\nimport type { DataContext, ElementRegistry } from \"..\/react\/registry\";\r\nimport { useEditor, type EditorApi } from \".\/useEditor\";\r\nimport { useLatestRef } from \".\/useLatestRef\";\r\nimport { Canvas } from \".\/Canvas\";\r\nimport { LayersPanel } from \".\/LayersPanel\";\r\nimport { Inspector } from \".\/Inspector\";\r\nimport { ADD_MENU, buildInsertOp, type AddKind } from \".\/insert\";\r\nimport { emptyDoc } from \"..\/document\/types\";\r\n\r\nexport interface EditorProps {\r\n  \/**\r\n   * Initial document; edits are surfaced via {@link EditorProps.onChange}.\r\n   * Omit to start from an empty page.\r\n   *\/\r\n  defaultValue?: PageDoc;\r\n  onChange?: (doc: PageDoc) => void;\r\n  \/**\r\n   * Custom element registry \u2014 mirrors {@link CmsPageProps.registry} so the edit\r\n   * canvas renders your own node types instead of a blank placeholder. Pass the\r\n   * same registry you give `CmsPage` at runtime.\r\n   *\/\r\n  registry?: ElementRegistry;\r\n  \/** Data context that `{ $bind: \"\u2026\" }` props + repeaters preview against on the canvas. *\/\r\n  data?: DataContext;\r\n}\r\n\r\n\/**\r\n * The fancy-cms editor: layers \u00b7 canvas \u00b7 inspector over the op-spine. Phase 1\r\n * cut \u2014 chrome is plain markup for now; it graduates to react-fancy next.\r\n *\/\r\nexport function Editor({ defaultValue, onChange, registry, data }: EditorProps): ReactElement {\r\n  \/\/ Render an empty page rather than throwing on a missing document. Reading\r\n  \/\/ `.sections` off undefined crashed the entire editor, which is a hostile way\r\n  \/\/ to tell a host \"you haven't passed a doc yet\" (#3).\r\n  const initial = useMemo(() => defaultValue ?? emptyDoc(\"untitled\"), [defaultValue]);\r\n  const ed = useEditor(initial);\r\n  const first = useRef(true);\r\n\r\n  \/\/ Notify depends ONLY on the doc \u2014 the latest onChange lives in a ref, so an\r\n  \/\/ inline-closure consumer (whose handler identity changes every render) can't\r\n  \/\/ re-trigger the notify and loop it (#1).\r\n  const onChangeRef = useLatestRef(onChange);\r\n  useEffect(() => {\r\n    if (first.current) {\r\n      first.current = false;\r\n      return;\r\n    }\r\n    onChangeRef.current?.(ed.state.doc);\r\n  }, [ed.state.doc, onChangeRef]);\r\n\r\n  const shell: CSSProperties = {\r\n    display: \"grid\",\r\n    gridTemplateColumns: \"220px 1fr 300px\",\r\n    height: \"100%\",\r\n    minHeight: 0,\r\n    background: \"var(--fcms-bg)\",\r\n    color: \"var(--fcms-fg)\",\r\n    border: \"1px solid var(--fcms-border)\",\r\n    borderRadius: 12,\r\n    overflow: \"hidden\",\r\n  };\r\n\r\n  return (\r\n    <div className=\"fancy-cms-editor\" style={shell}>\r\n      <style>{CHROME_CSS}<\/style>\r\n      <div style={{ borderRight: \"1px solid var(--fcms-border)\", display: \"flex\", flexDirection: \"column\", minHeight: 0 }}>\r\n        <Toolbar ed={ed} \/>\r\n        <LayersPanel\r\n          doc={ed.state.doc}\r\n          selection={ed.state.selection}\r\n          onSelect={ed.select}\r\n          onMove={(id, parent, order) => ed.apply({ t: \"move_node\", id, parent, order })}\r\n        \/>\r\n      <\/div>\r\n      <Canvas doc={ed.state.doc} selection={ed.state.selection} onSelect={ed.select} apply={ed.apply} registry={registry} data={data} \/>\r\n      <div style={{ borderLeft: \"1px solid var(--fcms-border)\", minHeight: 0 }}>\r\n        <Inspector doc={ed.state.doc} selection={ed.state.selection} apply={ed.apply} \/>\r\n      <\/div>\r\n    <\/div>\r\n  );\r\n}\r\n\r\nfunction Toolbar({ ed }: { ed: EditorApi }): ReactElement {\r\n  const btn: CSSProperties = {\r\n    font: \"inherit\",\r\n    fontSize: 12,\r\n    padding: \"4px 10px\",\r\n    borderRadius: 6,\r\n    border: \"1px solid var(--fcms-border)\",\r\n    background: \"var(--fcms-bg)\",\r\n    color: \"var(--fcms-fg)\",\r\n    cursor: \"pointer\",\r\n  };\r\n  return (\r\n    <div\r\n      style={{\r\n        display: \"flex\",\r\n        gap: 6,\r\n        padding: 8,\r\n        borderBottom: \"1px solid var(--fcms-border)\",\r\n        fontFamily: \"system-ui, sans-serif\",\r\n      }}\r\n    >\r\n      <button type=\"button\" style={{ ...btn, opacity: ed.canUndo ? 1 : 0.4 }} disabled={!ed.canUndo} onClick={ed.undo}>\r\n        Undo\r\n      <\/button>\r\n      <button type=\"button\" style={{ ...btn, opacity: ed.canRedo ? 1 : 0.4 }} disabled={!ed.canRedo} onClick={ed.redo}>\r\n        Redo\r\n      <\/button>\r\n      <AddElementMenu ed={ed} btn={btn} \/>\r\n    <\/div>\r\n  );\r\n}\r\n\r\n\/**\r\n * Insert control for the standalone editor.\r\n *\r\n * Without this, `Editor` could not add an element at all: a host starting from\r\n * `emptyDoc()` got a blank canvas with nothing to select and no way to put\r\n * anything on it (#3). The palette itself was only ever wired into\r\n * `EditablePage`, which has no `onChange` to hand the document back \u2014 so\r\n * neither surface alone was embeddable.\r\n *\/\r\nfunction AddElementMenu({ ed, btn }: { ed: EditorApi; btn: CSSProperties }): ReactElement {\r\n  const [open, setOpen] = useState(false);\r\n\r\n  \/\/ Dismiss on any outside click or Escape.\r\n  useEffect(() => {\r\n    if (!open) return;\r\n    const close = () => setOpen(false);\r\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\r\n    window.addEventListener(\"click\", close);\r\n    window.addEventListener(\"keydown\", onKey);\r\n    return () => {\r\n      window.removeEventListener(\"click\", close);\r\n      window.removeEventListener(\"keydown\", onKey);\r\n    };\r\n  }, [open]);\r\n\r\n  const add = (kind: AddKind) => {\r\n    const { op, id } = buildInsertOp(ed.state.doc, kind, ed.state.selection);\r\n    ed.apply(op);\r\n    ed.select(id);\r\n    setOpen(false);\r\n  };\r\n\r\n  return (\r\n    <div style={{ position: \"relative\", marginLeft: \"auto\" }} onClick={(e) => e.stopPropagation()}>\r\n      <button\r\n        type=\"button\"\r\n        style={{ ...btn, borderColor: \"var(--fcms-accent, #7c3aed)\" }}\r\n        aria-haspopup=\"menu\"\r\n        aria-expanded={open}\r\n        data-cms-add-trigger=\"\"\r\n        onClick={() => setOpen((v) => !v)}\r\n      >\r\n        + Add\r\n      <\/button>\r\n      {open && (\r\n        <div\r\n          role=\"menu\"\r\n          data-cms-add-menu=\"\"\r\n          style={{\r\n            position: \"absolute\",\r\n            top: \"calc(100% + 4px)\",\r\n            left: 0,\r\n            zIndex: 20,\r\n            minWidth: 150,\r\n            maxHeight: 280,\r\n            overflowY: \"auto\",\r\n            padding: 4,\r\n            borderRadius: 8,\r\n            border: \"1px solid var(--fcms-border)\",\r\n            background: \"var(--fcms-bg)\",\r\n            boxShadow: \"0 12px 32px rgba(0,0,0,.28)\",\r\n          }}\r\n        >\r\n          {ADD_MENU.map((item) => (\r\n            <button\r\n              key={item.kind}\r\n              type=\"button\"\r\n              role=\"menuitem\"\r\n              data-cms-add={item.kind}\r\n              style={{\r\n                ...btn,\r\n                display: \"block\",\r\n                width: \"100%\",\r\n                textAlign: \"left\",\r\n                border: 0,\r\n                background: \"transparent\",\r\n              }}\r\n              onClick={() => add(item.kind)}\r\n            >\r\n              {item.label}\r\n            <\/button>\r\n          ))}\r\n        <\/div>\r\n      )}\r\n    <\/div>\r\n  );\r\n}\r\n\r\n\/**\r\n * Editor chrome tokens. Light by default; dark under `@media (prefers-color-scheme:\r\n * dark)` OR an ancestor `.dark` \/ `[data-theme=\"dark\"]` (Tailwind\/class strategy),\r\n * with an explicit `.light` \/ `[data-theme=\"light\"]` ancestor forcing light back.\r\n * The panels below read `var(--fcms-*)`, so the whole editor embeds cleanly in a\r\n * dark app. `color-scheme` flips native inputs + scrollbars too.\r\n *\/\r\nconst DARK_VARS =\r\n  \"color-scheme:dark;--fcms-bg:#0b0f19;--fcms-fg:#e5e7eb;--fcms-muted:#94a3b8;--fcms-border:#27272a;\" +\r\n  \"--fcms-canvas:#0f141f;--fcms-input-bg:#111827;--fcms-row-fg:#cbd5e1;\" +\r\n  \"--fcms-sel-bg:color-mix(in oklab, #8b5cf6 24%, transparent);--fcms-sel-fg:#ddd6fe;\";\r\nconst LIGHT_VARS =\r\n  \"color-scheme:light;--fcms-bg:#ffffff;--fcms-fg:#0f172a;--fcms-muted:#64748b;--fcms-border:#e2e8f0;\" +\r\n  \"--fcms-canvas:#f8fafc;--fcms-input-bg:#ffffff;--fcms-row-fg:#334155;--fcms-sel-bg:#ede9fe;--fcms-sel-fg:#5b21b6;\";\r\nconst CHROME_CSS =\r\n  `.fancy-cms-editor{--fcms-accent:#8b5cf6;${LIGHT_VARS}}` +\r\n  `@media (prefers-color-scheme:dark){.fancy-cms-editor{${DARK_VARS}}}` +\r\n  `:where(.light,[data-theme=\"light\"]) .fancy-cms-editor{${LIGHT_VARS}}` +\r\n  `:where(.dark,[data-theme=\"dark\"]) .fancy-cms-editor{${DARK_VARS}}`;\r\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/Editor.tsx"},{"path":"components\/fancy\/cms-editor\/Inspector.tsx","content":"import type { CSSProperties, ReactElement } from \"react\";\r\nimport type { PageDoc, StyleProps } from \"..\/document\/types\";\r\nimport type { PageOp } from \"..\/document\/ops\";\r\n\r\nexport interface InspectorProps {\r\n  doc: PageDoc;\r\n  selection: string | null;\r\n  apply: (op: PageOp) => void;\r\n}\r\n\r\nconst label: CSSProperties = {\r\n  display: \"block\",\r\n  fontSize: 11,\r\n  textTransform: \"uppercase\",\r\n  letterSpacing: \"0.04em\",\r\n  color: \"var(--fcms-muted)\",\r\n  margin: \"10px 0 4px\",\r\n};\r\nconst input: CSSProperties = {\r\n  width: \"100%\",\r\n  boxSizing: \"border-box\",\r\n  padding: \"6px 8px\",\r\n  borderRadius: 6,\r\n  border: \"1px solid var(--fcms-border)\",\r\n  background: \"var(--fcms-input-bg)\",\r\n  color: \"var(--fcms-fg)\",\r\n  font: \"inherit\",\r\n  fontSize: 13,\r\n};\r\n\r\n\/** Contextual property editor for the selected node. Every change is one op. *\/\r\nexport function Inspector({ doc, selection, apply }: InspectorProps): ReactElement {\r\n  const node = selection ? doc.nodes[selection] : null;\r\n  if (!node) {\r\n    return (\r\n      <div style={{ padding: 16, color: \"var(--fcms-muted)\", fontFamily: \"system-ui, sans-serif\", fontSize: 13 }}>\r\n        Select an element to edit.\r\n      <\/div>\r\n    );\r\n  }\r\n\r\n  const s = node.style.base;\r\n  const setStyle = (patch: Partial<StyleProps>) =>\r\n    apply({ t: \"set_style\", id: node.id, breakpoint: \"base\", patch });\r\n  const setProp = (key: string, value: unknown) =>\r\n    apply({ t: \"set_props\", id: node.id, patch: { [key]: value } });\r\n\r\n  return (\r\n    <div style={{ overflow: \"auto\", padding: 16, fontFamily: \"system-ui, sans-serif\", height: \"100%\", boxSizing: \"border-box\" }}>\r\n      <div style={{ fontSize: 13, fontWeight: 600, color: \"var(--fcms-fg)\" }}>{node.type}<\/div>\r\n      <div style={{ fontSize: 11, color: \"var(--fcms-muted)\", marginBottom: 8 }}>{node.id}<\/div>\r\n\r\n      {node.type === \"text\" ? (\r\n        <>\r\n          <label style={label}>Content<\/label>\r\n          <textarea\r\n            style={{ ...input, minHeight: 64, resize: \"vertical\" }}\r\n            value={typeof node.props.content === \"string\" ? node.props.content : \"\"}\r\n            onChange={(e) => setProp(\"content\", e.target.value)}\r\n          \/>\r\n        <\/>\r\n      ) : null}\r\n\r\n      {node.type === \"image\" ? (\r\n        <>\r\n          <label style={label}>Source<\/label>\r\n          <input\r\n            style={input}\r\n            value={typeof node.props.src === \"string\" ? node.props.src : \"\"}\r\n            onChange={(e) => setProp(\"src\", e.target.value)}\r\n          \/>\r\n        <\/>\r\n      ) : null}\r\n\r\n      <label style={label}>Background<\/label>\r\n      <input\r\n        style={input}\r\n        placeholder=\"#ffffff | gradient | url(\u2026)\"\r\n        value={s.background ?? \"\"}\r\n        onChange={(e) => setStyle({ background: e.target.value })}\r\n      \/>\r\n\r\n      <label style={label}>Text color<\/label>\r\n      <input\r\n        style={input}\r\n        placeholder=\"#0f172a\"\r\n        value={s.color ?? \"\"}\r\n        onChange={(e) => setStyle({ color: e.target.value })}\r\n      \/>\r\n\r\n      <label style={label}>Font size (px)<\/label>\r\n      <input\r\n        type=\"number\"\r\n        style={input}\r\n        value={s.fontSize?.value ?? \"\"}\r\n        onChange={(e) => setStyle({ fontSize: { value: Number(e.target.value) || 0, unit: \"px\" } })}\r\n      \/>\r\n\r\n      <label style={label}>Padding (px)<\/label>\r\n      <input\r\n        type=\"number\"\r\n        style={input}\r\n        value={s.padding?.value ?? \"\"}\r\n        onChange={(e) => setStyle({ padding: { value: Number(e.target.value) || 0, unit: \"px\" } })}\r\n      \/>\r\n\r\n      <label style={label}>Opacity<\/label>\r\n      <input\r\n        type=\"number\"\r\n        step=\"0.1\"\r\n        min=\"0\"\r\n        max=\"1\"\r\n        style={input}\r\n        value={s.opacity ?? \"\"}\r\n        onChange={(e) => setStyle({ opacity: Number(e.target.value) })}\r\n      \/>\r\n    <\/div>\r\n  );\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/Inspector.tsx"},{"path":"components\/fancy\/cms-editor\/LayersPanel.tsx","content":"import { Fragment, useState, type DragEvent as ReactDragEvent, type ReactElement } from \"react\";\r\nimport type { NodeId, PageDoc } from \"..\/document\/types\";\r\nimport { childrenOf } from \"..\/document\/reduce\";\r\nimport { keyBetween } from \"..\/document\/fractional\";\r\n\r\nexport interface LayersPanelProps {\r\n  doc: PageDoc;\r\n  selection: string | null;\r\n  onSelect: (id: string) => void;\r\n  \/** Reparent\/reorder a node (drag-to-reorder). Omit to disable dragging. *\/\r\n  onMove?: (id: NodeId, parent: NodeId | null, order: string) => void;\r\n}\r\n\r\nconst MIME = \"application\/x-cms-node\";\r\n\r\n\/** True when `maybeAncestor` is `id` itself or one of its ancestors. *\/\r\nfunction isAncestorOrSelf(doc: PageDoc, id: string, maybeAncestor: string): boolean {\r\n  for (let cur: string | null = id; cur; cur = doc.nodes[cur]?.parent ?? null) {\r\n    if (cur === maybeAncestor) return true;\r\n  }\r\n  return false;\r\n}\r\n\r\n\/** The section\/layer tree. Click a row to select; drag a row onto another to reorder. *\/\r\nexport function LayersPanel({ doc, selection, onSelect, onMove }: LayersPanelProps): ReactElement {\r\n  const [overId, setOverId] = useState<string | null>(null);\r\n\r\n  return (\r\n    <div style={{ overflow: \"auto\", padding: 8, fontSize: 13, fontFamily: \"system-ui, sans-serif\" }}>\r\n      {doc.sections.map((id) => (\r\n        <LayerRow key={id} doc={doc} id={id} depth={0} selection={selection} onSelect={onSelect} onMove={onMove} overId={overId} setOverId={setOverId} \/>\r\n      ))}\r\n    <\/div>\r\n  );\r\n}\r\n\r\ninterface LayerRowProps {\r\n  doc: PageDoc;\r\n  id: string;\r\n  depth: number;\r\n  selection: string | null;\r\n  onSelect: (id: string) => void;\r\n  onMove?: (id: NodeId, parent: NodeId | null, order: string) => void;\r\n  overId: string | null;\r\n  setOverId: (id: string | null) => void;\r\n}\r\n\r\nfunction LayerRow({ doc, id, depth, selection, onSelect, onMove, overId, setOverId }: LayerRowProps): ReactElement | null {\r\n  const node = doc.nodes[id];\r\n  if (!node) return null;\r\n  const selected = selection === id;\r\n  const draggable = Boolean(onMove);\r\n\r\n  const drop = (e: ReactDragEvent) => {\r\n    e.preventDefault();\r\n    e.stopPropagation();\r\n    setOverId(null);\r\n    if (!onMove) return;\r\n    const dragId = e.dataTransfer.getData(MIME);\r\n    \/\/ Reject no-op + any move that would drop a node into its own subtree.\r\n    if (!dragId || dragId === id || isAncestorOrSelf(doc, id, dragId)) return;\r\n    \/\/ Place the dragged node immediately after this row, among this row's siblings.\r\n    const siblings = childrenOf(doc, node.parent);\r\n    const idx = siblings.findIndex((n) => n.id === id);\r\n    const next = siblings[idx + 1];\r\n    onMove(dragId, node.parent, keyBetween(node.order, next ? next.order : null));\r\n  };\r\n\r\n  return (\r\n    <Fragment>\r\n      <button\r\n        type=\"button\"\r\n        draggable={draggable}\r\n        onClick={() => onSelect(id)}\r\n        onDragStart={(e) => {\r\n          e.dataTransfer.setData(MIME, id);\r\n          e.dataTransfer.effectAllowed = \"move\";\r\n        }}\r\n        onDragOver={(e) => {\r\n          if (!onMove) return;\r\n          e.preventDefault();\r\n          e.dataTransfer.dropEffect = \"move\";\r\n          if (overId !== id) setOverId(id);\r\n        }}\r\n        onDragLeave={() => overId === id && setOverId(null)}\r\n        onDrop={drop}\r\n        style={{\r\n          display: \"block\",\r\n          width: \"100%\",\r\n          textAlign: \"left\",\r\n          border: \"none\",\r\n          borderRadius: 6,\r\n          cursor: draggable ? \"grab\" : \"pointer\",\r\n          padding: \"4px 8px\",\r\n          paddingLeft: 8 + depth * 14,\r\n          background: selected ? \"var(--fcms-sel-bg)\" : \"transparent\",\r\n          color: selected ? \"var(--fcms-sel-fg)\" : \"var(--fcms-row-fg)\",\r\n          font: \"inherit\",\r\n          boxShadow: overId === id ? \"inset 0 -2px 0 0 var(--fcms-accent, #8b5cf6)\" : \"none\",\r\n        }}\r\n      >\r\n        <span style={{ opacity: 0.6 }}>{node.type}<\/span> \u00b7 {id}\r\n      <\/button>\r\n      {childrenOf(doc, id).map((child) => (\r\n        <LayerRow key={child.id} doc={doc} id={child.id} depth={depth + 1} selection={selection} onSelect={onSelect} onMove={onMove} overId={overId} setOverId={setOverId} \/>\r\n      ))}\r\n    <\/Fragment>\r\n  );\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/LayersPanel.tsx"},{"path":"components\/fancy\/cms-editor\/NodeInspector.tsx","content":"import { type CSSProperties, type ReactElement, type ReactNode } from \"react\";\r\nimport { isBinding, type Binding, type LayoutMode, type Length, type Node, type StyleProps } from \"..\/document\/types\";\r\nimport type { NodeTransform } from \".\/EditablePage\";\r\n\r\nexport interface NodeInspectorProps {\r\n  node: Node;\r\n  \/** The currently-applied transform for this node (sampled from the active keyframe). *\/\r\n  transform: NodeTransform;\r\n  \/** Live measured size, used as the placeholder when width\/height are still \"auto\". *\/\r\n  measured: { w: number; h: number };\r\n  \/** Patch the node's props (commits through the op-spine \u2192 undoable). *\/\r\n  onProps: (patch: Record<string, unknown>) => void;\r\n  \/** Patch the node's base style. *\/\r\n  onStyle: (patch: Partial<StyleProps>) => void;\r\n  \/** Set how a container arranges its children (free \/ stack \/ grid). *\/\r\n  onLayout?: (layout: LayoutMode | undefined) => void;\r\n  \/** Commit a transform (position \/ size \/ opacity \/ scale \/ rotate) into the active keyframe. *\/\r\n  onTransform: (t: NodeTransform) => void;\r\n  onRemove: () => void;\r\n  onClose: () => void;\r\n}\r\n\r\nconst CONTAINER_TYPES = new Set([\"section\", \"frame\", \"stack\", \"grid\", \"shape\", \"card\", \"device\"]);\r\n\r\n\/**\r\n * The on-page element Inspector \u2014 a docked properties panel shown while a node\r\n * is selected in EditMode. Where the floating toolbar handles quick text tweaks,\r\n * this is the full surface: content, element-specific props (a button's label \/\r\n * link \/ variant, an image's src \/ alt, \u2026), exact position + size, and the style\r\n * box. Every control writes through the op-spine or the active keyframe, so edits\r\n * are undoable and animatable.\r\n *\/\r\nexport function NodeInspector({\r\n  node,\r\n  transform,\r\n  measured,\r\n  onProps,\r\n  onStyle,\r\n  onLayout,\r\n  onTransform,\r\n  onRemove,\r\n  onClose,\r\n}: NodeInspectorProps): ReactElement {\r\n  const base = node.style.base ?? {};\r\n  const setT = (patch: Partial<NodeTransform>) => onTransform({ ...transform, ...patch });\r\n\r\n  \/\/ Props rendered with a bespoke control; everything else falls through to the\r\n  \/\/ generic key\/value editor so any addon's props get controls for free.\r\n  const handled = new Set([\"content\", \"label\", \"html\"]);\r\n  const extraKeys = Object.keys(node.props).filter(\r\n    (k) => !handled.has(k) && ([\"string\", \"number\", \"boolean\"].includes(typeof node.props[k]) || isBinding(node.props[k])),\r\n  );\r\n\r\n  return (\r\n    <div style={panel} onPointerDown={(e) => e.stopPropagation()}>\r\n      <div style={headRow}>\r\n        <div style={{ display: \"flex\", flexDirection: \"column\", gap: 2, minWidth: 0 }}>\r\n          <strong style={{ fontSize: 12, textTransform: \"capitalize\" }}>{node.type}<\/strong>\r\n          <span style={{ fontSize: 10, opacity: 0.5, fontFamily: \"ui-monospace, monospace\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>\r\n            {node.id}\r\n          <\/span>\r\n        <\/div>\r\n        <span style={{ flex: 1 }} \/>\r\n        <button type=\"button\" style={{ ...iconBtn, color: \"#fca5a5\" }} title=\"Delete element\" onClick={onRemove}>\ud83d\uddd1<\/button>\r\n        <button type=\"button\" style={iconBtn} title=\"Close\" onClick={onClose}>\u2715<\/button>\r\n      <\/div>\r\n\r\n      <div style={body}>\r\n        {\"content\" in node.props || node.type === \"richtext\" ? (\r\n          <p style={{ fontSize: 11, color: \"var(--fg-3, #94a3b8)\", margin: \"0 0 12px\", lineHeight: 1.45, paddingBottom: 12, borderBottom: \"1px solid #1e293b\" }}>\r\n            \u270e Edit the text <strong>directly on the page<\/strong> \u2014 click in and type. This panel is for layout, style, and bindings.\r\n          <\/p>\r\n        ) : null}\r\n\r\n        {\"label\" in node.props ? (\r\n          <Group label=\"Label\">\r\n            <input style={input} value={String(node.props.label ?? \"\")} onChange={(e) => onProps({ label: e.target.value })} \/>\r\n          <\/Group>\r\n        ) : null}\r\n\r\n        {extraKeys.length ? (\r\n          <Group label=\"Properties\">\r\n            {extraKeys.map((k) => (\r\n              <PropField key={k} name={k} value={node.props[k]} onChange={(v) => onProps({ [k]: v })} \/>\r\n            ))}\r\n          <\/Group>\r\n        ) : null}\r\n\r\n        <Group label=\"Position & size\">\r\n          <Row>\r\n            <Num label=\"X\" value={transform.x} placeholder=\"0\" onChange={(v) => setT({ x: v })} \/>\r\n            <Num label=\"Y\" value={transform.y} placeholder=\"0\" onChange={(v) => setT({ y: v })} \/>\r\n          <\/Row>\r\n          <Row>\r\n            <Num label=\"W\" value={transform.w} placeholder={`${Math.round(measured.w)}`} onChange={(v) => setT({ w: v })} \/>\r\n            <Num label=\"H\" value={transform.h} placeholder={`${Math.round(measured.h)}`} onChange={(v) => setT({ h: v })} \/>\r\n          <\/Row>\r\n          <Row>\r\n            <Num label=\"Scale\" value={transform.scale} placeholder=\"1\" step={0.05} onChange={(v) => setT({ scale: v })} \/>\r\n            <Num label=\"Rotate\" value={transform.rotate} placeholder=\"0\" onChange={(v) => setT({ rotate: v })} \/>\r\n          <\/Row>\r\n          <Row>\r\n            <Num label=\"Opacity\" value={transform.opacity} placeholder=\"1\" min={0} max={1} step={0.05} onChange={(v) => setT({ opacity: v })} \/>\r\n            <button type=\"button\" style={{ ...miniBtn, alignSelf: \"end\" }} onClick={() => onTransform({})} title=\"Clear transform\">Reset<\/button>\r\n          <\/Row>\r\n        <\/Group>\r\n\r\n        {!CONTAINER_TYPES.has(node.type) ? (\r\n          <Group label=\"Text\">\r\n            <Row>\r\n              <Color label=\"Color\" value={base.color ?? \"#0f172a\"} onChange={(v) => onStyle({ color: v })} \/>\r\n              <Num label=\"Size\" value={base.fontSize?.value} placeholder=\"16\" onChange={(v) => onStyle({ fontSize: v == null ? undefined : len(v) })} \/>\r\n            <\/Row>\r\n            <Row>\r\n              <Sel\r\n                label=\"Weight\"\r\n                value={String(base.fontWeight ?? \"\")}\r\n                options={[[\"\", \"\u2014\"], [\"400\", \"Regular\"], [\"500\", \"Medium\"], [\"600\", \"Semibold\"], [\"700\", \"Bold\"], [\"800\", \"Heavy\"]]}\r\n                onChange={(v) => onStyle({ fontWeight: v ? Number(v) : undefined })}\r\n              \/>\r\n              <Sel\r\n                label=\"Align\"\r\n                value={base.textAlign ?? \"\"}\r\n                options={[[\"\", \"\u2014\"], [\"left\", \"Left\"], [\"center\", \"Center\"], [\"right\", \"Right\"], [\"justify\", \"Justify\"]]}\r\n                onChange={(v) => onStyle({ textAlign: (v || undefined) as StyleProps[\"textAlign\"] })}\r\n              \/>\r\n            <\/Row>\r\n          <\/Group>\r\n        ) : null}\r\n\r\n        <Group label=\"Box\">\r\n          <Row>\r\n            <Color label=\"Background\" value={hex(base.background)} onChange={(v) => onStyle({ background: v })} \/>\r\n            <Num label=\"Radius\" value={base.radius?.value} placeholder=\"0\" onChange={(v) => onStyle({ radius: v == null ? undefined : len(v) })} \/>\r\n          <\/Row>\r\n          <Row>\r\n            <Num label=\"Padding\" value={base.padding?.value} placeholder=\"0\" onChange={(v) => onStyle({ padding: v == null ? undefined : len(v) })} \/>\r\n            <Num label=\"Margin\" value={base.margin?.value} placeholder=\"0\" onChange={(v) => onStyle({ margin: v == null ? undefined : len(v) })} \/>\r\n          <\/Row>\r\n          <Row>\r\n            <Txt label=\"Border\" value={base.border ?? \"\"} placeholder=\"1px solid #e2e8f0\" onChange={(v) => onStyle({ border: v || undefined })} \/>\r\n          <\/Row>\r\n        <\/Group>\r\n\r\n        {CONTAINER_TYPES.has(node.type) ? (\r\n          <Group label=\"Layout\">\r\n            <Row>\r\n              <Sel\r\n                label=\"Mode\"\r\n                value={node.layout ?? \"free\"}\r\n                options={[[\"free\", \"Free\"], [\"stack\", \"Stack\"], [\"grid\", \"Grid\"]]}\r\n                onChange={(v) => onLayout?.(v as LayoutMode)}\r\n              \/>\r\n              <Num label=\"Gap\" value={base.gap?.value} placeholder=\"0\" onChange={(v) => onStyle({ gap: v == null ? undefined : len(v) })} \/>\r\n            <\/Row>\r\n            <Row>\r\n              <Sel\r\n                label=\"Align\"\r\n                value={base.align ?? \"\"}\r\n                options={[[\"\", \"\u2014\"], [\"start\", \"Start\"], [\"center\", \"Center\"], [\"end\", \"End\"], [\"stretch\", \"Stretch\"]]}\r\n                onChange={(v) => onStyle({ align: (v || undefined) as StyleProps[\"align\"] })}\r\n              \/>\r\n              <Sel\r\n                label=\"Justify\"\r\n                value={base.justify ?? \"\"}\r\n                options={[[\"\", \"\u2014\"], [\"start\", \"Start\"], [\"center\", \"Center\"], [\"end\", \"End\"], [\"between\", \"Between\"], [\"around\", \"Around\"]]}\r\n                onChange={(v) => onStyle({ justify: (v || undefined) as StyleProps[\"justify\"] })}\r\n              \/>\r\n            <\/Row>\r\n          <\/Group>\r\n        ) : null}\r\n      <\/div>\r\n    <\/div>\r\n  );\r\n}\r\n\r\n\/\/ \u2500\u2500 Small controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nfunction Group({ label, children }: { label: string; children: ReactNode }): ReactElement {\r\n  return (\r\n    <div style={{ display: \"flex\", flexDirection: \"column\", gap: 8, paddingBottom: 12, marginBottom: 12, borderBottom: \"1px solid #1e293b\" }}>\r\n      <span style={{ fontSize: 10, letterSpacing: \"0.06em\", textTransform: \"uppercase\", opacity: 0.5 }}>{label}<\/span>\r\n      {children}\r\n    <\/div>\r\n  );\r\n}\r\n\r\nfunction Row({ children }: { children: ReactNode }): ReactElement {\r\n  return <div style={{ display: \"flex\", gap: 8 }}>{children}<\/div>;\r\n}\r\n\r\nfunction PropField({ name, value, onChange }: { name: string; value: unknown; onChange: (v: unknown) => void }): ReactElement {\r\n  const bound = isBinding(value);\r\n  \/\/ A \ud83d\udd17 toggle flips any prop between a literal value and a data binding.\r\n  const link = (\r\n    <button\r\n      type=\"button\"\r\n      title={bound ? \"Unbind (use a literal value)\" : \"Bind to data (e.g. item.name)\"}\r\n      onClick={() => onChange(bound ? \"\" : ({ $bind: \"\" } satisfies Binding))}\r\n      style={{ background: \"none\", border: \"none\", cursor: \"pointer\", padding: 0, fontSize: 11, color: bound ? \"#a78bfa\" : \"#64748b\" }}\r\n    >\r\n      \ud83d\udd17\r\n    <\/button>\r\n  );\r\n\r\n  if (bound) {\r\n    return (\r\n      <label style={field}>\r\n        <span style={{ ...fieldLabel, display: \"flex\", justifyContent: \"space-between\", alignItems: \"center\" }}>\r\n          {name} {link}\r\n        <\/span>\r\n        <input style={{ ...input, borderColor: \"#7c3aed\" }} placeholder=\"data path \u00b7 e.g. item.name\" value={(value as Binding).$bind} onChange={(e) => onChange({ $bind: e.target.value } satisfies Binding)} \/>\r\n      <\/label>\r\n    );\r\n  }\r\n  if (typeof value === \"boolean\") {\r\n    return (\r\n      <label style={{ display: \"flex\", alignItems: \"center\", gap: 8, fontSize: 12 }}>\r\n        <input type=\"checkbox\" checked={value} onChange={(e) => onChange(e.target.checked)} \/>\r\n        <span style={{ textTransform: \"capitalize\", flex: 1 }}>{name}<\/span>\r\n        {link}\r\n      <\/label>\r\n    );\r\n  }\r\n  if (name === \"variant\") {\r\n    return <Sel label={name} value={String(value ?? \"\")} options={[[\"primary\", \"Primary\"], [\"ghost\", \"Ghost\"], [\"outline\", \"Outline\"]]} onChange={onChange} \/>;\r\n  }\r\n  const isNum = typeof value === \"number\";\r\n  return (\r\n    <label style={field}>\r\n      <span style={{ ...fieldLabel, display: \"flex\", justifyContent: \"space-between\", alignItems: \"center\" }}>\r\n        {name} {link}\r\n      <\/span>\r\n      <input\r\n        style={input}\r\n        type={isNum ? \"number\" : \"text\"}\r\n        value={String(value ?? \"\")}\r\n        onChange={(e) => onChange(isNum ? Number(e.target.value) : e.target.value)}\r\n      \/>\r\n    <\/label>\r\n  );\r\n}\r\n\r\nfunction Num({ label, value, placeholder, onChange, step, min, max }: { label: string; value?: number; placeholder?: string; onChange: (v: number | undefined) => void; step?: number; min?: number; max?: number }): ReactElement {\r\n  return (\r\n    <label style={field}>\r\n      <span style={fieldLabel}>{label}<\/span>\r\n      <input\r\n        style={input}\r\n        type=\"number\"\r\n        step={step}\r\n        min={min}\r\n        max={max}\r\n        value={value ?? \"\"}\r\n        placeholder={placeholder}\r\n        onChange={(e) => onChange(e.target.value === \"\" ? undefined : Number(e.target.value))}\r\n      \/>\r\n    <\/label>\r\n  );\r\n}\r\n\r\nfunction Txt({ label, value, placeholder, onChange }: { label: string; value: string; placeholder?: string; onChange: (v: string) => void }): ReactElement {\r\n  return (\r\n    <label style={field}>\r\n      <span style={fieldLabel}>{label}<\/span>\r\n      <input style={input} type=\"text\" value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} \/>\r\n    <\/label>\r\n  );\r\n}\r\n\r\nfunction Color({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }): ReactElement {\r\n  return (\r\n    <label style={field}>\r\n      <span style={fieldLabel}>{label}<\/span>\r\n      <input style={{ ...input, padding: 2, height: 30 }} type=\"color\" value={value} onChange={(e) => onChange(e.target.value)} \/>\r\n    <\/label>\r\n  );\r\n}\r\n\r\nfunction Sel({ label, value, options, onChange }: { label: string; value: string; options: Array<[string, string]>; onChange: (v: string) => void }): ReactElement {\r\n  return (\r\n    <label style={field}>\r\n      <span style={fieldLabel}>{label}<\/span>\r\n      <select style={input} value={value} onChange={(e) => onChange(e.target.value)}>\r\n        {options.map(([v, l]) => (\r\n          <option key={v} value={v}>{l}<\/option>\r\n        ))}\r\n      <\/select>\r\n    <\/label>\r\n  );\r\n}\r\n\r\nconst len = (value: number): Length => ({ value, unit: \"px\" });\r\nconst hex = (v: string | undefined): string => (v && \/^#[0-9a-fA-F]{6}$\/.test(v) ? v : \"#ffffff\");\r\n\r\n\/\/ \u2500\u2500 Styles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nconst Z = 2147483000;\r\nconst panel: CSSProperties = {\r\n  position: \"fixed\",\r\n  top: 60,\r\n  right: 12,\r\n  bottom: 96,\r\n  width: 264,\r\n  display: \"flex\",\r\n  flexDirection: \"column\",\r\n  background: \"#0b1220\",\r\n  color: \"#e2e8f0\",\r\n  border: \"1px solid #1e293b\",\r\n  borderRadius: 12,\r\n  boxShadow: \"0 18px 48px -16px rgba(0,0,0,0.6)\",\r\n  zIndex: Z + 2,\r\n  fontFamily: \"system-ui, sans-serif\",\r\n  overflow: \"hidden\",\r\n};\r\nconst headRow: CSSProperties = { display: \"flex\", alignItems: \"center\", gap: 8, padding: \"10px 12px\", borderBottom: \"1px solid #1e293b\" };\r\nconst body: CSSProperties = { padding: 12, overflowY: \"auto\" };\r\nconst field: CSSProperties = { display: \"flex\", flexDirection: \"column\", gap: 4, flex: 1, minWidth: 0 };\r\nconst fieldLabel: CSSProperties = { fontSize: 10, opacity: 0.6 };\r\nconst input: CSSProperties = {\r\n  width: \"100%\",\r\n  boxSizing: \"border-box\",\r\n  background: \"#0f172a\",\r\n  color: \"#e2e8f0\",\r\n  border: \"1px solid #334155\",\r\n  borderRadius: 6,\r\n  padding: \"5px 7px\",\r\n  font: \"inherit\",\r\n  fontSize: 12,\r\n};\r\nconst iconBtn: CSSProperties = { background: \"transparent\", border: \"none\", color: \"#e2e8f0\", cursor: \"pointer\", fontSize: 13, padding: 4, borderRadius: 6 };\r\nconst miniBtn: CSSProperties = { font: \"inherit\", fontSize: 11, color: \"#e2e8f0\", background: \"#334155\", border: \"1px solid #475569\", borderRadius: 6, padding: \"5px 10px\", cursor: \"pointer\" };\r\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/NodeInspector.tsx"},{"path":"components\/fancy\/cms-editor\/editorOps.ts","content":"\/**\r\n * Higher-level editor commands expressed as pure {@link PageOp} sequences over\r\n * the spine \u2014 duplicate, reorder, wrap, clipboard paste. Each returns ops the\r\n * caller threads through `ed.apply`, so every command stays undoable and total.\r\n *\/\r\nimport type { Node, NodeId, PageDoc } from \"..\/document\/types\";\r\nimport type { PageOp } from \"..\/document\/ops\";\r\nimport { childrenOf } from \"..\/document\/reduce\";\r\nimport { keyBetween } from \"..\/document\/fractional\";\r\n\r\nexport type NodeMap = Record<NodeId, Node>;\r\n\r\n\/** Children of `parent` within an arbitrary node map (snapshot-friendly), ordered. *\/\r\nfunction childrenOfMap(nodes: NodeMap, parent: NodeId | null): Node[] {\r\n  return Object.values(nodes)\r\n    .filter((n) => n.parent === parent)\r\n    .sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0));\r\n}\r\n\r\n\/** Parent-first BFS over a node map starting at `root`. *\/\r\nfunction subtreeBfs(nodes: NodeMap, root: NodeId): NodeId[] {\r\n  const out: NodeId[] = [];\r\n  const queue: NodeId[] = [root];\r\n  while (queue.length) {\r\n    const cur = queue.shift()!;\r\n    out.push(cur);\r\n    for (const child of childrenOfMap(nodes, cur)) queue.push(child.id);\r\n  }\r\n  return out;\r\n}\r\n\r\n\/**\r\n * Clone a subtree from `nodes` under `newParent`\/`newOrder`, remapping every id.\r\n * Returns parent-first insert ops + the new root id. Used by both Duplicate\r\n * (source = the live doc) and Paste (source = a detached clipboard snapshot).\r\n *\/\r\nexport function cloneFrom(\r\n  nodes: NodeMap,\r\n  rootId: NodeId,\r\n  newParent: NodeId | null,\r\n  newOrder: string,\r\n  seqBase: number,\r\n): { ops: PageOp[]; newRootId: NodeId | null } {\r\n  if (!nodes[rootId]) return { ops: [], newRootId: null };\r\n  const order = subtreeBfs(nodes, rootId);\r\n  const idMap: Record<NodeId, NodeId> = {};\r\n  order.forEach((old, i) => (idMap[old] = `${old}_c${seqBase}_${i}`));\r\n  const ops: PageOp[] = order.map((old) => {\r\n    const src = nodes[old]!;\r\n    const cloned: Node = JSON.parse(JSON.stringify(src));\r\n    cloned.id = idMap[old]!;\r\n    cloned.parent = old === rootId ? newParent : idMap[src.parent as NodeId]!;\r\n    if (old === rootId) cloned.order = newOrder;\r\n    return { t: \"insert_node\", node: cloned };\r\n  });\r\n  return { ops, newRootId: idMap[rootId]! };\r\n}\r\n\r\n\/** Duplicate a node (and its subtree) immediately after itself among its siblings. *\/\r\nexport function duplicateOps(doc: PageDoc, id: NodeId): { ops: PageOp[]; newRootId: NodeId | null } {\r\n  const node = doc.nodes[id];\r\n  if (!node) return { ops: [], newRootId: null };\r\n  const sibs = childrenOf(doc, node.parent);\r\n  const idx = sibs.findIndex((s) => s.id === id);\r\n  const newOrder = keyBetween(node.order, sibs[idx + 1]?.order ?? null);\r\n  const { ops, newRootId } = cloneFrom(doc.nodes, id, node.parent, newOrder, doc.seq);\r\n  if (node.parent === null && newRootId) {\r\n    const base = doc.sections;\r\n    const at = base.indexOf(id);\r\n    ops.push({ t: \"reorder_sections\", order: [...base.slice(0, at + 1), newRootId, ...base.slice(at + 1)] });\r\n  }\r\n  return { ops, newRootId };\r\n}\r\n\r\n\/** Paste a clipboard snapshot as a child of `target` (if a container) or its sibling. *\/\r\nexport function pasteOps(\r\n  doc: PageDoc,\r\n  clip: { rootId: NodeId; nodes: NodeMap },\r\n  target: NodeId | null,\r\n  containerTypes: Set<string>,\r\n): { ops: PageOp[]; newRootId: NodeId | null } {\r\n  const sel = target ? doc.nodes[target] : null;\r\n  const parent = sel ? (containerTypes.has(sel.type) ? sel.id : sel.parent) : (doc.sections[doc.sections.length - 1] ?? null);\r\n  const sibs = childrenOf(doc, parent);\r\n  const newOrder = keyBetween(sibs.length ? sibs[sibs.length - 1]!.order : null, null);\r\n  const { ops, newRootId } = cloneFrom(clip.nodes, clip.rootId, parent, newOrder, doc.seq);\r\n  if (parent === null && newRootId) {\r\n    \/\/ a top-level paste lands at the end of the section order, which is fine.\r\n  }\r\n  return { ops, newRootId };\r\n}\r\n\r\nexport type ReorderDir = \"up\" | \"down\" | \"front\" | \"back\";\r\n\r\n\/** Reorder a node among its siblings (or sections, for a top-level node). *\/\r\nexport function reorderOps(doc: PageDoc, id: NodeId, dir: ReorderDir): PageOp[] {\r\n  const node = doc.nodes[id];\r\n  if (!node) return [];\r\n\r\n  if (node.parent === null) {\r\n    const s = [...doc.sections];\r\n    const i = s.indexOf(id);\r\n    if (i < 0) return [];\r\n    s.splice(i, 1);\r\n    if (dir === \"front\") s.unshift(id);\r\n    else if (dir === \"back\") s.push(id);\r\n    else s.splice(Math.max(0, Math.min(s.length, dir === \"up\" ? i - 1 : i + 1)), 0, id);\r\n    return [{ t: \"reorder_sections\", order: s }];\r\n  }\r\n\r\n  const sibs = childrenOf(doc, node.parent);\r\n  const idx = sibs.findIndex((sb) => sb.id === id);\r\n  if (idx < 0) return [];\r\n  const last = sibs.length - 1;\r\n  let newOrder: string;\r\n  if (dir === \"up\") {\r\n    if (idx <= 0) return [];\r\n    newOrder = keyBetween(sibs[idx - 2]?.order ?? null, sibs[idx - 1]!.order);\r\n  } else if (dir === \"down\") {\r\n    if (idx >= last) return [];\r\n    newOrder = keyBetween(sibs[idx + 1]!.order, sibs[idx + 2]?.order ?? null);\r\n  } else if (dir === \"front\") {\r\n    if (idx >= last) return [];\r\n    newOrder = keyBetween(sibs[last]!.order, null);\r\n  } else {\r\n    if (idx <= 0) return [];\r\n    newOrder = keyBetween(null, sibs[0]!.order);\r\n  }\r\n  return [{ t: \"move_node\", id, parent: node.parent, order: newOrder }];\r\n}\r\n\r\n\/** Wrap a (non-top-level) node in a new padded Box (frame) that takes its slot. *\/\r\nexport function wrapInBoxOps(doc: PageDoc, id: NodeId): { ops: PageOp[]; newRootId: NodeId | null } {\r\n  const node = doc.nodes[id];\r\n  if (!node || node.parent === null) return { ops: [], newRootId: null };\r\n  const frameId = `box_${doc.seq}`;\r\n  return {\r\n    ops: [\r\n      { t: \"insert_node\", node: { id: frameId, type: \"frame\", parent: node.parent, order: node.order, props: {}, style: { base: { padding: { value: 16, unit: \"px\" } } } } },\r\n      { t: \"move_node\", id, parent: frameId, order: \"a\" },\r\n    ],\r\n    newRootId: frameId,\r\n  };\r\n}\r\n\r\n\/** Snapshot a node's subtree into a detached clipboard payload. *\/\r\nexport function snapshotSubtree(doc: PageDoc, id: NodeId): { rootId: NodeId; nodes: NodeMap } | null {\r\n  if (!doc.nodes[id]) return null;\r\n  const ids = subtreeBfs(doc.nodes, id);\r\n  const nodes: NodeMap = {};\r\n  for (const nid of ids) nodes[nid] = JSON.parse(JSON.stringify(doc.nodes[nid]));\r\n  return { rootId: id, nodes };\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/editorOps.ts"},{"path":"components\/fancy\/cms-editor\/index.ts","content":"\/**\r\n * @particle-academy\/fancy-cms-ui\/editor \u2014 the WYSIWYG editor.\r\n *\r\n * Layers \u00b7 canvas \u00b7 inspector over the op-spine, with snapshot undo\/redo. Import\r\n * the lightweight renderer from `.\/react` for published pages; import the editor\r\n * here for authoring.\r\n *\/\r\nexport { Editor, type EditorProps } from \".\/Editor\";\r\nexport { EditablePage, type EditablePageProps } from \".\/EditablePage\";\r\nexport { Canvas, type CanvasProps } from \".\/Canvas\";\r\nexport { LayersPanel, type LayersPanelProps } from \".\/LayersPanel\";\r\nexport { Inspector, type InspectorProps } from \".\/Inspector\";\r\nexport { NodeInspector, type NodeInspectorProps } from \".\/NodeInspector\";\r\nexport { useEditor, type EditorApi } from \".\/useEditor\";\r\nexport { editorReduce, initEditor, type EditorAction, type EditorState } from \".\/state\";\r\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/index.ts"},{"path":"components\/fancy\/cms-editor\/insert.ts","content":"import type { Json, LayoutMode, NodeId, PageDoc, StyleProps } from \"..\/document\/types\";\nimport type { PageOp } from \"..\/document\/ops\";\nimport { childrenOf } from \"..\/document\/reduce\";\nimport { keyBetween } from \"..\/document\/fractional\";\n\n\/**\n * Element insertion \u2014 shared by `EditablePage` (inline editing) and `Editor`\n * (the standalone WYSIWYG shell).\n *\n * This used to live entirely inside EditablePage, which meant the embeddable\n * `Editor` had no way to add an element at all: starting from `emptyDoc()` gave\n * a blank canvas with nothing to select and no control to insert anything, so\n * a host embedding `Editor` could not author a page from scratch (#3).\n * EditablePage had the menu but no `onChange` to hand the document back \u2014\n * each surface had half of what an embedding host needs.\n *\/\n\n\/** Node types that accept children, so an insert lands *inside* them. *\/\nexport const CONTAINER_TYPES = new Set([\n  \"section\",\n  \"frame\",\n  \"stack\",\n  \"grid\",\n  \"shape\",\n  \"card\",\n  \"device\",\n  \"repeater\",\n]);\n\nexport type AddKind =\n  | \"text\"\n  | \"heading\"\n  | \"button\"\n  | \"link\"\n  | \"image\"\n  | \"box\"\n  | \"stack\"\n  | \"grid\"\n  | \"card\"\n  | \"callout\"\n  | \"divider\"\n  | \"code\"\n  | \"richtext\"\n  | \"repeater\";\n\nexport const ADD_DEFAULTS: Record<\n  AddKind,\n  { type: string; props: Record<string, Json>; style: StyleProps; layout?: LayoutMode }\n> = {\n  text: { type: \"text\", props: { content: \"New text\" }, style: { color: \"inherit\" } },\n  heading: { type: \"heading\", props: { content: \"New heading\" }, style: { fontSize: { value: 28, unit: \"px\" }, fontWeight: 700 } },\n  button: { type: \"button\", props: { label: \"Button\", href: \"#\", variant: \"primary\" }, style: {} },\n  link: { type: \"link\", props: { content: \"link text\", href: \"#\" }, style: { color: \"#7c3aed\" } },\n  image: { type: \"image\", props: { src: \"\", alt: \"\" }, style: {} },\n  box: { type: \"frame\", props: {}, style: { padding: { value: 16, unit: \"px\" } } },\n  stack: { type: \"stack\", props: {}, style: { gap: { value: 12, unit: \"px\" } }, layout: \"stack\" },\n  grid: { type: \"grid\", props: {}, style: { gap: { value: 12, unit: \"px\" } }, layout: \"grid\" },\n  card: { type: \"card\", props: {}, style: { padding: { value: 16, unit: \"px\" }, radius: { value: 12, unit: \"px\" }, border: \"1px solid #e2e8f0\" } },\n  callout: { type: \"callout\", props: { content: \"Heads up \u2014 this is a callout.\", variant: \"info\" }, style: {} },\n  divider: { type: \"divider\", props: {}, style: {} },\n  code: { type: \"code\", props: { content: \"npm install @particle-academy\/react-fancy\", lang: \"bash\" }, style: {} },\n  richtext: { type: \"richtext\", props: { html: \"<p>Rich <strong>text<\/strong> with <em>inline<\/em> formatting.<\/p>\" }, style: {} },\n  repeater: { type: \"repeater\", props: { items: \"\" }, style: { gap: { value: 12, unit: \"px\" } }, layout: \"stack\" },\n};\n\nexport const ADD_MENU: Array<{ kind: AddKind; label: string }> = [\n  { kind: \"heading\", label: \"Heading\" },\n  { kind: \"text\", label: \"Text\" },\n  { kind: \"button\", label: \"Button\" },\n  { kind: \"link\", label: \"Link\" },\n  { kind: \"image\", label: \"Image\" },\n  { kind: \"card\", label: \"Card\" },\n  { kind: \"callout\", label: \"Callout\" },\n  { kind: \"stack\", label: \"Stack\" },\n  { kind: \"grid\", label: \"Grid\" },\n  { kind: \"box\", label: \"Box\" },\n  { kind: \"divider\", label: \"Divider\" },\n  { kind: \"code\", label: \"Code\" },\n  { kind: \"richtext\", label: \"Rich text\" },\n  { kind: \"repeater\", label: \"Repeater\" },\n];\n\n\/**\n * Where a new node of `kind` should land, given the current selection.\n *\n * Into the selected container; else alongside the selected node; else the last\n * section \u2014 and when the page has no sections at all, at the top level, which\n * is what makes authoring from `emptyDoc()` possible.\n *\/\nexport function resolveInsertParent(doc: PageDoc, selectedId: NodeId | null): NodeId | null {\n  const selected = selectedId ? doc.nodes[selectedId] : null;\n  if (selected) {\n    return CONTAINER_TYPES.has(selected.type) ? selected.id : selected.parent;\n  }\n  return doc.sections[doc.sections.length - 1] ?? null;\n}\n\n\/**\n * Build the `insert_node` op for adding `kind` to `doc`.\n *\n * Pure \u2014 returns the op plus the new id so the caller can select it. Callers\n * apply the op through their own editor state.\n *\/\nexport function buildInsertOp(\n  doc: PageDoc,\n  kind: AddKind,\n  selectedId: NodeId | null,\n  \/** Explicit target, overriding selection \u2014 used by drag-and-drop. *\/\n  targetId?: NodeId | null,\n): { op: PageOp; id: NodeId } {\n  const parent =\n    targetId !== undefined\n      ? targetId && doc.nodes[targetId]\n        ? CONTAINER_TYPES.has(doc.nodes[targetId]!.type)\n          ? targetId\n          : doc.nodes[targetId]!.parent\n        : null\n      : resolveInsertParent(doc, selectedId);\n\n  const siblings = childrenOf(doc, parent);\n  const order = keyBetween(siblings.length ? siblings[siblings.length - 1]!.order : null, null);\n  const id = `n${doc.seq + 1}-${Math.floor(performance.now())}`;\n  const def = ADD_DEFAULTS[kind];\n\n  return {\n    id,\n    op: {\n      t: \"insert_node\",\n      node: {\n        id,\n        type: def.type,\n        parent,\n        order,\n        layout: def.layout,\n        props: { ...def.props },\n        style: { base: { ...def.style } },\n      },\n    } as PageOp,\n  };\n}\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/insert.ts"},{"path":"components\/fancy\/cms-editor\/state.ts","content":"\/**\r\n * Editor state engine \u2014 pure and framework-agnostic so it's unit-testable.\r\n * `useEditor` wraps this with `useReducer`. Undo\/redo uses document snapshots\r\n * (simple + robust); the op-level `invert` in the spine is for collab\/op-stream\r\n * undo.\r\n *\/\r\nimport type { PageDoc } from \"..\/document\/types\";\r\nimport type { PageOp } from \"..\/document\/ops\";\r\nimport { reduce } from \"..\/document\/reduce\";\r\n\r\nexport interface EditorState {\r\n  doc: PageDoc;\r\n  past: PageDoc[];\r\n  future: PageDoc[];\r\n  selection: string | null;\r\n}\r\n\r\nexport type EditorAction =\r\n  | { type: \"apply\"; op: PageOp }\r\n  | { type: \"undo\" }\r\n  | { type: \"redo\" }\r\n  | { type: \"select\"; id: string | null }\r\n  | { type: \"replace\"; doc: PageDoc };\r\n\r\nconst HISTORY_LIMIT = 100;\r\n\r\nexport function initEditor(doc: PageDoc): EditorState {\r\n  return { doc, past: [], future: [], selection: null };\r\n}\r\n\r\nexport function editorReduce(state: EditorState, action: EditorAction): EditorState {\r\n  switch (action.type) {\r\n    case \"apply\": {\r\n      const next = reduce(state.doc, action.op);\r\n      if (next === state.doc) return state; \/\/ invalid op \u2192 no-op, no history entry\r\n      return {\r\n        ...state,\r\n        doc: next,\r\n        past: [...state.past, state.doc].slice(-HISTORY_LIMIT),\r\n        future: [],\r\n      };\r\n    }\r\n    case \"undo\": {\r\n      if (state.past.length === 0) return state;\r\n      const prev = state.past[state.past.length - 1]!;\r\n      return {\r\n        ...state,\r\n        doc: prev,\r\n        past: state.past.slice(0, -1),\r\n        future: [state.doc, ...state.future],\r\n      };\r\n    }\r\n    case \"redo\": {\r\n      if (state.future.length === 0) return state;\r\n      const next = state.future[0]!;\r\n      return {\r\n        ...state,\r\n        doc: next,\r\n        past: [...state.past, state.doc],\r\n        future: state.future.slice(1),\r\n      };\r\n    }\r\n    case \"select\":\r\n      return state.selection === action.id ? state : { ...state, selection: action.id };\r\n    case \"replace\":\r\n      return state.doc === action.doc ? state : { ...state, doc: action.doc, future: [] };\r\n    default:\r\n      return state;\r\n  }\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/state.ts"},{"path":"components\/fancy\/cms-editor\/useEditor.ts","content":"import { useCallback, useMemo, useReducer } from \"react\";\r\nimport type { PageDoc } from \"..\/document\/types\";\r\nimport type { PageOp } from \"..\/document\/ops\";\r\nimport { editorReduce, initEditor, type EditorState } from \".\/state\";\r\n\r\nexport interface EditorApi {\r\n  state: EditorState;\r\n  \/** Dispatch a mutation through the op-spine (records an undo snapshot). *\/\r\n  apply: (op: PageOp) => void;\r\n  undo: () => void;\r\n  redo: () => void;\r\n  select: (id: string | null) => void;\r\n  \/** Replace the document wholesale (e.g. a controlled `value` change). *\/\r\n  replace: (doc: PageDoc) => void;\r\n  canUndo: boolean;\r\n  canRedo: boolean;\r\n}\r\n\r\nexport function useEditor(initialDoc: PageDoc): EditorApi {\r\n  const [state, dispatch] = useReducer(editorReduce, initialDoc, initEditor);\r\n  const apply = useCallback((op: PageOp) => dispatch({ type: \"apply\", op }), []);\r\n  const undo = useCallback(() => dispatch({ type: \"undo\" }), []);\r\n  const redo = useCallback(() => dispatch({ type: \"redo\" }), []);\r\n  const select = useCallback((id: string | null) => dispatch({ type: \"select\", id }), []);\r\n  const replace = useCallback((doc: PageDoc) => dispatch({ type: \"replace\", doc }), []);\r\n\r\n  return useMemo(\r\n    () => ({\r\n      state,\r\n      apply,\r\n      undo,\r\n      redo,\r\n      select,\r\n      replace,\r\n      canUndo: state.past.length > 0,\r\n      canRedo: state.future.length > 0,\r\n    }),\r\n    [state, apply, undo, redo, select, replace],\r\n  );\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/useEditor.ts"},{"path":"components\/fancy\/cms-editor\/useLatestRef.ts","content":"import { useEffect, useRef, type RefObject } from \"react\";\n\n\/**\n * Hold the latest `value` in a ref, synced in an effect after every render.\n *\n * Notify effects read `ref.current` instead of closing over a callback prop, so\n * their dependency arrays stay data-only (`doc`, `selection`, \u2026). A consumer\n * passing an inline closure \u2014 new identity every render, the most natural\n * usage \u2014 can then never re-trigger the notify: without this, notify \u2192 consumer\n * setState \u2192 render \u2192 new closure identity \u2192 effect re-fires \u2192 notify \u2192 \u2026 loops\n * unbounded (#1). Standard uncontrolled-with-notify hygiene.\n *\/\nexport function useLatestRef<T>(value: T): RefObject<T> {\n  const ref = useRef(value);\n  useEffect(() => {\n    ref.current = value;\n  });\n  return ref;\n}\n","type":"registry:ui","target":"components\/fancy\/cms-editor\/useLatestRef.ts"}]}