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:
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user