@edv4h/usketch-plugin-tool-select 1.0.0 → 3.0.0
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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +287 -823
- package/dist/plugin.js.map +1 -1
- package/dist/resize-utils.d.ts +1 -132
- package/dist/resize-utils.d.ts.map +1 -1
- package/dist/resize-utils.js +5 -387
- package/dist/resize-utils.js.map +1 -1
- package/package.json +5 -4
package/dist/plugin.js
CHANGED
|
@@ -1,99 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { safeRotation } from "@edv4h/usketch-shared";
|
|
3
|
+
import { createDeleteWithChildrenCommand } from "@edv4h/usketch-store";
|
|
4
|
+
import { findHandleAtScreenPoint, findMultiHandleAtScreenPoint, findRotationHandleAtScreenPoint, findShapeAtPoint, getCursorForHandle, getMultiSelectionBounds, getRotatedCursorForHandle, startDragSession, startMarqueeSession, startResizeSession, startRotateSession, trackHover, } from "@edv4h/usketch-tool-helpers";
|
|
4
5
|
import { clearMovingSelectionListeners, setMovingSelection } from "./drag-state.js";
|
|
5
6
|
import { clearDropTargetListeners, setDropTargetId } from "./drop-target-state.js";
|
|
6
7
|
import { clearEditingGroupListeners, getEditingGroupId, setEditingGroupId, } from "./group-edit-state.js";
|
|
7
8
|
import { clearHoveredShapeListeners, setHoveredShapeId } from "./hover-state.js";
|
|
8
9
|
import { clearMarqueeListeners, setMarquee, setMarqueeMode } from "./marquee-state.js";
|
|
9
|
-
import { applyFlip, computeMultiResizeUpdates, computeRawBounds, computeRelativeProps, findHandleAtScreenPoint, findMultiHandleAtScreenPoint, findRotationHandleAtScreenPoint, fixAnchorDrift, getAnchorEdges, getCursorForHandle, getMultiSelectionBounds, getRotatedCursorForHandle, } from "./resize-utils.js";
|
|
10
10
|
import { SelectionOverlay } from "./selection-overlay.js";
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const editingGroupId = getEditingGroupId();
|
|
15
|
-
// Iterate in reverse insertion order (top-most shape first)
|
|
16
|
-
// but prioritize non-container shapes over containers (island/frame)
|
|
17
|
-
const CONTAINER_TYPES = new Set(["island", "frame"]);
|
|
18
|
-
const entries = [...shapes.entries()].reverse();
|
|
19
|
-
// First pass: find non-container shapes
|
|
20
|
-
let containerHit = null;
|
|
21
|
-
for (const [id, data] of entries) {
|
|
22
|
-
const def = ctx.shapes.get(data.type);
|
|
23
|
-
if (!def?.hitTest(data, point))
|
|
24
|
-
continue;
|
|
25
|
-
// If inside a group editing session, only hit children of that group
|
|
26
|
-
if (editingGroupId) {
|
|
27
|
-
if (data.parentId === editingGroupId)
|
|
28
|
-
return id;
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
// If this shape has a parent, check the parent type:
|
|
32
|
-
// - Frame/Island children are directly selectable (containers)
|
|
33
|
-
// - Group children resolve to the top-level group ancestor
|
|
34
|
-
if (typeof data.parentId === "string") {
|
|
35
|
-
const parent = ctx.store.getShape(data.parentId);
|
|
36
|
-
if (parent?.type === "frame" || parent?.type === "island") {
|
|
37
|
-
return id;
|
|
38
|
-
}
|
|
39
|
-
const ancestor = getTopLevelAncestor(ctx.store, id);
|
|
40
|
-
if (ancestor)
|
|
41
|
-
return ancestor.id;
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
// Defer containers — prefer their children first
|
|
45
|
-
if (CONTAINER_TYPES.has(data.type)) {
|
|
46
|
-
if (!containerHit)
|
|
47
|
-
containerHit = id;
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
return id;
|
|
51
|
-
}
|
|
52
|
-
// No non-container shape found, return the container itself
|
|
53
|
-
return containerHit;
|
|
54
|
-
}
|
|
55
|
-
function findShapesInRect(ctx, rect, mode) {
|
|
56
|
-
const test = mode === "contain" ? boxContains : boxesIntersect;
|
|
57
|
-
const shapes = ctx.store.getShapes();
|
|
58
|
-
const editingGroupId = getEditingGroupId();
|
|
59
|
-
const ids = new Set();
|
|
60
|
-
for (const [id, data] of shapes) {
|
|
61
|
-
const def = ctx.shapes.get(data.type);
|
|
62
|
-
const bounds = def
|
|
63
|
-
? def.getBounds(data)
|
|
64
|
-
: { x: data.x, y: data.y, width: data.width, height: data.height };
|
|
65
|
-
if (!test(rect, bounds))
|
|
66
|
-
continue;
|
|
67
|
-
// If in group edit mode, only select children of that group
|
|
68
|
-
if (editingGroupId) {
|
|
69
|
-
if (data.parentId === editingGroupId)
|
|
70
|
-
ids.add(id);
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
// Frame children are directly selectable; group children resolve to ancestor
|
|
74
|
-
if (typeof data.parentId === "string") {
|
|
75
|
-
const parent = ctx.store.getShape(data.parentId);
|
|
76
|
-
if (parent?.type === "frame") {
|
|
77
|
-
ids.add(id);
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
const ancestor = getTopLevelAncestor(ctx.store, id);
|
|
81
|
-
if (ancestor)
|
|
82
|
-
ids.add(ancestor.id);
|
|
83
|
-
}
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
ids.add(id);
|
|
87
|
-
}
|
|
88
|
-
return [...ids];
|
|
89
|
-
}
|
|
90
|
-
function boxesIntersect(a, b) {
|
|
91
|
-
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
92
|
-
}
|
|
93
|
-
/** Returns true if `a` fully contains `b` */
|
|
94
|
-
function boxContains(a, b) {
|
|
95
|
-
return (a.x <= b.x && a.y <= b.y && a.x + a.width >= b.x + b.width && a.y + a.height >= b.y + b.height);
|
|
96
|
-
}
|
|
11
|
+
// Hit-test helpers used to live here; they were extracted to
|
|
12
|
+
// `@edv4h/usketch-tool-helpers` (Issue #576) so other tools can reuse
|
|
13
|
+
// the same shape-vs-container precedence rules.
|
|
97
14
|
// ── Icon ──
|
|
98
15
|
function SelectIcon() {
|
|
99
16
|
return (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 20 20", children: _jsx("path", { d: "M6 2L6 16L10 12L14 16L16 14L12 10L16 6Z", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinejoin: "round" }) }));
|
|
@@ -111,792 +28,339 @@ function deleteSelectedShapes(ctx) {
|
|
|
111
28
|
ctx.store.clearSelection();
|
|
112
29
|
}
|
|
113
30
|
// ── Plugin ──
|
|
114
|
-
export
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
// ── Local drag state (scoped to this setup closure) ──
|
|
124
|
-
let dragState = null;
|
|
125
|
-
let overrideCursor = "";
|
|
126
|
-
let lastClickTime = 0;
|
|
127
|
-
let lastClickId = null;
|
|
128
|
-
// Inject a <style> tag to override canvas cursor via !important
|
|
129
|
-
const styleEl = document.createElement("style");
|
|
130
|
-
styleEl.dataset.selectTool = "";
|
|
131
|
-
document.head.appendChild(styleEl);
|
|
132
|
-
function setOverrideCursor(cursor) {
|
|
133
|
-
if (cursor === overrideCursor)
|
|
134
|
-
return;
|
|
135
|
-
overrideCursor = cursor;
|
|
136
|
-
styleEl.textContent = cursor ? `* { cursor: ${cursor} !important; }` : "";
|
|
137
|
-
}
|
|
138
|
-
// Prevent text selection during drag operations
|
|
139
|
-
const preventSelect = (e) => e.preventDefault();
|
|
140
|
-
function disableTextSelection() {
|
|
141
|
-
document.addEventListener("selectstart", preventSelect);
|
|
142
|
-
}
|
|
143
|
-
function enableTextSelection() {
|
|
144
|
-
document.removeEventListener("selectstart", preventSelect);
|
|
145
|
-
}
|
|
146
|
-
// ── Tool handlers ──
|
|
147
|
-
function onPointerDown(toolCtx, event) {
|
|
148
|
-
disableTextSelection();
|
|
149
|
-
const viewport = toolCtx.store.getViewport();
|
|
150
|
-
// 0. Check rotation handle hit first (outside bounding box, no conflict with resize)
|
|
151
|
-
const rotationHit = findRotationHandleAtScreenPoint(event.screenPoint, toolCtx.shapes, toolCtx.store, viewport);
|
|
152
|
-
if (rotationHit) {
|
|
153
|
-
const shape = toolCtx.store.getShape(rotationHit);
|
|
154
|
-
if (shape) {
|
|
155
|
-
const cx = shape.x + shape.width / 2;
|
|
156
|
-
const cy = shape.y + shape.height / 2;
|
|
157
|
-
const startAngle = Math.atan2(event.worldPoint.y - cy, event.worldPoint.x - cx) * (180 / Math.PI);
|
|
158
|
-
// Collect child snapshots for container rotation
|
|
159
|
-
const childSnapshots = new Map();
|
|
160
|
-
if (shape.type === "frame" || shape.type === "group" || shape.type === "island") {
|
|
161
|
-
const children = getChildShapes(toolCtx.store, rotationHit);
|
|
162
|
-
for (const child of children) {
|
|
163
|
-
childSnapshots.set(child.id, { ...child });
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
setOverrideCursor("grabbing");
|
|
167
|
-
dragState = {
|
|
168
|
-
mode: "rotate",
|
|
169
|
-
shapeId: rotationHit,
|
|
170
|
-
startAngle,
|
|
171
|
-
startRotation: safeRotation(shape.rotation),
|
|
172
|
-
center: { x: cx, y: cy },
|
|
173
|
-
childSnapshots,
|
|
174
|
-
};
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
31
|
+
export function createSelectToolPlugin() {
|
|
32
|
+
return {
|
|
33
|
+
id: "usketch-plugin-tool-select",
|
|
34
|
+
name: "選択",
|
|
35
|
+
setup(ctx) {
|
|
36
|
+
// Wrap setDropTargetId to also emit on EventBus for DomShapeLayer
|
|
37
|
+
function updateDropTarget(id) {
|
|
38
|
+
setDropTargetId(id);
|
|
39
|
+
ctx.events.emit("drop-target:changed", { id });
|
|
177
40
|
}
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
shapeId: handleHit.shapeId,
|
|
190
|
-
handle: handleHit.handle,
|
|
191
|
-
startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
|
|
192
|
-
startData: { ...shape },
|
|
193
|
-
};
|
|
41
|
+
// ── Local drag state (scoped to this setup closure) ──
|
|
42
|
+
let dragState = null;
|
|
43
|
+
let overrideCursor = "";
|
|
44
|
+
let lastClickTime = 0;
|
|
45
|
+
let lastClickId = null;
|
|
46
|
+
// Inject a <style> tag to override canvas cursor via !important
|
|
47
|
+
const styleEl = document.createElement("style");
|
|
48
|
+
styleEl.dataset.selectTool = "";
|
|
49
|
+
document.head.appendChild(styleEl);
|
|
50
|
+
function setOverrideCursor(cursor) {
|
|
51
|
+
if (cursor === overrideCursor)
|
|
194
52
|
return;
|
|
195
|
-
|
|
53
|
+
overrideCursor = cursor;
|
|
54
|
+
styleEl.textContent = cursor ? `* { cursor: ${cursor} !important; }` : "";
|
|
196
55
|
}
|
|
197
|
-
//
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (groupBounds) {
|
|
202
|
-
const multiHandle = findMultiHandleAtScreenPoint(event.screenPoint, groupBounds, viewport);
|
|
203
|
-
if (multiHandle) {
|
|
204
|
-
setOverrideCursor(getCursorForHandle(multiHandle));
|
|
205
|
-
const startShapeData = new Map();
|
|
206
|
-
const originalFullShapes = new Map();
|
|
207
|
-
for (const id of selection) {
|
|
208
|
-
const shape = toolCtx.store.getShape(id);
|
|
209
|
-
if (shape) {
|
|
210
|
-
originalFullShapes.set(id, { ...shape });
|
|
211
|
-
const def = toolCtx.shapes.get(shape.type);
|
|
212
|
-
const minSize = def?.minSize ?? { width: 1, height: 1 };
|
|
213
|
-
const rel = computeRelativeProps({ x: shape.x, y: shape.y, width: shape.width, height: shape.height }, groupBounds);
|
|
214
|
-
startShapeData.set(id, {
|
|
215
|
-
x: shape.x,
|
|
216
|
-
y: shape.y,
|
|
217
|
-
width: shape.width,
|
|
218
|
-
height: shape.height,
|
|
219
|
-
minWidth: minSize.width,
|
|
220
|
-
minHeight: minSize.height,
|
|
221
|
-
...rel,
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
dragState = {
|
|
226
|
-
mode: "multi-resize",
|
|
227
|
-
handle: multiHandle,
|
|
228
|
-
startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
|
|
229
|
-
startGroupBounds: groupBounds,
|
|
230
|
-
startShapeData,
|
|
231
|
-
originalShapeData: new Map(startShapeData),
|
|
232
|
-
originalFullShapes,
|
|
233
|
-
applyBoundsBase: new Map(originalFullShapes),
|
|
234
|
-
};
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
// 2. Double-click detection for group enter/exit
|
|
240
|
-
const now = Date.now();
|
|
241
|
-
const rawHitId = findShapeAtPoint(toolCtx, event.worldPoint);
|
|
242
|
-
if (rawHitId && now - lastClickTime < 400 && lastClickId === rawHitId) {
|
|
243
|
-
const shape = toolCtx.store.getShape(rawHitId);
|
|
244
|
-
if (shape?.type === "group" || shape?.type === "frame") {
|
|
245
|
-
// Enter group editing mode
|
|
246
|
-
setEditingGroupId(rawHitId);
|
|
247
|
-
toolCtx.store.clearSelection();
|
|
248
|
-
lastClickTime = 0;
|
|
249
|
-
lastClickId = null;
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
56
|
+
// Prevent text selection during drag operations
|
|
57
|
+
const preventSelect = (e) => e.preventDefault();
|
|
58
|
+
function disableTextSelection() {
|
|
59
|
+
document.addEventListener("selectstart", preventSelect);
|
|
252
60
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// If clicking outside the editing group, exit group edit mode
|
|
256
|
-
const editingGroupId = getEditingGroupId();
|
|
257
|
-
if (editingGroupId && rawHitId !== editingGroupId) {
|
|
258
|
-
const hitShape = rawHitId ? toolCtx.store.getShape(rawHitId) : null;
|
|
259
|
-
if (!hitShape || hitShape.parentId !== editingGroupId) {
|
|
260
|
-
setEditingGroupId(null);
|
|
261
|
-
}
|
|
61
|
+
function enableTextSelection() {
|
|
62
|
+
document.removeEventListener("selectstart", preventSelect);
|
|
262
63
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
toolCtx.store.addToSelection(hitId);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
else {
|
|
275
|
-
// If clicking on an already-selected shape, keep selection (for multi-drag)
|
|
276
|
-
if (!selection.has(hitId)) {
|
|
277
|
-
toolCtx.store.setSelection([hitId]);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
// Start drag-move for all selected shapes (including children of groups/frames)
|
|
281
|
-
const currentSelection = toolCtx.store.getSelection();
|
|
282
|
-
const startShapeSnapshots = new Map();
|
|
283
|
-
for (const id of currentSelection) {
|
|
284
|
-
const shape = toolCtx.store.getShape(id);
|
|
64
|
+
// ── Tool handlers ──
|
|
65
|
+
function onPointerDown(toolCtx, event) {
|
|
66
|
+
disableTextSelection();
|
|
67
|
+
const viewport = toolCtx.store.getViewport();
|
|
68
|
+
// 0. Rotation handle (outside bbox, no conflict with resize)
|
|
69
|
+
const rotationHit = findRotationHandleAtScreenPoint(event.screenPoint, toolCtx.shapes, toolCtx.store, viewport);
|
|
70
|
+
if (rotationHit) {
|
|
71
|
+
const shape = toolCtx.store.getShape(rotationHit);
|
|
285
72
|
if (shape) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
73
|
+
const cx = shape.x + shape.width / 2;
|
|
74
|
+
const cy = shape.y + shape.height / 2;
|
|
75
|
+
const startAngle = Math.atan2(event.worldPoint.y - cy, event.worldPoint.x - cx) * (180 / Math.PI);
|
|
76
|
+
setOverrideCursor("grabbing");
|
|
77
|
+
dragState = {
|
|
78
|
+
kind: "rotate",
|
|
79
|
+
session: startRotateSession({
|
|
80
|
+
ctx: toolCtx,
|
|
81
|
+
shapeId: rotationHit,
|
|
82
|
+
center: { x: cx, y: cy },
|
|
83
|
+
startAngle,
|
|
84
|
+
startRotation: safeRotation(shape.rotation),
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
return;
|
|
306
88
|
}
|
|
307
89
|
}
|
|
308
|
-
|
|
309
|
-
dragState = {
|
|
310
|
-
mode: "move",
|
|
311
|
-
startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
|
|
312
|
-
startShapeSnapshots,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
else {
|
|
316
|
-
// Click on empty — start marquee selection
|
|
317
|
-
if (!event.shiftKey) {
|
|
318
|
-
toolCtx.store.clearSelection();
|
|
319
|
-
}
|
|
320
|
-
dragState = {
|
|
321
|
-
mode: "marquee",
|
|
322
|
-
startWorldPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
|
|
323
|
-
shiftKey: event.shiftKey,
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
function onPointerMove(toolCtx, event) {
|
|
328
|
-
if (!dragState) {
|
|
329
|
-
// Hover: check for rotation handle first
|
|
330
|
-
const viewport = toolCtx.store.getViewport();
|
|
331
|
-
const rotHover = findRotationHandleAtScreenPoint(event.screenPoint, toolCtx.shapes, toolCtx.store, viewport);
|
|
332
|
-
if (rotHover) {
|
|
333
|
-
setOverrideCursor("grab");
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
// Hover: check for resize handle and update cursor
|
|
90
|
+
// 1. Single-shape resize handle
|
|
337
91
|
const handleHit = findHandleAtScreenPoint(event.screenPoint, toolCtx.shapes, toolCtx.store, viewport);
|
|
338
92
|
if (handleHit) {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
93
|
+
const shape = toolCtx.store.getShape(handleHit.shapeId);
|
|
94
|
+
if (shape) {
|
|
95
|
+
const shapeRotation = safeRotation(shape.rotation);
|
|
96
|
+
setOverrideCursor(shapeRotation
|
|
97
|
+
? getRotatedCursorForHandle(handleHit.handle, shapeRotation)
|
|
98
|
+
: getCursorForHandle(handleHit.handle));
|
|
99
|
+
dragState = {
|
|
100
|
+
kind: "resize",
|
|
101
|
+
rotation: shapeRotation,
|
|
102
|
+
session: startResizeSession({
|
|
103
|
+
kind: "single",
|
|
104
|
+
ctx: toolCtx,
|
|
105
|
+
shapeId: handleHit.shapeId,
|
|
106
|
+
handle: handleHit.handle,
|
|
107
|
+
startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
345
112
|
}
|
|
346
|
-
//
|
|
347
|
-
const
|
|
348
|
-
if (
|
|
349
|
-
const groupBounds = getMultiSelectionBounds(toolCtx.store, toolCtx.shapes,
|
|
113
|
+
// 1b. Multi-selection resize handle
|
|
114
|
+
const selection = toolCtx.store.getSelection();
|
|
115
|
+
if (selection.size > 1) {
|
|
116
|
+
const groupBounds = getMultiSelectionBounds(toolCtx.store, toolCtx.shapes, selection);
|
|
350
117
|
if (groupBounds) {
|
|
351
118
|
const multiHandle = findMultiHandleAtScreenPoint(event.screenPoint, groupBounds, viewport);
|
|
352
119
|
if (multiHandle) {
|
|
353
120
|
setOverrideCursor(getCursorForHandle(multiHandle));
|
|
121
|
+
dragState = {
|
|
122
|
+
kind: "multi-resize",
|
|
123
|
+
session: startResizeSession({
|
|
124
|
+
kind: "multi",
|
|
125
|
+
ctx: toolCtx,
|
|
126
|
+
selection,
|
|
127
|
+
handle: multiHandle,
|
|
128
|
+
startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
|
|
129
|
+
groupBounds,
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
354
132
|
return;
|
|
355
133
|
}
|
|
356
134
|
}
|
|
357
135
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (event.shiftKey) {
|
|
372
|
-
newRotation = snapAngle(newRotation, 15);
|
|
373
|
-
}
|
|
374
|
-
newRotation = normalizeAngle(newRotation);
|
|
375
|
-
toolCtx.store.updateShape(dragState.shapeId, { rotation: newRotation });
|
|
376
|
-
// Rotate child shapes around the container center
|
|
377
|
-
const deltaRad = ((newRotation - dragState.startRotation) * Math.PI) / 180;
|
|
378
|
-
const { center } = dragState;
|
|
379
|
-
for (const [childId, snap] of dragState.childSnapshots) {
|
|
380
|
-
const childCx = snap.x + snap.width / 2;
|
|
381
|
-
const childCy = snap.y + snap.height / 2;
|
|
382
|
-
const rx = childCx - center.x;
|
|
383
|
-
const ry = childCy - center.y;
|
|
384
|
-
const cos = Math.cos(deltaRad);
|
|
385
|
-
const sin = Math.sin(deltaRad);
|
|
386
|
-
const newCx = center.x + rx * cos - ry * sin;
|
|
387
|
-
const newCy = center.y + rx * sin + ry * cos;
|
|
388
|
-
const childRotation = normalizeAngle(safeRotation(snap.rotation) + newRotation - dragState.startRotation);
|
|
389
|
-
toolCtx.store.updateShape(childId, {
|
|
390
|
-
x: newCx - snap.width / 2,
|
|
391
|
-
y: newCy - snap.height / 2,
|
|
392
|
-
rotation: childRotation,
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
if (dragState.mode === "marquee") {
|
|
398
|
-
// Alt key toggles between intersect and contain mode
|
|
399
|
-
const mode = event.altKey ? "contain" : "intersect";
|
|
400
|
-
setMarqueeMode(mode);
|
|
401
|
-
// Compute world-space marquee (used for both hit testing and rendering)
|
|
402
|
-
const wx = Math.min(dragState.startWorldPoint.x, event.worldPoint.x);
|
|
403
|
-
const wy = Math.min(dragState.startWorldPoint.y, event.worldPoint.y);
|
|
404
|
-
const ww = Math.abs(event.worldPoint.x - dragState.startWorldPoint.x);
|
|
405
|
-
const wh = Math.abs(event.worldPoint.y - dragState.startWorldPoint.y);
|
|
406
|
-
const worldRect = { x: wx, y: wy, width: ww, height: wh };
|
|
407
|
-
const hitIds = findShapesInRect(toolCtx, worldRect, mode);
|
|
408
|
-
setMarquee(worldRect, hitIds);
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
if (dragState.mode === "resize") {
|
|
412
|
-
const def = toolCtx.shapes.get(dragState.startData.type);
|
|
413
|
-
if (!def)
|
|
414
|
-
return;
|
|
415
|
-
const rotation = safeRotation(dragState.startData.rotation);
|
|
416
|
-
const worldDelta = {
|
|
417
|
-
x: event.worldPoint.x - dragState.startPoint.x,
|
|
418
|
-
y: event.worldPoint.y - dragState.startPoint.y,
|
|
419
|
-
};
|
|
420
|
-
// Transform delta to local (un-rotated) space for rotated shapes
|
|
421
|
-
const delta = rotation ? deltaToLocal(worldDelta, rotation) : worldDelta;
|
|
422
|
-
// Flip detection: use unclamped (raw) bounds so minSize doesn't
|
|
423
|
-
// prevent the pointer from crossing the anchor edge.
|
|
424
|
-
const rawBounds = computeRawBounds(dragState.startData, dragState.handle, delta);
|
|
425
|
-
// Un-rotate the world point for flip detection in local space
|
|
426
|
-
let worldPointForFlip = event.worldPoint;
|
|
427
|
-
if (rotation) {
|
|
428
|
-
// Use rawBounds center (not startData) since resize changes x/y/width/height
|
|
429
|
-
const center = {
|
|
430
|
-
x: rawBounds.x + rawBounds.width / 2,
|
|
431
|
-
y: rawBounds.y + rawBounds.height / 2,
|
|
432
|
-
};
|
|
433
|
-
worldPointForFlip = unrotatePoint(event.worldPoint, center, (rotation * Math.PI) / 180);
|
|
434
|
-
}
|
|
435
|
-
const flip = applyFlip(dragState.handle, rawBounds, worldPointForFlip);
|
|
436
|
-
if (flip.flipped) {
|
|
437
|
-
const currentShape = toolCtx.store.getShape(dragState.shapeId);
|
|
438
|
-
if (currentShape) {
|
|
439
|
-
const anchor = getAnchorEdges(dragState.handle, rawBounds);
|
|
440
|
-
// Reset to zero-size shape at anchor position.
|
|
441
|
-
// This ensures the flipped handle's anchor is at the
|
|
442
|
-
// pointer's crossing point, preventing immediate re-flip.
|
|
443
|
-
const flippedData = { ...currentShape };
|
|
444
|
-
if (flip.flippedX && anchor.x !== undefined) {
|
|
445
|
-
flippedData.x = anchor.x;
|
|
446
|
-
flippedData.width = 0;
|
|
447
|
-
}
|
|
448
|
-
if (flip.flippedY && anchor.y !== undefined) {
|
|
449
|
-
flippedData.y = anchor.y;
|
|
450
|
-
flippedData.height = 0;
|
|
451
|
-
}
|
|
452
|
-
dragState = {
|
|
453
|
-
...dragState,
|
|
454
|
-
handle: flip.handle,
|
|
455
|
-
startData: flippedData,
|
|
456
|
-
startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
|
|
457
|
-
};
|
|
458
|
-
// Update store — def.resize will expand from zero on next frame
|
|
459
|
-
toolCtx.store.updateShape(dragState.shapeId, {
|
|
460
|
-
x: flippedData.x,
|
|
461
|
-
y: flippedData.y,
|
|
462
|
-
});
|
|
463
|
-
setOverrideCursor(rotation
|
|
464
|
-
? getRotatedCursorForHandle(flip.handle, rotation)
|
|
465
|
-
: getCursorForHandle(flip.handle));
|
|
136
|
+
// 2. Double-click detection for group enter/exit
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
const rawHitId = findShapeAtPoint(toolCtx, event.worldPoint, {
|
|
139
|
+
editingGroupId: getEditingGroupId(),
|
|
140
|
+
});
|
|
141
|
+
if (rawHitId && now - lastClickTime < 400 && lastClickId === rawHitId) {
|
|
142
|
+
const shape = toolCtx.store.getShape(rawHitId);
|
|
143
|
+
if (shape?.type === "group" || shape?.type === "frame") {
|
|
144
|
+
setEditingGroupId(rawHitId);
|
|
145
|
+
toolCtx.store.clearSelection();
|
|
146
|
+
lastClickTime = 0;
|
|
147
|
+
lastClickId = null;
|
|
148
|
+
return;
|
|
466
149
|
}
|
|
467
|
-
// Skip resize this frame — next frame will use the new
|
|
468
|
-
// startData/startPoint with the flipped handle.
|
|
469
|
-
return;
|
|
470
150
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
continue;
|
|
480
|
-
const resizedValue = resized[key];
|
|
481
|
-
const startValue = dragState.startData[key];
|
|
482
|
-
if (resizedValue !== startValue) {
|
|
483
|
-
updates[key] = resizedValue;
|
|
151
|
+
lastClickTime = now;
|
|
152
|
+
lastClickId = rawHitId;
|
|
153
|
+
// If clicking outside the editing group, exit group edit mode
|
|
154
|
+
const editingGroupId = getEditingGroupId();
|
|
155
|
+
if (editingGroupId && rawHitId !== editingGroupId) {
|
|
156
|
+
const hitShape = rawHitId ? toolCtx.store.getShape(rawHitId) : null;
|
|
157
|
+
if (!hitShape || hitShape.parentId !== editingGroupId) {
|
|
158
|
+
setEditingGroupId(null);
|
|
484
159
|
}
|
|
485
160
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
if (dragState.mode === "multi-resize") {
|
|
492
|
-
const delta = {
|
|
493
|
-
x: event.worldPoint.x - dragState.startPoint.x,
|
|
494
|
-
y: event.worldPoint.y - dragState.startPoint.y,
|
|
495
|
-
};
|
|
496
|
-
// Flip detection on the group bounding box
|
|
497
|
-
const rawGroupBounds = computeRawBounds(dragState.startGroupBounds, dragState.handle, delta);
|
|
498
|
-
const flip = applyFlip(dragState.handle, rawGroupBounds, event.worldPoint);
|
|
499
|
-
if (flip.flipped) {
|
|
500
|
-
const anchor = getAnchorEdges(dragState.handle, rawGroupBounds);
|
|
501
|
-
// Reset group bounds to zero-size at anchor position (same approach as single resize)
|
|
502
|
-
const flippedGroupBounds = { ...dragState.startGroupBounds };
|
|
503
|
-
if (flip.flippedX && anchor.x !== undefined) {
|
|
504
|
-
flippedGroupBounds.x = anchor.x;
|
|
505
|
-
flippedGroupBounds.width = 0;
|
|
506
|
-
}
|
|
507
|
-
if (flip.flippedY && anchor.y !== undefined) {
|
|
508
|
-
flippedGroupBounds.y = anchor.y;
|
|
509
|
-
flippedGroupBounds.height = 0;
|
|
510
|
-
}
|
|
511
|
-
// Mirror relative ratios and reset only the flipped axis
|
|
512
|
-
const flippedShapeData = new Map();
|
|
513
|
-
for (const [id, data] of dragState.startShapeData) {
|
|
514
|
-
const newRel = { ...data };
|
|
515
|
-
if (flip.flippedX) {
|
|
516
|
-
newRel.relX = 1 - data.relX - data.relWidth;
|
|
517
|
-
newRel.x = flippedGroupBounds.x;
|
|
518
|
-
newRel.width = 0;
|
|
519
|
-
}
|
|
520
|
-
if (flip.flippedY) {
|
|
521
|
-
newRel.relY = 1 - data.relY - data.relHeight;
|
|
522
|
-
newRel.y = flippedGroupBounds.y;
|
|
523
|
-
newRel.height = 0;
|
|
524
|
-
}
|
|
525
|
-
flippedShapeData.set(id, newRel);
|
|
526
|
-
}
|
|
527
|
-
// Temporarily disable snap during flip-reset to prevent
|
|
528
|
-
// snap from altering the zero-size anchor positioning
|
|
529
|
-
toolCtx.events.emit("snap:configure", { enabled: false });
|
|
530
|
-
for (const [id, data] of flippedShapeData) {
|
|
531
|
-
const update = {};
|
|
532
|
-
if (flip.flippedX) {
|
|
533
|
-
update.x = data.x;
|
|
534
|
-
update.width = 0;
|
|
161
|
+
const hitId = rawHitId;
|
|
162
|
+
if (hitId) {
|
|
163
|
+
if (event.shiftKey) {
|
|
164
|
+
if (selection.has(hitId)) {
|
|
165
|
+
toolCtx.store.removeFromSelection(hitId);
|
|
535
166
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
update.height = 0;
|
|
167
|
+
else {
|
|
168
|
+
toolCtx.store.addToSelection(hitId);
|
|
539
169
|
}
|
|
540
|
-
toolCtx.store.updateShape(id, update);
|
|
541
170
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const flippedApplyBoundsBase = new Map();
|
|
545
|
-
for (const id of flippedShapeData.keys()) {
|
|
546
|
-
const current = toolCtx.store.getShape(id);
|
|
547
|
-
if (current)
|
|
548
|
-
flippedApplyBoundsBase.set(id, { ...current });
|
|
171
|
+
else if (!selection.has(hitId)) {
|
|
172
|
+
toolCtx.store.setSelection([hitId]);
|
|
549
173
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
174
|
+
const currentSelection = toolCtx.store.getSelection();
|
|
175
|
+
setMovingSelection(true);
|
|
176
|
+
const session = startDragSession({
|
|
177
|
+
ctx: toolCtx,
|
|
553
178
|
startPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
179
|
+
shapeIds: currentSelection,
|
|
180
|
+
});
|
|
181
|
+
// Drop-target hit test must exclude every shape the session
|
|
182
|
+
// touches — including descendants of dragged containers — or
|
|
183
|
+
// the container's own children would falsely register as drop
|
|
184
|
+
// targets.
|
|
185
|
+
dragState = {
|
|
186
|
+
kind: "move",
|
|
187
|
+
session,
|
|
188
|
+
movingIds: session.movingShapeIds,
|
|
558
189
|
};
|
|
559
|
-
setOverrideCursor(getCursorForHandle(flip.handle));
|
|
560
|
-
return;
|
|
561
190
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if (def?.applyBounds && baseShape) {
|
|
567
|
-
// Pass {x,y,width,height} first so snap plugin can adjust
|
|
568
|
-
toolCtx.store.updateShape(id, upd);
|
|
569
|
-
// Read back snapped bounds and derive geometry
|
|
570
|
-
const snapped = toolCtx.store.getShape(id);
|
|
571
|
-
if (snapped) {
|
|
572
|
-
const snappedBounds = {
|
|
573
|
-
x: snapped.x,
|
|
574
|
-
y: snapped.y,
|
|
575
|
-
width: snapped.width,
|
|
576
|
-
height: snapped.height,
|
|
577
|
-
};
|
|
578
|
-
const geom = def.applyBounds(baseShape, snappedBounds);
|
|
579
|
-
const { x: _x, y: _y, width: _w, height: _h, ...rest } = geom;
|
|
580
|
-
if (Object.keys(rest).length > 0) {
|
|
581
|
-
toolCtx.store.updateShape(id, rest);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
else {
|
|
586
|
-
toolCtx.store.updateShape(id, upd);
|
|
191
|
+
else {
|
|
192
|
+
// Click on empty — start marquee selection
|
|
193
|
+
if (!event.shiftKey) {
|
|
194
|
+
toolCtx.store.clearSelection();
|
|
587
195
|
}
|
|
196
|
+
dragState = {
|
|
197
|
+
kind: "marquee",
|
|
198
|
+
shiftKey: event.shiftKey,
|
|
199
|
+
session: startMarqueeSession({
|
|
200
|
+
ctx: toolCtx,
|
|
201
|
+
startWorldPoint: { x: event.worldPoint.x, y: event.worldPoint.y },
|
|
202
|
+
editingGroupId: getEditingGroupId(),
|
|
203
|
+
}),
|
|
204
|
+
};
|
|
588
205
|
}
|
|
589
|
-
return;
|
|
590
206
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
continue;
|
|
603
|
-
toolCtx.store.updateShape(id, {
|
|
604
|
-
x: snapshot.x + dx,
|
|
605
|
-
y: snapshot.y + dy,
|
|
606
|
-
});
|
|
607
|
-
const snapped = toolCtx.store.getShape(id);
|
|
608
|
-
if (snapped) {
|
|
609
|
-
snapDx = snapped.x - (snapshot.x + dx);
|
|
610
|
-
snapDy = snapped.y - (snapshot.y + dy);
|
|
611
|
-
}
|
|
612
|
-
const def = toolCtx.shapes.get(snapshot.type);
|
|
613
|
-
if (def?.move) {
|
|
614
|
-
if (snapped) {
|
|
615
|
-
const snappedDx = snapped.x - snapshot.x;
|
|
616
|
-
const snappedDy = snapped.y - snapshot.y;
|
|
617
|
-
const geom = def.move(snapshot, snappedDx, snappedDy);
|
|
618
|
-
const { x: _x, y: _y, ...rest } = geom;
|
|
619
|
-
if (Object.keys(rest).length > 0) {
|
|
620
|
-
toolCtx.store.updateShape(id, rest);
|
|
621
|
-
}
|
|
622
|
-
}
|
|
207
|
+
function onPointerMove(toolCtx, event) {
|
|
208
|
+
if (!dragState) {
|
|
209
|
+
// Hover precedence (rotation handle → resize handle → multi-handle
|
|
210
|
+
// → shape body) is handled by the helper. The cursor is mirrored
|
|
211
|
+
// onto our `<style>` injection so it overrides any per-shape cursor.
|
|
212
|
+
const result = trackHover(toolCtx, event, {
|
|
213
|
+
editingGroupId: getEditingGroupId(),
|
|
214
|
+
});
|
|
215
|
+
setOverrideCursor(result.cursor);
|
|
216
|
+
setHoveredShapeId(result.handleHit || result.rotationHit ? null : result.hoveredShapeId);
|
|
217
|
+
return;
|
|
623
218
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
if (currentSelection.has(id))
|
|
628
|
-
continue;
|
|
629
|
-
toolCtx.store.updateShape(id, {
|
|
630
|
-
x: snapshot.x + dx + snapDx,
|
|
631
|
-
y: snapshot.y + dy + snapDy,
|
|
632
|
-
});
|
|
633
|
-
const def = toolCtx.shapes.get(snapshot.type);
|
|
634
|
-
if (def?.move) {
|
|
635
|
-
const snapped = toolCtx.store.getShape(id);
|
|
636
|
-
if (snapped) {
|
|
637
|
-
const snappedDx = snapped.x - snapshot.x;
|
|
638
|
-
const snappedDy = snapped.y - snapshot.y;
|
|
639
|
-
const geom = def.move(snapshot, snappedDx, snappedDy);
|
|
640
|
-
const { x: _x, y: _y, ...rest } = geom;
|
|
641
|
-
if (Object.keys(rest).length > 0) {
|
|
642
|
-
toolCtx.store.updateShape(id, rest);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
219
|
+
if (dragState.kind === "rotate") {
|
|
220
|
+
dragState.session.update(event);
|
|
221
|
+
return;
|
|
645
222
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
for (const [id, shape] of entries) {
|
|
652
|
-
if (movingIds.has(id))
|
|
653
|
-
continue;
|
|
654
|
-
if (shape.type !== "frame" && shape.type !== "group")
|
|
655
|
-
continue;
|
|
656
|
-
const def = toolCtx.shapes.get(shape.type);
|
|
657
|
-
const bounds = def
|
|
658
|
-
? def.getBounds(shape)
|
|
659
|
-
: { x: shape.x, y: shape.y, width: shape.width, height: shape.height };
|
|
660
|
-
if (event.worldPoint.x >= bounds.x &&
|
|
661
|
-
event.worldPoint.x <= bounds.x + bounds.width &&
|
|
662
|
-
event.worldPoint.y >= bounds.y &&
|
|
663
|
-
event.worldPoint.y <= bounds.y + bounds.height) {
|
|
664
|
-
dropTarget = id;
|
|
665
|
-
break;
|
|
223
|
+
if (dragState.kind === "marquee") {
|
|
224
|
+
const u = dragState.session.update(event);
|
|
225
|
+
setMarqueeMode(u.mode);
|
|
226
|
+
setMarquee(u.rect, [...u.hitIds]);
|
|
227
|
+
return;
|
|
666
228
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (!dragState)
|
|
674
|
-
return;
|
|
675
|
-
if (dragState.mode === "rotate") {
|
|
676
|
-
setOverrideCursor("");
|
|
677
|
-
const currentShape = toolCtx.store.getShape(dragState.shapeId);
|
|
678
|
-
if (currentShape) {
|
|
679
|
-
const parentFrom = { rotation: dragState.startRotation };
|
|
680
|
-
const parentTo = { rotation: safeRotation(currentShape.rotation) };
|
|
681
|
-
const hasMoved = parentFrom.rotation !== parentTo.rotation;
|
|
682
|
-
if (hasMoved) {
|
|
683
|
-
// Build before/after snapshots for parent + children
|
|
684
|
-
const beforeSnapshots = new Map();
|
|
685
|
-
const afterSnapshots = new Map();
|
|
686
|
-
const shapeId = dragState.shapeId;
|
|
687
|
-
beforeSnapshots.set(shapeId, { rotation: parentFrom.rotation });
|
|
688
|
-
afterSnapshots.set(shapeId, { rotation: parentTo.rotation });
|
|
689
|
-
for (const [childId, snap] of dragState.childSnapshots) {
|
|
690
|
-
const current = toolCtx.store.getShape(childId);
|
|
691
|
-
if (current) {
|
|
692
|
-
beforeSnapshots.set(childId, {
|
|
693
|
-
x: snap.x,
|
|
694
|
-
y: snap.y,
|
|
695
|
-
rotation: safeRotation(snap.rotation),
|
|
696
|
-
});
|
|
697
|
-
afterSnapshots.set(childId, {
|
|
698
|
-
x: current.x,
|
|
699
|
-
y: current.y,
|
|
700
|
-
rotation: safeRotation(current.rotation),
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
queueMicrotask(() => {
|
|
705
|
-
// Revert to before state
|
|
706
|
-
for (const [id, props] of beforeSnapshots) {
|
|
707
|
-
toolCtx.store.updateShape(id, props);
|
|
708
|
-
}
|
|
709
|
-
// Execute undoable command
|
|
710
|
-
toolCtx.commands.execute({
|
|
711
|
-
execute: () => {
|
|
712
|
-
for (const [id, props] of afterSnapshots) {
|
|
713
|
-
toolCtx.store.updateShape(id, props);
|
|
714
|
-
}
|
|
715
|
-
},
|
|
716
|
-
undo: () => {
|
|
717
|
-
for (const [id, props] of beforeSnapshots) {
|
|
718
|
-
toolCtx.store.updateShape(id, props);
|
|
719
|
-
}
|
|
720
|
-
},
|
|
721
|
-
});
|
|
722
|
-
});
|
|
229
|
+
if (dragState.kind === "resize" || dragState.kind === "multi-resize") {
|
|
230
|
+
const u = dragState.session.update(event);
|
|
231
|
+
if (u.flippedHandle) {
|
|
232
|
+
setOverrideCursor(dragState.kind === "resize" && dragState.rotation
|
|
233
|
+
? getRotatedCursorForHandle(u.flippedHandle, dragState.rotation)
|
|
234
|
+
: getCursorForHandle(u.flippedHandle));
|
|
723
235
|
}
|
|
724
|
-
}
|
|
725
|
-
dragState = null;
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
|
-
if (dragState.mode === "marquee") {
|
|
729
|
-
setMarquee(null);
|
|
730
|
-
// Calculate marquee rect in world coordinates
|
|
731
|
-
const wx1 = _event.worldPoint.x;
|
|
732
|
-
const wy1 = _event.worldPoint.y;
|
|
733
|
-
const sx = dragState.startWorldPoint.x;
|
|
734
|
-
const sy = dragState.startWorldPoint.y;
|
|
735
|
-
const mx = Math.min(sx, wx1);
|
|
736
|
-
const my = Math.min(sy, wy1);
|
|
737
|
-
const mw = Math.abs(wx1 - sx);
|
|
738
|
-
const mh = Math.abs(wy1 - sy);
|
|
739
|
-
// Skip tiny marquees (accidental clicks)
|
|
740
|
-
if (mw < 2 && mh < 2) {
|
|
741
|
-
dragState = null;
|
|
742
236
|
return;
|
|
743
237
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
238
|
+
// kind === "move"
|
|
239
|
+
dragState.session.update(event);
|
|
240
|
+
// Drop target: find frame/group under the cursor (excluding dragged shapes).
|
|
241
|
+
const allShapes = toolCtx.store.getShapes();
|
|
242
|
+
let dropTarget = null;
|
|
243
|
+
const entries = [...allShapes.entries()].reverse();
|
|
244
|
+
for (const [id, shape] of entries) {
|
|
245
|
+
if (dragState.movingIds.has(id))
|
|
246
|
+
continue;
|
|
247
|
+
if (shape.type !== "frame" && shape.type !== "group")
|
|
248
|
+
continue;
|
|
249
|
+
const def = toolCtx.shapes.get(shape.type);
|
|
250
|
+
const bounds = def
|
|
251
|
+
? def.getBounds(shape)
|
|
252
|
+
: { x: shape.x, y: shape.y, width: shape.width, height: shape.height };
|
|
253
|
+
if (event.worldPoint.x >= bounds.x &&
|
|
254
|
+
event.worldPoint.x <= bounds.x + bounds.width &&
|
|
255
|
+
event.worldPoint.y >= bounds.y &&
|
|
256
|
+
event.worldPoint.y <= bounds.y + bounds.height) {
|
|
257
|
+
dropTarget = id;
|
|
258
|
+
break;
|
|
750
259
|
}
|
|
751
260
|
}
|
|
752
|
-
|
|
753
|
-
toolCtx.store.setSelection(hitIds);
|
|
754
|
-
}
|
|
755
|
-
dragState = null;
|
|
756
|
-
return;
|
|
261
|
+
updateDropTarget(dropTarget);
|
|
757
262
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
if (
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const currentValue = currentShape[key];
|
|
769
|
-
const startValue = dragState.startData[key];
|
|
770
|
-
if (currentValue !== startValue) {
|
|
771
|
-
from[key] = startValue;
|
|
772
|
-
to[key] = currentValue;
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
if (Object.keys(to).length > 0) {
|
|
776
|
-
// Defer reset+execute to after canvas:pointerup disables snap
|
|
777
|
-
const shapeId = dragState.shapeId;
|
|
778
|
-
queueMicrotask(() => {
|
|
779
|
-
toolCtx.store.updateShape(shapeId, from);
|
|
780
|
-
toolCtx.commands.execute(createUpdateShapeCommand(toolCtx.store, shapeId, from, to));
|
|
781
|
-
});
|
|
263
|
+
function onPointerUp(toolCtx, _event) {
|
|
264
|
+
enableTextSelection();
|
|
265
|
+
updateDropTarget(null);
|
|
266
|
+
if (!dragState)
|
|
267
|
+
return;
|
|
268
|
+
if (dragState.kind === "rotate") {
|
|
269
|
+
setOverrideCursor("");
|
|
270
|
+
const result = dragState.session.commit();
|
|
271
|
+
if (result) {
|
|
272
|
+
queueMicrotask(() => toolCtx.commands.execute(result.command));
|
|
782
273
|
}
|
|
274
|
+
dragState = null;
|
|
275
|
+
return;
|
|
783
276
|
}
|
|
784
|
-
dragState
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
const from = {};
|
|
795
|
-
const to = {};
|
|
796
|
-
for (const key of Object.keys(currentShape)) {
|
|
797
|
-
if (key === "id" || key === "type" || key === "style")
|
|
798
|
-
continue;
|
|
799
|
-
const currentValue = currentShape[key];
|
|
800
|
-
const origValue = origFullShape[key];
|
|
801
|
-
if (currentValue !== origValue) {
|
|
802
|
-
from[key] = origValue;
|
|
803
|
-
to[key] = currentValue;
|
|
277
|
+
if (dragState.kind === "marquee") {
|
|
278
|
+
setMarquee(null);
|
|
279
|
+
const result = dragState.session.commit();
|
|
280
|
+
if (result) {
|
|
281
|
+
if (dragState.shiftKey) {
|
|
282
|
+
for (const id of result.selection)
|
|
283
|
+
toolCtx.store.addToSelection(id);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
toolCtx.store.setSelection([...result.selection]);
|
|
804
287
|
}
|
|
805
288
|
}
|
|
806
|
-
|
|
807
|
-
|
|
289
|
+
dragState = null;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (dragState.kind === "resize" || dragState.kind === "multi-resize") {
|
|
293
|
+
setOverrideCursor("");
|
|
294
|
+
const result = dragState.session.commit();
|
|
295
|
+
if (result) {
|
|
296
|
+
queueMicrotask(() => toolCtx.commands.execute(result.command));
|
|
808
297
|
}
|
|
298
|
+
dragState = null;
|
|
299
|
+
return;
|
|
809
300
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
301
|
+
// kind === "move"
|
|
302
|
+
setMovingSelection(false);
|
|
303
|
+
const userSelectedIds = [...toolCtx.store.getSelection()];
|
|
304
|
+
const result = dragState.session.commit();
|
|
305
|
+
if (result) {
|
|
813
306
|
queueMicrotask(() => {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
}
|
|
817
|
-
toolCtx.commands.execute(createBatchUpdateShapesCommand(storeRef, batchUpdates));
|
|
307
|
+
toolCtx.commands.execute(result.command);
|
|
308
|
+
// Notify after the move command is committed so reparent won't be undone.
|
|
309
|
+
toolCtx.events.emit("shapes:move-end", { shapeIds: userSelectedIds });
|
|
818
310
|
});
|
|
819
311
|
}
|
|
820
312
|
dragState = null;
|
|
821
|
-
return;
|
|
822
|
-
}
|
|
823
|
-
// mode === "move"
|
|
824
|
-
setMovingSelection(false);
|
|
825
|
-
// Build before/after snapshots for undoable command
|
|
826
|
-
const beforeSnapshots = dragState.startShapeSnapshots;
|
|
827
|
-
const afterSnapshots = new Map();
|
|
828
|
-
let hasMoved = false;
|
|
829
|
-
for (const [id, before] of beforeSnapshots) {
|
|
830
|
-
const current = toolCtx.store.getShape(id);
|
|
831
|
-
if (current) {
|
|
832
|
-
afterSnapshots.set(id, { ...current });
|
|
833
|
-
if (Math.abs(current.x - before.x) > 0.5 || Math.abs(current.y - before.y) > 0.5) {
|
|
834
|
-
hasMoved = true;
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
313
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
toolCtx.commands.execute(createMoveShapesCommand(toolCtx.store, beforeSnapshots, afterSnapshots));
|
|
848
|
-
// Notify after the move command is committed so reparent won't be undone
|
|
849
|
-
toolCtx.events.emit("shapes:move-end", { shapeIds: userSelectedIds });
|
|
850
|
-
});
|
|
314
|
+
function onDeactivate(_toolCtx) {
|
|
315
|
+
dragState = null;
|
|
316
|
+
enableTextSelection();
|
|
317
|
+
setMovingSelection(false);
|
|
318
|
+
setMarquee(null);
|
|
319
|
+
setOverrideCursor("");
|
|
320
|
+
setEditingGroupId(null);
|
|
321
|
+
setHoveredShapeId(null);
|
|
322
|
+
updateDropTarget(null);
|
|
851
323
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
clearEditingGroupListeners();
|
|
895
|
-
clearHoveredShapeListeners();
|
|
896
|
-
clearDropTargetListeners();
|
|
897
|
-
styleEl.remove();
|
|
898
|
-
ctx.layers.unregister("selection-overlay");
|
|
899
|
-
};
|
|
900
|
-
},
|
|
901
|
-
};
|
|
324
|
+
ctx.tools.register("select", {
|
|
325
|
+
icon: SelectIcon,
|
|
326
|
+
cursor: "default",
|
|
327
|
+
shortcut: "v",
|
|
328
|
+
order: 0,
|
|
329
|
+
onPointerDown,
|
|
330
|
+
onPointerMove,
|
|
331
|
+
onPointerUp,
|
|
332
|
+
onDeactivate,
|
|
333
|
+
});
|
|
334
|
+
// ── Selection foreground (default) ──
|
|
335
|
+
// Registered at priority 0 so any app option or third-party plugin
|
|
336
|
+
// (recommended: 50) replaces it. See guides/selection-foreground.
|
|
337
|
+
const unregisterSelectionForeground = ctx.ui.registerSelectionForeground({
|
|
338
|
+
id: "tool-select-default",
|
|
339
|
+
priority: 0,
|
|
340
|
+
order: 80,
|
|
341
|
+
fixed: true,
|
|
342
|
+
render: (renderCtx) => (_jsx(SelectionOverlay, { store: ctx.store, shapes: ctx.shapes, viewport: renderCtx.viewport })),
|
|
343
|
+
});
|
|
344
|
+
// Delete selected shapes
|
|
345
|
+
ctx.shortcuts.register("Delete", () => deleteSelectedShapes(ctx));
|
|
346
|
+
ctx.shortcuts.register("Backspace", () => deleteSelectedShapes(ctx));
|
|
347
|
+
// ── Teardown ──
|
|
348
|
+
return () => {
|
|
349
|
+
setOverrideCursor("");
|
|
350
|
+
setMovingSelection(false);
|
|
351
|
+
setMarquee(null);
|
|
352
|
+
setEditingGroupId(null);
|
|
353
|
+
setHoveredShapeId(null);
|
|
354
|
+
updateDropTarget(null);
|
|
355
|
+
clearMarqueeListeners();
|
|
356
|
+
clearMovingSelectionListeners();
|
|
357
|
+
clearEditingGroupListeners();
|
|
358
|
+
clearHoveredShapeListeners();
|
|
359
|
+
clearDropTargetListeners();
|
|
360
|
+
styleEl.remove();
|
|
361
|
+
unregisterSelectionForeground();
|
|
362
|
+
};
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
902
366
|
//# sourceMappingURL=plugin.js.map
|