返回 presentation-ai
getDropPath.ts
根目录 / src / components / notebook / presentation / editor / dnd / utils / getDropPath.ts
1 import { type DragItemNode, type DropDirection } from "@platejs/dnd";
2 import {
3 NodeApi,
4 PathApi,
5 type NodeEntry,
6 type Path,
7 type TElement,
8 } from "platejs";
9 import { type PlateEditor } from "platejs/react";
10 import { type DropTargetMonitor } from "react-dnd";
11
12 import {
13 canLayoutChildTypeBePlacedInParent,
14 CIRCULAR_GRID_GROUP,
15 CIRCULAR_GRID_ITEM,
16 CIRCULAR_GRID_MAX_ITEMS,
17 COLUMN_GROUP,
18 COLUMN_ITEM,
19 getLayoutParentTypes,
20 isLayoutChildType,
21 isLayoutParentType,
22 } from "../../lib";
23 import { type UseDropNodeOptions } from "../hooks";
24 import { getHoverDirection } from "./getHoverDirection";
25
26 type ResolvedDropDirection = Exclude<DropDirection, undefined>;
27
28 export type DropPathResult = {
29 createColumns: boolean;
30 direction: ResolvedDropDirection;
31 dragPath: Path | undefined;
32 hoveredPath: Path;
33 isExternalNode: boolean;
34 isNoop?: boolean;
35 to: Path;
36 };
37
38 /**
39 * Get the drop path for a drag and drop operation.
40 *
41 * @param canCreateColumns - If true, left/right returns path for column creation.
42 * If false, left/right is treated as reorder (like top/bottom).
43 */
44 export const getDropPath = (
45 editor: PlateEditor,
46 {
47 canDropNode,
48 canCreateColumns = false,
49 dragItem,
50 element,
51 monitor,
52 nodeRef,
53 }: {
54 dragItem: DragItemNode;
55 monitor: DropTargetMonitor;
56 canCreateColumns?: boolean;
57 } & Pick<UseDropNodeOptions, "canDropNode" | "element" | "nodeRef">,
58 ) => {
59 const direction = getHoverDirection({
60 dragItem,
61 element,
62 monitor,
63 nodeRef,
64 });
65
66 if (!direction) return;
67
68 return getDropPathFromDirection(editor, {
69 canDropNode,
70 canCreateColumns,
71 direction,
72 dragItem,
73 element,
74 });
75 };
76
77 export const getDropPathFromDirection = (
78 editor: PlateEditor,
79 {
80 canDropNode,
81 canCreateColumns = false,
82 direction,
83 dragItem,
84 element,
85 }: {
86 direction: ResolvedDropDirection;
87 dragItem: DragItemNode;
88 canCreateColumns?: boolean;
89 } & Pick<UseDropNodeOptions, "canDropNode" | "element">,
90 ): DropPathResult | undefined => {
91 let dragEntry: NodeEntry<TElement> | undefined;
92 let dropEntry: NodeEntry<TElement> | undefined;
93
94 if ("element" in dragItem) {
95 const dragPath = editor.api.findPath(dragItem.element);
96 const hoveredPath = editor.api.findPath(element);
97
98 if (!hoveredPath) return;
99
100 // If dragPath is found, we're moving an existing node
101 // If not, we're inserting a new node (e.g., from external source)
102 if (dragPath) {
103 dragEntry = [dragItem.element, dragPath];
104 }
105
106 dropEntry = [element, hoveredPath];
107 } else {
108 dropEntry = editor.api.node({
109 id: element.id as string,
110 at: [],
111 }) as NodeEntry<TElement> | undefined;
112 }
113
114 if (!dropEntry) return;
115
116 // Only check canDropNode if we have a dragEntry (for existing nodes)
117 if (
118 canDropNode &&
119 dragEntry &&
120 !canDropNode({ dragEntry, dragItem, dropEntry, editor })
121 ) {
122 return;
123 }
124
125 const dragPath = dragEntry?.[1];
126 const hoveredPath = dropEntry[1];
127
128 if (dragPath && PathApi.equals(dragPath, hoveredPath)) {
129 return {
130 createColumns: false,
131 direction,
132 dragPath,
133 hoveredPath,
134 isExternalNode: false,
135 isNoop: true,
136 to: dragPath,
137 };
138 }
139
140 const insideContainerPath = getInsideContainerPath({
141 dragPath,
142 dragType: getDraggedElementTypes(editor, dragItem)[0],
143 dropElement: dropEntry[0],
144 hoveredPath,
145 });
146
147 if (insideContainerPath) {
148 const insideResult = {
149 createColumns: false,
150 direction,
151 dragPath,
152 hoveredPath,
153 isExternalNode: !dragPath,
154 to: insideContainerPath,
155 };
156
157 if (!canDropAtPath(editor, dragItem, insideResult)) return;
158
159 return insideResult;
160 }
161
162 // Relationship-backed layout parents only accept their configured child
163 // element types. This blocks drops onto an existing child from sneaking
164 // unrelated blocks into the parent as siblings.
165 const returnIfValid = (result: DropPathResult): DropPathResult | undefined =>
166 canDropAtPath(editor, dragItem, result) ? result : undefined;
167
168 // Handle left/right directions
169 if (direction === "left" || direction === "right") {
170 const draggedTypes = getDraggedElementTypes(editor, dragItem);
171 const canCreateColumnSibling =
172 draggedTypes.length > 0 &&
173 draggedTypes.every(
174 (dragType) =>
175 !isLayoutChildType(dragType) && !isLayoutParentType(dragType),
176 );
177 const containingColumnItemPath = getContainingColumnItemPath(
178 editor,
179 hoveredPath,
180 );
181
182 if (containingColumnItemPath && canCreateColumnSibling) {
183 return returnIfValid({
184 direction,
185 dragPath,
186 hoveredPath,
187 to: containingColumnItemPath,
188 isExternalNode: !dragPath,
189 createColumns: true,
190 });
191 }
192
193 // If canCreateColumns is true, return for column creation (handled by onDropNode)
194 if (canCreateColumns) {
195 return returnIfValid({
196 direction,
197 dragPath,
198 hoveredPath,
199 to: hoveredPath,
200 isExternalNode: !dragPath,
201 createColumns: true,
202 });
203 }
204
205 // Otherwise, treat left/right like top/bottom (reorder)
206 // Left = before (like top), Right = after (like bottom)
207 let dropPath: Path | undefined;
208
209 if (direction === "right") {
210 // Insert after hovered node (like bottom)
211 dropPath = hoveredPath;
212 if (dragPath && PathApi.equals(dragPath, PathApi.next(dropPath))) {
213 return {
214 createColumns: false,
215 direction,
216 dragPath,
217 hoveredPath,
218 isExternalNode: false,
219 isNoop: true,
220 to: dragPath,
221 };
222 }
223 }
224
225 if (direction === "left") {
226 // Insert before hovered node (like top)
227 dropPath = [...hoveredPath.slice(0, -1), hoveredPath.at(-1)! - 1];
228 if (dragPath && PathApi.equals(dragPath, dropPath)) {
229 return {
230 createColumns: false,
231 direction,
232 dragPath,
233 hoveredPath,
234 isExternalNode: false,
235 isNoop: true,
236 to: dragPath,
237 };
238 }
239 }
240
241 if (!dropPath) return;
242
243 const before =
244 dragPath &&
245 PathApi.isBefore(dragPath, dropPath) &&
246 PathApi.isSibling(dragPath, dropPath);
247 const to = before ? dropPath : PathApi.next(dropPath);
248
249 return returnIfValid({
250 createColumns: false,
251 direction,
252 dragPath,
253 hoveredPath,
254 isExternalNode: !dragPath,
255 to,
256 });
257 }
258
259 // Handle top/bottom drops for vertical reordering
260 let dropPath: Path | undefined;
261
262 if (direction === "bottom") {
263 // Insert after hovered node
264 dropPath = hoveredPath;
265
266 // If the dragged node is already right after hovered node, no change
267 if (dragPath && PathApi.equals(dragPath, PathApi.next(dropPath))) {
268 return {
269 createColumns: false,
270 direction,
271 dragPath,
272 hoveredPath,
273 isExternalNode: false,
274 isNoop: true,
275 to: dragPath,
276 };
277 }
278 }
279
280 if (direction === "top") {
281 // Insert before hovered node
282 dropPath = [...hoveredPath.slice(0, -1), hoveredPath.at(-1)! - 1];
283
284 // If the dragged node is already right before hovered node, no change
285 if (dragPath && PathApi.equals(dragPath, dropPath)) {
286 return {
287 createColumns: false,
288 direction,
289 dragPath,
290 hoveredPath,
291 isExternalNode: false,
292 isNoop: true,
293 to: dragPath,
294 };
295 }
296 }
297
298 if (!dropPath) return;
299
300 const before =
301 dragPath &&
302 PathApi.isBefore(dragPath, dropPath) &&
303 PathApi.isSibling(dragPath, dropPath);
304 const to = before ? dropPath : PathApi.next(dropPath);
305
306 return returnIfValid({
307 createColumns: false,
308 direction,
309 dragPath,
310 hoveredPath,
311 isExternalNode: !dragPath,
312 to,
313 });
314 };
315
316 export function canDropAtPath(
317 editor: PlateEditor,
318 dragItem: DragItemNode,
319 result: Pick<DropPathResult, "createColumns" | "dragPath" | "to">,
320 ): boolean {
321 if (!("element" in dragItem)) return true;
322
323 const draggedTypes = getDraggedElementTypes(editor, dragItem);
324
325 if (draggedTypes.length === 0) return false;
326
327 if (result.createColumns) {
328 return draggedTypes.every((dragType) => !isLayoutChildType(dragType));
329 }
330
331 const parentType = getParentTypeAtPath(editor, result.to);
332
333 if (
334 parentType === CIRCULAR_GRID_GROUP &&
335 !canDropIntoCircularGrid(editor, dragItem, result.to)
336 ) {
337 return false;
338 }
339
340 if (parentType && isLayoutParentType(parentType)) {
341 return draggedTypes.every((dragType) =>
342 canLayoutChildTypeBePlacedInParent(dragType, parentType),
343 );
344 }
345
346 return draggedTypes.every((dragType) => {
347 if (!isLayoutChildType(dragType)) return true;
348
349 return Boolean(
350 parentType && getLayoutParentTypes(dragType).includes(parentType),
351 );
352 });
353 }
354
355 function canDropIntoCircularGrid(
356 editor: PlateEditor,
357 dragItem: DragItemNode,
358 dropPath: Path,
359 ): boolean {
360 const parentPath = PathApi.parent(dropPath);
361 const parent = NodeApi.get(editor, parentPath);
362
363 if (!isElementNode(parent) || parent.type !== CIRCULAR_GRID_GROUP) {
364 return true;
365 }
366
367 const draggedEntries = getDraggedElementEntries(editor, dragItem);
368 const circularGridItemsToAdd = draggedEntries.filter(
369 ([node]) => node.type === CIRCULAR_GRID_ITEM,
370 );
371
372 if (circularGridItemsToAdd.length === 0) return true;
373
374 const isSameParentReorder = circularGridItemsToAdd.every(([, path]) =>
375 path ? PathApi.equals(PathApi.parent(path), parentPath) : false,
376 );
377
378 if (isSameParentReorder) return true;
379
380 const currentCircularGridItems = parent.children.filter(
381 (child): child is TElement =>
382 isElementNode(child) && child.type === CIRCULAR_GRID_ITEM,
383 ).length;
384
385 return (
386 currentCircularGridItems + circularGridItemsToAdd.length <=
387 CIRCULAR_GRID_MAX_ITEMS
388 );
389 }
390
391 function getDraggedElementEntries(
392 editor: PlateEditor,
393 dragItem: DragItemNode,
394 ): Array<[TElement, Path | undefined]> {
395 if (!("element" in dragItem)) return [];
396
397 const draggedIds = getDraggedIds(dragItem);
398 const entriesFromIds = draggedIds
399 .map(
400 (id) =>
401 editor.api.node({ id, at: [] }) as NodeEntry<TElement> | undefined,
402 )
403 .filter((entry): entry is NodeEntry<TElement> =>
404 Boolean(entry && isElementNode(entry[0])),
405 )
406 .map(([node, path]) => [node, path] as [TElement, Path | undefined]);
407
408 if (entriesFromIds.length > 0) return entriesFromIds;
409
410 return getElementEntries(dragItem.element);
411 }
412
413 function getElementEntries(value: unknown): Array<[TElement, undefined]> {
414 if (Array.isArray(value)) {
415 return value.flatMap(getElementEntries);
416 }
417
418 if (!isElementNode(value)) return [];
419
420 return [[value, undefined]];
421 }
422
423 function getDraggedElementTypes(
424 editor: PlateEditor,
425 dragItem: DragItemNode,
426 ): string[] {
427 if (!("element" in dragItem)) return [];
428
429 const draggedIds = getDraggedIds(dragItem);
430 const typesFromIds = draggedIds
431 .map((id) => editor.api.node({ id, at: [] })?.[0])
432 .filter(isElementNode)
433 .map((node) => node.type)
434 .filter((type): type is string => typeof type === "string");
435
436 if (typesFromIds.length > 0) return typesFromIds;
437
438 return getElementTypes(dragItem.element);
439 }
440
441 function getDraggedIds(dragItem: DragItemNode): string[] {
442 if (!("id" in dragItem)) return [];
443
444 return Array.isArray(dragItem.id) ? dragItem.id : [dragItem.id];
445 }
446
447 function getElementTypes(value: unknown): string[] {
448 if (Array.isArray(value)) {
449 return value.flatMap(getElementTypes);
450 }
451
452 if (!isElementNode(value) || typeof value.type !== "string") return [];
453
454 return [value.type];
455 }
456
457 function getParentTypeAtPath(
458 editor: PlateEditor,
459 path: Path,
460 ): string | undefined {
461 if (path.length < 2) return undefined;
462
463 const parent = NodeApi.get(editor, PathApi.parent(path));
464
465 return isElementNode(parent) && typeof parent.type === "string"
466 ? parent.type
467 : undefined;
468 }
469
470 function getContainingColumnItemPath(
471 editor: PlateEditor,
472 path: Path,
473 ): Path | undefined {
474 let currentPath = path;
475
476 while (currentPath.length > 0) {
477 const node = NodeApi.get(editor, currentPath);
478
479 if (isElementNode(node) && node.type === COLUMN_ITEM) {
480 const parentPath = PathApi.parent(currentPath);
481 const parent = NodeApi.get(editor, parentPath);
482
483 if (isElementNode(parent) && parent.type === COLUMN_GROUP) {
484 return currentPath;
485 }
486 }
487
488 currentPath = PathApi.parent(currentPath);
489 }
490
491 return undefined;
492 }
493
494 function getInsideContainerPath({
495 dragPath,
496 dragType,
497 dropElement,
498 hoveredPath,
499 }: {
500 dragPath: Path | undefined;
501 dragType: string | undefined;
502 dropElement: TElement;
503 hoveredPath: Path;
504 }): Path | undefined {
505 if (!dragType) return undefined;
506
507 const requiredParentTypes = getRequiredParentTypes(dragType);
508
509 if (
510 requiredParentTypes.length === 0 ||
511 !requiredParentTypes.includes(dropElement.type)
512 ) {
513 return undefined;
514 }
515
516 const childCount = Array.isArray(dropElement.children)
517 ? dropElement.children.length
518 : 0;
519
520 if (dragPath && PathApi.equals(PathApi.parent(dragPath), hoveredPath)) {
521 return undefined;
522 }
523
524 return [...hoveredPath, childCount];
525 }
526
527 function getRequiredParentTypes(childType: string): string[] {
528 return [...getLayoutParentTypes(childType)];
529 }
530
531 function isElementNode(node: unknown): node is TElement {
532 return (
533 typeof node === "object" &&
534 node !== null &&
535 "type" in node &&
536 "children" in node
537 );
538 }
539
539 lines TYPESCRIPT