返回 presentation-ai
freeformDrop.ts
根目录 / src / components / notebook / presentation / editor / dnd / utils / freeformDrop.ts
1 import {
2 DndPlugin,
3 type DragItemNode,
4 type DropLineDirection,
5 type ElementDragItemNode,
6 } from "@platejs/dnd";
7 import { PathApi, type NodeEntry, type Path, type TElement } from "platejs";
8 import { type PlateEditor } from "platejs/react";
9 import { type RefObject } from "react";
10
11 import { type CanDropCallback } from "../hooks/useDropNode";
12 import { getDropPathFromDirection, type DropPathResult } from "./getDropPath";
13
14 export type DropOrientation = "horizontal" | "vertical";
15
16 type ResolvedDropLineDirection = Exclude<DropLineDirection, "">;
17
18 type PointerCoordinates = {
19 clientX: number;
20 clientY: number;
21 };
22
23 export type FreeformDropRegistration = {
24 canCreateColumns: boolean;
25 canDropNode?: CanDropCallback;
26 element: TElement;
27 id: string;
28 nodeRef: RefObject<HTMLElement | null>;
29 orientation: DropOrientation;
30 };
31
32 export type FreeformDropTarget = {
33 direction: ResolvedDropLineDirection;
34 dropPath: DropPathResult;
35 element: TElement;
36 id: string;
37 nodeRef: RefObject<HTMLElement | null>;
38 };
39
40 type FreeformDropCandidate = {
41 dropElement: TElement;
42 dropPath: Path;
43 orientation: DropOrientation;
44 rect: DOMRect;
45 registration: FreeformDropRegistration;
46 };
47
48 type FreeformDragSnapshot = {
49 candidates: FreeformDropCandidate[];
50 draggedEntries: NodeEntry<TElement>[];
51 primaryDragPath: Path | undefined;
52 resolvedDragItem: ElementDragItemNode;
53 };
54
55 type ActiveFreeformDrag = {
56 cleanup: () => void;
57 dragItem: ElementDragItemNode;
58 frameId: number | null;
59 lastCoordinates: PointerCoordinates | null;
60 lastTarget: FreeformDropTarget | null;
61 snapshot: FreeformDragSnapshot;
62 };
63
64 const COLUMN_EDGE_THRESHOLD_RATIO = 0.18;
65 const MIN_COLUMN_EDGE_THRESHOLD_PX = 44;
66 const MAX_COLUMN_EDGE_THRESHOLD_PX = 96;
67 const EDITOR_DROP_BOUNDARY_PADDING_PX = 36;
68 const SAME_PARENT_SCORE_BONUS = 18;
69 const TARGET_DEPTH_SCORE_BONUS = 14;
70 const INSIDE_TARGET_SCORE_BONUS = 22;
71 const registrationsByEditorId = new Map<
72 string,
73 Map<symbol, FreeformDropRegistration>
74 >();
75 const activeDragsByEditorId = new Map<string, ActiveFreeformDrag>();
76
77 export function registerFreeformDropNode(
78 editor: PlateEditor,
79 registration: FreeformDropRegistration,
80 ): () => void {
81 const key = Symbol(registration.id);
82 const editorRegistrations =
83 registrationsByEditorId.get(editor.id) ??
84 new Map<symbol, FreeformDropRegistration>();
85
86 editorRegistrations.set(key, registration);
87 registrationsByEditorId.set(editor.id, editorRegistrations);
88
89 return () => {
90 editorRegistrations.delete(key);
91
92 if (editorRegistrations.size === 0) {
93 registrationsByEditorId.delete(editor.id);
94 }
95 };
96 }
97
98 export function startFreeformDrag(
99 editor: PlateEditor,
100 dragItem: ElementDragItemNode,
101 ): void {
102 stopFreeformDrag(editor, { clearDropTarget: false });
103
104 const activeDrag: ActiveFreeformDrag = {
105 cleanup: () => undefined,
106 dragItem,
107 frameId: null,
108 lastCoordinates: null,
109 lastTarget: null,
110 snapshot: createFreeformDragSnapshot(editor, dragItem),
111 };
112
113 const syncFromEvent = (event: DragEvent) => {
114 if (event.clientX === 0 && event.clientY === 0) return;
115
116 activeDrag.lastCoordinates = {
117 clientX: event.clientX,
118 clientY: event.clientY,
119 };
120
121 if (activeDrag.frameId !== null) return;
122
123 activeDrag.frameId = window.requestAnimationFrame(() => {
124 activeDrag.frameId = null;
125
126 if (!activeDrag.lastCoordinates) return;
127
128 activeDrag.lastTarget = syncFreeformDropTarget(editor, dragItem, {
129 coordinates: activeDrag.lastCoordinates,
130 });
131 });
132 };
133
134 window.addEventListener("drag", syncFromEvent, true);
135 window.addEventListener("dragover", syncFromEvent, true);
136 window.addEventListener("drop", syncFromEvent, true);
137
138 activeDrag.cleanup = () => {
139 window.removeEventListener("drag", syncFromEvent, true);
140 window.removeEventListener("dragover", syncFromEvent, true);
141 window.removeEventListener("drop", syncFromEvent, true);
142
143 if (activeDrag.frameId !== null) {
144 window.cancelAnimationFrame(activeDrag.frameId);
145 activeDrag.frameId = null;
146 }
147 };
148
149 activeDragsByEditorId.set(editor.id, activeDrag);
150 }
151
152 export function stopFreeformDrag(
153 editor: PlateEditor,
154 { clearDropTarget = true }: { clearDropTarget?: boolean } = {},
155 ): void {
156 const activeDrag = activeDragsByEditorId.get(editor.id);
157
158 if (activeDrag) {
159 activeDrag.cleanup();
160 activeDragsByEditorId.delete(editor.id);
161 }
162
163 if (clearDropTarget) {
164 setEditorDropTarget(editor, null);
165 }
166 }
167
168 export function getActiveFreeformDropTarget(
169 editor: PlateEditor,
170 ): FreeformDropTarget | null {
171 return activeDragsByEditorId.get(editor.id)?.lastTarget ?? null;
172 }
173
174 export function syncFreeformDropTargetFromClientOffset(
175 editor: PlateEditor,
176 dragItem: DragItemNode,
177 clientOffset: { x: number; y: number } | null,
178 ): FreeformDropTarget | null | undefined {
179 if (!clientOffset || !isElementDragItem(dragItem)) return undefined;
180
181 const target = syncFreeformDropTarget(editor, dragItem, {
182 coordinates: {
183 clientX: clientOffset.x,
184 clientY: clientOffset.y,
185 },
186 });
187 const activeDrag = activeDragsByEditorId.get(editor.id);
188
189 if (activeDrag) {
190 activeDrag.lastCoordinates = {
191 clientX: clientOffset.x,
192 clientY: clientOffset.y,
193 };
194 activeDrag.lastTarget = target;
195 }
196
197 return target;
198 }
199
200 function resolveFreeformDropTarget(
201 editor: PlateEditor,
202 dragItem: ElementDragItemNode,
203 { coordinates }: { coordinates: PointerCoordinates },
204 ): FreeformDropTarget | null {
205 if (!isPointInsideEditor(editor, coordinates)) return null;
206
207 const snapshot =
208 activeDragsByEditorId.get(editor.id)?.snapshot ??
209 createFreeformDragSnapshot(editor, dragItem);
210 let bestTarget: FreeformDropTarget | null = null;
211 let bestScore = Number.POSITIVE_INFINITY;
212
213 snapshot.candidates.forEach(
214 ({ dropElement, dropPath, orientation, rect, registration }) => {
215 const direction = getCandidateDirection({
216 coordinates,
217 orientation,
218 rect,
219 registration,
220 });
221 const dropPathResult = getDropPathFromDirection(editor, {
222 canCreateColumns: registration.canCreateColumns,
223 canDropNode: registration.canDropNode,
224 direction,
225 dragItem: snapshot.resolvedDragItem,
226 element: dropElement,
227 });
228
229 if (!dropPathResult) return;
230
231 const score = getCandidateScore({
232 coordinates,
233 direction,
234 dropPath,
235 primaryDragPath: snapshot.primaryDragPath,
236 rect,
237 registration,
238 });
239
240 if (score < bestScore) {
241 bestScore = score;
242 bestTarget = {
243 direction,
244 dropPath: dropPathResult,
245 element: dropElement,
246 id: registration.id,
247 nodeRef: registration.nodeRef,
248 };
249 }
250 },
251 );
252
253 return bestTarget;
254 }
255
256 function syncFreeformDropTarget(
257 editor: PlateEditor,
258 dragItem: ElementDragItemNode,
259 { coordinates }: { coordinates: PointerCoordinates },
260 ): FreeformDropTarget | null {
261 const target = resolveFreeformDropTarget(editor, dragItem, { coordinates });
262
263 setEditorDropTarget(editor, target);
264
265 return target;
266 }
267
268 function setEditorDropTarget(
269 editor: PlateEditor,
270 target: FreeformDropTarget | null,
271 ): void {
272 const current = editor.getOptions(DndPlugin).dropTarget;
273 const nextDropTarget = target
274 ? { id: target.id, line: target.direction }
275 : { id: null, line: "" as DropLineDirection };
276
277 if (
278 current?.id === nextDropTarget.id &&
279 current?.line === nextDropTarget.line
280 ) {
281 return;
282 }
283
284 editor.setOption(DndPlugin, "dropTarget", nextDropTarget);
285 }
286
287 function isElementDragItem(
288 dragItem: DragItemNode | undefined,
289 ): dragItem is ElementDragItemNode {
290 return Boolean(
291 dragItem &&
292 "id" in dragItem &&
293 "element" in dragItem &&
294 "editorId" in dragItem,
295 );
296 }
297
298 function getResolvedDragItem(
299 editor: PlateEditor,
300 dragItem: ElementDragItemNode,
301 ): ElementDragItemNode {
302 const primaryDragId = getDraggedIds(dragItem)[0];
303
304 if (!primaryDragId) return dragItem;
305
306 const freshEntry = getElementEntryById(editor, primaryDragId);
307
308 if (!freshEntry) return dragItem;
309
310 return {
311 ...dragItem,
312 element: freshEntry[0],
313 };
314 }
315
316 function getDraggedEntries(
317 editor: PlateEditor,
318 dragItem: ElementDragItemNode,
319 ): NodeEntry<TElement>[] {
320 return getDraggedIds(dragItem)
321 .map((id) => getElementEntryById(editor, id))
322 .filter((entry): entry is NodeEntry<TElement> => Boolean(entry));
323 }
324
325 function getDraggedIds(dragItem: ElementDragItemNode): string[] {
326 return Array.isArray(dragItem.id) ? dragItem.id : [dragItem.id];
327 }
328
329 function createFreeformDragSnapshot(
330 editor: PlateEditor,
331 dragItem: ElementDragItemNode,
332 ): FreeformDragSnapshot {
333 const resolvedDragItem = getResolvedDragItem(editor, dragItem);
334 const draggedEntries = getDraggedEntries(editor, resolvedDragItem);
335 const primaryDragPath = draggedEntries[0]?.[1];
336 const groupedRegistrations = getGroupedRegistrations(editor.id);
337 const candidatesWithoutOrientation: Array<
338 Omit<FreeformDropCandidate, "orientation">
339 > = [];
340
341 groupedRegistrations.forEach((registrations) => {
342 const preferred = selectPreferredRegistrationWithRect(registrations);
343
344 if (!preferred) return;
345
346 const { rect, registration } = preferred;
347 const dropEntry = getElementEntryById(editor, registration.id);
348
349 if (!dropEntry) return;
350
351 const [dropElement, dropPath] = dropEntry;
352
353 if (
354 draggedEntries.some(([, dragPath]) =>
355 PathApi.isAncestor(dragPath, dropPath),
356 )
357 ) {
358 return;
359 }
360
361 candidatesWithoutOrientation.push({
362 dropElement,
363 dropPath,
364 rect,
365 registration,
366 });
367 });
368
369 const candidates = candidatesWithoutOrientation.map((candidate) => ({
370 ...candidate,
371 orientation: getResolvedDropOrientationFromCandidates({
372 candidates: candidatesWithoutOrientation,
373 fallbackOrientation: candidate.registration.orientation,
374 path: candidate.dropPath,
375 }),
376 }));
377
378 return {
379 candidates,
380 draggedEntries,
381 primaryDragPath,
382 resolvedDragItem,
383 };
384 }
385
386 function getElementEntryById(
387 editor: PlateEditor,
388 id: string,
389 ): NodeEntry<TElement> | undefined {
390 const entry = editor.api.node({ id, at: [] }) as
391 | NodeEntry<TElement>
392 | undefined;
393
394 if (!entry || !isElementNode(entry[0])) return undefined;
395
396 return entry;
397 }
398
399 function isElementNode(node: unknown): node is TElement {
400 return (
401 typeof node === "object" &&
402 node !== null &&
403 "type" in node &&
404 "children" in node
405 );
406 }
407
408 function getGroupedRegistrations(
409 editorId: string,
410 ): Map<string, FreeformDropRegistration[]> {
411 const editorRegistrations = registrationsByEditorId.get(editorId);
412 const grouped = new Map<string, FreeformDropRegistration[]>();
413
414 editorRegistrations?.forEach((registration) => {
415 const group = grouped.get(registration.id) ?? [];
416 group.push(registration);
417 grouped.set(registration.id, group);
418 });
419
420 return grouped;
421 }
422
423 function selectPreferredRegistrationWithRect(
424 registrations: FreeformDropRegistration[],
425 ): { rect: DOMRect; registration: FreeformDropRegistration } | undefined {
426 let best:
427 | { rect: DOMRect; registration: FreeformDropRegistration; score: number }
428 | undefined;
429
430 registrations.forEach((registration) => {
431 const node = registration.nodeRef.current;
432
433 if (!node?.isConnected) return;
434
435 const rect = node.getBoundingClientRect();
436
437 if (rect.width === 0 || rect.height === 0) return;
438
439 const score = getRegistrationScore(registration, rect);
440
441 if (!best || score > best.score) {
442 best = { rect, registration, score };
443 }
444 });
445
446 return best;
447 }
448
449 function getRegistrationScore(
450 registration: FreeformDropRegistration,
451 rect: DOMRect,
452 ): number {
453 const area = rect.width * rect.height;
454
455 return (
456 (registration.canDropNode ? 10_000_000 : 0) +
457 (registration.canCreateColumns ? 1_000_000 : 0) +
458 area
459 );
460 }
461
462 function getCandidateDirection({
463 coordinates,
464 orientation,
465 rect,
466 registration,
467 }: {
468 coordinates: PointerCoordinates;
469 orientation: DropOrientation;
470 rect: DOMRect;
471 registration: FreeformDropRegistration;
472 }): ResolvedDropLineDirection {
473 if (
474 registration.canCreateColumns &&
475 isPointInsideRect(coordinates, rect) &&
476 orientation === "vertical"
477 ) {
478 const threshold = Math.min(
479 Math.max(
480 rect.width * COLUMN_EDGE_THRESHOLD_RATIO,
481 MIN_COLUMN_EDGE_THRESHOLD_PX,
482 ),
483 MAX_COLUMN_EDGE_THRESHOLD_PX,
484 );
485
486 if (coordinates.clientX <= rect.left + threshold) return "left";
487 if (coordinates.clientX >= rect.right - threshold) return "right";
488 }
489
490 if (orientation === "horizontal") {
491 return coordinates.clientX < rect.left + rect.width / 2 ? "left" : "right";
492 }
493
494 return coordinates.clientY < rect.top + rect.height / 2 ? "top" : "bottom";
495 }
496
497 function getResolvedDropOrientationFromCandidates({
498 candidates,
499 fallbackOrientation,
500 path,
501 }: {
502 candidates: Array<Omit<FreeformDropCandidate, "orientation">>;
503 fallbackOrientation: DropOrientation;
504 path: Path;
505 }): DropOrientation {
506 const parentPath = PathApi.parent(path);
507 const siblingRects = candidates
508 .filter((candidate) =>
509 PathApi.equals(PathApi.parent(candidate.dropPath), parentPath),
510 )
511 .map((candidate) => candidate.rect);
512
513 if (siblingRects.length < 2) return fallbackOrientation;
514
515 const centers = siblingRects.map((rect) => ({
516 x: rect.left + rect.width / 2,
517 y: rect.top + rect.height / 2,
518 }));
519 const minX = Math.min(...centers.map((center) => center.x));
520 const maxX = Math.max(...centers.map((center) => center.x));
521 const minY = Math.min(...centers.map((center) => center.y));
522 const maxY = Math.max(...centers.map((center) => center.y));
523 const xSpread = maxX - minX;
524 const ySpread = maxY - minY;
525
526 return ySpread > xSpread ? "vertical" : "horizontal";
527 }
528
529 function getCandidateScore({
530 coordinates,
531 direction,
532 dropPath,
533 primaryDragPath,
534 rect,
535 registration,
536 }: {
537 coordinates: PointerCoordinates;
538 direction: ResolvedDropLineDirection;
539 dropPath: Path;
540 primaryDragPath: Path | undefined;
541 rect: DOMRect;
542 registration: FreeformDropRegistration;
543 }): number {
544 const insertionLineDistance =
545 direction === "left"
546 ? Math.abs(coordinates.clientX - rect.left)
547 : direction === "right"
548 ? Math.abs(coordinates.clientX - rect.right)
549 : direction === "top"
550 ? Math.abs(coordinates.clientY - rect.top)
551 : Math.abs(coordinates.clientY - rect.bottom);
552 const crossAxisDistance =
553 direction === "left" || direction === "right"
554 ? distanceToRange(coordinates.clientY, rect.top, rect.bottom)
555 : distanceToRange(coordinates.clientX, rect.left, rect.right);
556 const sameParentBonus =
557 primaryDragPath &&
558 PathApi.equals(PathApi.parent(primaryDragPath), PathApi.parent(dropPath))
559 ? SAME_PARENT_SCORE_BONUS
560 : 0;
561 const columnCreationBonus =
562 registration.canCreateColumns &&
563 (direction === "left" || direction === "right")
564 ? SAME_PARENT_SCORE_BONUS
565 : 0;
566 const depthBonus = dropPath.length * TARGET_DEPTH_SCORE_BONUS;
567 const insideTargetBonus = isPointInsideRect(coordinates, rect)
568 ? INSIDE_TARGET_SCORE_BONUS
569 : 0;
570
571 return (
572 insertionLineDistance +
573 crossAxisDistance * 0.35 -
574 sameParentBonus -
575 columnCreationBonus -
576 depthBonus -
577 insideTargetBonus
578 );
579 }
580
581 function distanceToRange(value: number, start: number, end: number): number {
582 if (value < start) return start - value;
583 if (value > end) return value - end;
584
585 return 0;
586 }
587
588 function isPointInsideRect(
589 { clientX, clientY }: PointerCoordinates,
590 rect: DOMRect,
591 ): boolean {
592 return (
593 clientX >= rect.left &&
594 clientX <= rect.right &&
595 clientY >= rect.top &&
596 clientY <= rect.bottom
597 );
598 }
599
600 function isPointInsideEditor(
601 editor: PlateEditor,
602 { clientX, clientY }: PointerCoordinates,
603 ): boolean {
604 try {
605 const editorNode = editor.api.toDOMNode(editor);
606
607 if (!(editorNode instanceof HTMLElement)) return false;
608
609 const rect = editorNode.getBoundingClientRect();
610
611 return (
612 clientX >= rect.left - EDITOR_DROP_BOUNDARY_PADDING_PX &&
613 clientX <= rect.right + EDITOR_DROP_BOUNDARY_PADDING_PX &&
614 clientY >= rect.top - EDITOR_DROP_BOUNDARY_PADDING_PX &&
615 clientY <= rect.bottom + EDITOR_DROP_BOUNDARY_PADDING_PX
616 );
617 } catch {
618 return false;
619 }
620 }
621
621 lines TYPESCRIPT