From a698f014b0fea705743b5457629ff656da36ec89 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 14 Jul 2026 07:36:00 -0700 Subject: [PATCH 1/2] feat(site-builder): page duplicate/reorder/set-landing + fix cross-page node copy/paste Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/hooks/clipboard.test.ts | 90 +++- craft/src/hooks/clipboard.ts | 72 ++- craft/src/hooks/useKeyboardShortcuts.test.tsx | 70 ++- craft/src/hooks/useKeyboardShortcuts.ts | 48 +- craft/src/panels/context-menu/ContextMenu.tsx | 51 ++- craft/src/panels/left/PagesPanel.tsx | 118 +++-- .../PageContext.pages-productivity.test.tsx | 427 ++++++++++++++++++ craft/src/state/PageContext.tsx | 165 +++++++ .../duplicate-paste.integration.test.tsx | 107 ++++- 9 files changed, 1040 insertions(+), 108 deletions(-) create mode 100644 craft/src/state/PageContext.pages-productivity.test.tsx diff --git a/craft/src/hooks/clipboard.test.ts b/craft/src/hooks/clipboard.test.ts index 03dd7f6..34860e0 100644 --- a/craft/src/hooks/clipboard.test.ts +++ b/craft/src/hooks/clipboard.test.ts @@ -1,29 +1,95 @@ import { describe, test, expect, afterEach } from 'vitest'; -import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; +import type { NodeTree } from '@craftjs/core'; +import { getClipboardTree, setClipboardTree } from './clipboard'; + +function makeTree(rootId: string, props: Record = {}): NodeTree { + return { + rootNodeId: rootId, + nodes: { + [rootId]: { + id: rootId, + data: { + type: { resolvedName: 'Container' }, + name: 'Container', + displayName: 'Container', + props, + custom: {}, + isCanvas: false, + parent: 'wherever-it-originally-lived', + nodes: [], + linkedNodes: {}, + hidden: false, + }, + info: {}, + events: { selected: false, dragged: false, hovered: false }, + dom: null, + related: {}, + rules: {}, + _hydrationTimestamp: 0, + } as unknown as NodeTree['nodes'][string], + }, + }; +} describe('clipboard', () => { afterEach(() => { - setClipboardNodeId(null); + setClipboardTree(null); }); test('starts empty', () => { - expect(getClipboardNodeId()).toBeNull(); + expect(getClipboardTree()).toBeNull(); }); - test('set then get returns the stored node id', () => { - setClipboardNodeId('node-123'); - expect(getClipboardNodeId()).toBe('node-123'); + test('set then get returns a tree with the same root id and shape', () => { + const tree = makeTree('node-123', { text: 'hello' }); + setClipboardTree(tree); + const got = getClipboardTree(); + expect(got).not.toBeNull(); + expect(got!.rootNodeId).toBe('node-123'); + expect(got!.nodes['node-123'].data.props).toEqual({ text: 'hello' }); }); test('is a shared module-level store -- overwriting replaces the previous value', () => { - setClipboardNodeId('first'); - setClipboardNodeId('second'); - expect(getClipboardNodeId()).toBe('second'); + setClipboardTree(makeTree('first')); + setClipboardTree(makeTree('second')); + expect(getClipboardTree()!.rootNodeId).toBe('second'); }); test('can be cleared back to null', () => { - setClipboardNodeId('node-123'); - setClipboardNodeId(null); - expect(getClipboardNodeId()).toBeNull(); + setClipboardTree(makeTree('node-123')); + setClipboardTree(null); + expect(getClipboardTree()).toBeNull(); + }); + + test('deep-clones on set: mutating the original tree after set does not affect the stored snapshot', () => { + const original = makeTree('node-123', { text: 'original' }); + setClipboardTree(original); + + // Mutate the original tree's props object directly (as if the source + // node were edited, or the same live node got copied again). + (original.nodes['node-123'].data.props as Record).text = 'mutated'; + + expect(getClipboardTree()!.nodes['node-123'].data.props).toEqual({ text: 'original' }); + }); + + test('deep-clones nested props (arrays/objects), not just the top-level props object', () => { + const original = makeTree('node-123', { links: [{ url: 'https://example.com' }] }); + setClipboardTree(original); + + (original.nodes['node-123'].data.props as any).links[0].url = 'https://mutated.example.com'; + + expect((getClipboardTree()!.nodes['node-123'].data.props as any).links[0].url).toBe( + 'https://example.com', + ); + }); + + test('survives the original tree object being discarded entirely (detached copy, not a live reference)', () => { + let tree: NodeTree | null = makeTree('node-abc', { text: 'snapshot' }); + setClipboardTree(tree); + tree = null; // simulate the original page's node/tree going away entirely + + const got = getClipboardTree(); + expect(got).not.toBeNull(); + expect(got!.nodes['node-abc'].data.props).toEqual({ text: 'snapshot' }); }); }); diff --git a/craft/src/hooks/clipboard.ts b/craft/src/hooks/clipboard.ts index 25f7fc3..58f902d 100644 --- a/craft/src/hooks/clipboard.ts +++ b/craft/src/hooks/clipboard.ts @@ -1,3 +1,5 @@ +import type { Node, NodeId, NodeTree } from '@craftjs/core'; + /** * Tiny shared clipboard for canvas node copy/paste. * @@ -9,15 +11,71 @@ * Deliberately not React state -- nothing in the UI needs to re-render * reactively when the clipboard changes; consumers just read the current * value at the moment they need it (on paste, or when a menu opens). + * + * Historical bug (cross-page copy/paste): this used to store only the copied + * node's bare id (`clipboardNodeId`) and re-resolve it via `query.node(id)` + * at paste time. That works fine same-page, but the moment the user switches + * pages the canvas is re-deserialized to the target page's Craft.js state -- + * the copied id no longer exists in `query` at all -- so a cross-page paste + * silently no-op'd (or threw, caught, and swallowed). Storing a detached + * TREE SNAPSHOT at copy time instead means paste never needs to look the + * source id up again: it just hands the snapshot to `regenerateTreeIds` + + * `actions.addNodeTree`, which works identically regardless of which page's + * state is currently loaded on the canvas. */ -let clipboardNodeId: string | null = null; +let clipboardTree: NodeTree | null = null; -/** Returns the id of the node currently on the clipboard, or null if empty. */ -export function getClipboardNodeId(): string | null { - return clipboardNodeId; +/** + * Deep, detached clone of a live Craft.js `NodeTree` (as returned by + * `query.node(id).toNodeTree()`). + * + * Not a plain `structuredClone(tree)`: for a REAL (live) Craft.js node, + * `data.type` is the actual component function/class reference (not a + * serializable `{resolvedName}` wrapper) -- `structuredClone` cannot clone a + * function and throws `DataCloneError` (see the identical note on + * `regenerateTreeIds` in `utils/craft-tree.ts`, which hit this exact bug + * historically). `type` is a stable reference shared by every node of that + * component across the whole app (it doesn't change per page), so it's safe + * to keep by reference -- only the mutable per-node data (`props`, `custom`, + * `nodes`, `linkedNodes`) needs an actual deep copy so a later mutation (a + * subsequent paste's `setProp`, or a fresh copy of the same live node) + * can never reach back into this stored snapshot. + */ +function cloneNodeTree(tree: NodeTree): NodeTree { + const nodes: Record = {}; + for (const [id, node] of Object.entries(tree.nodes)) { + nodes[id] = { + ...node, + data: { + ...node.data, + props: structuredClone(node.data.props), + custom: structuredClone(node.data.custom), + nodes: [...(node.data.nodes || [])], + linkedNodes: { ...(node.data.linkedNodes || {}) }, + }, + }; + } + return { rootNodeId: tree.rootNodeId, nodes }; } -/** Sets (or clears, with `null`) the node id on the clipboard. */ -export function setClipboardNodeId(nodeId: string | null): void { - clipboardNodeId = nodeId; +/** + * Returns the tree snapshot currently on the clipboard, or null if empty. + * The returned tree is safe to hand straight to `regenerateTreeIds` -- + * `regenerateTreeIds` never mutates its input, so repeated pastes of the + * same clipboard contents (including across a page switch) all work off the + * same untouched snapshot. + */ +export function getClipboardTree(): NodeTree | null { + return clipboardTree; +} + +/** + * Sets (or clears, with `null`) the tree snapshot on the clipboard. The tree + * is deep-cloned before being stored (see `cloneNodeTree`) so it is fully + * detached from the live Craft.js node it was captured from -- it survives + * that node being deleted, mutated, or (the whole point) the canvas being + * re-deserialized to a different page entirely. + */ +export function setClipboardTree(tree: NodeTree | null): void { + clipboardTree = tree ? cloneNodeTree(tree) : null; } diff --git a/craft/src/hooks/useKeyboardShortcuts.test.tsx b/craft/src/hooks/useKeyboardShortcuts.test.tsx index 6d1d0f9..5e43105 100644 --- a/craft/src/hooks/useKeyboardShortcuts.test.tsx +++ b/craft/src/hooks/useKeyboardShortcuts.test.tsx @@ -4,7 +4,7 @@ import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; import type { NodeTree, Node } from '@craftjs/core'; import { useKeyboardShortcuts } from './useKeyboardShortcuts'; -import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; +import { getClipboardTree, setClipboardTree } from './clipboard'; /** * Regression coverage: Ctrl/Cmd+V must run the copied subtree through @@ -15,6 +15,13 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; * ROOT-fallback targeting, the empty-clipboard no-op, and the existing * input-focus guard. * + * Also covers the cross-page clipboard fix: copy stores a detached TREE + * SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs + * to re-resolve the original node via `query.node(id)`, which is exactly + * what breaks once the canvas has been re-deserialized to a different page + * (see `hooks/clipboard.ts` and the cross-page integration test in + * `test-utils/integration/duplicate-paste.integration.test.tsx`). + * * Mock pattern mirrors PageContext.pure-updaters.test.tsx / * PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`, * mounted via a bare consumer component, with REAL `keydown` events @@ -27,6 +34,7 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; */ const addNodeTreeMock = vi.fn(); +const selectNodeMock = vi.fn(); let selectedIds: string[] = []; function makeNode(id: string, parent: string | null, children: string[] = []): Node { @@ -62,9 +70,10 @@ const COPIED_TREE: NodeTree = { }, }; -const nodeStore: Record = { +const nodeStore: Record = { 'selected-1': { data: { parent: 'parent-container-1' } }, - ROOT: { data: { parent: null } }, + 'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } }, + ROOT: { data: { parent: null, nodes: [] } }, 'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } }, }; @@ -88,6 +97,7 @@ vi.mock('@craftjs/core', () => ({ }, actions: { addNodeTree: addNodeTreeMock, + selectNode: selectNodeMock, history: { undo: vi.fn(), redo: vi.fn() }, delete: vi.fn(), clearEvents: vi.fn(), @@ -145,44 +155,51 @@ const Consumer: React.FC = () => { beforeEach(() => { selectedIds = []; addNodeTreeMock.mockClear(); + selectNodeMock.mockClear(); regenerateTreeIdsMock.mockClear(); - setClipboardNodeId(null); + setClipboardTree(null); }); afterEach(() => { - setClipboardNodeId(null); + setClipboardTree(null); if (root) unmount(); }); describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => { - test('Ctrl+C copies the selected node id to the clipboard', () => { + test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => { render(); - selectedIds = ['selected-1']; + selectedIds = ['copied-root-1']; pressKey('c'); - expect(getClipboardNodeId()).toBe('selected-1'); + const clip = getClipboardTree(); + expect(clip).not.toBeNull(); + expect(clip!.rootNodeId).toBe('copied-root-1'); + expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']); }); - test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => { + test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => { render(); selectedIds = ['copied-root-1']; pressKey('c'); - expect(getClipboardNodeId()).toBe('copied-root-1'); + expect(getClipboardTree()!.rootNodeId).toBe('copied-root-1'); selectedIds = ['selected-1']; pressKey('v'); // regenerateTreeIds actually ran before the tree was handed to Craft.js. expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1); - expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE); + expect(regenerateTreeIdsMock).toHaveBeenCalledWith(getClipboardTree()); expect(addNodeTreeMock).toHaveBeenCalledTimes(1); - const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0]; + const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0]; // Sibling of the current selection: selected-1's data.parent. expect(targetParent).toBe('parent-container-1'); + // Immediately after selected-1 (index 1 among parent-container-1's + // children), matching duplicate()'s "insert right after" UX. + expect(insertIndex).toBe(2); // The regression this guards: pasted ids must be fresh, never reuse the // ids the copied node already occupies in the live Craft.js tree. @@ -193,9 +210,12 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => { for (const id of pastedIds) { expect(originalIds.has(id)).toBe(false); } + + // The new copy is selected, same as duplicate()'s existing UX. + expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId); }); - test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => { + test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => { render(); selectedIds = ['copied-root-1']; @@ -205,8 +225,24 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => { pressKey('v'); expect(addNodeTreeMock).toHaveBeenCalledTimes(1); - const [, targetParent] = addNodeTreeMock.mock.calls[0]; + const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0]; expect(targetParent).toBe('ROOT'); + expect(insertIndex).toBeUndefined(); + }); + + test('Ctrl+V with nothing selected falls back to appending into ROOT', () => { + render(); + + selectedIds = ['copied-root-1']; + pressKey('c'); + + selectedIds = []; + pressKey('v'); + + expect(addNodeTreeMock).toHaveBeenCalledTimes(1); + const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0]; + expect(targetParent).toBe('ROOT'); + expect(insertIndex).toBeUndefined(); }); test('Ctrl+V with an empty clipboard is a no-op', () => { @@ -229,9 +265,9 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => { selectedIds = ['selected-1']; pressKey('c'); - expect(getClipboardNodeId()).toBeNull(); + expect(getClipboardTree()).toBeNull(); - setClipboardNodeId('copied-root-1'); + setClipboardTree(COPIED_TREE); pressKey('v'); expect(addNodeTreeMock).not.toHaveBeenCalled(); @@ -252,7 +288,7 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => { selectedIds = ['selected-1']; pressKey('c'); - expect(getClipboardNodeId()).toBeNull(); + expect(getClipboardTree()).toBeNull(); activeElementSpy.mockRestore(); }); diff --git a/craft/src/hooks/useKeyboardShortcuts.ts b/craft/src/hooks/useKeyboardShortcuts.ts index 284a5cf..84988ff 100644 --- a/craft/src/hooks/useKeyboardShortcuts.ts +++ b/craft/src/hooks/useKeyboardShortcuts.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { useEditor } from '@craftjs/core'; import { findDeletableTarget } from '../utils/craft-helpers'; import { regenerateTreeIds } from '../utils/craft-tree'; -import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; +import { getClipboardTree, setClipboardTree } from './clipboard'; function isInputFocused(): boolean { const el = document.activeElement; @@ -86,13 +86,16 @@ export function useKeyboardShortcuts() { return; } - // Ctrl+C: copy selected node id to the shared clipboard + // Ctrl+C: copy the selected node's subtree (a detached snapshot, not + // just its id -- see clipboard.ts for why: an id-based clipboard can't + // survive a page switch, since the copied id no longer exists in + // `query` once the canvas is re-deserialized to a different page). if (ctrl && (e.key === 'c' || e.key === 'C')) { e.preventDefault(); try { const selected = query.getEvent('selected').all(); if (selected.length > 0 && selected[0] !== 'ROOT') { - setClipboardNodeId(selected[0]); + setClipboardTree(query.node(selected[0]).toNodeTree()); } } catch (err) { console.error('Copy failed:', err); @@ -100,25 +103,44 @@ export function useKeyboardShortcuts() { return; } - // Ctrl+V: paste the clipboard node as a sibling of the current selection + // Ctrl+V: paste the clipboard tree as a sibling of the current + // selection (immediately after it, matching duplicate()'s UX), or + // append to ROOT when nothing is selected. Works regardless of which + // page is currently on the canvas -- the clipboard tree is a detached + // snapshot, not a reference to a node that may no longer exist here. if (ctrl && (e.key === 'v' || e.key === 'V')) { e.preventDefault(); try { - const sourceId = getClipboardNodeId(); - if (!sourceId || !query.node(sourceId).get()) return; + const clip = getClipboardTree(); + if (!clip) return; const selected = query.getEvent('selected').all(); - if (selected.length === 0) return; - const selectedId = selected[0]; + const selectedId = selected.length > 0 ? selected[0] : null; - let targetParent = 'ROOT'; - if (selectedId !== 'ROOT') { + let targetParentId = 'ROOT'; + let insertIndex: number | undefined; + if (selectedId && selectedId !== 'ROOT') { const node = query.node(selectedId).get(); - targetParent = node?.data?.parent || 'ROOT'; + const parentId: string | null | undefined = node?.data?.parent; + if (parentId) { + targetParentId = parentId; + try { + const siblings: string[] = query.node(parentId).get()?.data?.nodes || []; + const idx = siblings.indexOf(selectedId); + 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, targetParentId, insertIndex); + } else { + actions.addNodeTree(tree, targetParentId); + } + actions.selectNode(tree.rootNodeId); } catch (err) { console.error('Paste failed:', err); } diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx index 4be5807..7df535a 100644 --- a/craft/src/panels/context-menu/ContextMenu.tsx +++ b/craft/src/panels/context-menu/ContextMenu.tsx @@ -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 = ({ 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 = ({ icon: 'clipboard', shortcut: 'Ctrl+V', action: pasteNode, - disabled: !getClipboardNodeId(), + disabled: !getClipboardTree(), dividerAfter: true, }, { diff --git a/craft/src/panels/left/PagesPanel.tsx b/craft/src/panels/left/PagesPanel.tsx index 667a951..586c79f 100644 --- a/craft/src/panels/left/PagesPanel.tsx +++ b/craft/src/panels/left/PagesPanel.tsx @@ -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 = () => {
e.stopPropagation()} > +