返回 presentation-ai
onDropNode.ts
1 import { type ElementDragItemNode } from "@platejs/dnd";
2 import { insertColumnGroup } from "@platejs/layout";
3 import { NodeApi, PathApi, type Path, type TElement } from "platejs";
4 import { type PlateEditor } from "platejs/react";
5 import { type DropTargetMonitor } from "react-dnd";
6
7 import { usePresentationState } from "@/states/presentation-state";
8 import { COLUMN_GROUP, COLUMN_ITEM } from "../../lib";
9 import {
10 clonePaletteDropElements,
11 getElementId,
12 getPaletteDragItemKey,
13 getPaletteDragSource,
14 getPaletteMutableSignature,
15 } from "../../utils/paletteDrop";
16 import { type UseDropNodeOptions } from "../hooks";
17 import {
18 getActiveFreeformDropTarget,
19 syncFreeformDropTargetFromClientOffset,
20 } from "../utils/freeformDrop";
21 import {
22 canDropAtPath,
23 getDropPath,
24 type DropPathResult,
25 } from "../utils/getDropPath";
26 import { getHoverDirection } from "../utils/getHoverDirection";
27 import {
28 updateDroppedElementAfterDrop,
29 updateSiblingsAfterDropById,
30 } from "../utils/updateSiblingsForcefully";
31
32 /**
33 * Handle the drop of a node.
34 *
35 * @param canCreateColumns - If true and direction is left/right, create column layout.
36 * If false, left/right drops just reorder.
37 */
38 export const onDropNode = (
39 editor: PlateEditor,
40 {
41 canDropNode,
42 canCreateColumns = false,
43 dragItem,
44 element,
45 monitor,
46 nodeRef,
47 }: {
48 dragItem: ElementDragItemNode;
49 monitor: DropTargetMonitor;
50 canCreateColumns?: boolean;
51 } & Pick<UseDropNodeOptions, "canDropNode" | "element" | "nodeRef">,
52 ) => {
53 const freeformTarget = syncFreeformDropTargetFromClientOffset(
54 editor,
55 dragItem,
56 monitor.getClientOffset(),
57 );
58 let result =
59 freeformTarget?.dropPath ??
60 getDropPath(editor, {
61 canDropNode,
62 canCreateColumns,
63 dragItem,
64 element,
65 monitor,
66 nodeRef,
67 });
68
69 // If getDropPath returns null, try bubble-up for root elements over nested content
70 if (!result) {
71 const direction = getHoverDirection({
72 dragItem,
73 element,
74 monitor,
75 nodeRef,
76 });
77
78 // Only handle left/right for root element drops
79 if (direction === "left" || direction === "right") {
80 const dragPath = dragItem.element
81 ? editor.api.findPath(dragItem.element)
82 : undefined;
83
84 // If dragging a root-level element
85 if (dragPath && dragPath.length === 1) {
86 const hoveredPath = editor.api.findPath(element);
87
88 // If hovering over a nested element
89 if (hoveredPath && hoveredPath.length > 1) {
90 // Find the root-level parent element
91 const rootPath: Path = [hoveredPath[0] as number];
92 const rootElement = NodeApi.get(editor, rootPath) as
93 | TElement
94 | undefined;
95
96 if (
97 rootElement &&
98 rootElement.id !== (dragItem.element?.id as string)
99 ) {
100 // Re-call getDropPath with the root element
101 result = getDropPath(editor, {
102 canDropNode: undefined, // Skip canDropNode check for bubbled drops
103 canCreateColumns: true, // Root elements can create columns
104 dragItem,
105 element: rootElement,
106 monitor,
107 nodeRef: { current: editor.api.toDOMNode(rootElement) },
108 });
109
110 // If still no result, create a synthetic result for column creation
111 if (!result) {
112 result = {
113 direction,
114 dragPath,
115 hoveredPath: rootPath,
116 to: rootPath,
117 isExternalNode: false,
118 createColumns: true,
119 };
120 }
121 }
122 }
123 }
124 }
125
126 if (!result) return;
127 }
128
129 applyDropPathResult(editor, { dragItem, result });
130 };
131
132 export const dropNodeAtFreeformTarget = (
133 editor: PlateEditor,
134 dragItem: ElementDragItemNode,
135 ): boolean => {
136 const target = getActiveFreeformDropTarget(editor);
137
138 if (!target) return false;
139
140 applyDropPathResult(editor, {
141 dragItem,
142 result: target.dropPath,
143 });
144
145 return true;
146 };
147
148 export const applyDropPathResult = (
149 editor: PlateEditor,
150 {
151 dragItem,
152 result,
153 }: {
154 dragItem: ElementDragItemNode;
155 result: DropPathResult;
156 },
157 ): void => {
158 const {
159 direction,
160 dragPath,
161 to,
162 hoveredPath,
163 isExternalNode,
164 createColumns,
165 isNoop,
166 } = result;
167
168 if (isNoop) return;
169
170 if (!canDropAtPath(editor, dragItem, result)) return;
171
172 const draggedIds = Array.isArray(dragItem.id) ? dragItem.id : [dragItem.id];
173 const draggedElementIds = draggedIds.filter(
174 (id): id is string => typeof id === "string",
175 );
176 const trackPaletteDrop = (insertedElementId: string | null) => {
177 const source = getPaletteDragSource(dragItem);
178
179 if (!source || !insertedElementId) return;
180
181 const insertedEntry = editor.api.node({ id: insertedElementId, at: [] });
182 const [insertedElement] = insertedEntry ?? [];
183
184 usePresentationState.getState().setPaletteDropTarget({
185 editorId: editor.id,
186 elementId: insertedElementId,
187 itemKey: getPaletteDragItemKey(dragItem),
188 source,
189 mutableSignature: insertedElement
190 ? getPaletteMutableSignature(insertedElement)
191 : undefined,
192 });
193 };
194
195 // Handle column creation (only when canCreateColumns=true AND direction is left/right)
196 if (createColumns && (direction === "left" || direction === "right")) {
197 if (!hoveredPath) return;
198
199 const existingColumnItemPath = getContainingColumnItemPath(
200 editor,
201 hoveredPath,
202 );
203
204 if (existingColumnItemPath) {
205 const columnGroupPath = PathApi.parent(existingColumnItemPath);
206 const targetColumnIndex = existingColumnItemPath.at(-1);
207 const columnGroup = NodeApi.get(editor, columnGroupPath) as
208 | TElement
209 | undefined;
210
211 if (
212 typeof targetColumnIndex !== "number" ||
213 !isElementNode(columnGroup)
214 ) {
215 return;
216 }
217
218 const newColumnIndex =
219 direction === "left" ? targetColumnIndex : targetColumnIndex + 1;
220 const newColumnPath = [...columnGroupPath, newColumnIndex];
221 const draggedElementIdSet = new Set(draggedElementIds);
222 const currentWidths = getColumnWidths(columnGroup);
223 const targetWidth =
224 currentWidths[targetColumnIndex] ?? 100 / currentWidths.length;
225 const newColumnWidth = roundWidth(targetWidth / 2);
226 const targetColumnWidth = roundWidth(targetWidth - newColumnWidth);
227 const finalWidths = [...currentWidths];
228
229 finalWidths[targetColumnIndex] = targetColumnWidth;
230 finalWidths.splice(newColumnIndex, 0, newColumnWidth);
231
232 editor.tf.withoutNormalizing(() => {
233 editor.tf.insertNodes(
234 {
235 type: COLUMN_ITEM,
236 width: newColumnWidth,
237 children: [],
238 },
239 { at: newColumnPath },
240 );
241
242 finalWidths.forEach((width, index) => {
243 editor.tf.setNodes({ width }, { at: [...columnGroupPath, index] });
244 });
245
246 if (
247 isExternalNode &&
248 dragItem.element &&
249 typeof dragItem.element === "object"
250 ) {
251 const elements = clonePaletteDropElements(dragItem.element);
252
253 elements.forEach((elem, index) => {
254 editor.tf.insertNodes(elem, {
255 at: [...newColumnPath, index],
256 });
257 });
258 trackPaletteDrop(getElementId(elements[0]));
259 } else {
260 editor.tf.moveNodes({
261 at: [],
262 to: [...newColumnPath, 0],
263 match: (n) => draggedElementIdSet.has(n.id as string),
264 });
265 }
266
267 draggedElementIdSet.forEach((id) => {
268 updateSiblingsAfterDropById(editor, id);
269 });
270 });
271
272 return;
273 }
274
275 const targetElementId = (
276 NodeApi.get(editor, hoveredPath) as TElement | undefined
277 )?.id as string | undefined;
278
279 if (!targetElementId) return;
280
281 const draggedElementIdSet = new Set(draggedElementIds);
282
283 // Create a column group with 2 columns at the hovered position
284 insertColumnGroup(editor, {
285 columns: 2,
286 at: hoveredPath,
287 });
288
289 const columnGroupPath = hoveredPath;
290 const firstColumnPath = [...columnGroupPath, 0];
291 const secondColumnPath = [...columnGroupPath, 1];
292
293 // Determine which column gets which content based on direction
294 const targetColumnPath =
295 direction === "left" ? secondColumnPath : firstColumnPath;
296 const draggedColumnPath =
297 direction === "left" ? firstColumnPath : secondColumnPath;
298
299 editor.tf.withoutNormalizing(() => {
300 editor.tf.setNodes({ width: 50 }, { at: firstColumnPath });
301 editor.tf.setNodes({ width: 50 }, { at: secondColumnPath });
302
303 // Move the target element into its column
304 editor.tf.moveNodes({
305 at: [],
306 to: [...targetColumnPath, 0],
307 match: (n) => n.id === targetElementId,
308 });
309
310 if (
311 isExternalNode &&
312 dragItem.element &&
313 typeof dragItem.element === "object"
314 ) {
315 const elements = clonePaletteDropElements(dragItem.element);
316
317 elements.forEach((elem, index) => {
318 editor.tf.insertNodes(elem, {
319 at: [...draggedColumnPath, index],
320 });
321 });
322 trackPaletteDrop(getElementId(elements[0]));
323 } else {
324 // Move all dragged nodes into the dragged column
325 const nodesToMove: TElement[] = [];
326 draggedElementIdSet.forEach((id) => {
327 const entry = editor.api.node({ id, at: [] });
328 if (entry) {
329 nodesToMove.push(entry[0] as TElement);
330 }
331 });
332
333 if (nodesToMove.length > 0) {
334 editor.tf.moveNodes({
335 at: [],
336 to: [...draggedColumnPath, 0],
337 match: (n) => draggedElementIdSet.has(n.id as string),
338 });
339 }
340 }
341
342 // Resolve by id after the move. Adjacent same-parent moves can make the
343 // requested target path stale once Slate applies path transforms.
344 draggedElementIdSet.forEach((id) => {
345 updateSiblingsAfterDropById(editor, id);
346 });
347 });
348
349 return;
350 }
351
352 // Handle reordering (all other cases: top/bottom or left/right without column creation)
353 if (!to) return;
354
355 if (draggedIds.length > 1) {
356 // Handle multi-node drop
357 const draggedElementIdSet = new Set(draggedElementIds);
358 const draggedPathRefs = draggedElementIds.flatMap((id) => {
359 const entry = editor.api.node({ id, at: [] });
360
361 return entry ? [editor.api.pathRef(entry[1])] : [];
362 });
363
364 editor.tf.moveNodes({
365 at: [],
366 to,
367 match: (n) => draggedElementIdSet.has(n.id as string),
368 });
369
370 draggedPathRefs.forEach((pathRef, index) => {
371 const droppedPath = pathRef.unref();
372
373 if (droppedPath) {
374 updateDroppedElementAfterDrop(editor, droppedPath);
375 }
376
377 const droppedElementId = draggedElementIds[index];
378 if (droppedElementId) {
379 updateSiblingsAfterDropById(editor, droppedElementId);
380 }
381 });
382 } else if (
383 isExternalNode &&
384 dragItem.element &&
385 typeof dragItem.element === "object"
386 ) {
387 // External node - insert at position
388 const elements = clonePaletteDropElements(dragItem.element);
389 const firstElement = elements[0];
390
391 if (!firstElement) return;
392
393 editor.tf.insertNodes(elements.length === 1 ? firstElement : elements, {
394 at: to,
395 });
396 trackPaletteDrop(getElementId(firstElement));
397
398 const insertionIndex = to.at(-1);
399 if (typeof insertionIndex === "number") {
400 elements.forEach((_, index) => {
401 updateDroppedElementAfterDrop(editor, [
402 ...to.slice(0, -1),
403 insertionIndex + index,
404 ]);
405 });
406 }
407 } else if (dragPath) {
408 const droppedPathRef = editor.api.pathRef(dragPath);
409
410 // Single node drop - standard move
411 editor.tf.moveNodes({
412 at: dragPath,
413 to,
414 });
415
416 const droppedPath = droppedPathRef.unref();
417 const droppedElementId = draggedElementIds[0];
418
419 if (droppedPath) {
420 updateDroppedElementAfterDrop(editor, droppedPath);
421 }
422
423 if (droppedElementId) {
424 updateSiblingsAfterDropById(editor, droppedElementId);
425 }
426 }
427 };
428
429 function getContainingColumnItemPath(
430 editor: PlateEditor,
431 path: Path,
432 ): Path | undefined {
433 let currentPath = path;
434
435 while (currentPath.length > 0) {
436 const node = NodeApi.get(editor, currentPath);
437
438 if (isElementNode(node) && node.type === COLUMN_ITEM) {
439 const parentPath = PathApi.parent(currentPath);
440 const parent = NodeApi.get(editor, parentPath);
441
442 if (isElementNode(parent) && parent.type === COLUMN_GROUP) {
443 return currentPath;
444 }
445 }
446
447 currentPath = PathApi.parent(currentPath);
448 }
449
450 return undefined;
451 }
452
453 function isElementNode(node: unknown): node is TElement {
454 return (
455 typeof node === "object" &&
456 node !== null &&
457 "type" in node &&
458 "children" in node
459 );
460 }
461
462 function getColumnWidths(columnGroup: TElement): number[] {
463 const columns = columnGroup.children.filter(
464 (child): child is TElement =>
465 isElementNode(child) && child.type === COLUMN_ITEM,
466 );
467 const fallbackWidth = 100 / Math.max(columns.length, 1);
468
469 return columns.map(
470 (column) => parseColumnWidth(column.width) ?? fallbackWidth,
471 );
472 }
473
474 function parseColumnWidth(width: unknown): number | null {
475 if (width === undefined || width === null) return null;
476
477 const parsed = Number.parseFloat(String(width));
478
479 if (!Number.isFinite(parsed) || parsed <= 0) return null;
480
481 return parsed;
482 }
483
484 function roundWidth(width: number): number {
485 return Math.round(width * 100) / 100;
486 }
487
487 lines TYPESCRIPT