Skip to content

Commit b74136e

Browse files
committed
feat: add clone and delete element actions
1 parent 350212b commit b74136e

10 files changed

Lines changed: 283 additions & 13 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useEffect } from "react";
2+
import { useDocumentEditor } from "~/DocumentEditor/index.js";
3+
import { Commands } from "~/BaseEditor/index.js";
4+
import { $cloneElement } from "~/editorSdk/utils/index.js";
5+
6+
export const CloneElement = () => {
7+
const editor = useDocumentEditor();
8+
9+
useEffect(() => {
10+
return editor.registerCommandHandler(Commands.CloneElement, payload => {
11+
const newId = $cloneElement(editor, payload.id);
12+
if (newId) {
13+
editor.executeCommand(Commands.SelectElement, { id: newId });
14+
}
15+
});
16+
}, []);
17+
18+
return null;
19+
};

packages/app-website-builder/src/BaseEditor/commandHandlers/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from "react";
2+
import { CloneElement } from "./CloneElement.js";
23
import { CreateElement } from "./CreateElement.js";
34
import { DeleteElement } from "./DeleteElement.js";
45
import { MoveElement } from "./MoveElement.js";
@@ -9,6 +10,7 @@ import { HighlightElement } from "./HighlightElement.js";
910
export const CommandHandlers = React.memo(() => {
1011
return (
1112
<>
13+
<CloneElement />
1214
<CreateElement />
1315
<DeleteElement />
1416
<MoveElement />

packages/app-website-builder/src/BaseEditor/commands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const MoveElement = createCommand<{
2626
index: number;
2727
}>("MOVE_ELEMENT");
2828

29+
const CloneElement = createCommand<{ id: string }>("CLONE_ELEMENT");
30+
2931
const DeleteElement = createCommand<{
3032
id: string;
3133
}>("DELETE_ELEMENT");
@@ -47,6 +49,7 @@ const PreviewPatchElement = createCommand<{ elementId: string; patch: any[] }>(
4749
const SendPreviewMessage = createCommand<{ type: string; payload?: any }>("SEND_PREVIEW_MESSAGE");
4850

4951
export const Commands = {
52+
CloneElement,
5053
CreateElement,
5154
DeleteElement,
5255
MoveElement,

packages/app-website-builder/src/BaseEditor/defaultConfig/DefaultEditorConfig.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { CommandHandlers } from "~/BaseEditor/commandHandlers/index.js";
1010
import { ElementSettings } from "./Sidebar/ElementSettings/ElementSettings.js";
1111
import { ElementInputRenderers } from "./ElementInputRenderers.js";
1212
import { ContentPreviewConfig } from "./Content/ContentPreviewConfig.js";
13+
import { CloneElementAction } from "./Sidebar/ElementActions/CloneElementAction.js";
14+
import { DeleteElementAction } from "./Sidebar/ElementActions/DeleteElementAction.js";
1315

1416
const { Ui } = EditorConfig;
1517

@@ -73,6 +75,16 @@ export const DefaultEditorConfig = React.memo(() => {
7375
group={"element"}
7476
element={<DocumentStateEditor />}
7577
/>*/}
78+
{/* Element Actions (dropdown menu items) */}
79+
<EditorConfig.ElementAction
80+
name={"cloneElement"}
81+
element={<CloneElementAction />}
82+
/>
83+
<EditorConfig.ElementAction
84+
name={"deleteElement"}
85+
element={<DeleteElementAction />}
86+
after={"cloneElement"}
87+
/>
7688
</EditorConfig>
7789
</>
7890
);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React, { useCallback } from "react";
2+
import { DropdownMenu } from "@webiny/admin-ui";
3+
import { ReactComponent as ContentCopyIcon } from "@webiny/icons/content_copy.svg";
4+
import { useActiveElement } from "~/BaseEditor/hooks/useActiveElement.js";
5+
import { useDocumentEditor } from "~/DocumentEditor/index.js";
6+
import { Commands } from "~/BaseEditor/index.js";
7+
8+
export const CloneElementAction = () => {
9+
const [element] = useActiveElement();
10+
const editor = useDocumentEditor();
11+
12+
const onClick = useCallback(() => {
13+
if (element) {
14+
editor.executeCommand(Commands.CloneElement, { id: element.id });
15+
}
16+
}, [element?.id]);
17+
18+
return <DropdownMenu.Item icon={<ContentCopyIcon />} text={"Clone"} onClick={onClick} />;
19+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React, { useCallback } from "react";
2+
import { DropdownMenu } from "@webiny/admin-ui";
3+
import { ReactComponent as DeleteIcon } from "@webiny/icons/delete.svg";
4+
import { useActiveElement } from "~/BaseEditor/hooks/useActiveElement.js";
5+
import { useDocumentEditor } from "~/DocumentEditor/index.js";
6+
import { Commands } from "~/BaseEditor/index.js";
7+
8+
export const DeleteElementAction = () => {
9+
const [element] = useActiveElement();
10+
const editor = useDocumentEditor();
11+
12+
const onClick = useCallback(() => {
13+
if (element) {
14+
editor.executeCommand(Commands.DeleteElement, { id: element.id });
15+
}
16+
}, [element?.id]);
17+
18+
return (
19+
<DropdownMenu.Item
20+
className={"text-destructive-primary [&_svg]:fill-destructive"}
21+
icon={<DeleteIcon />}
22+
text={"Delete"}
23+
onClick={onClick}
24+
/>
25+
);
26+
};
Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import React from "react";
22
import type { DocumentElement } from "@webiny/website-builder-sdk";
33
import { useComponent } from "~/BaseEditor/hooks/useComponent.js";
4-
import { Icon } from "@webiny/admin-ui";
4+
import { DropdownMenu, Icon, IconButton } from "@webiny/admin-ui";
55
import { InlineSvg } from "~/BaseEditor/defaultConfig/Toolbar/InsertElements/InlineSvg.js";
6+
import { ReactComponent as MoreVerticalIcon } from "@webiny/icons/more_vert.svg";
7+
import { ElementActions } from "~/BaseEditor/config/ElementAction.js";
68

79
interface ElementPreviewProps {
810
element: DocumentElement;
@@ -12,19 +14,26 @@ export const ElementPreview = ({ element }: ElementPreviewProps) => {
1214
const component = useComponent(element.component.name);
1315

1416
return (
15-
<div className={"flex items-center gap-sm"}>
16-
<div className={"fill-accent-default"}>
17-
{component.image && (
18-
<Icon
19-
icon={<InlineSvg src={component.image} />}
20-
size={"md"}
21-
label={component.label ?? element.component.name}
22-
/>
23-
)}
17+
<div className={"flex items-center justify-between"}>
18+
<div className={"flex items-center gap-sm"}>
19+
<div className={"fill-accent-default"}>
20+
{component.image && (
21+
<Icon
22+
icon={<InlineSvg src={component.image} />}
23+
size={"md"}
24+
label={component.label ?? element.component.name}
25+
/>
26+
)}
27+
</div>
28+
<span className={"text-md font-semibold text-neutral-primary"}>
29+
{component.label ?? element.component.name}
30+
</span>
2431
</div>
25-
<span className={"text-md font-semibold text-neutral-primary"}>
26-
{component.label ?? element.component.name}
27-
</span>
32+
<DropdownMenu
33+
trigger={<IconButton variant={"ghost"} size={"sm"} icon={<MoreVerticalIcon />} />}
34+
>
35+
<ElementActions />
36+
</DropdownMenu>
2837
</div>
2938
);
3039
};
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { generateElementId } from "@webiny/website-builder-sdk";
2+
import type {
3+
Document,
4+
DocumentElement,
5+
DocumentElementBindings
6+
} from "@webiny/website-builder-sdk";
7+
import { ElementFactory } from "@webiny/website-builder-sdk";
8+
import type { Editor } from "../Editor.js";
9+
import { $getElementById } from "./$getElementById.js";
10+
import { $addElementReferenceToParent } from "./$addElementReferenceToParent.js";
11+
import { executeOnChange, applyAncestorUpdates } from "./executeOnChange.js";
12+
13+
function collectDescendants(document: Document, elementId: string): string[] {
14+
const ids: string[] = [];
15+
const bindings = document.bindings[elementId];
16+
if (!bindings?.inputs) {
17+
return ids;
18+
}
19+
20+
for (const binding of Object.values(bindings.inputs)) {
21+
if (binding.type !== "slot") {
22+
continue;
23+
}
24+
25+
const children = binding.list
26+
? ((binding.static as string[] | undefined) ?? [])
27+
: binding.static
28+
? [binding.static as string]
29+
: [];
30+
31+
for (const childId of children) {
32+
ids.push(childId);
33+
ids.push(...collectDescendants(document, childId));
34+
}
35+
}
36+
37+
return ids;
38+
}
39+
40+
function remapBindings(
41+
bindings: DocumentElementBindings,
42+
idMap: Map<string, string>
43+
): DocumentElementBindings {
44+
const cloned = structuredClone(bindings);
45+
46+
if (cloned.inputs) {
47+
for (const [key, binding] of Object.entries(cloned.inputs)) {
48+
if (binding.type !== "slot") {
49+
continue;
50+
}
51+
52+
if (binding.list && Array.isArray(binding.static)) {
53+
cloned.inputs[key] = {
54+
...binding,
55+
static: binding.static.map((id: string) => idMap.get(id) ?? id)
56+
};
57+
} else if (typeof binding.static === "string") {
58+
const newId = idMap.get(binding.static);
59+
if (newId) {
60+
cloned.inputs[key] = { ...binding, static: newId };
61+
}
62+
}
63+
}
64+
}
65+
66+
return cloned;
67+
}
68+
69+
function getSlotIndex(document: Document, element: DocumentElement): number {
70+
const parent = element.parent;
71+
if (!parent) {
72+
return -1;
73+
}
74+
75+
const parentBindings = document.bindings[parent.id];
76+
if (!parentBindings?.inputs) {
77+
return -1;
78+
}
79+
80+
const slotBinding = parentBindings.inputs[parent.slot];
81+
if (!slotBinding) {
82+
return -1;
83+
}
84+
85+
if (slotBinding.list && Array.isArray(slotBinding.static)) {
86+
return slotBinding.static.indexOf(element.id);
87+
}
88+
89+
return -1;
90+
}
91+
92+
export function $cloneElement(editor: Editor, id: string): string | undefined {
93+
const document = editor.getDocumentState().read();
94+
const element = $getElementById(document, id);
95+
96+
if (!element || !element.parent) {
97+
return undefined;
98+
}
99+
100+
const allIds = [id, ...collectDescendants(document, id)];
101+
const idMap = new Map<string, string>();
102+
for (const oldId of allIds) {
103+
idMap.set(oldId, generateElementId());
104+
}
105+
106+
const originalIndex = getSlotIndex(document, element);
107+
108+
editor.updateDocument(doc => {
109+
for (const oldId of allIds) {
110+
const original = doc.elements[oldId];
111+
const newId = idMap.get(oldId)!;
112+
113+
const clonedElement: DocumentElement = {
114+
...structuredClone(original),
115+
id: newId
116+
};
117+
118+
if (clonedElement.parent) {
119+
const newParentId = idMap.get(clonedElement.parent.id);
120+
if (newParentId) {
121+
clonedElement.parent = {
122+
...clonedElement.parent,
123+
id: newParentId
124+
};
125+
}
126+
}
127+
128+
doc.elements[newId] = clonedElement;
129+
130+
const originalBindings = doc.bindings[oldId];
131+
if (originalBindings) {
132+
doc.bindings[newId] = remapBindings(originalBindings, idMap);
133+
}
134+
}
135+
136+
const rootNewId = idMap.get(id)!;
137+
const insertIndex = originalIndex >= 0 ? originalIndex + 1 : originalIndex;
138+
139+
$addElementReferenceToParent(doc, {
140+
elementId: rootNewId,
141+
parentId: element.parent!.id,
142+
slot: element.parent!.slot,
143+
index: insertIndex
144+
});
145+
});
146+
147+
const componentsManifest = editor.getEditorState().read().components;
148+
const editorState = editor.getEditorState().read();
149+
const breakpointNames = editorState.viewport.breakpoints.map(bp => bp.name);
150+
const baseBreakpoint = breakpointNames[0];
151+
const elementFactory = new ElementFactory(componentsManifest);
152+
153+
for (const oldId of allIds) {
154+
const newId = idMap.get(oldId)!;
155+
const ancestorUpdates = executeOnChange({
156+
editor,
157+
elementId: newId,
158+
action: "create",
159+
breakpointNames,
160+
baseBreakpoint,
161+
elementFactory
162+
});
163+
164+
if (ancestorUpdates.length > 0) {
165+
editor.updateDocument(doc => {
166+
applyAncestorUpdates(
167+
doc,
168+
ancestorUpdates,
169+
breakpointNames,
170+
baseBreakpoint,
171+
elementFactory
172+
);
173+
});
174+
}
175+
}
176+
177+
return idMap.get(id);
178+
}

packages/app-website-builder/src/editorSdk/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ export * from "./$getFirstElementOfType.js";
1414
export * from "./$getElementInputValues.js";
1515
export * from "./$updateElementInputs.js";
1616
export * from "./$previewElementInputs.js";
17+
export * from "./$cloneElement.js";
1718
export * from "./executeOnChange.js";

packages/website-builder-sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ export * from "./InputBindingsProcessor.js";
2828
export * from "./StylesBindingsProcessor.js";
2929
export * from "./ElementFactory.js";
3030
export * from "./ConstraintEvaluator.js";
31+
export * from "./generateElementId.js";
3132
export { StyleSettings } from "./constants.js";

0 commit comments

Comments
 (0)