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>
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
import { useEditor } from '@craftjs/core';
|
|
import { Modal } from '../../ui/Modal';
|
|
import { useSiteDesign } from '../../state/SiteDesignContext';
|
|
import { useProjectExtras } from '../../state/ProjectExtrasContext';
|
|
import { useNotices } from '../../state/NoticeContext';
|
|
import { useRegisterCommands } from '../../editor/commands';
|
|
import {
|
|
CUSTOM_SECTIONS_KEY, CustomSection, SECTION_FAMILIES, SectionDef, SectionFamily,
|
|
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 ----
|
|
interface LibraryState { open: boolean; saving: boolean }
|
|
let state: LibraryState = { open: false, saving: false };
|
|
const listeners = new Set<() => void>();
|
|
const setState = (s: LibraryState) => { state = s; listeners.forEach((l) => l()); };
|
|
const subscribe = (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; };
|
|
|
|
export function openSectionLibrary(opts: { saving?: boolean } = {}): void {
|
|
setState({ open: true, saving: !!opts.saving });
|
|
}
|
|
export function closeSectionLibrary(): void { setState({ open: false, saving: false }); }
|
|
export function useSectionLibraryState(): LibraryState {
|
|
return useSyncExternalStore(subscribe, () => state, () => state);
|
|
}
|
|
|
|
/** Renders its iframe only once scrolled into view (60 live iframes at once is heavy). */
|
|
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');
|
|
useEffect(() => {
|
|
if (seen || !ref.current) return;
|
|
const io = new IntersectionObserver((entries) => {
|
|
if (entries.some((e) => e.isIntersecting)) { setSeen(true); io.disconnect(); }
|
|
});
|
|
io.observe(ref.current);
|
|
return () => io.disconnect();
|
|
}, [seen]);
|
|
const doc = useMemo(() => {
|
|
if (!seen) return '';
|
|
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 && (
|
|
<iframe
|
|
className="section-lib-frame"
|
|
title={title}
|
|
sandbox=""
|
|
srcDoc={doc}
|
|
tabIndex={-1}
|
|
loading="lazy"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const SectionLibraryModal: React.FC = () => {
|
|
const { open, saving } = useSectionLibraryState();
|
|
const { query, actions, selectedId } = useEditor((s) => ({ selectedId: Array.from(s.events.selected || [])[0] as string | undefined }));
|
|
const extras = useProjectExtras();
|
|
const { notify } = useNotices();
|
|
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('');
|
|
|
|
const canSave = open && isTopLevelSection(query, selectedId);
|
|
|
|
useRegisterCommands([
|
|
{
|
|
id: 'insert.section-library',
|
|
title: 'Browse sections…',
|
|
group: 'insert',
|
|
keywords: ['section', 'library', 'hero', 'faq', 'template', 'add section'],
|
|
run: () => openSectionLibrary(),
|
|
},
|
|
{
|
|
id: 'site.save-as-section',
|
|
title: 'Save selection as section…',
|
|
group: 'site',
|
|
keywords: ['section', 'library', 'save', 'reusable'],
|
|
when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]),
|
|
run: () => openSectionLibrary({ saving: true }),
|
|
},
|
|
], []);
|
|
|
|
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]);
|
|
|
|
const onPick = useCallback((def: SectionDef) => {
|
|
try {
|
|
const id = insertSection(query, actions, def, { afterSelection: true, select: true });
|
|
closeSectionLibrary();
|
|
notify({ kind: 'success', message: `Added "${def.name}"` });
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
try { query.node(id).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* best effort */ }
|
|
}));
|
|
} catch (e) {
|
|
console.error('Failed to insert section:', e);
|
|
notify({ kind: 'error', message: 'Could not add that section.' });
|
|
}
|
|
}, [query, actions, notify]);
|
|
|
|
const onSave = () => {
|
|
const name = saveName.trim();
|
|
if (!name || !selectedId) return;
|
|
try {
|
|
const entry: CustomSection = {
|
|
id: `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
name,
|
|
family: 'custom',
|
|
tree: nodeTreeToSerializedTree(query, selectedId),
|
|
createdAt: Date.now(),
|
|
};
|
|
extras.update<CustomSection[]>(CUSTOM_SECTIONS_KEY, (prev) => [...prev, entry], []);
|
|
setVersion((v) => v + 1);
|
|
setSaveName('');
|
|
setFamily('custom');
|
|
openSectionLibrary({ saving: false });
|
|
notify({ kind: 'success', message: `Saved "${name}" to My sections` });
|
|
} catch (e) {
|
|
console.error('Failed to save section:', e);
|
|
notify({ kind: 'error', message: 'Could not save that section.' });
|
|
}
|
|
};
|
|
|
|
const onDelete = (id: string) => {
|
|
extras.update<CustomSection[]>(CUSTOM_SECTIONS_KEY, (prev) => prev.filter((c) => c.id !== id), []);
|
|
setVersion((v) => v + 1);
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onClose={closeSectionLibrary}
|
|
width="min(960px, 94vw)"
|
|
backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Section library' }}
|
|
>
|
|
<div className="section-lib">
|
|
<div className="section-lib-head">
|
|
<h2>Add a section</h2>
|
|
<button type="button" className="section-lib-close" aria-label="Close" onClick={closeSectionLibrary}>
|
|
<i className="fa fa-times" aria-hidden />
|
|
</button>
|
|
</div>
|
|
<div className="section-lib-controls">
|
|
<input
|
|
type="search"
|
|
className="section-lib-search"
|
|
placeholder="Search sections…"
|
|
aria-label="Search sections"
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
/>
|
|
{canSave && !saving && (
|
|
<button type="button" className="section-lib-save-btn" onClick={() => openSectionLibrary({ saving: true })}>
|
|
<i className="fa fa-bookmark" aria-hidden /> Save current section
|
|
</button>
|
|
)}
|
|
</div>
|
|
{saving && canSave && (
|
|
<form className="section-lib-save" onSubmit={(e) => { e.preventDefault(); onSave(); }}>
|
|
<input
|
|
className="section-lib-search"
|
|
placeholder="Name this section"
|
|
aria-label="Section name"
|
|
value={saveName}
|
|
autoFocus
|
|
onChange={(e) => setSaveName(e.target.value)}
|
|
/>
|
|
<button type="submit" className="section-lib-save-btn" disabled={!saveName.trim()}>Save</button>
|
|
<button type="button" className="section-lib-save-btn" onClick={() => openSectionLibrary({ saving: false })}>Cancel</button>
|
|
</form>
|
|
)}
|
|
<div className="section-lib-chips" role="tablist" aria-label="Section families">
|
|
<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={!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}>
|
|
<button type="button" className="section-lib-pick" onClick={() => onPick(def)} aria-label={`Add ${def.name}`}>
|
|
<LazyPreview def={def} title={`${def.name} preview`} />
|
|
<span className="section-lib-name">{def.name}</span>
|
|
</button>
|
|
{def.family === 'custom' && (
|
|
<button type="button" className="section-lib-delete" aria-label={`Delete ${def.name}`} onClick={() => onDelete(def.id)}>
|
|
<i className="fa fa-trash" aria-hidden />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{results.length === 0 && (
|
|
<div className="section-lib-empty">
|
|
{family === 'custom' ? 'Nothing saved yet. Select a section on the canvas and choose "Save current section".' : `No sections match "${q.trim()}"`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|