Files
site-builder/craft/src/hooks/clipboard.ts
T

82 lines
3.6 KiB
TypeScript

import type { Node, NodeId, NodeTree } from '@craftjs/core';
/**
* Tiny shared clipboard for canvas node copy/paste.
*
* Both the context menu (right-click Copy/Paste) and the keyboard shortcuts
* hook (Ctrl/Cmd+C / Ctrl/Cmd+V) read and write this single module-level
* store, so copying a node via one entry point and pasting via the other
* behaves consistently instead of each maintaining its own clipboard.
*
* 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 clipboardTree: NodeTree | null = null;
/**
* 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<NodeId, Node> = {};
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 };
}
/**
* 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;
}