@tsdraw/react 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, pageToScreen, 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, HandDraggingState, startCameraSlide, isSelectTool, beginCameraPan, moveCameraPan } 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,15 +295,17 @@ function getCanvasCursor(currentTool, state) {
295
295
  }
296
296
  return state.showToolOverlay ? "none" : "crosshair";
297
297
  }
298
-
299
- // src/canvas/touchInteractions.ts
300
298
  var TAP_MAX_DURATION_MS = 100;
301
299
  var DOUBLE_TAP_INTERVAL_MS = 100;
302
300
  var TAP_MOVE_TOLERANCE = 14;
303
301
  var PINCH_MODE_ZOOM_DISTANCE = 24;
304
302
  var PINCH_MODE_PAN_DISTANCE = 16;
305
303
  var PINCH_MODE_SWITCH_TO_ZOOM_DISTANCE = 64;
306
- function createTouchInteractionController(editor, canvas, handlers) {
304
+ function createTouchInteractionController(editor, canvas, handlers, touchOptions) {
305
+ const allowPinchZoom = touchOptions?.pinchToZoom !== false;
306
+ const allowFingerPan = touchOptions?.fingerPanInPenMode !== false;
307
+ const allowTapUndoRedo = touchOptions?.tapUndoRedo !== false;
308
+ const allowTrackpadGestures = touchOptions?.trackpadGestures !== false;
307
309
  const activeTouchPoints = /* @__PURE__ */ new Map();
308
310
  const touchTapState = {
309
311
  active: false,
@@ -318,21 +320,34 @@ function createTouchInteractionController(editor, canvas, handlers) {
318
320
  mode: "not-sure",
319
321
  previousCenter: { x: 0, y: 0 },
320
322
  initialCenter: { x: 0, y: 0 },
321
- previousDistance: 1,
322
- initialDistance: 1
323
+ initialDistance: 1,
324
+ initialZoom: 1
323
325
  };
326
+ let fingerPanPointerId = null;
327
+ let fingerPanSession = null;
328
+ let fingerPanSlide = null;
324
329
  const isTouchPointer = (event) => event.pointerType === "touch";
330
+ const stopFingerPanSlide = () => {
331
+ if (fingerPanSlide) {
332
+ fingerPanSlide.stop();
333
+ fingerPanSlide = null;
334
+ }
335
+ };
336
+ const endFingerPan = () => {
337
+ fingerPanPointerId = null;
338
+ fingerPanSession = null;
339
+ };
325
340
  const endTouchCameraGesture = () => {
326
341
  touchCameraState.active = false;
327
342
  touchCameraState.mode = "not-sure";
328
- touchCameraState.previousDistance = 1;
329
343
  touchCameraState.initialDistance = 1;
344
+ touchCameraState.initialZoom = 1;
330
345
  };
331
346
  const maybeHandleTouchTapGesture = () => {
332
347
  if (activeTouchPoints.size > 0) return;
333
348
  if (!touchTapState.active) return;
334
349
  const elapsed = performance.now() - touchTapState.startTime;
335
- if (!touchTapState.moved && elapsed <= TAP_MAX_DURATION_MS && (touchTapState.maxTouchCount === 2 || touchTapState.maxTouchCount === 3)) {
350
+ if (allowTapUndoRedo && !touchTapState.moved && elapsed <= TAP_MAX_DURATION_MS && (touchTapState.maxTouchCount === 2 || touchTapState.maxTouchCount === 3)) {
336
351
  const fingerCount = touchTapState.maxTouchCount;
337
352
  const now = performance.now();
338
353
  const previousTapTime = touchTapState.lastTapAtByCount[fingerCount] ?? 0;
@@ -361,8 +376,8 @@ function createTouchInteractionController(editor, canvas, handlers) {
361
376
  touchCameraState.mode = "not-sure";
362
377
  touchCameraState.previousCenter = center;
363
378
  touchCameraState.initialCenter = center;
364
- touchCameraState.previousDistance = Math.max(1, distance);
365
379
  touchCameraState.initialDistance = Math.max(1, distance);
380
+ touchCameraState.initialZoom = editor.getZoomLevel();
366
381
  };
367
382
  const updateTouchCameraGesture = () => {
368
383
  if (!touchCameraState.active) return false;
@@ -380,24 +395,33 @@ function createTouchInteractionController(editor, canvas, handlers) {
380
395
  const touchDistance = Math.abs(distance - touchCameraState.initialDistance);
381
396
  const originDistance = Math.hypot(center.x - touchCameraState.initialCenter.x, center.y - touchCameraState.initialCenter.y);
382
397
  if (touchCameraState.mode === "not-sure") {
383
- if (touchDistance > PINCH_MODE_ZOOM_DISTANCE) touchCameraState.mode = "zooming";
398
+ if (allowPinchZoom && touchDistance > PINCH_MODE_ZOOM_DISTANCE) touchCameraState.mode = "zooming";
384
399
  else if (originDistance > PINCH_MODE_PAN_DISTANCE) touchCameraState.mode = "panning";
385
- } else if (touchCameraState.mode === "panning" && touchDistance > PINCH_MODE_SWITCH_TO_ZOOM_DISTANCE) touchCameraState.mode = "zooming";
400
+ } else if (allowPinchZoom && touchCameraState.mode === "panning" && touchDistance > PINCH_MODE_SWITCH_TO_ZOOM_DISTANCE) touchCameraState.mode = "zooming";
386
401
  const canvasRect = canvas.getBoundingClientRect();
387
402
  const centerOnCanvasX = center.x - canvasRect.left;
388
403
  const centerOnCanvasY = center.y - canvasRect.top;
389
- editor.panBy(centerDx, centerDy);
390
404
  if (touchCameraState.mode === "zooming") {
391
- const zoomFactor = distance / touchCameraState.previousDistance;
392
- editor.zoomAt(zoomFactor, centerOnCanvasX, centerOnCanvasY);
405
+ const targetZoom = Math.max(0.1, Math.min(4, touchCameraState.initialZoom * (distance / touchCameraState.initialDistance)));
406
+ const pannedX = editor.viewport.x + centerDx;
407
+ const pannedY = editor.viewport.y + centerDy;
408
+ const pageAtCenterX = (centerOnCanvasX - pannedX) / editor.viewport.zoom;
409
+ const pageAtCenterY = (centerOnCanvasY - pannedY) / editor.viewport.zoom;
410
+ editor.setViewport({
411
+ x: centerOnCanvasX - pageAtCenterX * targetZoom,
412
+ y: centerOnCanvasY - pageAtCenterY * targetZoom,
413
+ zoom: targetZoom
414
+ });
415
+ } else {
416
+ editor.panBy(centerDx, centerDy);
393
417
  }
394
418
  touchCameraState.previousCenter = center;
395
- touchCameraState.previousDistance = distance;
396
419
  handlers.refreshView();
397
420
  return true;
398
421
  };
399
422
  const handlePointerDown = (event) => {
400
423
  if (!isTouchPointer(event)) return false;
424
+ stopFingerPanSlide();
401
425
  activeTouchPoints.set(event.pointerId, { x: event.clientX, y: event.clientY });
402
426
  if (!touchTapState.active) {
403
427
  touchTapState.active = true;
@@ -410,9 +434,16 @@ function createTouchInteractionController(editor, canvas, handlers) {
410
434
  }
411
435
  touchTapState.startPoints.set(event.pointerId, { x: event.clientX, y: event.clientY });
412
436
  if (activeTouchPoints.size === 2) {
437
+ endFingerPan();
413
438
  beginTouchCameraGesture();
414
439
  return true;
415
440
  }
441
+ if (allowFingerPan && handlers.isPenModeActive() && activeTouchPoints.size === 1) {
442
+ handlers.cancelActivePointerInteraction();
443
+ fingerPanPointerId = event.pointerId;
444
+ fingerPanSession = beginCameraPan(editor.viewport, event.clientX, event.clientY);
445
+ return true;
446
+ }
416
447
  return false;
417
448
  };
418
449
  const handlePointerMove = (event) => {
@@ -423,22 +454,45 @@ function createTouchInteractionController(editor, canvas, handlers) {
423
454
  const moved = Math.hypot(event.clientX - tapStart.x, event.clientY - tapStart.y);
424
455
  if (moved > TAP_MOVE_TOLERANCE) touchTapState.moved = true;
425
456
  }
457
+ if (fingerPanPointerId === event.pointerId && fingerPanSession) {
458
+ const target = moveCameraPan(fingerPanSession, event.clientX, event.clientY);
459
+ editor.setViewport({ x: target.x, y: target.y });
460
+ handlers.refreshView();
461
+ return true;
462
+ }
426
463
  return updateTouchCameraGesture();
427
464
  };
428
465
  const handlePointerUpOrCancel = (event) => {
429
466
  if (!isTouchPointer(event)) return false;
430
467
  const wasCameraGestureActive = touchCameraState.active;
468
+ const wasFingerPan = fingerPanPointerId === event.pointerId;
469
+ const releasedPanSession = wasFingerPan ? fingerPanSession : null;
431
470
  activeTouchPoints.delete(event.pointerId);
432
471
  touchTapState.startPoints.delete(event.pointerId);
433
472
  if (activeTouchPoints.size < 2) endTouchCameraGesture();
473
+ if (wasFingerPan) {
474
+ endFingerPan();
475
+ if (releasedPanSession) {
476
+ const slideConfig = handlers.getSlideOptions();
477
+ if (slideConfig.enabled) {
478
+ fingerPanSlide = startCameraSlide(
479
+ releasedPanSession,
480
+ (dx, dy) => editor.panBy(dx, dy),
481
+ () => handlers.refreshView(),
482
+ slideConfig.slideOptions
483
+ );
484
+ }
485
+ }
486
+ }
434
487
  maybeHandleTouchTapGesture();
435
- return wasCameraGestureActive;
488
+ return wasCameraGestureActive || wasFingerPan;
436
489
  };
437
490
  let gestureLastScale = 1;
438
491
  let gestureActive = false;
439
492
  const handleGestureEvent = (event, container) => {
440
493
  if (!container.contains(event.target)) return;
441
494
  event.preventDefault();
495
+ if (!allowTrackpadGestures) return;
442
496
  const gestureEvent = event;
443
497
  if (gestureEvent.scale == null) return;
444
498
  if (event.type === "gesturestart") {
@@ -466,6 +520,8 @@ function createTouchInteractionController(editor, canvas, handlers) {
466
520
  touchTapState.active = false;
467
521
  touchTapState.startPoints.clear();
468
522
  endTouchCameraGesture();
523
+ endFingerPan();
524
+ stopFingerPanSlide();
469
525
  };
470
526
  return {
471
527
  handlePointerDown,
@@ -474,12 +530,13 @@ function createTouchInteractionController(editor, canvas, handlers) {
474
530
  handleGestureEvent,
475
531
  reset,
476
532
  isCameraGestureActive: () => touchCameraState.active,
533
+ isFingerPanActive: () => fingerPanPointerId !== null,
477
534
  isTrackpadZoomActive: () => gestureActive
478
535
  };
479
536
  }
480
537
 
481
538
  // src/canvas/keyboardShortcuts.ts
482
- var TOOL_SHORTCUTS = {
539
+ var DEFAULT_TOOL_SHORTCUTS = {
483
540
  v: "select",
484
541
  h: "hand",
485
542
  e: "eraser",
@@ -491,6 +548,11 @@ var TOOL_SHORTCUTS = {
491
548
  o: "circle",
492
549
  c: "circle"
493
550
  };
551
+ function resolveToolShortcuts(shortcutOptions) {
552
+ if (!shortcutOptions?.toolShortcuts) return DEFAULT_TOOL_SHORTCUTS;
553
+ if (shortcutOptions.overrideDefaults) return { ...shortcutOptions.toolShortcuts };
554
+ return { ...DEFAULT_TOOL_SHORTCUTS, ...shortcutOptions.toolShortcuts };
555
+ }
494
556
  function isEditableTarget(eventTarget) {
495
557
  const element = eventTarget;
496
558
  if (!element) return false;
@@ -498,7 +560,7 @@ function isEditableTarget(eventTarget) {
498
560
  const tagName = element.tagName;
499
561
  return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
500
562
  }
501
- function handleKeyboardShortcutKeyDown(event, handlers) {
563
+ function handleKeyboardShortcutKeyDown(event, handlers, toolShortcutMap = DEFAULT_TOOL_SHORTCUTS) {
502
564
  if (isEditableTarget(event.target)) return;
503
565
  const loweredKey = event.key.toLowerCase();
504
566
  const isMetaPressed = event.metaKey || event.ctrlKey;
@@ -511,7 +573,7 @@ function handleKeyboardShortcutKeyDown(event, handlers) {
511
573
  }
512
574
  }
513
575
  if (!isMetaPressed && !event.altKey) {
514
- const nextToolId = TOOL_SHORTCUTS[loweredKey];
576
+ const nextToolId = toolShortcutMap[loweredKey];
515
577
  if (nextToolId && handlers.isToolAvailable(nextToolId)) {
516
578
  handlers.setToolFromShortcut(nextToolId);
517
579
  event.preventDefault();
@@ -686,8 +748,17 @@ function getHandlePagePoint(bounds, handle) {
686
748
  }
687
749
  }
688
750
  var ZOOM_WHEEL_CAP = 10;
751
+ var VIEW_ONLY_TOOLS = /* @__PURE__ */ new Set(["select", "hand"]);
689
752
  function useTsdrawCanvasController(options = {}) {
690
753
  const onMountRef = useRef(options.onMount);
754
+ const onChangeRef = useRef(options.onChange);
755
+ const onCameraChangeRef = useRef(options.onCameraChange);
756
+ const onToolChangeRef = useRef(options.onToolChange);
757
+ const cameraOptionsRef = useRef(options.cameraOptions);
758
+ const touchOptionsRef = useRef(options.touchOptions);
759
+ const keyboardShortcutsRef = useRef(options.keyboardShortcuts);
760
+ const penOptionsRef = useRef(options.penOptions);
761
+ const readOnlyRef = useRef(options.readOnly ?? false);
691
762
  const containerRef = useRef(null);
692
763
  const canvasRef = useRef(null);
693
764
  const editorRef = useRef(null);
@@ -702,6 +773,7 @@ function useTsdrawCanvasController(options = {}) {
702
773
  const schedulePersistRef = useRef(null);
703
774
  const isPointerActiveRef = useRef(false);
704
775
  const pendingRemoteDocumentRef = useRef(null);
776
+ const activeCameraSlideRef = useRef(null);
705
777
  const selectionRotationRef = useRef(0);
706
778
  const resizeRef = useRef({
707
779
  handle: null,
@@ -746,6 +818,30 @@ function useTsdrawCanvasController(options = {}) {
746
818
  useEffect(() => {
747
819
  onMountRef.current = options.onMount;
748
820
  }, [options.onMount]);
821
+ useEffect(() => {
822
+ onChangeRef.current = options.onChange;
823
+ }, [options.onChange]);
824
+ useEffect(() => {
825
+ onCameraChangeRef.current = options.onCameraChange;
826
+ }, [options.onCameraChange]);
827
+ useEffect(() => {
828
+ onToolChangeRef.current = options.onToolChange;
829
+ }, [options.onToolChange]);
830
+ useEffect(() => {
831
+ cameraOptionsRef.current = options.cameraOptions;
832
+ }, [options.cameraOptions]);
833
+ useEffect(() => {
834
+ touchOptionsRef.current = options.touchOptions;
835
+ }, [options.touchOptions]);
836
+ useEffect(() => {
837
+ keyboardShortcutsRef.current = options.keyboardShortcuts;
838
+ }, [options.keyboardShortcuts]);
839
+ useEffect(() => {
840
+ penOptionsRef.current = options.penOptions;
841
+ }, [options.penOptions]);
842
+ useEffect(() => {
843
+ readOnlyRef.current = options.readOnly ?? false;
844
+ }, [options.readOnly]);
749
845
  useEffect(() => {
750
846
  selectedShapeIdsRef.current = selectedShapeIds;
751
847
  }, [selectedShapeIds]);
@@ -869,14 +965,21 @@ function useTsdrawCanvasController(options = {}) {
869
965
  const canvas = canvasRef.current;
870
966
  if (!container || !canvas) return;
871
967
  const initialTool = options.initialTool ?? "pen";
968
+ const cameraOpts = cameraOptionsRef.current;
969
+ const touchOpts = touchOptionsRef.current;
970
+ const toolShortcutMap = resolveToolShortcuts(keyboardShortcutsRef.current);
872
971
  const editor = new Editor({
873
972
  toolDefinitions: options.toolDefinitions,
874
- initialToolId: initialTool
973
+ initialToolId: initialTool,
974
+ zoomRange: cameraOpts?.zoomRange
875
975
  });
876
976
  editor.renderer.setTheme(options.theme ?? "light");
877
977
  if (!editor.tools.hasTool(initialTool)) {
878
978
  editor.setCurrentTool("pen");
879
979
  }
980
+ if (options.snapshot) {
981
+ editor.loadPersistenceSnapshot(options.snapshot);
982
+ }
880
983
  let disposed = false;
881
984
  let ignorePersistenceChanges = false;
882
985
  let disposeMount;
@@ -1043,40 +1146,60 @@ function useTsdrawCanvasController(options = {}) {
1043
1146
  render();
1044
1147
  refreshSelectionBounds(editor);
1045
1148
  };
1149
+ const emitCameraChange = () => {
1150
+ onCameraChangeRef.current?.({ ...editor.viewport });
1151
+ };
1046
1152
  const touchInteractions = createTouchInteractionController(editor, canvas, {
1047
1153
  cancelActivePointerInteraction,
1048
1154
  refreshView: () => {
1049
1155
  render();
1050
1156
  refreshSelectionBounds(editor);
1157
+ emitCameraChange();
1051
1158
  },
1052
1159
  runUndo: () => applyDocumentChangeResult(editor.undo()),
1053
- runRedo: () => applyDocumentChangeResult(editor.redo())
1054
- });
1055
- const isDrawingTool = (tool) => tool !== "select" && tool !== "hand";
1160
+ runRedo: () => applyDocumentChangeResult(editor.redo()),
1161
+ isPenModeActive: () => penModeRef.current,
1162
+ getSlideOptions: () => ({
1163
+ enabled: cameraOptionsRef.current?.slideEnabled !== false,
1164
+ slideOptions: { friction: cameraOptionsRef.current?.slideFriction }
1165
+ })
1166
+ }, touchOpts);
1056
1167
  const hasRealPressure = (pressure) => pressure != null && pressure > 0 && pressure !== 0.5;
1168
+ const stopActiveSlide = () => {
1169
+ if (activeCameraSlideRef.current) {
1170
+ activeCameraSlideRef.current.stop();
1171
+ activeCameraSlideRef.current = null;
1172
+ }
1173
+ };
1057
1174
  const handlePointerDown = (e) => {
1058
1175
  if (!canvas.contains(e.target)) return;
1059
- if (!penDetectedRef.current && (e.pointerType === "pen" || hasRealPressure(e.pressure))) {
1176
+ if (cameraOptionsRef.current?.locked && e.pointerType !== "pen") return;
1177
+ stopActiveSlide();
1178
+ const penAutoDetect = penOptionsRef.current?.autoDetect !== false;
1179
+ if (penAutoDetect && !penDetectedRef.current && (e.pointerType === "pen" || hasRealPressure(e.pressure))) {
1060
1180
  penDetectedRef.current = true;
1061
1181
  penModeRef.current = true;
1062
1182
  }
1063
1183
  lastPointerDownWithRef.current = e.pointerType;
1064
1184
  activePointerIdsRef.current.add(e.pointerId);
1065
1185
  const startedCameraGesture = touchInteractions.handlePointerDown(e);
1066
- if (startedCameraGesture || touchInteractions.isCameraGestureActive()) {
1186
+ if (startedCameraGesture || touchInteractions.isCameraGestureActive() || touchInteractions.isFingerPanActive()) {
1067
1187
  e.preventDefault();
1068
1188
  if (!canvas.hasPointerCapture(e.pointerId)) {
1069
1189
  canvas.setPointerCapture(e.pointerId);
1070
1190
  }
1071
1191
  return;
1072
1192
  }
1073
- const allowPointerDown = !penModeRef.current || e.pointerType !== "touch" || !isDrawingTool(currentToolRef.current);
1074
- if (!allowPointerDown) {
1193
+ const isTouchBlockedByPenMode = penModeRef.current && e.pointerType === "touch";
1194
+ if (isTouchBlockedByPenMode) {
1075
1195
  return;
1076
1196
  }
1077
1197
  if (activePointerIdsRef.current.size > 1) {
1078
1198
  return;
1079
1199
  }
1200
+ if (readOnlyRef.current && !VIEW_ONLY_TOOLS.has(currentToolRef.current)) {
1201
+ return;
1202
+ }
1080
1203
  isPointerActiveRef.current = true;
1081
1204
  editor.beginHistoryEntry();
1082
1205
  canvas.setPointerCapture(e.pointerId);
@@ -1084,7 +1207,8 @@ function useTsdrawCanvasController(options = {}) {
1084
1207
  updatePointerPreview(e.clientX, e.clientY);
1085
1208
  const first = sampleEvents(e)[0];
1086
1209
  const { x, y } = getPagePoint(first);
1087
- const pressure = first.pressure ?? 0.5;
1210
+ const pressureSensitivity = penOptionsRef.current?.pressureSensitivity ?? 1;
1211
+ const pressure = (first.pressure ?? 0.5) * pressureSensitivity;
1088
1212
  const isPen = first.pointerType === "pen" || hasRealPressure(first.pressure);
1089
1213
  if (currentToolRef.current === "select") {
1090
1214
  const hit = getTopShapeAtPoint(editor, { x, y });
@@ -1126,12 +1250,13 @@ function useTsdrawCanvasController(options = {}) {
1126
1250
  }
1127
1251
  editor.input.pointerDown(x, y, pressure, isPen);
1128
1252
  editor.input.setModifiers(first.shiftKey, first.ctrlKey, first.metaKey);
1129
- editor.tools.pointerDown({ point: { x, y, z: pressure } });
1253
+ editor.tools.pointerDown({ point: { x, y, z: pressure }, screenX: e.clientX, screenY: e.clientY });
1130
1254
  render();
1131
1255
  refreshSelectionBounds(editor);
1132
1256
  };
1133
1257
  const handlePointerMove = (e) => {
1134
- if (!penDetectedRef.current && (e.pointerType === "pen" || hasRealPressure(e.pressure))) {
1258
+ const penAutoDetectOnMove = penOptionsRef.current?.autoDetect !== false;
1259
+ if (penAutoDetectOnMove && !penDetectedRef.current && (e.pointerType === "pen" || hasRealPressure(e.pressure))) {
1135
1260
  penDetectedRef.current = true;
1136
1261
  penModeRef.current = true;
1137
1262
  }
@@ -1139,16 +1264,17 @@ function useTsdrawCanvasController(options = {}) {
1139
1264
  e.preventDefault();
1140
1265
  return;
1141
1266
  }
1142
- if (penModeRef.current && e.pointerType === "touch" && isDrawingTool(currentToolRef.current) && !isPointerActiveRef.current) return;
1267
+ if (penModeRef.current && e.pointerType === "touch" && !isPointerActiveRef.current) return;
1143
1268
  if (activePointerIdsRef.current.size > 1) return;
1144
1269
  updatePointerPreview(e.clientX, e.clientY);
1145
1270
  const prevClient = lastPointerClientRef.current;
1146
1271
  const dx = prevClient ? e.clientX - prevClient.x : 0;
1147
1272
  const dy = prevClient ? e.clientY - prevClient.y : 0;
1148
1273
  lastPointerClientRef.current = { x: e.clientX, y: e.clientY };
1274
+ const movePressureSensitivity = penOptionsRef.current?.pressureSensitivity ?? 1;
1149
1275
  for (const sample of sampleEvents(e)) {
1150
1276
  const { x, y } = getPagePoint(sample);
1151
- const pressure = sample.pressure ?? 0.5;
1277
+ const pressure = (sample.pressure ?? 0.5) * movePressureSensitivity;
1152
1278
  const isPen = sample.pointerType === "pen" || hasRealPressure(sample.pressure);
1153
1279
  editor.input.pointerMove(x, y, pressure, isPen);
1154
1280
  }
@@ -1193,7 +1319,7 @@ function useTsdrawCanvasController(options = {}) {
1193
1319
  }
1194
1320
  }
1195
1321
  editor.input.setModifiers(e.shiftKey, e.ctrlKey, e.metaKey);
1196
- editor.tools.pointerMove({ screenDeltaX: dx, screenDeltaY: dy });
1322
+ editor.tools.pointerMove({ screenDeltaX: dx, screenDeltaY: dy, screenX: e.clientX, screenY: e.clientY });
1197
1323
  render();
1198
1324
  refreshSelectionBounds(editor);
1199
1325
  };
@@ -1272,9 +1398,30 @@ function useTsdrawCanvasController(options = {}) {
1272
1398
  return;
1273
1399
  }
1274
1400
  }
1401
+ let handPanSession = null;
1402
+ if (currentToolRef.current === "hand") {
1403
+ const currentState = editor.tools.getCurrentState();
1404
+ if (currentState instanceof HandDraggingState) {
1405
+ handPanSession = currentState.getPanSession();
1406
+ }
1407
+ }
1275
1408
  editor.tools.pointerUp();
1276
1409
  render();
1277
1410
  refreshSelectionBounds(editor);
1411
+ if (handPanSession && cameraOptionsRef.current?.slideEnabled !== false) {
1412
+ activeCameraSlideRef.current = startCameraSlide(
1413
+ handPanSession,
1414
+ (slideDx, slideDy) => {
1415
+ editor.panBy(slideDx, slideDy);
1416
+ emitCameraChange();
1417
+ },
1418
+ () => {
1419
+ render();
1420
+ refreshSelectionBounds(editor);
1421
+ },
1422
+ { friction: cameraOptionsRef.current?.slideFriction }
1423
+ );
1424
+ }
1278
1425
  if (pendingRemoteDocumentRef.current) {
1279
1426
  const pendingRemoteDocument = pendingRemoteDocumentRef.current;
1280
1427
  pendingRemoteDocumentRef.current = null;
@@ -1317,41 +1464,59 @@ function useTsdrawCanvasController(options = {}) {
1317
1464
  const handleWheel = (e) => {
1318
1465
  if (!container.contains(e.target)) return;
1319
1466
  e.preventDefault();
1467
+ const camOpts = cameraOptionsRef.current;
1468
+ if (camOpts?.locked) return;
1469
+ if (camOpts?.wheelBehavior === "none") return;
1320
1470
  if (touchInteractions.isTrackpadZoomActive()) return;
1321
1471
  const delta = normalizeWheelDelta(e);
1472
+ const panMultiplier = camOpts?.panSpeed ?? 1;
1473
+ const zoomMultiplier = camOpts?.zoomSpeed ?? 1;
1322
1474
  if (delta.z !== 0) {
1323
1475
  const rect = canvas.getBoundingClientRect();
1324
1476
  const pointX = e.clientX - rect.left;
1325
1477
  const pointY = e.clientY - rect.top;
1326
- editor.zoomAt(Math.exp(delta.z), pointX, pointY);
1478
+ editor.zoomAt(Math.exp(delta.z * zoomMultiplier), pointX, pointY);
1327
1479
  } else {
1328
- editor.panBy(delta.x, delta.y);
1480
+ editor.panBy(delta.x * panMultiplier, delta.y * panMultiplier);
1329
1481
  }
1330
1482
  render();
1331
1483
  refreshSelectionBounds(editor);
1484
+ emitCameraChange();
1332
1485
  };
1333
1486
  const handleGestureEvent = (e) => {
1334
1487
  touchInteractions.handleGestureEvent(e, container);
1335
1488
  };
1336
1489
  const handleKeyDown = (e) => {
1490
+ if (keyboardShortcutsRef.current?.enabled === false) return;
1491
+ const isReadOnly = readOnlyRef.current;
1337
1492
  handleKeyboardShortcutKeyDown(e, {
1338
- isToolAvailable: (tool) => editor.tools.hasTool(tool),
1493
+ isToolAvailable: (tool) => {
1494
+ if (isReadOnly && !VIEW_ONLY_TOOLS.has(tool)) return false;
1495
+ return editor.tools.hasTool(tool);
1496
+ },
1339
1497
  setToolFromShortcut: (tool) => {
1340
1498
  editor.setCurrentTool(tool);
1341
1499
  setCurrentToolState(tool);
1342
1500
  currentToolRef.current = tool;
1343
1501
  if (tool !== "select") resetSelectUi();
1344
1502
  render();
1503
+ onToolChangeRef.current?.(tool);
1504
+ },
1505
+ runHistoryShortcut: (shouldRedo) => {
1506
+ if (isReadOnly) return false;
1507
+ return applyDocumentChangeResult(shouldRedo ? editor.redo() : editor.undo());
1508
+ },
1509
+ deleteSelection: () => {
1510
+ if (isReadOnly) return false;
1511
+ return currentToolRef.current === "select" ? deleteCurrentSelection() : false;
1345
1512
  },
1346
- runHistoryShortcut: (shouldRedo) => applyDocumentChangeResult(shouldRedo ? editor.redo() : editor.undo()),
1347
- deleteSelection: () => currentToolRef.current === "select" ? deleteCurrentSelection() : false,
1348
1513
  dispatchKeyDown: (event) => {
1349
1514
  editor.input.setModifiers(event.shiftKey, event.ctrlKey, event.metaKey);
1350
1515
  editor.tools.keyDown({ key: event.key });
1351
1516
  render();
1352
1517
  },
1353
1518
  dispatchKeyUp: () => void 0
1354
- });
1519
+ }, toolShortcutMap);
1355
1520
  };
1356
1521
  const handleKeyUp = (e) => {
1357
1522
  handleKeyboardShortcutKeyUp(e, {
@@ -1432,6 +1597,7 @@ function useTsdrawCanvasController(options = {}) {
1432
1597
  const cleanupEditorListener = editor.listen(() => {
1433
1598
  if (ignorePersistenceChanges) return;
1434
1599
  schedulePersist();
1600
+ onChangeRef.current?.(editor.getDocumentSnapshot());
1435
1601
  });
1436
1602
  const cleanupHistoryListener = editor.listenHistory(() => {
1437
1603
  syncHistoryState();
@@ -1498,6 +1664,9 @@ function useTsdrawCanvasController(options = {}) {
1498
1664
  render();
1499
1665
  }
1500
1666
  });
1667
+ if (options.autoFocus !== false) {
1668
+ container.focus({ preventScroll: true });
1669
+ }
1501
1670
  return () => {
1502
1671
  disposed = true;
1503
1672
  schedulePersistRef.current = null;
@@ -1519,6 +1688,7 @@ function useTsdrawCanvasController(options = {}) {
1519
1688
  isPointerActiveRef.current = false;
1520
1689
  activePointerIdsRef.current.clear();
1521
1690
  pendingRemoteDocumentRef.current = null;
1691
+ stopActiveSlide();
1522
1692
  touchInteractions.reset();
1523
1693
  persistenceChannel?.close();
1524
1694
  void persistenceDb?.close();
@@ -1545,10 +1715,12 @@ function useTsdrawCanvasController(options = {}) {
1545
1715
  const editor = editorRef.current;
1546
1716
  if (!editor) return;
1547
1717
  if (!editor.tools.hasTool(tool)) return;
1718
+ if (readOnlyRef.current && !VIEW_ONLY_TOOLS.has(tool)) return;
1548
1719
  editor.setCurrentTool(tool);
1549
1720
  setCurrentToolState(tool);
1550
1721
  currentToolRef.current = tool;
1551
1722
  if (tool !== "select") resetSelectUi();
1723
+ onToolChangeRef.current?.(tool);
1552
1724
  },
1553
1725
  [resetSelectUi]
1554
1726
  );
@@ -1784,12 +1956,22 @@ function Tsdraw(props) {
1784
1956
  initialTool,
1785
1957
  theme: resolvedTheme,
1786
1958
  persistenceKey: props.persistenceKey,
1787
- onMount: props.onMount
1959
+ onMount: props.onMount,
1960
+ cameraOptions: props.cameraOptions,
1961
+ touchOptions: props.touchOptions,
1962
+ keyboardShortcuts: props.keyboardShortcuts,
1963
+ penOptions: props.penOptions,
1964
+ readOnly: props.readOnly,
1965
+ autoFocus: props.autoFocus,
1966
+ snapshot: props.snapshot,
1967
+ onChange: props.onChange,
1968
+ onCameraChange: props.onCameraChange,
1969
+ onToolChange: props.onToolChange
1788
1970
  });
1789
1971
  const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 14);
1790
1972
  const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8, 8);
1791
1973
  const isToolbarHidden = props.uiOptions?.toolbar?.hide === true;
1792
- const isStylePanelHidden = props.uiOptions?.stylePanel?.hide === true;
1974
+ const isStylePanelHidden = props.uiOptions?.stylePanel?.hide === true || props.readOnly === true;
1793
1975
  const canvasCursor = props.uiOptions?.cursor?.getCursor?.(cursorContext) ?? defaultCanvasCursor;
1794
1976
  const defaultToolOverlay = /* @__PURE__ */ jsx(
1795
1977
  ToolOverlay,
@@ -1878,12 +2060,14 @@ function Tsdraw(props) {
1878
2060
  "div",
1879
2061
  {
1880
2062
  ref: containerRef,
2063
+ tabIndex: 0,
1881
2064
  className: `tsdraw tsdraw-${resolvedTheme}mode ${props.className ?? ""}`,
1882
2065
  style: {
1883
2066
  width: props.width ?? "100%",
1884
2067
  height: props.height ?? "100%",
1885
2068
  position: "relative",
1886
2069
  overflow: "hidden",
2070
+ outline: "none",
1887
2071
  ...props.style
1888
2072
  },
1889
2073
  children: [