feat(site-builder): page duplicate/reorder/set-landing + fix cross-page node copy/paste

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 07:36:00 -07:00
co-authored by Claude Opus 4.8
parent 204ea5e078
commit a698f014b0
9 changed files with 1040 additions and 108 deletions
+35 -16
View File
@@ -3,7 +3,7 @@ import { useEditor } from '@craftjs/core';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
import { useNodeActions } from '../../hooks/useNodeActions';
interface ContextMenuProps {
@@ -79,35 +79,54 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
setClipboardNodeId(nodeId);
// 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, onClose]);
}, [nodeId, query, onClose]);
const pasteNode = useCallback(() => {
const sourceId = getClipboardNodeId();
if (!sourceId) {
const clip = getClipboardTree();
if (!clip) {
onClose();
return;
}
try {
if (!query.node(sourceId).get()) {
onClose();
return;
}
// Paste as a SIBLING of the right-clicked node, not as its child --
// using the clicked node itself as the parent throws when it's a leaf.
// 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();
targetParent = clickedNode?.data?.parent || 'ROOT';
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(query.node(sourceId).toNodeTree());
actions.addNodeTree(tree, targetParent);
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);
}
@@ -176,7 +195,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
icon: 'clipboard',
shortcut: 'Ctrl+V',
action: pasteNode,
disabled: !getClipboardNodeId(),
disabled: !getClipboardTree(),
dividerAfter: true,
},
{
+78 -40
View File
@@ -15,6 +15,9 @@ export const PagesPanel: React.FC = () => {
addPage,
deletePage,
renamePage,
duplicatePage,
movePage,
setLandingPage,
updatePageSeo,
} = usePages();
const [isAdding, setIsAdding] = useState(false);
@@ -68,6 +71,33 @@ export const PagesPanel: React.FC = () => {
* differently-colored category. The active/editing state reuses the same
* accent-outline treatment the page list already uses for the active page,
* so there's one consistent "this is what's currently open" affordance. */
/* ---------- Per-page-row icon button ----------
* Shared 24x24 icon-button styling used by every action in the page row
* (SEO settings, rename, duplicate, reorder, set-as-home, delete) so a new
* action slots in looking identical to the pre-existing gear/pencil/trash
* buttons -- same dark-theme neutral treatment, `disabled` dims the icon
* (matching how `disabled` already reads elsewhere in this panel, e.g. the
* "Add Page" button), and `danger` reuses the existing delete-button
* accent color. */
const pageActionBtnStyle = (opts: { disabled?: boolean; danger?: boolean } = {}): React.CSSProperties => ({
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: opts.disabled
? 'var(--color-text-dim)'
: opts.danger
? 'var(--color-danger)'
: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: opts.disabled ? 'default' : 'pointer',
opacity: opts.disabled ? 0.5 : 1,
});
const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({
display: 'flex',
alignItems: 'center',
@@ -344,26 +374,58 @@ export const PagesPanel: React.FC = () => {
</div>
</div>
<div
style={{ display: 'flex', gap: 4, flexShrink: 0 }}
style={{ display: 'flex', gap: 2, flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 96 }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => movePage(page.id, 'up')}
disabled={pageIndex === 0}
data-tooltip="Move up"
aria-label={`Move ${page.name} up`}
style={pageActionBtnStyle({ disabled: pageIndex === 0 })}
>
<i className="fa fa-arrow-up" aria-hidden="true" />
</button>
<button
onClick={() => movePage(page.id, 'down')}
disabled={pageIndex === pages.length - 1}
data-tooltip="Move down"
aria-label={`Move ${page.name} down`}
style={pageActionBtnStyle({ disabled: pageIndex === pages.length - 1 })}
>
<i className="fa fa-arrow-down" aria-hidden="true" />
</button>
<button
onClick={() => duplicatePage(page.id)}
data-tooltip="Duplicate"
aria-label={`Duplicate ${page.name}`}
style={pageActionBtnStyle()}
>
<i className="fa fa-clone" aria-hidden="true" />
</button>
{isLanding ? (
<span
data-tooltip="This is the home page"
aria-label={`${page.name} is the home page`}
style={pageActionBtnStyle({ disabled: true })}
>
<i className="fa fa-home" aria-hidden="true" />
</span>
) : (
<button
onClick={() => setLandingPage(page.id)}
data-tooltip="Set as home page"
aria-label={`Set ${page.name} as the home page`}
style={pageActionBtnStyle()}
>
<i className="fa fa-home" aria-hidden="true" />
</button>
)}
<button
onClick={() => setSeoSettingsPageId(page.id)}
data-tooltip="Page Settings (SEO)"
aria-label={`Page settings for ${page.name}`}
style={{
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
style={pageActionBtnStyle()}
>
<i className="fa fa-cog" aria-hidden="true" />
</button>
@@ -371,19 +433,7 @@ export const PagesPanel: React.FC = () => {
onClick={() => startEditing(page)}
data-tooltip="Rename"
aria-label={`Rename ${page.name}`}
style={{
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
style={pageActionBtnStyle()}
>
<i className="fa fa-pencil" aria-hidden="true" />
</button>
@@ -392,19 +442,7 @@ export const PagesPanel: React.FC = () => {
onClick={() => setDeleteConfirmId(page.id)}
data-tooltip="Delete"
aria-label={`Delete ${page.name}`}
style={{
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
style={pageActionBtnStyle({ danger: true })}
>
<i className="fa fa-trash" aria-hidden="true" />
</button>