Files
site-builder/craft/src/panels/context-menu/ContextMenu.tsx
T
shadowdaoandClaude Opus 5.5 dd8b99be06 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>
2026-10-07 12:28:53 -07:00

516 lines
17 KiB
TypeScript

import React, { useEffect, useCallback, useRef, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
import { getCommand } from '../../editor/commands';
import { formatShortcut } from '../../utils/shortcuts';
import { useNodeActions } from '../../hooks/useNodeActions';
import { useNotices } from '../../state/NoticeContext';
import { useProjectExtras } from '../../state/ProjectExtrasContext';
import { useDevice } from '../../state/DeviceContext';
import {
applyStyleSnapshot, deletePreset, extractStyle, getStyleClipboard, isCompatible,
listPresets, nodeTypeOf, savePreset, setStyleClipboard, type StylePreset,
} from '../../utils/style-clipboard';
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;
x: number;
y: number;
nodeId: string | null;
onClose: () => void;
}
interface MenuItem {
label: string;
/** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */
icon?: string;
shortcut?: string;
action: () => void;
danger?: boolean;
disabled?: boolean;
dividerAfter?: boolean;
}
/** Shortcut label for a registry command, so the menu never drifts from the keymap. */
const shortcutLabel = (id: string): string | undefined => {
const spec = getCommand(id)?.shortcut;
return spec ? formatShortcut(spec) : undefined;
};
export const ContextMenu: React.FC<ContextMenuProps> = ({
visible,
x,
y,
nodeId,
onClose,
}) => {
const { actions, query } = useEditor();
const { open: openSitesmith } = useSitesmithModal();
const menuRef = useRef<HTMLDivElement>(null);
const { notify } = useNotices();
const extras = useProjectExtras();
const { device } = useDevice();
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
useEffect(() => {
if (!visible) return;
const handleClick = (e: MouseEvent) => {
const t = e.target as Element;
if (t.closest?.(`[${PRESETS_POPOVER_ATTR}]`)) return;
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleEsc);
};
}, [visible, onClose]);
const getParentId = useCallback((): string | null => {
if (!nodeId) return null;
try {
const node = query.node(nodeId).get();
return node?.data?.parent || null;
} catch {
return null;
}
}, [nodeId, query]);
// Shared move/duplicate/delete/select-parent logic (item 1, Phase B) --
// extracted into `useNodeActions` so the mobile selection toolbar drives
// the exact same behavior. Wrapped here purely to also `onClose()` the
// menu after each action, same as before the extraction.
const nodeActions = useNodeActions(nodeId);
const duplicate = useCallback(() => {
nodeActions.duplicate();
onClose();
}, [nodeActions, onClose]);
const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
// Store a detached subtree snapshot, not just the id -- an id-based
// clipboard can't survive a page switch (the copied id no longer
// exists in `query` once the canvas is re-deserialized to a different
// page's Craft.js state). See clipboard.ts.
setClipboardTree(query.node(nodeId).toNodeTree());
} catch (e) {
console.error('Copy failed:', e);
}
onClose();
}, [nodeId, query, onClose]);
const pasteNode = useCallback(() => {
const clip = getClipboardTree();
if (!clip) {
onClose();
return;
}
try {
// Paste as a SIBLING of the right-clicked node (immediately after it,
// matching duplicate()'s UX), not as its child -- using the clicked
// node itself as the parent throws when it's a leaf. Falls back to
// appending into ROOT when nothing valid was right-clicked. This works
// regardless of which page is on the canvas -- the clipboard tree is a
// detached snapshot, not a reference to a node that may not exist here.
let targetParent = 'ROOT';
let insertIndex: number | undefined;
if (nodeId && nodeId !== 'ROOT') {
const clickedNode = query.node(nodeId).get();
const parentId: string | null | undefined = clickedNode?.data?.parent;
if (parentId) {
targetParent = parentId;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(nodeId);
if (idx !== -1) insertIndex = idx + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
}
}
const tree = regenerateTreeIds(clip);
if (insertIndex !== undefined) {
actions.addNodeTree(tree, targetParent, insertIndex);
} else {
actions.addNodeTree(tree, targetParent);
}
actions.selectNode(tree.rootNodeId);
} catch (e) {
console.error('Paste failed:', e);
}
onClose();
}, [nodeId, actions, query, onClose]);
const targetType = nodeId && nodeId !== 'ROOT' ? nodeTypeOf(query, nodeId) : null;
const copyStyle = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const data = query.node(nodeId).get().data;
setStyleClipboard(extractStyle(data.name, data.props));
notify({ kind: 'success', message: 'Style copied' });
} catch (e) {
console.error('Copy style failed:', e);
}
onClose();
}, [nodeId, query, notify, onClose]);
const pasteStyle = useCallback((mode: 'replace' | 'merge') => {
const snap = getStyleClipboard();
if (snap && nodeId && nodeId !== 'ROOT') {
const n = applyStyleSnapshot(actions, [nodeId], snap, mode, { query, device });
notify(n ? { kind: 'success', message: mode === 'merge' ? 'Style merged' : 'Style pasted' } : { kind: 'info', message: 'That style does not fit this element' });
}
onClose();
}, [nodeId, actions, query, device, notify, onClose]);
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) });
};
const saveAsPreset = (name: string) => {
if (!nodeId) return;
try {
const data = query.node(nodeId).get().data;
const saved = savePreset(extras, { name, snapshot: extractStyle(data.name, data.props) });
if (saved) notify({ kind: 'success', message: `Preset "${saved.name}" saved` });
} catch (e) {
console.error('Save preset failed:', e);
}
onClose();
};
const applyPreset = (p: StylePreset) => {
if (nodeId) {
applyStyleSnapshot(actions, [nodeId], p.snapshot, 'replace', { query, device });
notify({ kind: 'success', message: `Preset "${p.name}" applied` });
}
onClose();
};
const moveUp = useCallback(() => {
nodeActions.moveUp();
onClose();
}, [nodeActions, onClose]);
const moveDown = useCallback(() => {
nodeActions.moveDown();
onClose();
}, [nodeActions, onClose]);
const selectParent = useCallback(() => {
nodeActions.selectParent();
onClose();
}, [nodeActions, onClose]);
const askSitesmith = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const target = buildSitesmithTarget(query, nodeId);
if (target) openSitesmith(target);
} catch (e) {
console.error('Ask Sitesmith failed:', e);
}
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();
}, [nodeActions, onClose]);
if (!visible) return null;
const isRoot = nodeId === 'ROOT' || !nodeId;
const items: MenuItem[] = [
{
label: 'Ask Sitesmith',
icon: 'magic',
action: askSitesmith,
disabled: isRoot,
dividerAfter: true,
},
{
label: 'Duplicate',
icon: 'clone',
shortcut: shortcutLabel('edit.duplicate'),
action: duplicate,
disabled: isRoot,
},
{
label: 'Copy',
icon: 'files-o',
shortcut: shortcutLabel('edit.copy'),
action: copyNode,
disabled: isRoot,
},
{
label: 'Paste',
icon: 'clipboard',
shortcut: shortcutLabel('edit.paste'),
action: pasteNode,
disabled: !getClipboardTree(),
},
{
label: 'Copy style',
icon: 'paint-brush',
action: copyStyle,
disabled: isRoot,
},
{
label: 'Paste style',
icon: 'magic',
action: () => pasteStyle('replace'),
disabled: isRoot || !!instance || !isCompatible(getStyleClipboard(), targetType),
},
{
label: 'Paste style (merge)',
icon: 'object-group',
action: () => pasteStyle('merge'),
disabled: isRoot || !!instance || !isCompatible(getStyleClipboard(), targetType),
dividerAfter: true,
},
{
label: 'Save style as preset\u2026',
icon: 'bookmark',
action: () => openPopover('save'),
disabled: isRoot,
},
{
label: 'Apply preset \u25B8',
icon: 'bookmark-o',
action: () => openPopover('apply'),
// #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',
shortcut: shortcutLabel('edit.move-up'),
action: moveUp,
disabled: isRoot,
},
{
label: 'Move Down',
icon: 'arrow-down',
shortcut: shortcutLabel('edit.move-down'),
action: moveDown,
disabled: isRoot,
},
{
label: 'Select Parent',
icon: 'level-up',
action: selectParent,
// Not just `isRoot`: the real dead-end is when nodeId's PARENT is
// ROOT (selecting a top-level section -> Select Parent would only
// select the page-wide, un-editable ROOT -- no outline, no toolbar).
// `canSelectParent` (useNodeActions) already encodes that and matches
// the mobile selection toolbar's identical guard.
disabled: !nodeActions.canSelectParent,
dividerAfter: true,
},
{
label: 'Delete',
icon: 'trash',
shortcut: shortcutLabel('edit.delete'),
action: deleteNode,
danger: true,
disabled: isRoot,
},
];
// Adjust position to stay within viewport
const adjustedX = Math.min(x, window.innerWidth - 200);
const adjustedY = Math.min(y, window.innerHeight - items.length * 34 - 10);
return (
<>
<div
ref={menuRef}
style={{
position: 'fixed',
top: adjustedY,
left: adjustedX,
zIndex: 10000,
minWidth: 180,
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
padding: '4px 0',
overflow: 'hidden',
}}
>
{items.map((item, i) => (
<React.Fragment key={item.label}>
<button
onClick={item.disabled ? undefined : item.action}
disabled={item.disabled}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '7px 12px',
fontSize: 12,
color: item.disabled
? 'var(--color-text-dim)'
: item.danger
? 'var(--color-danger)'
: 'var(--color-text)',
background: 'transparent',
border: 'none',
cursor: item.disabled ? 'default' : 'pointer',
textAlign: 'left',
transition: 'background var(--transition-fast)',
}}
onMouseEnter={(e) => {
if (!item.disabled) {
(e.target as HTMLElement).style.background = 'var(--color-bg-hover)';
}
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = 'transparent';
}}
>
<span>
{item.icon && <i className={`fa fa-${item.icon}`} style={{ marginRight: 6, width: 12 }} />}
{item.label}
</span>
{item.shortcut && (
<span
style={{
fontSize: 10,
color: 'var(--color-text-dim)',
marginLeft: 16,
}}
>
{item.shortcut}
</span>
)}
</button>
{item.dividerAfter && (
<div
style={{
height: 1,
background: 'var(--color-border)',
margin: '4px 0',
}}
/>
)}
</React.Fragment>
))}
</div>
{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)}
presets={listPresets(extras, targetType)}
onApply={applyPreset}
onDelete={(id) => { deletePreset(extras, id); setPopover({ ...popover }); }}
/>
)}
</>
);
};