@reekon-tools/boldr-utils 1.6.20 → 1.6.24

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.
@@ -24,6 +24,9 @@ export interface AnnotationCanvasInnerProps {
24
24
  renderMeasurementStamp?: RenderMeasurementStamp;
25
25
  onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
26
26
  onMeasurementStampLongPress?: (placed: PlacedMeasurementRef) => void;
27
+ onMeasurementStampDoubleTap?: (placed: PlacedMeasurementRef) => void;
28
+ resolveStampMeasurement?: (placed: PlacedMeasurementRef) => Measurement | null;
29
+ onMeasurementStampRemove?: (placed: PlacedMeasurementRef, defaultRemove: () => void) => void;
27
30
  stampFontSource?: unknown;
28
31
  stampValueFontSize?: number;
29
32
  stampLabelFontSize?: number;
@@ -3,7 +3,7 @@ import { Skia, useFont, useTypeface } from '@shopify/react-native-skia';
3
3
  import { useCallback, useEffect, useMemo, useRef, } from 'react';
4
4
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
5
5
  import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
6
- import { buildRemoveMeasurementOps } from './measurementGeometry.js';
6
+ import { buildRemoveMeasurementOps, hitPlacedMeasurement, } from './measurementGeometry.js';
7
7
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
8
8
  import { stampTileSize } from './stampLayout.js';
9
9
  // Screen-px radius of a measurement-annotation endpoint handle (matches the
@@ -244,8 +244,28 @@ export const AnnotationCanvasInner = (props) => {
244
244
  ...style,
245
245
  };
246
246
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
247
- const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, selection, } = props;
248
- return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
247
+ const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, onMeasurementStampDoubleTap, resolveStampMeasurement, onMeasurementStampRemove, selection, } = props;
248
+ // Double-click a stamp fire the consumer callback (e.g. open its value
249
+ // editor). Runs the SAME hit-test the select tool uses (top-of-z-order), on
250
+ // the gesture container, so the stamp overlay stays pointer-events:none and
251
+ // drag-to-move / single-tap select are unaffected. dblclick is a distinct
252
+ // browser event that doesn't suppress the preceding pointerdown/up select.
253
+ const handleDoubleClick = useCallback((event) => {
254
+ if (!onMeasurementStampDoubleTap)
255
+ return;
256
+ const rect = containerRef.current?.getBoundingClientRect();
257
+ const screen = {
258
+ x: event.clientX - (rect?.left ?? 0),
259
+ y: event.clientY - (rect?.top ?? 0),
260
+ };
261
+ const world = state.ctx.viewport.screenToWorld(screen);
262
+ const placed = [...state.effectiveCanvas.placedMeasurements]
263
+ .reverse()
264
+ .find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale));
265
+ if (placed)
266
+ onMeasurementStampDoubleTap(placed);
267
+ }, [onMeasurementStampDoubleTap, state]);
268
+ return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onDoubleClick: handleDoubleClick, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
249
269
  width,
250
270
  height,
251
271
  effectiveCanvas: state.effectiveCanvas,
@@ -279,7 +299,7 @@ export const AnnotationCanvasInner = (props) => {
279
299
  const isSelected = selection?.ids.includes(placed.id) ?? false;
280
300
  const measurement = placed.measurementId
281
301
  ? (state.measurementsById.get(placed.measurementId) ?? null)
282
- : null;
302
+ : (resolveStampMeasurement?.(placed) ?? null);
283
303
  // Corner-pinned, tile-proportional remove target (see the style
284
304
  // comment below) so it can't blanket a small tile and eat its grab.
285
305
  const removeSize = Math.min(40, size * 0.4);
@@ -300,10 +320,17 @@ export const AnnotationCanvasInner = (props) => {
300
320
  ? () => onMeasurementStampLongPress(placed)
301
321
  : undefined })), isSelected && measurement && (_jsx("div", { role: "button", "aria-label": "Remove measurement", onPointerDown: (e) => {
302
322
  e.stopPropagation();
303
- const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
304
- state.ctx.commit({ ops });
305
- if (!keepSelection)
306
- state.ctx.setSelection(null);
323
+ const defaultRemove = () => {
324
+ const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
325
+ state.ctx.commit({ ops });
326
+ if (!keepSelection)
327
+ state.ctx.setSelection(null);
328
+ };
329
+ if (onMeasurementStampRemove) {
330
+ onMeasurementStampRemove(placed, defaultRemove);
331
+ return;
332
+ }
333
+ defaultRemove();
307
334
  }, style: {
308
335
  // Sized as a fraction of the tile and corner-pinned so it
309
336
  // never covers the center. A fixed 40px target blanketed
@@ -25,6 +25,9 @@ export interface AnnotationCanvasInnerProps {
25
25
  renderMeasurementStamp?: RenderMeasurementStamp;
26
26
  onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
27
27
  onMeasurementStampLongPress?: (placed: PlacedMeasurementRef) => void;
28
+ onMeasurementStampDoubleTap?: (placed: PlacedMeasurementRef) => void;
29
+ resolveStampMeasurement?: (placed: PlacedMeasurementRef) => Measurement | null;
30
+ onMeasurementStampRemove?: (placed: PlacedMeasurementRef, defaultRemove: () => void) => void;
28
31
  stampFontSource?: unknown;
29
32
  stampValueFontSize?: number;
30
33
  stampLabelFontSize?: number;
@@ -9,6 +9,7 @@ import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
10
  import { buildRemoveMeasurementOps, } from './measurementGeometry.js';
11
11
  import { buildShapeFromDrag } from './tools/shapeTool.js';
12
+ import { SELECTION_PAD } from './textGeometry.js';
12
13
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
13
14
  let strokeCounter = 0;
14
15
  const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++).toString(36)}`;
@@ -24,6 +25,10 @@ const HANDLE_RADIUS_PX = 7;
24
25
  // Screen-px stroke width of the handle's colored ring (white-disc + ring, so the
25
26
  // knob stays legible over a line of any color). Also zoom-divided.
26
27
  const HANDLE_RING_PX = 2;
28
+ // Doc-space floor on a shape-corner resize's width/height — the worklet twin of
29
+ // selectTool's MIN_SHAPE_EXTENT (the live preview must clamp identically to the
30
+ // buildShapeCornerPatch commit). Keep the two in sync.
31
+ const MIN_SHAPE_EXTENT = 1;
27
32
  // Native fingerprint: one finger drives the active tool, two fingers
28
33
  // pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
29
34
  // like measurement-stamp (which only listen to onPointerUp) work via tap.
@@ -43,6 +48,11 @@ export const AnnotationCanvasInner = (props) => {
43
48
  // never rebuilt mid-gesture — its JS callbacks read `stateRef.current`.
44
49
  const stateRef = useRef(state);
45
50
  stateRef.current = state;
51
+ // Open-value-entry callback (edit mode). Rides a ref so the gesture needn't
52
+ // rebuild when it changes; the second-tap-on-selected detection lives in the
53
+ // tap gesture below (it compares against the selection captured pre-tap).
54
+ const onStampDoubleTapRef = useRef(props.onMeasurementStampDoubleTap);
55
+ onStampDoubleTapRef.current = props.onMeasurementStampDoubleTap;
46
56
  // Live viewport on the UI thread. Initialised from the JS snapshot; kept in
47
57
  // sync from JS only when not actively gesturing (see the effect below).
48
58
  const zoom = useSharedValue(state.viewport.zoom);
@@ -179,13 +189,14 @@ export const AnnotationCanvasInner = (props) => {
179
189
  // Live mirror of the active shape tool's config for the worklet path
180
190
  // builders (the derived values are created once, so they can't close over
181
191
  // the changing `shapeDraw` prop).
182
- const shapeCfg = useSharedValue({ kind: 'line', cap: 'round', width: 2 });
192
+ const shapeCfg = useSharedValue({ kind: 'line', cap: 'round', startCap: 'round', width: 2 });
183
193
  useEffect(() => {
184
194
  if (!shapeDraw)
185
195
  return;
186
196
  shapeCfg.value = {
187
197
  kind: shapeDraw.kind,
188
198
  cap: shapeDraw.cap ?? 'round',
199
+ startCap: shapeDraw.startCap ?? 'round',
189
200
  width: shapeDraw.width,
190
201
  };
191
202
  }, [shapeDraw, shapeCfg]);
@@ -256,15 +267,20 @@ export const AnnotationCanvasInner = (props) => {
256
267
  path.close();
257
268
  };
258
269
  for (const e of handoffShapes.value) {
259
- if (e.kind === 'line' && e.cap === 'arrow') {
270
+ if (e.kind !== 'line')
271
+ continue;
272
+ // End head at b (points a→b); start head at a (points b→a, args swapped).
273
+ if (e.cap === 'arrow')
260
274
  addHead(e.ax, e.ay, e.bx, e.by);
261
- }
275
+ if (e.startCap === 'arrow')
276
+ addHead(e.bx, e.by, e.ax, e.ay);
262
277
  }
263
278
  const s = liveShape.value;
264
- if (s.active &&
265
- shapeCfg.value.kind === 'line' &&
266
- shapeCfg.value.cap === 'arrow') {
267
- addHead(s.ax, s.ay, s.bx, s.by);
279
+ if (s.active && shapeCfg.value.kind === 'line') {
280
+ if (shapeCfg.value.cap === 'arrow')
281
+ addHead(s.ax, s.ay, s.bx, s.by);
282
+ if (shapeCfg.value.startCap === 'arrow')
283
+ addHead(s.bx, s.by, s.ax, s.ay);
268
284
  }
269
285
  return path;
270
286
  });
@@ -420,6 +436,124 @@ export const AnnotationCanvasInner = (props) => {
420
436
  { translateY: -c.py },
421
437
  ];
422
438
  });
439
+ // Geometric-shape corner resize (rect/ellipse/polygon). `shapeResizeId`
440
+ // (React state) gates which shape renders live; `shapeResizeCtx` carries the
441
+ // fixed (opposite) corner and the grabbed corner's start position (from
442
+ // DragSelectionConfig.hitTestShapeCorner), and `shapeResizePts` the shape's
443
+ // start geometry. The preview re-renders the shape from live GEOMETRY (a
444
+ // derived path) rather than a scale transform — a non-uniform scale transform
445
+ // would warp the stroke width (top/bottom edges thicken with scaleY); drawing
446
+ // the scaled outline at a constant stroke keeps it crisp. The commit on
447
+ // release goes through buildShapeCornerPatch, which scales identically.
448
+ const [shapeResizeId, setShapeResizeId] = useState(null);
449
+ const shapeResizeCtx = useSharedValue({ fx: 0, fy: 0, mx: 0, my: 0, uni: 0 });
450
+ // The resized shape's start geometry: kind + flat [x,y,x,y,…] points.
451
+ const shapeResizePts = useSharedValue({
452
+ kind: '',
453
+ pts: [],
454
+ });
455
+ const shapeResizeTargetRef = useRef(null);
456
+ // Live scale factors about the fixed corner. WORKLET TWIN of
457
+ // selectTool.shapeCornerPatch — keep the clamp + the ellipse uniform-scale
458
+ // branch in sync. Read by the path + box derived values below.
459
+ const shapeResizeScale = useDerivedValue(() => {
460
+ 'worklet';
461
+ const c = shapeResizeCtx.value;
462
+ const denomX = c.mx - c.fx;
463
+ const denomY = c.my - c.fy;
464
+ let offX = c.mx + dragX.value - c.fx;
465
+ let offY = c.my + dragY.value - c.fy;
466
+ offX =
467
+ denomX >= 0
468
+ ? Math.max(MIN_SHAPE_EXTENT, offX)
469
+ : Math.min(-MIN_SHAPE_EXTENT, offX);
470
+ offY =
471
+ denomY >= 0
472
+ ? Math.max(MIN_SHAPE_EXTENT, offY)
473
+ : Math.min(-MIN_SHAPE_EXTENT, offY);
474
+ let sx = denomX !== 0 ? offX / denomX : 1;
475
+ let sy = denomY !== 0 ? offY / denomY : 1;
476
+ if (c.uni === 1) {
477
+ const oldDiag = Math.sqrt(denomX * denomX + denomY * denomY);
478
+ const s0 = oldDiag !== 0 ? Math.sqrt(offX * offX + offY * offY) / oldDiag : 1;
479
+ sx = s0;
480
+ sy = s0;
481
+ }
482
+ return { sx, sy };
483
+ });
484
+ // The shape's outline scaled about the fixed corner, rebuilt per frame.
485
+ // WORKLET TWIN of ShapeElement's per-kind rendering (rect/ellipse/polygon) —
486
+ // keep in sync.
487
+ const shapeResizePath = useDerivedValue(() => {
488
+ 'worklet';
489
+ const path = Skia.Path.Make();
490
+ const g = shapeResizePts.value;
491
+ const c = shapeResizeCtx.value;
492
+ const { sx, sy } = shapeResizeScale.value;
493
+ const n = g.pts.length;
494
+ if (n < 4)
495
+ return path;
496
+ const px = (i) => c.fx + (g.pts[i] - c.fx) * sx;
497
+ const py = (i) => c.fy + (g.pts[i + 1] - c.fy) * sy;
498
+ if (g.kind === 'rect' || g.kind === 'ellipse') {
499
+ const ax = px(0);
500
+ const ay = py(0);
501
+ const bx = px(2);
502
+ const by = py(2);
503
+ if (g.kind === 'ellipse') {
504
+ const r = Math.max(Math.abs(bx - ax), Math.abs(by - ay)) / 2;
505
+ path.addCircle((ax + bx) / 2, (ay + by) / 2, r);
506
+ }
507
+ else {
508
+ const minX = Math.min(ax, bx);
509
+ const maxX = Math.max(ax, bx);
510
+ const minY = Math.min(ay, by);
511
+ const maxY = Math.max(ay, by);
512
+ path.moveTo(minX, minY);
513
+ path.lineTo(maxX, minY);
514
+ path.lineTo(maxX, maxY);
515
+ path.lineTo(minX, maxY);
516
+ path.close();
517
+ }
518
+ }
519
+ else {
520
+ // polygon (incl. triangle): the scaled outline through every point.
521
+ path.moveTo(px(0), py(0));
522
+ for (let i = 2; i < n; i += 2)
523
+ path.lineTo(px(i), py(i));
524
+ path.close();
525
+ }
526
+ return path;
527
+ });
528
+ // Live selection box: the scaled visual bounds (fixed corner ↔ scaled moving
529
+ // corner) padded by SELECTION_PAD, so the box tracks the resize at a constant
530
+ // stroke too (the transform would have warped it like the shape). Four
531
+ // separate derived values so each feeds its own animated <Rect> prop (Skia
532
+ // animates props individually — see `liveRect`).
533
+ const shapeResizeBoxX = useDerivedValue(() => {
534
+ 'worklet';
535
+ const c = shapeResizeCtx.value;
536
+ const nmx = c.fx + (c.mx - c.fx) * shapeResizeScale.value.sx;
537
+ return Math.min(c.fx, nmx) - SELECTION_PAD;
538
+ });
539
+ const shapeResizeBoxY = useDerivedValue(() => {
540
+ 'worklet';
541
+ const c = shapeResizeCtx.value;
542
+ const nmy = c.fy + (c.my - c.fy) * shapeResizeScale.value.sy;
543
+ return Math.min(c.fy, nmy) - SELECTION_PAD;
544
+ });
545
+ const shapeResizeBoxW = useDerivedValue(() => {
546
+ 'worklet';
547
+ const c = shapeResizeCtx.value;
548
+ const nmx = c.fx + (c.mx - c.fx) * shapeResizeScale.value.sx;
549
+ return Math.abs(nmx - c.fx) + SELECTION_PAD * 2;
550
+ });
551
+ const shapeResizeBoxH = useDerivedValue(() => {
552
+ 'worklet';
553
+ const c = shapeResizeCtx.value;
554
+ const nmy = c.fy + (c.my - c.fy) * shapeResizeScale.value.sy;
555
+ return Math.abs(nmy - c.fy) + SELECTION_PAD * 2;
556
+ });
423
557
  // Per-gesture refs so we always emit a matching down/move/up sequence.
424
558
  const pointerIdRef = useRef(1);
425
559
  const inFlightRef = useRef(null);
@@ -530,11 +664,33 @@ export const AnnotationCanvasInner = (props) => {
530
664
  st.ctx.commit({ ops: [{ op: 'addStroke', stroke }] });
531
665
  return;
532
666
  }
667
+ // Capture the selection BEFORE this tap re-selects, so we can tell a
668
+ // first tap (select) from a second tap on the already-selected tile.
669
+ const prevSelectedId = st.ctx.selection?.ids[0] ?? null;
533
670
  // Otherwise synthesize a down+up sequence so tools that only listen to
534
671
  // onPointerUp (e.g. measurement stamp) still fire.
535
672
  const id = pointerIdRef.current++;
536
673
  st.dispatchPointerDown(buildEvent(id, screen));
537
674
  st.dispatchPointerUp(buildEvent(id, screen));
675
+ // Edit-mode "tap the selected tile to open value entry": when the select
676
+ // tool is active the stamp overlay is non-interactive (so drag/select
677
+ // keep working), which means the view-mode TouchableOpacity can't catch
678
+ // the tap. Mirror handleViewStampPress here — a first tap only selects, a
679
+ // second tap on the same (already-selected) tile fires the consumer
680
+ // callback. Not a timed double-tap: the tile must already have been
681
+ // selected by a previous tap.
682
+ const onActivate = onStampDoubleTapRef.current;
683
+ if (!onActivate || !dragSelection || !prevSelectedId)
684
+ return;
685
+ const world = st.ctx.viewport.screenToWorld(screen);
686
+ const zoomNow = st.ctx.viewport.state.zoom;
687
+ const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
688
+ if (!hit || hit.kind !== 'measurement' || hit.id !== prevSelectedId) {
689
+ return;
690
+ }
691
+ const placed = st.ctx.document.placedMeasurements.find((p) => p.id === hit.id);
692
+ if (placed)
693
+ onActivate(placed);
538
694
  });
539
695
  // Viewport pan — runs on the UI thread (no runOnJS), mutating the shared
540
696
  // viewport directly. Mirrors viewport.ts `panBy`. Used for both the
@@ -610,6 +766,7 @@ export const AnnotationCanvasInner = (props) => {
610
766
  color: fh.color,
611
767
  width: fh.width,
612
768
  cap: fh.cap ?? 'round',
769
+ ...(fh.startCap === 'arrow' && { startCap: 'arrow' }),
613
770
  ...(fh.dash && { dash: true }),
614
771
  points: worldPoints,
615
772
  createdAt: Date.now(),
@@ -694,6 +851,7 @@ export const AnnotationCanvasInner = (props) => {
694
851
  color: cfg.color,
695
852
  width: cfg.width,
696
853
  cap: cfg.cap,
854
+ startCap: cfg.startCap,
697
855
  dash: cfg.dash,
698
856
  layerId: st.ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
699
857
  id,
@@ -746,6 +904,7 @@ export const AnnotationCanvasInner = (props) => {
746
904
  id,
747
905
  kind: cfg.kind,
748
906
  cap: cfg.cap ?? 'round',
907
+ startCap: cfg.startCap ?? 'round',
749
908
  ax: s.ax,
750
909
  ay: s.ay,
751
910
  bx: s.bx,
@@ -846,6 +1005,33 @@ export const AnnotationCanvasInner = (props) => {
846
1005
  setRectDragId(selId);
847
1006
  return;
848
1007
  }
1008
+ // Shape (rect/ellipse/polygon) bounding-box corner handle — the shape
1009
+ // twin of the rect-annotation corner above; scales length and width.
1010
+ const shapeCornerHit = cfg.hitTestShapeCorner?.(st.ctx.document, selId, world, zoomNow);
1011
+ if (shapeCornerHit) {
1012
+ const cornerShape = st.ctx.document.shapes.find((x) => x.id === selId);
1013
+ shapeResizeCtx.value = {
1014
+ fx: shapeCornerHit.fixed.x,
1015
+ fy: shapeCornerHit.fixed.y,
1016
+ mx: shapeCornerHit.moving.x,
1017
+ my: shapeCornerHit.moving.y,
1018
+ uni: cornerShape?.kind === 'ellipse' ? 1 : 0,
1019
+ };
1020
+ const flat = [];
1021
+ for (const p of cornerShape?.geometry.points ?? []) {
1022
+ flat.push(p.x, p.y);
1023
+ }
1024
+ shapeResizePts.value = {
1025
+ kind: cornerShape?.kind ?? '',
1026
+ pts: flat,
1027
+ };
1028
+ shapeResizeTargetRef.current = {
1029
+ id: selId,
1030
+ corner: shapeCornerHit.corner,
1031
+ };
1032
+ setShapeResizeId(selId);
1033
+ return;
1034
+ }
849
1035
  }
850
1036
  const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
851
1037
  if (!hit) {
@@ -907,6 +1093,19 @@ export const AnnotationCanvasInner = (props) => {
907
1093
  setRectDragId(null);
908
1094
  return;
909
1095
  }
1096
+ // Shape-corner commit: scale the shape by dragging the grabbed corner
1097
+ // (opposite corner fixed); buildShapeCornerPatch clamps as the preview.
1098
+ const shapeRT = shapeResizeTargetRef.current;
1099
+ if (shapeRT) {
1100
+ if (dx !== 0 || dy !== 0) {
1101
+ const patch = cfg.buildShapeCornerPatch?.(st.ctx.document, shapeRT.id, shapeRT.corner, { x: dx, y: dy });
1102
+ if (patch)
1103
+ st.ctx.commit(patch);
1104
+ }
1105
+ shapeResizeTargetRef.current = null;
1106
+ setShapeResizeId(null);
1107
+ return;
1108
+ }
910
1109
  // Endpoint commit: move the grabbed endpoint by the world delta.
911
1110
  const epT = epTargetRef.current;
912
1111
  if (epT) {
@@ -966,12 +1165,14 @@ export const AnnotationCanvasInner = (props) => {
966
1165
  shapeEpTargetRef.current = null;
967
1166
  resizeTargetRef.current = null;
968
1167
  rectTargetRef.current = null;
1168
+ shapeResizeTargetRef.current = null;
969
1169
  setDraggingId(null);
970
1170
  setSlidingId(null);
971
1171
  setEpDragId(null);
972
1172
  setShapeEpDragId(null);
973
1173
  setResizingId(null);
974
1174
  setRectDragId(null);
1175
+ setShapeResizeId(null);
975
1176
  };
976
1177
  return Gesture.Pan()
977
1178
  .minPointers(1)
@@ -1049,7 +1250,7 @@ export const AnnotationCanvasInner = (props) => {
1049
1250
  ]);
1050
1251
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
1051
1252
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
1052
- const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, selection, } = props;
1253
+ const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, resolveStampMeasurement, onMeasurementStampRemove, selection, } = props;
1053
1254
  return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
1054
1255
  width,
1055
1256
  height,
@@ -1105,6 +1306,14 @@ export const AnnotationCanvasInner = (props) => {
1105
1306
  width: liveRectW,
1106
1307
  height: liveRectH,
1107
1308
  },
1309
+ shapeResizeId,
1310
+ shapeResizePath,
1311
+ shapeResizeBox: {
1312
+ x: shapeResizeBoxX,
1313
+ y: shapeResizeBoxY,
1314
+ width: shapeResizeBoxW,
1315
+ height: shapeResizeBoxH,
1316
+ },
1108
1317
  // Endpoint/corner handles are drag affordances — only the select
1109
1318
  // tool can act on them, so suppress them when the active tool has
1110
1319
  // no drag support (e.g. view mode's pan tool, where a selected
@@ -1117,11 +1326,18 @@ export const AnnotationCanvasInner = (props) => {
1117
1326
  customPreview,
1118
1327
  }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1119
1328
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1120
- : 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, tileScaleFactor: state.effectiveCanvas.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
1121
- const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1122
- state.ctx.commit({ ops });
1123
- if (!keepSelection)
1124
- state.ctx.setSelection(null);
1329
+ : (resolveStampMeasurement?.(placed) ?? 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, tileScaleFactor: state.effectiveCanvas.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
1330
+ const defaultRemove = () => {
1331
+ const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1332
+ state.ctx.commit({ ops });
1333
+ if (!keepSelection)
1334
+ state.ctx.setSelection(null);
1335
+ };
1336
+ if (onMeasurementStampRemove) {
1337
+ onMeasurementStampRemove(placed, defaultRemove);
1338
+ return;
1339
+ }
1340
+ defaultRemove();
1125
1341
  } }, placed.id))) }))] }));
1126
1342
  };
1127
1343
  const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
@@ -69,6 +69,16 @@ export interface AnnotationCanvasSkiaProps {
69
69
  width: AnimatedNumber;
70
70
  height: AnimatedNumber;
71
71
  };
72
+ shapeResizeId?: string | null;
73
+ shapeResizePath?: SkPath | {
74
+ value: SkPath;
75
+ };
76
+ shapeResizeBox?: {
77
+ x: AnimatedNumber;
78
+ y: AnimatedNumber;
79
+ width: AnimatedNumber;
80
+ height: AnimatedNumber;
81
+ };
72
82
  handleRadius?: number | {
73
83
  value: number;
74
84
  };
@@ -77,5 +87,5 @@ export interface AnnotationCanvasSkiaProps {
77
87
  };
78
88
  customPreview?: ReactNode;
79
89
  }
80
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, handleRingWidth, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
90
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
81
91
  export {};