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