@pixerate/editor-svelte 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,179 @@
1
1
  import type { XYPosition } from '@xyflow/svelte';
2
- import type { CanvasNode, CanvasDockingCallbacks } from './types.js';
2
+ import type { CanvasNode, CanvasDockingCallbacks, DockStrategy } from './types.js';
3
+
4
+ export type { DockStrategy };
3
5
 
4
6
  export interface CreateCanvasDockingOptions<TNode extends CanvasNode = CanvasNode>
5
7
  extends CanvasDockingCallbacks<TNode> {
6
8
  isDockTarget?: (target: TNode) => boolean;
7
9
  restorePositionOnDock?: boolean;
8
10
  onSave?: () => void;
11
+ /**
12
+ * Disambiguation strategy when multiple candidate dock targets intersect the dragged node.
13
+ * - 'center-point': (Default) Resolves the target enclosing or closest to the dragged node's center.
14
+ * - 'max-overlap': Resolves the target with the greatest overlapping area.
15
+ * - 'pointer': Resolves the target enclosing or closest to the mouse/touch cursor.
16
+ * - 'first': Resolves the first intersecting target (legacy behavior).
17
+ */
18
+ dockStrategy?: DockStrategy;
19
+ /**
20
+ * Custom primary dock target resolution callback. If provided and returns a node, takes precedence.
21
+ */
22
+ resolvePrimaryDockTarget?: (
23
+ targets: TNode[],
24
+ draggedNode: TNode,
25
+ event?: MouseEvent | TouchEvent
26
+ ) => TNode | null;
27
+ /**
28
+ * When true (default), ensures only a single primary dock target is considered hovered at any time.
29
+ * Transitioning across adjacent dock targets automatically fires onDockLeave on the previous target
30
+ * and onDockHover on the new target. Set to false to allow concurrent multi-target hover.
31
+ */
32
+ exclusiveHover?: boolean;
33
+ }
34
+
35
+ interface NodeRect {
36
+ x: number;
37
+ y: number;
38
+ width: number;
39
+ height: number;
40
+ }
41
+
42
+ function getNodeRect(node: CanvasNode): NodeRect {
43
+ const x = node.position?.x ?? 0;
44
+ const y = node.position?.y ?? 0;
45
+ const width =
46
+ (node as any).measured?.width ??
47
+ (node as any).width ??
48
+ (node as any).initialWidth ??
49
+ 0;
50
+ const height =
51
+ (node as any).measured?.height ??
52
+ (node as any).height ??
53
+ (node as any).initialHeight ??
54
+ 0;
55
+ return { x, y, width, height };
56
+ }
57
+
58
+ function getNodeCenter(node: CanvasNode): { x: number; y: number } {
59
+ const rect = getNodeRect(node);
60
+ return {
61
+ x: rect.x + rect.width / 2,
62
+ y: rect.y + rect.height / 2
63
+ };
64
+ }
65
+
66
+ function getDistanceSq(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
67
+ const dx = p1.x - p2.x;
68
+ const dy = p1.y - p2.y;
69
+ return dx * dx + dy * dy;
70
+ }
71
+
72
+ function getOverlapArea(rectA: NodeRect, rectB: NodeRect): number {
73
+ const xOverlap = Math.max(
74
+ 0,
75
+ Math.min(rectA.x + rectA.width, rectB.x + rectB.width) - Math.max(rectA.x, rectB.x)
76
+ );
77
+ const yOverlap = Math.max(
78
+ 0,
79
+ Math.min(rectA.y + rectA.height, rectB.y + rectB.height) - Math.max(rectA.y, rectB.y)
80
+ );
81
+ return xOverlap * yOverlap;
82
+ }
83
+
84
+ function resolvePrimaryTarget<TNode extends CanvasNode>(
85
+ targets: TNode[],
86
+ draggedNode: TNode,
87
+ strategy: DockStrategy,
88
+ customResolver?: (
89
+ targets: TNode[],
90
+ draggedNode: TNode,
91
+ event?: MouseEvent | TouchEvent
92
+ ) => TNode | null,
93
+ event?: MouseEvent | TouchEvent
94
+ ): TNode | null {
95
+ if (targets.length === 0) return null;
96
+ if (targets.length === 1) return targets[0];
97
+
98
+ if (customResolver) {
99
+ const resolved = customResolver(targets, draggedNode, event);
100
+ if (resolved !== undefined) return resolved;
101
+ }
102
+
103
+ if (strategy === 'first') {
104
+ return targets[0];
105
+ }
106
+
107
+ if (strategy === 'pointer' && event) {
108
+ const clientX = 'clientX' in event ? event.clientX : event.touches?.[0]?.clientX;
109
+ const clientY = 'clientY' in event ? event.clientY : event.touches?.[0]?.clientY;
110
+ if (typeof clientX === 'number' && typeof clientY === 'number') {
111
+ const pointerInside = targets.filter((target) => {
112
+ const r = getNodeRect(target);
113
+ return (
114
+ r.width > 0 &&
115
+ r.height > 0 &&
116
+ clientX >= r.x &&
117
+ clientX <= r.x + r.width &&
118
+ clientY >= r.y &&
119
+ clientY <= r.y + r.height
120
+ );
121
+ });
122
+ if (pointerInside.length === 1) return pointerInside[0];
123
+ if (pointerInside.length > 1) {
124
+ return pointerInside.reduce((best, cur) => {
125
+ const bestDist = getDistanceSq(getNodeCenter(best), { x: clientX, y: clientY });
126
+ const curDist = getDistanceSq(getNodeCenter(cur), { x: clientX, y: clientY });
127
+ return curDist < bestDist ? cur : best;
128
+ });
129
+ }
130
+ }
131
+ }
132
+
133
+ if (strategy === 'max-overlap') {
134
+ const draggedRect = getNodeRect(draggedNode);
135
+ let maxArea = -1;
136
+ let bestTarget = targets[0];
137
+ for (const target of targets) {
138
+ const targetRect = getNodeRect(target);
139
+ const area = getOverlapArea(draggedRect, targetRect);
140
+ if (area > maxArea) {
141
+ maxArea = area;
142
+ bestTarget = target;
143
+ }
144
+ }
145
+ return bestTarget;
146
+ }
147
+
148
+ // Strategy: 'center-point' (default)
149
+ const draggedCenter = getNodeCenter(draggedNode);
150
+ const containing = targets.filter((target) => {
151
+ const r = getNodeRect(target);
152
+ if (r.width > 0 && r.height > 0) {
153
+ return (
154
+ draggedCenter.x >= r.x &&
155
+ draggedCenter.x <= r.x + r.width &&
156
+ draggedCenter.y >= r.y &&
157
+ draggedCenter.y <= r.y + r.height
158
+ );
159
+ }
160
+ return false;
161
+ });
162
+
163
+ if (containing.length === 1) return containing[0];
164
+ if (containing.length > 1) {
165
+ return containing.reduce((best, cur) => {
166
+ const bestDist = getDistanceSq(getNodeCenter(best), draggedCenter);
167
+ const curDist = getDistanceSq(getNodeCenter(cur), draggedCenter);
168
+ return curDist < bestDist ? cur : best;
169
+ });
170
+ }
171
+
172
+ return targets.reduce((best, cur) => {
173
+ const bestDist = getDistanceSq(getNodeCenter(best), draggedCenter);
174
+ const curDist = getDistanceSq(getNodeCenter(cur), draggedCenter);
175
+ return curDist < bestDist ? cur : best;
176
+ });
9
177
  }
10
178
 
11
179
  /**
@@ -21,6 +189,8 @@ export function createCanvasDocking<TNode extends CanvasNode = CanvasNode>(
21
189
  let currentHoverTargets = $state.raw<Set<string>>(new Set());
22
190
 
23
191
  const isDockTarget = options.isDockTarget ?? ((target: TNode) => (target as any).type === 'boardNode');
192
+ const dockStrategy = options.dockStrategy ?? 'center-point';
193
+ const exclusiveHover = options.exclusiveHover ?? true;
24
194
 
25
195
  function handleNodeDragStart(params: {
26
196
  targetNode: TNode | null;
@@ -43,24 +213,54 @@ export function createCanvasDocking<TNode extends CanvasNode = CanvasNode>(
43
213
 
44
214
  const intersecting = getIntersectingNodes(draggedNode);
45
215
  const currentDockTargets = intersecting.filter(isDockTarget);
46
- const newHoverSet = new Set(currentDockTargets.map((t) => t.id));
47
216
 
48
- // Check for left targets
49
- currentHoverTargets.forEach((targetId) => {
50
- if (!newHoverSet.has(targetId)) {
51
- const leftNode = intersecting.find((n) => n.id === targetId) ?? ({ id: targetId } as TNode);
52
- options.onDockLeave?.(leftNode, draggedNode!);
53
- }
54
- });
217
+ if (exclusiveHover) {
218
+ const primaryTarget =
219
+ currentDockTargets.length > 0
220
+ ? resolvePrimaryTarget(
221
+ currentDockTargets,
222
+ draggedNode,
223
+ dockStrategy,
224
+ options.resolvePrimaryDockTarget,
225
+ params.event
226
+ )
227
+ : null;
228
+ const primaryTargetId = primaryTarget ? primaryTarget.id : null;
229
+
230
+ // Fire onDockLeave for previous targets no longer considered primary
231
+ currentHoverTargets.forEach((targetId) => {
232
+ if (targetId !== primaryTargetId) {
233
+ const leftNode = intersecting.find((n) => n.id === targetId) ?? ({ id: targetId } as TNode);
234
+ options.onDockLeave?.(leftNode, draggedNode!);
235
+ }
236
+ });
55
237
 
56
- // Check for newly entered targets
57
- currentDockTargets.forEach((target) => {
58
- if (!currentHoverTargets.has(target.id)) {
59
- options.onDockHover?.(target, draggedNode!);
238
+ // Fire onDockHover if entering a new primary target
239
+ if (primaryTarget && !currentHoverTargets.has(primaryTarget.id)) {
240
+ options.onDockHover?.(primaryTarget, draggedNode);
60
241
  }
61
- });
62
242
 
63
- currentHoverTargets = newHoverSet;
243
+ currentHoverTargets = primaryTargetId ? new Set([primaryTargetId]) : new Set();
244
+ } else {
245
+ const newHoverSet = new Set(currentDockTargets.map((t) => t.id));
246
+
247
+ // Check for left targets
248
+ currentHoverTargets.forEach((targetId) => {
249
+ if (!newHoverSet.has(targetId)) {
250
+ const leftNode = intersecting.find((n) => n.id === targetId) ?? ({ id: targetId } as TNode);
251
+ options.onDockLeave?.(leftNode, draggedNode!);
252
+ }
253
+ });
254
+
255
+ // Check for newly entered targets
256
+ currentDockTargets.forEach((target) => {
257
+ if (!currentHoverTargets.has(target.id)) {
258
+ options.onDockHover?.(target, draggedNode!);
259
+ }
260
+ });
261
+
262
+ currentHoverTargets = newHoverSet;
263
+ }
64
264
  }
65
265
 
66
266
  function handleNodeDragStop(
@@ -75,7 +275,17 @@ export function createCanvasDocking<TNode extends CanvasNode = CanvasNode>(
75
275
 
76
276
  if (draggedNode) {
77
277
  const intersecting = getIntersectingNodes(draggedNode);
78
- const dockTarget = intersecting.find(isDockTarget);
278
+ const dockTargets = intersecting.filter(isDockTarget);
279
+ const dockTarget =
280
+ dockTargets.length > 0
281
+ ? resolvePrimaryTarget(
282
+ dockTargets,
283
+ draggedNode,
284
+ dockStrategy,
285
+ options.resolvePrimaryDockTarget,
286
+ params.event
287
+ )
288
+ : null;
79
289
 
80
290
  if (dockTarget) {
81
291
  options.onDockDrop?.(dockTarget, draggedNode);
@@ -28,7 +28,8 @@ export {
28
28
  // Docking & drag interactions rune
29
29
  export {
30
30
  createCanvasDocking,
31
- type CreateCanvasDockingOptions
31
+ type CreateCanvasDockingOptions,
32
+ type DockStrategy
32
33
  } from './createCanvasDocking.svelte.js';
33
34
 
34
35
  // Shortcuts manager
@@ -72,6 +72,15 @@ export interface CanvasShortcutsOptions<TNode extends CanvasNode = CanvasNode> {
72
72
  getSelectedNodes?: () => TNode[];
73
73
  }
74
74
 
75
+ /**
76
+ * Strategy used to resolve the primary dock target when multiple targets intersect a dragged node.
77
+ * - 'center-point': Chooses the target enclosing or closest to the center coordinate of the dragged node.
78
+ * - 'max-overlap': Chooses the target with the largest bounding box intersection area.
79
+ * - 'pointer': Chooses the target enclosing or closest to the mouse/touch cursor coordinates (falls back to 'center-point').
80
+ * - 'first': Chooses the first intersecting target (legacy behavior).
81
+ */
82
+ export type DockStrategy = 'center-point' | 'max-overlap' | 'pointer' | 'first';
83
+
75
84
  /**
76
85
  * Docking and intersection callbacks.
77
86
  */
@@ -24,7 +24,7 @@ export const initiateEditor = (
24
24
  ...defaultPreset,
25
25
  ...(extensions ?? []),
26
26
  ],
27
- autofocus: true,
27
+ autofocus: false,
28
28
  ...editorOptions,
29
29
  });
30
30
 
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Component } from "svelte";
2
- import type { Editor, Content, Extensions } from "@tiptap/core";
3
- import type { Template, ColorGradient } from "@pixerate/editor";
2
+ import type { Editor, Content, Extensions, FocusPosition } from "@tiptap/core";
3
+ import type { Template, ColorGradient, RichTextPresetOptions, MarkdownOptions } from "@pixerate/editor";
4
4
 
5
5
  import EditableTextNodeEditorComponent from "./EditableTextNodeEditor.svelte";
6
6
  import BubbleMenuComponent from "./BubbleMenu.svelte";
@@ -21,6 +21,9 @@ export interface EditableTextNodeEditorProps {
21
21
  onBlur?: () => void;
22
22
  onCreate?: () => void;
23
23
  placeholder?: string;
24
+ markdown?: boolean | MarkdownOptions;
25
+ richTextOptions?: RichTextPresetOptions;
26
+ autofocus?: FocusPosition | boolean;
24
27
  }
25
28
 
26
29
  export interface BubbleMenuProps {