canvu-react 0.3.5
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/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/camera-BwQjm5oh.d.cts +50 -0
- package/dist/camera-KwCYYPhm.d.ts +50 -0
- package/dist/chatbot.cjs +221 -0
- package/dist/chatbot.cjs.map +1 -0
- package/dist/chatbot.d.cts +36 -0
- package/dist/chatbot.d.ts +36 -0
- package/dist/chatbot.js +218 -0
- package/dist/chatbot.js.map +1 -0
- package/dist/index.cjs +1920 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +276 -0
- package/dist/index.d.ts +276 -0
- package/dist/index.js +1867 -0
- package/dist/index.js.map +1 -0
- package/dist/native.cjs +2572 -0
- package/dist/native.cjs.map +1 -0
- package/dist/native.d.cts +217 -0
- package/dist/native.d.ts +217 -0
- package/dist/native.js +2562 -0
- package/dist/native.js.map +1 -0
- package/dist/react.cjs +8540 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +481 -0
- package/dist/react.d.ts +481 -0
- package/dist/react.js +8492 -0
- package/dist/react.js.map +1 -0
- package/dist/realtime.cjs +2338 -0
- package/dist/realtime.cjs.map +1 -0
- package/dist/realtime.d.cts +309 -0
- package/dist/realtime.d.ts +309 -0
- package/dist/realtime.js +2317 -0
- package/dist/realtime.js.map +1 -0
- package/dist/shape-builders-DTYvub8W.d.ts +93 -0
- package/dist/shape-builders-DxPoOecg.d.cts +93 -0
- package/dist/tldraw.cjs +1948 -0
- package/dist/tldraw.cjs.map +1 -0
- package/dist/tldraw.d.cts +98 -0
- package/dist/tldraw.d.ts +98 -0
- package/dist/tldraw.js +1941 -0
- package/dist/tldraw.js.map +1 -0
- package/dist/types--ALu1mF-.d.ts +356 -0
- package/dist/types-B58i5k-u.d.cts +35 -0
- package/dist/types-CB0TZZuk.d.cts +157 -0
- package/dist/types-CB0TZZuk.d.ts +157 -0
- package/dist/types-D1ftVsOQ.d.cts +356 -0
- package/dist/types-DgEArHkA.d.ts +35 -0
- package/package.json +103 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1867 @@
|
|
|
1
|
+
import getStroke from 'perfect-freehand';
|
|
2
|
+
|
|
3
|
+
// src/math/rect.ts
|
|
4
|
+
function rectsIntersect(a, b) {
|
|
5
|
+
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;
|
|
6
|
+
}
|
|
7
|
+
function normalizeRect(r) {
|
|
8
|
+
const x0 = r.width >= 0 ? r.x : r.x + r.width;
|
|
9
|
+
const y0 = r.height >= 0 ? r.y : r.y + r.height;
|
|
10
|
+
return {
|
|
11
|
+
x: x0,
|
|
12
|
+
y: y0,
|
|
13
|
+
width: Math.abs(r.width),
|
|
14
|
+
height: Math.abs(r.height)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/camera/camera.ts
|
|
19
|
+
var Camera2D = class {
|
|
20
|
+
x = 0;
|
|
21
|
+
y = 0;
|
|
22
|
+
/** Scale: world units per CSS pixel (larger = more zoomed in). */
|
|
23
|
+
zoom = 1;
|
|
24
|
+
minZoom;
|
|
25
|
+
maxZoom;
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.minZoom = options.minZoom ?? 0.05;
|
|
28
|
+
this.maxZoom = options.maxZoom ?? 32;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Converts a point from world coordinates to CSS pixel coordinates relative to the viewport top-left.
|
|
32
|
+
*/
|
|
33
|
+
worldToScreen(worldX, worldY) {
|
|
34
|
+
return {
|
|
35
|
+
screenX: worldX * this.zoom + this.x,
|
|
36
|
+
screenY: worldY * this.zoom + this.y
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Converts a point from CSS pixel coordinates (viewport-relative) to world coordinates.
|
|
41
|
+
*/
|
|
42
|
+
screenToWorld(screenX, screenY) {
|
|
43
|
+
const z = this.zoom;
|
|
44
|
+
if (z === 0) {
|
|
45
|
+
return { worldX: 0, worldY: 0 };
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
worldX: (screenX - this.x) / z,
|
|
49
|
+
worldY: (screenY - this.y) / z
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Sets zoom, clamped to `[minZoom, maxZoom]`, optionally anchoring a screen point so it stays under the cursor.
|
|
54
|
+
*/
|
|
55
|
+
setZoom(nextZoom, anchorScreen) {
|
|
56
|
+
const clamped = Math.min(this.maxZoom, Math.max(this.minZoom, nextZoom));
|
|
57
|
+
if (!anchorScreen) {
|
|
58
|
+
this.zoom = clamped;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const anchorWorld = this.screenToWorld(anchorScreen.x, anchorScreen.y);
|
|
62
|
+
this.zoom = clamped;
|
|
63
|
+
this.x = anchorScreen.x - anchorWorld.worldX * this.zoom;
|
|
64
|
+
this.y = anchorScreen.y - anchorWorld.worldY * this.zoom;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Returns the world-space rectangle visible in a viewport of `viewportWidth` x `viewportHeight` CSS pixels.
|
|
68
|
+
*/
|
|
69
|
+
getVisibleWorldRect(viewportWidth, viewportHeight) {
|
|
70
|
+
const topLeft = this.screenToWorld(0, 0);
|
|
71
|
+
const bottomRight = this.screenToWorld(viewportWidth, viewportHeight);
|
|
72
|
+
return normalizeRect({
|
|
73
|
+
x: topLeft.worldX,
|
|
74
|
+
y: topLeft.worldY,
|
|
75
|
+
width: bottomRight.worldX - topLeft.worldX,
|
|
76
|
+
height: bottomRight.worldY - topLeft.worldY
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// src/image/raster-image.ts
|
|
82
|
+
var MAX_RASTER_EMBED_DIMENSION = 4096;
|
|
83
|
+
function normalizeLoadOptions(maxDimensionOrOptions) {
|
|
84
|
+
if (typeof maxDimensionOrOptions === "number") {
|
|
85
|
+
return {
|
|
86
|
+
maxDimension: maxDimensionOrOptions,
|
|
87
|
+
embedMode: "dataUrl"
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
maxDimension: maxDimensionOrOptions?.maxDimension ?? MAX_RASTER_EMBED_DIMENSION,
|
|
92
|
+
embedMode: maxDimensionOrOptions?.embedMode ?? "dataUrl"
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function loadImageFromObjectUrl(objectUrl) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const img = new Image();
|
|
98
|
+
img.onload = () => resolve(img);
|
|
99
|
+
img.onerror = () => reject(new Error("Could not decode image"));
|
|
100
|
+
img.decoding = "async";
|
|
101
|
+
img.src = objectUrl;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function canvasToObjectUrl(canvas, mime, quality) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
canvas.toBlob(
|
|
107
|
+
(blob) => {
|
|
108
|
+
if (!blob) {
|
|
109
|
+
reject(new Error("Could not encode image blob"));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
resolve(URL.createObjectURL(blob));
|
|
113
|
+
},
|
|
114
|
+
mime,
|
|
115
|
+
quality
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function loadImageFileAsRasterSceneSource(file, maxDimensionOrOptions) {
|
|
120
|
+
const { maxDimension: maxDim, embedMode } = normalizeLoadOptions(maxDimensionOrOptions);
|
|
121
|
+
const fileObjectUrl = URL.createObjectURL(file);
|
|
122
|
+
let fileUrlReleased = false;
|
|
123
|
+
try {
|
|
124
|
+
const img = await loadImageFromObjectUrl(fileObjectUrl);
|
|
125
|
+
const w0 = img.naturalWidth;
|
|
126
|
+
const h0 = img.naturalHeight;
|
|
127
|
+
if (w0 < 1 || h0 < 1) {
|
|
128
|
+
throw new Error("Image has no dimensions");
|
|
129
|
+
}
|
|
130
|
+
const scale = Math.min(1, maxDim / Math.max(w0, h0));
|
|
131
|
+
const needsDownscale = scale < 1 - 1e-9;
|
|
132
|
+
if (embedMode === "blobUrl" && !needsDownscale) {
|
|
133
|
+
fileUrlReleased = true;
|
|
134
|
+
return { dataUrl: fileObjectUrl, width: w0, height: h0 };
|
|
135
|
+
}
|
|
136
|
+
URL.revokeObjectURL(fileObjectUrl);
|
|
137
|
+
fileUrlReleased = true;
|
|
138
|
+
const cw = needsDownscale ? Math.max(1, Math.round(w0 * scale)) : Math.max(1, w0);
|
|
139
|
+
const ch = needsDownscale ? Math.max(1, Math.round(h0 * scale)) : Math.max(1, h0);
|
|
140
|
+
const canvas = document.createElement("canvas");
|
|
141
|
+
canvas.width = cw;
|
|
142
|
+
canvas.height = ch;
|
|
143
|
+
const ctx = canvas.getContext("2d");
|
|
144
|
+
if (!ctx) {
|
|
145
|
+
throw new Error("Canvas 2D context unavailable");
|
|
146
|
+
}
|
|
147
|
+
ctx.imageSmoothingEnabled = true;
|
|
148
|
+
ctx.imageSmoothingQuality = "high";
|
|
149
|
+
ctx.drawImage(img, 0, 0, cw, ch);
|
|
150
|
+
const isJpeg = file.type === "image/jpeg" || file.type === "image/jpg" || file.type === "image/pjpeg";
|
|
151
|
+
if (embedMode === "blobUrl") {
|
|
152
|
+
const mime = isJpeg ? "image/jpeg" : "image/png";
|
|
153
|
+
const blobHref = await canvasToObjectUrl(
|
|
154
|
+
canvas,
|
|
155
|
+
mime,
|
|
156
|
+
isJpeg ? 0.92 : void 0
|
|
157
|
+
);
|
|
158
|
+
return { dataUrl: blobHref, width: cw, height: ch };
|
|
159
|
+
}
|
|
160
|
+
const dataUrl = isJpeg ? canvas.toDataURL("image/jpeg", 0.92) : canvas.toDataURL("image/png");
|
|
161
|
+
return { dataUrl, width: cw, height: ch };
|
|
162
|
+
} catch (e) {
|
|
163
|
+
if (!fileUrlReleased) {
|
|
164
|
+
URL.revokeObjectURL(fileObjectUrl);
|
|
165
|
+
}
|
|
166
|
+
throw e;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/input/apple-pencil-navigation.ts
|
|
171
|
+
var DRAWING_LIKE_TOOLS = /* @__PURE__ */ new Set([
|
|
172
|
+
"draw",
|
|
173
|
+
"marker",
|
|
174
|
+
"annotate",
|
|
175
|
+
"pen",
|
|
176
|
+
"highlight",
|
|
177
|
+
"rect",
|
|
178
|
+
"ellipse",
|
|
179
|
+
"line",
|
|
180
|
+
"arrow",
|
|
181
|
+
"text",
|
|
182
|
+
"image"
|
|
183
|
+
]);
|
|
184
|
+
function attachApplePencilNavigation(options) {
|
|
185
|
+
const {
|
|
186
|
+
element,
|
|
187
|
+
camera,
|
|
188
|
+
getCurrentToolId,
|
|
189
|
+
onUpdate,
|
|
190
|
+
touchPanSensitivity = 1.25
|
|
191
|
+
} = options;
|
|
192
|
+
let penDetected = false;
|
|
193
|
+
const activePenPointerIds = /* @__PURE__ */ new Set();
|
|
194
|
+
const blockedTouchPointerIds = /* @__PURE__ */ new Set();
|
|
195
|
+
const pointers = /* @__PURE__ */ new Map();
|
|
196
|
+
let mode = "idle";
|
|
197
|
+
let pinchStartDist = 0;
|
|
198
|
+
let pinchStartZoom = 1;
|
|
199
|
+
let panLast = null;
|
|
200
|
+
const shouldIntercept = (e) => {
|
|
201
|
+
if (e.pointerType !== "touch") return false;
|
|
202
|
+
const tool = getCurrentToolId();
|
|
203
|
+
if (tool === "hand") return true;
|
|
204
|
+
return penDetected && DRAWING_LIKE_TOOLS.has(tool);
|
|
205
|
+
};
|
|
206
|
+
const dist = (a, b) => Math.hypot(b.x - a.x, b.y - a.y);
|
|
207
|
+
const mid = (a, b) => ({
|
|
208
|
+
x: (a.x + b.x) / 2,
|
|
209
|
+
y: (a.y + b.y) / 2
|
|
210
|
+
});
|
|
211
|
+
const onPointerDown = (e) => {
|
|
212
|
+
if (e.pointerType === "pen") {
|
|
213
|
+
penDetected = true;
|
|
214
|
+
activePenPointerIds.add(e.pointerId);
|
|
215
|
+
pointers.clear();
|
|
216
|
+
mode = "idle";
|
|
217
|
+
panLast = null;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (e.pointerType === "touch" && activePenPointerIds.size > 0) {
|
|
221
|
+
blockedTouchPointerIds.add(e.pointerId);
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
e.stopImmediatePropagation();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (!shouldIntercept(e)) return;
|
|
227
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
228
|
+
if (pointers.size === 1) {
|
|
229
|
+
mode = "pan";
|
|
230
|
+
panLast = { x: e.clientX, y: e.clientY };
|
|
231
|
+
} else if (pointers.size === 2) {
|
|
232
|
+
const vals = Array.from(pointers.values());
|
|
233
|
+
const a = vals[0];
|
|
234
|
+
const b = vals[1];
|
|
235
|
+
if (a === void 0 || b === void 0) return;
|
|
236
|
+
mode = "pinch";
|
|
237
|
+
pinchStartDist = dist(a, b);
|
|
238
|
+
pinchStartZoom = camera.zoom;
|
|
239
|
+
panLast = mid(a, b);
|
|
240
|
+
}
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
e.stopImmediatePropagation();
|
|
243
|
+
};
|
|
244
|
+
const onPointerMove = (e) => {
|
|
245
|
+
if (blockedTouchPointerIds.has(e.pointerId)) {
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
e.stopImmediatePropagation();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (mode === "idle" || !pointers.has(e.pointerId)) return;
|
|
251
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
252
|
+
if (mode === "pan" && pointers.size === 1 && panLast) {
|
|
253
|
+
const dx = e.clientX - panLast.x;
|
|
254
|
+
const dy = e.clientY - panLast.y;
|
|
255
|
+
panLast = { x: e.clientX, y: e.clientY };
|
|
256
|
+
camera.x += dx * touchPanSensitivity;
|
|
257
|
+
camera.y += dy * touchPanSensitivity;
|
|
258
|
+
onUpdate();
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
e.stopImmediatePropagation();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (mode === "pinch" && pointers.size === 2) {
|
|
264
|
+
const vals = Array.from(pointers.values());
|
|
265
|
+
const a = vals[0];
|
|
266
|
+
const b = vals[1];
|
|
267
|
+
if (a === void 0 || b === void 0) return;
|
|
268
|
+
const d = dist(a, b);
|
|
269
|
+
const m = mid(a, b);
|
|
270
|
+
const scale = d / Math.max(pinchStartDist, 1e-6);
|
|
271
|
+
const nextZoom = Math.min(
|
|
272
|
+
camera.maxZoom,
|
|
273
|
+
Math.max(camera.minZoom, pinchStartZoom * scale)
|
|
274
|
+
);
|
|
275
|
+
const rect = element.getBoundingClientRect();
|
|
276
|
+
camera.setZoom(nextZoom, {
|
|
277
|
+
x: m.x - rect.left,
|
|
278
|
+
y: m.y - rect.top
|
|
279
|
+
});
|
|
280
|
+
if (panLast) {
|
|
281
|
+
camera.x += m.x - panLast.x;
|
|
282
|
+
camera.y += m.y - panLast.y;
|
|
283
|
+
panLast = m;
|
|
284
|
+
}
|
|
285
|
+
onUpdate();
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
e.stopImmediatePropagation();
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const onPointerUp = (e) => {
|
|
291
|
+
if (e.pointerType === "pen") {
|
|
292
|
+
activePenPointerIds.delete(e.pointerId);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (blockedTouchPointerIds.has(e.pointerId)) {
|
|
296
|
+
blockedTouchPointerIds.delete(e.pointerId);
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
e.stopImmediatePropagation();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!pointers.has(e.pointerId)) return;
|
|
302
|
+
pointers.delete(e.pointerId);
|
|
303
|
+
if (pointers.size === 0) {
|
|
304
|
+
mode = "idle";
|
|
305
|
+
panLast = null;
|
|
306
|
+
} else if (pointers.size === 1 && mode === "pinch") {
|
|
307
|
+
mode = "pan";
|
|
308
|
+
const r = Array.from(pointers.values())[0];
|
|
309
|
+
panLast = r ?? null;
|
|
310
|
+
}
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
e.stopImmediatePropagation();
|
|
313
|
+
};
|
|
314
|
+
element.addEventListener("pointerdown", onPointerDown, { capture: true });
|
|
315
|
+
element.addEventListener("pointermove", onPointerMove, { capture: true });
|
|
316
|
+
element.addEventListener("pointerup", onPointerUp, { capture: true });
|
|
317
|
+
element.addEventListener("pointercancel", onPointerUp, { capture: true });
|
|
318
|
+
return () => {
|
|
319
|
+
element.removeEventListener("pointerdown", onPointerDown, { capture: true });
|
|
320
|
+
element.removeEventListener("pointermove", onPointerMove, { capture: true });
|
|
321
|
+
element.removeEventListener("pointerup", onPointerUp, { capture: true });
|
|
322
|
+
element.removeEventListener("pointercancel", onPointerUp, { capture: true });
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/input/attach-viewport-input.ts
|
|
327
|
+
function distanceBetween(a, b) {
|
|
328
|
+
const dx = b.x - a.x;
|
|
329
|
+
const dy = b.y - a.y;
|
|
330
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
331
|
+
}
|
|
332
|
+
function midpoint(a, b) {
|
|
333
|
+
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
334
|
+
}
|
|
335
|
+
function wheelDeltaYPixels(e) {
|
|
336
|
+
switch (e.deltaMode) {
|
|
337
|
+
case WheelEvent.DOM_DELTA_LINE:
|
|
338
|
+
return e.deltaY * 16;
|
|
339
|
+
case WheelEvent.DOM_DELTA_PAGE:
|
|
340
|
+
return e.deltaY * 400;
|
|
341
|
+
default:
|
|
342
|
+
return e.deltaY;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function attachViewportInput(options) {
|
|
346
|
+
const {
|
|
347
|
+
element,
|
|
348
|
+
camera,
|
|
349
|
+
onUpdate,
|
|
350
|
+
wheelElement,
|
|
351
|
+
wheelPanSensitivity = 0.5,
|
|
352
|
+
wheelZoomSensitivity = 15e-4,
|
|
353
|
+
touchPanSensitivity = 1.25,
|
|
354
|
+
touchHandledElsewhere = false,
|
|
355
|
+
allowPrimaryPointerPan = () => true
|
|
356
|
+
} = options;
|
|
357
|
+
const wheelTarget = wheelElement ?? element;
|
|
358
|
+
const pointers = /* @__PURE__ */ new Map();
|
|
359
|
+
let mode = "idle";
|
|
360
|
+
let pinchStartDist = 0;
|
|
361
|
+
let pinchStartZoom = 1;
|
|
362
|
+
let panLast = null;
|
|
363
|
+
let mousePanButton = null;
|
|
364
|
+
const onWheel = (e) => {
|
|
365
|
+
if (e.ctrlKey || e.metaKey) {
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
const dy = wheelDeltaYPixels(e);
|
|
368
|
+
const normDy = dy < 20 ? dy * 12 : dy;
|
|
369
|
+
const factor = Math.exp(-normDy * wheelZoomSensitivity);
|
|
370
|
+
const rect = element.getBoundingClientRect();
|
|
371
|
+
camera.setZoom(camera.zoom * factor, {
|
|
372
|
+
x: e.clientX - rect.left,
|
|
373
|
+
y: e.clientY - rect.top
|
|
374
|
+
});
|
|
375
|
+
onUpdate();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
camera.x -= e.deltaX * wheelPanSensitivity / camera.zoom;
|
|
380
|
+
camera.y -= e.deltaY * wheelPanSensitivity / camera.zoom;
|
|
381
|
+
onUpdate();
|
|
382
|
+
};
|
|
383
|
+
const onPointerDown = (e) => {
|
|
384
|
+
if (e.pointerType === "pen") return;
|
|
385
|
+
if (touchHandledElsewhere && e.pointerType === "touch") {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const panOk = allowPrimaryPointerPan();
|
|
389
|
+
if (e.pointerType === "mouse" && e.button === 0) {
|
|
390
|
+
if (!panOk) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
mousePanButton = 0;
|
|
394
|
+
mode = "mousePan";
|
|
395
|
+
panLast = { x: e.clientX, y: e.clientY };
|
|
396
|
+
element.setPointerCapture(e.pointerId);
|
|
397
|
+
e.preventDefault();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (e.pointerType === "mouse" && e.button === 1) {
|
|
401
|
+
mousePanButton = 1;
|
|
402
|
+
mode = "mousePan";
|
|
403
|
+
panLast = { x: e.clientX, y: e.clientY };
|
|
404
|
+
element.setPointerCapture(e.pointerId);
|
|
405
|
+
e.preventDefault();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
409
|
+
if (pointers.size === 1 && e.pointerType === "touch") {
|
|
410
|
+
if (panOk) {
|
|
411
|
+
mode = "pan";
|
|
412
|
+
panLast = { x: e.clientX, y: e.clientY };
|
|
413
|
+
e.preventDefault();
|
|
414
|
+
}
|
|
415
|
+
} else if (pointers.size === 2) {
|
|
416
|
+
const vals = Array.from(pointers.values());
|
|
417
|
+
const a = vals[0];
|
|
418
|
+
const b = vals[1];
|
|
419
|
+
if (a === void 0 || b === void 0) return;
|
|
420
|
+
mode = "pinch";
|
|
421
|
+
pinchStartDist = distanceBetween(a, b);
|
|
422
|
+
pinchStartZoom = camera.zoom;
|
|
423
|
+
panLast = midpoint(a, b);
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const onPointerMove = (e) => {
|
|
428
|
+
if (e.pointerType === "pen") return;
|
|
429
|
+
if (touchHandledElsewhere && e.pointerType === "touch") {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (mode === "mousePan" && panLast && mousePanButton !== null) {
|
|
433
|
+
const dx = e.clientX - panLast.x;
|
|
434
|
+
const dy = e.clientY - panLast.y;
|
|
435
|
+
panLast = { x: e.clientX, y: e.clientY };
|
|
436
|
+
camera.x += dx;
|
|
437
|
+
camera.y += dy;
|
|
438
|
+
onUpdate();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!pointers.has(e.pointerId)) return;
|
|
442
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
443
|
+
if (mode === "pan" && pointers.size === 1 && panLast) {
|
|
444
|
+
const dx = e.clientX - panLast.x;
|
|
445
|
+
const dy = e.clientY - panLast.y;
|
|
446
|
+
panLast = { x: e.clientX, y: e.clientY };
|
|
447
|
+
camera.x += dx * touchPanSensitivity;
|
|
448
|
+
camera.y += dy * touchPanSensitivity;
|
|
449
|
+
onUpdate();
|
|
450
|
+
e.preventDefault();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (mode === "pinch" && pointers.size === 2) {
|
|
454
|
+
const vals = Array.from(pointers.values());
|
|
455
|
+
const a = vals[0];
|
|
456
|
+
const b = vals[1];
|
|
457
|
+
if (a === void 0 || b === void 0) return;
|
|
458
|
+
const dist = distanceBetween(a, b);
|
|
459
|
+
const mid = midpoint(a, b);
|
|
460
|
+
const scale = dist / Math.max(pinchStartDist, 1e-6);
|
|
461
|
+
const nextZoom = Math.min(
|
|
462
|
+
camera.maxZoom,
|
|
463
|
+
Math.max(camera.minZoom, pinchStartZoom * scale)
|
|
464
|
+
);
|
|
465
|
+
const rect = element.getBoundingClientRect();
|
|
466
|
+
camera.setZoom(nextZoom, {
|
|
467
|
+
x: mid.x - rect.left,
|
|
468
|
+
y: mid.y - rect.top
|
|
469
|
+
});
|
|
470
|
+
if (panLast) {
|
|
471
|
+
const dx = mid.x - panLast.x;
|
|
472
|
+
const dy = mid.y - panLast.y;
|
|
473
|
+
camera.x += dx;
|
|
474
|
+
camera.y += dy;
|
|
475
|
+
panLast = mid;
|
|
476
|
+
}
|
|
477
|
+
onUpdate();
|
|
478
|
+
e.preventDefault();
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
const onPointerUp = (e) => {
|
|
482
|
+
if (e.pointerType === "pen") return;
|
|
483
|
+
if (touchHandledElsewhere && e.pointerType === "touch") {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (mode === "mousePan") {
|
|
487
|
+
if (e.pointerType === "mouse" && e.button === mousePanButton) {
|
|
488
|
+
mode = "idle";
|
|
489
|
+
panLast = null;
|
|
490
|
+
mousePanButton = null;
|
|
491
|
+
try {
|
|
492
|
+
element.releasePointerCapture(e.pointerId);
|
|
493
|
+
} catch {
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
pointers.delete(e.pointerId);
|
|
499
|
+
if (pointers.size === 0) {
|
|
500
|
+
mode = "idle";
|
|
501
|
+
panLast = null;
|
|
502
|
+
} else if (pointers.size === 1 && mode === "pinch") {
|
|
503
|
+
mode = "pan";
|
|
504
|
+
const remaining = Array.from(pointers.values())[0];
|
|
505
|
+
panLast = remaining ?? null;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
const onPointerCancel = (e) => {
|
|
509
|
+
onPointerUp(e);
|
|
510
|
+
};
|
|
511
|
+
wheelTarget.addEventListener("wheel", onWheel, { passive: false });
|
|
512
|
+
element.addEventListener("pointerdown", onPointerDown);
|
|
513
|
+
element.addEventListener("pointermove", onPointerMove);
|
|
514
|
+
element.addEventListener("pointerup", onPointerUp);
|
|
515
|
+
element.addEventListener("pointercancel", onPointerCancel);
|
|
516
|
+
return () => {
|
|
517
|
+
wheelTarget.removeEventListener("wheel", onWheel);
|
|
518
|
+
element.removeEventListener("pointerdown", onPointerDown);
|
|
519
|
+
element.removeEventListener("pointermove", onPointerMove);
|
|
520
|
+
element.removeEventListener("pointerup", onPointerUp);
|
|
521
|
+
element.removeEventListener("pointercancel", onPointerCancel);
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/math/item-transform.ts
|
|
526
|
+
function getItemRotationRad(item) {
|
|
527
|
+
return item.rotation ?? 0;
|
|
528
|
+
}
|
|
529
|
+
function itemLocalToWorld(lx, ly, itemX, itemY, w, h, rotationRad) {
|
|
530
|
+
const c = { x: w / 2, y: h / 2 };
|
|
531
|
+
const dlx = lx - c.x;
|
|
532
|
+
const dly = ly - c.y;
|
|
533
|
+
const cos = Math.cos(rotationRad);
|
|
534
|
+
const sin = Math.sin(rotationRad);
|
|
535
|
+
return {
|
|
536
|
+
x: itemX + c.x + cos * dlx - sin * dly,
|
|
537
|
+
y: itemY + c.y + sin * dlx + cos * dly
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function worldToItemLocal(wx, wy, itemX, itemY, w, h, rotationRad) {
|
|
541
|
+
const c = { x: w / 2, y: h / 2 };
|
|
542
|
+
const vx = wx - itemX;
|
|
543
|
+
const vy = wy - itemY;
|
|
544
|
+
const dx = vx - c.x;
|
|
545
|
+
const dy = vy - c.y;
|
|
546
|
+
const cos = Math.cos(-rotationRad);
|
|
547
|
+
const sin = Math.sin(-rotationRad);
|
|
548
|
+
const lx = cos * dx - sin * dy;
|
|
549
|
+
const ly = sin * dx + cos * dy;
|
|
550
|
+
return { x: c.x + lx, y: c.y + ly };
|
|
551
|
+
}
|
|
552
|
+
function boundsAabbForRotatedItem(item) {
|
|
553
|
+
const rot = getItemRotationRad(item);
|
|
554
|
+
if (Math.abs(rot) < 1e-12 && item.bounds.width >= 0 && item.bounds.height >= 0) {
|
|
555
|
+
return item.bounds;
|
|
556
|
+
}
|
|
557
|
+
const r = normalizeRect(item.bounds);
|
|
558
|
+
if (Math.abs(rot) < 1e-12) {
|
|
559
|
+
return r;
|
|
560
|
+
}
|
|
561
|
+
const corners = [
|
|
562
|
+
[0, 0],
|
|
563
|
+
[r.width, 0],
|
|
564
|
+
[r.width, r.height],
|
|
565
|
+
[0, r.height]
|
|
566
|
+
];
|
|
567
|
+
let minX = Infinity;
|
|
568
|
+
let minY = Infinity;
|
|
569
|
+
let maxX = -Infinity;
|
|
570
|
+
let maxY = -Infinity;
|
|
571
|
+
for (const [lx, ly] of corners) {
|
|
572
|
+
const p = itemLocalToWorld(lx, ly, item.x, item.y, r.width, r.height, rot);
|
|
573
|
+
minX = Math.min(minX, p.x);
|
|
574
|
+
minY = Math.min(minY, p.y);
|
|
575
|
+
maxX = Math.max(maxX, p.x);
|
|
576
|
+
maxY = Math.max(maxY, p.y);
|
|
577
|
+
}
|
|
578
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/scene/custom-shape.ts
|
|
582
|
+
function expandCustomShapeTemplate(template, width, height) {
|
|
583
|
+
return template.replace(/\{\{w\}\}/g, String(width)).replace(/\{\{h\}\}/g, String(height)).replace(/\{\{width\}\}/g, String(width)).replace(/\{\{height\}\}/g, String(height));
|
|
584
|
+
}
|
|
585
|
+
function resolveCustomInner(content, size) {
|
|
586
|
+
if ("render" in content) {
|
|
587
|
+
return content.render(size);
|
|
588
|
+
}
|
|
589
|
+
return expandCustomShapeTemplate(content.svg, size.width, size.height);
|
|
590
|
+
}
|
|
591
|
+
function buildCustomShapeChildrenSvg(inner, intrinsic, bounds) {
|
|
592
|
+
const b = normalizeRect(bounds);
|
|
593
|
+
const sx = b.width / intrinsic.width;
|
|
594
|
+
const sy = b.height / intrinsic.height;
|
|
595
|
+
return `<g transform="scale(${sx},${sy})">${inner}</g>`;
|
|
596
|
+
}
|
|
597
|
+
function createCustomShapeItem(id, bounds, content) {
|
|
598
|
+
const r = normalizeRect(bounds);
|
|
599
|
+
const intrinsic = { width: r.width, height: r.height };
|
|
600
|
+
const inner = resolveCustomInner(content, intrinsic);
|
|
601
|
+
return {
|
|
602
|
+
id,
|
|
603
|
+
x: r.x,
|
|
604
|
+
y: r.y,
|
|
605
|
+
bounds: { ...r },
|
|
606
|
+
toolKind: "custom",
|
|
607
|
+
customIntrinsicSize: intrinsic,
|
|
608
|
+
customInnerSvg: inner,
|
|
609
|
+
childrenSvg: buildCustomShapeChildrenSvg(inner, intrinsic, r)
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/scene/freehand-path.ts
|
|
614
|
+
function dedupeFreehandPoints(points, minDist) {
|
|
615
|
+
if (points.length <= 2) {
|
|
616
|
+
return points.map((p) => ({ ...p }));
|
|
617
|
+
}
|
|
618
|
+
const minSq = minDist * minDist;
|
|
619
|
+
const first = points[0];
|
|
620
|
+
if (!first) return [];
|
|
621
|
+
const out = [{ ...first }];
|
|
622
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
623
|
+
const p = points[i];
|
|
624
|
+
const last = out[out.length - 1];
|
|
625
|
+
if (!p || !last) continue;
|
|
626
|
+
const dx = p.x - last.x;
|
|
627
|
+
const dy = p.y - last.y;
|
|
628
|
+
if (dx * dx + dy * dy >= minSq) {
|
|
629
|
+
out.push({ ...p });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const end = points[points.length - 1];
|
|
633
|
+
const lastKept = out[out.length - 1];
|
|
634
|
+
if (!end || !lastKept) return out;
|
|
635
|
+
if ((end.x - lastKept.x) ** 2 + (end.y - lastKept.y) ** 2 > 1e-12) {
|
|
636
|
+
out.push({ ...end });
|
|
637
|
+
}
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
function smoothFreehandPointsToPathD(points) {
|
|
641
|
+
const n = points.length;
|
|
642
|
+
if (n === 0) return "";
|
|
643
|
+
if (n === 1) {
|
|
644
|
+
const p = points[0];
|
|
645
|
+
if (!p) return "";
|
|
646
|
+
return `M ${p.x} ${p.y}`;
|
|
647
|
+
}
|
|
648
|
+
if (n === 2) {
|
|
649
|
+
const a = points[0];
|
|
650
|
+
const b = points[1];
|
|
651
|
+
if (!a || !b) return "";
|
|
652
|
+
return `M ${a.x} ${a.y} L ${b.x} ${b.y}`;
|
|
653
|
+
}
|
|
654
|
+
const p0 = points[0];
|
|
655
|
+
if (!p0) return "";
|
|
656
|
+
let d = `M ${p0.x} ${p0.y}`;
|
|
657
|
+
let i = 1;
|
|
658
|
+
for (; i < n - 2; i++) {
|
|
659
|
+
const pi = points[i];
|
|
660
|
+
const pi1 = points[i + 1];
|
|
661
|
+
if (!pi || !pi1) continue;
|
|
662
|
+
const xc = (pi.x + pi1.x) / 2;
|
|
663
|
+
const yc = (pi.y + pi1.y) / 2;
|
|
664
|
+
d += ` Q ${pi.x} ${pi.y} ${xc} ${yc}`;
|
|
665
|
+
}
|
|
666
|
+
const pLast = points[i];
|
|
667
|
+
const pEnd = points[i + 1];
|
|
668
|
+
if (!pLast || !pEnd) return d;
|
|
669
|
+
d += ` Q ${pLast.x} ${pLast.y} ${pEnd.x} ${pEnd.y}`;
|
|
670
|
+
return d;
|
|
671
|
+
}
|
|
672
|
+
function outlineStrokeToClosedPathD(outline) {
|
|
673
|
+
const len = outline.length;
|
|
674
|
+
if (len === 0) return "";
|
|
675
|
+
const first = outline[0];
|
|
676
|
+
if (!first) return "";
|
|
677
|
+
if (len < 3) {
|
|
678
|
+
let d2 = `M ${first[0]} ${first[1]}`;
|
|
679
|
+
for (let i = 1; i < len; i++) {
|
|
680
|
+
const pt = outline[i];
|
|
681
|
+
if (!pt) continue;
|
|
682
|
+
d2 += ` L ${pt[0]} ${pt[1]}`;
|
|
683
|
+
}
|
|
684
|
+
return `${d2} Z`;
|
|
685
|
+
}
|
|
686
|
+
let d = `M ${first[0]} ${first[1]} Q`;
|
|
687
|
+
for (let i = 0; i < len; i++) {
|
|
688
|
+
const p0 = outline[i];
|
|
689
|
+
const p1 = outline[(i + 1) % len];
|
|
690
|
+
if (!p0 || !p1) continue;
|
|
691
|
+
d += ` ${p0[0]} ${p0[1]} ${(p0[0] + p1[0]) / 2} ${(p0[1] + p1[1]) / 2}`;
|
|
692
|
+
}
|
|
693
|
+
return `${d} Z`;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// src/scene/text-svg.ts
|
|
697
|
+
function escapeSvgTextContent(s) {
|
|
698
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
699
|
+
}
|
|
700
|
+
function escapeHtmlText(s) {
|
|
701
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
702
|
+
}
|
|
703
|
+
var DEFAULT_TEXT_FONT_SIZE = 18;
|
|
704
|
+
var LINE_HEIGHT_RATIO = 22 / 18;
|
|
705
|
+
var FIRST_LINE_BASELINE_RATIO = 24 / 18;
|
|
706
|
+
var PLACEHOLDER = "Tap to type";
|
|
707
|
+
var MIN_TEXT_BOX_W = 40;
|
|
708
|
+
var MIN_TEXT_BOX_H = 36;
|
|
709
|
+
var TEXT_PAD_X = 4;
|
|
710
|
+
var MAX_TEXT_MEASURE_CACHE_ENTRIES = 2e3;
|
|
711
|
+
var sharedMeasureContext;
|
|
712
|
+
var textMeasureCache = /* @__PURE__ */ new Map();
|
|
713
|
+
function getSharedMeasureContext() {
|
|
714
|
+
if (sharedMeasureContext !== void 0) {
|
|
715
|
+
return sharedMeasureContext;
|
|
716
|
+
}
|
|
717
|
+
if (typeof document === "undefined") {
|
|
718
|
+
sharedMeasureContext = null;
|
|
719
|
+
return sharedMeasureContext;
|
|
720
|
+
}
|
|
721
|
+
const canvas = document.createElement("canvas");
|
|
722
|
+
sharedMeasureContext = canvas.getContext("2d");
|
|
723
|
+
return sharedMeasureContext;
|
|
724
|
+
}
|
|
725
|
+
function textMeasureCacheKey(content, fontSize) {
|
|
726
|
+
return `${fontSize}
|
|
727
|
+
${content}`;
|
|
728
|
+
}
|
|
729
|
+
function cacheMeasuredBounds(key, bounds) {
|
|
730
|
+
if (textMeasureCache.size >= MAX_TEXT_MEASURE_CACHE_ENTRIES) {
|
|
731
|
+
textMeasureCache.clear();
|
|
732
|
+
}
|
|
733
|
+
textMeasureCache.set(key, bounds);
|
|
734
|
+
}
|
|
735
|
+
function lineHeightFor(fontSize) {
|
|
736
|
+
return fontSize * LINE_HEIGHT_RATIO;
|
|
737
|
+
}
|
|
738
|
+
function firstLineBaselineY(fontSize) {
|
|
739
|
+
return fontSize * FIRST_LINE_BASELINE_RATIO;
|
|
740
|
+
}
|
|
741
|
+
function measureTextBoundsLocal(content, fontSize = DEFAULT_TEXT_FONT_SIZE) {
|
|
742
|
+
const cacheKey = textMeasureCacheKey(content, fontSize);
|
|
743
|
+
const cached = textMeasureCache.get(cacheKey);
|
|
744
|
+
if (cached) {
|
|
745
|
+
return cached;
|
|
746
|
+
}
|
|
747
|
+
const lh = lineHeightFor(fontSize);
|
|
748
|
+
const baselineY = firstLineBaselineY(fontSize);
|
|
749
|
+
const trimmed = content.trim();
|
|
750
|
+
const lines = trimmed.length === 0 ? [PLACEHOLDER] : content.split("\n");
|
|
751
|
+
let maxInnerW = 0;
|
|
752
|
+
const ctx = getSharedMeasureContext();
|
|
753
|
+
if (ctx) {
|
|
754
|
+
ctx.font = `${fontSize}px system-ui, sans-serif`;
|
|
755
|
+
for (const line of lines) {
|
|
756
|
+
const toMeasure = trimmed.length === 0 ? PLACEHOLDER : line.length === 0 ? " " : line;
|
|
757
|
+
maxInnerW = Math.max(maxInnerW, ctx.measureText(toMeasure).width);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (maxInnerW === 0) {
|
|
761
|
+
for (const line of lines) {
|
|
762
|
+
const toMeasure = trimmed.length === 0 ? PLACEHOLDER : line.length === 0 ? " " : line;
|
|
763
|
+
maxInnerW = Math.max(maxInnerW, toMeasure.length * fontSize * 0.52);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const minW = Math.max(MIN_TEXT_BOX_W, TEXT_PAD_X * 2 + fontSize);
|
|
767
|
+
const width = Math.max(minW, TEXT_PAD_X * 2 + maxInnerW);
|
|
768
|
+
const height = Math.max(
|
|
769
|
+
MIN_TEXT_BOX_H,
|
|
770
|
+
baselineY + (lines.length - 1) * lh + Math.max(8, fontSize * 0.35)
|
|
771
|
+
);
|
|
772
|
+
const measured = { width, height };
|
|
773
|
+
cacheMeasuredBounds(cacheKey, measured);
|
|
774
|
+
return measured;
|
|
775
|
+
}
|
|
776
|
+
function buildTextSvg(content, _width, _height, fillColor = "#2563eb", fontSize = DEFAULT_TEXT_FONT_SIZE) {
|
|
777
|
+
const lh = lineHeightFor(fontSize);
|
|
778
|
+
const y0 = firstLineBaselineY(fontSize);
|
|
779
|
+
const trimmed = content.trim();
|
|
780
|
+
if (trimmed.length === 0) {
|
|
781
|
+
return `<text x="4" y="${y0}" font-size="${fontSize}" font-family="system-ui,sans-serif" fill="#94a3b8" font-style="italic">${escapeSvgTextContent(PLACEHOLDER)}</text>`;
|
|
782
|
+
}
|
|
783
|
+
const lines = content.split("\n");
|
|
784
|
+
if (lines.length === 1) {
|
|
785
|
+
return `<text x="4" y="${y0}" font-size="${fontSize}" font-family="system-ui,sans-serif" fill="${fillColor}">${escapeSvgTextContent(lines[0] ?? "")}</text>`;
|
|
786
|
+
}
|
|
787
|
+
const parts = [];
|
|
788
|
+
for (let i = 0; i < lines.length; i++) {
|
|
789
|
+
const line = lines[i] ?? "";
|
|
790
|
+
if (i === 0) {
|
|
791
|
+
parts.push(`<tspan x="4">${escapeSvgTextContent(line)}</tspan>`);
|
|
792
|
+
} else {
|
|
793
|
+
parts.push(`<tspan x="4" dy="${lh}">${escapeSvgTextContent(line)}</tspan>`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return `<text x="4" y="${y0}" font-size="${fontSize}" font-family="system-ui,sans-serif" fill="${fillColor}">${parts.join("")}</text>`;
|
|
797
|
+
}
|
|
798
|
+
function buildTextFixedBoundsSvg(content, width, height, fillColor = "#2563eb", fontSize = DEFAULT_TEXT_FONT_SIZE) {
|
|
799
|
+
const w = Math.max(1, width);
|
|
800
|
+
const h = Math.max(1, height);
|
|
801
|
+
const lh = lineHeightFor(fontSize);
|
|
802
|
+
const trimmed = content.trim();
|
|
803
|
+
if (trimmed.length === 0) {
|
|
804
|
+
return `<foreignObject width="${w}" height="${h}"><div xmlns="http://www.w3.org/1999/xhtml" style="box-sizing:border-box;width:100%;height:100%;margin:0;padding:2px 4px;font-size:${fontSize}px;line-height:${lh}px;font-family:system-ui,sans-serif;white-space:pre-wrap;word-wrap:break-word;overflow:hidden;color:#94a3b8;font-style:italic">${escapeHtmlText(PLACEHOLDER)}</div></foreignObject>`;
|
|
805
|
+
}
|
|
806
|
+
const body = escapeHtmlText(content);
|
|
807
|
+
return `<foreignObject width="${w}" height="${h}"><div xmlns="http://www.w3.org/1999/xhtml" style="box-sizing:border-box;width:100%;height:100%;margin:0;padding:2px 4px;font-size:${fontSize}px;line-height:${lh}px;font-family:system-ui,sans-serif;white-space:pre-wrap;word-wrap:break-word;overflow:hidden;color:${fillColor}">${body}</div></foreignObject>`;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// src/scene/shape-builders.ts
|
|
811
|
+
var DEFAULT_STROKE_STYLE = {
|
|
812
|
+
stroke: "#2563eb",
|
|
813
|
+
strokeWidth: 2
|
|
814
|
+
};
|
|
815
|
+
var TOOL_FREEHAND_DEFAULTS = {
|
|
816
|
+
draw: { strokeWidth: 3 },
|
|
817
|
+
pencil: { strokeWidth: 3 },
|
|
818
|
+
brush: { strokeWidth: 10 },
|
|
819
|
+
marker: { stroke: "#fde047", strokeWidth: 16, strokeOpacity: 0.5 }
|
|
820
|
+
};
|
|
821
|
+
function perfectFreehandOptions(toolKind, style, strokeComplete, pressureAware = false) {
|
|
822
|
+
const sw = style.strokeWidth;
|
|
823
|
+
const base = {
|
|
824
|
+
last: strokeComplete,
|
|
825
|
+
simulatePressure: true
|
|
826
|
+
};
|
|
827
|
+
if (toolKind === "draw" || toolKind === "pencil") {
|
|
828
|
+
if (pressureAware && toolKind === "draw") {
|
|
829
|
+
return {
|
|
830
|
+
...base,
|
|
831
|
+
size: Math.max(2, sw * 1.05),
|
|
832
|
+
thinning: 0.42,
|
|
833
|
+
smoothing: 0.56,
|
|
834
|
+
streamline: 0.18,
|
|
835
|
+
simulatePressure: false
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
...base,
|
|
840
|
+
size: Math.max(2, sw * 1.18),
|
|
841
|
+
thinning: 0.12,
|
|
842
|
+
smoothing: 0.72,
|
|
843
|
+
streamline: 0.42,
|
|
844
|
+
simulatePressure: false
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
if (toolKind === "brush") {
|
|
848
|
+
return {
|
|
849
|
+
...base,
|
|
850
|
+
size: Math.max(4, sw * 1.22),
|
|
851
|
+
thinning: 0.52,
|
|
852
|
+
smoothing: 0.64,
|
|
853
|
+
streamline: 0.68
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
...base,
|
|
858
|
+
size: Math.max(6, sw * 1.08),
|
|
859
|
+
thinning: 0.08,
|
|
860
|
+
smoothing: 0.88,
|
|
861
|
+
streamline: 0.84,
|
|
862
|
+
simulatePressure: false
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
function resolveStrokeStyle(item) {
|
|
866
|
+
return {
|
|
867
|
+
stroke: item.stroke ?? DEFAULT_STROKE_STYLE.stroke,
|
|
868
|
+
strokeWidth: item.strokeWidth ?? DEFAULT_STROKE_STYLE.strokeWidth,
|
|
869
|
+
strokeOpacity: item.strokeOpacity
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
function strokeOpacityAttr(style) {
|
|
873
|
+
return style.strokeOpacity != null ? ` stroke-opacity="${style.strokeOpacity}"` : "";
|
|
874
|
+
}
|
|
875
|
+
function buildRectSvg(width, height, style = DEFAULT_STROKE_STYLE) {
|
|
876
|
+
return `<rect width="${width}" height="${height}" fill="none" stroke="${style.stroke}" stroke-width="${style.strokeWidth}" rx="4"${strokeOpacityAttr(style)} />`;
|
|
877
|
+
}
|
|
878
|
+
function buildEllipseSvg(width, height, style = DEFAULT_STROKE_STYLE) {
|
|
879
|
+
const rx = width / 2;
|
|
880
|
+
const ry = height / 2;
|
|
881
|
+
return `<ellipse cx="${rx}" cy="${ry}" rx="${rx}" ry="${ry}" fill="none" stroke="${style.stroke}" stroke-width="${style.strokeWidth}"${strokeOpacityAttr(style)} />`;
|
|
882
|
+
}
|
|
883
|
+
function buildLineSvg(line, style = DEFAULT_STROKE_STYLE) {
|
|
884
|
+
return `<line x1="${line.x1}" y1="${line.y1}" x2="${line.x2}" y2="${line.y2}" stroke="${style.stroke}" stroke-width="${style.strokeWidth}"${strokeOpacityAttr(style)} />`;
|
|
885
|
+
}
|
|
886
|
+
function computeStraightArrowGeometry(line, strokeWidth) {
|
|
887
|
+
const dx = line.x2 - line.x1;
|
|
888
|
+
const dy = line.y2 - line.y1;
|
|
889
|
+
const len = Math.hypot(dx, dy);
|
|
890
|
+
if (len < 1e-6) return null;
|
|
891
|
+
const ux = dx / len;
|
|
892
|
+
const uy = dy / len;
|
|
893
|
+
const headLength = Math.min(Math.max(strokeWidth * 4.2, 12), len * 0.38);
|
|
894
|
+
const headAngle = Math.PI / 6;
|
|
895
|
+
const cos = Math.cos(headAngle);
|
|
896
|
+
const sin = Math.sin(headAngle);
|
|
897
|
+
const rx1 = ux * cos - uy * sin;
|
|
898
|
+
const ry1 = ux * sin + uy * cos;
|
|
899
|
+
const rx2 = ux * cos + uy * sin;
|
|
900
|
+
const ry2 = -ux * sin + uy * cos;
|
|
901
|
+
return {
|
|
902
|
+
shaftEndX: line.x2,
|
|
903
|
+
shaftEndY: line.y2,
|
|
904
|
+
headTipX: line.x2,
|
|
905
|
+
headTipY: line.y2,
|
|
906
|
+
headLeftX: line.x2 - rx1 * headLength,
|
|
907
|
+
headLeftY: line.y2 - ry1 * headLength,
|
|
908
|
+
headRightX: line.x2 - rx2 * headLength,
|
|
909
|
+
headRightY: line.y2 - ry2 * headLength
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
function buildArrowSvg(itemId, line, style = DEFAULT_STROKE_STYLE) {
|
|
913
|
+
const geometry = computeStraightArrowGeometry(line, style.strokeWidth);
|
|
914
|
+
if (!geometry) {
|
|
915
|
+
return buildLineSvg(line, style);
|
|
916
|
+
}
|
|
917
|
+
const op = strokeOpacityAttr(style);
|
|
918
|
+
return `
|
|
919
|
+
<line x1="${line.x1}" y1="${line.y1}" x2="${geometry.shaftEndX}" y2="${geometry.shaftEndY}" stroke="${style.stroke}" stroke-width="${style.strokeWidth}" stroke-linecap="round"${op} />
|
|
920
|
+
<path d="M ${geometry.headLeftX} ${geometry.headLeftY} L ${geometry.headTipX} ${geometry.headTipY} L ${geometry.headRightX} ${geometry.headRightY}" fill="none" stroke="${style.stroke}" stroke-width="${style.strokeWidth}" stroke-linecap="round" stroke-linejoin="round" shape-rendering="geometricPrecision"${op} />
|
|
921
|
+
`;
|
|
922
|
+
}
|
|
923
|
+
function buildDrawDotSvg(r, style = DEFAULT_STROKE_STYLE) {
|
|
924
|
+
const op = style.strokeOpacity != null ? ` fill-opacity="${style.strokeOpacity}"` : "";
|
|
925
|
+
return `<circle cx="${r}" cy="${r}" r="${r}" fill="${style.stroke}" shape-rendering="geometricPrecision"${op} />`;
|
|
926
|
+
}
|
|
927
|
+
function computeFreehandSvgPayload(pathPointsLocal, style, toolKind, strokeComplete = true) {
|
|
928
|
+
if (pathPointsLocal.length === 0) return null;
|
|
929
|
+
if (pathPointsLocal.length === 1) {
|
|
930
|
+
const p = pathPointsLocal[0];
|
|
931
|
+
if (!p) return null;
|
|
932
|
+
const r = Math.max(0.5, style.strokeWidth / 2);
|
|
933
|
+
return {
|
|
934
|
+
kind: "circle",
|
|
935
|
+
cx: p.x,
|
|
936
|
+
cy: p.y,
|
|
937
|
+
r,
|
|
938
|
+
fill: style.stroke,
|
|
939
|
+
fillOpacity: style.strokeOpacity
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
const minDist = Math.min(0.25, Math.max(0.02, style.strokeWidth * 0.02));
|
|
943
|
+
const pts = dedupeFreehandPoints(pathPointsLocal, minDist);
|
|
944
|
+
if (pts.length === 0) return null;
|
|
945
|
+
if (pts.length === 1) {
|
|
946
|
+
const p = pts[0];
|
|
947
|
+
if (!p) return null;
|
|
948
|
+
const r = Math.max(0.5, style.strokeWidth / 2);
|
|
949
|
+
return {
|
|
950
|
+
kind: "circle",
|
|
951
|
+
cx: p.x,
|
|
952
|
+
cy: p.y,
|
|
953
|
+
r,
|
|
954
|
+
fill: style.stroke,
|
|
955
|
+
fillOpacity: style.strokeOpacity
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
const hasPressure = toolKind === "draw" && pts.some((p) => p.pressure != null && Number.isFinite(p.pressure));
|
|
959
|
+
if (toolKind === "draw" && !hasPressure) {
|
|
960
|
+
const d2 = smoothFreehandPointsToPathD(pts);
|
|
961
|
+
return {
|
|
962
|
+
kind: "strokePath",
|
|
963
|
+
d: d2,
|
|
964
|
+
stroke: style.stroke,
|
|
965
|
+
strokeWidth: style.strokeWidth,
|
|
966
|
+
strokeOpacity: style.strokeOpacity
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
const input = hasPressure ? pts.map(
|
|
970
|
+
(p) => [p.x, p.y, Math.min(1, Math.max(0, p.pressure ?? 0.5))]
|
|
971
|
+
) : pts.map((p) => [p.x, p.y]);
|
|
972
|
+
const opts = perfectFreehandOptions(toolKind, style, strokeComplete, hasPressure);
|
|
973
|
+
let outline = [];
|
|
974
|
+
try {
|
|
975
|
+
const raw = getStroke(input, opts);
|
|
976
|
+
outline = raw.map(([x, y]) => [x, y]);
|
|
977
|
+
} catch {
|
|
978
|
+
outline = [];
|
|
979
|
+
}
|
|
980
|
+
if (outline.length >= 3) {
|
|
981
|
+
const d2 = outlineStrokeToClosedPathD(outline);
|
|
982
|
+
return {
|
|
983
|
+
kind: "fillPath",
|
|
984
|
+
d: d2,
|
|
985
|
+
fill: style.stroke,
|
|
986
|
+
fillOpacity: style.strokeOpacity
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
const d = smoothFreehandPointsToPathD(pts);
|
|
990
|
+
return {
|
|
991
|
+
kind: "strokePath",
|
|
992
|
+
d,
|
|
993
|
+
stroke: style.stroke,
|
|
994
|
+
strokeWidth: style.strokeWidth,
|
|
995
|
+
strokeOpacity: style.strokeOpacity
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
function freehandPayloadToSvgString(payload) {
|
|
999
|
+
if (payload.kind === "circle") {
|
|
1000
|
+
const op2 = payload.fillOpacity != null ? ` fill-opacity="${payload.fillOpacity}"` : "";
|
|
1001
|
+
return `<circle cx="${payload.cx}" cy="${payload.cy}" r="${payload.r}" fill="${payload.fill}" shape-rendering="geometricPrecision"${op2} />`;
|
|
1002
|
+
}
|
|
1003
|
+
if (payload.kind === "fillPath") {
|
|
1004
|
+
const op2 = payload.fillOpacity != null ? ` fill-opacity="${payload.fillOpacity}"` : "";
|
|
1005
|
+
return `<path d="${payload.d}" fill="${payload.fill}" fill-rule="nonzero" stroke="none"${op2} shape-rendering="geometricPrecision" />`;
|
|
1006
|
+
}
|
|
1007
|
+
const op = strokeOpacityAttr({
|
|
1008
|
+
stroke: payload.stroke,
|
|
1009
|
+
strokeWidth: payload.strokeWidth,
|
|
1010
|
+
strokeOpacity: payload.strokeOpacity
|
|
1011
|
+
});
|
|
1012
|
+
return `<path d="${payload.d}" fill="none" stroke="${payload.stroke}" stroke-width="${payload.strokeWidth}" stroke-linecap="round" stroke-linejoin="round" shape-rendering="geometricPrecision"${op} />`;
|
|
1013
|
+
}
|
|
1014
|
+
function buildFreehandPathSvg(pathPointsLocal, style, toolKind, strokeComplete = true) {
|
|
1015
|
+
const payload = computeFreehandSvgPayload(
|
|
1016
|
+
pathPointsLocal,
|
|
1017
|
+
style,
|
|
1018
|
+
toolKind,
|
|
1019
|
+
strokeComplete
|
|
1020
|
+
);
|
|
1021
|
+
if (!payload) return "";
|
|
1022
|
+
return freehandPayloadToSvgString(payload);
|
|
1023
|
+
}
|
|
1024
|
+
function createShapeId() {
|
|
1025
|
+
const uid = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Date.now());
|
|
1026
|
+
return `user-shape-${uid}`;
|
|
1027
|
+
}
|
|
1028
|
+
function lineEndpointsToLocal(bounds, worldEndA, worldEndB) {
|
|
1029
|
+
const b = normalizeRect(bounds);
|
|
1030
|
+
return {
|
|
1031
|
+
x1: worldEndA.x - b.x,
|
|
1032
|
+
y1: worldEndA.y - b.y,
|
|
1033
|
+
x2: worldEndB.x - b.x,
|
|
1034
|
+
y2: worldEndB.y - b.y
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
function rebuildItemSvg(item) {
|
|
1038
|
+
const style = resolveStrokeStyle(item);
|
|
1039
|
+
const k = item.toolKind;
|
|
1040
|
+
if (k === "rect") {
|
|
1041
|
+
const b = normalizeRect(item.bounds);
|
|
1042
|
+
return {
|
|
1043
|
+
...item,
|
|
1044
|
+
childrenSvg: buildRectSvg(b.width, b.height, style)
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
if (k === "ellipse") {
|
|
1048
|
+
const b = normalizeRect(item.bounds);
|
|
1049
|
+
return {
|
|
1050
|
+
...item,
|
|
1051
|
+
childrenSvg: buildEllipseSvg(b.width, b.height, style)
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
if ((k === "line" || k === "arrow") && item.line) {
|
|
1055
|
+
const line = item.line;
|
|
1056
|
+
const childrenSvg = k === "arrow" ? buildArrowSvg(item.id, line, style) : buildLineSvg(line, style);
|
|
1057
|
+
return { ...item, childrenSvg };
|
|
1058
|
+
}
|
|
1059
|
+
if (k === "text" && item.text !== void 0) {
|
|
1060
|
+
const fs = item.textFontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
1061
|
+
if (item.textFixedBounds) {
|
|
1062
|
+
const b2 = normalizeRect(item.bounds);
|
|
1063
|
+
return {
|
|
1064
|
+
...item,
|
|
1065
|
+
x: b2.x,
|
|
1066
|
+
y: b2.y,
|
|
1067
|
+
bounds: b2,
|
|
1068
|
+
childrenSvg: buildTextFixedBoundsSvg(
|
|
1069
|
+
item.text,
|
|
1070
|
+
b2.width,
|
|
1071
|
+
b2.height,
|
|
1072
|
+
style.stroke,
|
|
1073
|
+
fs
|
|
1074
|
+
)
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
const m = measureTextBoundsLocal(item.text, fs);
|
|
1078
|
+
const b = normalizeRect({
|
|
1079
|
+
x: item.x,
|
|
1080
|
+
y: item.y,
|
|
1081
|
+
width: m.width,
|
|
1082
|
+
height: m.height
|
|
1083
|
+
});
|
|
1084
|
+
return {
|
|
1085
|
+
...item,
|
|
1086
|
+
x: b.x,
|
|
1087
|
+
y: b.y,
|
|
1088
|
+
bounds: b,
|
|
1089
|
+
childrenSvg: buildTextSvg(item.text, b.width, b.height, style.stroke, fs)
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
if ((k === "draw" || k === "pencil" || k === "brush" || k === "marker") && item.pathPointsLocal && item.pathPointsLocal.length > 0) {
|
|
1093
|
+
return {
|
|
1094
|
+
...item,
|
|
1095
|
+
childrenSvg: buildFreehandPathSvg(item.pathPointsLocal, style, k)
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
if (k === "draw") {
|
|
1099
|
+
const b = normalizeRect(item.bounds);
|
|
1100
|
+
const r = Math.min(b.width, b.height) / 2;
|
|
1101
|
+
return {
|
|
1102
|
+
...item,
|
|
1103
|
+
childrenSvg: buildDrawDotSvg(r, style)
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
if (k === "image" && item.imageRasterHref && item.imageIntrinsicSize) {
|
|
1107
|
+
const b = normalizeRect(item.bounds);
|
|
1108
|
+
return {
|
|
1109
|
+
...item,
|
|
1110
|
+
childrenSvg: buildRasterImageChildrenSvg(
|
|
1111
|
+
item.imageRasterHref,
|
|
1112
|
+
item.imageIntrinsicSize,
|
|
1113
|
+
b
|
|
1114
|
+
)
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
if (k === "custom" && item.customIntrinsicSize && item.customInnerSvg) {
|
|
1118
|
+
const b = normalizeRect(item.bounds);
|
|
1119
|
+
return {
|
|
1120
|
+
...item,
|
|
1121
|
+
x: b.x,
|
|
1122
|
+
y: b.y,
|
|
1123
|
+
bounds: b,
|
|
1124
|
+
childrenSvg: buildCustomShapeChildrenSvg(
|
|
1125
|
+
item.customInnerSvg,
|
|
1126
|
+
item.customIntrinsicSize,
|
|
1127
|
+
b
|
|
1128
|
+
)
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
return item;
|
|
1132
|
+
}
|
|
1133
|
+
function applyStrokeToItem(item, patch) {
|
|
1134
|
+
return rebuildItemSvg({ ...item, ...patch });
|
|
1135
|
+
}
|
|
1136
|
+
function createRectangleItem(id, bounds, style) {
|
|
1137
|
+
const r = normalizeRect(bounds);
|
|
1138
|
+
const s = { ...DEFAULT_STROKE_STYLE, ...style };
|
|
1139
|
+
return rebuildItemSvg({
|
|
1140
|
+
id,
|
|
1141
|
+
x: r.x,
|
|
1142
|
+
y: r.y,
|
|
1143
|
+
bounds: { ...r },
|
|
1144
|
+
toolKind: "rect",
|
|
1145
|
+
stroke: s.stroke,
|
|
1146
|
+
strokeWidth: s.strokeWidth,
|
|
1147
|
+
...s.strokeOpacity != null ? { strokeOpacity: s.strokeOpacity } : {},
|
|
1148
|
+
childrenSvg: ""
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
function createEllipseItem(id, bounds, style) {
|
|
1152
|
+
const r = normalizeRect(bounds);
|
|
1153
|
+
const s = { ...DEFAULT_STROKE_STYLE, ...style };
|
|
1154
|
+
return rebuildItemSvg({
|
|
1155
|
+
id,
|
|
1156
|
+
x: r.x,
|
|
1157
|
+
y: r.y,
|
|
1158
|
+
bounds: { ...r },
|
|
1159
|
+
toolKind: "ellipse",
|
|
1160
|
+
stroke: s.stroke,
|
|
1161
|
+
strokeWidth: s.strokeWidth,
|
|
1162
|
+
...s.strokeOpacity != null ? { strokeOpacity: s.strokeOpacity } : {},
|
|
1163
|
+
childrenSvg: ""
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
function createLineItem(id, bounds, line, toolKind, style, arrowBind) {
|
|
1167
|
+
const r = normalizeRect(bounds);
|
|
1168
|
+
const s = { ...DEFAULT_STROKE_STYLE, ...style };
|
|
1169
|
+
return rebuildItemSvg({
|
|
1170
|
+
id,
|
|
1171
|
+
x: r.x,
|
|
1172
|
+
y: r.y,
|
|
1173
|
+
bounds: { ...r },
|
|
1174
|
+
toolKind,
|
|
1175
|
+
line: { ...line },
|
|
1176
|
+
stroke: s.stroke,
|
|
1177
|
+
strokeWidth: s.strokeWidth,
|
|
1178
|
+
...s.strokeOpacity != null ? { strokeOpacity: s.strokeOpacity } : {},
|
|
1179
|
+
...arrowBind ? { arrowBind } : {},
|
|
1180
|
+
childrenSvg: ""
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
function createDrawDotItem(id, worldX, worldY, radius, style) {
|
|
1184
|
+
const s = { ...DEFAULT_STROKE_STYLE, ...style };
|
|
1185
|
+
return rebuildItemSvg({
|
|
1186
|
+
id,
|
|
1187
|
+
x: worldX - radius,
|
|
1188
|
+
y: worldY - radius,
|
|
1189
|
+
bounds: {
|
|
1190
|
+
x: worldX - radius,
|
|
1191
|
+
y: worldY - radius,
|
|
1192
|
+
width: radius * 2,
|
|
1193
|
+
height: radius * 2
|
|
1194
|
+
},
|
|
1195
|
+
toolKind: "draw",
|
|
1196
|
+
stroke: s.stroke,
|
|
1197
|
+
strokeWidth: s.strokeWidth,
|
|
1198
|
+
...s.strokeOpacity != null ? { strokeOpacity: s.strokeOpacity } : {},
|
|
1199
|
+
childrenSvg: ""
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
function createTextItem(id, bounds, text = "", style) {
|
|
1203
|
+
const r = normalizeRect(bounds);
|
|
1204
|
+
const s = { ...DEFAULT_STROKE_STYLE, ...style };
|
|
1205
|
+
return rebuildItemSvg({
|
|
1206
|
+
id,
|
|
1207
|
+
x: r.x,
|
|
1208
|
+
y: r.y,
|
|
1209
|
+
bounds: { ...r },
|
|
1210
|
+
toolKind: "text",
|
|
1211
|
+
text,
|
|
1212
|
+
stroke: s.stroke,
|
|
1213
|
+
strokeWidth: s.strokeWidth,
|
|
1214
|
+
...s.strokeOpacity != null ? { strokeOpacity: s.strokeOpacity } : {},
|
|
1215
|
+
childrenSvg: ""
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
function createFreehandStrokeItem(id, pointsWorld, toolKind, style) {
|
|
1219
|
+
if (pointsWorld.length === 0) return null;
|
|
1220
|
+
const merged = {
|
|
1221
|
+
...DEFAULT_STROKE_STYLE,
|
|
1222
|
+
...TOOL_FREEHAND_DEFAULTS[toolKind],
|
|
1223
|
+
...style
|
|
1224
|
+
};
|
|
1225
|
+
const sw = merged.strokeWidth;
|
|
1226
|
+
const pad = Math.max(sw * 0.75 + 4, sw / 2 + 6);
|
|
1227
|
+
const xs = pointsWorld.map((p) => p.x);
|
|
1228
|
+
const ys = pointsWorld.map((p) => p.y);
|
|
1229
|
+
const minX = Math.min(...xs);
|
|
1230
|
+
const maxX = Math.max(...xs);
|
|
1231
|
+
const minY = Math.min(...ys);
|
|
1232
|
+
const maxY = Math.max(...ys);
|
|
1233
|
+
const x = minX - pad;
|
|
1234
|
+
const y = minY - pad;
|
|
1235
|
+
const w = Math.max(maxX - minX + 2 * pad, sw);
|
|
1236
|
+
const h = Math.max(maxY - minY + 2 * pad, sw);
|
|
1237
|
+
const pathPointsLocal = pointsWorld.map((p) => ({
|
|
1238
|
+
x: p.x - x,
|
|
1239
|
+
y: p.y - y,
|
|
1240
|
+
...p.pressure != null ? { pressure: p.pressure } : {}
|
|
1241
|
+
}));
|
|
1242
|
+
return rebuildItemSvg({
|
|
1243
|
+
id,
|
|
1244
|
+
x,
|
|
1245
|
+
y,
|
|
1246
|
+
bounds: { x, y, width: w, height: h },
|
|
1247
|
+
toolKind,
|
|
1248
|
+
stroke: merged.stroke,
|
|
1249
|
+
strokeWidth: merged.strokeWidth,
|
|
1250
|
+
...merged.strokeOpacity != null ? { strokeOpacity: merged.strokeOpacity } : {},
|
|
1251
|
+
pathPointsLocal,
|
|
1252
|
+
childrenSvg: ""
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
function escapeSvgAttr(s) {
|
|
1256
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
1257
|
+
}
|
|
1258
|
+
function buildRasterImageChildrenSvg(dataUrl, intrinsic, bounds) {
|
|
1259
|
+
const r = normalizeRect(bounds);
|
|
1260
|
+
const iw = Math.max(1e-6, intrinsic.width);
|
|
1261
|
+
const ih = Math.max(1e-6, intrinsic.height);
|
|
1262
|
+
const href = escapeSvgAttr(dataUrl);
|
|
1263
|
+
const arB = r.width / Math.max(1e-9, r.height);
|
|
1264
|
+
const arI = iw / ih;
|
|
1265
|
+
if (Math.abs(arB - arI) < 1e-3) {
|
|
1266
|
+
const s2 = r.width / iw;
|
|
1267
|
+
return `<g transform="scale(${s2})"><image href="${href}" x="0" y="0" width="${iw}" height="${ih}" /></g>`;
|
|
1268
|
+
}
|
|
1269
|
+
const s = Math.min(r.width / iw, r.height / ih);
|
|
1270
|
+
const tx = (r.width - iw * s) / 2;
|
|
1271
|
+
const ty = (r.height - ih * s) / 2;
|
|
1272
|
+
return `<g transform="translate(${tx}, ${ty}) scale(${s})"><image href="${href}" x="0" y="0" width="${iw}" height="${ih}" /></g>`;
|
|
1273
|
+
}
|
|
1274
|
+
function createImageItem(id, bounds, imageRasterHref, imageIntrinsicSize) {
|
|
1275
|
+
const r = normalizeRect(bounds);
|
|
1276
|
+
const iw = Math.max(1e-6, imageIntrinsicSize.width);
|
|
1277
|
+
const ih = Math.max(1e-6, imageIntrinsicSize.height);
|
|
1278
|
+
return {
|
|
1279
|
+
id,
|
|
1280
|
+
x: r.x,
|
|
1281
|
+
y: r.y,
|
|
1282
|
+
bounds: { ...r },
|
|
1283
|
+
toolKind: "image",
|
|
1284
|
+
imageRasterHref,
|
|
1285
|
+
imageIntrinsicSize: { width: iw, height: ih },
|
|
1286
|
+
childrenSvg: buildRasterImageChildrenSvg(
|
|
1287
|
+
imageRasterHref,
|
|
1288
|
+
{ width: iw, height: ih },
|
|
1289
|
+
r
|
|
1290
|
+
)
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
function createImageFromVectorTrace(id, bounds, imageVectorInnerSvg, imageVectorLocalSize) {
|
|
1294
|
+
const r = normalizeRect(bounds);
|
|
1295
|
+
const lw = Math.max(1e-6, imageVectorLocalSize.width);
|
|
1296
|
+
const lh = Math.max(1e-6, imageVectorLocalSize.height);
|
|
1297
|
+
const arB = r.width / Math.max(1e-9, r.height);
|
|
1298
|
+
const arI = lw / lh;
|
|
1299
|
+
let childrenSvg;
|
|
1300
|
+
if (Math.abs(arB - arI) < 1e-3) {
|
|
1301
|
+
const s = r.width / lw;
|
|
1302
|
+
childrenSvg = `<g transform="scale(${s})">${imageVectorInnerSvg}</g>`;
|
|
1303
|
+
} else {
|
|
1304
|
+
const s = Math.min(r.width / lw, r.height / lh);
|
|
1305
|
+
const tx = (r.width - lw * s) / 2;
|
|
1306
|
+
const ty = (r.height - lh * s) / 2;
|
|
1307
|
+
childrenSvg = `<g transform="translate(${tx}, ${ty}) scale(${s})">${imageVectorInnerSvg}</g>`;
|
|
1308
|
+
}
|
|
1309
|
+
return {
|
|
1310
|
+
id,
|
|
1311
|
+
x: r.x,
|
|
1312
|
+
y: r.y,
|
|
1313
|
+
bounds: { ...r },
|
|
1314
|
+
toolKind: "image",
|
|
1315
|
+
imageVectorInnerSvg,
|
|
1316
|
+
imageVectorLocalSize: { width: lw, height: lh },
|
|
1317
|
+
childrenSvg
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// src/interaction/resize-handles.ts
|
|
1322
|
+
function rectFromCorners(a, b) {
|
|
1323
|
+
const minX = Math.min(a.x, b.x);
|
|
1324
|
+
const maxX = Math.max(a.x, b.x);
|
|
1325
|
+
const minY = Math.min(a.y, b.y);
|
|
1326
|
+
const maxY = Math.max(a.y, b.y);
|
|
1327
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/interaction/arrow-bindings.ts
|
|
1331
|
+
var ARROW_BIND_SNAP_PX = 16;
|
|
1332
|
+
function closestPointOnSegment(px, py, ax, ay, bx, by) {
|
|
1333
|
+
const abx = bx - ax;
|
|
1334
|
+
const aby = by - ay;
|
|
1335
|
+
const apx = px - ax;
|
|
1336
|
+
const apy = py - ay;
|
|
1337
|
+
const abLenSq = abx * abx + aby * aby;
|
|
1338
|
+
if (abLenSq < 1e-12) {
|
|
1339
|
+
return { x: ax, y: ay };
|
|
1340
|
+
}
|
|
1341
|
+
let t = (apx * abx + apy * aby) / abLenSq;
|
|
1342
|
+
t = Math.max(0, Math.min(1, t));
|
|
1343
|
+
return { x: ax + t * abx, y: ay + t * aby };
|
|
1344
|
+
}
|
|
1345
|
+
function closestPointOnRectBoundaryLocal(px, py, w, h) {
|
|
1346
|
+
const edges = [
|
|
1347
|
+
[0, 0, w, 0],
|
|
1348
|
+
[w, 0, w, h],
|
|
1349
|
+
[w, h, 0, h],
|
|
1350
|
+
[0, h, 0, 0]
|
|
1351
|
+
];
|
|
1352
|
+
let best = { x: 0, y: 0, d: Infinity };
|
|
1353
|
+
for (const [ax, ay, bx, by] of edges) {
|
|
1354
|
+
const p = closestPointOnSegment(px, py, ax, ay, bx, by);
|
|
1355
|
+
const d = Math.hypot(px - p.x, py - p.y);
|
|
1356
|
+
if (d < best.d) {
|
|
1357
|
+
best = { x: p.x, y: p.y, d };
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return { x: best.x, y: best.y };
|
|
1361
|
+
}
|
|
1362
|
+
function closestPointOnEllipseBoundaryLocal(px, py, w, h) {
|
|
1363
|
+
const cx = w / 2;
|
|
1364
|
+
const cy = h / 2;
|
|
1365
|
+
const rx = w / 2;
|
|
1366
|
+
const ry = h / 2;
|
|
1367
|
+
if (rx < 1e-9 || ry < 1e-9) {
|
|
1368
|
+
return { x: cx, y: cy };
|
|
1369
|
+
}
|
|
1370
|
+
const dx = px - cx;
|
|
1371
|
+
const dy = py - cy;
|
|
1372
|
+
if (dx === 0 && dy === 0) {
|
|
1373
|
+
return { x: cx + rx, y: cy };
|
|
1374
|
+
}
|
|
1375
|
+
const t = Math.atan2(dy / ry, dx / rx);
|
|
1376
|
+
return {
|
|
1377
|
+
x: cx + rx * Math.cos(t),
|
|
1378
|
+
y: cy + ry * Math.sin(t)
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
function isArrowBindTarget(item) {
|
|
1382
|
+
if (item.locked) return false;
|
|
1383
|
+
const k = item.toolKind;
|
|
1384
|
+
if (!k) return false;
|
|
1385
|
+
return k === "rect" || k === "ellipse" || k === "text" || k === "image" || k === "custom";
|
|
1386
|
+
}
|
|
1387
|
+
function closestPointOnShapeBoundaryLocal(item, lx, ly, w, h) {
|
|
1388
|
+
if (item.toolKind === "ellipse") {
|
|
1389
|
+
return closestPointOnEllipseBoundaryLocal(lx, ly, w, h);
|
|
1390
|
+
}
|
|
1391
|
+
return closestPointOnRectBoundaryLocal(lx, ly, w, h);
|
|
1392
|
+
}
|
|
1393
|
+
function anchorNormalizedToWorld(target, anchor) {
|
|
1394
|
+
const b = normalizeRect(target.bounds);
|
|
1395
|
+
const w = b.width;
|
|
1396
|
+
const h = b.height;
|
|
1397
|
+
const lx = anchor.x * w;
|
|
1398
|
+
const ly = anchor.y * h;
|
|
1399
|
+
const rot = getItemRotationRad(target);
|
|
1400
|
+
return itemLocalToWorld(lx, ly, target.x, target.y, w, h, rot);
|
|
1401
|
+
}
|
|
1402
|
+
function snapArrowEndpointToShape(worldX, worldY, items, excludeIds, maxDistWorld) {
|
|
1403
|
+
let best = null;
|
|
1404
|
+
for (const item of items) {
|
|
1405
|
+
if (excludeIds.has(item.id)) continue;
|
|
1406
|
+
if (!isArrowBindTarget(item)) continue;
|
|
1407
|
+
const b = normalizeRect(item.bounds);
|
|
1408
|
+
const w = b.width;
|
|
1409
|
+
const h = b.height;
|
|
1410
|
+
const rot = getItemRotationRad(item);
|
|
1411
|
+
const pl = worldToItemLocal(worldX, worldY, item.x, item.y, w, h, rot);
|
|
1412
|
+
const localClosest = closestPointOnShapeBoundaryLocal(item, pl.x, pl.y, w, h);
|
|
1413
|
+
const worldClosest = itemLocalToWorld(
|
|
1414
|
+
localClosest.x,
|
|
1415
|
+
localClosest.y,
|
|
1416
|
+
item.x,
|
|
1417
|
+
item.y,
|
|
1418
|
+
w,
|
|
1419
|
+
h,
|
|
1420
|
+
rot
|
|
1421
|
+
);
|
|
1422
|
+
const d = Math.hypot(worldX - worldClosest.x, worldY - worldClosest.y);
|
|
1423
|
+
if (d > maxDistWorld) continue;
|
|
1424
|
+
if (!best || d < best.d) {
|
|
1425
|
+
best = {
|
|
1426
|
+
d,
|
|
1427
|
+
world: worldClosest,
|
|
1428
|
+
binding: {
|
|
1429
|
+
targetId: item.id,
|
|
1430
|
+
anchor: {
|
|
1431
|
+
x: localClosest.x / Math.max(1e-9, w),
|
|
1432
|
+
y: localClosest.y / Math.max(1e-9, h)
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
return best ? { world: best.world, binding: best.binding } : null;
|
|
1439
|
+
}
|
|
1440
|
+
function resolveOneArrowItem(item, map) {
|
|
1441
|
+
const bind = item.arrowBind;
|
|
1442
|
+
if (!bind || item.toolKind !== "arrow") return item;
|
|
1443
|
+
const ln = item.line;
|
|
1444
|
+
if (!ln) return item;
|
|
1445
|
+
const b0 = normalizeRect(item.bounds);
|
|
1446
|
+
const rot = getItemRotationRad(item);
|
|
1447
|
+
const w0 = b0.width;
|
|
1448
|
+
const h0 = b0.height;
|
|
1449
|
+
const startFallback = itemLocalToWorld(ln.x1, ln.y1, item.x, item.y, w0, h0, rot);
|
|
1450
|
+
const endFallback = itemLocalToWorld(ln.x2, ln.y2, item.x, item.y, w0, h0, rot);
|
|
1451
|
+
const startW = bind.start ? (() => {
|
|
1452
|
+
const t = map.get(bind.start.targetId);
|
|
1453
|
+
return t ? anchorNormalizedToWorld(t, bind.start.anchor) : startFallback;
|
|
1454
|
+
})() : startFallback;
|
|
1455
|
+
const endW = bind.end ? (() => {
|
|
1456
|
+
const t = map.get(bind.end.targetId);
|
|
1457
|
+
return t ? anchorNormalizedToWorld(t, bind.end.anchor) : endFallback;
|
|
1458
|
+
})() : endFallback;
|
|
1459
|
+
const raw = rectFromCorners(startW, endW);
|
|
1460
|
+
const br = normalizeRect(raw);
|
|
1461
|
+
const newLine = lineEndpointsToLocal(br, startW, endW);
|
|
1462
|
+
const next = {
|
|
1463
|
+
...item,
|
|
1464
|
+
x: br.x,
|
|
1465
|
+
y: br.y,
|
|
1466
|
+
bounds: br,
|
|
1467
|
+
line: newLine,
|
|
1468
|
+
rotation: 0
|
|
1469
|
+
};
|
|
1470
|
+
return rebuildItemSvg(next);
|
|
1471
|
+
}
|
|
1472
|
+
function resolveArrowBindingsInScene(items) {
|
|
1473
|
+
let hasBoundArrow = false;
|
|
1474
|
+
for (const item of items) {
|
|
1475
|
+
if (item.toolKind === "arrow" && item.arrowBind) {
|
|
1476
|
+
hasBoundArrow = true;
|
|
1477
|
+
break;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (!hasBoundArrow) {
|
|
1481
|
+
return items;
|
|
1482
|
+
}
|
|
1483
|
+
const map = new Map(items.map((i) => [i.id, i]));
|
|
1484
|
+
return items.map((item) => {
|
|
1485
|
+
if (item.toolKind !== "arrow" || !item.arrowBind) return item;
|
|
1486
|
+
return resolveOneArrowItem(item, map);
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
function bakeArrowItemToAbsolute(item, items) {
|
|
1490
|
+
if (item.toolKind !== "arrow" || !item.arrowBind) return item;
|
|
1491
|
+
const map = new Map(items.map((i) => [i.id, i]));
|
|
1492
|
+
const resolved = resolveOneArrowItem(item, map);
|
|
1493
|
+
const { arrowBind: _a, ...rest } = resolved;
|
|
1494
|
+
return { ...rest };
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// src/interaction/hit-test.ts
|
|
1498
|
+
function pointInLocalRect(lx, ly, w, h) {
|
|
1499
|
+
return lx >= 0 && lx <= w && ly >= 0 && ly <= h;
|
|
1500
|
+
}
|
|
1501
|
+
function pointInLocalEllipse(lx, ly, w, h) {
|
|
1502
|
+
const rx = w / 2;
|
|
1503
|
+
const ry = h / 2;
|
|
1504
|
+
if (rx < 1e-9 || ry < 1e-9) {
|
|
1505
|
+
return false;
|
|
1506
|
+
}
|
|
1507
|
+
const cx = w / 2;
|
|
1508
|
+
const cy = h / 2;
|
|
1509
|
+
const nx = (lx - cx) / rx;
|
|
1510
|
+
const ny = (ly - cy) / ry;
|
|
1511
|
+
return nx * nx + ny * ny <= 1;
|
|
1512
|
+
}
|
|
1513
|
+
function distancePointToSegment(px, py, ax, ay, bx, by) {
|
|
1514
|
+
const abx = bx - ax;
|
|
1515
|
+
const aby = by - ay;
|
|
1516
|
+
const apx = px - ax;
|
|
1517
|
+
const apy = py - ay;
|
|
1518
|
+
const abLenSq = abx * abx + aby * aby;
|
|
1519
|
+
if (abLenSq < 1e-12) {
|
|
1520
|
+
return Math.hypot(px - ax, py - ay);
|
|
1521
|
+
}
|
|
1522
|
+
let t = (apx * abx + apy * aby) / abLenSq;
|
|
1523
|
+
t = Math.max(0, Math.min(1, t));
|
|
1524
|
+
const qx = ax + t * abx;
|
|
1525
|
+
const qy = ay + t * aby;
|
|
1526
|
+
return Math.hypot(px - qx, py - qy);
|
|
1527
|
+
}
|
|
1528
|
+
function hitTestFilledShape(item, worldX, worldY) {
|
|
1529
|
+
const b = normalizeRect(item.bounds);
|
|
1530
|
+
const w = b.width;
|
|
1531
|
+
const h = b.height;
|
|
1532
|
+
const rot = getItemRotationRad(item);
|
|
1533
|
+
const pl = worldToItemLocal(worldX, worldY, item.x, item.y, w, h, rot);
|
|
1534
|
+
if (item.toolKind === "ellipse") {
|
|
1535
|
+
return pointInLocalEllipse(pl.x, pl.y, w, h);
|
|
1536
|
+
}
|
|
1537
|
+
return pointInLocalRect(pl.x, pl.y, w, h);
|
|
1538
|
+
}
|
|
1539
|
+
function itemHitTestWorldPoint(item, worldX, worldY, options) {
|
|
1540
|
+
const lineHit = options?.lineHitWorld ?? 8;
|
|
1541
|
+
const lineHitSq = lineHit * lineHit;
|
|
1542
|
+
const b = normalizeRect(item.bounds);
|
|
1543
|
+
const w = b.width;
|
|
1544
|
+
const h = b.height;
|
|
1545
|
+
const rot = getItemRotationRad(item);
|
|
1546
|
+
if (item.toolKind === "line" || item.toolKind === "arrow") {
|
|
1547
|
+
const ln = item.line;
|
|
1548
|
+
if (ln) {
|
|
1549
|
+
const a = itemLocalToWorld(ln.x1, ln.y1, item.x, item.y, w, h, rot);
|
|
1550
|
+
const p2 = itemLocalToWorld(ln.x2, ln.y2, item.x, item.y, w, h, rot);
|
|
1551
|
+
const d = distancePointToSegment(worldX, worldY, a.x, a.y, p2.x, p2.y);
|
|
1552
|
+
if (d * d <= lineHitSq) {
|
|
1553
|
+
return true;
|
|
1554
|
+
}
|
|
1555
|
+
} else if (hitTestFilledShape(item, worldX, worldY)) {
|
|
1556
|
+
return true;
|
|
1557
|
+
}
|
|
1558
|
+
return false;
|
|
1559
|
+
}
|
|
1560
|
+
if (item.toolKind === "draw" || item.toolKind === "pencil" || item.toolKind === "brush" || item.toolKind === "marker") {
|
|
1561
|
+
const pts = item.pathPointsLocal;
|
|
1562
|
+
const halfW = Math.max((item.strokeWidth ?? 2) / 2, lineHit * 0.5);
|
|
1563
|
+
const tol = Math.max(lineHit, halfW);
|
|
1564
|
+
const tolSq = tol * tol;
|
|
1565
|
+
if (pts && pts.length >= 2) {
|
|
1566
|
+
for (let j = 0; j < pts.length - 1; j++) {
|
|
1567
|
+
const a = pts[j];
|
|
1568
|
+
const p2 = pts[j + 1];
|
|
1569
|
+
if (!a || !p2) continue;
|
|
1570
|
+
const aw = itemLocalToWorld(a.x, a.y, item.x, item.y, w, h, rot);
|
|
1571
|
+
const bw = itemLocalToWorld(p2.x, p2.y, item.x, item.y, w, h, rot);
|
|
1572
|
+
const d = distancePointToSegment(worldX, worldY, aw.x, aw.y, bw.x, bw.y);
|
|
1573
|
+
if (d * d <= tolSq) {
|
|
1574
|
+
return true;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
if (pts?.length === 1) {
|
|
1579
|
+
const p = pts[0];
|
|
1580
|
+
if (p) {
|
|
1581
|
+
const cw = itemLocalToWorld(p.x, p.y, item.x, item.y, w, h, rot);
|
|
1582
|
+
const dsq = (worldX - cw.x) ** 2 + (worldY - cw.y) ** 2;
|
|
1583
|
+
if (dsq <= tolSq) {
|
|
1584
|
+
return true;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
return hitTestFilledShape(item, worldX, worldY);
|
|
1589
|
+
}
|
|
1590
|
+
return hitTestFilledShape(item, worldX, worldY);
|
|
1591
|
+
}
|
|
1592
|
+
function hitTestWorldPoint(items, worldX, worldY, options) {
|
|
1593
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
1594
|
+
const item = items[i];
|
|
1595
|
+
if (item === void 0) continue;
|
|
1596
|
+
if (options?.ignoreLocked && item.locked) continue;
|
|
1597
|
+
if (itemHitTestWorldPoint(item, worldX, worldY, options)) {
|
|
1598
|
+
return item;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
return null;
|
|
1602
|
+
}
|
|
1603
|
+
function collectEraserTargetsAtWorldPoint(items, worldX, worldY, options) {
|
|
1604
|
+
const topHit = hitTestWorldPoint(items, worldX, worldY, {
|
|
1605
|
+
...options,
|
|
1606
|
+
ignoreLocked: true
|
|
1607
|
+
});
|
|
1608
|
+
if (!topHit) return [];
|
|
1609
|
+
const ids = [];
|
|
1610
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1611
|
+
for (let i = 0; i < items.length; i++) {
|
|
1612
|
+
const item = items[i];
|
|
1613
|
+
if (item === void 0 || item.locked) continue;
|
|
1614
|
+
if (!itemHitTestWorldPoint(item, worldX, worldY, options)) continue;
|
|
1615
|
+
if (!seen.has(item.id)) {
|
|
1616
|
+
seen.add(item.id);
|
|
1617
|
+
ids.push(item.id);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return ids;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// src/scene/spatial-cull.ts
|
|
1624
|
+
var spatialIndexCache = /* @__PURE__ */ new WeakMap();
|
|
1625
|
+
function cellKey(ix, iy) {
|
|
1626
|
+
return `${ix},${iy}`;
|
|
1627
|
+
}
|
|
1628
|
+
function buildSpatialIndex(items, cellSize) {
|
|
1629
|
+
const cached = spatialIndexCache.get(items);
|
|
1630
|
+
if (cached && cached.cellSize === cellSize) {
|
|
1631
|
+
return cached;
|
|
1632
|
+
}
|
|
1633
|
+
const aabbs = new Array(items.length);
|
|
1634
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
1635
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
1636
|
+
const item = items[index];
|
|
1637
|
+
if (!item) continue;
|
|
1638
|
+
const aabb = boundsAabbForRotatedItem(item);
|
|
1639
|
+
aabbs[index] = aabb;
|
|
1640
|
+
const { minIx, maxIx, minIy, maxIy } = cellRangeForRect(aabb, cellSize);
|
|
1641
|
+
for (let ix = minIx; ix <= maxIx; ix += 1) {
|
|
1642
|
+
for (let iy = minIy; iy <= maxIy; iy += 1) {
|
|
1643
|
+
const key = cellKey(ix, iy);
|
|
1644
|
+
let bucket = buckets.get(key);
|
|
1645
|
+
if (!bucket) {
|
|
1646
|
+
bucket = [];
|
|
1647
|
+
buckets.set(key, bucket);
|
|
1648
|
+
}
|
|
1649
|
+
bucket.push(index);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
const next = {
|
|
1654
|
+
cellSize,
|
|
1655
|
+
aabbs,
|
|
1656
|
+
buckets
|
|
1657
|
+
};
|
|
1658
|
+
spatialIndexCache.set(items, next);
|
|
1659
|
+
return next;
|
|
1660
|
+
}
|
|
1661
|
+
function cellRangeForRect(r, cellSize) {
|
|
1662
|
+
const n = normalizeRect(r);
|
|
1663
|
+
const x1 = n.x + n.width;
|
|
1664
|
+
const y1 = n.y + n.height;
|
|
1665
|
+
const minIx = Math.floor(n.x / cellSize);
|
|
1666
|
+
const maxIx = Math.max(minIx, Math.ceil(x1 / cellSize) - 1);
|
|
1667
|
+
const minIy = Math.floor(n.y / cellSize);
|
|
1668
|
+
const maxIy = Math.max(minIy, Math.ceil(y1 / cellSize) - 1);
|
|
1669
|
+
return { minIx, maxIx, minIy, maxIy };
|
|
1670
|
+
}
|
|
1671
|
+
function cullItemsByViewportSpatial(items, visibleWorld, cellSize) {
|
|
1672
|
+
const { aabbs, buckets } = buildSpatialIndex(items, cellSize);
|
|
1673
|
+
const vr = cellRangeForRect(visibleWorld, cellSize);
|
|
1674
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1675
|
+
const outIndices = [];
|
|
1676
|
+
for (let ix = vr.minIx; ix <= vr.maxIx; ix++) {
|
|
1677
|
+
for (let iy = vr.minIy; iy <= vr.maxIy; iy++) {
|
|
1678
|
+
const bucket = buckets.get(cellKey(ix, iy));
|
|
1679
|
+
if (!bucket) {
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
for (const index of bucket) {
|
|
1683
|
+
if (seen.has(index)) {
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
seen.add(index);
|
|
1687
|
+
const aabb = aabbs[index];
|
|
1688
|
+
if (aabb && rectsIntersect(aabb, visibleWorld)) {
|
|
1689
|
+
outIndices.push(index);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
outIndices.sort((a, b) => a - b);
|
|
1695
|
+
return outIndices.map((index) => items[index]).filter((item) => item != null);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// src/scene/cull.ts
|
|
1699
|
+
var SPATIAL_MIN_ITEMS = 400;
|
|
1700
|
+
var SPATIAL_CELL_SIZE = 256;
|
|
1701
|
+
function cullItemsLinear(items, visibleWorld) {
|
|
1702
|
+
return items.filter(
|
|
1703
|
+
(item) => rectsIntersect(boundsAabbForCull(item), visibleWorld)
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
function boundsAabbForCull(item) {
|
|
1707
|
+
return boundsAabbForRotatedItem(item);
|
|
1708
|
+
}
|
|
1709
|
+
function cullItemsByViewport(items, visibleWorld) {
|
|
1710
|
+
if (items.length < SPATIAL_MIN_ITEMS) {
|
|
1711
|
+
return cullItemsLinear(items, visibleWorld);
|
|
1712
|
+
}
|
|
1713
|
+
return cullItemsByViewportSpatial(items, visibleWorld, SPATIAL_CELL_SIZE);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// src/renderer/svg-vector-renderer.ts
|
|
1717
|
+
function formatCameraTransform(camera) {
|
|
1718
|
+
const z = camera.zoom;
|
|
1719
|
+
return `matrix(${z}, 0, 0, ${z}, ${camera.x}, ${camera.y})`;
|
|
1720
|
+
}
|
|
1721
|
+
function formatItemPlacementTransform(item) {
|
|
1722
|
+
const r = normalizeRect(item.bounds);
|
|
1723
|
+
const rot = item.rotation ?? 0;
|
|
1724
|
+
if (Math.abs(rot) < 1e-12) {
|
|
1725
|
+
return `translate(${item.x}, ${item.y})`;
|
|
1726
|
+
}
|
|
1727
|
+
const deg = rot * 180 / Math.PI;
|
|
1728
|
+
const cx = r.width / 2;
|
|
1729
|
+
const cy = r.height / 2;
|
|
1730
|
+
return `translate(${item.x}, ${item.y}) translate(${cx}, ${cy}) rotate(${deg}) translate(${-cx}, ${-cy})`;
|
|
1731
|
+
}
|
|
1732
|
+
var SvgVectorRenderer = class {
|
|
1733
|
+
container;
|
|
1734
|
+
scene;
|
|
1735
|
+
camera;
|
|
1736
|
+
svg;
|
|
1737
|
+
rootG;
|
|
1738
|
+
itemNodeCache = /* @__PURE__ */ new Map();
|
|
1739
|
+
resizeObserver;
|
|
1740
|
+
constructor(options) {
|
|
1741
|
+
this.container = options.container;
|
|
1742
|
+
this.scene = options.scene;
|
|
1743
|
+
this.camera = options.camera;
|
|
1744
|
+
this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
1745
|
+
this.svg.setAttribute("width", "100%");
|
|
1746
|
+
this.svg.setAttribute("height", "100%");
|
|
1747
|
+
this.svg.setAttribute("shape-rendering", "geometricPrecision");
|
|
1748
|
+
this.svg.style.display = "block";
|
|
1749
|
+
this.svg.setAttribute("role", "presentation");
|
|
1750
|
+
this.svg.setAttribute("focusable", "false");
|
|
1751
|
+
this.setPointerEventsNone(options.pointerEventsNone ?? false);
|
|
1752
|
+
this.rootG = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
1753
|
+
this.svg.appendChild(this.rootG);
|
|
1754
|
+
this.container.appendChild(this.svg);
|
|
1755
|
+
this.resizeObserver = new ResizeObserver(() => this.render());
|
|
1756
|
+
this.resizeObserver.observe(this.container);
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Reads container size, culls items, and updates the SVG (incrementally when possible).
|
|
1760
|
+
*/
|
|
1761
|
+
render() {
|
|
1762
|
+
const w = this.container.clientWidth;
|
|
1763
|
+
const h = this.container.clientHeight;
|
|
1764
|
+
if (w <= 0 || h <= 0) {
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
this.svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
|
|
1768
|
+
this.svg.setAttribute("preserveAspectRatio", "xMidYMid slice");
|
|
1769
|
+
const visible = this.camera.getVisibleWorldRect(w, h);
|
|
1770
|
+
const items = cullItemsByViewport(this.scene.getItems(), visible);
|
|
1771
|
+
this.syncVisibleItems(items);
|
|
1772
|
+
this.rootG.setAttribute("transform", formatCameraTransform(this.camera));
|
|
1773
|
+
}
|
|
1774
|
+
syncVisibleItems(items) {
|
|
1775
|
+
const visibleIds = /* @__PURE__ */ new Set();
|
|
1776
|
+
for (const item of items) {
|
|
1777
|
+
visibleIds.add(item.id);
|
|
1778
|
+
}
|
|
1779
|
+
for (const [id, cached] of this.itemNodeCache) {
|
|
1780
|
+
if (!visibleIds.has(id)) {
|
|
1781
|
+
cached.g.remove();
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
let previousNode = null;
|
|
1785
|
+
for (const item of items) {
|
|
1786
|
+
let cached = this.itemNodeCache.get(item.id);
|
|
1787
|
+
if (!cached) {
|
|
1788
|
+
const g2 = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
1789
|
+
g2.setAttribute("data-item-id", item.id);
|
|
1790
|
+
cached = {
|
|
1791
|
+
g: g2,
|
|
1792
|
+
lastChildrenSvg: "",
|
|
1793
|
+
lastTransform: ""
|
|
1794
|
+
};
|
|
1795
|
+
this.itemNodeCache.set(item.id, cached);
|
|
1796
|
+
}
|
|
1797
|
+
const { g } = cached;
|
|
1798
|
+
const t = formatItemPlacementTransform(item);
|
|
1799
|
+
if (cached.lastTransform !== t) {
|
|
1800
|
+
g.setAttribute("transform", t);
|
|
1801
|
+
cached.lastTransform = t;
|
|
1802
|
+
}
|
|
1803
|
+
if (cached.lastChildrenSvg !== item.childrenSvg) {
|
|
1804
|
+
g.innerHTML = item.childrenSvg;
|
|
1805
|
+
cached.lastChildrenSvg = item.childrenSvg;
|
|
1806
|
+
}
|
|
1807
|
+
const expectedPosition = previousNode ? previousNode.nextSibling : this.rootG.firstChild;
|
|
1808
|
+
if (expectedPosition !== g) {
|
|
1809
|
+
this.rootG.insertBefore(g, expectedPosition);
|
|
1810
|
+
}
|
|
1811
|
+
previousNode = g;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
destroy() {
|
|
1815
|
+
this.resizeObserver.disconnect();
|
|
1816
|
+
this.itemNodeCache.clear();
|
|
1817
|
+
this.svg.remove();
|
|
1818
|
+
}
|
|
1819
|
+
/** Toggle whether the scene SVG receives pointer events (vs overlay handling them). */
|
|
1820
|
+
setPointerEventsNone(value) {
|
|
1821
|
+
this.svg.style.pointerEvents = value ? "none" : "auto";
|
|
1822
|
+
}
|
|
1823
|
+
};
|
|
1824
|
+
|
|
1825
|
+
// src/scene/clone-item.ts
|
|
1826
|
+
function cloneVectorSceneItemWithNewId(item) {
|
|
1827
|
+
const id = createShapeId();
|
|
1828
|
+
const copy = JSON.parse(JSON.stringify(item));
|
|
1829
|
+
let next = { ...copy, id };
|
|
1830
|
+
if (next.toolKind === "arrow" && next.line) {
|
|
1831
|
+
next = {
|
|
1832
|
+
...next,
|
|
1833
|
+
childrenSvg: buildArrowSvg(id, next.line, resolveStrokeStyle(next))
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
if (next.toolKind === "text" && next.text !== void 0) {
|
|
1837
|
+
return rebuildItemSvg(next);
|
|
1838
|
+
}
|
|
1839
|
+
if (next.toolKind === "custom" && next.customInnerSvg && next.customIntrinsicSize) {
|
|
1840
|
+
return rebuildItemSvg(next);
|
|
1841
|
+
}
|
|
1842
|
+
return next;
|
|
1843
|
+
}
|
|
1844
|
+
function cloneVectorSceneItemsWithNewIds(items) {
|
|
1845
|
+
return items.map(cloneVectorSceneItemWithNewId);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// src/scene/scene.ts
|
|
1849
|
+
var VectorScene = class {
|
|
1850
|
+
items = [];
|
|
1851
|
+
getItems() {
|
|
1852
|
+
return this.items;
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* Replaces the entire scene contents.
|
|
1856
|
+
*/
|
|
1857
|
+
setItems(next) {
|
|
1858
|
+
this.items = [...next];
|
|
1859
|
+
}
|
|
1860
|
+
clear() {
|
|
1861
|
+
this.items = [];
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1865
|
+
export { ARROW_BIND_SNAP_PX, Camera2D, DEFAULT_STROKE_STYLE, DEFAULT_TEXT_FONT_SIZE, MAX_RASTER_EMBED_DIMENSION, SvgVectorRenderer, VectorScene, applyStrokeToItem, attachApplePencilNavigation, attachViewportInput, bakeArrowItemToAbsolute, buildArrowSvg, buildCustomShapeChildrenSvg, buildDrawDotSvg, buildEllipseSvg, buildFreehandPathSvg, buildLineSvg, buildRectSvg, buildTextSvg, cloneVectorSceneItemWithNewId, cloneVectorSceneItemsWithNewIds, collectEraserTargetsAtWorldPoint, computeFreehandSvgPayload, createCustomShapeItem, createDrawDotItem, createEllipseItem, createFreehandStrokeItem, createImageFromVectorTrace, createImageItem, createLineItem, createRectangleItem, createShapeId, createTextItem, cullItemsByViewport, expandCustomShapeTemplate, formatCameraTransform, formatItemPlacementTransform, hitTestWorldPoint, isArrowBindTarget, itemHitTestWorldPoint, lineEndpointsToLocal, loadImageFileAsRasterSceneSource, normalizeRect, rebuildItemSvg, rectsIntersect, resolveArrowBindingsInScene, resolveStrokeStyle, snapArrowEndpointToShape };
|
|
1866
|
+
//# sourceMappingURL=index.js.map
|
|
1867
|
+
//# sourceMappingURL=index.js.map
|