@reekon-tools/boldr-utils 1.6.12 → 1.6.13

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.
Files changed (30) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +4 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.js +19 -5
  3. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +4 -2
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +150 -8
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +38 -9
  7. package/dist/annotation/canvas/Tool.d.ts +17 -0
  8. package/dist/annotation/canvas/elements/BackgroundImageElement.js +4 -1
  9. package/dist/annotation/canvas/elements/ShapeElement.js +28 -2
  10. package/dist/annotation/canvas/elements/StrokeElement.js +8 -3
  11. package/dist/annotation/canvas/measurementGeometry.d.ts +20 -0
  12. package/dist/annotation/canvas/measurementGeometry.js +38 -1
  13. package/dist/annotation/canvas/strokeGeometry.d.ts +1 -0
  14. package/dist/annotation/canvas/strokeGeometry.js +8 -0
  15. package/dist/annotation/canvas/textGeometry.d.ts +24 -0
  16. package/dist/annotation/canvas/textGeometry.js +110 -0
  17. package/dist/annotation/canvas/tools/penTool.d.ts +1 -0
  18. package/dist/annotation/canvas/tools/penTool.js +3 -1
  19. package/dist/annotation/canvas/tools/selectTool.js +155 -19
  20. package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
  21. package/dist/annotation/canvas/tools/textTool.js +78 -0
  22. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -1
  23. package/dist/annotation/canvas/useAnnotationCanvasState.js +30 -4
  24. package/dist/annotation/data/coalescedRunner.d.ts +1 -0
  25. package/dist/annotation/data/coalescedRunner.js +48 -0
  26. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +35 -14
  27. package/dist/exports.d.ts +4 -2
  28. package/dist/exports.js +3 -1
  29. package/dist/types/annotation.d.ts +7 -0
  30. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  import { type CSSProperties, type MutableRefObject } from 'react';
2
2
  import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../../types/firestore.js';
3
- import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../../types/annotation.js';
3
+ import type { AnnotationCanvasState, AnnotationDocumentPatch, PlacedMeasurementRef, Selection } from '../../types/annotation.js';
4
4
  import type { MeasurementRef } from './measurementPicker.js';
5
- import type { Tool } from './Tool.js';
5
+ import type { RequestTextInput, Tool } from './Tool.js';
6
6
  import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
7
7
  import { type ViewportState } from './viewport.js';
8
8
  import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
@@ -20,7 +20,9 @@ export interface AnnotationCanvasInnerProps {
20
20
  decimalTolerance?: DecimalTolerance;
21
21
  resolveImageUrl?: (storagePath: string) => Promise<string>;
22
22
  pickMeasurement?: () => Promise<MeasurementRef | null>;
23
+ requestTextInput?: RequestTextInput;
23
24
  renderMeasurementStamp?: RenderMeasurementStamp;
25
+ onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
24
26
  stampFontSource?: unknown;
25
27
  stampValueFontSize?: number;
26
28
  stampLabelFontSize?: number;
@@ -55,7 +55,10 @@ export const AnnotationCanvasInner = (props) => {
55
55
  y: event.clientY - (rect?.top ?? 0),
56
56
  };
57
57
  if (isPanTriggerDown(event)) {
58
- panGestureRef.current = { pointerId: event.pointerId, lastScreen: screen };
58
+ panGestureRef.current = {
59
+ pointerId: event.pointerId,
60
+ lastScreen: screen,
61
+ };
59
62
  event.currentTarget.setPointerCapture(event.pointerId);
60
63
  event.preventDefault();
61
64
  return;
@@ -75,7 +78,10 @@ export const AnnotationCanvasInner = (props) => {
75
78
  x: screen.x - pan.lastScreen.x,
76
79
  y: screen.y - pan.lastScreen.y,
77
80
  });
78
- panGestureRef.current = { pointerId: pan.pointerId, lastScreen: screen };
81
+ panGestureRef.current = {
82
+ pointerId: pan.pointerId,
83
+ lastScreen: screen,
84
+ };
79
85
  return;
80
86
  }
81
87
  state.dispatchPointerMove(toCanvasPointer(event));
@@ -165,7 +171,7 @@ export const AnnotationCanvasInner = (props) => {
165
171
  ...style,
166
172
  };
167
173
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
168
- const { renderMeasurementStamp, selection } = props;
174
+ const { renderMeasurementStamp, onMeasurementStampPress, selection } = props;
169
175
  return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
170
176
  width,
171
177
  height,
@@ -190,7 +196,7 @@ export const AnnotationCanvasInner = (props) => {
190
196
  const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
191
197
  const isSelected = selection?.ids.includes(placed.id) ?? false;
192
198
  const measurement = placed.measurementId
193
- ? state.measurementsById.get(placed.measurementId) ?? null
199
+ ? (state.measurementsById.get(placed.measurementId) ?? null)
194
200
  : null;
195
201
  return (_jsxs("div", { style: {
196
202
  position: 'absolute',
@@ -205,7 +211,15 @@ export const AnnotationCanvasInner = (props) => {
205
211
  selected: isSelected,
206
212
  size,
207
213
  zoom: state.viewport.zoom,
208
- }), isSelected && measurement && (_jsx("div", { role: "button", "aria-label": "Remove measurement", onPointerDown: (e) => {
214
+ }), onMeasurementStampPress && (_jsx("button", { "aria-label": "Open measurement", onPointerDown: (e) => e.stopPropagation(), onClick: () => onMeasurementStampPress(placed), style: {
215
+ position: 'absolute',
216
+ inset: 0,
217
+ pointerEvents: 'auto',
218
+ cursor: 'pointer',
219
+ background: 'transparent',
220
+ border: 'none',
221
+ padding: 0,
222
+ } })), isSelected && measurement && (_jsx("div", { role: "button", "aria-label": "Remove measurement", onPointerDown: (e) => {
209
223
  e.stopPropagation();
210
224
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
211
225
  state.ctx.commit({ ops });
@@ -2,9 +2,9 @@ import { type MutableRefObject } from 'react';
2
2
  import { type ViewStyle } from 'react-native';
3
3
  import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
4
4
  import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../../types/firestore.js';
5
- import { type AnnotationCanvasState, type AnnotationDocumentPatch, type Selection } from '../../types/annotation.js';
5
+ import { type AnnotationCanvasState, type AnnotationDocumentPatch, type PlacedMeasurementRef, type Selection } from '../../types/annotation.js';
6
6
  import type { MeasurementRef } from './measurementPicker.js';
7
- import type { Tool } from './Tool.js';
7
+ import type { RequestTextInput, Tool } from './Tool.js';
8
8
  import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
9
9
  import type { ViewportState } from './viewport.js';
10
10
  export type { AnnotationCanvasHandle };
@@ -21,7 +21,9 @@ export interface AnnotationCanvasInnerProps {
21
21
  decimalTolerance?: DecimalTolerance;
22
22
  resolveImageUrl?: (storagePath: string) => Promise<string>;
23
23
  pickMeasurement?: () => Promise<MeasurementRef | null>;
24
+ requestTextInput?: RequestTextInput;
24
25
  renderMeasurementStamp?: RenderMeasurementStamp;
26
+ onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
25
27
  stampFontSource?: unknown;
26
28
  stampValueFontSize?: number;
27
29
  stampLabelFontSize?: number;
@@ -1,13 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Skia, useFont } from '@shopify/react-native-skia';
3
3
  import { useEffect, useMemo, useRef, useState, } from 'react';
4
- import { StyleSheet, TouchableOpacity, View } from 'react-native';
4
+ import { StyleSheet, TouchableOpacity, View, } from 'react-native';
5
5
  import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
6
6
  import Animated, { runOnJS, useAnimatedStyle, useDerivedValue, useSharedValue, } from 'react-native-reanimated';
7
7
  import { STAMP_TILE_SIZE } from './stampLayout.js';
8
8
  import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
- import { buildRemoveMeasurementOps } from './measurementGeometry.js';
10
+ import { buildRemoveMeasurementOps, } from './measurementGeometry.js';
11
11
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
12
12
  let strokeCounter = 0;
13
13
  const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++).toString(36)}`;
@@ -154,6 +154,67 @@ export const AnnotationCanvasInner = (props) => {
154
154
  'worklet';
155
155
  return HANDLE_RADIUS_PX / zoom.value;
156
156
  });
157
+ // Rectangle-annotation corner drag. `rectDragId` (React state) marks which
158
+ // annotation's rect renders from the live geometry; `rectCtx` carries the
159
+ // fixed (opposite) corner and the grabbed corner's start position so the
160
+ // Skia rect + the tile (locked to the live center) are driven on the UI
161
+ // thread from `dragX`/`dragY`.
162
+ const [rectDragId, setRectDragId] = useState(null);
163
+ const rectCtx = useSharedValue({ fx: 0, fy: 0, mx: 0, my: 0 });
164
+ const rectTargetRef = useRef(null);
165
+ // Live normalized rect during a corner drag (the grabbed corner follows the
166
+ // finger, the opposite corner stays put).
167
+ const liveRectX = useDerivedValue(() => {
168
+ 'worklet';
169
+ const c = rectCtx.value;
170
+ return Math.min(c.fx, c.mx + dragX.value);
171
+ });
172
+ const liveRectY = useDerivedValue(() => {
173
+ 'worklet';
174
+ const c = rectCtx.value;
175
+ return Math.min(c.fy, c.my + dragY.value);
176
+ });
177
+ const liveRectW = useDerivedValue(() => {
178
+ 'worklet';
179
+ const c = rectCtx.value;
180
+ return Math.abs(c.mx + dragX.value - c.fx);
181
+ });
182
+ const liveRectH = useDerivedValue(() => {
183
+ 'worklet';
184
+ const c = rectCtx.value;
185
+ return Math.abs(c.my + dragY.value - c.fy);
186
+ });
187
+ // Corner-scale resize (text shapes). `resizingId` (React state) gates which
188
+ // shape gets the live transform; `resizeCtx` carries the pivot (top-left
189
+ // anchor), the handle's grab-start position and the scale clamps (from
190
+ // DragSelectionConfig.hitTestResizeHandle) so the preview is a pure
191
+ // UI-thread scale-about-pivot driven by dragX/dragY. The commit on release
192
+ // goes through buildResizePatch, which clamps identically.
193
+ const [resizingId, setResizingId] = useState(null);
194
+ const resizeCtx = useSharedValue({ px: 0, py: 0, hx: 0, hy: 0, minS: 1, maxS: 1 });
195
+ const resizeTargetRef = useRef(null);
196
+ const resizeTransform = useDerivedValue(() => {
197
+ 'worklet';
198
+ // WORKLET TWIN of textGeometry.resizeScaleFromDrag — keep in sync.
199
+ const c = resizeCtx.value;
200
+ const bx = c.hx - c.px;
201
+ const by = c.hy - c.py;
202
+ const baseLen = Math.sqrt(bx * bx + by * by);
203
+ let s = 1;
204
+ if (baseLen > 0) {
205
+ const nx = bx + dragX.value;
206
+ const ny = by + dragY.value;
207
+ s = Math.sqrt(nx * nx + ny * ny) / baseLen;
208
+ }
209
+ s = Math.min(c.maxS, Math.max(c.minS, s));
210
+ return [
211
+ { translateX: c.px },
212
+ { translateY: c.py },
213
+ { scale: s },
214
+ { translateX: -c.px },
215
+ { translateY: -c.py },
216
+ ];
217
+ });
157
218
  // Per-gesture refs so we always emit a matching down/move/up sequence.
158
219
  const pointerIdRef = useRef(1);
159
220
  const inFlightRef = useRef(null);
@@ -330,6 +391,7 @@ export const AnnotationCanvasInner = (props) => {
330
391
  color: fh.color,
331
392
  width: fh.width,
332
393
  cap: fh.cap ?? 'round',
394
+ ...(fh.dash && { dash: true }),
333
395
  points: worldPoints,
334
396
  createdAt: Date.now(),
335
397
  };
@@ -415,6 +477,34 @@ export const AnnotationCanvasInner = (props) => {
415
477
  return;
416
478
  }
417
479
  }
480
+ // Corner resize handle (text shapes) — also selected-element-only.
481
+ const resizeGeom = cfg.hitTestResizeHandle?.(st.ctx.document, selId, world, zoomNow);
482
+ if (resizeGeom) {
483
+ resizeCtx.value = {
484
+ px: resizeGeom.pivot.x,
485
+ py: resizeGeom.pivot.y,
486
+ hx: resizeGeom.handle.x,
487
+ hy: resizeGeom.handle.y,
488
+ minS: resizeGeom.minScale,
489
+ maxS: resizeGeom.maxScale,
490
+ };
491
+ resizeTargetRef.current = { id: selId };
492
+ setResizingId(selId);
493
+ return;
494
+ }
495
+ // Rectangle-annotation corner handle — also selected-element-only.
496
+ const rectCornerHit = cfg.hitTestRectCorner?.(st.ctx.document, selId, world, zoomNow);
497
+ if (rectCornerHit) {
498
+ rectCtx.value = {
499
+ fx: rectCornerHit.fixed.x,
500
+ fy: rectCornerHit.fixed.y,
501
+ mx: rectCornerHit.moving.x,
502
+ my: rectCornerHit.moving.y,
503
+ };
504
+ rectTargetRef.current = { id: selId, corner: rectCornerHit.corner };
505
+ setRectDragId(selId);
506
+ return;
507
+ }
418
508
  }
419
509
  const hit = cfg.hitTest(st.ctx.document, world, zoomNow);
420
510
  if (!hit) {
@@ -447,6 +537,35 @@ export const AnnotationCanvasInner = (props) => {
447
537
  };
448
538
  const endSelectDrag = (dx, dy) => {
449
539
  const st = stateRef.current;
540
+ // Resize commit: scale the shape by the final drag (clamped in
541
+ // buildResizePatch exactly like the live preview).
542
+ const rT = resizeTargetRef.current;
543
+ if (rT) {
544
+ if (dx !== 0 || dy !== 0) {
545
+ const patch = cfg.buildResizePatch?.(st.ctx.document, rT.id, {
546
+ x: dx,
547
+ y: dy,
548
+ });
549
+ if (patch)
550
+ st.ctx.commit(patch);
551
+ }
552
+ resizeTargetRef.current = null;
553
+ setResizingId(null);
554
+ return;
555
+ }
556
+ // Rect-corner commit: drag the grabbed corner by the world delta
557
+ // (opposite corner fixed); anchor re-centers in the same patch.
558
+ const rectT = rectTargetRef.current;
559
+ if (rectT) {
560
+ if (dx !== 0 || dy !== 0) {
561
+ const patch = cfg.buildRectCornerPatch?.(st.ctx.document, rectT.id, rectT.corner, { x: dx, y: dy });
562
+ if (patch)
563
+ st.ctx.commit(patch);
564
+ }
565
+ rectTargetRef.current = null;
566
+ setRectDragId(null);
567
+ return;
568
+ }
450
569
  // Endpoint commit: move the grabbed endpoint by the world delta.
451
570
  const epT = epTargetRef.current;
452
571
  if (epT) {
@@ -487,13 +606,17 @@ export const AnnotationCanvasInner = (props) => {
487
606
  setDraggingId(null);
488
607
  };
489
608
  const cancelSelectDrag = () => {
490
- // No commit — dropping draggingId/slidingId/epDragId snaps everything back.
609
+ // No commit — dropping the gating ids snaps everything back.
491
610
  dragTargetRef.current = null;
492
611
  slideTargetRef.current = null;
493
612
  epTargetRef.current = null;
613
+ resizeTargetRef.current = null;
614
+ rectTargetRef.current = null;
494
615
  setDraggingId(null);
495
616
  setSlidingId(null);
496
617
  setEpDragId(null);
618
+ setResizingId(null);
619
+ setRectDragId(null);
497
620
  };
498
621
  return Gesture.Pan()
499
622
  .minPointers(1)
@@ -555,10 +678,12 @@ export const AnnotationCanvasInner = (props) => {
555
678
  dragEnded,
556
679
  slideCtx,
557
680
  epCtx,
681
+ resizeCtx,
682
+ rectCtx,
558
683
  ]);
559
684
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
560
685
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
561
- const { renderMeasurementStamp, selection } = props;
686
+ const { renderMeasurementStamp, onMeasurementStampPress, selection } = props;
562
687
  return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
563
688
  width,
564
689
  height,
@@ -575,27 +700,37 @@ export const AnnotationCanvasInner = (props) => {
575
700
  color: freehand.color,
576
701
  width: freehand.width,
577
702
  cap: freehand.cap ?? 'round',
703
+ dash: freehand.dash ?? false,
578
704
  opacity: freehand.variant === 'highlighter' ? 0.3 : 1,
579
705
  }
580
706
  : null,
581
707
  draggingId,
582
708
  dragTransform,
709
+ resizingId,
710
+ resizeTransform,
583
711
  selectedId: selection?.ids[0] ?? null,
584
712
  endpointDragId: epDragId,
585
713
  liveLineP1,
586
714
  liveLineP2,
715
+ rectDragId,
716
+ liveRect: {
717
+ x: liveRectX,
718
+ y: liveRectY,
719
+ width: liveRectW,
720
+ height: liveRectH,
721
+ },
587
722
  handleRadius,
588
723
  customPreview,
589
724
  }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
590
- ? state.measurementsById.get(placed.measurementId) ?? null
591
- : null, selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, renderMeasurementStamp: renderMeasurementStamp, onRemove: () => {
725
+ ? (state.measurementsById.get(placed.measurementId) ?? null)
726
+ : null, selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, onStampPress: onMeasurementStampPress, onRemove: () => {
592
727
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
593
728
  state.ctx.commit({ ops });
594
729
  if (!keepSelection)
595
730
  state.ctx.setSelection(null);
596
731
  } }, placed.id))) }))] }));
597
732
  };
598
- const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, renderMeasurementStamp, onRemove, }) => {
733
+ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, onStampPress, onRemove, }) => {
599
734
  const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
600
735
  const half = size / 2;
601
736
  const anchorX = placed.anchor.x;
@@ -639,6 +774,13 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
639
774
  worldX = ax + (bx - ax) * c.t0;
640
775
  worldY = ay + (by - ay) * c.t0;
641
776
  }
777
+ else if (rectResizing) {
778
+ // Tile stays at the LIVE rect's center: midpoint of the fixed corner
779
+ // and the grabbed corner with the drag delta folded in.
780
+ const c = rectCtx.value;
781
+ worldX = (c.fx + c.mx + dragX.value) / 2;
782
+ worldY = (c.fy + c.my + dragY.value) / 2;
783
+ }
642
784
  else if (dragging) {
643
785
  worldX = anchorX + dragX.value;
644
786
  worldY = anchorY + dragY.value;
@@ -658,7 +800,7 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
658
800
  selected,
659
801
  size,
660
802
  zoom: zoomSnapshot,
661
- }) }), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", hitSlop: 10, onPress: onRemove, style: {
803
+ }) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), style: StyleSheet.absoluteFill })), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", hitSlop: 10, onPress: onRemove, style: {
662
804
  position: 'absolute',
663
805
  top: -8,
664
806
  right: -8,
@@ -10,6 +10,9 @@ type AnimatedPoint = {
10
10
  y: number;
11
11
  };
12
12
  };
13
+ type AnimatedNumber = number | {
14
+ value: number;
15
+ };
13
16
  export interface AnnotationCanvasSkiaProps {
14
17
  width: number;
15
18
  height: number;
@@ -27,20 +30,32 @@ export interface AnnotationCanvasSkiaProps {
27
30
  color: string;
28
31
  width: number;
29
32
  cap?: StrokeCap;
33
+ dash?: boolean;
30
34
  opacity: number;
31
35
  } | null;
32
36
  draggingId?: string | null;
33
37
  dragTransform?: Transforms3d | {
34
38
  value: Transforms3d;
35
39
  };
40
+ resizingId?: string | null;
41
+ resizeTransform?: Transforms3d | {
42
+ value: Transforms3d;
43
+ };
36
44
  selectedId?: string | null;
37
45
  endpointDragId?: string | null;
38
46
  liveLineP1?: AnimatedPoint;
39
47
  liveLineP2?: AnimatedPoint;
48
+ rectDragId?: string | null;
49
+ liveRect?: {
50
+ x: AnimatedNumber;
51
+ y: AnimatedNumber;
52
+ width: AnimatedNumber;
53
+ height: AnimatedNumber;
54
+ };
40
55
  handleRadius?: number | {
41
56
  value: number;
42
57
  };
43
58
  customPreview?: ReactNode;
44
59
  }
45
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
60
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
46
61
  export {};
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Canvas, Circle, Group, Line, Path, Rect, Skia, } from '@shopify/react-native-skia';
3
- import { placementOf } from './measurementGeometry.js';
4
- import { arrowheadTriangle, toSkiaStrokeCap } from './strokeGeometry.js';
2
+ import { Canvas, Circle, DashPathEffect, Group, Line, Path, Rect, Skia, } from '@shopify/react-native-skia';
3
+ import { normalizeRect, placementOf } from './measurementGeometry.js';
4
+ import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from './strokeGeometry.js';
5
+ import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeometry.js';
5
6
  import { BackgroundImageElement } from './elements/BackgroundImageElement.js';
6
7
  import { ShapeElement } from './elements/ShapeElement.js';
7
8
  import { StrokeElement } from './elements/StrokeElement.js';
@@ -17,8 +18,9 @@ const MEASUREMENT_HANDLE_COLOR = '#0066FF';
17
18
  // Selection bounding box for strokes/shapes (measurements get handles instead).
18
19
  // Padding + stroke width are in doc units (scale with zoom) — simple and clear;
19
20
  // the box is only a "this is selected" affordance, not a precise gizmo.
21
+ // SELECTION_PAD lives in textGeometry.ts (Skia-free) so the select tool can
22
+ // hit-test the text resize handle, which sits on the padded box corner.
20
23
  const SELECTION_COLOR = '#0066FF';
21
- const SELECTION_PAD = 6;
22
24
  const SELECTION_STROKE = 1.5;
23
25
  // Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
24
26
  const strokeBounds = (points) => {
@@ -84,7 +86,22 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
84
86
  // since the function-call pattern works identically on native we use it
85
87
  // in both Inners for consistency. Don't add hooks here; this is a plain
86
88
  // JSX-returning helper, not a component.
87
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, handleRadius, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId, transform: dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
89
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
90
+ // Rectangle annotation: a stroked border whose center carries the
91
+ // tile. A corner drag renders from the live geometry (outside the
92
+ // group translate, like an endpoint drag); otherwise the committed
93
+ // rect + (when selected) four corner handles, wrapped for group move.
94
+ if (placementOf(placed) === 'rectangle' && placed.rect) {
95
+ const isSelected = placed.id === selectedId;
96
+ const isRectDrag = placed.id === rectDragId;
97
+ const rectColor = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
98
+ const rectWidth = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
99
+ if (isRectDrag && liveRect) {
100
+ return (_jsx(Rect, { x: liveRect.x, y: liveRect.y, width: liveRect.width, height: liveRect.height, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }, placed.id));
101
+ }
102
+ const n = normalizeRect(placed.rect);
103
+ return (_jsxs(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: [_jsx(Rect, { x: n.minX, y: n.minY, width: n.maxX - n.minX, height: n.maxY - n.minY, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: { x: n.minX, y: n.minY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.maxX, y: n.minY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.minX, y: n.maxY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.maxX, y: n.maxY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }, placed.id));
104
+ }
88
105
  if (placementOf(placed) !== 'line' || !placed.line)
89
106
  return null;
90
107
  const isEndpointDrag = placed.id === endpointDragId;
@@ -109,7 +126,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
109
126
  p.close();
110
127
  return p;
111
128
  })();
112
- const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: p1, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: p2, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }));
129
+ const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap), children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(lineWidth) })) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: p1, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: p2, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }));
113
130
  return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
114
131
  }), (() => {
115
132
  if (!selectedId)
@@ -122,8 +139,20 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
122
139
  }
123
140
  const shape = effectiveCanvas.shapes.find((s) => s.id === selectedId);
124
141
  if (shape) {
125
- const b = pointsBounds(shape.geometry.points);
126
- return b ? (_jsx(SelectionBox, { bounds: b, isDragging: isDragging, transform: dragTransform })) : null;
142
+ // Text shapes derive their box from the estimated text bounds (the
143
+ // stored geometry is just the top-left anchor) and add a corner
144
+ // resize handle; both track the live resize transform so the chrome
145
+ // scales with the shape during a native UI-thread resize.
146
+ const isText = shape.kind === 'text';
147
+ const b = isText
148
+ ? textShapeBounds(shape)
149
+ : pointsBounds(shape.geometry.points);
150
+ if (!b)
151
+ return null;
152
+ const isResizing = shape.id === resizingId;
153
+ const liveTransform = isResizing ? resizeTransform : dragTransform;
154
+ const resizeGeom = isText ? textResizeGeometry(shape) : null;
155
+ return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isDragging || isResizing, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: _jsx(Circle, { c: resizeGeom.handle, r: handleRadius, color: SELECTION_COLOR }) }))] }));
127
156
  }
128
157
  return null;
129
- })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity })), customPreview] }) }));
158
+ })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) })), customPreview] }) }));
@@ -1,7 +1,12 @@
1
1
  import type { ReactNode, ComponentType } from 'react';
2
2
  import type { AnnotationCanvasState, AnnotationDocumentPatch, AnnotationElement, AnnotationElementId, Selection, StrokeCap, Vec2 } from '../../types/annotation.js';
3
3
  import type { MeasurementRef } from './measurementPicker.js';
4
+ import type { RectCorner } from './measurementGeometry.js';
5
+ import type { ResizeGeometry } from './textGeometry.js';
4
6
  import type { ViewportApi } from './viewport.js';
7
+ export type RequestTextInput = (options?: {
8
+ initialText?: string;
9
+ }) => Promise<string | null>;
5
10
  export interface CanvasPointerEvent {
6
11
  pointerId: number;
7
12
  world: Vec2;
@@ -20,6 +25,9 @@ export interface ToolContext {
20
25
  commit(patch: AnnotationDocumentPatch): void;
21
26
  setSelection(selection: Selection | null): void;
22
27
  requestPickMeasurement(): Promise<MeasurementRef | null>;
28
+ requestTextInput(options?: {
29
+ initialText?: string;
30
+ }): Promise<string | null>;
23
31
  applyPan(deltaScreen: Vec2): void;
24
32
  applyZoom(focalScreen: Vec2, nextZoom: number): void;
25
33
  }
@@ -29,6 +37,7 @@ export interface FreehandConfig {
29
37
  color: string;
30
38
  width: number;
31
39
  cap?: StrokeCap;
40
+ dash?: boolean;
32
41
  minSampleDistance: number;
33
42
  }
34
43
  export type DragElementKind = 'stroke' | 'shape' | 'measurement';
@@ -42,6 +51,14 @@ export interface DragSelectionConfig {
42
51
  buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
43
52
  hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
44
53
  buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
54
+ hitTestRectCorner?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): {
55
+ corner: RectCorner;
56
+ moving: Vec2;
57
+ fixed: Vec2;
58
+ } | null;
59
+ buildRectCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
60
+ hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
61
+ buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
45
62
  }
46
63
  export interface Tool {
47
64
  id: string;
@@ -2,7 +2,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Image, useImage } from '@shopify/react-native-skia';
3
3
  import { memo, useEffect, useRef, useState } from 'react';
4
4
  const computeFit = (imgW, imgH, docW, docH, fit) => {
5
- if (fit === 'stretch') {
5
+ // Guard against unknown/zero image dimensions: dividing by them yields
6
+ // Infinity → NaN geometry, and a <Image> with NaN bounds renders nothing
7
+ // (a silent "background never appears"). Fall back to filling the doc.
8
+ if (!(imgW > 0) || !(imgH > 0) || fit === 'stretch') {
6
9
  return { x: 0, y: 0, width: docW, height: docH };
7
10
  }
8
11
  const scale = fit === 'cover'
@@ -1,6 +1,12 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Circle, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
2
+ import { Circle, DashPathEffect, Group, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
3
3
  import { memo, useMemo } from 'react';
4
+ import { dashIntervals } from '../strokeGeometry.js';
5
+ import { DEFAULT_TEXT_FONT_SIZE, TEXT_BASELINE_FACTOR, TEXT_LINE_HEIGHT_FACTOR, } from '../textGeometry.js';
6
+ // Outline width of dash-styled text, as a fraction of the base font size.
7
+ // Drawn pre-scale (inside the fontSize/baseSize Group), so the outline — and
8
+ // the dash pattern derived from it — scales with the glyphs.
9
+ const TEXT_OUTLINE_WIDTH_FACTOR = 1 / 16;
4
10
  const polygonPath = (points) => {
5
11
  const path = Skia.Path.Make();
6
12
  if (points.length === 0)
@@ -53,12 +59,32 @@ export const ShapeElement = memo(({ shape, font }) => {
53
59
  return (_jsxs(_Fragment, { children: [fill && _jsx(Path, { path: polyPath, color: fill }), _jsx(Path, { path: polyPath, color: stroke, style: "stroke", strokeWidth: strokeWidth })] }));
54
60
  }
55
61
  case 'text': {
62
+ // `origin` is the TOP-LEFT of the text block (textGeometry derives
63
+ // bounds/hit boxes from the same anchor + factors, so chrome and glyphs
64
+ // agree). The shared font is loaded at one fixed size; style.fontSize is
65
+ // honored by scaling the glyph outlines about the anchor — Skia text is
66
+ // vector, so this is loss-free. Lines are laid out with the textGeometry
67
+ // factors; Skia <Text> y is the BASELINE, hence the baseline offset.
56
68
  const [origin] = geometry.points;
57
69
  if (!origin || !text)
58
70
  return null;
59
71
  if (!font)
60
72
  return null;
61
- return (_jsx(Text, { x: origin.x, y: origin.y, text: text, font: font, color: stroke }));
73
+ const fontSize = style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
74
+ const baseSize = font.getSize();
75
+ const scale = baseSize > 0 ? fontSize / baseSize : 1;
76
+ // Dash-styled text strokes the glyph outlines (a dash effect is a path
77
+ // effect — it needs stroked geometry, so it can't apply to filled
78
+ // glyphs) and runs the shared dash pattern along them.
79
+ const outlineWidth = baseSize * TEXT_OUTLINE_WIDTH_FACTOR;
80
+ return (_jsx(Group, { transform: [
81
+ { translateX: origin.x },
82
+ { translateY: origin.y },
83
+ { scale },
84
+ ], children: text.split('\n').map((line, i) => (_jsx(Text, { x: 0, y: (i * TEXT_LINE_HEIGHT_FACTOR + TEXT_BASELINE_FACTOR) * baseSize, text: line, font: font, color: stroke, ...(style.dash && {
85
+ style: 'stroke',
86
+ strokeWidth: outlineWidth,
87
+ }), children: style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(outlineWidth) })) }, i))) }));
62
88
  }
63
89
  }
64
90
  });
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Path, Skia } from '@shopify/react-native-skia';
2
+ import { DashPathEffect, Path, Skia, } from '@shopify/react-native-skia';
3
3
  import { memo, useMemo } from 'react';
4
- import { arrowheadTriangle, toSkiaStrokeCap } from '../strokeGeometry.js';
4
+ import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from '../strokeGeometry.js';
5
5
  export const pointsToSkPath = (points) => {
6
6
  const path = Skia.Path.Make();
7
7
  if (points.length < 2)
@@ -36,5 +36,10 @@ export const StrokeElement = memo(({ stroke }) => {
36
36
  const path = useMemo(() => pointsToSkPath(stroke.points), [stroke.points]);
37
37
  const arrow = useMemo(() => arrowheadPath(stroke), [stroke.points, stroke.cap, stroke.width]);
38
38
  const opacity = stroke.tool === 'highlighter' ? 0.3 : 1;
39
- return (_jsxs(_Fragment, { children: [_jsx(Path, { path: path, color: stroke.color, style: "stroke", strokeWidth: stroke.width, strokeCap: toSkiaStrokeCap(stroke.cap), strokeJoin: "round", opacity: opacity }), arrow && (_jsx(Path, { path: arrow, color: stroke.color, style: "fill", opacity: opacity }))] }));
39
+ // A tap-dot is a zero-length two-point path; a dash effect would erase it
40
+ // (the contour has no length to dash), so dots always render solid.
41
+ const isDot = stroke.points.length === 4 &&
42
+ stroke.points[0] === stroke.points[2] &&
43
+ stroke.points[1] === stroke.points[3];
44
+ return (_jsxs(_Fragment, { children: [_jsx(Path, { path: path, color: stroke.color, style: "stroke", strokeWidth: stroke.width, strokeCap: toSkiaStrokeCap(stroke.cap), strokeJoin: "round", opacity: opacity, children: stroke.dash && !isDot && (_jsx(DashPathEffect, { intervals: dashIntervals(stroke.width) })) }), arrow && (_jsx(Path, { path: arrow, color: stroke.color, style: "fill", opacity: opacity }))] }));
40
45
  });