@canvas-harness/react 0.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.cjs +1675 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +462 -0
- package/dist/index.d.ts +462 -0
- package/dist/index.js +1653 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1653 @@
|
|
|
1
|
+
import { createRenderer, DEFAULT_MINIMAP_MAX_NODES, renderMinimapContent, sceneBounds, drawMinimapViewport, worldViewportFromCamera, screenToWorld, hitTestAny, copy, cut, paste, createPalmRejectionState, createDefaultTextareaEditor, minimapScreenToWorld, notePenActive, shouldRejectTouch, notePenInactive, asEdgeId, midpointToCubicControls, projectToNodeBoundary, marqueeNodes, shouldAutoFit, computeAutoFitHeight, hitTestPoint, worldToNodeLocal, edgeLabelBoundsWorld, asNodeId, zoomAtScreenPoint, clampZoom, panByScreen } from '@canvas-harness/core';
|
|
2
|
+
import { createContext, useContext, useRef, useState, useEffect, useSyncExternalStore } from 'react';
|
|
3
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
// src/Canvas.tsx
|
|
6
|
+
var CanvasContext = createContext(null);
|
|
7
|
+
function CanvasProvider({ store, children }) {
|
|
8
|
+
return /* @__PURE__ */ jsx(CanvasContext.Provider, { value: store, children });
|
|
9
|
+
}
|
|
10
|
+
function useCanvasStore() {
|
|
11
|
+
const store = useContext(CanvasContext);
|
|
12
|
+
if (!store) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"useCanvasStore() must be used inside <CanvasProvider>. Wrap your tree with <CanvasProvider store={store}>."
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return store;
|
|
18
|
+
}
|
|
19
|
+
function EditorMount({
|
|
20
|
+
store,
|
|
21
|
+
factory = createDefaultTextareaEditor
|
|
22
|
+
}) {
|
|
23
|
+
const hostRef = useRef(null);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const host = hostRef.current;
|
|
26
|
+
if (!host) return;
|
|
27
|
+
let activeAdapter = null;
|
|
28
|
+
let currentEditingKey = null;
|
|
29
|
+
const teardown = () => {
|
|
30
|
+
if (activeAdapter) {
|
|
31
|
+
activeAdapter.destroy();
|
|
32
|
+
activeAdapter = null;
|
|
33
|
+
}
|
|
34
|
+
currentEditingKey = null;
|
|
35
|
+
};
|
|
36
|
+
const onInteraction = () => {
|
|
37
|
+
const state = store.getInteractionState();
|
|
38
|
+
const target = state.mode === "editing" ? state.editingTarget : null;
|
|
39
|
+
const key = target ? `${target.kind}:${target.id}` : null;
|
|
40
|
+
if (key === currentEditingKey) return;
|
|
41
|
+
teardown();
|
|
42
|
+
if (!target) return;
|
|
43
|
+
let editorNode = null;
|
|
44
|
+
if (target.kind === "node") {
|
|
45
|
+
editorNode = store.getNode(target.id) ?? null;
|
|
46
|
+
} else {
|
|
47
|
+
const edge = store.getEdge(target.id);
|
|
48
|
+
const geom = store.getEdgeGeometry(target.id);
|
|
49
|
+
if (edge && geom) editorNode = synthesizeLabelNode(edge, geom);
|
|
50
|
+
}
|
|
51
|
+
if (!editorNode) return;
|
|
52
|
+
currentEditingKey = key;
|
|
53
|
+
activeAdapter = factory({
|
|
54
|
+
node: editorNode,
|
|
55
|
+
container: host,
|
|
56
|
+
camera: store.getCamera(),
|
|
57
|
+
dpr: window.devicePixelRatio || 1,
|
|
58
|
+
onCommit: (text) => {
|
|
59
|
+
store.commitEdit(text);
|
|
60
|
+
},
|
|
61
|
+
onCancel: () => {
|
|
62
|
+
store.cancelEdit();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
onInteraction();
|
|
67
|
+
const unsub = store.subscribe("interaction", onInteraction);
|
|
68
|
+
return () => {
|
|
69
|
+
unsub();
|
|
70
|
+
teardown();
|
|
71
|
+
};
|
|
72
|
+
}, [store, factory]);
|
|
73
|
+
return /* @__PURE__ */ jsx(
|
|
74
|
+
"div",
|
|
75
|
+
{
|
|
76
|
+
ref: hostRef,
|
|
77
|
+
style: {
|
|
78
|
+
position: "absolute",
|
|
79
|
+
inset: 0,
|
|
80
|
+
pointerEvents: "none"
|
|
81
|
+
// Children (the textarea) re-enable pointer events themselves.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
var synthesizeLabelNode = (edge, geom) => {
|
|
87
|
+
const labelStyle = { fontFamily: "handwriting", ...edge.style };
|
|
88
|
+
const bounds = edgeLabelBoundsWorld(edge, geom);
|
|
89
|
+
if (!bounds) {
|
|
90
|
+
if (geom.samples.length < 2) return null;
|
|
91
|
+
const mid = geom.samples[Math.floor(geom.samples.length / 2)];
|
|
92
|
+
return {
|
|
93
|
+
id: asNodeId(`__edge-label:${edge.id}`),
|
|
94
|
+
type: "text",
|
|
95
|
+
x: mid.x - 60,
|
|
96
|
+
y: mid.y - 12,
|
|
97
|
+
w: 120,
|
|
98
|
+
h: 24,
|
|
99
|
+
angle: 0,
|
|
100
|
+
z: 0,
|
|
101
|
+
groups: [],
|
|
102
|
+
content: "",
|
|
103
|
+
style: labelStyle
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
id: asNodeId(`__edge-label:${edge.id}`),
|
|
108
|
+
type: "text",
|
|
109
|
+
x: bounds.x,
|
|
110
|
+
y: bounds.y,
|
|
111
|
+
w: bounds.w,
|
|
112
|
+
h: bounds.h,
|
|
113
|
+
angle: 0,
|
|
114
|
+
z: 0,
|
|
115
|
+
groups: [],
|
|
116
|
+
content: edge.content ?? "",
|
|
117
|
+
style: { ...labelStyle, autoFit: false }
|
|
118
|
+
// labels don't autofit height
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
var CLICK_MAX_PIXELS = 4;
|
|
122
|
+
var useArrowTool = (ref, store, enabled, defaults) => {
|
|
123
|
+
const defaultsRef = useRef(defaults);
|
|
124
|
+
defaultsRef.current = defaults;
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!enabled) return;
|
|
127
|
+
const el = ref.current;
|
|
128
|
+
if (!el) return;
|
|
129
|
+
let pointerDownAt = null;
|
|
130
|
+
let active = false;
|
|
131
|
+
let sourceEnd = null;
|
|
132
|
+
const screenFromEvent = (e) => {
|
|
133
|
+
const rect = el.getBoundingClientRect();
|
|
134
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
135
|
+
};
|
|
136
|
+
const worldFromEvent = (e) => screenToWorld(screenFromEvent(e), store.getCamera());
|
|
137
|
+
const endFromWorldPoint = (world) => {
|
|
138
|
+
const hit = hitTestPoint(store, world, store.getCamera().z);
|
|
139
|
+
if (hit && hit.kind === "body") {
|
|
140
|
+
const node = store.getNode(hit.nodeId);
|
|
141
|
+
const localOffset = projectToNodeBoundary(world, node);
|
|
142
|
+
return { end: { nodeId: node.id, localOffset }, nodeId: node.id };
|
|
143
|
+
}
|
|
144
|
+
return { end: { worldPoint: world }, nodeId: null };
|
|
145
|
+
};
|
|
146
|
+
const followingEnd = (world) => {
|
|
147
|
+
const hit = hitTestPoint(store, world, store.getCamera().z);
|
|
148
|
+
if (hit && hit.kind === "body") {
|
|
149
|
+
const node = store.getNode(hit.nodeId);
|
|
150
|
+
const local = worldToNodeLocal(world, node);
|
|
151
|
+
const clamped = {
|
|
152
|
+
x: Math.max(0, Math.min(node.w, local.x)),
|
|
153
|
+
y: Math.max(0, Math.min(node.h, local.y))
|
|
154
|
+
};
|
|
155
|
+
return { end: { nodeId: node.id, localOffset: clamped }, nodeId: node.id };
|
|
156
|
+
}
|
|
157
|
+
return { end: { worldPoint: world }, nodeId: null };
|
|
158
|
+
};
|
|
159
|
+
const onPointerDown = (e) => {
|
|
160
|
+
if (e.button !== 0) return;
|
|
161
|
+
pointerDownAt = screenFromEvent(e);
|
|
162
|
+
const world = worldFromEvent(e);
|
|
163
|
+
const { end } = endFromWorldPoint(world);
|
|
164
|
+
sourceEnd = end;
|
|
165
|
+
el.setPointerCapture(e.pointerId);
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
};
|
|
168
|
+
const onPointerMove = (e) => {
|
|
169
|
+
if (!pointerDownAt || !sourceEnd) return;
|
|
170
|
+
const screen = screenFromEvent(e);
|
|
171
|
+
const dx = screen.x - pointerDownAt.x;
|
|
172
|
+
const dy = screen.y - pointerDownAt.y;
|
|
173
|
+
if (!active && Math.abs(dx) < CLICK_MAX_PIXELS && Math.abs(dy) < CLICK_MAX_PIXELS) return;
|
|
174
|
+
active = true;
|
|
175
|
+
const world = worldFromEvent(e);
|
|
176
|
+
const { end: target, nodeId: snapTargetNodeId } = followingEnd(world);
|
|
177
|
+
store.setInteractionState({
|
|
178
|
+
mode: "creating-edge",
|
|
179
|
+
draftEdge: {
|
|
180
|
+
source: sourceEnd,
|
|
181
|
+
target,
|
|
182
|
+
reconnectingId: null,
|
|
183
|
+
snapTargetNodeId
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
const onPointerUp = (e) => {
|
|
188
|
+
if (!pointerDownAt) return;
|
|
189
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
190
|
+
const wasActive = active;
|
|
191
|
+
if (wasActive && sourceEnd) {
|
|
192
|
+
const world = worldFromEvent(e);
|
|
193
|
+
const { end: target } = endFromWorldPoint(world);
|
|
194
|
+
const d = defaultsRef.current;
|
|
195
|
+
store.addEdge({
|
|
196
|
+
id: asEdgeId(store.generateId()),
|
|
197
|
+
source: sourceEnd,
|
|
198
|
+
target,
|
|
199
|
+
pathStyle: d?.pathStyle ?? "bezier",
|
|
200
|
+
z: 0,
|
|
201
|
+
groups: [],
|
|
202
|
+
...d?.style ? { style: d.style } : {}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
store.resetInteractionState();
|
|
206
|
+
pointerDownAt = null;
|
|
207
|
+
active = false;
|
|
208
|
+
sourceEnd = null;
|
|
209
|
+
};
|
|
210
|
+
const onPointerCancel = (e) => {
|
|
211
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
212
|
+
store.resetInteractionState();
|
|
213
|
+
pointerDownAt = null;
|
|
214
|
+
active = false;
|
|
215
|
+
sourceEnd = null;
|
|
216
|
+
};
|
|
217
|
+
el.addEventListener("pointerdown", onPointerDown);
|
|
218
|
+
el.addEventListener("pointermove", onPointerMove);
|
|
219
|
+
el.addEventListener("pointerup", onPointerUp);
|
|
220
|
+
el.addEventListener("pointercancel", onPointerCancel);
|
|
221
|
+
return () => {
|
|
222
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
223
|
+
el.removeEventListener("pointermove", onPointerMove);
|
|
224
|
+
el.removeEventListener("pointerup", onPointerUp);
|
|
225
|
+
el.removeEventListener("pointercancel", onPointerCancel);
|
|
226
|
+
};
|
|
227
|
+
}, [ref, store, enabled]);
|
|
228
|
+
};
|
|
229
|
+
var CLICK_MAX_PIXELS2 = 4;
|
|
230
|
+
var LONG_PRESS_MS = 500;
|
|
231
|
+
var LONG_PRESS_MAX_MOVE_PX = 10;
|
|
232
|
+
var useInteractionGesture = (ref, store, tool) => {
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
const el = ref.current;
|
|
235
|
+
if (!el) return;
|
|
236
|
+
if (tool !== "select") return;
|
|
237
|
+
let pointerDownAt = null;
|
|
238
|
+
let activeGesture = "idle";
|
|
239
|
+
let resizeHandle = null;
|
|
240
|
+
let dragOriginals = [];
|
|
241
|
+
let marqueeStartWorld = null;
|
|
242
|
+
let marqueeShift = false;
|
|
243
|
+
let reconnectEdgeId = null;
|
|
244
|
+
let reconnectEnd = null;
|
|
245
|
+
let midpointEdgeId = null;
|
|
246
|
+
let rotateNodeId = null;
|
|
247
|
+
let rotateOriginAngle = 0;
|
|
248
|
+
let rotatePointerStartAngle = 0;
|
|
249
|
+
const palm = createPalmRejectionState();
|
|
250
|
+
let longPressTimer = null;
|
|
251
|
+
const clearLongPress = () => {
|
|
252
|
+
if (longPressTimer !== null) {
|
|
253
|
+
clearTimeout(longPressTimer);
|
|
254
|
+
longPressTimer = null;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const screenFromEvent = (e) => {
|
|
258
|
+
const rect = el.getBoundingClientRect();
|
|
259
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
260
|
+
};
|
|
261
|
+
const worldFromEvent = (e) => screenToWorld(screenFromEvent(e), store.getCamera());
|
|
262
|
+
const snapshotOriginals = (ids) => {
|
|
263
|
+
const result = [];
|
|
264
|
+
for (const id of ids) {
|
|
265
|
+
const n = store.getNode(id);
|
|
266
|
+
if (n) result.push({ id, x: n.x, y: n.y, w: n.w, h: n.h, angle: n.angle });
|
|
267
|
+
}
|
|
268
|
+
return result;
|
|
269
|
+
};
|
|
270
|
+
const beginDrag = (ids) => {
|
|
271
|
+
dragOriginals = snapshotOriginals(ids);
|
|
272
|
+
store.setInteractionState({
|
|
273
|
+
mode: "dragging",
|
|
274
|
+
draggedIds: ids,
|
|
275
|
+
dragOriginals,
|
|
276
|
+
dragDelta: { x: 0, y: 0 }
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
const beginResize = (id, handle) => {
|
|
280
|
+
dragOriginals = snapshotOriginals([id]);
|
|
281
|
+
store.setInteractionState({
|
|
282
|
+
mode: "resizing",
|
|
283
|
+
draggedIds: [id],
|
|
284
|
+
dragOriginals,
|
|
285
|
+
resizeHandle: handle,
|
|
286
|
+
resizeLockAspect: false,
|
|
287
|
+
resizeFromCenter: false
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
const pointerAngleFromCenter = (node, world) => {
|
|
291
|
+
const cx = node.x + node.w / 2;
|
|
292
|
+
const cy = node.y + node.h / 2;
|
|
293
|
+
return Math.atan2(world.y - cy, world.x - cx);
|
|
294
|
+
};
|
|
295
|
+
const beginRotate = (id, worldAtStart) => {
|
|
296
|
+
const node = store.getNode(id);
|
|
297
|
+
if (!node) return;
|
|
298
|
+
rotateNodeId = id;
|
|
299
|
+
rotateOriginAngle = node.angle;
|
|
300
|
+
rotatePointerStartAngle = pointerAngleFromCenter(node, worldAtStart);
|
|
301
|
+
store.setInteractionState({
|
|
302
|
+
mode: "rotating",
|
|
303
|
+
draggedIds: [id]
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
const ROTATE_SNAP_RAD = 15 * Math.PI / 180;
|
|
307
|
+
const updateRotate = (worldPoint, shift) => {
|
|
308
|
+
if (!rotateNodeId) return;
|
|
309
|
+
const node = store.getNode(rotateNodeId);
|
|
310
|
+
if (!node) return;
|
|
311
|
+
const pointerAngle = pointerAngleFromCenter(node, worldPoint);
|
|
312
|
+
const delta = pointerAngle - rotatePointerStartAngle;
|
|
313
|
+
let next = rotateOriginAngle + delta;
|
|
314
|
+
if (shift) next = Math.round(next / ROTATE_SNAP_RAD) * ROTATE_SNAP_RAD;
|
|
315
|
+
store.updateNode(rotateNodeId, { angle: next });
|
|
316
|
+
};
|
|
317
|
+
const commitRotate = () => {
|
|
318
|
+
rotateNodeId = null;
|
|
319
|
+
store.resetInteractionState();
|
|
320
|
+
};
|
|
321
|
+
const updateDrag = (delta) => {
|
|
322
|
+
store.setInteractionState({ dragDelta: delta });
|
|
323
|
+
};
|
|
324
|
+
const updateResize = (worldPoint, modifiers) => {
|
|
325
|
+
const orig = dragOriginals[0];
|
|
326
|
+
if (!orig || !resizeHandle) return;
|
|
327
|
+
const next = computeResizeGeometry(orig, resizeHandle, worldPoint, modifiers);
|
|
328
|
+
store.updateNode(orig.id, next);
|
|
329
|
+
store.setInteractionState({
|
|
330
|
+
resizeLockAspect: modifiers.shift,
|
|
331
|
+
resizeFromCenter: modifiers.alt
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
const commitDrag = () => {
|
|
335
|
+
const interaction = store.getInteractionState();
|
|
336
|
+
const delta = interaction.dragDelta;
|
|
337
|
+
if (delta.x !== 0 || delta.y !== 0) {
|
|
338
|
+
store.batch(() => {
|
|
339
|
+
for (const orig of dragOriginals) {
|
|
340
|
+
store.updateNode(orig.id, { x: orig.x + delta.x, y: orig.y + delta.y });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
store.resetInteractionState();
|
|
345
|
+
};
|
|
346
|
+
const commitResize = () => {
|
|
347
|
+
const selected = store.getSelection();
|
|
348
|
+
for (const id of selected) {
|
|
349
|
+
const node = store.getNode(id);
|
|
350
|
+
if (!node) continue;
|
|
351
|
+
if (!shouldAutoFit(node)) continue;
|
|
352
|
+
const fitted = computeAutoFitHeight(node);
|
|
353
|
+
if (fitted > node.h) store.updateNode(node.id, { h: fitted });
|
|
354
|
+
}
|
|
355
|
+
store.resetInteractionState();
|
|
356
|
+
};
|
|
357
|
+
const beginMarquee = (start, shift) => {
|
|
358
|
+
marqueeStartWorld = start;
|
|
359
|
+
marqueeShift = shift;
|
|
360
|
+
store.setInteractionState({
|
|
361
|
+
mode: "marqueeing",
|
|
362
|
+
marqueeRect: { x: start.x, y: start.y, w: 0, h: 0 },
|
|
363
|
+
marqueeAdditive: shift
|
|
364
|
+
});
|
|
365
|
+
};
|
|
366
|
+
const updateMarquee = (current) => {
|
|
367
|
+
if (!marqueeStartWorld) return;
|
|
368
|
+
const rect = {
|
|
369
|
+
x: Math.min(marqueeStartWorld.x, current.x),
|
|
370
|
+
y: Math.min(marqueeStartWorld.y, current.y),
|
|
371
|
+
w: Math.abs(current.x - marqueeStartWorld.x),
|
|
372
|
+
h: Math.abs(current.y - marqueeStartWorld.y)
|
|
373
|
+
};
|
|
374
|
+
store.setInteractionState({ marqueeRect: rect });
|
|
375
|
+
};
|
|
376
|
+
const commitMarquee = () => {
|
|
377
|
+
const interaction = store.getInteractionState();
|
|
378
|
+
const rect = interaction.marqueeRect;
|
|
379
|
+
if (rect && (rect.w > 0 || rect.h > 0)) {
|
|
380
|
+
const hits = marqueeNodes(store, rect);
|
|
381
|
+
if (marqueeShift) {
|
|
382
|
+
const existing = new Set(store.getSelection());
|
|
383
|
+
for (const id of hits) existing.add(id);
|
|
384
|
+
store.setSelection([...existing]);
|
|
385
|
+
} else {
|
|
386
|
+
store.setSelection(hits);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
store.resetInteractionState();
|
|
390
|
+
};
|
|
391
|
+
const onPointerDown = (e) => {
|
|
392
|
+
if (e.button !== 0) return;
|
|
393
|
+
if (e.pointerType === "pen") notePenActive(palm);
|
|
394
|
+
else if (e.pointerType === "touch" && shouldRejectTouch(palm, Date.now())) return;
|
|
395
|
+
pointerDownAt = screenFromEvent(e);
|
|
396
|
+
const world = worldFromEvent(e);
|
|
397
|
+
const camera = store.getCamera();
|
|
398
|
+
const selection = store.getSelection();
|
|
399
|
+
const selectedNodeIds = /* @__PURE__ */ new Set();
|
|
400
|
+
const selectedEdgeIds = /* @__PURE__ */ new Set();
|
|
401
|
+
for (const id of selection) {
|
|
402
|
+
if (store.getNode(id)) selectedNodeIds.add(id);
|
|
403
|
+
else if (store.getEdge(id)) selectedEdgeIds.add(id);
|
|
404
|
+
}
|
|
405
|
+
const hit = hitTestAny(store, world, camera.z, selectedNodeIds, selectedEdgeIds);
|
|
406
|
+
if (hit?.kind === "rotate-handle") {
|
|
407
|
+
activeGesture = "rotate";
|
|
408
|
+
beginRotate(hit.nodeId, world);
|
|
409
|
+
el.setPointerCapture(e.pointerId);
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (hit?.kind === "resize-handle") {
|
|
414
|
+
resizeHandle = hit.handle;
|
|
415
|
+
activeGesture = "resize";
|
|
416
|
+
beginResize(hit.nodeId, hit.handle);
|
|
417
|
+
el.setPointerCapture(e.pointerId);
|
|
418
|
+
e.preventDefault();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (hit?.kind === "midpoint-handle") {
|
|
422
|
+
midpointEdgeId = hit.edgeId;
|
|
423
|
+
activeGesture = "edge-midpoint";
|
|
424
|
+
el.setPointerCapture(e.pointerId);
|
|
425
|
+
e.preventDefault();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (hit?.kind === "source-handle" || hit?.kind === "target-handle") {
|
|
429
|
+
reconnectEdgeId = hit.edgeId;
|
|
430
|
+
reconnectEnd = hit.kind === "source-handle" ? "source" : "target";
|
|
431
|
+
activeGesture = "reconnect-edge";
|
|
432
|
+
const edge = store.getEdge(hit.edgeId);
|
|
433
|
+
if (edge) {
|
|
434
|
+
store.setInteractionState({
|
|
435
|
+
mode: "reconnecting-edge",
|
|
436
|
+
draftEdge: {
|
|
437
|
+
source: edge.source,
|
|
438
|
+
target: edge.target,
|
|
439
|
+
reconnectingId: hit.edgeId,
|
|
440
|
+
snapTargetNodeId: null
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
el.setPointerCapture(e.pointerId);
|
|
445
|
+
e.preventDefault();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (hit?.kind === "body" && "nodeId" in hit) {
|
|
449
|
+
const alreadySelected = selectedNodeIds.has(hit.nodeId);
|
|
450
|
+
if (e.shiftKey) {
|
|
451
|
+
const next = new Set(selectedNodeIds);
|
|
452
|
+
if (alreadySelected) next.delete(hit.nodeId);
|
|
453
|
+
else next.add(hit.nodeId);
|
|
454
|
+
store.setSelection([...next]);
|
|
455
|
+
} else if (!alreadySelected) {
|
|
456
|
+
store.setSelection([hit.nodeId]);
|
|
457
|
+
}
|
|
458
|
+
activeGesture = "click-pending";
|
|
459
|
+
el.setPointerCapture(e.pointerId);
|
|
460
|
+
if (e.pointerType === "touch") {
|
|
461
|
+
const targetIds = e.shiftKey ? [...selectedNodeIds, hit.nodeId] : [hit.nodeId];
|
|
462
|
+
clearLongPress();
|
|
463
|
+
longPressTimer = setTimeout(() => {
|
|
464
|
+
longPressTimer = null;
|
|
465
|
+
if (activeGesture !== "click-pending") return;
|
|
466
|
+
activeGesture = "drag";
|
|
467
|
+
beginDrag(targetIds);
|
|
468
|
+
}, LONG_PRESS_MS);
|
|
469
|
+
}
|
|
470
|
+
e.preventDefault();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (hit?.kind === "body" && "edgeId" in hit) {
|
|
474
|
+
if (e.shiftKey) {
|
|
475
|
+
const next = new Set(selectedEdgeIds);
|
|
476
|
+
if (selectedEdgeIds.has(hit.edgeId)) next.delete(hit.edgeId);
|
|
477
|
+
else next.add(hit.edgeId);
|
|
478
|
+
store.setSelection([...selectedNodeIds, ...next]);
|
|
479
|
+
} else {
|
|
480
|
+
store.setSelection([hit.edgeId]);
|
|
481
|
+
}
|
|
482
|
+
activeGesture = "click-pending";
|
|
483
|
+
el.setPointerCapture(e.pointerId);
|
|
484
|
+
e.preventDefault();
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (!e.shiftKey) store.setSelection([]);
|
|
488
|
+
activeGesture = "click-pending";
|
|
489
|
+
el.setPointerCapture(e.pointerId);
|
|
490
|
+
};
|
|
491
|
+
const onPointerMove = (e) => {
|
|
492
|
+
if (!pointerDownAt) return;
|
|
493
|
+
const screen = screenFromEvent(e);
|
|
494
|
+
const dx = screen.x - pointerDownAt.x;
|
|
495
|
+
const dy = screen.y - pointerDownAt.y;
|
|
496
|
+
if (longPressTimer !== null && (Math.abs(dx) > LONG_PRESS_MAX_MOVE_PX || Math.abs(dy) > LONG_PRESS_MAX_MOVE_PX)) {
|
|
497
|
+
clearLongPress();
|
|
498
|
+
}
|
|
499
|
+
if (activeGesture === "click-pending") {
|
|
500
|
+
if (Math.abs(dx) < CLICK_MAX_PIXELS2 && Math.abs(dy) < CLICK_MAX_PIXELS2) return;
|
|
501
|
+
const startWorld = screenToWorld(pointerDownAt, store.getCamera());
|
|
502
|
+
const camera = store.getCamera();
|
|
503
|
+
const selectedIds = new Set(
|
|
504
|
+
store.getSelection().filter((id) => store.getNode(id))
|
|
505
|
+
);
|
|
506
|
+
const hit = hitTestAny(store, startWorld, camera.z, selectedIds, /* @__PURE__ */ new Set());
|
|
507
|
+
if (hit?.kind === "body" && "nodeId" in hit && selectedIds.has(hit.nodeId)) {
|
|
508
|
+
activeGesture = "drag";
|
|
509
|
+
beginDrag([...selectedIds]);
|
|
510
|
+
} else {
|
|
511
|
+
activeGesture = "marquee";
|
|
512
|
+
beginMarquee(startWorld, e.shiftKey);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (activeGesture === "drag") {
|
|
516
|
+
const camera = store.getCamera();
|
|
517
|
+
updateDrag({ x: dx / camera.z, y: dy / camera.z });
|
|
518
|
+
} else if (activeGesture === "resize") {
|
|
519
|
+
const world = worldFromEvent(e);
|
|
520
|
+
updateResize(world, { shift: e.shiftKey, alt: e.altKey });
|
|
521
|
+
} else if (activeGesture === "rotate") {
|
|
522
|
+
updateRotate(worldFromEvent(e), e.shiftKey);
|
|
523
|
+
} else if (activeGesture === "marquee") {
|
|
524
|
+
updateMarquee(worldFromEvent(e));
|
|
525
|
+
} else if (activeGesture === "reconnect-edge" && reconnectEdgeId && reconnectEnd) {
|
|
526
|
+
updateReconnect(worldFromEvent(e));
|
|
527
|
+
} else if (activeGesture === "edge-midpoint" && midpointEdgeId) {
|
|
528
|
+
updateEdgeMidpoint(worldFromEvent(e));
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
const updateEdgeMidpoint = (world) => {
|
|
532
|
+
if (!midpointEdgeId) return;
|
|
533
|
+
const geom = store.getEdgeGeometry(midpointEdgeId);
|
|
534
|
+
if (!geom) return;
|
|
535
|
+
const { c1, c2 } = midpointToCubicControls(geom.source, world, geom.target);
|
|
536
|
+
store.updateEdge(midpointEdgeId, { control: [c1, c2] });
|
|
537
|
+
};
|
|
538
|
+
const updateReconnect = (world) => {
|
|
539
|
+
if (!reconnectEdgeId || !reconnectEnd) return;
|
|
540
|
+
const edge = store.getEdge(reconnectEdgeId);
|
|
541
|
+
if (!edge) return;
|
|
542
|
+
const camera = store.getCamera();
|
|
543
|
+
const newEnd = followingEnd(world, camera.z);
|
|
544
|
+
const draftSource = reconnectEnd === "source" ? newEnd.end : edge.source;
|
|
545
|
+
const draftTarget = reconnectEnd === "target" ? newEnd.end : edge.target;
|
|
546
|
+
store.setInteractionState({
|
|
547
|
+
mode: "reconnecting-edge",
|
|
548
|
+
draftEdge: {
|
|
549
|
+
source: draftSource,
|
|
550
|
+
target: draftTarget,
|
|
551
|
+
reconnectingId: reconnectEdgeId,
|
|
552
|
+
snapTargetNodeId: newEnd.nodeId
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
};
|
|
556
|
+
const followingEnd = (world, cameraZ) => {
|
|
557
|
+
const hit = hitTestAny(store, world, cameraZ);
|
|
558
|
+
if (hit?.kind === "body" && "nodeId" in hit) {
|
|
559
|
+
const node = store.getNode(hit.nodeId);
|
|
560
|
+
if (node) {
|
|
561
|
+
const local = worldToNodeLocal(world, node);
|
|
562
|
+
const clamped = {
|
|
563
|
+
x: Math.max(0, Math.min(node.w, local.x)),
|
|
564
|
+
y: Math.max(0, Math.min(node.h, local.y))
|
|
565
|
+
};
|
|
566
|
+
return { end: { nodeId: node.id, localOffset: clamped }, nodeId: node.id };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return { end: { worldPoint: world }, nodeId: null };
|
|
570
|
+
};
|
|
571
|
+
const commitReconnect = (e) => {
|
|
572
|
+
if (!reconnectEdgeId || !reconnectEnd) return;
|
|
573
|
+
const world = worldFromEvent(e);
|
|
574
|
+
const hit = hitTestAny(store, world, store.getCamera().z);
|
|
575
|
+
let newEnd;
|
|
576
|
+
if (hit?.kind === "body" && "nodeId" in hit) {
|
|
577
|
+
const node = store.getNode(hit.nodeId);
|
|
578
|
+
if (node) {
|
|
579
|
+
const localOffset = projectToNodeBoundary(world, node);
|
|
580
|
+
newEnd = { nodeId: node.id, localOffset };
|
|
581
|
+
} else {
|
|
582
|
+
newEnd = { worldPoint: world };
|
|
583
|
+
}
|
|
584
|
+
} else {
|
|
585
|
+
newEnd = { worldPoint: world };
|
|
586
|
+
}
|
|
587
|
+
store.updateEdge(
|
|
588
|
+
reconnectEdgeId,
|
|
589
|
+
reconnectEnd === "source" ? { source: newEnd } : { target: newEnd }
|
|
590
|
+
);
|
|
591
|
+
store.resetInteractionState();
|
|
592
|
+
};
|
|
593
|
+
const onPointerUp = (e) => {
|
|
594
|
+
if (e.pointerType === "pen") notePenInactive(palm, Date.now());
|
|
595
|
+
clearLongPress();
|
|
596
|
+
if (!pointerDownAt) return;
|
|
597
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
598
|
+
switch (activeGesture) {
|
|
599
|
+
case "drag":
|
|
600
|
+
commitDrag();
|
|
601
|
+
break;
|
|
602
|
+
case "resize":
|
|
603
|
+
commitResize();
|
|
604
|
+
break;
|
|
605
|
+
case "rotate":
|
|
606
|
+
commitRotate();
|
|
607
|
+
break;
|
|
608
|
+
case "marquee":
|
|
609
|
+
commitMarquee();
|
|
610
|
+
break;
|
|
611
|
+
case "reconnect-edge":
|
|
612
|
+
commitReconnect(e);
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
pointerDownAt = null;
|
|
616
|
+
activeGesture = "idle";
|
|
617
|
+
resizeHandle = null;
|
|
618
|
+
dragOriginals = [];
|
|
619
|
+
marqueeStartWorld = null;
|
|
620
|
+
reconnectEdgeId = null;
|
|
621
|
+
midpointEdgeId = null;
|
|
622
|
+
reconnectEnd = null;
|
|
623
|
+
};
|
|
624
|
+
const onKeyDown = (e) => {
|
|
625
|
+
const target = e.target;
|
|
626
|
+
if (target && (target.tagName === "TEXTAREA" || target.tagName === "INPUT" || target.isContentEditable))
|
|
627
|
+
return;
|
|
628
|
+
if (e.key === "Escape") {
|
|
629
|
+
store.setSelection([]);
|
|
630
|
+
store.resetInteractionState();
|
|
631
|
+
}
|
|
632
|
+
if ((e.key === "Delete" || e.key === "Backspace") && store.getSelection().length > 0) {
|
|
633
|
+
const ids = store.getSelection();
|
|
634
|
+
store.batch(() => {
|
|
635
|
+
for (const id of ids) {
|
|
636
|
+
if (store.getNode(id)) store.removeNode(id);
|
|
637
|
+
else if (store.getEdge(id)) store.removeEdge(id);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
store.setSelection([]);
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
el.addEventListener("pointerdown", onPointerDown);
|
|
644
|
+
el.addEventListener("pointermove", onPointerMove);
|
|
645
|
+
el.addEventListener("pointerup", onPointerUp);
|
|
646
|
+
el.addEventListener("pointercancel", onPointerUp);
|
|
647
|
+
window.addEventListener("keydown", onKeyDown);
|
|
648
|
+
return () => {
|
|
649
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
650
|
+
el.removeEventListener("pointermove", onPointerMove);
|
|
651
|
+
el.removeEventListener("pointerup", onPointerUp);
|
|
652
|
+
el.removeEventListener("pointercancel", onPointerUp);
|
|
653
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
654
|
+
};
|
|
655
|
+
}, [ref, store, tool]);
|
|
656
|
+
};
|
|
657
|
+
var computeResizeGeometry = (orig, handle, pointer, modifiers) => {
|
|
658
|
+
let x = orig.x;
|
|
659
|
+
let y = orig.y;
|
|
660
|
+
let w = orig.w;
|
|
661
|
+
let h = orig.h;
|
|
662
|
+
const rightFixed = handle === "nw" || handle === "w" || handle === "sw";
|
|
663
|
+
const leftFixed = handle === "ne" || handle === "e" || handle === "se";
|
|
664
|
+
const bottomFixed = handle === "nw" || handle === "n" || handle === "ne";
|
|
665
|
+
const topFixed = handle === "sw" || handle === "s" || handle === "se";
|
|
666
|
+
if (rightFixed) {
|
|
667
|
+
const right = orig.x + orig.w;
|
|
668
|
+
w = Math.max(1, right - pointer.x);
|
|
669
|
+
x = right - w;
|
|
670
|
+
} else if (leftFixed) {
|
|
671
|
+
w = Math.max(1, pointer.x - orig.x);
|
|
672
|
+
}
|
|
673
|
+
if (bottomFixed) {
|
|
674
|
+
const bottom = orig.y + orig.h;
|
|
675
|
+
h = Math.max(1, bottom - pointer.y);
|
|
676
|
+
y = bottom - h;
|
|
677
|
+
} else if (topFixed) {
|
|
678
|
+
h = Math.max(1, pointer.y - orig.y);
|
|
679
|
+
}
|
|
680
|
+
if (modifiers.shift) {
|
|
681
|
+
const targetAspect = orig.w / orig.h;
|
|
682
|
+
const currentAspect = w / h;
|
|
683
|
+
if (currentAspect > targetAspect) {
|
|
684
|
+
w = h * targetAspect;
|
|
685
|
+
if (rightFixed) x = orig.x + orig.w - w;
|
|
686
|
+
} else {
|
|
687
|
+
h = w / targetAspect;
|
|
688
|
+
if (bottomFixed) y = orig.y + orig.h - h;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (modifiers.alt) {
|
|
692
|
+
const cx = orig.x + orig.w / 2;
|
|
693
|
+
const cy = orig.y + orig.h / 2;
|
|
694
|
+
if (handle !== "n" && handle !== "s") {
|
|
695
|
+
w = Math.max(1, w * 2 - orig.w + (orig.w - Math.abs(orig.w - w * 0)));
|
|
696
|
+
const newW = Math.abs(pointer.x - cx) * 2;
|
|
697
|
+
w = Math.max(1, newW);
|
|
698
|
+
x = cx - w / 2;
|
|
699
|
+
}
|
|
700
|
+
if (handle !== "e" && handle !== "w") {
|
|
701
|
+
const newH = Math.abs(pointer.y - cy) * 2;
|
|
702
|
+
h = Math.max(1, newH);
|
|
703
|
+
y = cy - h / 2;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return { x, y, w, h };
|
|
707
|
+
};
|
|
708
|
+
var useOverlayHost = () => {
|
|
709
|
+
const [mountedIds, setMountedIds] = useState([]);
|
|
710
|
+
useEffect(() => {
|
|
711
|
+
}, []);
|
|
712
|
+
return { mountedIds, setMountedIds };
|
|
713
|
+
};
|
|
714
|
+
var usePanZoom = (ref, store) => {
|
|
715
|
+
useEffect(() => {
|
|
716
|
+
const el = ref.current;
|
|
717
|
+
if (!el) return;
|
|
718
|
+
let panning = false;
|
|
719
|
+
let panActivatedBySpace = false;
|
|
720
|
+
let lastX = 0;
|
|
721
|
+
let lastY = 0;
|
|
722
|
+
let pendingDx = 0;
|
|
723
|
+
let pendingDy = 0;
|
|
724
|
+
let pendingZoomFactor = 1;
|
|
725
|
+
let pendingZoomAnchor = null;
|
|
726
|
+
let scheduled = false;
|
|
727
|
+
let rafId = 0;
|
|
728
|
+
const MOTION_RESET_MS = 150;
|
|
729
|
+
let motionEndDeadline = 0;
|
|
730
|
+
let motionEndPolling = false;
|
|
731
|
+
const setMotion = (mode) => {
|
|
732
|
+
const current = store.getInteractionState().mode;
|
|
733
|
+
if (mode === null) {
|
|
734
|
+
if (current === "panning" || current === "zooming") {
|
|
735
|
+
store.setInteractionState({ mode: "idle" });
|
|
736
|
+
}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (current !== "idle" && current !== "panning" && current !== "zooming") return;
|
|
740
|
+
if (current !== mode) store.setInteractionState({ mode });
|
|
741
|
+
};
|
|
742
|
+
const pollMotionEnd = () => {
|
|
743
|
+
if (performance.now() >= motionEndDeadline) {
|
|
744
|
+
motionEndPolling = false;
|
|
745
|
+
setMotion(null);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
requestAnimationFrame(pollMotionEnd);
|
|
749
|
+
};
|
|
750
|
+
const pulseMotion = (mode) => {
|
|
751
|
+
motionEndDeadline = performance.now() + MOTION_RESET_MS;
|
|
752
|
+
const current = store.getInteractionState().mode;
|
|
753
|
+
if (current !== mode) setMotion(mode);
|
|
754
|
+
if (!motionEndPolling) {
|
|
755
|
+
motionEndPolling = true;
|
|
756
|
+
requestAnimationFrame(pollMotionEnd);
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
const activeTouches = /* @__PURE__ */ new Map();
|
|
760
|
+
let lastPinchDistance = 0;
|
|
761
|
+
let lastPinchMidpoint = null;
|
|
762
|
+
const palm = createPalmRejectionState();
|
|
763
|
+
const flushPending = () => {
|
|
764
|
+
scheduled = false;
|
|
765
|
+
rafId = 0;
|
|
766
|
+
if (pendingZoomFactor !== 1 && pendingZoomAnchor) {
|
|
767
|
+
const camera = store.getCamera();
|
|
768
|
+
store.setCamera(
|
|
769
|
+
zoomAtScreenPoint(camera, clampZoom(camera.z * pendingZoomFactor), pendingZoomAnchor)
|
|
770
|
+
);
|
|
771
|
+
pendingZoomFactor = 1;
|
|
772
|
+
pendingZoomAnchor = null;
|
|
773
|
+
}
|
|
774
|
+
if (pendingDx !== 0 || pendingDy !== 0) {
|
|
775
|
+
const camera = store.getCamera();
|
|
776
|
+
store.setCamera(panByScreen(camera, { x: pendingDx, y: pendingDy }));
|
|
777
|
+
pendingDx = 0;
|
|
778
|
+
pendingDy = 0;
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
const schedule = () => {
|
|
782
|
+
if (scheduled) return;
|
|
783
|
+
scheduled = true;
|
|
784
|
+
rafId = requestAnimationFrame(flushPending);
|
|
785
|
+
};
|
|
786
|
+
const isEditing = () => store.getInteractionState().mode === "editing";
|
|
787
|
+
const screenFromClient = (clientX, clientY) => {
|
|
788
|
+
const rect = el.getBoundingClientRect();
|
|
789
|
+
return { x: clientX - rect.left, y: clientY - rect.top };
|
|
790
|
+
};
|
|
791
|
+
const updatePointerInfo = (e) => {
|
|
792
|
+
const { x: sx, y: sy } = screenFromClient(e.clientX, e.clientY);
|
|
793
|
+
const camera = store.getCamera();
|
|
794
|
+
store.setInteractionState({
|
|
795
|
+
pointer: {
|
|
796
|
+
worldX: sx / camera.z + camera.x,
|
|
797
|
+
worldY: sy / camera.z + camera.y,
|
|
798
|
+
screenX: sx,
|
|
799
|
+
screenY: sy,
|
|
800
|
+
pointerType: e.pointerType,
|
|
801
|
+
pressure: e.pointerType === "pen" ? e.pressure : void 0
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
};
|
|
805
|
+
const onWheel = (e) => {
|
|
806
|
+
if (isEditing()) return;
|
|
807
|
+
e.preventDefault();
|
|
808
|
+
if (e.ctrlKey || e.metaKey) {
|
|
809
|
+
const factor = Math.exp(-e.deltaY * 0.01);
|
|
810
|
+
pendingZoomFactor *= factor;
|
|
811
|
+
pendingZoomAnchor = screenFromClient(e.clientX, e.clientY);
|
|
812
|
+
pulseMotion("zooming");
|
|
813
|
+
} else {
|
|
814
|
+
pendingDx += -e.deltaX;
|
|
815
|
+
pendingDy += -e.deltaY;
|
|
816
|
+
pulseMotion("panning");
|
|
817
|
+
}
|
|
818
|
+
schedule();
|
|
819
|
+
};
|
|
820
|
+
const resetPinchTracking = () => {
|
|
821
|
+
lastPinchDistance = 0;
|
|
822
|
+
lastPinchMidpoint = null;
|
|
823
|
+
};
|
|
824
|
+
const recomputePinchSeed = () => {
|
|
825
|
+
const pts = [...activeTouches.values()];
|
|
826
|
+
if (pts.length !== 2) {
|
|
827
|
+
resetPinchTracking();
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const [a, b] = pts;
|
|
831
|
+
lastPinchDistance = Math.hypot(a.x - b.x, a.y - b.y);
|
|
832
|
+
lastPinchMidpoint = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
833
|
+
};
|
|
834
|
+
const onPointerDown = (e) => {
|
|
835
|
+
if (isEditing()) return;
|
|
836
|
+
if (e.pointerType === "pen") {
|
|
837
|
+
notePenActive(palm);
|
|
838
|
+
} else if (e.pointerType === "touch" && shouldRejectTouch(palm, Date.now())) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (e.pointerType === "touch") {
|
|
842
|
+
const pt = screenFromClient(e.clientX, e.clientY);
|
|
843
|
+
activeTouches.set(e.pointerId, { id: e.pointerId, x: pt.x, y: pt.y });
|
|
844
|
+
if (activeTouches.size === 2) {
|
|
845
|
+
recomputePinchSeed();
|
|
846
|
+
el.setPointerCapture(e.pointerId);
|
|
847
|
+
setMotion("zooming");
|
|
848
|
+
e.preventDefault();
|
|
849
|
+
}
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (e.button === 1 || e.button === 0 && panActivatedBySpace) {
|
|
853
|
+
panning = true;
|
|
854
|
+
lastX = e.clientX;
|
|
855
|
+
lastY = e.clientY;
|
|
856
|
+
el.setPointerCapture(e.pointerId);
|
|
857
|
+
setMotion("panning");
|
|
858
|
+
e.preventDefault();
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
const onPointerMove = (e) => {
|
|
862
|
+
updatePointerInfo(e);
|
|
863
|
+
if (e.pointerType === "touch" && activeTouches.has(e.pointerId)) {
|
|
864
|
+
const pt = screenFromClient(e.clientX, e.clientY);
|
|
865
|
+
activeTouches.set(e.pointerId, { id: e.pointerId, x: pt.x, y: pt.y });
|
|
866
|
+
if (activeTouches.size === 2 && lastPinchMidpoint && lastPinchDistance > 0) {
|
|
867
|
+
const pts = [...activeTouches.values()];
|
|
868
|
+
const [a, b] = pts;
|
|
869
|
+
const dist = Math.hypot(a.x - b.x, a.y - b.y);
|
|
870
|
+
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
871
|
+
const factor = dist / lastPinchDistance;
|
|
872
|
+
if (Number.isFinite(factor) && factor > 0) {
|
|
873
|
+
pendingZoomFactor *= factor;
|
|
874
|
+
pendingZoomAnchor = mid;
|
|
875
|
+
}
|
|
876
|
+
pendingDx += mid.x - lastPinchMidpoint.x;
|
|
877
|
+
pendingDy += mid.y - lastPinchMidpoint.y;
|
|
878
|
+
lastPinchDistance = dist;
|
|
879
|
+
lastPinchMidpoint = mid;
|
|
880
|
+
schedule();
|
|
881
|
+
}
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (!panning) return;
|
|
885
|
+
const dx = e.clientX - lastX;
|
|
886
|
+
const dy = e.clientY - lastY;
|
|
887
|
+
lastX = e.clientX;
|
|
888
|
+
lastY = e.clientY;
|
|
889
|
+
pendingDx += dx;
|
|
890
|
+
pendingDy += dy;
|
|
891
|
+
schedule();
|
|
892
|
+
};
|
|
893
|
+
const onPointerUp = (e) => {
|
|
894
|
+
if (e.pointerType === "pen") notePenInactive(palm, Date.now());
|
|
895
|
+
if (e.pointerType === "touch" && activeTouches.has(e.pointerId)) {
|
|
896
|
+
activeTouches.delete(e.pointerId);
|
|
897
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
898
|
+
if (activeTouches.size === 2) recomputePinchSeed();
|
|
899
|
+
else {
|
|
900
|
+
resetPinchTracking();
|
|
901
|
+
setMotion(null);
|
|
902
|
+
}
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (!panning) return;
|
|
906
|
+
panning = false;
|
|
907
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
908
|
+
setMotion(null);
|
|
909
|
+
};
|
|
910
|
+
const onPointerCancel = (e) => {
|
|
911
|
+
if (e.pointerType === "touch") {
|
|
912
|
+
activeTouches.delete(e.pointerId);
|
|
913
|
+
if (activeTouches.size < 2) {
|
|
914
|
+
resetPinchTracking();
|
|
915
|
+
setMotion(null);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (panning) {
|
|
919
|
+
panning = false;
|
|
920
|
+
setMotion(null);
|
|
921
|
+
}
|
|
922
|
+
if (e.pointerType === "pen") notePenInactive(palm, Date.now());
|
|
923
|
+
};
|
|
924
|
+
const onKeyDown = (e) => {
|
|
925
|
+
if (e.code === "Space") panActivatedBySpace = true;
|
|
926
|
+
};
|
|
927
|
+
const onKeyUp = (e) => {
|
|
928
|
+
if (e.code === "Space") panActivatedBySpace = false;
|
|
929
|
+
};
|
|
930
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
931
|
+
el.addEventListener("pointerdown", onPointerDown);
|
|
932
|
+
el.addEventListener("pointermove", onPointerMove);
|
|
933
|
+
el.addEventListener("pointerup", onPointerUp);
|
|
934
|
+
el.addEventListener("pointercancel", onPointerCancel);
|
|
935
|
+
window.addEventListener("keydown", onKeyDown);
|
|
936
|
+
window.addEventListener("keyup", onKeyUp);
|
|
937
|
+
return () => {
|
|
938
|
+
el.removeEventListener("wheel", onWheel);
|
|
939
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
940
|
+
el.removeEventListener("pointermove", onPointerMove);
|
|
941
|
+
el.removeEventListener("pointerup", onPointerUp);
|
|
942
|
+
el.removeEventListener("pointercancel", onPointerCancel);
|
|
943
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
944
|
+
window.removeEventListener("keyup", onKeyUp);
|
|
945
|
+
if (rafId !== 0) cancelAnimationFrame(rafId);
|
|
946
|
+
};
|
|
947
|
+
}, [ref, store]);
|
|
948
|
+
};
|
|
949
|
+
var useResizeObserver = (ref) => {
|
|
950
|
+
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
951
|
+
useEffect(() => {
|
|
952
|
+
const el = ref.current;
|
|
953
|
+
if (!el) return;
|
|
954
|
+
const ro = new ResizeObserver((entries) => {
|
|
955
|
+
const entry = entries[0];
|
|
956
|
+
if (!entry) return;
|
|
957
|
+
const { width, height } = entry.contentRect;
|
|
958
|
+
setSize({ w: Math.round(width), h: Math.round(height) });
|
|
959
|
+
});
|
|
960
|
+
ro.observe(el);
|
|
961
|
+
return () => ro.disconnect();
|
|
962
|
+
}, [ref]);
|
|
963
|
+
return size;
|
|
964
|
+
};
|
|
965
|
+
function Canvas(props) {
|
|
966
|
+
if (props.store) {
|
|
967
|
+
return /* @__PURE__ */ jsx(CanvasProvider, { store: props.store, children: /* @__PURE__ */ jsx(CanvasSurface, { ...props }) });
|
|
968
|
+
}
|
|
969
|
+
return /* @__PURE__ */ jsx(CanvasSurface, { ...props });
|
|
970
|
+
}
|
|
971
|
+
var DRAG_CREATE_MIN_SIZE_PX = 5;
|
|
972
|
+
function CanvasSurface({
|
|
973
|
+
tool,
|
|
974
|
+
theme,
|
|
975
|
+
editorAdapter,
|
|
976
|
+
onRenderer,
|
|
977
|
+
onClick,
|
|
978
|
+
onDoubleClick,
|
|
979
|
+
onCreateDrag,
|
|
980
|
+
arrowDefaults,
|
|
981
|
+
background,
|
|
982
|
+
renderCustomNodeView,
|
|
983
|
+
children
|
|
984
|
+
}) {
|
|
985
|
+
const store = useCanvasStore();
|
|
986
|
+
const wrapRef = useRef(null);
|
|
987
|
+
const staticRef = useRef(null);
|
|
988
|
+
const interactiveRef = useRef(null);
|
|
989
|
+
const overlayRef = useRef(null);
|
|
990
|
+
const rendererRef = useRef(null);
|
|
991
|
+
const toolRef = useRef(tool);
|
|
992
|
+
toolRef.current = tool;
|
|
993
|
+
const { w, h } = useResizeObserver(wrapRef);
|
|
994
|
+
usePanZoom(wrapRef, store);
|
|
995
|
+
useInteractionGesture(wrapRef, store, tool);
|
|
996
|
+
useArrowTool(wrapRef, store, tool === "arrow", arrowDefaults);
|
|
997
|
+
const { mountedIds, setMountedIds } = useOverlayHost();
|
|
998
|
+
const [camera, setCamera] = useState(() => store.getCamera());
|
|
999
|
+
useEffect(() => store.subscribe("camera", (c) => setCamera({ ...c })), [store]);
|
|
1000
|
+
useEffect(() => {
|
|
1001
|
+
if (!staticRef.current || !interactiveRef.current || w === 0 || h === 0) return;
|
|
1002
|
+
if (rendererRef.current) {
|
|
1003
|
+
rendererRef.current.setSize(w, h);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const r = createRenderer({
|
|
1007
|
+
store,
|
|
1008
|
+
staticCanvas: staticRef.current,
|
|
1009
|
+
interactiveCanvas: interactiveRef.current,
|
|
1010
|
+
theme,
|
|
1011
|
+
width: w,
|
|
1012
|
+
height: h,
|
|
1013
|
+
background,
|
|
1014
|
+
onOverlayChange: (ids) => setMountedIds(ids)
|
|
1015
|
+
});
|
|
1016
|
+
r.start();
|
|
1017
|
+
rendererRef.current = r;
|
|
1018
|
+
onRenderer?.(r);
|
|
1019
|
+
return () => {
|
|
1020
|
+
r.dispose();
|
|
1021
|
+
rendererRef.current = null;
|
|
1022
|
+
};
|
|
1023
|
+
}, [store, theme, w, h, onRenderer, setMountedIds]);
|
|
1024
|
+
useEffect(() => {
|
|
1025
|
+
rendererRef.current?.setBackground(background);
|
|
1026
|
+
}, [background]);
|
|
1027
|
+
useEffect(() => {
|
|
1028
|
+
const el = wrapRef.current;
|
|
1029
|
+
if (!el) return;
|
|
1030
|
+
const dispatch = (e, cb) => {
|
|
1031
|
+
if (!cb) return;
|
|
1032
|
+
const rect = el.getBoundingClientRect();
|
|
1033
|
+
const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
1034
|
+
const world = screenToWorld(screen, store.getCamera());
|
|
1035
|
+
cb({ screen, world, tool: toolRef.current, native: e });
|
|
1036
|
+
};
|
|
1037
|
+
const onClickHandler = (e) => dispatch(e, onClick);
|
|
1038
|
+
const onDoubleClickHandler = (e) => {
|
|
1039
|
+
if (toolRef.current === "select") {
|
|
1040
|
+
const rect = el.getBoundingClientRect();
|
|
1041
|
+
const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
1042
|
+
const camera2 = store.getCamera();
|
|
1043
|
+
const world = screenToWorld(screen, camera2);
|
|
1044
|
+
const hit = hitTestAny(store, world, camera2.z);
|
|
1045
|
+
if (hit && hit.kind === "body" && "nodeId" in hit) {
|
|
1046
|
+
store.beginEdit(hit.nodeId);
|
|
1047
|
+
} else if (hit && hit.kind === "body" && "edgeId" in hit) {
|
|
1048
|
+
store.beginEdit(hit.edgeId);
|
|
1049
|
+
} else if (hit && hit.kind === "label") {
|
|
1050
|
+
store.beginEdit(hit.edgeId);
|
|
1051
|
+
} else if (hit && hit.kind === "midpoint-handle") {
|
|
1052
|
+
store.updateEdge(hit.edgeId, { control: void 0 });
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
dispatch(e, onDoubleClick);
|
|
1056
|
+
};
|
|
1057
|
+
el.addEventListener("click", onClickHandler);
|
|
1058
|
+
el.addEventListener("dblclick", onDoubleClickHandler);
|
|
1059
|
+
return () => {
|
|
1060
|
+
el.removeEventListener("click", onClickHandler);
|
|
1061
|
+
el.removeEventListener("dblclick", onDoubleClickHandler);
|
|
1062
|
+
};
|
|
1063
|
+
}, [store, onClick, onDoubleClick]);
|
|
1064
|
+
useEffect(() => {
|
|
1065
|
+
const el = wrapRef.current;
|
|
1066
|
+
if (!el || !onCreateDrag) return;
|
|
1067
|
+
let startWorld = null;
|
|
1068
|
+
let startScreen = null;
|
|
1069
|
+
let activePointerId = null;
|
|
1070
|
+
let committed = false;
|
|
1071
|
+
const justCommittedRef = { current: false };
|
|
1072
|
+
const screenFromEvent = (e) => {
|
|
1073
|
+
const rect = el.getBoundingClientRect();
|
|
1074
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
1075
|
+
};
|
|
1076
|
+
const worldFromEvent = (e) => screenToWorld(screenFromEvent(e), store.getCamera());
|
|
1077
|
+
const isShapeTool = (t) => t !== "select" && t !== "arrow" && t !== "text";
|
|
1078
|
+
const onPointerDown = (e) => {
|
|
1079
|
+
if (e.button !== 0) return;
|
|
1080
|
+
if (!isShapeTool(toolRef.current)) return;
|
|
1081
|
+
if (store.getInteractionState().mode === "editing") return;
|
|
1082
|
+
const camera2 = store.getCamera();
|
|
1083
|
+
const world = screenToWorld(screenFromEvent(e), camera2);
|
|
1084
|
+
if (hitTestAny(store, world, camera2.z)) return;
|
|
1085
|
+
startWorld = world;
|
|
1086
|
+
startScreen = screenFromEvent(e);
|
|
1087
|
+
activePointerId = e.pointerId;
|
|
1088
|
+
committed = false;
|
|
1089
|
+
el.setPointerCapture(e.pointerId);
|
|
1090
|
+
};
|
|
1091
|
+
const onPointerMove = (e) => {
|
|
1092
|
+
if (startWorld === null || startScreen === null) return;
|
|
1093
|
+
if (e.pointerId !== activePointerId) return;
|
|
1094
|
+
const screen = screenFromEvent(e);
|
|
1095
|
+
const dx = screen.x - startScreen.x;
|
|
1096
|
+
const dy = screen.y - startScreen.y;
|
|
1097
|
+
if (!committed && Math.abs(dx) < DRAG_CREATE_MIN_SIZE_PX && Math.abs(dy) < DRAG_CREATE_MIN_SIZE_PX) {
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
if (!committed) committed = true;
|
|
1101
|
+
const world = worldFromEvent(e);
|
|
1102
|
+
const rect = {
|
|
1103
|
+
x: Math.min(startWorld.x, world.x),
|
|
1104
|
+
y: Math.min(startWorld.y, world.y),
|
|
1105
|
+
w: Math.abs(world.x - startWorld.x),
|
|
1106
|
+
h: Math.abs(world.y - startWorld.y)
|
|
1107
|
+
};
|
|
1108
|
+
store.setInteractionState({
|
|
1109
|
+
mode: "creating-shape",
|
|
1110
|
+
createDraftRect: rect,
|
|
1111
|
+
createTool: toolRef.current
|
|
1112
|
+
});
|
|
1113
|
+
};
|
|
1114
|
+
const onPointerUp = (e) => {
|
|
1115
|
+
if (activePointerId !== e.pointerId) return;
|
|
1116
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
1117
|
+
const wasCommitted = committed;
|
|
1118
|
+
activePointerId = null;
|
|
1119
|
+
if (!wasCommitted || !startWorld) {
|
|
1120
|
+
startWorld = null;
|
|
1121
|
+
startScreen = null;
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const world = worldFromEvent(e);
|
|
1125
|
+
const rect = {
|
|
1126
|
+
x: Math.min(startWorld.x, world.x),
|
|
1127
|
+
y: Math.min(startWorld.y, world.y),
|
|
1128
|
+
w: Math.abs(world.x - startWorld.x),
|
|
1129
|
+
h: Math.abs(world.y - startWorld.y)
|
|
1130
|
+
};
|
|
1131
|
+
startWorld = null;
|
|
1132
|
+
startScreen = null;
|
|
1133
|
+
store.resetInteractionState();
|
|
1134
|
+
justCommittedRef.current = true;
|
|
1135
|
+
setTimeout(() => {
|
|
1136
|
+
justCommittedRef.current = false;
|
|
1137
|
+
}, 0);
|
|
1138
|
+
onCreateDrag({ rect, tool: toolRef.current, native: e });
|
|
1139
|
+
};
|
|
1140
|
+
const onClickCapture = (e) => {
|
|
1141
|
+
if (justCommittedRef.current) {
|
|
1142
|
+
e.stopPropagation();
|
|
1143
|
+
e.preventDefault();
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
el.addEventListener("pointerdown", onPointerDown);
|
|
1147
|
+
el.addEventListener("pointermove", onPointerMove);
|
|
1148
|
+
el.addEventListener("pointerup", onPointerUp);
|
|
1149
|
+
el.addEventListener("pointercancel", onPointerUp);
|
|
1150
|
+
el.addEventListener("click", onClickCapture, true);
|
|
1151
|
+
return () => {
|
|
1152
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
1153
|
+
el.removeEventListener("pointermove", onPointerMove);
|
|
1154
|
+
el.removeEventListener("pointerup", onPointerUp);
|
|
1155
|
+
el.removeEventListener("pointercancel", onPointerUp);
|
|
1156
|
+
el.removeEventListener("click", onClickCapture, true);
|
|
1157
|
+
};
|
|
1158
|
+
}, [store, onCreateDrag]);
|
|
1159
|
+
useEffect(() => {
|
|
1160
|
+
const onKey = (e) => {
|
|
1161
|
+
const target = e.target;
|
|
1162
|
+
if (target && (target.tagName === "TEXTAREA" || target.tagName === "INPUT")) return;
|
|
1163
|
+
const meta = e.metaKey || e.ctrlKey;
|
|
1164
|
+
if (!meta) return;
|
|
1165
|
+
if (e.key === "c" || e.key === "C") {
|
|
1166
|
+
e.preventDefault();
|
|
1167
|
+
void copy(store);
|
|
1168
|
+
} else if (e.key === "x" || e.key === "X") {
|
|
1169
|
+
e.preventDefault();
|
|
1170
|
+
void cut(store);
|
|
1171
|
+
} else if (e.key === "v" || e.key === "V") {
|
|
1172
|
+
e.preventDefault();
|
|
1173
|
+
void paste(store);
|
|
1174
|
+
} else if (e.key === "]") {
|
|
1175
|
+
const selection = store.getSelection();
|
|
1176
|
+
if (selection.length === 0) return;
|
|
1177
|
+
e.preventDefault();
|
|
1178
|
+
if (e.shiftKey) store.bringToFront(selection);
|
|
1179
|
+
else store.bringForward(selection);
|
|
1180
|
+
} else if (e.key === "[") {
|
|
1181
|
+
const selection = store.getSelection();
|
|
1182
|
+
if (selection.length === 0) return;
|
|
1183
|
+
e.preventDefault();
|
|
1184
|
+
if (e.shiftKey) store.sendToBack(selection);
|
|
1185
|
+
else store.sendBackward(selection);
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
window.addEventListener("keydown", onKey);
|
|
1189
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
1190
|
+
}, [store]);
|
|
1191
|
+
const overlayTransform = `translate(${-camera.x * camera.z}px, ${-camera.y * camera.z}px) scale(${camera.z})`;
|
|
1192
|
+
return /* @__PURE__ */ jsxs(
|
|
1193
|
+
"div",
|
|
1194
|
+
{
|
|
1195
|
+
ref: wrapRef,
|
|
1196
|
+
"data-canvas-host": "",
|
|
1197
|
+
style: {
|
|
1198
|
+
position: "absolute",
|
|
1199
|
+
inset: 0,
|
|
1200
|
+
background: "#f8fafc",
|
|
1201
|
+
overflow: "hidden",
|
|
1202
|
+
cursor: tool === "select" ? "default" : "crosshair",
|
|
1203
|
+
touchAction: "none"
|
|
1204
|
+
},
|
|
1205
|
+
children: [
|
|
1206
|
+
/* @__PURE__ */ jsx("canvas", { ref: staticRef, style: { position: "absolute", inset: 0, pointerEvents: "none" } }),
|
|
1207
|
+
/* @__PURE__ */ jsx(
|
|
1208
|
+
"div",
|
|
1209
|
+
{
|
|
1210
|
+
ref: overlayRef,
|
|
1211
|
+
style: {
|
|
1212
|
+
position: "absolute",
|
|
1213
|
+
inset: 0,
|
|
1214
|
+
transformOrigin: "0 0",
|
|
1215
|
+
transform: overlayTransform,
|
|
1216
|
+
pointerEvents: "none"
|
|
1217
|
+
},
|
|
1218
|
+
children: renderCustomNodeView ? mountedIds.map((id) => /* @__PURE__ */ jsx(OverlayItem, { id, children: renderCustomNodeView(id) }, id)) : null
|
|
1219
|
+
}
|
|
1220
|
+
),
|
|
1221
|
+
/* @__PURE__ */ jsx(
|
|
1222
|
+
"canvas",
|
|
1223
|
+
{
|
|
1224
|
+
ref: interactiveRef,
|
|
1225
|
+
style: { position: "absolute", inset: 0, pointerEvents: "none" }
|
|
1226
|
+
}
|
|
1227
|
+
),
|
|
1228
|
+
/* @__PURE__ */ jsx(EditorMount, { store, factory: editorAdapter }),
|
|
1229
|
+
children
|
|
1230
|
+
]
|
|
1231
|
+
}
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
function OverlayItem({ id, children }) {
|
|
1235
|
+
const store = useCanvasStore();
|
|
1236
|
+
const [node, setNode] = useState(() => store.getNode(id));
|
|
1237
|
+
useEffect(() => {
|
|
1238
|
+
return store.subscribe("change", () => setNode(store.getNode(id)));
|
|
1239
|
+
}, [id, store]);
|
|
1240
|
+
if (!node) return null;
|
|
1241
|
+
return /* @__PURE__ */ jsx(
|
|
1242
|
+
"div",
|
|
1243
|
+
{
|
|
1244
|
+
style: {
|
|
1245
|
+
position: "absolute",
|
|
1246
|
+
left: node.x,
|
|
1247
|
+
top: node.y,
|
|
1248
|
+
width: node.w,
|
|
1249
|
+
height: node.h,
|
|
1250
|
+
transform: node.angle !== 0 ? `rotate(${node.angle}rad)` : void 0,
|
|
1251
|
+
transformOrigin: "center"
|
|
1252
|
+
},
|
|
1253
|
+
children
|
|
1254
|
+
}
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
var POSITION_STYLES = {
|
|
1258
|
+
"top-left": { top: 12, left: 12 },
|
|
1259
|
+
"top-right": { top: 12, right: 12 },
|
|
1260
|
+
"bottom-left": { bottom: 12, left: 12 },
|
|
1261
|
+
"bottom-right": { bottom: 12, right: 12 }
|
|
1262
|
+
};
|
|
1263
|
+
function Minimap({
|
|
1264
|
+
width = 200,
|
|
1265
|
+
height = 150,
|
|
1266
|
+
maxNodes = DEFAULT_MINIMAP_MAX_NODES,
|
|
1267
|
+
position = "bottom-right",
|
|
1268
|
+
style,
|
|
1269
|
+
viewportColor = "#3b82f6",
|
|
1270
|
+
backgroundColor = "#ffffff",
|
|
1271
|
+
borderColor = "#cbd5e1",
|
|
1272
|
+
defaultNodeColor = "#94a3b8"
|
|
1273
|
+
}) {
|
|
1274
|
+
const store = useCanvasStore();
|
|
1275
|
+
const containerRef = useRef(null);
|
|
1276
|
+
const canvasRef = useRef(null);
|
|
1277
|
+
const cacheRef = useRef(null);
|
|
1278
|
+
const cachedBoundsRef = useRef(null);
|
|
1279
|
+
const dirtyRef = useRef(true);
|
|
1280
|
+
const rafRef = useRef(0);
|
|
1281
|
+
const [overCap, setOverCap] = useState(false);
|
|
1282
|
+
useEffect(() => {
|
|
1283
|
+
const c = document.createElement("canvas");
|
|
1284
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1285
|
+
c.width = Math.ceil(width * dpr);
|
|
1286
|
+
c.height = Math.ceil(height * dpr);
|
|
1287
|
+
cacheRef.current = c;
|
|
1288
|
+
dirtyRef.current = true;
|
|
1289
|
+
}, [width, height]);
|
|
1290
|
+
const schedule = () => {
|
|
1291
|
+
if (rafRef.current !== 0) return;
|
|
1292
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
1293
|
+
rafRef.current = 0;
|
|
1294
|
+
repaint();
|
|
1295
|
+
});
|
|
1296
|
+
};
|
|
1297
|
+
const repaint = () => {
|
|
1298
|
+
const canvas = canvasRef.current;
|
|
1299
|
+
if (!canvas) return;
|
|
1300
|
+
const ctx = canvas.getContext("2d");
|
|
1301
|
+
if (!ctx) return;
|
|
1302
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1303
|
+
if (dirtyRef.current) {
|
|
1304
|
+
const cache = cacheRef.current;
|
|
1305
|
+
if (cache) {
|
|
1306
|
+
const cctx = cache.getContext("2d");
|
|
1307
|
+
if (cctx) {
|
|
1308
|
+
cctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1309
|
+
cctx.clearRect(0, 0, width, height);
|
|
1310
|
+
cctx.fillStyle = backgroundColor;
|
|
1311
|
+
cctx.fillRect(0, 0, width, height);
|
|
1312
|
+
const ok = renderMinimapContent(cctx, store, width, height, {
|
|
1313
|
+
maxNodes,
|
|
1314
|
+
defaultNodeColor
|
|
1315
|
+
});
|
|
1316
|
+
cachedBoundsRef.current = ok ? sceneBounds(store) : null;
|
|
1317
|
+
setOverCap(!ok && store.getNodeCount() > maxNodes);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
dirtyRef.current = false;
|
|
1321
|
+
}
|
|
1322
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1323
|
+
ctx.clearRect(0, 0, width, height);
|
|
1324
|
+
if (cacheRef.current) {
|
|
1325
|
+
ctx.drawImage(cacheRef.current, 0, 0, width, height);
|
|
1326
|
+
} else {
|
|
1327
|
+
ctx.fillStyle = backgroundColor;
|
|
1328
|
+
ctx.fillRect(0, 0, width, height);
|
|
1329
|
+
}
|
|
1330
|
+
const bounds = cachedBoundsRef.current;
|
|
1331
|
+
if (bounds && bounds.w > 0 && bounds.h > 0) {
|
|
1332
|
+
const camera = store.getCamera();
|
|
1333
|
+
const wrap = containerRef.current?.closest("[data-canvas-host]");
|
|
1334
|
+
const screenW = wrap?.clientWidth ?? window.innerWidth;
|
|
1335
|
+
const screenH = wrap?.clientHeight ?? window.innerHeight;
|
|
1336
|
+
drawMinimapViewport(
|
|
1337
|
+
ctx,
|
|
1338
|
+
worldViewportFromCamera(camera, screenW, screenH),
|
|
1339
|
+
bounds,
|
|
1340
|
+
width,
|
|
1341
|
+
height,
|
|
1342
|
+
viewportColor
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
useEffect(() => {
|
|
1347
|
+
const onChange = () => {
|
|
1348
|
+
dirtyRef.current = true;
|
|
1349
|
+
schedule();
|
|
1350
|
+
};
|
|
1351
|
+
const onCamera = () => {
|
|
1352
|
+
schedule();
|
|
1353
|
+
};
|
|
1354
|
+
const unsubChange = store.subscribe("change", onChange);
|
|
1355
|
+
const unsubCamera = store.subscribe("camera", onCamera);
|
|
1356
|
+
schedule();
|
|
1357
|
+
return () => {
|
|
1358
|
+
unsubChange();
|
|
1359
|
+
unsubCamera();
|
|
1360
|
+
if (rafRef.current !== 0) {
|
|
1361
|
+
cancelAnimationFrame(rafRef.current);
|
|
1362
|
+
rafRef.current = 0;
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
}, [store]);
|
|
1366
|
+
useEffect(() => {
|
|
1367
|
+
const canvas = canvasRef.current;
|
|
1368
|
+
if (!canvas) return;
|
|
1369
|
+
let dragging = false;
|
|
1370
|
+
const move = (e) => {
|
|
1371
|
+
const rect = canvas.getBoundingClientRect();
|
|
1372
|
+
const worldCenter = minimapScreenToWorld(
|
|
1373
|
+
store,
|
|
1374
|
+
e.clientX - rect.left,
|
|
1375
|
+
e.clientY - rect.top,
|
|
1376
|
+
width,
|
|
1377
|
+
height
|
|
1378
|
+
);
|
|
1379
|
+
if (!worldCenter) return;
|
|
1380
|
+
const wrap = containerRef.current?.closest("[data-canvas-host]");
|
|
1381
|
+
const screenW = wrap?.clientWidth ?? window.innerWidth;
|
|
1382
|
+
const screenH = wrap?.clientHeight ?? window.innerHeight;
|
|
1383
|
+
const camera = store.getCamera();
|
|
1384
|
+
store.setCamera({
|
|
1385
|
+
x: worldCenter.x - screenW / camera.z / 2,
|
|
1386
|
+
y: worldCenter.y - screenH / camera.z / 2
|
|
1387
|
+
});
|
|
1388
|
+
};
|
|
1389
|
+
const down = (e) => {
|
|
1390
|
+
if (e.button !== 0) return;
|
|
1391
|
+
dragging = true;
|
|
1392
|
+
canvas.setPointerCapture(e.pointerId);
|
|
1393
|
+
move(e);
|
|
1394
|
+
};
|
|
1395
|
+
const drag = (e) => {
|
|
1396
|
+
if (!dragging) return;
|
|
1397
|
+
move(e);
|
|
1398
|
+
};
|
|
1399
|
+
const up = (e) => {
|
|
1400
|
+
if (!dragging) return;
|
|
1401
|
+
dragging = false;
|
|
1402
|
+
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
|
|
1403
|
+
};
|
|
1404
|
+
canvas.addEventListener("pointerdown", down);
|
|
1405
|
+
canvas.addEventListener("pointermove", drag);
|
|
1406
|
+
canvas.addEventListener("pointerup", up);
|
|
1407
|
+
canvas.addEventListener("pointercancel", up);
|
|
1408
|
+
return () => {
|
|
1409
|
+
canvas.removeEventListener("pointerdown", down);
|
|
1410
|
+
canvas.removeEventListener("pointermove", drag);
|
|
1411
|
+
canvas.removeEventListener("pointerup", up);
|
|
1412
|
+
canvas.removeEventListener("pointercancel", up);
|
|
1413
|
+
};
|
|
1414
|
+
}, [store, width, height]);
|
|
1415
|
+
const containerStyle = style ?? {
|
|
1416
|
+
position: "absolute",
|
|
1417
|
+
...POSITION_STYLES[position],
|
|
1418
|
+
width,
|
|
1419
|
+
height,
|
|
1420
|
+
background: backgroundColor,
|
|
1421
|
+
border: `1px solid ${borderColor}`,
|
|
1422
|
+
borderRadius: 6,
|
|
1423
|
+
boxShadow: "0 1px 3px rgba(0,0,0,.08)",
|
|
1424
|
+
overflow: "hidden",
|
|
1425
|
+
zIndex: 10
|
|
1426
|
+
};
|
|
1427
|
+
return /* @__PURE__ */ jsxs("div", { ref: containerRef, style: containerStyle, children: [
|
|
1428
|
+
/* @__PURE__ */ jsx(
|
|
1429
|
+
"canvas",
|
|
1430
|
+
{
|
|
1431
|
+
ref: canvasRef,
|
|
1432
|
+
width: Math.ceil(
|
|
1433
|
+
width * (typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1)
|
|
1434
|
+
),
|
|
1435
|
+
height: Math.ceil(
|
|
1436
|
+
height * (typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1)
|
|
1437
|
+
),
|
|
1438
|
+
style: { width, height, display: "block", cursor: "crosshair" }
|
|
1439
|
+
}
|
|
1440
|
+
),
|
|
1441
|
+
overCap && /* @__PURE__ */ jsxs(
|
|
1442
|
+
"div",
|
|
1443
|
+
{
|
|
1444
|
+
style: {
|
|
1445
|
+
position: "absolute",
|
|
1446
|
+
inset: 0,
|
|
1447
|
+
display: "flex",
|
|
1448
|
+
alignItems: "center",
|
|
1449
|
+
justifyContent: "center",
|
|
1450
|
+
color: "#64748b",
|
|
1451
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
1452
|
+
fontSize: 11,
|
|
1453
|
+
textAlign: "center",
|
|
1454
|
+
padding: 8,
|
|
1455
|
+
pointerEvents: "none"
|
|
1456
|
+
},
|
|
1457
|
+
children: [
|
|
1458
|
+
"Minimap disabled",
|
|
1459
|
+
/* @__PURE__ */ jsx("br", {}),
|
|
1460
|
+
"(",
|
|
1461
|
+
store.getNodeCount(),
|
|
1462
|
+
" > ",
|
|
1463
|
+
maxNodes,
|
|
1464
|
+
")"
|
|
1465
|
+
]
|
|
1466
|
+
}
|
|
1467
|
+
)
|
|
1468
|
+
] });
|
|
1469
|
+
}
|
|
1470
|
+
function useNode(id) {
|
|
1471
|
+
const store = useCanvasStore();
|
|
1472
|
+
return useSyncExternalStore(
|
|
1473
|
+
(cb) => {
|
|
1474
|
+
return store.subscribe("change", (batch) => {
|
|
1475
|
+
for (const op of batch.ops) {
|
|
1476
|
+
if ("node" in op && op.node.id === id) {
|
|
1477
|
+
cb();
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
if ("id" in op && op.id === id) {
|
|
1481
|
+
cb();
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
},
|
|
1487
|
+
() => store.getNode(id)
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
function useNodes(predicate) {
|
|
1491
|
+
const store = useCanvasStore();
|
|
1492
|
+
const [nodes, setNodes] = useState(() => {
|
|
1493
|
+
const all = store.getAllNodes();
|
|
1494
|
+
return predicate ? all.filter(predicate) : all;
|
|
1495
|
+
});
|
|
1496
|
+
useEffect(() => {
|
|
1497
|
+
const recompute = () => {
|
|
1498
|
+
const all = store.getAllNodes();
|
|
1499
|
+
setNodes(predicate ? all.filter(predicate) : all);
|
|
1500
|
+
};
|
|
1501
|
+
return store.subscribe("change", recompute);
|
|
1502
|
+
}, [store, predicate]);
|
|
1503
|
+
return nodes;
|
|
1504
|
+
}
|
|
1505
|
+
function useEdge(id) {
|
|
1506
|
+
const store = useCanvasStore();
|
|
1507
|
+
return useSyncExternalStore(
|
|
1508
|
+
(cb) => store.subscribe("change", (batch) => {
|
|
1509
|
+
for (const op of batch.ops) {
|
|
1510
|
+
if ("edge" in op && op.edge.id === id) {
|
|
1511
|
+
cb();
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
if ("id" in op && op.id === id) {
|
|
1515
|
+
cb();
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}),
|
|
1520
|
+
() => store.getEdge(id)
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
function useEdges(predicate) {
|
|
1524
|
+
const store = useCanvasStore();
|
|
1525
|
+
const [edges, setEdges] = useState(() => {
|
|
1526
|
+
const all = store.getAllEdges();
|
|
1527
|
+
return predicate ? all.filter(predicate) : all;
|
|
1528
|
+
});
|
|
1529
|
+
useEffect(() => {
|
|
1530
|
+
const recompute = () => {
|
|
1531
|
+
const all = store.getAllEdges();
|
|
1532
|
+
setEdges(predicate ? all.filter(predicate) : all);
|
|
1533
|
+
};
|
|
1534
|
+
return store.subscribe("change", recompute);
|
|
1535
|
+
}, [store, predicate]);
|
|
1536
|
+
return edges;
|
|
1537
|
+
}
|
|
1538
|
+
function useSelection() {
|
|
1539
|
+
const store = useCanvasStore();
|
|
1540
|
+
return useSyncExternalStore(
|
|
1541
|
+
(cb) => store.subscribe("selection", cb),
|
|
1542
|
+
() => store.getSelection()
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
function useCamera() {
|
|
1546
|
+
const store = useCanvasStore();
|
|
1547
|
+
return useSyncExternalStore(
|
|
1548
|
+
(cb) => store.subscribe("camera", cb),
|
|
1549
|
+
() => store.getCamera()
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
function useInteractionState() {
|
|
1553
|
+
const store = useCanvasStore();
|
|
1554
|
+
return useSyncExternalStore(
|
|
1555
|
+
(cb) => store.subscribe("interaction", cb),
|
|
1556
|
+
() => store.getInteractionState()
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
function useInteractionMode() {
|
|
1560
|
+
const store = useCanvasStore();
|
|
1561
|
+
return useSyncExternalStore(
|
|
1562
|
+
(cb) => {
|
|
1563
|
+
let lastMode = store.getInteractionState().mode;
|
|
1564
|
+
return store.subscribe("interaction", (state) => {
|
|
1565
|
+
if (state.mode !== lastMode) {
|
|
1566
|
+
lastMode = state.mode;
|
|
1567
|
+
cb();
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
},
|
|
1571
|
+
() => store.getInteractionState().mode
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
function useCursor() {
|
|
1575
|
+
const store = useCanvasStore();
|
|
1576
|
+
return useSyncExternalStore(
|
|
1577
|
+
(cb) => store.subscribe("interaction", cb),
|
|
1578
|
+
() => store.getInteractionState().pointer
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
function useIsMoving() {
|
|
1582
|
+
const mode = useInteractionMode();
|
|
1583
|
+
return mode === "panning" || mode === "zooming" || mode === "dragging" || mode === "resizing" || mode === "rotating";
|
|
1584
|
+
}
|
|
1585
|
+
var EMPTY_DRAGGED = [];
|
|
1586
|
+
function useDraggedIds() {
|
|
1587
|
+
const store = useCanvasStore();
|
|
1588
|
+
return useSyncExternalStore(
|
|
1589
|
+
(cb) => store.subscribe("interaction", cb),
|
|
1590
|
+
() => {
|
|
1591
|
+
const state = store.getInteractionState();
|
|
1592
|
+
return state.draggedIds.length === 0 ? EMPTY_DRAGGED : state.draggedIds;
|
|
1593
|
+
}
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
function useIsPenActive() {
|
|
1597
|
+
const cursor = useCursor();
|
|
1598
|
+
return cursor?.pointerType === "pen";
|
|
1599
|
+
}
|
|
1600
|
+
function useLocalPresence() {
|
|
1601
|
+
const store = useCanvasStore();
|
|
1602
|
+
return useSyncExternalStore(
|
|
1603
|
+
(cb) => store.subscribe("presence", (e) => {
|
|
1604
|
+
if ("removed" in e && e.removed) return;
|
|
1605
|
+
if (e.state.clientId === store.clientId) cb();
|
|
1606
|
+
}),
|
|
1607
|
+
() => store.presence.getLocal()
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
function usePresence(clientId) {
|
|
1611
|
+
const store = useCanvasStore();
|
|
1612
|
+
const [, force] = useState(0);
|
|
1613
|
+
useEffect(() => {
|
|
1614
|
+
if (clientId !== void 0) return;
|
|
1615
|
+
return store.subscribe("presence", (e) => {
|
|
1616
|
+
if ("removed" in e && e.removed) return force((n) => n + 1);
|
|
1617
|
+
if (e.state.clientId !== store.clientId) force((n) => n + 1);
|
|
1618
|
+
});
|
|
1619
|
+
}, [store, clientId]);
|
|
1620
|
+
const [snap, setSnap] = useState(
|
|
1621
|
+
() => clientId === void 0 ? void 0 : store.presence.get(clientId)
|
|
1622
|
+
);
|
|
1623
|
+
useEffect(() => {
|
|
1624
|
+
if (clientId === void 0) return;
|
|
1625
|
+
return store.subscribe("presence", (e) => {
|
|
1626
|
+
if ("removed" in e && e.removed && e.clientId === clientId) setSnap(void 0);
|
|
1627
|
+
else if (!("removed" in e) && e.state.clientId === clientId) setSnap(e.state);
|
|
1628
|
+
});
|
|
1629
|
+
}, [store, clientId]);
|
|
1630
|
+
if (clientId !== void 0) return snap;
|
|
1631
|
+
return store.presence.getAll();
|
|
1632
|
+
}
|
|
1633
|
+
function useCanUndo() {
|
|
1634
|
+
const store = useCanvasStore();
|
|
1635
|
+
return useSyncExternalStore(
|
|
1636
|
+
(cb) => store.subscribe("change", cb),
|
|
1637
|
+
() => store.canUndo()
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
function useCanRedo() {
|
|
1641
|
+
const store = useCanvasStore();
|
|
1642
|
+
return useSyncExternalStore(
|
|
1643
|
+
(cb) => store.subscribe("change", cb),
|
|
1644
|
+
() => store.canRedo()
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// src/index.ts
|
|
1649
|
+
var VERSION = "0.0.0";
|
|
1650
|
+
|
|
1651
|
+
export { Canvas, CanvasProvider, Minimap, VERSION, useCamera, useCanRedo, useCanUndo, useCanvasStore, useCursor, useDraggedIds, useEdge, useEdges, useInteractionMode, useInteractionState, useIsMoving, useIsPenActive, useLocalPresence, useNode, useNodes, usePresence, useSelection };
|
|
1652
|
+
//# sourceMappingURL=index.js.map
|
|
1653
|
+
//# sourceMappingURL=index.js.map
|