@tsdraw/react 0.7.0 → 0.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
2
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';
3
+ import { DEFAULT_COLORS, getSelectionBoundsPage, buildTransformSnapshots, Editor, STROKE_WIDTHS, ERASER_MARGIN, resolveThemeColor, pageToScreen, getTopShapeAtPoint, buildStartPositions, applyRotation, applyResize, applyMove, normalizeSelectionBounds, getShapesInBounds, isSelectTool } from '@tsdraw/core';
4
4
  import { IconPointer, IconPencil, IconSquare, IconCircle, IconEraser, IconHandStop, IconArrowBackUp, IconArrowForwardUp } from '@tabler/icons-react';
5
5
 
6
6
  // src/components/TsdrawCanvas.tsx
@@ -295,6 +295,249 @@ function getCanvasCursor(currentTool, state) {
295
295
  return state.showToolOverlay ? "none" : "crosshair";
296
296
  }
297
297
 
298
+ // src/canvas/touchInteractions.ts
299
+ var TAP_MAX_DURATION_MS = 100;
300
+ var DOUBLE_TAP_INTERVAL_MS = 100;
301
+ var TAP_MOVE_TOLERANCE = 14;
302
+ var PINCH_MODE_ZOOM_DISTANCE = 24;
303
+ var PINCH_MODE_PAN_DISTANCE = 16;
304
+ var PINCH_MODE_SWITCH_TO_ZOOM_DISTANCE = 64;
305
+ function createTouchInteractionController(editor, canvas, handlers) {
306
+ const activeTouchPoints = /* @__PURE__ */ new Map();
307
+ const touchTapState = {
308
+ active: false,
309
+ startTime: 0,
310
+ maxTouchCount: 0,
311
+ moved: false,
312
+ startPoints: /* @__PURE__ */ new Map(),
313
+ lastTapAtByCount: {}
314
+ };
315
+ const touchCameraState = {
316
+ active: false,
317
+ mode: "not-sure",
318
+ previousCenter: { x: 0, y: 0 },
319
+ initialCenter: { x: 0, y: 0 },
320
+ previousDistance: 1,
321
+ initialDistance: 1,
322
+ previousAngle: 0
323
+ };
324
+ const isTouchPointer = (event) => event.pointerType === "touch";
325
+ const endTouchCameraGesture = () => {
326
+ touchCameraState.active = false;
327
+ touchCameraState.mode = "not-sure";
328
+ touchCameraState.previousDistance = 1;
329
+ touchCameraState.initialDistance = 1;
330
+ touchCameraState.previousAngle = 0;
331
+ };
332
+ const maybeHandleTouchTapGesture = () => {
333
+ if (activeTouchPoints.size > 0) return;
334
+ if (!touchTapState.active) return;
335
+ const elapsed = performance.now() - touchTapState.startTime;
336
+ if (!touchTapState.moved && elapsed <= TAP_MAX_DURATION_MS && (touchTapState.maxTouchCount === 2 || touchTapState.maxTouchCount === 3)) {
337
+ const fingerCount = touchTapState.maxTouchCount;
338
+ const now = performance.now();
339
+ const previousTapTime = touchTapState.lastTapAtByCount[fingerCount] ?? 0;
340
+ const isDoubleTap = previousTapTime > 0 && now - previousTapTime <= DOUBLE_TAP_INTERVAL_MS;
341
+ if (isDoubleTap) {
342
+ touchTapState.lastTapAtByCount[fingerCount] = 0;
343
+ if (fingerCount === 2) {
344
+ if (handlers.runUndo()) handlers.refreshView();
345
+ } else if (handlers.runRedo()) handlers.refreshView();
346
+ } else touchTapState.lastTapAtByCount[fingerCount] = now;
347
+ }
348
+ touchTapState.active = false;
349
+ touchTapState.startPoints.clear();
350
+ touchTapState.maxTouchCount = 0;
351
+ touchTapState.moved = false;
352
+ };
353
+ const beginTouchCameraGesture = () => {
354
+ const points = [...activeTouchPoints.values()];
355
+ if (points.length !== 2) return;
356
+ handlers.cancelActivePointerInteraction();
357
+ const first = points[0];
358
+ const second = points[1];
359
+ const center = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
360
+ const distance = Math.hypot(second.x - first.x, second.y - first.y);
361
+ const angle = Math.atan2(second.y - first.y, second.x - first.x);
362
+ touchCameraState.active = true;
363
+ touchCameraState.mode = "not-sure";
364
+ touchCameraState.previousCenter = center;
365
+ touchCameraState.initialCenter = center;
366
+ touchCameraState.previousDistance = Math.max(1, distance);
367
+ touchCameraState.initialDistance = Math.max(1, distance);
368
+ touchCameraState.previousAngle = angle;
369
+ };
370
+ const updateTouchCameraGesture = () => {
371
+ if (!touchCameraState.active) return false;
372
+ const points = [...activeTouchPoints.values()];
373
+ if (points.length !== 2) {
374
+ endTouchCameraGesture();
375
+ return false;
376
+ }
377
+ const first = points[0];
378
+ const second = points[1];
379
+ const center = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
380
+ const distance = Math.max(1, Math.hypot(second.x - first.x, second.y - first.y));
381
+ const angle = Math.atan2(second.y - first.y, second.x - first.x);
382
+ const centerDx = center.x - touchCameraState.previousCenter.x;
383
+ const centerDy = center.y - touchCameraState.previousCenter.y;
384
+ const touchDistance = Math.abs(distance - touchCameraState.initialDistance);
385
+ const originDistance = Math.hypot(center.x - touchCameraState.initialCenter.x, center.y - touchCameraState.initialCenter.y);
386
+ if (touchCameraState.mode === "not-sure") {
387
+ if (touchDistance > PINCH_MODE_ZOOM_DISTANCE) touchCameraState.mode = "zooming";
388
+ else if (originDistance > PINCH_MODE_PAN_DISTANCE) touchCameraState.mode = "panning";
389
+ } else if (touchCameraState.mode === "panning" && touchDistance > PINCH_MODE_SWITCH_TO_ZOOM_DISTANCE) touchCameraState.mode = "zooming";
390
+ const canvasRect = canvas.getBoundingClientRect();
391
+ const centerOnCanvasX = center.x - canvasRect.left;
392
+ const centerOnCanvasY = center.y - canvasRect.top;
393
+ editor.panBy(centerDx, centerDy);
394
+ if (touchCameraState.mode === "zooming") {
395
+ const zoomFactor = distance / touchCameraState.previousDistance;
396
+ editor.zoomAt(zoomFactor, centerOnCanvasX, centerOnCanvasY);
397
+ editor.rotateAt(angle - touchCameraState.previousAngle, centerOnCanvasX, centerOnCanvasY);
398
+ }
399
+ touchCameraState.previousCenter = center;
400
+ touchCameraState.previousDistance = distance;
401
+ touchCameraState.previousAngle = angle;
402
+ handlers.refreshView();
403
+ return true;
404
+ };
405
+ const handlePointerDown = (event) => {
406
+ if (!isTouchPointer(event)) return false;
407
+ activeTouchPoints.set(event.pointerId, { x: event.clientX, y: event.clientY });
408
+ if (!touchTapState.active) {
409
+ touchTapState.active = true;
410
+ touchTapState.startTime = performance.now();
411
+ touchTapState.maxTouchCount = activeTouchPoints.size;
412
+ touchTapState.moved = false;
413
+ touchTapState.startPoints.clear();
414
+ } else {
415
+ touchTapState.maxTouchCount = Math.max(touchTapState.maxTouchCount, activeTouchPoints.size);
416
+ }
417
+ touchTapState.startPoints.set(event.pointerId, { x: event.clientX, y: event.clientY });
418
+ if (activeTouchPoints.size === 2) {
419
+ beginTouchCameraGesture();
420
+ return true;
421
+ }
422
+ return false;
423
+ };
424
+ const handlePointerMove = (event) => {
425
+ if (!isTouchPointer(event)) return false;
426
+ if (activeTouchPoints.has(event.pointerId)) activeTouchPoints.set(event.pointerId, { x: event.clientX, y: event.clientY });
427
+ const tapStart = touchTapState.startPoints.get(event.pointerId);
428
+ if (tapStart) {
429
+ const moved = Math.hypot(event.clientX - tapStart.x, event.clientY - tapStart.y);
430
+ if (moved > TAP_MOVE_TOLERANCE) touchTapState.moved = true;
431
+ }
432
+ return updateTouchCameraGesture();
433
+ };
434
+ const handlePointerUpOrCancel = (event) => {
435
+ if (!isTouchPointer(event)) return false;
436
+ const wasCameraGestureActive = touchCameraState.active;
437
+ activeTouchPoints.delete(event.pointerId);
438
+ touchTapState.startPoints.delete(event.pointerId);
439
+ if (activeTouchPoints.size < 2) endTouchCameraGesture();
440
+ maybeHandleTouchTapGesture();
441
+ return wasCameraGestureActive;
442
+ };
443
+ let gestureLastScale = 1;
444
+ let gestureActive = false;
445
+ const handleGestureEvent = (event, container) => {
446
+ if (!container.contains(event.target)) return;
447
+ event.preventDefault();
448
+ const gestureEvent = event;
449
+ if (gestureEvent.scale == null) return;
450
+ if (event.type === "gesturestart") {
451
+ gestureLastScale = gestureEvent.scale;
452
+ gestureActive = true;
453
+ return;
454
+ }
455
+ if (event.type === "gestureend") {
456
+ gestureActive = false;
457
+ gestureLastScale = 1;
458
+ return;
459
+ }
460
+ if (event.type === "gesturechange" && gestureActive) {
461
+ const zoomFactor = gestureEvent.scale / gestureLastScale;
462
+ gestureLastScale = gestureEvent.scale;
463
+ const canvasRect = canvas.getBoundingClientRect();
464
+ const cx = (gestureEvent.clientX ?? canvasRect.left + canvasRect.width / 2) - canvasRect.left;
465
+ const cy = (gestureEvent.clientY ?? canvasRect.top + canvasRect.height / 2) - canvasRect.top;
466
+ editor.zoomAt(zoomFactor, cx, cy);
467
+ handlers.refreshView();
468
+ }
469
+ };
470
+ const reset = () => {
471
+ activeTouchPoints.clear();
472
+ touchTapState.active = false;
473
+ touchTapState.startPoints.clear();
474
+ endTouchCameraGesture();
475
+ };
476
+ return {
477
+ handlePointerDown,
478
+ handlePointerMove,
479
+ handlePointerUpOrCancel,
480
+ handleGestureEvent,
481
+ reset,
482
+ isCameraGestureActive: () => touchCameraState.active,
483
+ isTrackpadZoomActive: () => gestureActive
484
+ };
485
+ }
486
+
487
+ // src/canvas/keyboardShortcuts.ts
488
+ var TOOL_SHORTCUTS = {
489
+ v: "select",
490
+ h: "hand",
491
+ e: "eraser",
492
+ p: "pen",
493
+ b: "pen",
494
+ d: "pen",
495
+ x: "pen",
496
+ r: "square",
497
+ o: "circle",
498
+ c: "circle"
499
+ };
500
+ function isEditableTarget(eventTarget) {
501
+ const element = eventTarget;
502
+ if (!element) return false;
503
+ if (element.isContentEditable) return true;
504
+ const tagName = element.tagName;
505
+ return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
506
+ }
507
+ function handleKeyboardShortcutKeyDown(event, handlers) {
508
+ if (isEditableTarget(event.target)) return;
509
+ const loweredKey = event.key.toLowerCase();
510
+ const isMetaPressed = event.metaKey || event.ctrlKey;
511
+ if (isMetaPressed && (loweredKey === "z" || loweredKey === "y")) {
512
+ const shouldRedo = loweredKey === "y" || loweredKey === "z" && event.shiftKey;
513
+ if (handlers.runHistoryShortcut(shouldRedo)) {
514
+ event.preventDefault();
515
+ event.stopPropagation();
516
+ return;
517
+ }
518
+ }
519
+ if (!isMetaPressed && !event.altKey) {
520
+ const nextToolId = TOOL_SHORTCUTS[loweredKey];
521
+ if (nextToolId && handlers.isToolAvailable(nextToolId)) {
522
+ handlers.setToolFromShortcut(nextToolId);
523
+ event.preventDefault();
524
+ return;
525
+ }
526
+ }
527
+ if (event.key === "Delete" || event.key === "Backspace") {
528
+ if (handlers.deleteSelection()) {
529
+ event.preventDefault();
530
+ event.stopPropagation();
531
+ return;
532
+ }
533
+ }
534
+ handlers.dispatchKeyDown(event);
535
+ }
536
+ function handleKeyboardShortcutKeyUp(event, handlers) {
537
+ if (isEditableTarget(event.target)) return;
538
+ handlers.dispatchKeyUp(event);
539
+ }
540
+
298
541
  // src/persistence/localIndexedDb.ts
299
542
  var DATABASE_PREFIX = "tsdraw_v1_";
300
543
  var DATABASE_VERSION = 2;
@@ -418,23 +661,35 @@ function getOrCreateSessionId() {
418
661
 
419
662
  // src/canvas/useTsdrawCanvasController.ts
420
663
  function toScreenRect(editor, bounds) {
421
- const { x, y, zoom } = editor.viewport;
664
+ const topLeft = pageToScreen(editor.viewport, bounds.minX, bounds.minY);
665
+ const topRight = pageToScreen(editor.viewport, bounds.maxX, bounds.minY);
666
+ const bottomLeft = pageToScreen(editor.viewport, bounds.minX, bounds.maxY);
667
+ const bottomRight = pageToScreen(editor.viewport, bounds.maxX, bounds.maxY);
668
+ const minX = Math.min(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x);
669
+ const minY = Math.min(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y);
670
+ const maxX = Math.max(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x);
671
+ const maxY = Math.max(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y);
422
672
  return {
423
- left: bounds.minX * zoom + x,
424
- top: bounds.minY * zoom + y,
425
- width: (bounds.maxX - bounds.minX) * zoom,
426
- height: (bounds.maxY - bounds.minY) * zoom
673
+ left: minX,
674
+ top: minY,
675
+ width: Math.max(0, maxX - minX),
676
+ height: Math.max(0, maxY - minY)
427
677
  };
428
678
  }
429
679
  function resolveDrawColor(colorStyle, theme) {
430
680
  return resolveThemeColor(colorStyle, theme);
431
681
  }
682
+ var ZOOM_WHEEL_CAP = 10;
432
683
  function useTsdrawCanvasController(options = {}) {
433
684
  const onMountRef = useRef(options.onMount);
434
685
  const containerRef = useRef(null);
435
686
  const canvasRef = useRef(null);
436
687
  const editorRef = useRef(null);
437
688
  const dprRef = useRef(1);
689
+ const penDetectedRef = useRef(false);
690
+ const penModeRef = useRef(false);
691
+ const lastPointerDownWithRef = useRef("mouse");
692
+ const activePointerIdsRef = useRef(/* @__PURE__ */ new Set());
438
693
  const lastPointerClientRef = useRef(null);
439
694
  const currentToolRef = useRef(options.initialTool ?? "pen");
440
695
  const selectedShapeIdsRef = useRef([]);
@@ -691,9 +946,9 @@ function useTsdrawCanvasController(options = {}) {
691
946
  }
692
947
  refreshSelectionBounds(editor, nextSelectedShapeIds);
693
948
  };
694
- const applyRemoteDocumentSnapshot = (document) => {
949
+ const applyRemoteDocumentSnapshot = (document2) => {
695
950
  ignorePersistenceChanges = true;
696
- editor.loadDocumentSnapshot(document);
951
+ editor.loadDocumentSnapshot(document2);
697
952
  editor.clearRedoHistory();
698
953
  reconcileSelectionAfterDocumentLoad();
699
954
  render();
@@ -725,8 +980,95 @@ function useTsdrawCanvasController(options = {}) {
725
980
  const coalesced = e.getCoalescedEvents?.();
726
981
  return coalesced && coalesced.length > 0 ? coalesced : [e];
727
982
  };
983
+ const applyDocumentChangeResult = (changed) => {
984
+ if (!changed) return false;
985
+ reconcileSelectionAfterDocumentLoad();
986
+ setSelectionRotationDeg(0);
987
+ render();
988
+ syncHistoryState();
989
+ return true;
990
+ };
991
+ const normalizeWheelDelta = (event) => {
992
+ let deltaX = event.deltaX;
993
+ let deltaY = event.deltaY;
994
+ let deltaZoom = 0;
995
+ if (event.ctrlKey || event.metaKey || event.altKey) {
996
+ const clamped = Math.abs(deltaY) > ZOOM_WHEEL_CAP ? ZOOM_WHEEL_CAP * Math.sign(deltaY) : deltaY;
997
+ deltaZoom = -clamped / 100;
998
+ } else if (event.shiftKey && !navigator.userAgent.includes("Mac") && !navigator.userAgent.includes("iPhone") && !navigator.userAgent.includes("iPad")) {
999
+ deltaX = deltaY;
1000
+ deltaY = 0;
1001
+ }
1002
+ return { x: -deltaX, y: -deltaY, z: deltaZoom };
1003
+ };
1004
+ const deleteCurrentSelection = () => {
1005
+ const selectedIds = selectedShapeIdsRef.current;
1006
+ if (selectedIds.length === 0) return false;
1007
+ editor.beginHistoryEntry();
1008
+ editor.deleteShapes(selectedIds);
1009
+ editor.endHistoryEntry();
1010
+ setSelectedShapeIds([]);
1011
+ selectedShapeIdsRef.current = [];
1012
+ setSelectionBounds(null);
1013
+ setSelectionBrush(null);
1014
+ setSelectionRotationDeg(0);
1015
+ render();
1016
+ syncHistoryState();
1017
+ return true;
1018
+ };
1019
+ const cancelActivePointerInteraction = () => {
1020
+ if (!isPointerActiveRef.current) return;
1021
+ isPointerActiveRef.current = false;
1022
+ lastPointerClientRef.current = null;
1023
+ editor.input.pointerUp();
1024
+ if (currentToolRef.current === "select") {
1025
+ const dragMode = selectDragRef.current.mode;
1026
+ if (dragMode === "rotate") setIsRotatingSelection(false);
1027
+ if (dragMode === "resize") setIsResizingSelection(false);
1028
+ if (dragMode === "move") setIsMovingSelection(false);
1029
+ if (dragMode === "marquee") setSelectionBrush(null);
1030
+ selectDragRef.current.mode = "none";
1031
+ } else {
1032
+ editor.tools.pointerUp();
1033
+ }
1034
+ editor.endHistoryEntry();
1035
+ render();
1036
+ refreshSelectionBounds(editor);
1037
+ };
1038
+ const touchInteractions = createTouchInteractionController(editor, canvas, {
1039
+ cancelActivePointerInteraction,
1040
+ refreshView: () => {
1041
+ render();
1042
+ refreshSelectionBounds(editor);
1043
+ },
1044
+ runUndo: () => applyDocumentChangeResult(editor.undo()),
1045
+ runRedo: () => applyDocumentChangeResult(editor.redo())
1046
+ });
1047
+ const isDrawingTool = (tool) => tool !== "select" && tool !== "hand";
1048
+ const hasRealPressure = (pressure) => pressure != null && pressure > 0 && pressure !== 0.5;
728
1049
  const handlePointerDown = (e) => {
729
1050
  if (!canvas.contains(e.target)) return;
1051
+ if (!penDetectedRef.current && (e.pointerType === "pen" || hasRealPressure(e.pressure))) {
1052
+ penDetectedRef.current = true;
1053
+ penModeRef.current = true;
1054
+ }
1055
+ lastPointerDownWithRef.current = e.pointerType;
1056
+ activePointerIdsRef.current.add(e.pointerId);
1057
+ const startedCameraGesture = touchInteractions.handlePointerDown(e);
1058
+ if (startedCameraGesture || touchInteractions.isCameraGestureActive()) {
1059
+ e.preventDefault();
1060
+ if (!canvas.hasPointerCapture(e.pointerId)) {
1061
+ canvas.setPointerCapture(e.pointerId);
1062
+ }
1063
+ return;
1064
+ }
1065
+ const allowPointerDown = !penModeRef.current || e.pointerType !== "touch" || !isDrawingTool(currentToolRef.current);
1066
+ if (!allowPointerDown) {
1067
+ return;
1068
+ }
1069
+ if (activePointerIdsRef.current.size > 1) {
1070
+ return;
1071
+ }
730
1072
  isPointerActiveRef.current = true;
731
1073
  editor.beginHistoryEntry();
732
1074
  canvas.setPointerCapture(e.pointerId);
@@ -735,7 +1077,7 @@ function useTsdrawCanvasController(options = {}) {
735
1077
  const first = sampleEvents(e)[0];
736
1078
  const { x, y } = getPagePoint(first);
737
1079
  const pressure = first.pressure ?? 0.5;
738
- const isPen = first.pointerType === "pen" || first.pointerType === "touch";
1080
+ const isPen = first.pointerType === "pen" || hasRealPressure(first.pressure);
739
1081
  if (currentToolRef.current === "select") {
740
1082
  const hit = getTopShapeAtPoint(editor, { x, y });
741
1083
  const isHitSelected = !!(hit && selectedShapeIdsRef.current.includes(hit.id));
@@ -775,6 +1117,16 @@ function useTsdrawCanvasController(options = {}) {
775
1117
  refreshSelectionBounds(editor);
776
1118
  };
777
1119
  const handlePointerMove = (e) => {
1120
+ if (!penDetectedRef.current && (e.pointerType === "pen" || hasRealPressure(e.pressure))) {
1121
+ penDetectedRef.current = true;
1122
+ penModeRef.current = true;
1123
+ }
1124
+ if (touchInteractions.handlePointerMove(e)) {
1125
+ e.preventDefault();
1126
+ return;
1127
+ }
1128
+ if (penModeRef.current && e.pointerType === "touch" && isDrawingTool(currentToolRef.current) && !isPointerActiveRef.current) return;
1129
+ if (activePointerIdsRef.current.size > 1) return;
778
1130
  updatePointerPreview(e.clientX, e.clientY);
779
1131
  const prevClient = lastPointerClientRef.current;
780
1132
  const dx = prevClient ? e.clientX - prevClient.x : 0;
@@ -783,7 +1135,7 @@ function useTsdrawCanvasController(options = {}) {
783
1135
  for (const sample of sampleEvents(e)) {
784
1136
  const { x, y } = getPagePoint(sample);
785
1137
  const pressure = sample.pressure ?? 0.5;
786
- const isPen = sample.pointerType === "pen" || sample.pointerType === "touch";
1138
+ const isPen = sample.pointerType === "pen" || hasRealPressure(sample.pressure);
787
1139
  editor.input.pointerMove(x, y, pressure, isPen);
788
1140
  }
789
1141
  if (currentToolRef.current === "select") {
@@ -833,6 +1185,13 @@ function useTsdrawCanvasController(options = {}) {
833
1185
  refreshSelectionBounds(editor);
834
1186
  };
835
1187
  const handlePointerUp = (e) => {
1188
+ activePointerIdsRef.current.delete(e.pointerId);
1189
+ const hadTouchCameraGesture = touchInteractions.handlePointerUpOrCancel(e);
1190
+ if (hadTouchCameraGesture || touchInteractions.isCameraGestureActive()) {
1191
+ e.preventDefault();
1192
+ return;
1193
+ }
1194
+ if (!isPointerActiveRef.current) return;
836
1195
  isPointerActiveRef.current = false;
837
1196
  lastPointerClientRef.current = null;
838
1197
  updatePointerPreview(e.clientX, e.clientY);
@@ -916,7 +1275,10 @@ function useTsdrawCanvasController(options = {}) {
916
1275
  }
917
1276
  editor.endHistoryEntry();
918
1277
  };
919
- const handlePointerCancel = () => {
1278
+ const handlePointerCancel = (e) => {
1279
+ activePointerIdsRef.current.delete(e.pointerId);
1280
+ const hadTouchCameraGesture = touchInteractions.handlePointerUpOrCancel(e);
1281
+ if (hadTouchCameraGesture || touchInteractions.isCameraGestureActive()) return;
920
1282
  if (!isPointerActiveRef.current) return;
921
1283
  isPointerActiveRef.current = false;
922
1284
  lastPointerClientRef.current = null;
@@ -945,31 +1307,58 @@ function useTsdrawCanvasController(options = {}) {
945
1307
  applyRemoteDocumentSnapshot(pending);
946
1308
  }
947
1309
  };
948
- const handleKeyDown = (e) => {
949
- const isMetaPressed = e.metaKey || e.ctrlKey;
950
- const loweredKey = e.key.toLowerCase();
951
- const isUndoOrRedoKey = loweredKey === "z" || loweredKey === "y";
952
- if (isMetaPressed && isUndoOrRedoKey) {
953
- const shouldRedo = loweredKey === "y" || loweredKey === "z" && e.shiftKey;
954
- const changed = shouldRedo ? editor.redo() : editor.undo();
955
- if (changed) {
956
- e.preventDefault();
957
- e.stopPropagation();
958
- reconcileSelectionAfterDocumentLoad();
959
- setSelectionRotationDeg(0);
960
- render();
961
- syncHistoryState();
962
- return;
963
- }
1310
+ const handleWheel = (e) => {
1311
+ if (!container.contains(e.target)) return;
1312
+ e.preventDefault();
1313
+ if (touchInteractions.isTrackpadZoomActive()) return;
1314
+ const delta = normalizeWheelDelta(e);
1315
+ if (delta.z !== 0) {
1316
+ const rect = canvas.getBoundingClientRect();
1317
+ const pointX = e.clientX - rect.left;
1318
+ const pointY = e.clientY - rect.top;
1319
+ editor.zoomAt(Math.exp(delta.z), pointX, pointY);
1320
+ } else {
1321
+ editor.panBy(delta.x, delta.y);
964
1322
  }
965
- editor.input.setModifiers(e.shiftKey, e.ctrlKey, e.metaKey);
966
- editor.tools.keyDown({ key: e.key });
967
1323
  render();
1324
+ refreshSelectionBounds(editor);
1325
+ };
1326
+ const handleGestureEvent = (e) => {
1327
+ touchInteractions.handleGestureEvent(e, container);
1328
+ };
1329
+ const handleKeyDown = (e) => {
1330
+ handleKeyboardShortcutKeyDown(e, {
1331
+ isToolAvailable: (tool) => editor.tools.hasTool(tool),
1332
+ setToolFromShortcut: (tool) => {
1333
+ editor.setCurrentTool(tool);
1334
+ setCurrentToolState(tool);
1335
+ currentToolRef.current = tool;
1336
+ if (tool !== "select") resetSelectUi();
1337
+ render();
1338
+ },
1339
+ runHistoryShortcut: (shouldRedo) => applyDocumentChangeResult(shouldRedo ? editor.redo() : editor.undo()),
1340
+ deleteSelection: () => currentToolRef.current === "select" ? deleteCurrentSelection() : false,
1341
+ dispatchKeyDown: (event) => {
1342
+ editor.input.setModifiers(event.shiftKey, event.ctrlKey, event.metaKey);
1343
+ editor.tools.keyDown({ key: event.key });
1344
+ render();
1345
+ },
1346
+ dispatchKeyUp: () => void 0
1347
+ });
968
1348
  };
969
1349
  const handleKeyUp = (e) => {
970
- editor.input.setModifiers(e.shiftKey, e.ctrlKey, e.metaKey);
971
- editor.tools.keyUp({ key: e.key });
972
- render();
1350
+ handleKeyboardShortcutKeyUp(e, {
1351
+ isToolAvailable: () => false,
1352
+ setToolFromShortcut: () => void 0,
1353
+ runHistoryShortcut: () => false,
1354
+ deleteSelection: () => false,
1355
+ dispatchKeyDown: () => void 0,
1356
+ dispatchKeyUp: (event) => {
1357
+ editor.input.setModifiers(event.shiftKey, event.ctrlKey, event.metaKey);
1358
+ editor.tools.keyUp({ key: event.key });
1359
+ render();
1360
+ }
1361
+ });
973
1362
  };
974
1363
  const initializePersistence = async () => {
975
1364
  if (!persistenceKey) {
@@ -1046,6 +1435,10 @@ function useTsdrawCanvasController(options = {}) {
1046
1435
  const ro = new ResizeObserver(resize);
1047
1436
  ro.observe(container);
1048
1437
  canvas.addEventListener("pointerdown", handlePointerDown);
1438
+ container.addEventListener("wheel", handleWheel, { passive: false });
1439
+ document.addEventListener("gesturestart", handleGestureEvent);
1440
+ document.addEventListener("gesturechange", handleGestureEvent);
1441
+ document.addEventListener("gestureend", handleGestureEvent);
1049
1442
  window.addEventListener("pointermove", handlePointerMove);
1050
1443
  window.addEventListener("pointerup", handlePointerUp);
1051
1444
  window.addEventListener("pointercancel", handlePointerCancel);
@@ -1102,13 +1495,19 @@ function useTsdrawCanvasController(options = {}) {
1102
1495
  disposeMount?.();
1103
1496
  ro.disconnect();
1104
1497
  canvas.removeEventListener("pointerdown", handlePointerDown);
1498
+ container.removeEventListener("wheel", handleWheel);
1499
+ document.removeEventListener("gesturestart", handleGestureEvent);
1500
+ document.removeEventListener("gesturechange", handleGestureEvent);
1501
+ document.removeEventListener("gestureend", handleGestureEvent);
1105
1502
  window.removeEventListener("pointermove", handlePointerMove);
1106
1503
  window.removeEventListener("pointerup", handlePointerUp);
1107
1504
  window.removeEventListener("pointercancel", handlePointerCancel);
1108
1505
  window.removeEventListener("keydown", handleKeyDown);
1109
1506
  window.removeEventListener("keyup", handleKeyUp);
1110
1507
  isPointerActiveRef.current = false;
1508
+ activePointerIdsRef.current.clear();
1111
1509
  pendingRemoteDocumentRef.current = null;
1510
+ touchInteractions.reset();
1112
1511
  persistenceChannel?.close();
1113
1512
  void persistenceDb?.close();
1114
1513
  editorRef.current = null;
@@ -1119,6 +1518,7 @@ function useTsdrawCanvasController(options = {}) {
1119
1518
  options.persistenceKey,
1120
1519
  options.toolDefinitions,
1121
1520
  refreshSelectionBounds,
1521
+ resetSelectUi,
1122
1522
  render,
1123
1523
  updatePointerPreview
1124
1524
  ]);