@tsdraw/react 0.4.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.js ADDED
@@ -0,0 +1,975 @@
1
+ import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
2
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
+ import { DEFAULT_COLORS, getSelectionBoundsPage, buildTransformSnapshots, Editor, STROKE_WIDTHS, ERASER_MARGIN, resolveThemeColor, getTopShapeAtPoint, buildStartPositions, applyRotation, applyResize, applyMove, normalizeSelectionBounds, getShapesInBounds, isSelectTool } from '@tsdraw/core';
4
+ import { IconPointer, IconPencil, IconEraser, IconHandStop } from '@tabler/icons-react';
5
+
6
+ // src/components/TsdrawCanvas.tsx
7
+ function SelectionOverlay({
8
+ selectionBrush,
9
+ selectionBounds,
10
+ selectionRotationDeg,
11
+ currentTool,
12
+ selectedCount,
13
+ onRotatePointerDown,
14
+ onResizePointerDown
15
+ }) {
16
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
17
+ selectionBrush && /* @__PURE__ */ jsx(
18
+ "div",
19
+ {
20
+ className: "tsdraw-selection-brush",
21
+ style: {
22
+ left: selectionBrush.left,
23
+ top: selectionBrush.top,
24
+ width: selectionBrush.width,
25
+ height: selectionBrush.height
26
+ }
27
+ }
28
+ ),
29
+ selectionBounds && /* @__PURE__ */ jsxs(
30
+ "div",
31
+ {
32
+ className: "tsdraw-selection-frame",
33
+ style: {
34
+ left: selectionBounds.left,
35
+ top: selectionBounds.top,
36
+ width: selectionBounds.width,
37
+ height: selectionBounds.height,
38
+ transform: `rotate(${selectionRotationDeg}deg)`
39
+ },
40
+ children: [
41
+ /* @__PURE__ */ jsx("div", { className: "tsdraw-selection-bounds" }),
42
+ currentTool === "select" && selectedCount > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
43
+ /* @__PURE__ */ jsx("div", { className: "tsdraw-rotation-stem" }),
44
+ /* @__PURE__ */ jsx(
45
+ "button",
46
+ {
47
+ type: "button",
48
+ className: "tsdraw-rotation-handle",
49
+ "aria-label": "Rotate selection",
50
+ onPointerDown: onRotatePointerDown
51
+ }
52
+ ),
53
+ /* @__PURE__ */ jsx(
54
+ "button",
55
+ {
56
+ type: "button",
57
+ className: "tsdraw-selection-handle tsdraw-selection-handle--nw",
58
+ style: { left: "0%", top: "0%" },
59
+ "aria-label": "Resize top left",
60
+ onPointerDown: (e) => onResizePointerDown(e, "nw")
61
+ }
62
+ ),
63
+ /* @__PURE__ */ jsx(
64
+ "button",
65
+ {
66
+ type: "button",
67
+ className: "tsdraw-selection-handle tsdraw-selection-handle--ne",
68
+ style: { left: "100%", top: "0%" },
69
+ "aria-label": "Resize top right",
70
+ onPointerDown: (e) => onResizePointerDown(e, "ne")
71
+ }
72
+ ),
73
+ /* @__PURE__ */ jsx(
74
+ "button",
75
+ {
76
+ type: "button",
77
+ className: "tsdraw-selection-handle tsdraw-selection-handle--sw",
78
+ style: { left: "0%", top: "100%" },
79
+ "aria-label": "Resize bottom left",
80
+ onPointerDown: (e) => onResizePointerDown(e, "sw")
81
+ }
82
+ ),
83
+ /* @__PURE__ */ jsx(
84
+ "button",
85
+ {
86
+ type: "button",
87
+ className: "tsdraw-selection-handle tsdraw-selection-handle--se",
88
+ style: { left: "100%", top: "100%" },
89
+ "aria-label": "Resize bottom right",
90
+ onPointerDown: (e) => onResizePointerDown(e, "se")
91
+ }
92
+ )
93
+ ] })
94
+ ]
95
+ }
96
+ )
97
+ ] });
98
+ }
99
+ var STYLE_COLORS = Object.entries(DEFAULT_COLORS).filter(([key]) => key !== "white").map(([value]) => ({ value }));
100
+ var STYLE_DASHES = ["draw", "solid", "dashed", "dotted"];
101
+ var STYLE_SIZES = ["s", "m", "l", "xl"];
102
+ function StylePanel({
103
+ visible,
104
+ style,
105
+ theme,
106
+ drawColor,
107
+ drawDash,
108
+ drawSize,
109
+ onColorSelect,
110
+ onDashSelect,
111
+ onSizeSelect
112
+ }) {
113
+ if (!visible) return null;
114
+ return /* @__PURE__ */ jsxs("div", { className: "tsdraw-style-panel", style, "aria-label": "Draw style panel", children: [
115
+ /* @__PURE__ */ jsx("div", { className: "tsdraw-style-colors", children: STYLE_COLORS.map((item) => /* @__PURE__ */ jsx(
116
+ "button",
117
+ {
118
+ type: "button",
119
+ className: "tsdraw-style-color",
120
+ "data-active": drawColor === item.value ? "true" : void 0,
121
+ "aria-label": `Color ${item.value}`,
122
+ title: item.value,
123
+ onClick: () => onColorSelect(item.value),
124
+ children: /* @__PURE__ */ jsx(
125
+ "span",
126
+ {
127
+ className: "tsdraw-style-color-dot",
128
+ style: { background: resolveThemeColor(item.value, theme) }
129
+ }
130
+ )
131
+ },
132
+ item.value
133
+ )) }),
134
+ /* @__PURE__ */ jsx("div", { className: "tsdraw-style-section", children: STYLE_DASHES.map((dash) => /* @__PURE__ */ jsx(
135
+ "button",
136
+ {
137
+ type: "button",
138
+ className: "tsdraw-style-row",
139
+ "data-active": drawDash === dash ? "true" : void 0,
140
+ "aria-label": `Stroke ${dash}`,
141
+ title: dash,
142
+ onClick: () => onDashSelect(dash),
143
+ children: /* @__PURE__ */ jsx("span", { className: "tsdraw-style-preview", children: /* @__PURE__ */ jsx("span", { className: `tsdraw-style-preview-line tsdraw-style-preview-line--${dash}` }) })
144
+ },
145
+ dash
146
+ )) }),
147
+ /* @__PURE__ */ jsx("div", { className: "tsdraw-style-section", children: STYLE_SIZES.map((size) => /* @__PURE__ */ jsx(
148
+ "button",
149
+ {
150
+ type: "button",
151
+ className: "tsdraw-style-row",
152
+ "data-active": drawSize === size ? "true" : void 0,
153
+ "aria-label": `Thickness ${size}`,
154
+ title: size,
155
+ onClick: () => onSizeSelect(size),
156
+ children: /* @__PURE__ */ jsx("span", { className: "tsdraw-style-preview", children: /* @__PURE__ */ jsx("span", { className: `tsdraw-style-size tsdraw-style-size--${size}` }) })
157
+ },
158
+ size
159
+ )) })
160
+ ] });
161
+ }
162
+ function ToolOverlay({
163
+ visible,
164
+ pointerX,
165
+ pointerY,
166
+ isPenPreview,
167
+ penRadius,
168
+ penColor,
169
+ eraserRadius
170
+ }) {
171
+ if (!visible) return null;
172
+ return /* @__PURE__ */ jsx("div", { className: "tsdraw-tool-overlay", "aria-hidden": "true", children: isPenPreview ? /* @__PURE__ */ jsx(
173
+ "span",
174
+ {
175
+ className: "tsdraw-tool-dot",
176
+ style: {
177
+ left: pointerX,
178
+ top: pointerY,
179
+ width: penRadius * 2,
180
+ height: penRadius * 2,
181
+ backgroundColor: penColor
182
+ }
183
+ }
184
+ ) : /* @__PURE__ */ jsx(
185
+ "span",
186
+ {
187
+ className: "tsdraw-tool-ring",
188
+ style: {
189
+ left: pointerX,
190
+ top: pointerY,
191
+ width: eraserRadius * 2,
192
+ height: eraserRadius * 2
193
+ }
194
+ }
195
+ ) });
196
+ }
197
+ function getDefaultToolbarIcon(toolId, isActive) {
198
+ if (toolId === "select") return /* @__PURE__ */ jsx(IconPointer, { size: 18, stroke: 1.8, fill: isActive ? "currentColor" : "none" });
199
+ if (toolId === "pen") return /* @__PURE__ */ jsx(IconPencil, { size: 18, stroke: 1.8, fill: isActive ? "currentColor" : "none" });
200
+ if (toolId === "eraser") return /* @__PURE__ */ jsx(IconEraser, { size: 18, stroke: 1.8, fill: isActive ? "currentColor" : "none" });
201
+ if (toolId === "hand") return /* @__PURE__ */ jsx(IconHandStop, { size: 18, stroke: isActive ? 1 : 1.8, fill: isActive ? "currentColor" : "none", style: isActive ? { stroke: "#000000" } : void 0 });
202
+ return null;
203
+ }
204
+ function Toolbar({ items, currentTool, onToolChange, style }) {
205
+ return /* @__PURE__ */ jsx("div", { className: "tsdraw-toolbar", style, children: items.map((item) => {
206
+ const isActive = currentTool === item.id;
207
+ return /* @__PURE__ */ jsx(
208
+ "button",
209
+ {
210
+ type: "button",
211
+ className: "tsdraw-toolbar-btn",
212
+ "data-active": isActive ? "true" : void 0,
213
+ onClick: () => onToolChange(item.id),
214
+ title: item.label,
215
+ "aria-label": item.label,
216
+ children: typeof item.icon === "function" ? item.icon(isActive) : item.icon
217
+ },
218
+ item.id
219
+ );
220
+ }) });
221
+ }
222
+ function getCanvasCursor(currentTool, state) {
223
+ if (currentTool === "hand") return "grab";
224
+ if (isSelectTool(currentTool)) {
225
+ if (state.isRotatingSelection) return "grabbing";
226
+ if (state.isResizingSelection) return "nwse-resize";
227
+ if (state.isMovingSelection) return "grabbing";
228
+ return "default";
229
+ }
230
+ return state.showToolOverlay ? "none" : "crosshair";
231
+ }
232
+
233
+ // src/canvas/useTsdrawCanvasController.ts
234
+ function toScreenRect(editor, bounds) {
235
+ const { x, y, zoom } = editor.viewport;
236
+ return {
237
+ left: bounds.minX * zoom + x,
238
+ top: bounds.minY * zoom + y,
239
+ width: (bounds.maxX - bounds.minX) * zoom,
240
+ height: (bounds.maxY - bounds.minY) * zoom
241
+ };
242
+ }
243
+ function resolveDrawColor(colorStyle, theme) {
244
+ return resolveThemeColor(colorStyle, theme);
245
+ }
246
+ function useTsdrawCanvasController(options = {}) {
247
+ const stylePanelToolIds = options.stylePanelToolIds ?? ["pen"];
248
+ const stylePanelToolIdsRef = useRef(stylePanelToolIds);
249
+ const containerRef = useRef(null);
250
+ const canvasRef = useRef(null);
251
+ const editorRef = useRef(null);
252
+ const dprRef = useRef(1);
253
+ const lastPointerClientRef = useRef(null);
254
+ const currentToolRef = useRef(options.initialTool ?? "pen");
255
+ const selectedShapeIdsRef = useRef([]);
256
+ const selectionRotationRef = useRef(0);
257
+ const resizeRef = useRef({
258
+ handle: null,
259
+ startBounds: null,
260
+ startShapes: /* @__PURE__ */ new Map()
261
+ });
262
+ const rotateRef = useRef({
263
+ center: null,
264
+ startAngle: 0,
265
+ startSelectionRotationDeg: 0,
266
+ startShapes: /* @__PURE__ */ new Map()
267
+ });
268
+ const selectDragRef = useRef({
269
+ mode: "none",
270
+ startPage: { x: 0, y: 0 },
271
+ currentPage: { x: 0, y: 0 },
272
+ startPositions: /* @__PURE__ */ new Map(),
273
+ additive: false,
274
+ initialSelection: []
275
+ });
276
+ const [currentTool, setCurrentToolState] = useState(options.initialTool ?? "pen");
277
+ const [drawColor, setDrawColor] = useState("black");
278
+ const [drawDash, setDrawDash] = useState("draw");
279
+ const [drawSize, setDrawSize] = useState("m");
280
+ const [selectedShapeIds, setSelectedShapeIds] = useState([]);
281
+ const [selectionBrush, setSelectionBrush] = useState(null);
282
+ const [selectionBounds, setSelectionBounds] = useState(null);
283
+ const [selectionRotationDeg, setSelectionRotationDeg] = useState(0);
284
+ const [isMovingSelection, setIsMovingSelection] = useState(false);
285
+ const [isResizingSelection, setIsResizingSelection] = useState(false);
286
+ const [isRotatingSelection, setIsRotatingSelection] = useState(false);
287
+ const [pointerScreenPoint, setPointerScreenPoint] = useState({ x: 0, y: 0 });
288
+ const [isPointerInsideCanvas, setIsPointerInsideCanvas] = useState(false);
289
+ useEffect(() => {
290
+ currentToolRef.current = currentTool;
291
+ }, [currentTool]);
292
+ useEffect(() => {
293
+ stylePanelToolIdsRef.current = stylePanelToolIds;
294
+ }, [stylePanelToolIds]);
295
+ useEffect(() => {
296
+ selectedShapeIdsRef.current = selectedShapeIds;
297
+ }, [selectedShapeIds]);
298
+ useEffect(() => {
299
+ selectionRotationRef.current = selectionRotationDeg;
300
+ }, [selectionRotationDeg]);
301
+ const render = useCallback(() => {
302
+ const canvas = canvasRef.current;
303
+ const editor = editorRef.current;
304
+ if (!canvas || !editor) return;
305
+ const ctx = canvas.getContext("2d");
306
+ if (!ctx) return;
307
+ const dpr = dprRef.current || 1;
308
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
309
+ ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
310
+ editor.render(ctx);
311
+ }, []);
312
+ const refreshSelectionBounds = useCallback((editor, ids = selectedShapeIdsRef.current) => {
313
+ const pageBounds = getSelectionBoundsPage(editor, ids);
314
+ setSelectionBounds(pageBounds ? toScreenRect(editor, pageBounds) : null);
315
+ }, []);
316
+ const getPagePointFromClient = useCallback((editor, clientX, clientY) => {
317
+ const canvas = canvasRef.current;
318
+ if (!canvas) return { x: 0, y: 0 };
319
+ const rect = canvas.getBoundingClientRect();
320
+ return editor.screenToPage(clientX - rect.left, clientY - rect.top);
321
+ }, []);
322
+ const updatePointerPreview = useCallback((clientX, clientY) => {
323
+ const canvas = canvasRef.current;
324
+ if (!canvas) return;
325
+ const rect = canvas.getBoundingClientRect();
326
+ const isInside = clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
327
+ setIsPointerInsideCanvas(isInside);
328
+ setPointerScreenPoint({
329
+ x: clientX - rect.left,
330
+ y: clientY - rect.top
331
+ });
332
+ }, []);
333
+ const resetSelectUi = useCallback(() => {
334
+ setSelectionBrush(null);
335
+ setSelectionRotationDeg(0);
336
+ setIsMovingSelection(false);
337
+ setIsResizingSelection(false);
338
+ setIsRotatingSelection(false);
339
+ selectDragRef.current.mode = "none";
340
+ resizeRef.current = { handle: null, startBounds: null, startShapes: /* @__PURE__ */ new Map() };
341
+ rotateRef.current = {
342
+ center: null,
343
+ startAngle: 0,
344
+ startSelectionRotationDeg: selectionRotationRef.current,
345
+ startShapes: /* @__PURE__ */ new Map()
346
+ };
347
+ }, []);
348
+ const handleResizePointerDown = useCallback(
349
+ (e, handle) => {
350
+ e.preventDefault();
351
+ e.stopPropagation();
352
+ const editor = editorRef.current;
353
+ if (!editor || selectedShapeIdsRef.current.length === 0) return;
354
+ const bounds = getSelectionBoundsPage(editor, selectedShapeIdsRef.current);
355
+ if (!bounds) return;
356
+ resizeRef.current = {
357
+ handle,
358
+ startBounds: bounds,
359
+ startShapes: buildTransformSnapshots(editor, selectedShapeIdsRef.current)
360
+ };
361
+ selectDragRef.current.mode = "resize";
362
+ const p = getPagePointFromClient(editor, e.clientX, e.clientY);
363
+ editor.input.pointerDown(p.x, p.y, 0.5, false);
364
+ selectDragRef.current.startPage = p;
365
+ selectDragRef.current.currentPage = p;
366
+ lastPointerClientRef.current = { x: e.clientX, y: e.clientY };
367
+ setIsResizingSelection(true);
368
+ },
369
+ [getPagePointFromClient]
370
+ );
371
+ const handleRotatePointerDown = useCallback(
372
+ (e) => {
373
+ e.preventDefault();
374
+ e.stopPropagation();
375
+ const editor = editorRef.current;
376
+ if (!editor || selectedShapeIdsRef.current.length === 0) return;
377
+ const bounds = getSelectionBoundsPage(editor, selectedShapeIdsRef.current);
378
+ if (!bounds) return;
379
+ const center = {
380
+ x: (bounds.minX + bounds.maxX) / 2,
381
+ y: (bounds.minY + bounds.maxY) / 2
382
+ };
383
+ const p = getPagePointFromClient(editor, e.clientX, e.clientY);
384
+ rotateRef.current = {
385
+ center,
386
+ startAngle: Math.atan2(p.y - center.y, p.x - center.x),
387
+ startSelectionRotationDeg: selectionRotationRef.current,
388
+ startShapes: buildTransformSnapshots(editor, selectedShapeIdsRef.current)
389
+ };
390
+ selectDragRef.current.mode = "rotate";
391
+ editor.input.pointerDown(p.x, p.y, 0.5, false);
392
+ selectDragRef.current.startPage = p;
393
+ selectDragRef.current.currentPage = p;
394
+ lastPointerClientRef.current = { x: e.clientX, y: e.clientY };
395
+ setIsRotatingSelection(true);
396
+ },
397
+ [getPagePointFromClient]
398
+ );
399
+ useEffect(() => {
400
+ const container = containerRef.current;
401
+ const canvas = canvasRef.current;
402
+ if (!container || !canvas) return;
403
+ const initialTool = options.initialTool ?? "pen";
404
+ const editor = new Editor({
405
+ toolDefinitions: options.toolDefinitions,
406
+ initialToolId: initialTool
407
+ });
408
+ editor.renderer.setTheme(options.theme ?? "light");
409
+ if (!editor.tools.hasTool(initialTool)) {
410
+ editor.setCurrentTool("pen");
411
+ }
412
+ const activeTool = editor.getCurrentToolId();
413
+ editorRef.current = editor;
414
+ setCurrentToolState(activeTool);
415
+ currentToolRef.current = activeTool;
416
+ const initialStyle = editor.getCurrentDrawStyle();
417
+ setDrawColor(initialStyle.color);
418
+ setDrawDash(initialStyle.dash);
419
+ setDrawSize(initialStyle.size);
420
+ const resize = () => {
421
+ const dpr = window.devicePixelRatio ?? 1;
422
+ dprRef.current = dpr;
423
+ const rect = container.getBoundingClientRect();
424
+ canvas.width = Math.round(rect.width * dpr);
425
+ canvas.height = Math.round(rect.height * dpr);
426
+ canvas.style.width = `${rect.width}px`;
427
+ canvas.style.height = `${rect.height}px`;
428
+ editor.viewport.x = 0;
429
+ editor.viewport.y = 0;
430
+ editor.viewport.zoom = 1;
431
+ render();
432
+ refreshSelectionBounds(editor);
433
+ };
434
+ const getPagePoint = (e) => {
435
+ const rect = canvas.getBoundingClientRect();
436
+ return editor.screenToPage(e.clientX - rect.left, e.clientY - rect.top);
437
+ };
438
+ const sampleEvents = (e) => {
439
+ const coalesced = e.getCoalescedEvents?.();
440
+ return coalesced && coalesced.length > 0 ? coalesced : [e];
441
+ };
442
+ const handlePointerDown = (e) => {
443
+ if (!canvas.contains(e.target)) return;
444
+ canvas.setPointerCapture(e.pointerId);
445
+ lastPointerClientRef.current = { x: e.clientX, y: e.clientY };
446
+ updatePointerPreview(e.clientX, e.clientY);
447
+ const first = sampleEvents(e)[0];
448
+ const { x, y } = getPagePoint(first);
449
+ const pressure = first.pressure ?? 0.5;
450
+ const isPen = first.pointerType === "pen" || first.pointerType === "touch";
451
+ if (currentToolRef.current === "select") {
452
+ const hit = getTopShapeAtPoint(editor, { x, y });
453
+ const isHitSelected = !!(hit && selectedShapeIdsRef.current.includes(hit.id));
454
+ if (isHitSelected) {
455
+ selectDragRef.current = {
456
+ mode: "move",
457
+ startPage: { x, y },
458
+ currentPage: { x, y },
459
+ startPositions: buildStartPositions(editor, selectedShapeIdsRef.current),
460
+ additive: false,
461
+ initialSelection: [...selectedShapeIdsRef.current]
462
+ };
463
+ setIsMovingSelection(true);
464
+ return;
465
+ }
466
+ selectDragRef.current = {
467
+ mode: "marquee",
468
+ startPage: { x, y },
469
+ currentPage: { x, y },
470
+ startPositions: /* @__PURE__ */ new Map(),
471
+ additive: first.shiftKey,
472
+ initialSelection: [...selectedShapeIdsRef.current]
473
+ };
474
+ setSelectionBrush({ left: e.offsetX, top: e.offsetY, width: 0, height: 0 });
475
+ if (!e.shiftKey) {
476
+ setSelectedShapeIds([]);
477
+ selectedShapeIdsRef.current = [];
478
+ setSelectionBounds(null);
479
+ setSelectionRotationDeg(0);
480
+ }
481
+ return;
482
+ }
483
+ editor.input.pointerDown(x, y, pressure, isPen);
484
+ editor.input.setModifiers(first.shiftKey, first.ctrlKey, first.metaKey);
485
+ editor.tools.pointerDown({ point: { x, y, z: pressure } });
486
+ render();
487
+ refreshSelectionBounds(editor);
488
+ };
489
+ const handlePointerMove = (e) => {
490
+ updatePointerPreview(e.clientX, e.clientY);
491
+ const prevClient = lastPointerClientRef.current;
492
+ const dx = prevClient ? e.clientX - prevClient.x : 0;
493
+ const dy = prevClient ? e.clientY - prevClient.y : 0;
494
+ lastPointerClientRef.current = { x: e.clientX, y: e.clientY };
495
+ for (const sample of sampleEvents(e)) {
496
+ const { x, y } = getPagePoint(sample);
497
+ const pressure = sample.pressure ?? 0.5;
498
+ const isPen = sample.pointerType === "pen" || sample.pointerType === "touch";
499
+ editor.input.pointerMove(x, y, pressure, isPen);
500
+ }
501
+ if (currentToolRef.current === "select") {
502
+ const mode = selectDragRef.current.mode;
503
+ const { x: px, y: py } = editor.input.getCurrentPagePoint();
504
+ if (mode === "rotate") {
505
+ const { center, startAngle, startSelectionRotationDeg, startShapes } = rotateRef.current;
506
+ if (!center) return;
507
+ const angle = Math.atan2(py - center.y, px - center.x);
508
+ const delta = angle - startAngle;
509
+ setSelectionRotationDeg(startSelectionRotationDeg + delta * 180 / Math.PI);
510
+ applyRotation(editor, startShapes, center, delta);
511
+ render();
512
+ return;
513
+ }
514
+ if (mode === "resize") {
515
+ const { handle, startBounds, startShapes } = resizeRef.current;
516
+ if (!handle || !startBounds) return;
517
+ applyResize(editor, handle, startBounds, startShapes, { x: px, y: py }, e.shiftKey);
518
+ render();
519
+ refreshSelectionBounds(editor);
520
+ return;
521
+ }
522
+ if (mode === "move") {
523
+ const drag = selectDragRef.current;
524
+ applyMove(editor, drag.startPositions, px - drag.startPage.x, py - drag.startPage.y);
525
+ render();
526
+ refreshSelectionBounds(editor);
527
+ return;
528
+ }
529
+ if (mode === "marquee") {
530
+ selectDragRef.current.currentPage = { x: px, y: py };
531
+ const pageRect = normalizeSelectionBounds(selectDragRef.current.startPage, selectDragRef.current.currentPage);
532
+ setSelectionBrush(toScreenRect(editor, pageRect));
533
+ const ids = getShapesInBounds(editor, pageRect);
534
+ const nextIds = selectDragRef.current.additive ? Array.from(/* @__PURE__ */ new Set([...selectDragRef.current.initialSelection, ...ids])) : ids;
535
+ setSelectedShapeIds(nextIds);
536
+ selectedShapeIdsRef.current = nextIds;
537
+ setSelectionRotationDeg(0);
538
+ refreshSelectionBounds(editor, nextIds);
539
+ return;
540
+ }
541
+ }
542
+ editor.input.setModifiers(e.shiftKey, e.ctrlKey, e.metaKey);
543
+ editor.tools.pointerMove({ screenDeltaX: dx, screenDeltaY: dy });
544
+ render();
545
+ refreshSelectionBounds(editor);
546
+ };
547
+ const handlePointerUp = (e) => {
548
+ lastPointerClientRef.current = null;
549
+ updatePointerPreview(e.clientX, e.clientY);
550
+ const { x, y } = getPagePoint(e);
551
+ editor.input.pointerMove(x, y);
552
+ editor.input.pointerUp();
553
+ if (currentToolRef.current === "select") {
554
+ const drag = selectDragRef.current;
555
+ if (drag.mode === "rotate") {
556
+ setIsRotatingSelection(false);
557
+ selectDragRef.current.mode = "none";
558
+ setSelectionRotationDeg(0);
559
+ rotateRef.current = {
560
+ center: null,
561
+ startAngle: 0,
562
+ startSelectionRotationDeg: selectionRotationRef.current,
563
+ startShapes: /* @__PURE__ */ new Map()
564
+ };
565
+ render();
566
+ refreshSelectionBounds(editor);
567
+ return;
568
+ }
569
+ if (drag.mode === "resize") {
570
+ setIsResizingSelection(false);
571
+ selectDragRef.current.mode = "none";
572
+ resizeRef.current = { handle: null, startBounds: null, startShapes: /* @__PURE__ */ new Map() };
573
+ render();
574
+ refreshSelectionBounds(editor);
575
+ return;
576
+ }
577
+ if (drag.mode === "move") {
578
+ setIsMovingSelection(false);
579
+ selectDragRef.current.mode = "none";
580
+ render();
581
+ refreshSelectionBounds(editor);
582
+ return;
583
+ }
584
+ if (drag.mode === "marquee") {
585
+ const rect = normalizeSelectionBounds(drag.startPage, { x, y });
586
+ const moved = Math.abs(x - drag.startPage.x) > 2 || Math.abs(y - drag.startPage.y) > 2;
587
+ let ids = [];
588
+ if (!moved) {
589
+ const hit = getTopShapeAtPoint(editor, { x, y });
590
+ if (hit) {
591
+ ids = drag.additive ? Array.from(/* @__PURE__ */ new Set([...drag.initialSelection, hit.id])) : [hit.id];
592
+ } else {
593
+ ids = drag.additive ? drag.initialSelection : [];
594
+ }
595
+ } else {
596
+ ids = getShapesInBounds(editor, rect);
597
+ if (drag.additive) {
598
+ ids = Array.from(/* @__PURE__ */ new Set([...drag.initialSelection, ...ids]));
599
+ }
600
+ }
601
+ setSelectedShapeIds(ids);
602
+ selectedShapeIdsRef.current = ids;
603
+ setSelectionRotationDeg(0);
604
+ setSelectionBrush(null);
605
+ selectDragRef.current.mode = "none";
606
+ render();
607
+ refreshSelectionBounds(editor, ids);
608
+ return;
609
+ }
610
+ }
611
+ editor.tools.pointerUp();
612
+ render();
613
+ refreshSelectionBounds(editor);
614
+ };
615
+ const handleKeyDown = (e) => {
616
+ editor.input.setModifiers(e.shiftKey, e.ctrlKey, e.metaKey);
617
+ editor.tools.keyDown({ key: e.key });
618
+ render();
619
+ };
620
+ const handleKeyUp = (e) => {
621
+ editor.input.setModifiers(e.shiftKey, e.ctrlKey, e.metaKey);
622
+ editor.tools.keyUp({ key: e.key });
623
+ render();
624
+ };
625
+ resize();
626
+ const ro = new ResizeObserver(resize);
627
+ ro.observe(container);
628
+ canvas.addEventListener("pointerdown", handlePointerDown);
629
+ window.addEventListener("pointermove", handlePointerMove);
630
+ window.addEventListener("pointerup", handlePointerUp);
631
+ window.addEventListener("keydown", handleKeyDown);
632
+ window.addEventListener("keyup", handleKeyUp);
633
+ const disposeMount = options.onMount?.({
634
+ editor,
635
+ container,
636
+ canvas,
637
+ setTool: (tool) => {
638
+ if (!editor.tools.hasTool(tool)) return;
639
+ editor.setCurrentTool(tool);
640
+ setCurrentToolState(tool);
641
+ currentToolRef.current = tool;
642
+ },
643
+ getCurrentTool: () => editor.getCurrentToolId(),
644
+ applyDrawStyle: (partial) => {
645
+ editor.setCurrentDrawStyle(partial);
646
+ if (partial.color) setDrawColor(partial.color);
647
+ if (partial.dash) setDrawDash(partial.dash);
648
+ if (partial.size) setDrawSize(partial.size);
649
+ render();
650
+ }
651
+ });
652
+ return () => {
653
+ disposeMount?.();
654
+ ro.disconnect();
655
+ canvas.removeEventListener("pointerdown", handlePointerDown);
656
+ window.removeEventListener("pointermove", handlePointerMove);
657
+ window.removeEventListener("pointerup", handlePointerUp);
658
+ window.removeEventListener("keydown", handleKeyDown);
659
+ window.removeEventListener("keyup", handleKeyUp);
660
+ editorRef.current = null;
661
+ };
662
+ }, [
663
+ getPagePointFromClient,
664
+ options.initialTool,
665
+ options.onMount,
666
+ options.toolDefinitions,
667
+ refreshSelectionBounds,
668
+ render,
669
+ updatePointerPreview
670
+ ]);
671
+ useEffect(() => {
672
+ const editor = editorRef.current;
673
+ if (!editor) return;
674
+ editor.renderer.setTheme(options.theme ?? "light");
675
+ render();
676
+ }, [options.theme, render]);
677
+ const setTool = useCallback(
678
+ (tool) => {
679
+ const editor = editorRef.current;
680
+ if (!editor) return;
681
+ if (!editor.tools.hasTool(tool)) return;
682
+ editor.setCurrentTool(tool);
683
+ setCurrentToolState(tool);
684
+ currentToolRef.current = tool;
685
+ if (tool !== "select") resetSelectUi();
686
+ },
687
+ [resetSelectUi]
688
+ );
689
+ const applyDrawStyle = useCallback(
690
+ (partial) => {
691
+ const editor = editorRef.current;
692
+ if (!editor) return;
693
+ editor.setCurrentDrawStyle(partial);
694
+ if (partial.color) setDrawColor(partial.color);
695
+ if (partial.dash) setDrawDash(partial.dash);
696
+ if (partial.size) setDrawSize(partial.size);
697
+ render();
698
+ },
699
+ [render]
700
+ );
701
+ const showToolOverlay = isPointerInsideCanvas && (currentTool === "pen" || currentTool === "eraser");
702
+ const canvasCursor = getCanvasCursor(currentTool, {
703
+ isMovingSelection,
704
+ isResizingSelection,
705
+ isRotatingSelection,
706
+ showToolOverlay
707
+ });
708
+ const cursorContext = {
709
+ currentTool,
710
+ defaultCursor: canvasCursor,
711
+ showToolOverlay,
712
+ isMovingSelection,
713
+ isResizingSelection,
714
+ isRotatingSelection
715
+ };
716
+ const toolOverlay = {
717
+ visible: showToolOverlay,
718
+ pointerX: pointerScreenPoint.x,
719
+ pointerY: pointerScreenPoint.y,
720
+ isPenPreview: currentTool === "pen",
721
+ penRadius: Math.max(2, STROKE_WIDTHS[drawSize] / 2),
722
+ penColor: resolveDrawColor(drawColor, options.theme ?? "light"),
723
+ eraserRadius: ERASER_MARGIN
724
+ };
725
+ return {
726
+ containerRef,
727
+ canvasRef,
728
+ currentTool,
729
+ drawColor,
730
+ drawDash,
731
+ drawSize,
732
+ selectedShapeIds,
733
+ selectionBrush,
734
+ selectionBounds,
735
+ selectionRotationDeg,
736
+ canvasCursor,
737
+ cursorContext,
738
+ toolOverlay,
739
+ showStylePanel: stylePanelToolIdsRef.current.includes(currentTool),
740
+ setTool,
741
+ applyDrawStyle,
742
+ handleResizePointerDown,
743
+ handleRotatePointerDown
744
+ };
745
+ }
746
+ var DEFAULT_TOOL_IDS = ["select", "pen", "eraser", "hand"];
747
+ var DEFAULT_TOOL_LABELS = {
748
+ select: "Select",
749
+ pen: "Pen",
750
+ eraser: "Eraser",
751
+ hand: "Hand"
752
+ };
753
+ function parseAnchor(anchor) {
754
+ const parts = anchor.split("-");
755
+ let vertical = "center";
756
+ let horizontal = "center";
757
+ for (const part of parts) {
758
+ if (part === "top" || part === "bottom") vertical = part;
759
+ else if (part === "left" || part === "right") horizontal = part;
760
+ }
761
+ return { vertical, horizontal };
762
+ }
763
+ function isCustomTool(toolItem) {
764
+ return typeof toolItem !== "string";
765
+ }
766
+ function getToolId(toolItem) {
767
+ return typeof toolItem === "string" ? toolItem : toolItem.id;
768
+ }
769
+ function resolvePlacementStyle(placement, fallbackAnchor, fallbackOffsetX, fallbackOffsetY) {
770
+ const anchor = placement?.anchor ?? fallbackAnchor;
771
+ const offsetX = placement?.offsetX ?? fallbackOffsetX;
772
+ const offsetY = placement?.offsetY ?? fallbackOffsetY;
773
+ const { vertical, horizontal } = parseAnchor(anchor);
774
+ const result = {};
775
+ const transforms = [];
776
+ if (horizontal === "left") {
777
+ result.left = offsetX;
778
+ } else if (horizontal === "right") {
779
+ result.right = offsetX;
780
+ } else {
781
+ result.left = "50%";
782
+ transforms.push("translateX(-50%)");
783
+ if (offsetX) transforms.push(`translateX(${offsetX}px)`);
784
+ }
785
+ if (vertical === "top") {
786
+ result.top = offsetY;
787
+ } else if (vertical === "bottom") {
788
+ result.bottom = offsetY;
789
+ } else {
790
+ result.top = "50%";
791
+ transforms.push("translateY(-50%)");
792
+ if (offsetY) transforms.push(`translateY(${offsetY}px)`);
793
+ }
794
+ if (transforms.length > 0) result.transform = transforms.join(" ");
795
+ return { ...result, ...placement?.style ?? {} };
796
+ }
797
+ function Tsdraw(props) {
798
+ const [systemTheme, setSystemTheme] = useState(() => {
799
+ if (typeof window === "undefined") return "light";
800
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
801
+ });
802
+ const toolItems = props.tools ?? DEFAULT_TOOL_IDS;
803
+ const customTools = useMemo(
804
+ () => toolItems.filter(isCustomTool),
805
+ [toolItems]
806
+ );
807
+ const toolDefinitions = useMemo(
808
+ () => customTools.map((tool) => tool.definition),
809
+ [customTools]
810
+ );
811
+ const toolbarItems = useMemo(
812
+ () => toolItems.map((tool) => {
813
+ if (typeof tool === "string") {
814
+ return {
815
+ id: tool,
816
+ label: DEFAULT_TOOL_LABELS[tool],
817
+ icon: (isActive) => getDefaultToolbarIcon(tool, isActive)
818
+ };
819
+ }
820
+ return {
821
+ id: tool.id,
822
+ label: tool.label,
823
+ icon: (isActive) => isActive && tool.iconSelected ? tool.iconSelected : tool.icon
824
+ };
825
+ }),
826
+ [toolItems]
827
+ );
828
+ const stylePanelToolIds = useMemo(
829
+ () => toolItems.filter((tool) => {
830
+ if (typeof tool === "string") return tool === "pen";
831
+ return tool.showStylePanel ?? false;
832
+ }).map(getToolId),
833
+ [toolItems]
834
+ );
835
+ const initialTool = props.initialToolId ?? toolbarItems[0]?.id ?? "pen";
836
+ const requestedTheme = props.theme ?? "light";
837
+ useEffect(() => {
838
+ if (requestedTheme !== "system" || typeof window === "undefined") return;
839
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
840
+ const syncSystemTheme = () => setSystemTheme(mediaQuery.matches ? "dark" : "light");
841
+ syncSystemTheme();
842
+ mediaQuery.addEventListener("change", syncSystemTheme);
843
+ return () => mediaQuery.removeEventListener("change", syncSystemTheme);
844
+ }, [requestedTheme]);
845
+ const resolvedTheme = requestedTheme === "system" ? systemTheme : requestedTheme;
846
+ const {
847
+ containerRef,
848
+ canvasRef,
849
+ currentTool,
850
+ drawColor,
851
+ drawDash,
852
+ drawSize,
853
+ selectedShapeIds,
854
+ selectionBrush,
855
+ selectionBounds,
856
+ selectionRotationDeg,
857
+ canvasCursor: defaultCanvasCursor,
858
+ cursorContext,
859
+ toolOverlay,
860
+ showStylePanel,
861
+ setTool,
862
+ applyDrawStyle,
863
+ handleResizePointerDown,
864
+ handleRotatePointerDown
865
+ } = useTsdrawCanvasController({
866
+ toolDefinitions,
867
+ initialTool,
868
+ theme: resolvedTheme,
869
+ stylePanelToolIds,
870
+ onMount: props.onMount
871
+ });
872
+ const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 16);
873
+ const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8, 8);
874
+ const canvasCursor = props.uiOptions?.cursor?.getCursor?.(cursorContext) ?? defaultCanvasCursor;
875
+ const defaultToolOverlay = /* @__PURE__ */ jsx(
876
+ ToolOverlay,
877
+ {
878
+ visible: toolOverlay.visible,
879
+ pointerX: toolOverlay.pointerX,
880
+ pointerY: toolOverlay.pointerY,
881
+ isPenPreview: toolOverlay.isPenPreview,
882
+ penRadius: toolOverlay.penRadius,
883
+ penColor: toolOverlay.penColor,
884
+ eraserRadius: toolOverlay.eraserRadius
885
+ }
886
+ );
887
+ const overlayNode = props.uiOptions?.overlays?.renderToolOverlay?.({ defaultOverlay: defaultToolOverlay, overlayState: toolOverlay, currentTool }) ?? defaultToolOverlay;
888
+ const customElements = props.uiOptions?.customElements ?? [];
889
+ return /* @__PURE__ */ jsxs(
890
+ "div",
891
+ {
892
+ ref: containerRef,
893
+ className: `tsdraw tsdraw-${resolvedTheme}mode ${props.className ?? ""}`,
894
+ style: {
895
+ width: props.width ?? "100%",
896
+ height: props.height ?? "100%",
897
+ position: "relative",
898
+ overflow: "hidden",
899
+ ...props.style
900
+ },
901
+ children: [
902
+ /* @__PURE__ */ jsx(
903
+ "canvas",
904
+ {
905
+ ref: canvasRef,
906
+ style: {
907
+ display: "block",
908
+ width: "100%",
909
+ height: "100%",
910
+ touchAction: "none",
911
+ cursor: canvasCursor
912
+ },
913
+ "data-testid": "tsdraw-canvas"
914
+ }
915
+ ),
916
+ overlayNode,
917
+ /* @__PURE__ */ jsx(
918
+ SelectionOverlay,
919
+ {
920
+ selectionBrush,
921
+ selectionBounds,
922
+ selectionRotationDeg,
923
+ currentTool,
924
+ selectedCount: selectedShapeIds.length,
925
+ onRotatePointerDown: handleRotatePointerDown,
926
+ onResizePointerDown: handleResizePointerDown
927
+ }
928
+ ),
929
+ /* @__PURE__ */ jsx(
930
+ StylePanel,
931
+ {
932
+ visible: showStylePanel,
933
+ style: stylePanelPlacementStyle,
934
+ theme: resolvedTheme,
935
+ drawColor,
936
+ drawDash,
937
+ drawSize,
938
+ onColorSelect: (color) => applyDrawStyle({ color }),
939
+ onDashSelect: (dash) => applyDrawStyle({ dash }),
940
+ onSizeSelect: (size) => applyDrawStyle({ size })
941
+ }
942
+ ),
943
+ customElements.map((customElement) => /* @__PURE__ */ jsx(
944
+ "div",
945
+ {
946
+ style: {
947
+ position: "absolute",
948
+ zIndex: 130,
949
+ pointerEvents: "all",
950
+ ...resolvePlacementStyle(customElement.placement, "top-left", 8, 8)
951
+ },
952
+ children: customElement.render({ currentTool, setTool, applyDrawStyle })
953
+ },
954
+ customElement.id
955
+ )),
956
+ /* @__PURE__ */ jsx(
957
+ Toolbar,
958
+ {
959
+ items: toolbarItems,
960
+ style: toolbarPlacementStyle,
961
+ currentTool,
962
+ onToolChange: setTool
963
+ }
964
+ )
965
+ ]
966
+ }
967
+ );
968
+ }
969
+ function TsdrawCanvas(props) {
970
+ return /* @__PURE__ */ jsx(Tsdraw, { ...props });
971
+ }
972
+
973
+ export { Tsdraw, TsdrawCanvas, getDefaultToolbarIcon };
974
+ //# sourceMappingURL=index.js.map
975
+ //# sourceMappingURL=index.js.map