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