From dd8b99be061a41e9b2f1ed17f5efb70edacec728 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 12:28:53 -0700 Subject: [PATCH] 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, + }; +}