From dd8b99be061a41e9b2f1ed17f5efb70edacec728 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 12:28:53 -0700 Subject: [PATCH 1/2] feat(site-builder): reusable global sections with per-page overrides Make a top-level section global (context menu), place instances from the Section library's new Global tab, edit the master once in its own zone ("Editing global section" banner, Done - update N instances) and every instance on every page/header/footer is rebuilt from it. Instances are real inlined subtrees (custom.global on the root, custom.gkey on every node), so export and published output are unchanged static HTML. - utils/global-sections.ts: pure model (createMaster, instantiate, syncInstances, collectOverridesFromInstance, applyOverrides, detach, findInstanceRoot, master zone round trip). Unchanged input returns the identical string; sync keeps the instance root id and the instance's own inner ids per gkey. - PageContext: __global__: zone (editGlobal / finishGlobal); leaving it commits the master (rev + 1 only when it changed), syncs every stored slot and applies the sync to the returned-to canvas as ONE undo step. - GlobalSectionsContext: makeGlobal / insertInstance / detach / setOverride / resetOverride / remove (refused while used), one undo step each. - Inline text edits inside an instance record an override (text + html) via useGlobalOverrideCommit; GuidedStyles shows a Global section panel (Edit master, Detach, content override fields) instead of style panels. - RenderNode: Global badge, dashed outline, lock icon on inner nodes. - buildSavePayload: a master zone on the canvas is never saved into a page. Refs #57 Co-Authored-By: Claude Opus 5.5 --- craft/src/App.tsx | 3 + .../src/components/basic/useInlineEditable.ts | 6 +- craft/src/editor/Canvas.tsx | 30 +- craft/src/editor/RenderNode.tsx | 39 +- craft/src/hooks/useWhpApi.ts | 5 +- craft/src/panels/context-menu/ContextMenu.tsx | 93 +++- craft/src/panels/right/GuidedStyles.tsx | 95 +++- .../panels/sections/SectionLibraryModal.tsx | 80 +++- craft/src/state/GlobalInstanceContext.tsx | 51 +++ .../src/state/GlobalSectionsContext.test.tsx | 430 ++++++++++++++++++ craft/src/state/GlobalSectionsContext.tsx | 243 ++++++++++ craft/src/state/PageContext.tsx | 163 ++++++- craft/src/styles/editor.css | 45 ++ craft/src/utils/global-sections.test.ts | 253 +++++++++++ craft/src/utils/global-sections.ts | 391 ++++++++++++++++ 15 files changed, 1898 insertions(+), 29 deletions(-) create mode 100644 craft/src/state/GlobalInstanceContext.tsx create mode 100644 craft/src/state/GlobalSectionsContext.test.tsx create mode 100644 craft/src/state/GlobalSectionsContext.tsx create mode 100644 craft/src/utils/global-sections.test.ts create mode 100644 craft/src/utils/global-sections.ts diff --git a/craft/src/App.tsx b/craft/src/App.tsx index 31c8b21..eb05005 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -12,6 +12,7 @@ import { NoticeProvider } from './state/NoticeContext'; import { ProjectExtrasProvider } from './state/ProjectExtrasContext'; import { CanvasOverlays } from './editor/CanvasOverlays'; import { PageProvider } from './state/PageContext'; +import { GlobalSectionsProvider } from './state/GlobalSectionsContext'; import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext'; import { SitesmithModal } from './panels/sitesmith/SitesmithModal'; @@ -68,11 +69,13 @@ export const App: React.FC = ({ whpConfig }) => { + + diff --git a/craft/src/components/basic/useInlineEditable.ts b/craft/src/components/basic/useInlineEditable.ts index 7d4c22a..822870b 100644 --- a/craft/src/components/basic/useInlineEditable.ts +++ b/craft/src/components/basic/useInlineEditable.ts @@ -7,6 +7,7 @@ import { resolveInlineHtml, markExternalLinks, } from '../../utils/inline-html'; +import { useGlobalOverrideCommit } from '../../state/GlobalInstanceContext'; interface InlineTextProps { text?: string; @@ -35,6 +36,7 @@ export function useInlineEditable( ) { const elRef = useRef(null); const pendingRef = useRef(false); + const commitProps = useGlobalOverrideCommit(setProp); // #57: inside a global instance -> override const commit = useCallback(() => { if (!pendingRef.current) return; @@ -45,8 +47,8 @@ export function useInlineEditable( const clean = sanitizeInlineHtml(el.innerHTML); const nextText = inlineHtmlToText(clean); const nextHtml = isPlainInlineHtml(clean) ? '' : clean; - setProp((p) => { p.html = nextHtml; p.text = nextText; }); - }, [setProp]); + commitProps((p) => { p.html = nextHtml; p.text = nextText; }); + }, [commitProps]); // Deselect without a blur (e.g. Escape, programmatic selection change). useEffect(() => { diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index 19ac7e4..bb67cbc 100644 --- a/craft/src/editor/Canvas.tsx +++ b/craft/src/editor/Canvas.tsx @@ -10,6 +10,7 @@ import { useMobileChrome } from '../state/MobileChromeContext'; import { useSitesmithModal } from '../state/SitesmithContext'; import { insertBlock } from '../panels/left/blocks-registry'; import { openSectionLibrary } from '../panels/sections/SectionLibraryModal'; +import { useGlobalSections } from '../state/GlobalSectionsContext'; interface CanvasProps { /** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */ @@ -156,9 +157,16 @@ export const EmptyCanvasHint: React.FC = () => { export const Canvas: React.FC = ({ showGuides }) => { const { device } = useDevice(); const width = DEVICE_WIDTHS[device]; - const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages(); + const { isEditingHeader, isEditingFooter, isEditingGlobal, globalEditingId, headerPage, footerPage, pages } = usePages(); + const globals = useGlobalSections(); - const isEditingRegularPage = !isEditingHeader && !isEditingFooter; + const isEditingRegularPage = !isEditingHeader && !isEditingFooter && !isEditingGlobal; + // #57: instances the master feeds (stored pages; the canvas holds the master). + const instanceCount = useMemo( + () => (globalEditingId ? globals.usage()[globalEditingId] ?? 0 : 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [globalEditingId, pages, headerPage, footerPage], + ); // Fast-follow item 4: while `MobileSelectionToolbar` is up (fixed, ~53px // tall, just above the tab bar), it covers whatever content was at the @@ -238,6 +246,24 @@ export const Canvas: React.FC = ({ showGuides }) => { )} + {isEditingGlobal && globalEditingId && ( +
+ + + Editing global section: {globals.library[globalEditingId]?.name ?? 'Global section'} + {' '}-- changes apply everywhere it is used + + +
+ )} + {isEditingRegularPage && ( )} diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx index 8d15a55..b5b318f 100644 --- a/craft/src/editor/RenderNode.tsx +++ b/craft/src/editor/RenderNode.tsx @@ -1,9 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { NodeElement, useEditor, useNode } from '@craftjs/core'; import { useDevice } from '../state/DeviceContext'; import { hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style'; import type { DeviceMode } from '../types'; +import { GlobalInstanceContext } from '../state/GlobalInstanceContext'; interface RenderNodeProps { render: React.ReactElement; @@ -44,6 +45,12 @@ interface RenderNodeProps { * - `data-hide-desktop|tablet|mobile` / `data-craft-selected` (#37): mirror * the node's hide flags and selection for the device-emulation CSS. * + * - `data-wsb-global` / `data-wsb-global-inner` (#57): global-section + * instance root (dashed accent outline) / a node inside one (locked + * styling; content edits become overrides). Every node is wrapped in a + * `GlobalInstanceContext` provider (always, so the element type never + * changes) carrying the nearest instance root id. + * * Per-breakpoint styles (#45/#37): when the device is not desktop and the * node carries `styleTablet`/`styleMobile`, this is the ONE place the * effective style is resolved (`resolveStyleForDevice`), so no component @@ -89,7 +96,8 @@ const ResolvedRender: React.FC<{ device: DeviceMode }> = ({ device }) => { export const RenderNode: React.FC = ({ render }) => { const { actions } = useEditor(); const { device } = useDevice(); - const { id, isSelected, dom, name, parent, isCanvas, isHovered, isComponent, tabletOverride, mobileOverride, hideDesktop, hideTablet, hideMobile } = useNode((node) => { + const parentInstance = useContext(GlobalInstanceContext); + const { id, isSelected, dom, name, parent, isCanvas, isHovered, isComponent, tabletOverride, mobileOverride, hideDesktop, hideTablet, hideMobile, isInstanceRoot } = useNode((node) => { // Keep the collector cheap (booleans only); ResolvedRender collects the // node's type/props/children itself, as Craft's DefaultRender does. const props = node.data.props as ResponsiveProps | undefined; @@ -107,8 +115,11 @@ export const RenderNode: React.FC = ({ render }) => { hideDesktop: !!node.data.props?.hideOnDesktop, hideTablet: !!node.data.props?.hideOnTablet, hideMobile: !!node.data.props?.hideOnMobile, + isInstanceRoot: !!(node.data.custom?.global as { libraryId?: string } | undefined)?.libraryId, }; }); + const instanceRoot = isInstanceRoot ? id : parentInstance; + const isInstanceInner = !isInstanceRoot && !!parentInstance; // Overrides that apply on the current device (mobile inherits tablet's). const activeOverride = device === 'tablet' ? tabletOverride : device === 'mobile' ? mobileOverride : false; @@ -180,18 +191,38 @@ export const RenderNode: React.FC = ({ render }) => { } }, [dom, activeOverride]); + useEffect(() => { + if (!dom) return; + if (isInstanceRoot) dom.setAttribute('data-wsb-global', ''); + else dom.removeAttribute('data-wsb-global'); + if (isInstanceInner) dom.setAttribute('data-wsb-global-inner', ''); + else dom.removeAttribute('data-wsb-global-inner'); + }, [dom, isInstanceRoot, isInstanceInner]); + // String-typed (plain DOM) nodes go through Craft's own SimpleElement // connector wrapper, which is not exported -- leave those untouched. Every // resolver component is a React component (see ResolvedRender above). - const output: React.ReactElement = isComponent ? : render; + const output: React.ReactElement = ( + + {isComponent ? : render} + + ); - if (!active) return <>{output}; + if (!active) return output; return ( <> {output} {createPortal(
+ {isInstanceRoot && ( + + Global + + )} + {isInstanceInner && ( + + )} {name} {parent && ( +
+ ); +}; interface ContextMenuProps { visible: boolean; @@ -54,7 +87,9 @@ export const ContextMenu: React.FC = ({ const { notify } = useNotices(); const extras = useProjectExtras(); const { device } = useDevice(); - const [popover, setPopover] = useState<{ mode: 'save' | 'apply'; x: number; y: number } | null>(null); + const [popover, setPopover] = useState<{ mode: 'save' | 'apply' | 'global'; x: number; y: number } | null>(null); + const globals = useGlobalSections(); + const { isEditingGlobal } = usePages(); useEffect(() => { if (!visible) setPopover(null); }, [visible]); // Close on click outside @@ -179,7 +214,7 @@ export const ContextMenu: React.FC = ({ onClose(); }, [nodeId, actions, query, device, notify, onClose]); - const openPopover = (mode: 'save' | 'apply') => { + const openPopover = (mode: 'save' | 'apply' | 'global') => { // Beside the menu so the menu itself stays readable. setPopover({ mode, x: Math.min(x, window.innerWidth - 200) + 190, y: Math.min(y, window.innerHeight - 300) }); }; @@ -230,6 +265,31 @@ export const ContextMenu: React.FC = ({ onClose(); }, [nodeId, query, openSitesmith, onClose]); + // #57 global sections + const instance = nodeId && nodeId !== 'ROOT' ? globals.instanceInfo(nodeId) : null; + const canMakeGlobal = !!nodeId && !instance && !isEditingGlobal && isTopLevelSection(query, nodeId); + const defaultGlobalName = (() => { + if (!canMakeGlobal || !nodeId) return ''; + try { + const d = query.node(nodeId).get().data; + return (d.custom?.label as string) || (d.props?.aiName as string) || d.displayName || 'Section'; + } catch { return 'Section'; } + })(); + const makeGlobal = (name: string) => { + if (nodeId && globals.makeGlobal(nodeId, name)) { + notify({ kind: 'success', message: `"${name}" is now a global section` }); + } + onClose(); + }; + const editMaster = () => { + if (instance) globals.editMaster(instance.libraryId); + onClose(); + }; + const detachGlobal = () => { + if (nodeId && globals.detach(nodeId)) notify({ kind: 'success', message: 'Detached: this copy no longer follows the global section' }); + onClose(); + }; + const deleteNode = useCallback(() => { nodeActions.deleteNode(); onClose(); @@ -278,13 +338,13 @@ export const ContextMenu: React.FC = ({ label: 'Paste style', icon: 'magic', action: () => pasteStyle('replace'), - disabled: isRoot || !isCompatible(getStyleClipboard(), targetType), + disabled: isRoot || !!instance || !isCompatible(getStyleClipboard(), targetType), }, { label: 'Paste style (merge)', icon: 'object-group', action: () => pasteStyle('merge'), - disabled: isRoot || !isCompatible(getStyleClipboard(), targetType), + disabled: isRoot || !!instance || !isCompatible(getStyleClipboard(), targetType), dividerAfter: true, }, { @@ -297,9 +357,24 @@ export const ContextMenu: React.FC = ({ label: 'Apply preset \u25B8', icon: 'bookmark-o', action: () => openPopover('apply'), - disabled: isRoot, + // #57: instance styling comes from the master. + disabled: isRoot || !!instance, dividerAfter: true, }, + ...(instance + ? [ + { label: 'Edit master', icon: 'globe', action: editMaster }, + { label: 'Detach from global', icon: 'chain-broken', action: detachGlobal, dividerAfter: true }, + ] + : [ + { + label: 'Make global section\u2026', + icon: 'globe', + action: () => openPopover('global'), + disabled: !canMakeGlobal, + dividerAfter: true, + }, + ]), { label: 'Move Up', icon: 'arrow-up', @@ -421,6 +496,12 @@ export const ContextMenu: React.FC = ({ {popover && popover.mode === 'save' && ( setPopover(null)} onSave={saveAsPreset} /> )} + {popover && popover.mode === 'global' && ( + setPopover(null)} onSave={makeGlobal} + /> + )} {popover && popover.mode === 'apply' && ( setPopover(null)} diff --git a/craft/src/panels/right/GuidedStyles.tsx b/craft/src/panels/right/GuidedStyles.tsx index 39bcec3..80b890f 100644 --- a/craft/src/panels/right/GuidedStyles.tsx +++ b/craft/src/panels/right/GuidedStyles.tsx @@ -1,4 +1,4 @@ -import React, { useLayoutEffect } from 'react'; +import React, { useEffect, useLayoutEffect, useState } from 'react'; import { useEditor } from '@craftjs/core'; import { SiteDesignPanel } from './SiteDesignPanel'; import { useSitesmithModal } from '../../state/SitesmithContext'; @@ -22,6 +22,84 @@ import { import { DeviceBanner } from './styles/responsive-panel'; import { setBulkSelection } from './styles/shared'; import { CORE_COMMANDS } from '../../editor/core-commands'; +import { useGlobalSections } from '../../state/GlobalSectionsContext'; +import type { OverridePatch } from '../../utils/global-sections'; + +const OVERRIDE_FIELDS: Array<{ key: 'text' | 'src' | 'alt' | 'href'; label: string }> = [ + { key: 'text', label: 'Text' }, + { key: 'src', label: 'Image URL' }, + { key: 'alt', label: 'Alt text' }, + { key: 'href', label: 'Link' }, +]; + +/** One override input: local draft, committed on blur/Enter (one undo step per edit). */ +const OverrideField: React.FC<{ label: string; value: string; onCommit: (v: string) => void }> = ({ label, value, onCommit }) => { + const [draft, setDraft] = useState(value); + useEffect(() => setDraft(value), [value]); + const commit = () => { if (draft !== value) onCommit(draft); }; + return ( + + ); +}; + +/** + * #57: replaces the style panels for a node inside a global-section instance. + * Design is master-only; content (text / image / link) can be overridden on + * this page, and the master is one click away. + */ +export const GlobalInstancePanel: React.FC<{ selectedId: string; nodeProps: Record }> = ({ selectedId, nodeProps }) => { + const globals = useGlobalSections(); + const info = globals.instanceInfo(selectedId); + if (!info) return null; + const fields = OVERRIDE_FIELDS.filter((f) => typeof nodeProps[f.key] === 'string'); + const commit = (key: string, v: string) => { + // Plain text from the panel replaces any inline formatting. + const patch: OverridePatch = key === 'text' ? { text: v, html: '' } : { [key]: v }; + globals.setOverride(selectedId, patch); + }; + return ( +
+
+ + Global section: {info.name} +
+
+ + +
+

Design comes from the master. Text, images and links can be overridden on this page.

+ {info.gkey && fields.length > 0 && ( +
+
+ Override {info.overridden.length > 0 && overridden} +
+ {fields.map((f) => ( + commit(f.key, v)} /> + ))} + {info.overridden.length > 0 && ( + + )} +
+ )} +
+ ); +}; const MULTI_ACTIONS = [ { id: 'edit.delete', label: 'Delete', icon: 'fa-trash-o' }, @@ -76,7 +154,7 @@ export const GuidedStyles: React.FC<{ const { open: openSitesmith } = useSitesmithModal(); const { query } = useEditor(); - const { selected, selectedType, nodeProps, selectedIds, mixedTypes } = useEditor((state) => { + const { selected, selectedType, nodeProps, selectedIds, mixedTypes, inInstance } = useEditor((state) => { const all = state.events.selected ? Array.from(state.events.selected) : []; const currentNodeId = all[0]; const typeOf = (id: string) => state.nodes[id]?.data.displayName || state.nodes[id]?.data.name || null; @@ -92,7 +170,17 @@ export const GuidedStyles: React.FC<{ } } + // #57: inside a global-section instance (nearest ancestor-or-self root)? + let inInstance = false; + for (let id: string | null = currentNodeId ?? null, guard = 0; id && guard < 500; guard++) { + const nd: any = state.nodes[id]; + if (!nd) break; + if (nd.data.custom?.global?.libraryId) { inInstance = true; break; } + id = nd.data.parent; + } + return { + inInstance, selected: currentNodeId || null, selectedType, nodeProps, @@ -202,6 +290,8 @@ export const GuidedStyles: React.FC<{ Ask Sitesmith + {inInstance && } + {!inInstance && <> {/* TEXT */} @@ -250,6 +340,7 @@ export const GuidedStyles: React.FC<{ {!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && ( )} + } ); }; diff --git a/craft/src/panels/sections/SectionLibraryModal.tsx b/craft/src/panels/sections/SectionLibraryModal.tsx index 21b2dd2..af0cc1c 100644 --- a/craft/src/panels/sections/SectionLibraryModal.tsx +++ b/craft/src/panels/sections/SectionLibraryModal.tsx @@ -10,6 +10,10 @@ import { insertSection, isTopLevelSection, nodeTreeToSerializedTree, searchSections, useSections, } from '../../sections/catalog'; import { buildSectionPreviewDoc } from '../../sections/thumbnails'; +import { exportToHtml } from '../../utils/html-export'; +import { masterZoneState, type GlobalMaster } from '../../utils/global-sections'; +import { useGlobalSections } from '../../state/GlobalSectionsContext'; +import { usePages } from '../../state/PageContext'; // ---- open state: module-level so the command, the empty-state button and the // Blocks panel button can open the modal without a provider ---- @@ -28,7 +32,7 @@ export function useSectionLibraryState(): LibraryState { } /** Renders its iframe only once scrolled into view (60 live iframes at once is heavy). */ -const LazyPreview: React.FC<{ def: SectionDef; title: string }> = ({ def, title }) => { +const LazyPreview: React.FC<{ def?: SectionDef; master?: GlobalMaster; title: string }> = ({ def, master, title }) => { const { design } = useSiteDesign(); const ref = useRef(null); const [seen, setSeen] = useState(typeof IntersectionObserver === 'undefined'); @@ -42,8 +46,11 @@ const LazyPreview: React.FC<{ def: SectionDef; title: string }> = ({ def, title }, [seen]); const doc = useMemo(() => { if (!seen) return ''; - try { return buildSectionPreviewDoc(def, design); } catch { return ''; } - }, [seen, def, design]); + try { + if (master) return exportToHtml(masterZoneState(master), { design, includeFonts: true }).html; + return def ? buildSectionPreviewDoc(def, design) : ''; + } catch { return ''; } + }, [seen, def, master, design]); return (
{doc && ( @@ -68,6 +75,10 @@ export const SectionLibraryModal: React.FC = () => { const [version, setVersion] = useState(0); const sections = useSections(version, open); const [family, setFamily] = useState(null); + // #57: the Global tab lists the site's global sections (instances, not copies). + const [globalTab, setGlobalTab] = useState(false); + const globals = useGlobalSections(); + const { isEditingGlobal } = usePages(); const [q, setQ] = useState(''); const [saveName, setSaveName] = useState(''); @@ -91,7 +102,29 @@ export const SectionLibraryModal: React.FC = () => { }, ], []); - useEffect(() => { if (open) { setQ(''); setFamily(null); setSaveName(''); } }, [open]); + useEffect(() => { if (open) { setQ(''); setFamily(null); setGlobalTab(false); setSaveName(''); } }, [open]); + const usage = useMemo(() => (open && globalTab ? globals.usage() : {}), [open, globalTab, globals]); + const globalEntries = useMemo(() => { + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + return Object.entries(globals.library) + .filter(([, m]) => words.every((w) => m.name.toLowerCase().includes(w))) + .sort((a, b) => a[1].name.localeCompare(b[1].name)); + }, [globals.library, q]); + + const onPickGlobal = (libraryId: string, name: string) => { + const id = globals.insertInstance(libraryId, selectedId ?? null); + if (!id) { + notify({ kind: 'error', message: isEditingGlobal ? 'A global section cannot be placed inside another one.' : 'Could not add that section.' }); + return; + } + closeSectionLibrary(); + notify({ kind: 'success', message: `Added global section "${name}"` }); + }; + const onDeleteGlobal = (libraryId: string, name: string) => { + if (!globals.remove(libraryId)) { + notify({ kind: 'info', message: `"${name}" is still used on the site. Delete or detach its copies first.` }); + } + }; const results = useMemo(() => searchSections(q, family, sections), [q, family, sections]); @@ -181,18 +214,48 @@ export const SectionLibraryModal: React.FC = () => { )}
- + {SECTION_FAMILIES.map((f) => ( ))} +
+ {globalTab ? ( +
+ {globalEntries.map(([id, m]) => ( +
+ + +
+ ))} + {globalEntries.length === 0 && ( +
+ No global sections yet. Right-click a section on the canvas and choose "Make global section". +
+ )} +
+ ) : (
{results.map((def) => (
@@ -213,6 +276,7 @@ export const SectionLibraryModal: React.FC = () => {
)}
+ )}
); diff --git a/craft/src/state/GlobalInstanceContext.tsx b/craft/src/state/GlobalInstanceContext.tsx new file mode 100644 index 0000000..40119d8 --- /dev/null +++ b/craft/src/state/GlobalInstanceContext.tsx @@ -0,0 +1,51 @@ +import { createContext, useCallback, useContext } from 'react'; +import { useEditor, useNode } from '@craftjs/core'; +import { OVERRIDE_KEYS, type OverridePatch } from '../utils/global-sections'; + +/** + * Id of the global-section instance root the rendering node sits in (#57), + * or null. Provided by `editor/RenderNode.tsx` around every node, so any + * component (and RenderNode itself) knows it is inside an instance without + * walking the tree. Kept in its own tiny module: components import it, and + * GlobalSectionsContext pulls in PageContext -> craft-tree -> the resolver -> + * components (an import cycle). + */ +export const GlobalInstanceContext = createContext(null); + +export const useGlobalInstanceRoot = () => useContext(GlobalInstanceContext); + +/** Overridable content keys a prop writer assigns (runs it on a scratch object). */ +export function overridePatchOf(cb: (p: any) => void): OverridePatch { + const draft: Record = {}; + try { cb(draft); } catch { return {}; } + const out: OverridePatch = {}; + for (const k of OVERRIDE_KEYS) if (typeof draft[k] === 'string') out[k] = draft[k] as string; + return out; +} + +/** + * Wraps a component's `setProp` commit so that, inside a global-section + * instance, a content edit is ALSO recorded as an override on the instance + * root (`custom.global.overrides[gkey]`), folded into the same undo step via + * `history.merge()`. Outside an instance it is the plain `setProp`. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useGlobalOverrideCommit

(setProp: (cb: (p: P) => void) => void) { + const rootId = useContext(GlobalInstanceContext); + const { gkey } = useNode((n) => ({ gkey: (n.data?.custom?.gkey as string | undefined) ?? null })); + const { actions } = useEditor(); + return useCallback((cb: (p: P) => void) => { + setProp(cb); + if (!rootId || !gkey) return; + const patch = overridePatchOf(cb as (p: any) => void); + if (!Object.keys(patch).length) return; + try { + actions.history.merge().setCustom(rootId, (c: any) => { + if (!c.global) return; + c.global.overrides = { ...(c.global.overrides || {}), [gkey]: { ...(c.global.overrides?.[gkey] || {}), ...patch } }; + }); + } catch (e) { + console.error('Recording global section override failed:', e); + } + }, [setProp, rootId, gkey, actions]); +} diff --git a/craft/src/state/GlobalSectionsContext.test.tsx b/craft/src/state/GlobalSectionsContext.test.tsx new file mode 100644 index 0000000..c30c1b5 --- /dev/null +++ b/craft/src/state/GlobalSectionsContext.test.tsx @@ -0,0 +1,430 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; +import { RenderNode } from '../editor/RenderNode'; +import { PageProvider, usePages } from './PageContext'; +import { GlobalSectionsProvider, useGlobalSections } from './GlobalSectionsContext'; +import { ProjectExtrasProvider, useProjectExtras } from './ProjectExtrasContext'; +import { SaveGuardProvider, useSaveGuard } from './SaveGuardContext'; +import { NoticeProvider } from './NoticeContext'; +import { SiteDesignProvider, DEFAULT_SITE_DESIGN } from './SiteDesignContext'; +import { buildNodeTree } from '../utils/craft-tree'; +import { exportBodyHtml } from '../utils/html-export'; +import { buildSavePayload } from '../hooks/useWhpApi'; +import { GLOBAL_SECTIONS_KEY, instanceMeta, type GlobalLibrary, type SNodes } from '../utils/global-sections'; +import { GuidedStyles } from '../panels/right/GuidedStyles'; +import { ContextMenu } from '../panels/context-menu/ContextMenu'; +import { SectionLibraryModal, openSectionLibrary, closeSectionLibrary } from '../panels/sections/SectionLibraryModal'; +import { Canvas } from '../editor/Canvas'; + +/** + * #57 global sections against the REAL Craft editor + PageProvider + + * ProjectExtrasProvider (no mocks): make global, place on a second page, + * edit the master once, per-page overrides, detach, remove guard. + */ + +let h: EditorHarness | null = null; +let pages: ReturnType; +let globals: ReturnType; +let extras: ReturnType; +let guard: ReturnType; + +const Probe: React.FC = () => { + pages = usePages(); + globals = useGlobalSections(); + extras = useProjectExtras(); + guard = useSaveGuard(); + return null; +}; + +afterEach(() => { h?.unmount(); h = null; closeSectionLibrary(); document.body.innerHTML = ''; }); + +function mount(ui: React.ReactNode = null): EditorHarness { + h = renderEditorHarness({ + onRender: RenderNode, + wrap: (tree) => ( + {tree} + ), + }); + h.mountChild({ui}); + return h; +} + +/** Flush PageContext.loadState's setTimeout(0) deserialize. */ +async function flush() { + await h!.act(async () => { await new Promise((r) => setTimeout(r, 0)); }); +} + +/** A Section holding Heading / Text / Button, appended to ROOT. */ +function addPromo(): string { + let id = ''; + h!.act(() => { + const tree = buildNodeTree(h!.query, { + type: { resolvedName: 'Section' }, + props: {}, + nodes: [ + { type: { resolvedName: 'Heading' }, props: { text: 'Welcome', styleMobile: { fontSize: '22px' } } }, + { type: { resolvedName: 'TextBlock' }, props: { text: 'Body copy' } }, + { type: { resolvedName: 'ButtonLink' }, props: { text: 'Go', href: '#a' } }, + ], + }); + h!.actions.addNodeTree(tree, 'ROOT'); + id = tree.rootNodeId; + }); + h!.act(() => { h!.actions.history.clear(); }); + return id; +} + +const live = (): SNodes => JSON.parse(h!.getSerialized()); +const lib = (): GlobalLibrary => extras.get(GLOBAL_SECTIONS_KEY, {}); +const byType = (nodes: SNodes, name: string, under?: string) => + Object.entries(nodes).filter(([id, n]) => { + if (n.type.resolvedName !== name) return false; + if (!under) return true; + for (let p: string | null = id; p; p = nodes[p]?.parent ?? null) if (p === under) return true; + return false; + }).map(([id]) => id); +const instanceRoots = (nodes: SNodes) => Object.keys(nodes).filter((id) => instanceMeta(nodes[id])); +function undoDepth(): number { + let n = 0; + while (h!.query.history.canUndo() && n < 50) { h!.act(() => { h!.actions.history.undo(); }); n++; } + return n; +} + +describe('#57 global sections', () => { + test('make a section global: master with gkeys; the original becomes instance #1 (one undo step)', () => { + mount(); + const sec = addPromo(); + let libraryId: string | null = null; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo'); }); + expect(libraryId).toBeTruthy(); + const master = lib()[libraryId!]; + expect(master.name).toBe('Promo'); + expect(master.rev).toBe(1); + const masterNodes = Object.values(master.nodes); + expect(masterNodes.length).toBe(5); // Section, inner Container, Heading, Text, Button + expect(masterNodes.every((n) => typeof n.custom.gkey === 'string')).toBe(true); + const nodes = live(); + expect(instanceMeta(nodes[sec])).toEqual({ libraryId, rev: 1, overrides: {} }); + // live gkeys are exactly the master's keys + const liveKeys = Object.keys(nodes).filter((id) => id !== 'ROOT').map((id) => nodes[id].custom.gkey).sort(); + expect(liveKeys).toEqual(Object.keys(master.nodes).sort()); + expect(globals.usage()).toEqual({ [libraryId!]: 1 }); + expect(undoDepth()).toBe(1); + expect(instanceMeta(live()[sec])).toBeNull(); + expect(Object.values(live()).some((n) => n.custom?.gkey)).toBe(false); + }); + + test('insert an instance on page 2: real inlined subtree, static export', async () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + h!.act(() => { pages.addPage('About', 'about'); }); + await flush(); + let rootId: string | null = null; + h!.act(() => { rootId = globals.insertInstance(libraryId); }); + expect(rootId).toBeTruthy(); + const nodes = live(); + expect(nodes.ROOT.nodes).toEqual([rootId]); + expect(instanceMeta(nodes[rootId!])!.libraryId).toBe(libraryId); + // fresh ids: none shared with page 1's instance + h!.act(() => { pages.switchPage('home'); }); + await flush(); + const page2 = pages.pages[1].craftState!; + const p1 = Object.keys(live()); + expect(Object.keys(JSON.parse(page2)).filter((id) => id !== 'ROOT' && p1.includes(id))).toEqual([]); + const html = exportBodyHtml(page2).html; + expect(html).toContain('Welcome'); + expect(html).toContain('Body copy'); + expect(html).not.toContain('gkey'); + expect(globals.usage()).toEqual({ [libraryId]: 2 }); + }); + + test('inline edit inside an instance writes an override and the prop, master unchanged, one undo step', () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + h!.act(() => { h!.actions.history.clear(); }); + const [hid] = byType(live(), 'Heading', sec); + const gkey = live()[hid].custom.gkey as string; + const el = h!.query.node(hid).get().dom as HTMLElement; + h!.act(() => { h!.actions.selectNode(hid); }); + h!.act(() => { el.innerHTML = 'Hi page one'; el.dispatchEvent(new Event('input', { bubbles: true })); }); + h!.act(() => { el.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); + const nodes = live(); + expect(nodes[hid].props.text).toBe('Hi page one'); + expect(instanceMeta(nodes[sec])!.overrides[gkey]).toEqual({ text: 'Hi page one', html: 'Hi page one' }); + expect(lib()[libraryId].nodes[gkey].props.text).toBe('Welcome'); + h!.act(() => { h!.actions.selectNode(); }); + expect(undoDepth()).toBe(1); + expect(live()[hid].props.text).toBe('Welcome'); + expect(instanceMeta(live()[sec])!.overrides).toEqual({}); + }); + + test('a plain node outside any instance commits without an override (no custom write)', () => { + mount(); + const sec = addPromo(); + const [hid] = byType(live(), 'Heading', sec); + const el = h!.query.node(hid).get().dom as HTMLElement; + h!.act(() => { h!.actions.selectNode(hid); }); + h!.act(() => { el.innerHTML = 'Plain'; el.dispatchEvent(new Event('input', { bubbles: true })); }); + h!.act(() => { el.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); + expect(live()[hid].props.text).toBe('Plain'); + expect(live()[sec].custom).toEqual({}); + }); + + test('edit the master once: every instance updates, overrides survive, rev+1, dirty, ONE undo step on the canvas', async () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + h!.act(() => { pages.addPage('About', 'about'); }); + await flush(); + let inst2 = ''; + h!.act(() => { inst2 = globals.insertInstance(libraryId)!; }); + // page 2 overrides its button link through the panel path + const [btn2] = byType(live(), 'ButtonLink', inst2); + const btnKey = live()[btn2].custom.gkey as string; + h!.act(() => { globals.setOverride(btn2, { href: '/contact' }); }); + const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string; + + // Edit the master (a zone switch: fresh history) + h!.act(() => { pages.editGlobal(libraryId); }); + await flush(); + expect(pages.isEditingGlobal).toBe(true); + expect(pages.globalEditingId).toBe(libraryId); + expect(h!.query.history.canUndo()).toBe(false); + expect(live()[headKey].props.text).toBe('Welcome'); + h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Fresh headline'; }); }); + h!.act(() => { h!.actions.setProp(btnKey, (p: any) => { p.text = 'Book now'; }); }); + const v0 = guard.getVersion(); + + h!.act(() => { globals.finishEditingMaster(); }); + await flush(); + expect(pages.isEditingGlobal).toBe(false); + expect(pages.activePageId).toBe(pages.pages[1].id); // back where we came from + expect(lib()[libraryId].rev).toBe(2); + expect(lib()[libraryId].nodes[headKey].props.text).toBe('Fresh headline'); + expect(guard.getVersion()).toBeGreaterThan(v0); + expect(guard.isDirty()).toBe(true); + + // live canvas (page 2): new master content, page override kept, per-breakpoint style intact + const p2 = live(); + const [root2] = instanceRoots(p2); + expect(root2).toBe(inst2); // the root id is stable + expect(instanceMeta(p2[root2])!.rev).toBe(2); + const head2 = byType(p2, 'Heading', root2)[0]; + expect(p2[head2].props.text).toBe('Fresh headline'); + expect(p2[head2].props.styleMobile).toEqual({ fontSize: '22px' }); + const b2 = byType(p2, 'ButtonLink', root2)[0]; + expect(p2[b2].props).toMatchObject({ text: 'Book now', href: '/contact' }); + + // stored page 1 got the update too + const p1 = JSON.parse(pages.pages[0].craftState!) as SNodes; + expect(p1[byType(p1, 'Heading', sec)[0]].props.text).toBe('Fresh headline'); + expect(p1[byType(p1, 'ButtonLink', sec)[0]].props.href).toBe('#a'); + expect(exportBodyHtml(pages.pages[0].craftState!).html).toContain('Fresh headline'); + + // exactly one undoable step: the sync on this page + expect(undoDepth()).toBe(1); + expect(live()[byType(live(), 'Heading', inst2)[0]].props.text).toBe('Welcome'); + }); + + test('Done without changes: no rev bump, no sync, nothing to undo', async () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + const before = h!.getSerialized(); + h!.act(() => { pages.editGlobal(libraryId); }); + await flush(); + let r: any; + h!.act(() => { r = globals.finishEditingMaster(); }); + await flush(); + expect(r).toEqual({ changed: false, empty: false, dropped: 0 }); + expect(lib()[libraryId].rev).toBe(1); + expect(h!.query.history.canUndo()).toBe(false); + expect(instanceRoots(live())).toEqual([sec]); + // same nodes back (deserialize may fill in component defaults; ids and content are untouched) + expect(Object.keys(live()).sort()).toEqual(Object.keys(JSON.parse(before)).sort()); + expect(instanceMeta(live()[sec])!.rev).toBe(1); + }); + + test('switching pages out of the master zone also commits it; save never writes the master into a page slot', async () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + h!.act(() => { pages.addPage('About', 'about'); }); + await flush(); + h!.act(() => { pages.editGlobal(libraryId); }); + await flush(); + const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string; + h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Via switch'; }); }); + + const payload = buildSavePayload({ + siteId: 1, siteName: 'S', liveCraftState: h!.getSerialized(), pages: pages.pages, + headerPage: pages.headerPage, footerPage: pages.footerPage, activePageId: pages.activePageId, + isEditingHeader: pages.isEditingHeader, isEditingFooter: pages.isEditingFooter, + headCode: '', design: DEFAULT_SITE_DESIGN, + }); + for (const p of payload.pages_craft_state) expect(p.craftState ?? '').not.toContain('Via switch'); + expect(payload.craft_state ?? '').not.toContain('Via switch'); + + h!.act(() => { pages.switchPage('home'); }); + await flush(); + expect(pages.activePageId).toBe('home'); + expect(lib()[libraryId].rev).toBe(2); + expect(live()[byType(live(), 'Heading', sec)[0]].props.text).toBe('Via switch'); + }); + + test('detach: plain copy (one undo step) that later master edits never touch', async () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + h!.act(() => { h!.actions.history.clear(); }); + const [hid] = byType(live(), 'Heading', sec); + h!.act(() => { expect(globals.detach(hid)).toBe(true); }); + expect(instanceMeta(live()[sec])).toBeNull(); + expect(Object.values(live()).some((n) => n.custom?.gkey)).toBe(false); + expect(globals.usage()).toEqual({}); + expect(undoDepth()).toBe(1); + expect(instanceMeta(live()[sec])!.libraryId).toBe(libraryId); + h!.act(() => { globals.detach(sec); }); + + h!.act(() => { pages.editGlobal(libraryId); }); + await flush(); + const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string; + h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Changed'; }); }); + h!.act(() => { globals.finishEditingMaster(); }); + await flush(); + expect(lib()[libraryId].rev).toBe(2); + expect(live()[hid].props.text).toBe('Welcome'); + expect(h!.query.history.canUndo()).toBe(false); + }); + + test('remove is refused while instances exist, allowed once none are left', () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + let ok = true; + h!.act(() => { ok = globals.remove(libraryId); }); + expect(ok).toBe(false); + expect(lib()[libraryId]).toBeTruthy(); + h!.act(() => { h!.actions.delete(sec); }); + h!.act(() => { ok = globals.remove(libraryId); }); + expect(ok).toBe(true); + expect(lib()[libraryId]).toBeUndefined(); + }); + + test('canvas marks instance roots and inner nodes with editor-only attributes', () => { + mount(); + const sec = addPromo(); + h!.act(() => { globals.makeGlobal(sec, 'Promo'); }); + const [hid] = byType(live(), 'Heading', sec); + expect(h!.query.node(sec).get().dom.hasAttribute('data-wsb-global')).toBe(true); + expect(h!.query.node(hid).get().dom.hasAttribute('data-wsb-global-inner')).toBe(true); + expect(exportBodyHtml(h!.getSerialized()).html).not.toContain('data-wsb-global'); + }); + + describe('UI', () => { + const $ = (sel: string) => document.body.querySelector(sel) as HTMLElement | null; + const btn = (label: string) => + Array.from(document.body.querySelectorAll('button')).find((b) => b.textContent?.includes(label)) as HTMLButtonElement | undefined; + const typeInto = (el: HTMLInputElement, v: string) => h!.act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(el, v); + el.dispatchEvent(new Event('input', { bubbles: true })); + }); + + test('GuidedStyles: instance nodes get the global panel instead of style panels; override field writes an override', () => { + mount(); + const sec = addPromo(); + h!.act(() => { globals.makeGlobal(sec, 'Promo'); }); + const [bid] = byType(live(), 'ButtonLink', sec); + const gkey = live()[bid].custom.gkey as string; + h!.act(() => { h!.actions.selectNode(bid); }); + expect($('[data-testid="global-instance-panel"]')).not.toBeNull(); + expect($('[data-testid="global-instance-panel"]')!.textContent).toContain('Promo'); + expect(btn('Edit master')).toBeTruthy(); + expect(btn('Detach')).toBeTruthy(); + const link = document.body.querySelector('input[aria-label="Override link"]') as HTMLInputElement; + typeInto(link, '/book'); + h!.act(() => { link.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); + expect(live()[bid].props.href).toBe('/book'); + expect(instanceMeta(live()[sec])!.overrides[gkey]).toEqual({ href: '/book' }); + // Reset to master restores the master value and drops the override + h!.act(() => { btn('Reset to master')!.click(); }); + expect(live()[bid].props.href).toBe('#a'); + expect(instanceMeta(live()[sec])!.overrides[gkey]).toBeUndefined(); + // a detached copy is styled normally again + h!.act(() => { btn('Detach')!.click(); }); + expect($('[data-testid="global-instance-panel"]')).toBeNull(); + }); + + test('ContextMenu: Make global section… on a top-level section; Edit master / Detach on an instance', () => { + mount(); + const sec = addPromo(); + const [hid] = byType(live(), 'Heading', sec); + const menu = (nodeId: string) => h!.mountChild( + {}} />, + ); + menu(hid); + expect(btn('Make global section')!.disabled).toBe(true); // not a top-level section + menu(sec); + expect(btn('Make global section')!.disabled).toBe(false); + h!.act(() => { btn('Make global section')!.click(); }); + const name = document.body.querySelector('#wsb-global-name') as HTMLInputElement; + typeInto(name, 'Footer promo'); + h!.act(() => { (document.body.querySelector('[aria-label="Make global section"] .wsb-presets-btn') as HTMLElement).click(); }); + const ids = Object.keys(lib()); + expect(ids).toHaveLength(1); + expect(lib()[ids[0]].name).toBe('Footer promo'); + menu(hid); + expect(btn('Make global section')).toBeUndefined(); + expect(btn('Paste style (merge)')!.disabled).toBe(true); + expect(btn('Detach from global')).toBeTruthy(); + h!.act(() => { btn('Edit master')!.click(); }); + expect(pages.isEditingGlobal).toBe(true); + }); + + test('Section library Global tab: usage counts, insert an instance, delete refused while used', () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + h!.act(() => openSectionLibrary()); + h!.act(() => { btn('Global')!.click(); }); + const card = () => document.body.querySelector(`[data-global-id="${libraryId}"]`) as HTMLElement; + expect(card()).not.toBeNull(); + expect(card().textContent).toContain('Used 1 time'); + h!.act(() => { (card().querySelector('.section-lib-delete') as HTMLElement).click(); }); + expect(lib()[libraryId]).toBeTruthy(); + h!.act(() => { (card().querySelector('.section-lib-pick') as HTMLElement).click(); }); + expect(instanceRoots(live())).toHaveLength(2); + expect(globals.usage()[libraryId]).toBe(2); + }); + + test('Canvas: banner while editing a master; Done updates the instances', async () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + expect($('[data-testid="global-master-banner"]')).toBeNull(); + h!.act(() => { pages.editGlobal(libraryId); }); + await flush(); + const banner = $('[data-testid="global-master-banner"]')!; + expect(banner.textContent).toContain('Editing global section: Promo'); + expect(banner.textContent).toContain('Done - update 1 instance'); + const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string; + h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'From banner'; }); }); + h!.act(() => { ($('[data-testid="global-master-done"]') as HTMLElement).click(); }); + await flush(); + expect($('[data-testid="global-master-banner"]')).toBeNull(); + expect(live()[byType(live(), 'Heading', sec)[0]].props.text).toBe('From banner'); + }); + }); +}); diff --git a/craft/src/state/GlobalSectionsContext.tsx b/craft/src/state/GlobalSectionsContext.tsx new file mode 100644 index 0000000..61c52dc --- /dev/null +++ b/craft/src/state/GlobalSectionsContext.tsx @@ -0,0 +1,243 @@ +import React, { createContext, useCallback, useContext, useMemo, useState, ReactNode } from 'react'; +import { useEditor } from '@craftjs/core'; +import { usePages, GlobalCommitResult } from './PageContext'; +import { useProjectExtras } from './ProjectExtrasContext'; +import { useNotices } from './NoticeContext'; +import { getAncestorIds, getIndexInParent } from '../utils/craft-helpers'; +import { + GLOBAL_SECTIONS_KEY, GlobalLibrary, GlobalMaster, OverridePatch, SNodes, + countInstances, createMaster, findInstanceRoot, gkeyOf, instanceMeta, instantiate, newLibraryId, +} from '../utils/global-sections'; + +/** + * Global sections (#57): user-facing operations over the pure model in + * `utils/global-sections.ts` and the master zone in `PageContext`. + * Every canvas mutation here is ONE undo step (follow-up writes fold into the + * first via `history.merge()`). + */ +export interface GlobalInstanceInfo { + rootId: string; + libraryId: string; + name: string; + isRoot: boolean; + gkey: string | null; + /** Content keys this node currently overrides. */ + overridden: string[]; +} + +interface GlobalSectionsValue { + library: GlobalLibrary; + /** libraryId -> instances across every page, header and footer (live canvas included). */ + usage: () => Record; + makeGlobal: (nodeId: string, name: string) => string | null; + insertInstance: (libraryId: string, afterNodeId?: string | null) => string | null; + editMaster: (libraryId: string) => boolean; + finishEditingMaster: () => GlobalCommitResult; + detach: (nodeId: string) => boolean; + setOverride: (nodeId: string, patch: OverridePatch) => boolean; + resetOverride: (nodeId: string) => boolean; + /** Refused (false) while any instance exists. */ + remove: (libraryId: string) => boolean; + instanceInfo: (nodeId: string | null | undefined) => GlobalInstanceInfo | null; +} + +const empty: GlobalSectionsValue = { + library: {}, + usage: () => ({}), + makeGlobal: () => null, + insertInstance: () => null, + editMaster: () => false, + finishEditingMaster: () => ({ changed: false, empty: false, dropped: 0 }), + detach: () => false, + setOverride: () => false, + resetOverride: () => false, + remove: () => false, + instanceInfo: () => null, +}; + +const GlobalSectionsContext = createContext(empty); +export const useGlobalSections = () => useContext(GlobalSectionsContext); + +/** Craft `setCustom` for the first write, folded into it for the rest. */ +const stepper = (actions: any) => { + let first = true; + return () => { + const target = first ? actions : actions.history.merge(); + first = false; + return target; + }; +}; + +export const GlobalSectionsProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const { query, actions } = useEditor(); + const extras = useProjectExtras(); + const pages = usePages(); + const { notify } = useNotices(); + const [version, setVersion] = useState(0); + const bump = () => setVersion((v) => v + 1); + + // Extras are ref-backed; re-read on every render (PageContext re-renders us + // after a master commit, our own writes bump `version`). + const library = extras.get(GLOBAL_SECTIONS_KEY, {}); + const getLib = useCallback(() => extras.get(GLOBAL_SECTIONS_KEY, {}), [extras]); + + const usage = useCallback(() => { + const live = query.serialize(); + const id = pages.activePageId; + const states: Array = [ + pages.isEditingHeader ? live : pages.headerPage.craftState, + pages.isEditingFooter ? live : pages.footerPage.craftState, + ...pages.pages.map((p) => (p.id === id ? live : p.craftState)), + ]; + return countInstances(states); + }, [query, pages]); + + const makeGlobal = useCallback((nodeId: string, name: string): string | null => { + if (!nodeId || nodeId === 'ROOT' || pages.isEditingGlobal) return null; + if (findInstanceRoot(query, nodeId)) return null; + const nodes = JSON.parse(query.serialize()) as SNodes; + if (!nodes[nodeId]) return null; + const { master, gkeyById } = createMaster(name.trim() || 'Global section', nodes, nodeId); + const libraryId = newLibraryId(); + extras.update(GLOBAL_SECTIONS_KEY, (prev) => ({ ...prev, [libraryId]: master }), {}); + const step = stepper(actions); + step().setCustom(nodeId, (c: any) => { + c.gkey = gkeyById[nodeId]; + c.global = { libraryId, rev: master.rev, overrides: {} }; + }); + for (const [id, g] of Object.entries(gkeyById)) { + if (id === nodeId) continue; + step().setCustom(id, (c: any) => { c.gkey = g; delete c.global; }); + } + bump(); + return libraryId; + }, [query, actions, extras, pages.isEditingGlobal]); + + const insertInstance = useCallback((libraryId: string, afterNodeId?: string | null): string | null => { + const master = getLib()[libraryId]; + if (!master || pages.isEditingGlobal) return null; // no instances inside a master + const inst = instantiate(libraryId, master, { parentId: 'ROOT' }); + let index: number | undefined; + let anchor = afterNodeId ?? null; + if (anchor === undefined || anchor === null) { + try { anchor = query.getEvent('selected').first() ?? null; } catch { anchor = null; } + } + if (anchor && anchor !== 'ROOT') { + try { + const chain = [anchor, ...getAncestorIds(query, anchor)]; + const top = chain[chain.length - 1]; + const idx = getIndexInParent(query, top); + if (idx !== -1) index = idx + 1; + } catch { /* append */ } + } + const treeNodes: Record = {}; + for (const [id, sn] of Object.entries(inst.nodes)) { + treeNodes[id] = query.parseSerializedNode(sn as any).toNode((n: any) => { n.id = id; }); + } + actions.addNodeTree({ rootNodeId: inst.rootId, nodes: treeNodes }, 'ROOT', index); + try { actions.selectNode(inst.rootId); } catch { /* nothing to select */ } + return inst.rootId; + }, [getLib, query, actions, pages.isEditingGlobal]); + + const detach = useCallback((nodeId: string): boolean => { + const rootId = findInstanceRoot(query, nodeId); + if (!rootId) return false; + const step = stepper(actions); + step().setCustom(rootId, (c: any) => { delete c.global; delete c.gkey; }); + const walk = (id: string) => { + const data = query.node(id).get()?.data; + if (!data) return; + if (id !== rootId && gkeyOf(data)) step().setCustom(id, (c: any) => { delete c.gkey; }); + for (const cid of data.nodes || []) walk(cid); + for (const cid of Object.values(data.linkedNodes || {})) walk(cid); + }; + walk(rootId); + return true; + }, [query, actions]); + + const setOverride = useCallback((nodeId: string, patch: OverridePatch): boolean => { + const rootId = findInstanceRoot(query, nodeId); + const gkey = gkeyOf(query.node(nodeId).get()?.data); + if (!rootId || !gkey || !Object.keys(patch).length) return false; + actions.setProp(nodeId, (p: any) => { Object.assign(p, patch); }); + actions.history.merge().setCustom(rootId, (c: any) => { + if (!c.global) return; + c.global.overrides = { ...(c.global.overrides || {}), [gkey]: { ...(c.global.overrides?.[gkey] || {}), ...patch } }; + }); + return true; + }, [query, actions]); + + const resetOverride = useCallback((nodeId: string): boolean => { + const rootId = findInstanceRoot(query, nodeId); + const gkey = gkeyOf(query.node(nodeId).get()?.data); + if (!rootId || !gkey) return false; + const meta = instanceMeta(query.node(rootId).get()?.data); + const master: GlobalMaster | undefined = meta ? getLib()[meta.libraryId] : undefined; + const patch = meta?.overrides?.[gkey]; + if (!meta || !patch) return false; + const masterProps = (master?.nodes[gkey]?.props ?? {}) as Record; + actions.setProp(nodeId, (p: any) => { + for (const k of Object.keys(patch)) if (k in masterProps) p[k] = masterProps[k]; + }); + actions.history.merge().setCustom(rootId, (c: any) => { + if (c.global?.overrides) { + const next = { ...c.global.overrides }; + delete next[gkey]; + c.global.overrides = next; + } + }); + return true; + }, [query, actions, getLib]); + + const remove = useCallback((libraryId: string): boolean => { + if ((usage()[libraryId] ?? 0) > 0) return false; + extras.update(GLOBAL_SECTIONS_KEY, (prev) => { + const next = { ...prev }; + delete next[libraryId]; + return next; + }, {}); + bump(); + return true; + }, [usage, extras]); + + const instanceInfo = useCallback((nodeId: string | null | undefined): GlobalInstanceInfo | null => { + if (!nodeId) return null; + const rootId = findInstanceRoot(query, nodeId); + if (!rootId) return null; + const meta = instanceMeta(query.node(rootId).get()?.data)!; + const gkey = gkeyOf(query.node(nodeId).get()?.data); + return { + rootId, + libraryId: meta.libraryId, + name: getLib()[meta.libraryId]?.name ?? 'Global section', + isRoot: rootId === nodeId, + gkey, + overridden: gkey ? Object.keys(meta.overrides?.[gkey] ?? {}) : [], + }; + }, [query, getLib]); + + const value = useMemo(() => ({ + library, + usage, + makeGlobal, + insertInstance, + editMaster: (libraryId: string) => pages.editGlobal(libraryId), + finishEditingMaster: () => { + const name = pages.globalEditingId ? library[pages.globalEditingId]?.name : null; + const r = pages.finishGlobal(); + bump(); + if (r.empty) notify({ kind: 'info', message: 'The global section was emptied, so it was left unchanged.' }); + else if (r.changed) notify({ kind: 'success', message: `Updated every copy of "${name ?? 'global section'}"` }); + if (r.dropped > 0) notify({ kind: 'info', message: `${r.dropped} element(s) placed beside the section are not part of it and were not kept.` }); + return r; + }, + detach, + setOverride, + resetOverride, + remove, + instanceInfo, + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [library, version, usage, makeGlobal, insertInstance, pages, notify, detach, setOverride, resetOverride, remove, instanceInfo]); + + return {children}; +}; diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 64b61d0..62462dc 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -7,6 +7,11 @@ import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/cra import { repairOrphanNodes } from '../utils/orphan-repair'; import { mergeLinks, rewriteHeaderLinks, isFollowPagesNav } from '../utils/nav-links'; import { tokenRef } from '../utils/tokens'; +import { useProjectExtras } from './ProjectExtrasContext'; +import { + GLOBAL_SECTIONS_KEY, GlobalLibrary, globalZoneId, isGlobalZoneId, libraryIdOfZone, + masterFromZoneState, masterZoneState, syncInstances, +} from '../utils/global-sections'; interface PageContextValue { pages: PageData[]; @@ -15,6 +20,21 @@ interface PageContextValue { activePageId: string; isEditingHeader: boolean; isEditingFooter: boolean; + /** #57: the canvas shows a global section's master (zone `__global__:`). */ + isEditingGlobal: boolean; + /** Library id of the master on the canvas, or null. */ + globalEditingId: string | null; + /** + * #57: put a global section's master on the canvas (a zone switch: the + * current page is stored, history starts fresh). Leaving the zone -- Done, + * a page/zone switch, another master -- saves the master back (rev + 1 + * when it changed) and rewrites every instance in every stored page; the + * page you return to gets the update as ONE undoable step. Returns false + * when `libraryId` is unknown. + */ + editGlobal: (libraryId: string) => boolean; + /** Leave the master zone back to where `editGlobal` was called from. */ + finishGlobal: () => GlobalCommitResult; switchPage: (pageId: string) => void; editHeader: () => void; editFooter: () => void; @@ -69,6 +89,17 @@ interface PageContextValue { siteDesign: SiteDesign; } +/** Outcome of leaving a master zone (#57). */ +export interface GlobalCommitResult { + /** The master changed: rev bumped, instances synced. */ + changed: boolean; + /** The zone was emptied: the master was left as it was. */ + empty: boolean; + /** Top-level nodes beside the master that are not part of it. */ + dropped: number; +} +const NO_COMMIT: GlobalCommitResult = { changed: false, empty: false, dropped: 0 }; + const HEADER_ID = '__header__'; const FOOTER_ID = '__footer__'; @@ -216,6 +247,10 @@ const PageContext = createContext({ activePageId: 'home', isEditingHeader: false, isEditingFooter: false, + isEditingGlobal: false, + globalEditingId: null, + editGlobal: () => false, + finishGlobal: () => NO_COMMIT, switchPage: () => {}, editHeader: () => {}, editFooter: () => {}, @@ -344,6 +379,7 @@ const DEFAULT_FOOTER: PageData = { export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const { query, actions } = useEditor(); const { design } = useSiteDesign(); + const extras = useProjectExtras(); const [pages, setPages] = useState([DEFAULT_PAGE]); const [headerPage, setHeaderPage] = useState(DEFAULT_HEADER); const [footerPage, setFooterPage] = useState(DEFAULT_FOOTER); @@ -364,11 +400,66 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => const isEditingHeader = activePageId === HEADER_ID; const isEditingFooter = activePageId === FOOTER_ID; + const globalEditingId = libraryIdOfZone(activePageId); + const isEditingGlobal = globalEditingId !== null; + /** Where Done in a master zone returns to (#57). */ + const returnToRef = useRef(null); + /** The master zone as first deserialized: the "unchanged" reference. */ + const zoneBaselineRef = useRef(null); + + /** Stored (not live) state of a page/header/footer slot. */ + const storedStateOf = useCallback((id: string): string | null => { + if (id === HEADER_ID) return headerPageRef.current.craftState; + if (id === FOOTER_ID) return footerPageRef.current.craftState; + return pagesRef.current.find((p) => p.id === id)?.craftState ?? null; + }, []); + + /** + * #57: save the master zone on the canvas back to the library and sync + * every stored page/header/footer state (refs are updated synchronously so + * the caller can load an already-synced slot). Returns the sync transform + * (null when nothing changed) plus the outcome. + */ + const commitGlobalZone = useCallback((): { transform: ((s: string) => string) | null; result: GlobalCommitResult } => { + const libraryId = libraryIdOfZone(activePageIdRef.current); + const lib = extras.get(GLOBAL_SECTIONS_KEY, {}); + const prev = libraryId ? lib[libraryId] : undefined; + if (!libraryId || !prev) return { transform: null, result: NO_COMMIT }; + const res = masterFromZoneState(query.serialize(), prev, Date.now(), zoneBaselineRef.current); + if (!res) return { transform: null, result: { changed: false, empty: true, dropped: 0 } }; + if (!res.changed) return { transform: null, result: { ...NO_COMMIT, dropped: res.dropped } }; + const nextLib: GlobalLibrary = { ...lib, [libraryId]: res.master }; + extras.set(GLOBAL_SECTIONS_KEY, nextLib); // marks dirty + const transform = (st: string) => syncInstances(st, nextLib, lib); + const nextPages = pagesRef.current.map((p) => { + if (!p.craftState) return p; + const n = transform(p.craftState); + return n === p.craftState ? p : { ...p, craftState: n }; + }); + if (nextPages.some((p, i) => p !== pagesRef.current[i])) { + pagesRef.current = nextPages; + setPages(nextPages); + } + for (const [ref, set] of [[headerPageRef, setHeaderPage], [footerPageRef, setFooterPage]] as const) { + const cur = ref.current; + const n = cur.craftState ? transform(cur.craftState) : cur.craftState; + if (n !== cur.craftState) { + ref.current = { ...cur, craftState: n }; + set(ref.current); + } + } + return { transform, result: { changed: true, empty: false, dropped: res.dropped } }; + }, [extras, query]); /** Save whatever is on the current Frame back to the right state slot */ const saveCurrentState = useCallback(() => { - const currentState = query.serialize(); const currentId = activePageIdRef.current; + if (isGlobalZoneId(currentId)) { + // A master zone is not a page slot: it goes back to the library. + commitGlobalZone(); + return; + } + const currentState = query.serialize(); if (currentId === HEADER_ID) { setHeaderPage((prev) => ({ ...prev, craftState: currentState })); @@ -379,7 +470,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)), ); } - }, [query]); + }, [query, commitGlobalZone]); /** Load a craft state into the Frame. * @@ -397,7 +488,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => * can't be selected or deleted) -- that report is still unreproduced; * see `orphan-repair.ts` for detail. */ const loadState = useCallback( - (craftState: string | null, fallback: string, opts?: { keepHistory?: boolean }) => { + (craftState: string | null, fallback: string, opts?: { keepHistory?: boolean; applyAfter?: string; onLoaded?: () => void }) => { // A switch puts a different page/zone on the canvas: its history must // start fresh, or Undo would deserialize the previous page's content // into this slot. Only an in-place replacement of the SAME slot (the @@ -419,6 +510,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => try { actions.deserialize(state); if (clearHistory) actions.history.clear(); + // #57: a global-section sync of this page lands as ONE undoable + // step on top of the freshly loaded (pre-sync) state. + if (opts?.applyAfter) actions.deserialize(repairOrphanNodes(opts.applyAfter).state); + opts?.onLoaded?.(); } catch (e) { console.error('Failed to deserialize state:', e); try { @@ -438,10 +533,33 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => [actions], ); + /** + * #57: leave the master zone for `targetId`. The master is committed (and + * every stored slot synced) first; the target is then loaded as it was + * BEFORE the sync with fresh history, and the sync applied on top as one + * undoable step. + */ + const leaveGlobalTo = useCallback((targetId: string): GlobalCommitResult => { + const pre = storedStateOf(targetId); + const { result } = commitGlobalZone(); + const post = storedStateOf(targetId); + const fallback = targetId === HEADER_ID ? EMPTY_HEADER : targetId === FOOTER_ID ? EMPTY_FOOTER : EMPTY_CANVAS; + loadState(pre, fallback, post && post !== pre ? { applyAfter: post } : undefined); + returnToRef.current = null; + setActivePageId(targetId); + activePageIdRef.current = targetId; + return result; + }, [storedStateOf, commitGlobalZone, loadState]); + const switchPage = useCallback( (pageId: string) => { if (pageId === activePageIdRef.current) return; + if (isGlobalZoneId(activePageIdRef.current)) { + leaveGlobalTo(pageId); + return; + } + // Serialize the current Craft.js state synchronously BEFORE switching const currentState = query.serialize(); const currentId = activePageIdRef.current; @@ -475,9 +593,42 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => setActivePageId(pageId); activePageIdRef.current = pageId; }, - [query, loadState], + [query, loadState, leaveGlobalTo], ); + const editGlobal = useCallback( + (libraryId: string): boolean => { + const master = extras.get(GLOBAL_SECTIONS_KEY, {})[libraryId]; + if (!master) return false; + const zoneId = globalZoneId(libraryId); + const currentId = activePageIdRef.current; + if (currentId === zoneId) return true; + if (isGlobalZoneId(currentId)) { + commitGlobalZone(); // keep the original return target + } else { + saveCurrentState(); + returnToRef.current = currentId; + } + zoneBaselineRef.current = null; + loadState(masterZoneState(master), EMPTY_CANVAS, { + onLoaded: () => { zoneBaselineRef.current = query.serialize(); }, + }); + setActivePageId(zoneId); + activePageIdRef.current = zoneId; + return true; + }, + [extras, query, commitGlobalZone, saveCurrentState, loadState], + ); + + const finishGlobal = useCallback((): GlobalCommitResult => { + if (!isGlobalZoneId(activePageIdRef.current)) return NO_COMMIT; + const back = returnToRef.current; + const target = back && (back === HEADER_ID || back === FOOTER_ID || pagesRef.current.some((p) => p.id === back)) + ? back + : pagesRef.current[0]?.id ?? DEFAULT_PAGE.id; + return leaveGlobalTo(target); + }, [leaveGlobalTo]); + const editHeader = useCallback(() => { switchPage(HEADER_ID); }, [switchPage]); @@ -828,6 +979,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => activePageId, isEditingHeader, isEditingFooter, + isEditingGlobal, + globalEditingId, + editGlobal, + finishGlobal, switchPage, editHeader, editFooter, diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 90b6320..ee91337 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -3279,3 +3279,48 @@ body { border-radius: 6px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; } .sitesmith-regenerate-btn:disabled { opacity: 0.6; cursor: not-allowed; } + +/* ---- #57 Global sections ---- */ +/* Instance root: dashed accent outline on the canvas (editor-only attribute). */ +.canvas-device-frame [data-wsb-global] { outline: 2px dashed #8b5cf6; outline-offset: -2px; } +.canvas-device-frame [data-wsb-global-inner][data-craft-hovered] { outline-color: #8b5cf6; cursor: text; } +.canvas-device-frame.guides-off [data-wsb-global] { outline: none; } +.component-indicator-global { + display: inline-flex; align-items: center; gap: 3px; + background: #7c3aed; color: #fff; border-radius: 3px; padding: 0 5px; margin-left: -4px; +} +.component-indicator-lock { opacity: 0.85; } +.global-master-banner { + display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 11px; font-weight: 600; + color: #a78bfa; background: rgba(139, 92, 246, 0.1); border-bottom: 1px solid rgba(139, 92, 246, 0.35); +} +.global-master-banner-text { flex: 1; min-width: 0; } +.global-master-banner-done { + flex-shrink: 0; border: none; border-radius: var(--radius-sm); padding: 4px 10px; font-size: 11px; font-weight: 600; + color: #fff; background: #7c3aed; cursor: pointer; +} +.global-master-banner-done:hover { background: #6d28d9; } +.global-instance-panel { display: flex; flex-direction: column; gap: 8px; } +.global-instance-head { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #a78bfa; } +.global-instance-head strong { color: var(--color-text); } +.global-instance-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.global-instance-btn { + display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; font-size: 12px; cursor: pointer; + color: var(--color-text); background: var(--color-bg-base); border: 1px solid var(--color-border); border-radius: var(--radius-sm); +} +.global-instance-btn:hover { border-color: #8b5cf6; } +.global-instance-hint { margin: 0; font-size: 11px; color: var(--color-text-muted); } +.global-override-fields { display: flex; flex-direction: column; gap: 6px; } +.global-override-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--color-text-muted); } +.global-override-badge { + margin-left: 6px; text-transform: none; letter-spacing: 0; font-weight: 500; + color: #a78bfa; background: rgba(139, 92, 246, 0.15); border-radius: 999px; padding: 1px 6px; +} +.global-override-field { display: flex; flex-direction: column; gap: 3px; } +.global-override-label { font-size: 11px; color: var(--color-text-muted); } +.global-override-input { + width: 100%; box-sizing: border-box; padding: 6px 8px; font-size: 12px; + color: var(--color-text); background: var(--color-bg-base); border: 1px solid var(--color-border); border-radius: var(--radius-sm); +} +.section-lib-chip-global i { margin-right: 3px; } +.section-lib-usage { display: block; font-size: 11px; color: var(--color-text-muted); } diff --git a/craft/src/utils/global-sections.test.ts b/craft/src/utils/global-sections.test.ts new file mode 100644 index 0000000..5f311b5 --- /dev/null +++ b/craft/src/utils/global-sections.test.ts @@ -0,0 +1,253 @@ +import { describe, test, expect } from 'vitest'; +import { + applyOverrides, collectOverridesFromInstance, countInstances, createMaster, detach, findInstanceRootIn, + instanceMeta, instantiate, masterFromZoneState, masterZoneState, subtreeIds, syncInstances, + type GlobalLibrary, type SNode, type SNodes, +} from './global-sections'; +import { exportBodyHtml } from './html-export'; + +const n = (resolvedName: string, props: Record, extra: Partial = {}): SNode => ({ + type: { resolvedName }, + isCanvas: resolvedName === 'Container', + props, + displayName: resolvedName, + custom: {}, + hidden: false, + parent: null, + nodes: [], + linkedNodes: {}, + ...extra, +}); + +/** ROOT > sec (Section, inner container) > [h (Heading), t (Text), img (Image)] */ +function page(): SNodes { + return { + ROOT: n('Container', { tag: 'div' }, { nodes: ['sec'] }), + sec: n('Section', { style: { padding: '40px' } }, { parent: 'ROOT', linkedNodes: { 'section-inner': 'inner' } }), + inner: n('Container', { tag: 'div' }, { parent: 'sec', nodes: ['h', 't', 'img'], custom: { label: 'Inner' } }), + h: n('Heading', { text: 'Hello', html: '', level: 'h2', style: { fontSize: '36px' }, styleMobile: { fontSize: '24px' } }, { parent: 'inner' }), + t: n('TextBlock', { text: 'Body', html: '' }, { parent: 'inner' }), + img: n('ImageBlock', { src: 'assets/a.png', alt: 'A' }, { parent: 'inner', hidden: true }), + }; +} + +function libWith(nodes = page()) { + const { master, gkeyById } = createMaster('Promo', nodes, 'sec', 1000); + const lib: GlobalLibrary = { L1: master }; + return { master, gkeyById, lib }; +} + +/** A page holding one instance of L1 (root id `inst`), with optional overrides. */ +function pageWithInstance(lib: GlobalLibrary, overrides = {}) { + const inst = instantiate('L1', lib.L1, { rootId: 'inst', parentId: 'ROOT', overrides }); + return JSON.stringify({ ROOT: n('Container', { tag: 'div' }, { nodes: ['inst'] }), ...inst.nodes }); +} + +const nodeByGkey = (nodes: SNodes, g: string) => Object.values(nodes).find((x) => x.custom?.gkey === g)!; + +describe('createMaster', () => { + test('keys every subtree node by a fresh gkey, keeps labels/hidden/linked structure, drops nested instance markers', () => { + const p = page(); + p.sec.custom = { global: { libraryId: 'OLD', rev: 3, overrides: {} }, gkey: 'stale' }; + const { master, gkeyById } = createMaster('Promo', p, 'sec', 1000); + expect(Object.keys(master.nodes).sort()).toEqual(Object.values(gkeyById).sort()); + expect(master.rootId).toBe(gkeyById.sec); + expect(master.rev).toBe(1); + const root = master.nodes[master.rootId]; + expect(root.parent).toBeNull(); + expect(root.custom.global).toBeUndefined(); + expect(root.custom.gkey).toBe(gkeyById.sec); + expect(gkeyById.sec).not.toBe('stale'); + expect(root.linkedNodes).toEqual({ 'section-inner': gkeyById.inner }); + expect(master.nodes[gkeyById.inner].custom.label).toBe('Inner'); + expect(master.nodes[gkeyById.img].hidden).toBe(true); + expect(master.nodes[gkeyById.h].props.styleMobile).toEqual({ fontSize: '24px' }); + // the input is not mutated + expect(p.h.custom).toEqual({}); + }); +}); + +describe('instantiate', () => { + test('fresh inner ids, given root id, overrides applied and recorded on the root', () => { + const { lib, gkeyById } = libWith(); + const ov = { [gkeyById.h]: { text: 'Hi there', html: '' } }; + const a = instantiate('L1', lib.L1, { rootId: 'r1', parentId: 'ROOT', overrides: ov }); + const b = instantiate('L1', lib.L1, { parentId: 'ROOT' }); + expect(a.rootId).toBe('r1'); + expect(a.nodes.r1.parent).toBe('ROOT'); + expect(instanceMeta(a.nodes.r1)).toEqual({ libraryId: 'L1', rev: 1, overrides: ov }); + const aIds = Object.keys(a.nodes); + const bIds = Object.keys(b.nodes); + expect(aIds.filter((id) => bIds.includes(id))).toEqual([]); + expect(aIds.some((id) => Object.values(gkeyById).includes(id))).toBe(false); + expect(nodeByGkey(a.nodes, gkeyById.h).props.text).toBe('Hi there'); + expect(nodeByGkey(b.nodes, gkeyById.h).props.text).toBe('Hello'); + // structure is intact: every reference resolves inside the instance + expect(subtreeIds(a.nodes, "r1")).toHaveLength(5); + }); +}); + +describe('syncInstances', () => { + test('identical string when nothing changed (no dirty churn)', () => { + const { lib } = libWith(); + const state = pageWithInstance(lib); + expect(syncInstances(state, lib)).toBe(state); + const plain = JSON.stringify(page()); + expect(syncInstances(plain, lib)).toBe(plain); + expect(syncInstances('not json', lib)).toBe('not json'); + // a stored state in another JSON spelling (PHP's escaped slashes, pretty + // printing) must come back byte-identical, not re-serialised + const php = JSON.stringify(JSON.parse(state), null, 1).replace(/\//g, '\\/'); + expect(php).toContain('\\/'); + expect(syncInstances(php, lib)).toBe(php); + }); + + test('rebuilds an out-of-date instance from the master, keeps the root id and the overrides', () => { + const { lib, gkeyById } = libWith(); + const state = pageWithInstance(lib, { [gkeyById.t]: { text: 'Mine', html: 'Mine' } }); + const before = JSON.parse(state) as SNodes; + const next: GlobalLibrary = JSON.parse(JSON.stringify(lib)); + next.L1.rev = 2; + next.L1.nodes[gkeyById.h].props.text = 'New headline'; + next.L1.nodes[gkeyById.t].props.text = 'Master body'; + next.L1.nodes[gkeyById.h].props.styleMobile = { fontSize: '20px' }; + const out = syncInstances(state, next, lib); + expect(out).not.toBe(state); + const nodes = JSON.parse(out) as SNodes; + expect(nodes.ROOT.nodes).toEqual(['inst']); + expect(nodes.inst.parent).toBe('ROOT'); + expect(instanceMeta(nodes.inst)!.rev).toBe(2); + expect(nodeByGkey(nodes, gkeyById.h).props.text).toBe('New headline'); + expect(nodeByGkey(nodes, gkeyById.h).props.styleMobile).toEqual({ fontSize: '20px' }); + // rich-text override survives the master change + expect(nodeByGkey(nodes, gkeyById.t).props).toMatchObject({ text: 'Mine', html: 'Mine' }); + // inner nodes keep the instance's own id for their gkey (a mounted shell + // caches its linked inner-canvas id) + const idOf = (ns: SNodes, g: string) => Object.keys(ns).find((id) => ns[id].custom?.gkey === g); + for (const g of [gkeyById.inner, gkeyById.h, gkeyById.t]) expect(idOf(nodes, g)).toBe(idOf(before, g)); + expect(Object.keys(nodes)).toHaveLength(6); + // and the result still exports as plain static markup + const html = exportBodyHtml(out).html; + expect(html).toContain('New headline'); + expect(html).toContain('Mine'); + }); + + test('a node added to the master gets a fresh id; a node removed from it is dropped', () => { + const { lib, gkeyById } = libWith(); + const state = pageWithInstance(lib); + const before = JSON.parse(state) as SNodes; + const next: GlobalLibrary = JSON.parse(JSON.stringify(lib)); + next.L1.rev = 2; + const m = next.L1.nodes; + m[gkeyById.inner].nodes = m[gkeyById.inner].nodes.filter((g) => g !== gkeyById.img).concat('gNEW'); + delete m[gkeyById.img]; + m.gNEW = n('Divider', {}, { parent: gkeyById.inner, custom: { gkey: 'gNEW' } }); + const nodes = JSON.parse(syncInstances(state, next)) as SNodes; + const newId = Object.keys(nodes).find((id) => nodes[id].custom?.gkey === 'gNEW')!; + expect(newId).toBeTruthy(); + expect(before[newId]).toBeUndefined(); + expect(Object.values(nodes).some((x) => x.custom?.gkey === gkeyById.img)).toBe(false); + const innerId = Object.keys(nodes).find((id) => nodes[id].custom?.gkey === gkeyById.inner)!; + expect(nodes[innerId].nodes[2]).toBe(newId); + expect(nodes[newId].parent).toBe(innerId); + }); + + test('an edit that bypassed the override hook is kept when the previous master is known', () => { + const { lib, gkeyById } = libWith(); + const nodes = JSON.parse(pageWithInstance(lib)) as SNodes; + nodeByGkey(nodes, gkeyById.img).props.src = 'assets/mine.png'; + const next: GlobalLibrary = JSON.parse(JSON.stringify(lib)); + next.L1.rev = 2; + next.L1.nodes[gkeyById.img].props.src = 'assets/master2.png'; + const withPrev = JSON.parse(syncInstances(JSON.stringify(nodes), next, lib)) as SNodes; + expect(nodeByGkey(withPrev, gkeyById.img).props.src).toBe('assets/mine.png'); + expect(instanceMeta(withPrev.inst)!.overrides[gkeyById.img]).toEqual({ src: 'assets/mine.png' }); + const without = JSON.parse(syncInstances(JSON.stringify(nodes), next)) as SNodes; + expect(nodeByGkey(without, gkeyById.img).props.src).toBe('assets/master2.png'); + }); + + test('detached copies and unknown libraries are left alone', () => { + const { lib } = libWith(); + const nodes = JSON.parse(pageWithInstance(lib)) as SNodes; + const plain = JSON.stringify(detach(nodes, 'inst')); + const next: GlobalLibrary = { L1: { ...lib.L1, rev: 9 } }; + expect(syncInstances(plain, next)).toBe(plain); + const orphan = pageWithInstance(lib); + expect(syncInstances(orphan, {})).toBe(orphan); + }); +}); + +describe('overrides helpers', () => { + test('collectOverridesFromInstance reports changed content only; text and html travel together', () => { + const { lib, gkeyById } = libWith(); + const nodes = JSON.parse(pageWithInstance(lib)) as SNodes; + expect(collectOverridesFromInstance(nodes, 'inst', lib.L1)).toEqual({}); + nodeByGkey(nodes, gkeyById.h).props.html = 'Hello'; + nodeByGkey(nodes, gkeyById.h).props.style = { fontSize: '99px' }; // styling is never an override + nodeByGkey(nodes, gkeyById.img).props.alt = 'B'; + expect(collectOverridesFromInstance(nodes, 'inst', lib.L1)).toEqual({ + [gkeyById.h]: { text: 'Hello', html: 'Hello' }, + [gkeyById.img]: { alt: 'B' }, + }); + }); + + test('applyOverrides patches by gkey without touching the input', () => { + const { lib, gkeyById } = libWith(); + const out = applyOverrides(lib.L1.nodes, { [gkeyById.h]: { text: 'X' } }); + expect(out[gkeyById.h].props.text).toBe('X'); + expect(lib.L1.nodes[gkeyById.h].props.text).toBe('Hello'); + }); + + test('detach strips the marker and gkeys; countInstances / findInstanceRootIn see instances', () => { + const { lib, gkeyById } = libWith(); + const state = pageWithInstance(lib); + const nodes = JSON.parse(state) as SNodes; + const inner = nodeByGkey(nodes, gkeyById.h); + const innerId = Object.keys(nodes).find((id) => nodes[id] === inner)!; + expect(findInstanceRootIn(nodes, innerId)).toBe('inst'); + expect(countInstances([state, state, null, JSON.stringify(page())])).toEqual({ L1: 2 }); + const plain = detach(nodes, 'inst'); + expect(plain.inst.custom.global).toBeUndefined(); + expect(Object.values(plain).some((x) => x.custom?.gkey)).toBe(false); + expect(findInstanceRootIn(plain, innerId)).toBeNull(); + expect(countInstances([JSON.stringify(plain)])).toEqual({}); + }); +}); + +describe('master zone round trip', () => { + test('unchanged zone -> same master, same rev', () => { + const { lib } = libWith(); + const res = masterFromZoneState(masterZoneState(lib.L1), lib.L1, 2000)!; + expect(res.changed).toBe(false); + expect(res.master).toBe(lib.L1); + }); + + test('an edit bumps rev; new and duplicated nodes get fresh gkeys; extra top-level nodes are reported', () => { + const { lib, gkeyById } = libWith(); + const zone = JSON.parse(masterZoneState(lib.L1)) as SNodes; + zone[gkeyById.h].props = { ...zone[gkeyById.h].props, text: 'Edited' }; + // a duplicate of the text node carries the same gkey; a brand new node has none + zone.dup = { ...zone[gkeyById.t], custom: { ...zone[gkeyById.t].custom } }; + zone.fresh = n('TextBlock', { text: 'New' }, { parent: gkeyById.inner }); + zone[gkeyById.inner].nodes = [...zone[gkeyById.inner].nodes, 'dup', 'fresh']; + zone.extra = n('Spacer', {}, { parent: 'ROOT' }); + zone.ROOT.nodes = [...zone.ROOT.nodes, 'extra']; + const res = masterFromZoneState(JSON.stringify(zone), lib.L1, 2000)!; + expect(res.changed).toBe(true); + expect(res.dropped).toBe(1); + expect(res.master.rev).toBe(2); + expect(res.master.updatedAt).toBe(2000); + expect(res.master.nodes[gkeyById.h].props.text).toBe('Edited'); + const keys = Object.values(res.master.nodes).map((x) => x.custom.gkey); + expect(new Set(keys).size).toBe(keys.length); + expect(Object.keys(res.master.nodes)).toHaveLength(7); + expect(Object.values(res.master.nodes).find((x) => x.props.text === 'New')).toBeTruthy(); + }); + + test('an emptied zone yields null (the caller keeps the master)', () => { + const { lib } = libWith(); + const zone = JSON.parse(masterZoneState(lib.L1)) as SNodes; + zone.ROOT.nodes = []; + expect(masterFromZoneState(JSON.stringify(zone), lib.L1)).toBeNull(); + }); +}); diff --git a/craft/src/utils/global-sections.ts b/craft/src/utils/global-sections.ts new file mode 100644 index 0000000..81eb560 --- /dev/null +++ b/craft/src/utils/global-sections.ts @@ -0,0 +1,391 @@ +import { getRandomId } from '@craftjs/utils'; +import type { FlatCraftNode } from './craft-tree'; + +/** + * Reusable global sections (#57) -- pure model. + * + * Library (`extras.globalSections`): one MASTER per library id, stored as a + * flat Craft serialized subtree keyed by `gkey` (lossless: layer labels, + * hidden flags, shell inner containers and per-breakpoint styles all + * round-trip). Every master node carries a stable `custom.gkey`. + * + * Instance: a REAL subtree in a page whose root has + * `custom.global = { libraryId, rev, overrides }` and whose nodes carry the + * master's `gkey`s. Instances render and export like any other node, so the + * published output stays a static snapshot with no export changes. + * Overrides are content-only (`text/html/src/alt/href`), keyed by gkey; all + * styling comes from the master. + * + * Sync: when a master changes, `syncInstances` rebuilds every instance from + * the master with fresh inner ids (the instance ROOT id is kept, so links and + * selection survive) and re-applies the instance's overrides. + * + * `gkey` lives in `custom`, not `props`: props reach `render`/`toHtml` and the + * generic props editor would show it as a field. + */ + +export const GLOBAL_SECTIONS_KEY = 'globalSections'; +export const GLOBAL_ZONE_PREFIX = '__global__:'; + +export const OVERRIDE_KEYS = ['text', 'html', 'src', 'alt', 'href'] as const; +export type OverrideKey = (typeof OVERRIDE_KEYS)[number]; +export type OverridePatch = Partial>; +export type Overrides = Record; + +export type SNode = FlatCraftNode; +export type SNodes = Record; + +export interface GlobalMaster { + name: string; + /** gkey of the master's root node (a key of `nodes`). */ + rootId: string; + /** Flat serialized subtree; ids are the nodes' gkeys; root.parent is null. */ + nodes: SNodes; + rev: number; + updatedAt: number; +} +export type GlobalLibrary = Record; + +export interface GlobalInstanceMeta { + libraryId: string; + rev: number; + overrides: Overrides; +} + +export const isGlobalZoneId = (id: string | null | undefined): boolean => + typeof id === 'string' && id.startsWith(GLOBAL_ZONE_PREFIX); +export const globalZoneId = (libraryId: string): string => GLOBAL_ZONE_PREFIX + libraryId; +export const libraryIdOfZone = (id: string): string | null => + isGlobalZoneId(id) ? id.slice(GLOBAL_ZONE_PREFIX.length) : null; + +export const newGkey = (): string => 'g' + getRandomId(8); +export const newLibraryId = (): string => 'gs-' + Date.now().toString(36) + '-' + getRandomId(4); + +const clone = (v: T): T => (v === undefined ? v : JSON.parse(JSON.stringify(v))); + +/** JSON with sorted object keys: Craft's deserialize/serialize round trip + * reorders props (defaults first), which must not read as an edit. */ +function stableStringify(v: unknown): string { + if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']'; + if (v && typeof v === 'object') { + return '{' + Object.keys(v as object).sort() + .filter((k) => (v as Record)[k] !== undefined) + .map((k) => JSON.stringify(k) + ':' + stableStringify((v as Record)[k])).join(',') + '}'; + } + return JSON.stringify(v) ?? 'null'; +} + +export function instanceMeta(node: { custom?: Record } | undefined | null): GlobalInstanceMeta | null { + const g = node?.custom?.global as GlobalInstanceMeta | undefined; + return g && typeof g.libraryId === 'string' ? g : null; +} + +export const gkeyOf = (node: { custom?: Record } | undefined | null): string | null => + typeof node?.custom?.gkey === 'string' ? (node.custom.gkey as string) : null; + +/** Ids of `rootId` and everything under it (children and linked nodes), depth first. */ +export function subtreeIds(nodes: SNodes, rootId: string): string[] { + const out: string[] = []; + const walk = (id: string) => { + const n = nodes[id]; + if (!n) return; + out.push(id); + for (const c of n.nodes || []) walk(c); + for (const c of Object.values(n.linkedNodes || {})) walk(c); + }; + walk(rootId); + return out; +} + +/** Content props of one node that an instance may override. */ +function overridable(props: Record | undefined): OverridePatch { + const out: OverridePatch = {}; + for (const k of OVERRIDE_KEYS) { + if (props && typeof props[k] === 'string') out[k] = props[k] as string; + } + return out; +} + +/** + * Build a master from a subtree of a serialized page. Every node gets a FRESH + * gkey (a subtree copied from another instance must not share its keys) and + * any nested instance marker is dropped (no nesting). Returns the master and + * `gkeyById` (original node id -> gkey) so the caller can stamp the live nodes. + */ +export function createMaster( + name: string, + pageNodes: SNodes, + rootId: string, + now: number = Date.now(), +): { master: GlobalMaster; gkeyById: Record } { + const ids = subtreeIds(pageNodes, rootId); + const gkeyById: Record = {}; + for (const id of ids) gkeyById[id] = newGkey(); + const nodes: SNodes = {}; + for (const id of ids) { + const src = pageNodes[id]; + const custom = { ...clone(src.custom || {}), gkey: gkeyById[id] }; + delete (custom as Record).global; + nodes[gkeyById[id]] = { + ...clone(src), + custom, + parent: id === rootId ? null : gkeyById[src.parent as string] ?? null, + nodes: (src.nodes || []).map((c) => gkeyById[c]).filter(Boolean), + linkedNodes: Object.fromEntries( + Object.entries(src.linkedNodes || {}).map(([k, v]) => [k, gkeyById[v]]), + ), + }; + } + return { master: { name, rootId: gkeyById[rootId], nodes, rev: 1, updatedAt: now }, gkeyById }; +} + +/** Assign the override patches onto the nodes whose gkey they target (new map; input untouched). */ +export function applyOverrides(nodes: SNodes, overrides: Overrides): SNodes { + const out: SNodes = {}; + for (const [id, n] of Object.entries(nodes)) { + const patch = overrides[gkeyOf(n) ?? '']; + out[id] = patch ? { ...n, props: { ...n.props, ...patch } } : n; + } + return out; +} + +/** + * An instance of `master`: the root id is `opts.rootId` (kept stable on sync) + * or new; inner ids are new unless `opts.reuseIds` (gkey -> id) names one; + * overrides are applied by gkey and recorded on the root's `custom.global`. + */ +export function instantiate( + libraryId: string, + master: GlobalMaster, + opts: { rootId?: string; parentId?: string | null; overrides?: Overrides; reuseIds?: Record } = {}, +): { rootId: string; nodes: SNodes } { + const overrides = clone(opts.overrides ?? {}); + const idMap: Record = {}; + for (const g of Object.keys(master.nodes)) { + idMap[g] = g === master.rootId && opts.rootId ? opts.rootId : opts.reuseIds?.[g] ?? getRandomId(); + } + const nodes: SNodes = {}; + for (const [g, src] of Object.entries(master.nodes)) { + const n = clone(src); + nodes[idMap[g]] = { + ...n, + parent: g === master.rootId ? (opts.parentId ?? null) : idMap[n.parent as string] ?? null, + nodes: (n.nodes || []).map((c) => idMap[c]), + linkedNodes: Object.fromEntries(Object.entries(n.linkedNodes || {}).map(([k, v]) => [k, idMap[v]])), + }; + } + const rootId = idMap[master.rootId]; + const applied = applyOverrides(nodes, overrides); + applied[rootId] = { + ...applied[rootId], + custom: { ...applied[rootId].custom, global: { libraryId, rev: master.rev, overrides } satisfies GlobalInstanceMeta }, + }; + return { rootId, nodes: applied }; +} + +/** + * Overrides implied by an instance's CURRENT content compared with the master + * it was built from: every overridable prop that differs. `text`/`html` travel + * together so rich text and its plain mirror never disagree. + */ +export function collectOverridesFromInstance(pageNodes: SNodes, rootId: string, master: GlobalMaster): Overrides { + const out: Overrides = {}; + for (const id of subtreeIds(pageNodes, rootId)) { + const n = pageNodes[id]; + const g = gkeyOf(n); + const m = g ? master.nodes[g] : undefined; + if (!g || !m) continue; + const mine = overridable(n.props); + const theirs = overridable(m.props); + const patch: OverridePatch = {}; + for (const k of OVERRIDE_KEYS) { + if (mine[k] !== undefined && mine[k] !== theirs[k]) patch[k] = mine[k]; + } + if ((patch.text !== undefined || patch.html !== undefined) && mine.text !== undefined) { + patch.text = mine.text; + if (mine.html !== undefined) patch.html = mine.html; + } + if (Object.keys(patch).length) out[g] = patch; + } + return out; +} + +/** Plain copy: no instance marker on the root, no gkeys below (new map). */ +export function detach(nodes: SNodes, rootId: string): SNodes { + const out: SNodes = { ...nodes }; + for (const id of subtreeIds(nodes, rootId)) { + const custom = { ...(nodes[id].custom || {}) }; + delete custom.gkey; + if (id === rootId) delete custom.global; + out[id] = { ...nodes[id], custom }; + } + return out; +} + +/** Nearest ancestor-or-self of `nodeId` that is an instance root, via a live Craft `query`. */ +export function findInstanceRoot(query: any, nodeId: string | null | undefined): string | null { + let id = nodeId ?? null; + let guard = 0; + while (id && guard++ < 500) { + let data: any; + try { data = query.node(id).get()?.data; } catch { return null; } + if (!data) return null; + if (instanceMeta(data)) return id; + id = data.parent ?? null; + } + return null; +} + +/** Same as `findInstanceRoot`, over a flat serialized map. */ +export function findInstanceRootIn(nodes: SNodes, nodeId: string): string | null { + let id: string | null = nodeId; + let guard = 0; + while (id && nodes[id] && guard++ < 500) { + if (instanceMeta(nodes[id])) return id; + id = nodes[id].parent; + } + return null; +} + +function parseState(state: string | null | undefined): SNodes | null { + if (!state) return null; + try { + const parsed = JSON.parse(state); + return parsed && typeof parsed === 'object' ? (parsed as SNodes) : null; + } catch { + return null; + } +} + +/** Instance roots in a page, outermost only. */ +function instanceRoots(nodes: SNodes): string[] { + return Object.keys(nodes).filter((id) => { + if (!instanceMeta(nodes[id])) return false; + const p = nodes[id].parent; + return !(p && findInstanceRootIn(nodes, p)); + }); +} + +/** libraryId -> number of instances in the given serialized states. */ +export function countInstances(states: Array): Record { + const out: Record = {}; + for (const s of states) { + const nodes = parseState(s); + if (!nodes) continue; + for (const id of instanceRoots(nodes)) { + const lib = instanceMeta(nodes[id])!.libraryId; + out[lib] = (out[lib] ?? 0) + 1; + } + } + return out; +} + +/** + * Rewrite every out-of-date instance in a serialized page from `library`. + * An instance is rebuilt when its `rev` differs from its master's; the root + * id and parent slot are kept and its overrides are re-applied. Inner nodes + * keep the instance's OWN id for their gkey (still unique in the page) and + * only new master nodes get fresh ids: a mounted shell (Section & co.) caches + * its linked inner-canvas id, so swapping that id under a kept root renders a + * node that no longer exists (measured: Craft crash in DefaultRender). When `previous` (the library before the change) holds the + * master at the instance's rev, edits made outside the override hook (AI + * patch, a direct prop write) are also kept, by diffing against it. + * Unchanged input returns the IDENTICAL string (no dirty churn). + */ +export function syncInstances(pageState: string, library: GlobalLibrary, previous?: GlobalLibrary): string { + const nodes = parseState(pageState); + if (!nodes) return pageState; + let changed = false; + for (const rootId of instanceRoots(nodes)) { + const meta = instanceMeta(nodes[rootId])!; + const master = library[meta.libraryId]; + if (!master || master.rev === meta.rev) continue; + const before = previous?.[meta.libraryId]; + const overrides: Overrides = clone(meta.overrides || {}); + if (before && before.rev === meta.rev) { + for (const [g, patch] of Object.entries(collectOverridesFromInstance(nodes, rootId, before))) { + overrides[g] = { ...overrides[g], ...patch }; + } + } + const parentId = nodes[rootId].parent; + const reuseIds: Record = {}; + for (const id of subtreeIds(nodes, rootId)) { + const g = gkeyOf(nodes[id]); + if (g && id !== rootId && !(g in reuseIds)) reuseIds[g] = id; + delete nodes[id]; + } + Object.assign(nodes, instantiate(meta.libraryId, master, { rootId, parentId, overrides, reuseIds }).nodes); + changed = true; + } + return changed ? JSON.stringify(nodes) : pageState; +} + +/** Serialized state for editing a master in the canvas: a plain ROOT holding the master. */ +export function masterZoneState(master: GlobalMaster): string { + const nodes: SNodes = clone(master.nodes); + nodes[master.rootId] = { ...nodes[master.rootId], parent: 'ROOT' }; + nodes.ROOT = { + type: { resolvedName: 'Container' }, + isCanvas: true, + props: { style: { backgroundColor: '#ffffff', minHeight: '200px' }, tag: 'div' }, + displayName: 'Container', + custom: {}, + hidden: false, + parent: null, + nodes: [master.rootId], + linkedNodes: {}, + }; + return JSON.stringify(nodes); +} + +/** + * The master as edited in the zone. Takes ROOT's FIRST child (`dropped` counts + * any other top-level nodes, which are not part of the master), keys nodes by + * gkey (new nodes, and duplicates that copied a gkey, get fresh ones) and + * strips instance markers. `changed` is false when the content is identical + * to `prev` -- then `master` is `prev` (same rev, no sync needed). + * `baselineState` is the zone as first loaded: Craft's deserialize fills in + * component defaults (e.g. on a shell's inner container), so "unchanged" is + * judged against that canonical form when given, not the stored master. + */ +export function masterFromZoneState( + zoneState: string, + prev: GlobalMaster, + now: number = Date.now(), + baselineState?: string | null, +): { master: GlobalMaster; changed: boolean; dropped: number } | null { + const nodes = parseState(zoneState); + const top = nodes?.ROOT?.nodes || []; + if (!nodes || top.length === 0) return null; + const rootId = top[0]; + const ids = subtreeIds(nodes, rootId); + const seen = new Set(); + const keyOf: Record = {}; + for (const id of ids) { + let g = gkeyOf(nodes[id]); + if (!g || seen.has(g)) g = newGkey(); + seen.add(g); + keyOf[id] = g; + } + const out: SNodes = {}; + for (const id of ids) { + const n = nodes[id]; + const custom = { ...(n.custom || {}), gkey: keyOf[id] }; + delete (custom as Record).global; + out[keyOf[id]] = { + ...n, + custom, + parent: id === rootId ? null : keyOf[n.parent as string] ?? null, + nodes: (n.nodes || []).map((c) => keyOf[c]), + linkedNodes: Object.fromEntries(Object.entries(n.linkedNodes || {}).map(([k, v]) => [k, keyOf[v]])), + }; + } + const base = baselineState ? masterFromZoneState(baselineState, prev, now) : null; + const reference = base ? base.master.nodes : prev.nodes; + const changed = keyOf[rootId] !== prev.rootId || stableStringify(out) !== stableStringify(reference); + return { + master: changed ? { ...prev, rootId: keyOf[rootId], nodes: out, rev: prev.rev + 1, updatedAt: now } : prev, + changed, + dropped: top.length - 1, + }; +} From dcc7e27be4d1d79eb60c254e88b0776f880b9585 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 12:35:56 -0700 Subject: [PATCH 2/2] fix(site-builder): refuse Sitesmith page replace while editing a global master A page-scope Sitesmith replace with a master zone open loaded the AI page onto the zone canvas. On Done, its first section became the new master and the sync rewrote every instance on every page with it, orphaning all overrides. replaceCurrentPage now returns false inside a master zone and the apply path reports an error asking the user to click Done first. Refs #57 Co-Authored-By: Claude Opus 5.5 --- .../src/state/GlobalSectionsContext.test.tsx | 52 +++++++++++++++++++ craft/src/state/PageContext.tsx | 14 +++-- craft/src/utils/apply-ai-response.ts | 7 ++- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/craft/src/state/GlobalSectionsContext.test.tsx b/craft/src/state/GlobalSectionsContext.test.tsx index c30c1b5..53de5ae 100644 --- a/craft/src/state/GlobalSectionsContext.test.tsx +++ b/craft/src/state/GlobalSectionsContext.test.tsx @@ -16,6 +16,7 @@ import { GuidedStyles } from '../panels/right/GuidedStyles'; import { ContextMenu } from '../panels/context-menu/ContextMenu'; import { SectionLibraryModal, openSectionLibrary, closeSectionLibrary } from '../panels/sections/SectionLibraryModal'; import { Canvas } from '../editor/Canvas'; +import { useApplyAiResponse } from '../utils/apply-ai-response'; /** * #57 global sections against the REAL Craft editor + PageProvider + @@ -28,12 +29,14 @@ let pages: ReturnType; let globals: ReturnType; let extras: ReturnType; let guard: ReturnType; +let applyAi: ReturnType; const Probe: React.FC = () => { pages = usePages(); globals = useGlobalSections(); extras = useProjectExtras(); guard = useSaveGuard(); + applyAi = useApplyAiResponse(); return null; }; @@ -306,6 +309,55 @@ describe('#57 global sections', () => { expect(h!.query.history.canUndo()).toBe(false); }); + test('Sitesmith page-scope replace is refused while a master is open: no cross-page rewrite', async () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + h!.act(() => { pages.addPage('About', 'about'); }); + await flush(); + let inst2 = ''; + h!.act(() => { inst2 = globals.insertInstance(libraryId)!; }); + const aiPage = { + name: 'AI page', + tree: { + type: { resolvedName: 'Container' }, props: {}, + nodes: [{ type: { resolvedName: 'Section' }, props: {}, nodes: [ + { type: { resolvedName: 'Heading' }, props: { text: 'AI takeover' } }, + ] }], + }, + } as any; + + h!.act(() => { pages.editGlobal(libraryId); }); + await flush(); + const zoneBefore = h!.getSerialized(); + let r: any; + await h!.act(async () => { r = await applyAi({ type: 'replace', scope: 'page', pages: [aiPage] } as any); }); + await flush(); + expect(r.ok).toBe(false); + expect(r.message).toMatch(/global section/i); + expect(pages.isEditingGlobal).toBe(true); + expect(h!.getSerialized()).toBe(zoneBefore); + expect(pages.pages.map((p) => p.name)).toEqual(['Home', 'About']); + + h!.act(() => { globals.finishEditingMaster(); }); + await flush(); + expect(lib()[libraryId].rev).toBe(1); + const p2 = live(); + expect(instanceRoots(p2)).toEqual([inst2]); + expect(JSON.stringify(p2)).not.toContain('AI takeover'); + expect(JSON.stringify(p2)).toContain('Welcome'); + const p1 = pages.pages[0].craftState!; + expect(p1).not.toContain('AI takeover'); + expect(instanceMeta((JSON.parse(p1) as SNodes)[sec])!.rev).toBe(1); + + // control: the same response on a normal page still replaces it + await h!.act(async () => { r = await applyAi({ type: 'replace', scope: 'page', pages: [aiPage] } as any); }); + await flush(); + expect(r.ok).toBe(true); + expect(h!.getSerialized()).toContain('AI takeover'); + }); + test('remove is refused while instances exist, allowed once none are left', () => { mount(); const sec = addPromo(); diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 62462dc..4855637 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -83,7 +83,8 @@ interface PageContextValue { setActivePageIdDirect: (pageId: string) => void; /** AI helpers — replace entire site or page with a new tree */ replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void; - replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void; + /** False (nothing changed) while a global-section master is on the canvas (#57). */ + replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => boolean; setHeader: (tree: SerializedTreeNode) => void; setFooter: (tree: SerializedTreeNode) => void; siteDesign: SiteDesign; @@ -267,7 +268,7 @@ const PageContext = createContext({ setPagesCraftState: () => {}, setActivePageIdDirect: () => {}, replaceAllPages: () => {}, - replaceCurrentPage: () => {}, + replaceCurrentPage: () => false, setHeader: () => {}, setFooter: () => {}, siteDesign: {} as SiteDesign, @@ -889,9 +890,13 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => * AI helper: replace the current page's tree. * Deserializes the new tree into the live Craft.js canvas and persists it. */ - const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }) => { - const craftState = treeToCraftState(page.tree); + const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }): boolean => { const currentId = activePageIdRef.current; + // #57: a master zone is not a page. Loading a page tree here would make + // its first section the new master on Done, and the sync would then + // rewrite every instance on every page with it. Refuse instead. + if (isGlobalZoneId(currentId)) return false; + const craftState = treeToCraftState(page.tree); if (currentId === HEADER_ID) { setHeaderPage((prev) => ({ ...prev, name: page.name, craftState })); } else if (currentId === FOOTER_ID) { @@ -902,6 +907,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => ); } loadState(craftState, EMPTY_CANVAS, { keepHistory: true }); + return true; }, [loadState]); /** diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 1b2b555..0fe283e 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -65,7 +65,12 @@ export function useApplyAiResponse() { } if (resp.scope === 'page') { - pages.replaceCurrentPage(resp.pages[0]); + if (!pages.replaceCurrentPage(resp.pages[0])) { + return { + ok: false, + message: 'You are editing a global section. Click Done, then ask again to replace the page.', + }; + } return { ok: true }; }