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__:<id> 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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 12:28:53 -07:00
1 parent 48061d37ae
commit dd8b99be06
15 files changed
+1898 -29

No files matched your search

+3
View File
@@ -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<AppProps> = ({ whpConfig }) => {
<CanvasTokenStyle />
<SaveAwareEditor>
<PageProvider>
<GlobalSectionsProvider>
<SitesmithProvider>
<EditorShell />
<CanvasOverlays />
<SitesmithModalMount />
</SitesmithProvider>
</GlobalSectionsProvider>
</PageProvider>
</SaveAwareEditor>
</SiteDesignProvider>
@@ -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<HTMLElement | null>(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(() => {
+28 -2
View File
@@ -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<CanvasProps> = ({ 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<CanvasProps> = ({ showGuides }) => {
</div>
)}
{isEditingGlobal && globalEditingId && (
<div className="global-master-banner" data-testid="global-master-banner">
<i className="fa fa-globe" aria-hidden />
<span className="global-master-banner-text">
Editing global section: <strong>{globals.library[globalEditingId]?.name ?? 'Global section'}</strong>
{' '}-- changes apply everywhere it is used
</span>
<button
type="button"
className="global-master-banner-done"
data-testid="global-master-done"
onClick={() => globals.finishEditingMaster()}
>
Done - update {instanceCount} {instanceCount === 1 ? 'instance' : 'instances'}
</button>
</div>
)}
{isEditingRegularPage && (
<ZonePreview craftState={headerPage.craftState} zone="header" />
)}
+35 -4
View File
@@ -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<RenderNodeProps> = ({ 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<RenderNodeProps> = ({ 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<RenderNodeProps> = ({ 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 ? <ResolvedRender device={device} /> : render;
const output: React.ReactElement = (
<GlobalInstanceContext.Provider value={instanceRoot}>
{isComponent ? <ResolvedRender device={device} /> : render}
</GlobalInstanceContext.Provider>
);
if (!active) return <>{output}</>;
if (!active) return output;
return (
<>
{output}
{createPortal(
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
{isInstanceRoot && (
<span className="component-indicator-global" data-testid="global-badge" title="Global section: edit the master to change it everywhere">
<i className="fa fa-globe" aria-hidden /> Global
</span>
)}
{isInstanceInner && (
<i className="fa fa-lock component-indicator-lock" aria-label="Part of a global section" title="Part of a global section: text and images are per-page overrides; design comes from the master" />
)}
<span>{name}</span>
{parent && (
<button
+4 -1
View File
@@ -8,6 +8,7 @@ import { repairOrphanNodes } from '../utils/orphan-repair';
import { PageData } from '../types';
import { useSaveGuard } from '../state/SaveGuardContext';
import { useProjectExtras } from '../state/ProjectExtrasContext';
import { isGlobalZoneId } from '../utils/global-sections';
/**
* Per-state export memo. A craft-state string fully determines its exported
@@ -74,7 +75,9 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
extras,
} = input;
const isPageActive = !isEditingHeader && !isEditingFooter;
// #57: a global-section master zone is not a page either -- its live
// canvas must never land in a page slot (the master is saved via extras).
const isPageActive = !isEditingHeader && !isEditingFooter && !isGlobalZoneId(activePageId);
// Stored states touched by this build; the cache is pruned to this set.
const usedStates = new Set<string>();
+87 -6
View File
@@ -14,7 +14,40 @@ import {
applyStyleSnapshot, deletePreset, extractStyle, getStyleClipboard, isCompatible,
listPresets, nodeTypeOf, savePreset, setStyleClipboard, type StylePreset,
} from '../../utils/style-clipboard';
import { PresetsPopover, PRESETS_POPOVER_ATTR } from './PresetsPopover';
import { PresetsPopover, PRESETS_POPOVER_ATTR, clampToViewport } from './PresetsPopover';
import { useGlobalSections } from '../../state/GlobalSectionsContext';
import { usePages } from '../../state/PageContext';
import { isTopLevelSection } from '../../sections/catalog';
/** #57: name prompt for "Make global section…" (same chrome as the presets popover). */
const GlobalNamePopover: React.FC<{ x: number; y: number; initial: string; onSave: (name: string) => void; onClose: () => void }> = ({
x, y, initial, onSave, onClose,
}) => {
const [name, setName] = useState(initial);
const pos = clampToViewport(x, y, 220, 120);
const submit = () => { if (name.trim()) onSave(name.trim()); };
return (
<div
{...{ [PRESETS_POPOVER_ATTR]: 'true' }}
className="wsb-presets-popover"
role="dialog"
aria-label="Make global section"
style={{ position: 'fixed', ...pos, zIndex: 10001 }}
>
<label className="wsb-presets-label" htmlFor="wsb-global-name">Global section name</label>
<input
id="wsb-global-name"
className="wsb-presets-input"
autoFocus
value={name}
maxLength={60}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose(); }}
/>
<button type="button" className="wsb-presets-btn" disabled={!name.trim()} onClick={submit}>Make global</button>
</div>
);
};
interface ContextMenuProps {
visible: boolean;
@@ -54,7 +87,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
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<ContextMenuProps> = ({
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<ContextMenuProps> = ({
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<ContextMenuProps> = ({
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<ContextMenuProps> = ({
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<ContextMenuProps> = ({
{popover && popover.mode === 'save' && (
<PresetsPopover mode="save" x={popover.x} y={popover.y} onClose={() => setPopover(null)} onSave={saveAsPreset} />
)}
{popover && popover.mode === 'global' && (
<GlobalNamePopover
x={popover.x} y={popover.y} initial={defaultGlobalName}
onClose={() => setPopover(null)} onSave={makeGlobal}
/>
)}
{popover && popover.mode === 'apply' && (
<PresetsPopover
mode="apply" x={popover.x} y={popover.y} onClose={() => setPopover(null)}
+93 -2
View File
@@ -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 (
<label className="global-override-field">
<span className="global-override-label">{label}</span>
<input
className="global-override-input"
aria-label={`Override ${label.toLowerCase()}`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') commit(); }}
/>
</label>
);
};
/**
* #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<string, any> }> = ({ 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 (
<div className="guided-section global-instance-panel" data-testid="global-instance-panel">
<div className="global-instance-head">
<i className="fa fa-globe" aria-hidden />
<span>Global section: <strong>{info.name}</strong></span>
</div>
<div className="global-instance-actions">
<button type="button" className="global-instance-btn" onClick={() => globals.editMaster(info.libraryId)}>
<i className="fa fa-pencil" aria-hidden /> Edit master
</button>
<button type="button" className="global-instance-btn" onClick={() => globals.detach(selectedId)}>
<i className="fa fa-chain-broken" aria-hidden /> Detach
</button>
</div>
<p className="global-instance-hint">Design comes from the master. Text, images and links can be overridden on this page.</p>
{info.gkey && fields.length > 0 && (
<div className="global-override-fields">
<div className="global-override-title">
Override {info.overridden.length > 0 && <span className="global-override-badge">overridden</span>}
</div>
{fields.map((f) => (
<OverrideField key={f.key} label={f.label} value={String(nodeProps[f.key] ?? '')} onCommit={(v) => commit(f.key, v)} />
))}
{info.overridden.length > 0 && (
<button type="button" className="global-instance-btn" onClick={() => globals.resetOverride(selectedId)}>
<i className="fa fa-undo" aria-hidden /> Reset to master
</button>
)}
</div>
)}
</div>
);
};
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<{
<i className="fa fa-magic" /> Ask Sitesmith
</button>
</div>
{inInstance && <GlobalInstancePanel selectedId={selected} nodeProps={nodeProps} />}
{!inInstance && <>
<DeviceBanner />
{/* TEXT */}
@@ -250,6 +340,7 @@ export const GuidedStyles: React.FC<{
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && (
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
)}
</>}
</div>
);
};
@@ -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<HTMLDivElement>(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 (
<div className="section-lib-thumb" ref={ref}>
{doc && (
@@ -68,6 +75,10 @@ export const SectionLibraryModal: React.FC = () => {
const [version, setVersion] = useState(0);
const sections = useSections(version, open);
const [family, setFamily] = useState<SectionFamily | null>(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 = () => {
</form>
)}
<div className="section-lib-chips" role="tablist" aria-label="Section families">
<button type="button" role="tab" aria-selected={family === null} className={`section-lib-chip${family === null ? ' active' : ''}`} onClick={() => setFamily(null)}>All</button>
<button type="button" role="tab" aria-selected={!globalTab && family === null} className={`section-lib-chip${!globalTab && family === null ? ' active' : ''}`} onClick={() => { setGlobalTab(false); setFamily(null); }}>All</button>
{SECTION_FAMILIES.map((f) => (
<button
key={f.id}
type="button"
role="tab"
aria-selected={family === f.id}
className={`section-lib-chip${family === f.id ? ' active' : ''}`}
onClick={() => setFamily(f.id)}
aria-selected={!globalTab && family === f.id}
className={`section-lib-chip${!globalTab && family === f.id ? ' active' : ''}`}
onClick={() => { setGlobalTab(false); setFamily(f.id); }}
>{f.label}</button>
))}
<button
type="button"
role="tab"
aria-selected={globalTab}
className={`section-lib-chip section-lib-chip-global${globalTab ? ' active' : ''}`}
onClick={() => setGlobalTab(true)}
><i className="fa fa-globe" aria-hidden /> Global</button>
</div>
{globalTab ? (
<div className="section-lib-grid">
{globalEntries.map(([id, m]) => (
<div key={id} className="section-lib-card" data-global-id={id}>
<button type="button" className="section-lib-pick" onClick={() => onPickGlobal(id, m.name)} aria-label={`Add ${m.name}`}>
<LazyPreview master={m} title={`${m.name} preview`} />
<span className="section-lib-name">{m.name}</span>
<span className="section-lib-usage" data-testid="global-usage">
Used {usage[id] ?? 0} {(usage[id] ?? 0) === 1 ? 'time' : 'times'}
</span>
</button>
<button type="button" className="section-lib-delete" aria-label={`Delete ${m.name}`} onClick={() => onDeleteGlobal(id, m.name)}>
<i className="fa fa-trash" aria-hidden />
</button>
</div>
))}
{globalEntries.length === 0 && (
<div className="section-lib-empty">
No global sections yet. Right-click a section on the canvas and choose "Make global section".
</div>
)}
</div>
) : (
<div className="section-lib-grid">
{results.map((def) => (
<div key={def.id} className="section-lib-card" data-section-id={def.id}>
@@ -213,6 +276,7 @@ export const SectionLibraryModal: React.FC = () => {
</div>
)}
</div>
)}
</div>
</Modal>
);
+51
View File
@@ -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<string | null>(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<string, unknown> = {};
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<P = any>(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]);
}
@@ -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<typeof usePages>;
let globals: ReturnType<typeof useGlobalSections>;
let extras: ReturnType<typeof useProjectExtras>;
let guard: ReturnType<typeof useSaveGuard>;
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) => (
<SaveGuardProvider><NoticeProvider><ProjectExtrasProvider><SiteDesignProvider>{tree}</SiteDesignProvider></ProjectExtrasProvider></NoticeProvider></SaveGuardProvider>
),
});
h.mountChild(<PageProvider><GlobalSectionsProvider><Probe />{ui}</GlobalSectionsProvider></PageProvider>);
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<GlobalLibrary>(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 <b>page one</b>'; 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 <b>page one</b>' });
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(<GuidedStyles />);
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(
<PageProvider><GlobalSectionsProvider><Probe /><ContextMenu visible x={10} y={10} nodeId={nodeId} onClose={() => {}} /></GlobalSectionsProvider></PageProvider>,
);
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(<SectionLibraryModal />);
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(<Canvas showGuides />);
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');
});
});
});
+243
View File
@@ -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<string, number>;
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<GlobalSectionsValue>(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<GlobalLibrary>(GLOBAL_SECTIONS_KEY, {});
const getLib = useCallback(() => extras.get<GlobalLibrary>(GLOBAL_SECTIONS_KEY, {}), [extras]);
const usage = useCallback(() => {
const live = query.serialize();
const id = pages.activePageId;
const states: Array<string | null> = [
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<GlobalLibrary>(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<string, any> = {};
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<string>(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<string, unknown>;
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<GlobalLibrary>(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<GlobalSectionsValue>(() => ({
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 <GlobalSectionsContext.Provider value={value}>{children}</GlobalSectionsContext.Provider>;
};
+159 -4
View File
@@ -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__:<id>`). */
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<PageContextValue>({
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<PageData[]>([DEFAULT_PAGE]);
const [headerPage, setHeaderPage] = useState<PageData>(DEFAULT_HEADER);
const [footerPage, setFooterPage] = useState<PageData>(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<string | null>(null);
/** The master zone as first deserialized: the "unchanged" reference. */
const zoneBaselineRef = useRef<string | null>(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<GlobalLibrary>(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<GlobalLibrary>(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,
+45
View File
@@ -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); }
+253
View File
@@ -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<string, unknown>, extra: Partial<SNode> = {}): 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: '<b>Mine</b>' } });
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: '<b>Mine</b>' });
// 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('<b>Mine</b>');
});
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 = '<i>Hello</i>';
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: '<i>Hello</i>' },
[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();
});
});
+391
View File
@@ -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<Record<OverrideKey, string>>;
export type Overrides = Record<string, OverridePatch>;
export type SNode = FlatCraftNode;
export type SNodes = Record<string, SNode>;
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<string, GlobalMaster>;
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 = <T,>(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<string, unknown>)[k] !== undefined)
.map((k) => JSON.stringify(k) + ':' + stableStringify((v as Record<string, unknown>)[k])).join(',') + '}';
}
return JSON.stringify(v) ?? 'null';
}
export function instanceMeta(node: { custom?: Record<string, unknown> } | 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<string, unknown> } | 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<string, unknown> | 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<string, string> } {
const ids = subtreeIds(pageNodes, rootId);
const gkeyById: Record<string, string> = {};
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<string, unknown>).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<string, string> } = {},
): { rootId: string; nodes: SNodes } {
const overrides = clone(opts.overrides ?? {});
const idMap: Record<string, string> = {};
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<string | null | undefined>): Record<string, number> {
const out: Record<string, number> = {};
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<string, string> = {};
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<string>();
const keyOf: Record<string, string> = {};
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<string, unknown>).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,
};
}