@tsdraw/react 0.8.4 → 0.9.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.d.cts CHANGED
@@ -1,6 +1,32 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode, CSSProperties } from 'react';
3
- import { ColorStyle, DashStyle, FillStyle, SizeStyle, ToolId, Editor, ToolDefinition } from '@tsdraw/core';
3
+ import { ZoomRange, ToolId, ColorStyle, DashStyle, FillStyle, SizeStyle, Editor, ToolDefinition, TsdrawBackgroundOptions, TsdrawEditorSnapshot, TsdrawDocumentSnapshot, Viewport } from '@tsdraw/core';
4
+ export { TsdrawBackgroundCustom, TsdrawBackgroundOptions, TsdrawBackgroundPreset, TsdrawBackgroundType } from '@tsdraw/core';
5
+
6
+ interface TsdrawCameraOptions {
7
+ panSpeed?: number;
8
+ zoomSpeed?: number;
9
+ zoomRange?: ZoomRange;
10
+ wheelBehavior?: 'pan' | 'zoom' | 'none';
11
+ slideEnabled?: boolean;
12
+ slideFriction?: number;
13
+ locked?: boolean;
14
+ }
15
+ interface TsdrawTouchOptions {
16
+ pinchToZoom?: boolean;
17
+ fingerPanInPenMode?: boolean;
18
+ tapUndoRedo?: boolean;
19
+ trackpadGestures?: boolean;
20
+ }
21
+ interface TsdrawKeyboardShortcutOptions {
22
+ enabled?: boolean;
23
+ toolShortcuts?: Record<string, ToolId>;
24
+ overrideDefaults?: boolean;
25
+ }
26
+ interface TsdrawPenOptions {
27
+ pressureSensitivity?: number;
28
+ autoDetect?: boolean;
29
+ }
4
30
 
5
31
  type TsdrawStylePanelPartItem = 'colors' | 'dashes' | 'fills' | 'sizes' | (string & {});
6
32
  interface TsdrawStylePanelRenderContext {
@@ -123,6 +149,17 @@ interface TsdrawProps {
123
149
  initialToolId?: ToolId;
124
150
  uiOptions?: TsdrawUiOptions;
125
151
  onMount?: (api: TsdrawMountApi) => void | (() => void);
152
+ cameraOptions?: TsdrawCameraOptions;
153
+ touchOptions?: TsdrawTouchOptions;
154
+ keyboardShortcuts?: TsdrawKeyboardShortcutOptions;
155
+ penOptions?: TsdrawPenOptions;
156
+ background?: TsdrawBackgroundOptions;
157
+ readOnly?: boolean;
158
+ autoFocus?: boolean;
159
+ snapshot?: TsdrawEditorSnapshot;
160
+ onChange?: (snapshot: TsdrawDocumentSnapshot) => void;
161
+ onCameraChange?: (viewport: Viewport) => void;
162
+ onToolChange?: (toolId: ToolId) => void;
126
163
  }
127
164
  type TsdrawCanvasProps = TsdrawProps;
128
165
  declare function Tsdraw(props: TsdrawProps): react_jsx_runtime.JSX.Element;
@@ -148,4 +185,4 @@ interface ToolbarPart {
148
185
  }
149
186
  declare function getDefaultToolbarIcon(toolId: ToolId, isActive: boolean): ReactNode;
150
187
 
151
- export { type ToolbarActionItem, type ToolbarPart, type ToolbarPartItem, type ToolbarRenderItem, type ToolbarToolItem, Tsdraw, TsdrawCanvas, type TsdrawCanvasProps, type TsdrawCursorContext, type TsdrawCustomElement, type TsdrawCustomElementRenderArgs, type TsdrawCustomTool, type TsdrawMountApi, type TsdrawProps, type TsdrawStylePanelCustomPart, type TsdrawStylePanelPartItem, type TsdrawStylePanelRenderContext, type TsdrawToolOverlayState, type TsdrawToolbarBuiltInAction, type TsdrawUiOptions, type TsdrawUiPlacement, type UiAnchor, getDefaultToolbarIcon };
188
+ export { type ToolbarActionItem, type ToolbarPart, type ToolbarPartItem, type ToolbarRenderItem, type ToolbarToolItem, Tsdraw, type TsdrawCameraOptions, TsdrawCanvas, type TsdrawCanvasProps, type TsdrawCursorContext, type TsdrawCustomElement, type TsdrawCustomElementRenderArgs, type TsdrawCustomTool, type TsdrawKeyboardShortcutOptions, type TsdrawMountApi, type TsdrawPenOptions, type TsdrawProps, type TsdrawStylePanelCustomPart, type TsdrawStylePanelPartItem, type TsdrawStylePanelRenderContext, type TsdrawToolOverlayState, type TsdrawToolbarBuiltInAction, type TsdrawTouchOptions, type TsdrawUiOptions, type TsdrawUiPlacement, type UiAnchor, getDefaultToolbarIcon };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,32 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode, CSSProperties } from 'react';
3
- import { ColorStyle, DashStyle, FillStyle, SizeStyle, ToolId, Editor, ToolDefinition } from '@tsdraw/core';
3
+ import { ZoomRange, ToolId, ColorStyle, DashStyle, FillStyle, SizeStyle, Editor, ToolDefinition, TsdrawBackgroundOptions, TsdrawEditorSnapshot, TsdrawDocumentSnapshot, Viewport } from '@tsdraw/core';
4
+ export { TsdrawBackgroundCustom, TsdrawBackgroundOptions, TsdrawBackgroundPreset, TsdrawBackgroundType } from '@tsdraw/core';
5
+
6
+ interface TsdrawCameraOptions {
7
+ panSpeed?: number;
8
+ zoomSpeed?: number;
9
+ zoomRange?: ZoomRange;
10
+ wheelBehavior?: 'pan' | 'zoom' | 'none';
11
+ slideEnabled?: boolean;
12
+ slideFriction?: number;
13
+ locked?: boolean;
14
+ }
15
+ interface TsdrawTouchOptions {
16
+ pinchToZoom?: boolean;
17
+ fingerPanInPenMode?: boolean;
18
+ tapUndoRedo?: boolean;
19
+ trackpadGestures?: boolean;
20
+ }
21
+ interface TsdrawKeyboardShortcutOptions {
22
+ enabled?: boolean;
23
+ toolShortcuts?: Record<string, ToolId>;
24
+ overrideDefaults?: boolean;
25
+ }
26
+ interface TsdrawPenOptions {
27
+ pressureSensitivity?: number;
28
+ autoDetect?: boolean;
29
+ }
4
30
 
5
31
  type TsdrawStylePanelPartItem = 'colors' | 'dashes' | 'fills' | 'sizes' | (string & {});
6
32
  interface TsdrawStylePanelRenderContext {
@@ -123,6 +149,17 @@ interface TsdrawProps {
123
149
  initialToolId?: ToolId;
124
150
  uiOptions?: TsdrawUiOptions;
125
151
  onMount?: (api: TsdrawMountApi) => void | (() => void);
152
+ cameraOptions?: TsdrawCameraOptions;
153
+ touchOptions?: TsdrawTouchOptions;
154
+ keyboardShortcuts?: TsdrawKeyboardShortcutOptions;
155
+ penOptions?: TsdrawPenOptions;
156
+ background?: TsdrawBackgroundOptions;
157
+ readOnly?: boolean;
158
+ autoFocus?: boolean;
159
+ snapshot?: TsdrawEditorSnapshot;
160
+ onChange?: (snapshot: TsdrawDocumentSnapshot) => void;
161
+ onCameraChange?: (viewport: Viewport) => void;
162
+ onToolChange?: (toolId: ToolId) => void;
126
163
  }
127
164
  type TsdrawCanvasProps = TsdrawProps;
128
165
  declare function Tsdraw(props: TsdrawProps): react_jsx_runtime.JSX.Element;
@@ -148,4 +185,4 @@ interface ToolbarPart {
148
185
  }
149
186
  declare function getDefaultToolbarIcon(toolId: ToolId, isActive: boolean): ReactNode;
150
187
 
151
- export { type ToolbarActionItem, type ToolbarPart, type ToolbarPartItem, type ToolbarRenderItem, type ToolbarToolItem, Tsdraw, TsdrawCanvas, type TsdrawCanvasProps, type TsdrawCursorContext, type TsdrawCustomElement, type TsdrawCustomElementRenderArgs, type TsdrawCustomTool, type TsdrawMountApi, type TsdrawProps, type TsdrawStylePanelCustomPart, type TsdrawStylePanelPartItem, type TsdrawStylePanelRenderContext, type TsdrawToolOverlayState, type TsdrawToolbarBuiltInAction, type TsdrawUiOptions, type TsdrawUiPlacement, type UiAnchor, getDefaultToolbarIcon };
188
+ export { type ToolbarActionItem, type ToolbarPart, type ToolbarPartItem, type ToolbarRenderItem, type ToolbarToolItem, Tsdraw, type TsdrawCameraOptions, TsdrawCanvas, type TsdrawCanvasProps, type TsdrawCursorContext, type TsdrawCustomElement, type TsdrawCustomElementRenderArgs, type TsdrawCustomTool, type TsdrawKeyboardShortcutOptions, type TsdrawMountApi, type TsdrawPenOptions, type TsdrawProps, type TsdrawStylePanelCustomPart, type TsdrawStylePanelPartItem, type TsdrawStylePanelRenderContext, type TsdrawToolOverlayState, type TsdrawToolbarBuiltInAction, type TsdrawTouchOptions, type TsdrawUiOptions, type TsdrawUiPlacement, type UiAnchor, getDefaultToolbarIcon };
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, HandDraggingState, startCameraSlide, isSelectTool, beginCameraPan, moveCameraPan } from '@tsdraw/core';
3
+ import { DEFAULT_COLORS, renderCanvasBackground, 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
@@ -301,7 +301,11 @@ var TAP_MOVE_TOLERANCE = 14;
301
301
  var PINCH_MODE_ZOOM_DISTANCE = 24;
302
302
  var PINCH_MODE_PAN_DISTANCE = 16;
303
303
  var PINCH_MODE_SWITCH_TO_ZOOM_DISTANCE = 64;
304
- 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;
305
309
  const activeTouchPoints = /* @__PURE__ */ new Map();
306
310
  const touchTapState = {
307
311
  active: false,
@@ -343,7 +347,7 @@ function createTouchInteractionController(editor, canvas, handlers) {
343
347
  if (activeTouchPoints.size > 0) return;
344
348
  if (!touchTapState.active) return;
345
349
  const elapsed = performance.now() - touchTapState.startTime;
346
- 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)) {
347
351
  const fingerCount = touchTapState.maxTouchCount;
348
352
  const now = performance.now();
349
353
  const previousTapTime = touchTapState.lastTapAtByCount[fingerCount] ?? 0;
@@ -391,9 +395,9 @@ function createTouchInteractionController(editor, canvas, handlers) {
391
395
  const touchDistance = Math.abs(distance - touchCameraState.initialDistance);
392
396
  const originDistance = Math.hypot(center.x - touchCameraState.initialCenter.x, center.y - touchCameraState.initialCenter.y);
393
397
  if (touchCameraState.mode === "not-sure") {
394
- if (touchDistance > PINCH_MODE_ZOOM_DISTANCE) touchCameraState.mode = "zooming";
398
+ if (allowPinchZoom && touchDistance > PINCH_MODE_ZOOM_DISTANCE) touchCameraState.mode = "zooming";
395
399
  else if (originDistance > PINCH_MODE_PAN_DISTANCE) touchCameraState.mode = "panning";
396
- } 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";
397
401
  const canvasRect = canvas.getBoundingClientRect();
398
402
  const centerOnCanvasX = center.x - canvasRect.left;
399
403
  const centerOnCanvasY = center.y - canvasRect.top;
@@ -434,7 +438,7 @@ function createTouchInteractionController(editor, canvas, handlers) {
434
438
  beginTouchCameraGesture();
435
439
  return true;
436
440
  }
437
- if (handlers.isPenModeActive() && activeTouchPoints.size === 1) {
441
+ if (allowFingerPan && handlers.isPenModeActive() && activeTouchPoints.size === 1) {
438
442
  handlers.cancelActivePointerInteraction();
439
443
  fingerPanPointerId = event.pointerId;
440
444
  fingerPanSession = beginCameraPan(editor.viewport, event.clientX, event.clientY);
@@ -469,11 +473,15 @@ function createTouchInteractionController(editor, canvas, handlers) {
469
473
  if (wasFingerPan) {
470
474
  endFingerPan();
471
475
  if (releasedPanSession) {
472
- fingerPanSlide = startCameraSlide(
473
- releasedPanSession,
474
- (dx, dy) => editor.panBy(dx, dy),
475
- () => handlers.refreshView()
476
- );
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
+ }
477
485
  }
478
486
  }
479
487
  maybeHandleTouchTapGesture();
@@ -484,6 +492,7 @@ function createTouchInteractionController(editor, canvas, handlers) {
484
492
  const handleGestureEvent = (event, container) => {
485
493
  if (!container.contains(event.target)) return;
486
494
  event.preventDefault();
495
+ if (!allowTrackpadGestures) return;
487
496
  const gestureEvent = event;
488
497
  if (gestureEvent.scale == null) return;
489
498
  if (event.type === "gesturestart") {
@@ -527,7 +536,7 @@ function createTouchInteractionController(editor, canvas, handlers) {
527
536
  }
528
537
 
529
538
  // src/canvas/keyboardShortcuts.ts
530
- var TOOL_SHORTCUTS = {
539
+ var DEFAULT_TOOL_SHORTCUTS = {
531
540
  v: "select",
532
541
  h: "hand",
533
542
  e: "eraser",
@@ -539,6 +548,11 @@ var TOOL_SHORTCUTS = {
539
548
  o: "circle",
540
549
  c: "circle"
541
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
+ }
542
556
  function isEditableTarget(eventTarget) {
543
557
  const element = eventTarget;
544
558
  if (!element) return false;
@@ -546,7 +560,7 @@ function isEditableTarget(eventTarget) {
546
560
  const tagName = element.tagName;
547
561
  return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
548
562
  }
549
- function handleKeyboardShortcutKeyDown(event, handlers) {
563
+ function handleKeyboardShortcutKeyDown(event, handlers, toolShortcutMap = DEFAULT_TOOL_SHORTCUTS) {
550
564
  if (isEditableTarget(event.target)) return;
551
565
  const loweredKey = event.key.toLowerCase();
552
566
  const isMetaPressed = event.metaKey || event.ctrlKey;
@@ -559,7 +573,7 @@ function handleKeyboardShortcutKeyDown(event, handlers) {
559
573
  }
560
574
  }
561
575
  if (!isMetaPressed && !event.altKey) {
562
- const nextToolId = TOOL_SHORTCUTS[loweredKey];
576
+ const nextToolId = toolShortcutMap[loweredKey];
563
577
  if (nextToolId && handlers.isToolAvailable(nextToolId)) {
564
578
  handlers.setToolFromShortcut(nextToolId);
565
579
  event.preventDefault();
@@ -734,8 +748,18 @@ function getHandlePagePoint(bounds, handle) {
734
748
  }
735
749
  }
736
750
  var ZOOM_WHEEL_CAP = 10;
751
+ var VIEW_ONLY_TOOLS = /* @__PURE__ */ new Set(["select", "hand"]);
737
752
  function useTsdrawCanvasController(options = {}) {
738
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 backgroundRef = useRef(options.background);
762
+ const readOnlyRef = useRef(options.readOnly ?? false);
739
763
  const containerRef = useRef(null);
740
764
  const canvasRef = useRef(null);
741
765
  const editorRef = useRef(null);
@@ -795,6 +819,33 @@ function useTsdrawCanvasController(options = {}) {
795
819
  useEffect(() => {
796
820
  onMountRef.current = options.onMount;
797
821
  }, [options.onMount]);
822
+ useEffect(() => {
823
+ onChangeRef.current = options.onChange;
824
+ }, [options.onChange]);
825
+ useEffect(() => {
826
+ onCameraChangeRef.current = options.onCameraChange;
827
+ }, [options.onCameraChange]);
828
+ useEffect(() => {
829
+ onToolChangeRef.current = options.onToolChange;
830
+ }, [options.onToolChange]);
831
+ useEffect(() => {
832
+ cameraOptionsRef.current = options.cameraOptions;
833
+ }, [options.cameraOptions]);
834
+ useEffect(() => {
835
+ touchOptionsRef.current = options.touchOptions;
836
+ }, [options.touchOptions]);
837
+ useEffect(() => {
838
+ keyboardShortcutsRef.current = options.keyboardShortcuts;
839
+ }, [options.keyboardShortcuts]);
840
+ useEffect(() => {
841
+ penOptionsRef.current = options.penOptions;
842
+ }, [options.penOptions]);
843
+ useEffect(() => {
844
+ backgroundRef.current = options.background;
845
+ }, [options.background]);
846
+ useEffect(() => {
847
+ readOnlyRef.current = options.readOnly ?? false;
848
+ }, [options.readOnly]);
798
849
  useEffect(() => {
799
850
  selectedShapeIdsRef.current = selectedShapeIds;
800
851
  }, [selectedShapeIds]);
@@ -811,8 +862,11 @@ function useTsdrawCanvasController(options = {}) {
811
862
  const ctx = canvas.getContext("2d");
812
863
  if (!ctx) return;
813
864
  const dpr = dprRef.current || 1;
865
+ const logicalWidth = canvas.width / dpr;
866
+ const logicalHeight = canvas.height / dpr;
814
867
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
815
- ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
868
+ ctx.clearRect(0, 0, logicalWidth, logicalHeight);
869
+ renderCanvasBackground(ctx, editor.viewport, logicalWidth, logicalHeight, backgroundRef.current, editor.renderer.theme);
816
870
  editor.render(ctx);
817
871
  }, []);
818
872
  const refreshSelectionBounds = useCallback((editor, ids = selectedShapeIdsRef.current) => {
@@ -918,14 +972,21 @@ function useTsdrawCanvasController(options = {}) {
918
972
  const canvas = canvasRef.current;
919
973
  if (!container || !canvas) return;
920
974
  const initialTool = options.initialTool ?? "pen";
975
+ const cameraOpts = cameraOptionsRef.current;
976
+ const touchOpts = touchOptionsRef.current;
977
+ const toolShortcutMap = resolveToolShortcuts(keyboardShortcutsRef.current);
921
978
  const editor = new Editor({
922
979
  toolDefinitions: options.toolDefinitions,
923
- initialToolId: initialTool
980
+ initialToolId: initialTool,
981
+ zoomRange: cameraOpts?.zoomRange
924
982
  });
925
983
  editor.renderer.setTheme(options.theme ?? "light");
926
984
  if (!editor.tools.hasTool(initialTool)) {
927
985
  editor.setCurrentTool("pen");
928
986
  }
987
+ if (options.snapshot) {
988
+ editor.loadPersistenceSnapshot(options.snapshot);
989
+ }
929
990
  let disposed = false;
930
991
  let ignorePersistenceChanges = false;
931
992
  let disposeMount;
@@ -1092,16 +1153,24 @@ function useTsdrawCanvasController(options = {}) {
1092
1153
  render();
1093
1154
  refreshSelectionBounds(editor);
1094
1155
  };
1156
+ const emitCameraChange = () => {
1157
+ onCameraChangeRef.current?.({ ...editor.viewport });
1158
+ };
1095
1159
  const touchInteractions = createTouchInteractionController(editor, canvas, {
1096
1160
  cancelActivePointerInteraction,
1097
1161
  refreshView: () => {
1098
1162
  render();
1099
1163
  refreshSelectionBounds(editor);
1164
+ emitCameraChange();
1100
1165
  },
1101
1166
  runUndo: () => applyDocumentChangeResult(editor.undo()),
1102
1167
  runRedo: () => applyDocumentChangeResult(editor.redo()),
1103
- isPenModeActive: () => penModeRef.current
1104
- });
1168
+ isPenModeActive: () => penModeRef.current,
1169
+ getSlideOptions: () => ({
1170
+ enabled: cameraOptionsRef.current?.slideEnabled !== false,
1171
+ slideOptions: { friction: cameraOptionsRef.current?.slideFriction }
1172
+ })
1173
+ }, touchOpts);
1105
1174
  const hasRealPressure = (pressure) => pressure != null && pressure > 0 && pressure !== 0.5;
1106
1175
  const stopActiveSlide = () => {
1107
1176
  if (activeCameraSlideRef.current) {
@@ -1111,8 +1180,10 @@ function useTsdrawCanvasController(options = {}) {
1111
1180
  };
1112
1181
  const handlePointerDown = (e) => {
1113
1182
  if (!canvas.contains(e.target)) return;
1183
+ if (cameraOptionsRef.current?.locked && e.pointerType !== "pen") return;
1114
1184
  stopActiveSlide();
1115
- if (!penDetectedRef.current && (e.pointerType === "pen" || hasRealPressure(e.pressure))) {
1185
+ const penAutoDetect = penOptionsRef.current?.autoDetect !== false;
1186
+ if (penAutoDetect && !penDetectedRef.current && (e.pointerType === "pen" || hasRealPressure(e.pressure))) {
1116
1187
  penDetectedRef.current = true;
1117
1188
  penModeRef.current = true;
1118
1189
  }
@@ -1133,6 +1204,9 @@ function useTsdrawCanvasController(options = {}) {
1133
1204
  if (activePointerIdsRef.current.size > 1) {
1134
1205
  return;
1135
1206
  }
1207
+ if (readOnlyRef.current && !VIEW_ONLY_TOOLS.has(currentToolRef.current)) {
1208
+ return;
1209
+ }
1136
1210
  isPointerActiveRef.current = true;
1137
1211
  editor.beginHistoryEntry();
1138
1212
  canvas.setPointerCapture(e.pointerId);
@@ -1140,7 +1214,8 @@ function useTsdrawCanvasController(options = {}) {
1140
1214
  updatePointerPreview(e.clientX, e.clientY);
1141
1215
  const first = sampleEvents(e)[0];
1142
1216
  const { x, y } = getPagePoint(first);
1143
- const pressure = first.pressure ?? 0.5;
1217
+ const pressureSensitivity = penOptionsRef.current?.pressureSensitivity ?? 1;
1218
+ const pressure = (first.pressure ?? 0.5) * pressureSensitivity;
1144
1219
  const isPen = first.pointerType === "pen" || hasRealPressure(first.pressure);
1145
1220
  if (currentToolRef.current === "select") {
1146
1221
  const hit = getTopShapeAtPoint(editor, { x, y });
@@ -1187,7 +1262,8 @@ function useTsdrawCanvasController(options = {}) {
1187
1262
  refreshSelectionBounds(editor);
1188
1263
  };
1189
1264
  const handlePointerMove = (e) => {
1190
- if (!penDetectedRef.current && (e.pointerType === "pen" || hasRealPressure(e.pressure))) {
1265
+ const penAutoDetectOnMove = penOptionsRef.current?.autoDetect !== false;
1266
+ if (penAutoDetectOnMove && !penDetectedRef.current && (e.pointerType === "pen" || hasRealPressure(e.pressure))) {
1191
1267
  penDetectedRef.current = true;
1192
1268
  penModeRef.current = true;
1193
1269
  }
@@ -1202,9 +1278,10 @@ function useTsdrawCanvasController(options = {}) {
1202
1278
  const dx = prevClient ? e.clientX - prevClient.x : 0;
1203
1279
  const dy = prevClient ? e.clientY - prevClient.y : 0;
1204
1280
  lastPointerClientRef.current = { x: e.clientX, y: e.clientY };
1281
+ const movePressureSensitivity = penOptionsRef.current?.pressureSensitivity ?? 1;
1205
1282
  for (const sample of sampleEvents(e)) {
1206
1283
  const { x, y } = getPagePoint(sample);
1207
- const pressure = sample.pressure ?? 0.5;
1284
+ const pressure = (sample.pressure ?? 0.5) * movePressureSensitivity;
1208
1285
  const isPen = sample.pointerType === "pen" || hasRealPressure(sample.pressure);
1209
1286
  editor.input.pointerMove(x, y, pressure, isPen);
1210
1287
  }
@@ -1338,14 +1415,18 @@ function useTsdrawCanvasController(options = {}) {
1338
1415
  editor.tools.pointerUp();
1339
1416
  render();
1340
1417
  refreshSelectionBounds(editor);
1341
- if (handPanSession) {
1418
+ if (handPanSession && cameraOptionsRef.current?.slideEnabled !== false) {
1342
1419
  activeCameraSlideRef.current = startCameraSlide(
1343
1420
  handPanSession,
1344
- (slideDx, slideDy) => editor.panBy(slideDx, slideDy),
1421
+ (slideDx, slideDy) => {
1422
+ editor.panBy(slideDx, slideDy);
1423
+ emitCameraChange();
1424
+ },
1345
1425
  () => {
1346
1426
  render();
1347
1427
  refreshSelectionBounds(editor);
1348
- }
1428
+ },
1429
+ { friction: cameraOptionsRef.current?.slideFriction }
1349
1430
  );
1350
1431
  }
1351
1432
  if (pendingRemoteDocumentRef.current) {
@@ -1390,41 +1471,59 @@ function useTsdrawCanvasController(options = {}) {
1390
1471
  const handleWheel = (e) => {
1391
1472
  if (!container.contains(e.target)) return;
1392
1473
  e.preventDefault();
1474
+ const camOpts = cameraOptionsRef.current;
1475
+ if (camOpts?.locked) return;
1476
+ if (camOpts?.wheelBehavior === "none") return;
1393
1477
  if (touchInteractions.isTrackpadZoomActive()) return;
1394
1478
  const delta = normalizeWheelDelta(e);
1479
+ const panMultiplier = camOpts?.panSpeed ?? 1;
1480
+ const zoomMultiplier = camOpts?.zoomSpeed ?? 1;
1395
1481
  if (delta.z !== 0) {
1396
1482
  const rect = canvas.getBoundingClientRect();
1397
1483
  const pointX = e.clientX - rect.left;
1398
1484
  const pointY = e.clientY - rect.top;
1399
- editor.zoomAt(Math.exp(delta.z), pointX, pointY);
1485
+ editor.zoomAt(Math.exp(delta.z * zoomMultiplier), pointX, pointY);
1400
1486
  } else {
1401
- editor.panBy(delta.x, delta.y);
1487
+ editor.panBy(delta.x * panMultiplier, delta.y * panMultiplier);
1402
1488
  }
1403
1489
  render();
1404
1490
  refreshSelectionBounds(editor);
1491
+ emitCameraChange();
1405
1492
  };
1406
1493
  const handleGestureEvent = (e) => {
1407
1494
  touchInteractions.handleGestureEvent(e, container);
1408
1495
  };
1409
1496
  const handleKeyDown = (e) => {
1497
+ if (keyboardShortcutsRef.current?.enabled === false) return;
1498
+ const isReadOnly = readOnlyRef.current;
1410
1499
  handleKeyboardShortcutKeyDown(e, {
1411
- isToolAvailable: (tool) => editor.tools.hasTool(tool),
1500
+ isToolAvailable: (tool) => {
1501
+ if (isReadOnly && !VIEW_ONLY_TOOLS.has(tool)) return false;
1502
+ return editor.tools.hasTool(tool);
1503
+ },
1412
1504
  setToolFromShortcut: (tool) => {
1413
1505
  editor.setCurrentTool(tool);
1414
1506
  setCurrentToolState(tool);
1415
1507
  currentToolRef.current = tool;
1416
1508
  if (tool !== "select") resetSelectUi();
1417
1509
  render();
1510
+ onToolChangeRef.current?.(tool);
1511
+ },
1512
+ runHistoryShortcut: (shouldRedo) => {
1513
+ if (isReadOnly) return false;
1514
+ return applyDocumentChangeResult(shouldRedo ? editor.redo() : editor.undo());
1515
+ },
1516
+ deleteSelection: () => {
1517
+ if (isReadOnly) return false;
1518
+ return currentToolRef.current === "select" ? deleteCurrentSelection() : false;
1418
1519
  },
1419
- runHistoryShortcut: (shouldRedo) => applyDocumentChangeResult(shouldRedo ? editor.redo() : editor.undo()),
1420
- deleteSelection: () => currentToolRef.current === "select" ? deleteCurrentSelection() : false,
1421
1520
  dispatchKeyDown: (event) => {
1422
1521
  editor.input.setModifiers(event.shiftKey, event.ctrlKey, event.metaKey);
1423
1522
  editor.tools.keyDown({ key: event.key });
1424
1523
  render();
1425
1524
  },
1426
1525
  dispatchKeyUp: () => void 0
1427
- });
1526
+ }, toolShortcutMap);
1428
1527
  };
1429
1528
  const handleKeyUp = (e) => {
1430
1529
  handleKeyboardShortcutKeyUp(e, {
@@ -1505,6 +1604,7 @@ function useTsdrawCanvasController(options = {}) {
1505
1604
  const cleanupEditorListener = editor.listen(() => {
1506
1605
  if (ignorePersistenceChanges) return;
1507
1606
  schedulePersist();
1607
+ onChangeRef.current?.(editor.getDocumentSnapshot());
1508
1608
  });
1509
1609
  const cleanupHistoryListener = editor.listenHistory(() => {
1510
1610
  syncHistoryState();
@@ -1571,6 +1671,9 @@ function useTsdrawCanvasController(options = {}) {
1571
1671
  render();
1572
1672
  }
1573
1673
  });
1674
+ if (options.autoFocus !== false) {
1675
+ container.focus({ preventScroll: true });
1676
+ }
1574
1677
  return () => {
1575
1678
  disposed = true;
1576
1679
  schedulePersistRef.current = null;
@@ -1614,15 +1717,21 @@ function useTsdrawCanvasController(options = {}) {
1614
1717
  editor.renderer.setTheme(options.theme ?? "light");
1615
1718
  render();
1616
1719
  }, [options.theme, render]);
1720
+ useEffect(() => {
1721
+ if (!editorRef.current) return;
1722
+ render();
1723
+ }, [options.background, render]);
1617
1724
  const setTool = useCallback(
1618
1725
  (tool) => {
1619
1726
  const editor = editorRef.current;
1620
1727
  if (!editor) return;
1621
1728
  if (!editor.tools.hasTool(tool)) return;
1729
+ if (readOnlyRef.current && !VIEW_ONLY_TOOLS.has(tool)) return;
1622
1730
  editor.setCurrentTool(tool);
1623
1731
  setCurrentToolState(tool);
1624
1732
  currentToolRef.current = tool;
1625
1733
  if (tool !== "select") resetSelectUi();
1734
+ onToolChangeRef.current?.(tool);
1626
1735
  },
1627
1736
  [resetSelectUi]
1628
1737
  );
@@ -1858,12 +1967,23 @@ function Tsdraw(props) {
1858
1967
  initialTool,
1859
1968
  theme: resolvedTheme,
1860
1969
  persistenceKey: props.persistenceKey,
1861
- onMount: props.onMount
1970
+ onMount: props.onMount,
1971
+ cameraOptions: props.cameraOptions,
1972
+ touchOptions: props.touchOptions,
1973
+ keyboardShortcuts: props.keyboardShortcuts,
1974
+ penOptions: props.penOptions,
1975
+ background: props.background,
1976
+ readOnly: props.readOnly,
1977
+ autoFocus: props.autoFocus,
1978
+ snapshot: props.snapshot,
1979
+ onChange: props.onChange,
1980
+ onCameraChange: props.onCameraChange,
1981
+ onToolChange: props.onToolChange
1862
1982
  });
1863
1983
  const toolbarPlacementStyle = resolvePlacementStyle(props.uiOptions?.toolbar?.placement, "bottom-center", 0, 14);
1864
1984
  const stylePanelPlacementStyle = resolvePlacementStyle(props.uiOptions?.stylePanel?.placement, "top-right", 8, 8);
1865
1985
  const isToolbarHidden = props.uiOptions?.toolbar?.hide === true;
1866
- const isStylePanelHidden = props.uiOptions?.stylePanel?.hide === true;
1986
+ const isStylePanelHidden = props.uiOptions?.stylePanel?.hide === true || props.readOnly === true;
1867
1987
  const canvasCursor = props.uiOptions?.cursor?.getCursor?.(cursorContext) ?? defaultCanvasCursor;
1868
1988
  const defaultToolOverlay = /* @__PURE__ */ jsx(
1869
1989
  ToolOverlay,
@@ -1952,12 +2072,14 @@ function Tsdraw(props) {
1952
2072
  "div",
1953
2073
  {
1954
2074
  ref: containerRef,
2075
+ tabIndex: 0,
1955
2076
  className: `tsdraw tsdraw-${resolvedTheme}mode ${props.className ?? ""}`,
1956
2077
  style: {
1957
2078
  width: props.width ?? "100%",
1958
2079
  height: props.height ?? "100%",
1959
2080
  position: "relative",
1960
2081
  overflow: "hidden",
2082
+ outline: "none",
1961
2083
  ...props.style
1962
2084
  },
1963
2085
  children: [