返回 presentation-ai
paletteDrop.ts
根目录 / src / components / notebook / presentation / editor / utils / paletteDrop.ts
1 import { KEYS, nanoid, NodeApi, type TElement } from "platejs";
2 import { type PlateEditor } from "platejs/react";
3
4 export type PaletteDropSource =
5 | "basicBlocks"
6 | "charts"
7 | "diagrams"
8 | "elements";
9
10 export type PaletteDropTarget = {
11 editorId: string;
12 elementId: string;
13 itemKey?: string;
14 source: PaletteDropSource;
15 targetKind?: "element" | "rootImage";
16 mutableSignature?: string;
17 };
18
19 export const PALETTE_DROP_MUTABLE_KEY = "paletteDropMutable";
20 const EMPTY_PLACEHOLDER_BLOCK_TYPES = new Set([
21 KEYS.p,
22 "h1",
23 "h2",
24 "h3",
25 "h4",
26 "h5",
27 "h6",
28 ]);
29
30 type PaletteDragItem = {
31 element?: unknown;
32 itemKey?: unknown;
33 sourcePanel?: PaletteDropSource;
34 };
35
36 function isElementNode(node: unknown): node is TElement {
37 return (
38 typeof node === "object" &&
39 node !== null &&
40 "type" in node &&
41 "children" in node
42 );
43 }
44
45 function cloneTextLikeNode(node: unknown): unknown {
46 if (typeof node !== "object" || node === null) {
47 return node;
48 }
49
50 if (Array.isArray(node)) {
51 return node.map((child) =>
52 isElementNode(child)
53 ? cloneElementWithFreshIds(child)
54 : cloneTextLikeNode(child),
55 );
56 }
57
58 return { ...(node as Record<string, unknown>) };
59 }
60
61 function cloneElementWithFreshIds(
62 element: TElement,
63 preservedRootId?: string,
64 ): TElement {
65 const elementRecord = element as TElement & Record<string, unknown>;
66 const children = Array.isArray(elementRecord.children)
67 ? elementRecord.children.map((child) =>
68 isElementNode(child)
69 ? cloneElementWithFreshIds(child)
70 : cloneTextLikeNode(child),
71 )
72 : [];
73
74 return {
75 ...elementRecord,
76 id: preservedRootId ?? nanoid(),
77 [PALETTE_DROP_MUTABLE_KEY]: true,
78 children,
79 } as TElement;
80 }
81
82 export function isPaletteDropMutable(target: unknown): boolean {
83 return (
84 typeof target === "object" &&
85 target !== null &&
86 (target as Record<string, unknown>)[PALETTE_DROP_MUTABLE_KEY] === true
87 );
88 }
89
90 function normalizeForPaletteSignature(value: unknown): unknown {
91 if (Array.isArray(value)) {
92 return value.map(normalizeForPaletteSignature);
93 }
94
95 if (typeof value !== "object" || value === null) {
96 return value;
97 }
98
99 const valueRecord = value as Record<string, unknown>;
100 const normalizedEntries = Object.keys(valueRecord)
101 .filter(
102 (key) =>
103 key !== "id" &&
104 key !== "lastUpdate" &&
105 key !== PALETTE_DROP_MUTABLE_KEY,
106 )
107 .sort()
108 .map((key) => [key, normalizeForPaletteSignature(valueRecord[key])]);
109
110 return Object.fromEntries(normalizedEntries);
111 }
112
113 export function getPaletteMutableSignature(value: unknown): string {
114 return JSON.stringify(normalizeForPaletteSignature(value));
115 }
116
117 export function clonePaletteDropElements(element: unknown): TElement[] {
118 if (Array.isArray(element)) {
119 return element
120 .filter(isElementNode)
121 .map((node) => cloneElementWithFreshIds(node));
122 }
123
124 return isElementNode(element) ? [cloneElementWithFreshIds(element)] : [];
125 }
126
127 export function getPaletteDragSource(
128 dragItem: unknown,
129 ): PaletteDropSource | null {
130 const sourcePanel = (dragItem as PaletteDragItem | undefined)?.sourcePanel;
131
132 return sourcePanel === "basicBlocks" ||
133 sourcePanel === "elements" ||
134 sourcePanel === "charts" ||
135 sourcePanel === "diagrams"
136 ? sourcePanel
137 : null;
138 }
139
140 export function getPaletteDragItemKey(dragItem: unknown): string | undefined {
141 const itemKey = (dragItem as PaletteDragItem | undefined)?.itemKey;
142
143 return typeof itemKey === "string" ? itemKey : undefined;
144 }
145
146 export function getElementId(element: TElement | undefined): string | null {
147 const id = (element as { id?: unknown } | undefined)?.id;
148
149 return typeof id === "string" ? id : null;
150 }
151
152 function isEmptyPlaceholderBlock(node: unknown): node is TElement {
153 return (
154 isElementNode(node) &&
155 EMPTY_PLACEHOLDER_BLOCK_TYPES.has(node.type) &&
156 NodeApi.string(node).trim().length === 0
157 );
158 }
159
160 function getEmptyPlaceholderFallback(
161 editor: PlateEditor,
162 ): [TElement, number[]] | null {
163 if (!editor.api.isEmpty()) return null;
164
165 const firstBlock = editor.children[0];
166
167 return isEmptyPlaceholderBlock(firstBlock) ? [firstBlock, [0]] : null;
168 }
169
170 export function replaceElementById(
171 editor: PlateEditor,
172 elementId: string,
173 nextElement: TElement,
174 expectedMutableSignature?: string,
175 ): boolean {
176 const entry = editor.api.node({ id: elementId, at: [] });
177
178 if (!entry) return false;
179
180 const [currentElement, path] = entry;
181
182 if (!isPaletteDropMutable(currentElement)) return false;
183 if (
184 expectedMutableSignature &&
185 getPaletteMutableSignature(currentElement) !== expectedMutableSignature
186 ) {
187 return false;
188 }
189
190 const replacement = cloneElementWithFreshIds(nextElement, elementId);
191
192 editor.tf.withoutNormalizing(() => {
193 editor.tf.removeNodes({ at: path });
194 editor.tf.insertNodes(replacement, { at: path });
195 });
196
197 return true;
198 }
199
200 export function replaceFocusedEmptyParagraph(
201 editor: PlateEditor,
202 nextElement: TElement,
203 ): TElement | null {
204 const focus = editor.selection?.focus;
205 const blockEntry = focus ? editor.api.block({ at: focus }) : null;
206 const targetEntry =
207 blockEntry && isEmptyPlaceholderBlock(blockEntry[0])
208 ? blockEntry
209 : getEmptyPlaceholderFallback(editor);
210
211 if (!targetEntry) return null;
212
213 const [, blockPath] = targetEntry;
214
215 const replacement = cloneElementWithFreshIds(nextElement);
216
217 editor.tf.withoutNormalizing(() => {
218 editor.tf.removeNodes({ at: blockPath });
219 editor.tf.insertNodes(replacement, { at: blockPath });
220 });
221
222 return replacement;
223 }
224
224 lines TYPESCRIPT