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:
1 parent
48061d37ae
commit
dd8b99be06
15 files changed
+1898
-29
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user