返回 presentation-ai
CustomItemControls.tsx
根目录 / src / components / presentation / floating-toolbar / CustomItemControls.tsx
1 "use client";
2
3 import { Copy, Shapes, StepBack, StepForward, Trash2 } from "lucide-react";
4 import { KEYS, nanoid, NodeApi, PathApi, type TElement } from "platejs";
5
6 import { updateSiblingsForcefully } from "@/components/notebook/presentation/editor/dnd/utils/updateSiblingsForcefully";
7 import {
8 ARROW_LIST_ITEM,
9 BOX_ITEM,
10 BULLET_ITEM,
11 CYCLE_ITEM,
12 ICON_ELEMENT,
13 ICON_LIST_ITEM,
14 PYRAMID_ITEM,
15 STAIR_ITEM,
16 TIMELINE_ITEM,
17 } from "@/components/notebook/presentation/editor/lib";
18 import { ToolbarButton, ToolbarGroup } from "@/components/plate/ui/toolbar";
19 import { IconPicker } from "@/components/ui/icon-picker";
20 import { cn } from "@/lib/utils";
21 import { useToolbarContext } from "./ToolbarContext";
22
23 const ICON_EDITABLE_ITEM_TYPES = new Set<string>([
24 ARROW_LIST_ITEM,
25 BOX_ITEM,
26 BULLET_ITEM,
27 CYCLE_ITEM,
28 ICON_ELEMENT,
29 ICON_LIST_ITEM,
30 PYRAMID_ITEM,
31 STAIR_ITEM,
32 TIMELINE_ITEM,
33 ]);
34
35 function isElementNode(node: unknown): node is TElement {
36 return (
37 typeof node === "object" &&
38 node !== null &&
39 "type" in node &&
40 "children" in node
41 );
42 }
43
44 function createBlankItemFrom(item: TElement): TElement {
45 const itemRecord = item as TElement & Record<string, unknown>;
46 const alignment =
47 typeof itemRecord.alignment === "string"
48 ? { alignment: itemRecord.alignment }
49 : {};
50
51 return {
52 ...alignment,
53 id: nanoid(),
54 type: item.type,
55 children: [
56 {
57 id: nanoid(),
58 type: KEYS.p,
59 children: [{ text: "" }],
60 },
61 ],
62 };
63 }
64
65 function cloneWithNewIds(node: unknown): unknown {
66 if (Array.isArray(node)) {
67 return node.map(cloneWithNewIds);
68 }
69 if (node !== null && typeof node === "object") {
70 const clone = { ...node } as Record<string, unknown>;
71 if ("id" in clone) {
72 clone.id = nanoid();
73 }
74 for (const key in clone) {
75 clone[key] = cloneWithNewIds(clone[key]);
76 }
77 return clone;
78 }
79 return node;
80 }
81
82 export function CustomItemControls() {
83 const {
84 editor,
85 element,
86 elementType,
87 handleNodePropertyUpdate,
88 isLayoutChildElement,
89 } = useToolbarContext();
90
91 if (!isLayoutChildElement || !element || !isElementNode(element)) {
92 return null;
93 }
94
95 const itemPath = editor.api.findPath(element);
96 const parentPath = itemPath ? PathApi.parent(itemPath) : undefined;
97 const currentIcon =
98 typeof (element as { icon?: unknown }).icon === "string"
99 ? (element as TElement & { icon: string }).icon
100 : undefined;
101 const supportsIcon = ICON_EDITABLE_ITEM_TYPES.has(elementType);
102
103 const forceUpdateSiblings = () => {
104 if (!parentPath) return;
105 const currentParent = NodeApi.get(editor, parentPath) as
106 | TElement
107 | undefined;
108 if (currentParent && Array.isArray(currentParent.children)) {
109 updateSiblingsForcefully(editor, currentParent, parentPath);
110 }
111 };
112
113 const insertItem = (placement: "before" | "after") => {
114 if (!itemPath) return;
115
116 editor.tf.withoutNormalizing(() => {
117 const insertionPath =
118 placement === "before" ? itemPath : PathApi.next(itemPath);
119
120 editor.tf.insertNodes(createBlankItemFrom(element), {
121 at: insertionPath,
122 select: true,
123 });
124 forceUpdateSiblings();
125 });
126 };
127
128 const duplicateItem = () => {
129 if (!itemPath) return;
130
131 editor.tf.withoutNormalizing(() => {
132 const clonedNode = cloneWithNewIds(element) as TElement;
133 editor.tf.insertNodes(clonedNode, {
134 at: PathApi.next(itemPath),
135 select: true,
136 });
137 forceUpdateSiblings();
138 });
139 };
140
141 return (
142 <>
143 <ToolbarGroup>
144 <ToolbarButton
145 onClick={() => insertItem("before")}
146 size="sm"
147 tooltip="Add Previous Item"
148 >
149 <StepBack className="size-4" />
150 </ToolbarButton>
151 <ToolbarButton
152 onClick={() => insertItem("after")}
153 size="sm"
154 tooltip="Add Next Item"
155 >
156 <StepForward className="size-4" />
157 </ToolbarButton>
158 <ToolbarButton
159 onClick={duplicateItem}
160 size="sm"
161 tooltip="Duplicate Item"
162 >
163 <Copy className="size-4" />
164 </ToolbarButton>
165 </ToolbarGroup>
166
167 {supportsIcon && (
168 <ToolbarGroup>
169 <IconPicker
170 defaultIcon={currentIcon}
171 placeholder={<Shapes className="size-4" />}
172 size="sm"
173 variant="ghost"
174 className={cn("border-0 shadow-none")}
175 title="Change icon"
176 onIconSelect={(iconName) =>
177 handleNodePropertyUpdate("icon", iconName)
178 }
179 onIconRemove={() => handleNodePropertyUpdate("icon", undefined)}
180 />
181 </ToolbarGroup>
182 )}
183 </>
184 );
185 }
186
187 export function CustomItemDeleteButton() {
188 const { editor, element, isLayoutChildElement } = useToolbarContext();
189
190 if (!isLayoutChildElement || !element || !isElementNode(element)) {
191 return null;
192 }
193
194 const itemPath = editor.api.findPath(element);
195 const parentPath = itemPath ? PathApi.parent(itemPath) : undefined;
196 const parent = parentPath ? NodeApi.get(editor, parentPath) : undefined;
197 const canDelete =
198 isElementNode(parent) && Array.isArray(parent.children)
199 ? parent.children.length > 1
200 : true;
201
202 const forceUpdateSiblings = () => {
203 if (!parentPath) return;
204 const currentParent = NodeApi.get(editor, parentPath) as
205 | TElement
206 | undefined;
207 if (currentParent && Array.isArray(currentParent.children)) {
208 updateSiblingsForcefully(editor, currentParent, parentPath);
209 }
210 };
211
212 const deleteItem = () => {
213 if (!itemPath || !canDelete) return;
214
215 editor.tf.withoutNormalizing(() => {
216 editor.tf.removeNodes({ at: itemPath });
217 forceUpdateSiblings();
218 });
219 };
220
221 return (
222 <ToolbarButton
223 disabled={!canDelete}
224 onClick={deleteItem}
225 size="sm"
226 tooltip={canDelete ? "Delete Item" : "Keep at least one item"}
227 className="text-destructive hover:bg-destructive/10 hover:text-destructive"
228 >
229 <Trash2 className="size-4" />
230 </ToolbarButton>
231 );
232 }
233
233 lines Plain Text