Skip to content

Commit 4b45e2d

Browse files
authored
WEB-55 Add ActionBarOverride component with copy and paste functionality (#81)
- Adds new component, `ActionBarOverride` to override the action bar when hovering components - Allows for copying and pasting of raw component assemblies - Pastes occur subsequent to the selected area - Hides Puck's "duplicate" button using `permissions` since it uses the `Copy` icon -- ActionBarOverride has its own Duplicate button with a clearer icon choice (`copy-plus`) <img width="271" height="149" alt="image" src="https://github.com/user-attachments/assets/ff6cf94e-1105-446a-97c8-ac9b58187b70" />
2 parents 290a1d1 + d6f514b commit 4b45e2d

2 files changed

Lines changed: 195 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"use client";
2+
3+
import { useCallback, useRef } from "react";
4+
import { ActionBar, usePuck } from "@puckeditor/core";
5+
import type { ComponentData, Config } from "@puckeditor/core";
6+
import { Copy, CopyPlus, ClipboardPaste } from "lucide-react";
7+
8+
const CLIPBOARD_MARKER = "_puckClipboard";
9+
10+
type ClipboardPayload = {
11+
_puckClipboard: true;
12+
version: 1;
13+
component: ComponentData;
14+
};
15+
16+
/** Check whether a prop is a slot by looking up its field definition in the config. */
17+
function isSlotArray(
18+
propKey: string,
19+
componentType: string,
20+
config: Config
21+
): boolean {
22+
const fieldDef = config.components?.[componentType]?.fields?.[propKey];
23+
return fieldDef?.type === "slot";
24+
}
25+
26+
/** Recursively regenerate all `props.id` fields to avoid duplicates. */
27+
function regenerateIds(
28+
component: ComponentData,
29+
config: Config
30+
): ComponentData {
31+
const clone = structuredClone(component);
32+
clone.props.id = crypto.randomUUID();
33+
34+
for (const key of Object.keys(clone.props)) {
35+
if (key === "id") continue;
36+
const val = clone.props[key];
37+
if (Array.isArray(val) && isSlotArray(key, clone.type, config)) {
38+
clone.props[key] = val.map((child: ComponentData) =>
39+
regenerateIds(child, config)
40+
);
41+
}
42+
}
43+
44+
return clone;
45+
}
46+
47+
async function copyComponent(component: ComponentData): Promise<void> {
48+
const payload: ClipboardPayload = {
49+
[CLIPBOARD_MARKER]: true,
50+
version: 1,
51+
component: structuredClone(component),
52+
};
53+
await navigator.clipboard.writeText(JSON.stringify(payload));
54+
}
55+
56+
/**
57+
* Read and validate Puck clipboard data. Returns the raw component data
58+
* without modifying IDs — callers are responsible for calling regenerateIds.
59+
*/
60+
async function readFromClipboard(): Promise<ComponentData | null> {
61+
let text: string;
62+
try {
63+
text = await navigator.clipboard.readText();
64+
} catch (err) {
65+
console.warn("Clipboard access denied:", err);
66+
return null;
67+
}
68+
69+
try {
70+
const parsed = JSON.parse(text);
71+
if (
72+
parsed?.[CLIPBOARD_MARKER] &&
73+
parsed?.version === 1 &&
74+
parsed?.component?.type &&
75+
parsed?.component?.props?.id
76+
) {
77+
return parsed.component as ComponentData;
78+
}
79+
} catch {
80+
// Clipboard content is not JSON — not Puck data, safe to ignore
81+
console.warn("Clipboard does not contain valid Puck data");
82+
}
83+
return null;
84+
}
85+
86+
export function ActionBarOverride({
87+
children,
88+
label,
89+
parentAction,
90+
}: {
91+
children: React.ReactNode;
92+
label?: string;
93+
parentAction: React.ReactNode;
94+
}) {
95+
const { selectedItem, config, dispatch, getSelectorForId } = usePuck();
96+
const isPastingRef = useRef(false);
97+
98+
const handleDuplicate = useCallback(() => {
99+
if (!selectedItem) return;
100+
const selector = getSelectorForId(selectedItem.props.id);
101+
if (!selector) return;
102+
dispatch({
103+
type: "duplicate",
104+
sourceIndex: selector.index,
105+
sourceZone: selector.zone ?? "default-zone",
106+
});
107+
}, [selectedItem, dispatch, getSelectorForId]);
108+
109+
const handleCopy = useCallback(async () => {
110+
if (!selectedItem) return;
111+
try {
112+
await copyComponent(selectedItem);
113+
} catch (err) {
114+
console.warn("Failed to copy component:", err);
115+
}
116+
}, [selectedItem]);
117+
118+
const handlePaste = useCallback(async () => {
119+
if (isPastingRef.current) return;
120+
isPastingRef.current = true;
121+
try {
122+
if (!selectedItem) return;
123+
const selector = getSelectorForId(selectedItem.props.id);
124+
if (!selector) return;
125+
126+
// Read and validate clipboard
127+
const raw = await readFromClipboard();
128+
if (!raw) {
129+
alert("Invalid clipboard data")
130+
return;
131+
}
132+
133+
// Regenerate UUIDs of components
134+
const component = regenerateIds(raw, config);
135+
136+
// Validate component type exists in config
137+
// (in case a user copies components from different versions, for example)
138+
if (!config.components?.[component.type]) {
139+
console.warn(
140+
`Cannot paste: unknown component type "${component.type}"`
141+
);
142+
return;
143+
}
144+
145+
const zone = selector.zone ?? "default-zone";
146+
const index = selector.index + 1;
147+
148+
// Puck's InsertAction only accepts componentType + position, not full
149+
// component data. We insert a stub first, then immediately replace it
150+
// with the full component data (including props and slot content).
151+
dispatch({
152+
type: "insert",
153+
componentType: component.type,
154+
destinationIndex: index,
155+
destinationZone: zone,
156+
id: component.props.id,
157+
});
158+
159+
try {
160+
dispatch({
161+
type: "replace",
162+
destinationIndex: index,
163+
destinationZone: zone,
164+
data: component,
165+
});
166+
} catch {
167+
// Replace failed — remove the stub to avoid leaving a broken component
168+
dispatch({ type: "remove", index, zone });
169+
}
170+
} finally {
171+
isPastingRef.current = false;
172+
}
173+
}, [selectedItem, config, dispatch, getSelectorForId]);
174+
175+
return (
176+
<ActionBar label={label}>
177+
<ActionBar.Group>
178+
<ActionBar.Action label="Duplicate" onClick={handleDuplicate}>
179+
<CopyPlus size={16} />
180+
</ActionBar.Action>
181+
<ActionBar.Action label="Copy" onClick={handleCopy}>
182+
<Copy size={16} />
183+
</ActionBar.Action>
184+
<ActionBar.Action label="Paste" onClick={handlePaste}>
185+
<ClipboardPaste size={16} />
186+
</ActionBar.Action>
187+
{children}
188+
{parentAction}
189+
</ActionBar.Group>
190+
</ActionBar>
191+
);
192+
}

sga-puck/app/puck/[...puckPath]/client.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Puck } from "@puckeditor/core";
55
import config from "../../../puck.config";
66
import { useState, createContext, useContext } from "react";
77
import { DraftPlugin } from "./DraftPlugin";
8+
import { ActionBarOverride } from "./ActionBarOverride";
89

910
type DraftContextType = {
1011
path: string;
@@ -41,6 +42,8 @@ export function Client({
4142
data={currentData}
4243
ui={{plugin: {current: "draft-plugin"}}}
4344
plugins={[DraftPlugin]}
45+
permissions={{ duplicate: false }}
46+
overrides={{ actionBar: ActionBarOverride }}
4447
onChange={(data) => {
4548
setCurrentData(data);
4649
}}

0 commit comments

Comments
 (0)