@reekon-tools/boldr-utils 1.6.23 → 1.6.25

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.
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Skia, useFont } from '@shopify/react-native-skia';
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Group, Line, Rect, RoundedRect, Skia, rect, rrect, useFont, } from '@shopify/react-native-skia';
3
3
  import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
4
4
  import { StyleSheet, TouchableOpacity, View, } from 'react-native';
5
5
  import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
@@ -7,8 +7,10 @@ import Animated, { runOnJS, runOnUI, useAnimatedStyle, useDerivedValue, useShare
7
7
  import { stampTileSize } from './stampLayout.js';
8
8
  import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
+ import { useBackgroundSkImage } from './elements/BackgroundImageElement.js';
10
11
  import { buildRemoveMeasurementOps, } from './measurementGeometry.js';
11
12
  import { buildShapeFromDrag } from './tools/shapeTool.js';
13
+ import { SELECTION_PAD } from './textGeometry.js';
12
14
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
13
15
  let strokeCounter = 0;
14
16
  const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++).toString(36)}`;
@@ -24,6 +26,24 @@ const HANDLE_RADIUS_PX = 7;
24
26
  // Screen-px stroke width of the handle's colored ring (white-disc + ring, so the
25
27
  // knob stays legible over a line of any color). Also zoom-divided.
26
28
  const HANDLE_RING_PX = 2;
29
+ // Doc-space floor on a shape-corner resize's width/height — the worklet twin of
30
+ // selectTool's MIN_SHAPE_EXTENT (the live preview must clamp identically to the
31
+ // buildShapeCornerPatch commit). Keep the two in sync.
32
+ const MIN_SHAPE_EXTENT = 1;
33
+ // Magnifying-glass loupe. A separate, top-most overlay canvas re-draws the Skia
34
+ // scene zoomed around the finger and clipped to a fixed lens window, so it sits
35
+ // ABOVE the RN measurement/input overlay (which the main canvas sits below) and
36
+ // never touches the main canvas (no flicker). It mounts only during a precision
37
+ // gesture, so there's no cost at rest.
38
+ const LOUPE_SIZE_FRACTION = 0.3; // of the smaller canvas dimension
39
+ const LOUPE_MARGIN = 12; // points from the top-left corner
40
+ const LOUPE_MAGNIFICATION = 2; // extra zoom inside the lens, on top of viewport
41
+ const LOUPE_RADIUS = 16; // lens corner radius
42
+ const LOUPE_BORDER_WIDTH = 2;
43
+ const LOUPE_BORDER_COLOR = '#1F2937';
44
+ const LOUPE_BG_COLOR = '#FFFFFF';
45
+ const LOUPE_CROSSHAIR_COLOR = '#1F293799'; // ~60% dark — marks the exact point
46
+ const LOUPE_CROSSHAIR_ARM = 9; // half-length of each crosshair arm, points
27
47
  // Native fingerprint: one finger drives the active tool, two fingers
28
48
  // pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
29
49
  // like measurement-stamp (which only listen to onPointerUp) work via tap.
@@ -36,13 +56,22 @@ const HANDLE_RING_PX = 2;
36
56
  // is only re-synced once each gesture ends. The tool/tap path still hops to
37
57
  // JS (`runOnJS(true)`) since drawing/selection are React-state operations.
38
58
  export const AnnotationCanvasInner = (props) => {
39
- const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, width, height, style, } = props;
59
+ const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, } = props;
40
60
  const valueFont = useFont(stampFontSource, stampValueFontSize);
41
61
  const state = useAnnotationCanvasState(props);
62
+ // Decode the background image ONCE here and share it with both the main
63
+ // canvas and the magnifier loupe. If each canvas loaded its own, the loupe
64
+ // would flash its blank base every gesture while it re-resolved + re-decoded.
65
+ const backgroundSkImage = useBackgroundSkImage(state.effectiveCanvas.viewport.backgroundImage, resolveImageUrl);
42
66
  // Latest state behind a ref so the gesture object can be built once and
43
67
  // never rebuilt mid-gesture — its JS callbacks read `stateRef.current`.
44
68
  const stateRef = useRef(state);
45
69
  stateRef.current = state;
70
+ // Open-value-entry callback (edit mode). Rides a ref so the gesture needn't
71
+ // rebuild when it changes; the second-tap-on-selected detection lives in the
72
+ // tap gesture below (it compares against the selection captured pre-tap).
73
+ const onStampDoubleTapRef = useRef(props.onMeasurementStampDoubleTap);
74
+ onStampDoubleTapRef.current = props.onMeasurementStampDoubleTap;
46
75
  // Live viewport on the UI thread. Initialised from the JS snapshot; kept in
47
76
  // sync from JS only when not actively gesturing (see the effect below).
48
77
  const zoom = useSharedValue(state.viewport.zoom);
@@ -58,6 +87,40 @@ export const AnnotationCanvasInner = (props) => {
58
87
  { translateY: -panY.value },
59
88
  ];
60
89
  });
90
+ // Magnifying-glass loupe. The live touch (screen points) rides shared values
91
+ // the gesture worklets update; `magnifying` (React state) gates whether the
92
+ // loupe shader wraps the canvas at all — so the extra fullscreen pass exists
93
+ // only while a precision gesture is in flight (drawing, shape rubber-band,
94
+ // endpoint/corner drags), never during idle renders or viewport pan/zoom.
95
+ const magTouchX = useSharedValue(0);
96
+ const magTouchY = useSharedValue(0);
97
+ const [magnifying, setMagnifying] = useState(false);
98
+ // Lens geometry (screen points): a fixed square in the top-left, pushed below
99
+ // any overlaid toolbar by magnifierTopOffset. Its CENTER maps to the live
100
+ // touch point, so the magnified slice is the canvas around the finger.
101
+ const loupeSize = Math.round(Math.min(width, height) * LOUPE_SIZE_FRACTION);
102
+ const loupeX = LOUPE_MARGIN;
103
+ const loupeY = magnifierTopOffset + LOUPE_MARGIN;
104
+ const loupeCx = loupeX + loupeSize / 2;
105
+ const loupeCy = loupeY + loupeSize / 2;
106
+ // World→lens transform: take the live screen point under the finger to the
107
+ // lens center, scaled by LOUPE_MAGNIFICATION on top of the viewport zoom.
108
+ // screen' = lensCenter + m * (worldTransform(world) - touch). Composed as a
109
+ // transform list (last entry applied to the point first), prepended onto the
110
+ // viewport's own [scale, -pan] ops.
111
+ const loupeTransform = useDerivedValue(() => {
112
+ 'worklet';
113
+ return [
114
+ { translateX: loupeCx },
115
+ { translateY: loupeCy },
116
+ { scale: LOUPE_MAGNIFICATION },
117
+ { translateX: -magTouchX.value },
118
+ { translateY: -magTouchY.value },
119
+ { scale: zoom.value },
120
+ { translateX: -panX.value },
121
+ { translateY: -panY.value },
122
+ ];
123
+ }, [loupeCx, loupeCy]);
61
124
  // In-flight freehand stroke, owned by the UI thread. The drawing gesture
62
125
  // appends world-space points to `livePoints`; `livePath` rebuilds the Skia
63
126
  // path off it. React state is untouched until the stroke commits on
@@ -179,13 +242,14 @@ export const AnnotationCanvasInner = (props) => {
179
242
  // Live mirror of the active shape tool's config for the worklet path
180
243
  // builders (the derived values are created once, so they can't close over
181
244
  // the changing `shapeDraw` prop).
182
- const shapeCfg = useSharedValue({ kind: 'line', cap: 'round', width: 2 });
245
+ const shapeCfg = useSharedValue({ kind: 'line', cap: 'round', startCap: 'round', width: 2 });
183
246
  useEffect(() => {
184
247
  if (!shapeDraw)
185
248
  return;
186
249
  shapeCfg.value = {
187
250
  kind: shapeDraw.kind,
188
251
  cap: shapeDraw.cap ?? 'round',
252
+ startCap: shapeDraw.startCap ?? 'round',
189
253
  width: shapeDraw.width,
190
254
  };
191
255
  }, [shapeDraw, shapeCfg]);
@@ -256,15 +320,20 @@ export const AnnotationCanvasInner = (props) => {
256
320
  path.close();
257
321
  };
258
322
  for (const e of handoffShapes.value) {
259
- if (e.kind === 'line' && e.cap === 'arrow') {
323
+ if (e.kind !== 'line')
324
+ continue;
325
+ // End head at b (points a→b); start head at a (points b→a, args swapped).
326
+ if (e.cap === 'arrow')
260
327
  addHead(e.ax, e.ay, e.bx, e.by);
261
- }
328
+ if (e.startCap === 'arrow')
329
+ addHead(e.bx, e.by, e.ax, e.ay);
262
330
  }
263
331
  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);
332
+ if (s.active && shapeCfg.value.kind === 'line') {
333
+ if (shapeCfg.value.cap === 'arrow')
334
+ addHead(s.ax, s.ay, s.bx, s.by);
335
+ if (shapeCfg.value.startCap === 'arrow')
336
+ addHead(s.bx, s.by, s.ax, s.ay);
268
337
  }
269
338
  return path;
270
339
  });
@@ -420,6 +489,124 @@ export const AnnotationCanvasInner = (props) => {
420
489
  { translateY: -c.py },
421
490
  ];
422
491
  });
492
+ // Geometric-shape corner resize (rect/ellipse/polygon). `shapeResizeId`
493
+ // (React state) gates which shape renders live; `shapeResizeCtx` carries the
494
+ // fixed (opposite) corner and the grabbed corner's start position (from
495
+ // DragSelectionConfig.hitTestShapeCorner), and `shapeResizePts` the shape's
496
+ // start geometry. The preview re-renders the shape from live GEOMETRY (a
497
+ // derived path) rather than a scale transform — a non-uniform scale transform
498
+ // would warp the stroke width (top/bottom edges thicken with scaleY); drawing
499
+ // the scaled outline at a constant stroke keeps it crisp. The commit on
500
+ // release goes through buildShapeCornerPatch, which scales identically.
501
+ const [shapeResizeId, setShapeResizeId] = useState(null);
502
+ const shapeResizeCtx = useSharedValue({ fx: 0, fy: 0, mx: 0, my: 0, uni: 0 });
503
+ // The resized shape's start geometry: kind + flat [x,y,x,y,…] points.
504
+ const shapeResizePts = useSharedValue({
505
+ kind: '',
506
+ pts: [],
507
+ });
508
+ const shapeResizeTargetRef = useRef(null);
509
+ // Live scale factors about the fixed corner. WORKLET TWIN of
510
+ // selectTool.shapeCornerPatch — keep the clamp + the ellipse uniform-scale
511
+ // branch in sync. Read by the path + box derived values below.
512
+ const shapeResizeScale = useDerivedValue(() => {
513
+ 'worklet';
514
+ const c = shapeResizeCtx.value;
515
+ const denomX = c.mx - c.fx;
516
+ const denomY = c.my - c.fy;
517
+ let offX = c.mx + dragX.value - c.fx;
518
+ let offY = c.my + dragY.value - c.fy;
519
+ offX =
520
+ denomX >= 0
521
+ ? Math.max(MIN_SHAPE_EXTENT, offX)
522
+ : Math.min(-MIN_SHAPE_EXTENT, offX);
523
+ offY =
524
+ denomY >= 0
525
+ ? Math.max(MIN_SHAPE_EXTENT, offY)
526
+ : Math.min(-MIN_SHAPE_EXTENT, offY);
527
+ let sx = denomX !== 0 ? offX / denomX : 1;
528
+ let sy = denomY !== 0 ? offY / denomY : 1;
529
+ if (c.uni === 1) {
530
+ const oldDiag = Math.sqrt(denomX * denomX + denomY * denomY);
531
+ const s0 = oldDiag !== 0 ? Math.sqrt(offX * offX + offY * offY) / oldDiag : 1;
532
+ sx = s0;
533
+ sy = s0;
534
+ }
535
+ return { sx, sy };
536
+ });
537
+ // The shape's outline scaled about the fixed corner, rebuilt per frame.
538
+ // WORKLET TWIN of ShapeElement's per-kind rendering (rect/ellipse/polygon) —
539
+ // keep in sync.
540
+ const shapeResizePath = useDerivedValue(() => {
541
+ 'worklet';
542
+ const path = Skia.Path.Make();
543
+ const g = shapeResizePts.value;
544
+ const c = shapeResizeCtx.value;
545
+ const { sx, sy } = shapeResizeScale.value;
546
+ const n = g.pts.length;
547
+ if (n < 4)
548
+ return path;
549
+ const px = (i) => c.fx + (g.pts[i] - c.fx) * sx;
550
+ const py = (i) => c.fy + (g.pts[i + 1] - c.fy) * sy;
551
+ if (g.kind === 'rect' || g.kind === 'ellipse') {
552
+ const ax = px(0);
553
+ const ay = py(0);
554
+ const bx = px(2);
555
+ const by = py(2);
556
+ if (g.kind === 'ellipse') {
557
+ const r = Math.max(Math.abs(bx - ax), Math.abs(by - ay)) / 2;
558
+ path.addCircle((ax + bx) / 2, (ay + by) / 2, r);
559
+ }
560
+ else {
561
+ const minX = Math.min(ax, bx);
562
+ const maxX = Math.max(ax, bx);
563
+ const minY = Math.min(ay, by);
564
+ const maxY = Math.max(ay, by);
565
+ path.moveTo(minX, minY);
566
+ path.lineTo(maxX, minY);
567
+ path.lineTo(maxX, maxY);
568
+ path.lineTo(minX, maxY);
569
+ path.close();
570
+ }
571
+ }
572
+ else {
573
+ // polygon (incl. triangle): the scaled outline through every point.
574
+ path.moveTo(px(0), py(0));
575
+ for (let i = 2; i < n; i += 2)
576
+ path.lineTo(px(i), py(i));
577
+ path.close();
578
+ }
579
+ return path;
580
+ });
581
+ // Live selection box: the scaled visual bounds (fixed corner ↔ scaled moving
582
+ // corner) padded by SELECTION_PAD, so the box tracks the resize at a constant
583
+ // stroke too (the transform would have warped it like the shape). Four
584
+ // separate derived values so each feeds its own animated <Rect> prop (Skia
585
+ // animates props individually — see `liveRect`).
586
+ const shapeResizeBoxX = useDerivedValue(() => {
587
+ 'worklet';
588
+ const c = shapeResizeCtx.value;
589
+ const nmx = c.fx + (c.mx - c.fx) * shapeResizeScale.value.sx;
590
+ return Math.min(c.fx, nmx) - SELECTION_PAD;
591
+ });
592
+ const shapeResizeBoxY = useDerivedValue(() => {
593
+ 'worklet';
594
+ const c = shapeResizeCtx.value;
595
+ const nmy = c.fy + (c.my - c.fy) * shapeResizeScale.value.sy;
596
+ return Math.min(c.fy, nmy) - SELECTION_PAD;
597
+ });
598
+ const shapeResizeBoxW = useDerivedValue(() => {
599
+ 'worklet';
600
+ const c = shapeResizeCtx.value;
601
+ const nmx = c.fx + (c.mx - c.fx) * shapeResizeScale.value.sx;
602
+ return Math.abs(nmx - c.fx) + SELECTION_PAD * 2;
603
+ });
604
+ const shapeResizeBoxH = useDerivedValue(() => {
605
+ 'worklet';
606
+ const c = shapeResizeCtx.value;
607
+ const nmy = c.fy + (c.my - c.fy) * shapeResizeScale.value.sy;
608
+ return Math.abs(nmy - c.fy) + SELECTION_PAD * 2;
609
+ });
423
610
  // Per-gesture refs so we always emit a matching down/move/up sequence.
424
611
  const pointerIdRef = useRef(1);
425
612
  const inFlightRef = useRef(null);
@@ -463,6 +650,8 @@ export const AnnotationCanvasInner = (props) => {
463
650
  const id = pointerIdRef.current++;
464
651
  const screen = { x: e.x, y: e.y };
465
652
  inFlightRef.current = { id, lastScreen: screen };
653
+ magTouchX.value = e.x;
654
+ magTouchY.value = e.y;
466
655
  stateRef.current.dispatchPointerDown(buildEvent(id, screen));
467
656
  })
468
657
  .onUpdate((e) => {
@@ -471,6 +660,12 @@ export const AnnotationCanvasInner = (props) => {
471
660
  return;
472
661
  const screen = { x: e.x, y: e.y };
473
662
  f.lastScreen = screen;
663
+ // Drag-to-draw tools (measurement line / rectangle) run through this
664
+ // JS-thread pointer path; show the loupe once the touch actually moves
665
+ // so a plain tap-to-place tool never flashes it.
666
+ magTouchX.value = e.x;
667
+ magTouchY.value = e.y;
668
+ setMagnifying(true);
474
669
  stateRef.current.dispatchPointerMove(buildEvent(f.id, screen));
475
670
  })
476
671
  .onEnd((e) => {
@@ -481,6 +676,7 @@ export const AnnotationCanvasInner = (props) => {
481
676
  inFlightRef.current = null;
482
677
  })
483
678
  .onFinalize(() => {
679
+ setMagnifying(false);
484
680
  if (inFlightRef.current) {
485
681
  stateRef.current.dispatchPointerCancel();
486
682
  inFlightRef.current = null;
@@ -530,11 +726,33 @@ export const AnnotationCanvasInner = (props) => {
530
726
  st.ctx.commit({ ops: [{ op: 'addStroke', stroke }] });
531
727
  return;
532
728
  }
729
+ // Capture the selection BEFORE this tap re-selects, so we can tell a
730
+ // first tap (select) from a second tap on the already-selected tile.
731
+ const prevSelectedId = st.ctx.selection?.ids[0] ?? null;
533
732
  // Otherwise synthesize a down+up sequence so tools that only listen to
534
733
  // onPointerUp (e.g. measurement stamp) still fire.
535
734
  const id = pointerIdRef.current++;
536
735
  st.dispatchPointerDown(buildEvent(id, screen));
537
736
  st.dispatchPointerUp(buildEvent(id, screen));
737
+ // Edit-mode "tap the selected tile to open value entry": when the select
738
+ // tool is active the stamp overlay is non-interactive (so drag/select
739
+ // keep working), which means the view-mode TouchableOpacity can't catch
740
+ // the tap. Mirror handleViewStampPress here — a first tap only selects, a
741
+ // second tap on the same (already-selected) tile fires the consumer
742
+ // callback. Not a timed double-tap: the tile must already have been
743
+ // selected by a previous tap.
744
+ const onActivate = onStampDoubleTapRef.current;
745
+ if (!onActivate || !dragSelection || !prevSelectedId)
746
+ return;
747
+ const world = st.ctx.viewport.screenToWorld(screen);
748
+ const zoomNow = st.ctx.viewport.state.zoom;
749
+ const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
750
+ if (!hit || hit.kind !== 'measurement' || hit.id !== prevSelectedId) {
751
+ return;
752
+ }
753
+ const placed = st.ctx.document.placedMeasurements.find((p) => p.id === hit.id);
754
+ if (placed)
755
+ onActivate(placed);
538
756
  });
539
757
  // Viewport pan — runs on the UI thread (no runOnJS), mutating the shared
540
758
  // viewport directly. Mirrors viewport.ts `panBy`. Used for both the
@@ -610,6 +828,7 @@ export const AnnotationCanvasInner = (props) => {
610
828
  color: fh.color,
611
829
  width: fh.width,
612
830
  cap: fh.cap ?? 'round',
831
+ ...(fh.startCap === 'arrow' && { startCap: 'arrow' }),
613
832
  ...(fh.dash && { dash: true }),
614
833
  points: worldPoints,
615
834
  createdAt: Date.now(),
@@ -637,9 +856,14 @@ export const AnnotationCanvasInner = (props) => {
637
856
  e.x / zoom.value + panX.value,
638
857
  e.y / zoom.value + panY.value,
639
858
  ];
859
+ magTouchX.value = e.x;
860
+ magTouchY.value = e.y;
861
+ runOnJS(setMagnifying)(true);
640
862
  })
641
863
  .onChange((e) => {
642
864
  'worklet';
865
+ magTouchX.value = e.x;
866
+ magTouchY.value = e.y;
643
867
  const wx = e.x / zoom.value + panX.value;
644
868
  const wy = e.y / zoom.value + panY.value;
645
869
  const pts = livePoints.value;
@@ -675,6 +899,7 @@ export const AnnotationCanvasInner = (props) => {
675
899
  // Cancel path (no onEnd): drop the in-flight preview. After a normal
676
900
  // end the buffer is already empty, so this is a no-op.
677
901
  livePoints.value = [];
902
+ runOnJS(setMagnifying)(false);
678
903
  });
679
904
  };
680
905
  // Shape rubber-band (line/arrow/rect/triangle/circle tools) — one finger,
@@ -694,6 +919,7 @@ export const AnnotationCanvasInner = (props) => {
694
919
  color: cfg.color,
695
920
  width: cfg.width,
696
921
  cap: cfg.cap,
922
+ startCap: cfg.startCap,
697
923
  dash: cfg.dash,
698
924
  layerId: st.ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
699
925
  id,
@@ -709,12 +935,17 @@ export const AnnotationCanvasInner = (props) => {
709
935
  const wx = e.x / zoom.value + panX.value;
710
936
  const wy = e.y / zoom.value + panY.value;
711
937
  liveShape.value = { active: true, ax: wx, ay: wy, bx: wx, by: wy };
938
+ magTouchX.value = e.x;
939
+ magTouchY.value = e.y;
940
+ runOnJS(setMagnifying)(true);
712
941
  })
713
942
  .onChange((e) => {
714
943
  'worklet';
715
944
  const s = liveShape.value;
716
945
  if (!s.active)
717
946
  return;
947
+ magTouchX.value = e.x;
948
+ magTouchY.value = e.y;
718
949
  liveShape.value = {
719
950
  active: true,
720
951
  ax: s.ax,
@@ -746,6 +977,7 @@ export const AnnotationCanvasInner = (props) => {
746
977
  id,
747
978
  kind: cfg.kind,
748
979
  cap: cfg.cap ?? 'round',
980
+ startCap: cfg.startCap ?? 'round',
749
981
  ax: s.ax,
750
982
  ay: s.ay,
751
983
  bx: s.bx,
@@ -760,6 +992,7 @@ export const AnnotationCanvasInner = (props) => {
760
992
  // Cancel path (no onEnd): drop the in-flight rubber-band. After a
761
993
  // normal end the slot is already inactive, so this is a no-op.
762
994
  liveShape.value = { active: false, ax: 0, ay: 0, bx: 0, by: 0 };
995
+ runOnJS(setMagnifying)(false);
763
996
  });
764
997
  };
765
998
  // Element drag (select tool) — one finger, UI thread. Hit-tests on the JS
@@ -794,6 +1027,7 @@ export const AnnotationCanvasInner = (props) => {
794
1027
  };
795
1028
  epTargetRef.current = { id: selId, handle };
796
1029
  setEpDragId(selId);
1030
+ setMagnifying(true);
797
1031
  return;
798
1032
  }
799
1033
  }
@@ -815,6 +1049,7 @@ export const AnnotationCanvasInner = (props) => {
815
1049
  };
816
1050
  shapeEpTargetRef.current = { id: selId, handle: shapeHandle };
817
1051
  setShapeEpDragId(selId);
1052
+ setMagnifying(true);
818
1053
  return;
819
1054
  }
820
1055
  }
@@ -831,6 +1066,7 @@ export const AnnotationCanvasInner = (props) => {
831
1066
  };
832
1067
  resizeTargetRef.current = { id: selId };
833
1068
  setResizingId(selId);
1069
+ setMagnifying(true);
834
1070
  return;
835
1071
  }
836
1072
  // Rectangle-annotation corner handle — also selected-element-only.
@@ -844,6 +1080,35 @@ export const AnnotationCanvasInner = (props) => {
844
1080
  };
845
1081
  rectTargetRef.current = { id: selId, corner: rectCornerHit.corner };
846
1082
  setRectDragId(selId);
1083
+ setMagnifying(true);
1084
+ return;
1085
+ }
1086
+ // Shape (rect/ellipse/polygon) bounding-box corner handle — the shape
1087
+ // twin of the rect-annotation corner above; scales length and width.
1088
+ const shapeCornerHit = cfg.hitTestShapeCorner?.(st.ctx.document, selId, world, zoomNow);
1089
+ if (shapeCornerHit) {
1090
+ const cornerShape = st.ctx.document.shapes.find((x) => x.id === selId);
1091
+ shapeResizeCtx.value = {
1092
+ fx: shapeCornerHit.fixed.x,
1093
+ fy: shapeCornerHit.fixed.y,
1094
+ mx: shapeCornerHit.moving.x,
1095
+ my: shapeCornerHit.moving.y,
1096
+ uni: cornerShape?.kind === 'ellipse' ? 1 : 0,
1097
+ };
1098
+ const flat = [];
1099
+ for (const p of cornerShape?.geometry.points ?? []) {
1100
+ flat.push(p.x, p.y);
1101
+ }
1102
+ shapeResizePts.value = {
1103
+ kind: cornerShape?.kind ?? '',
1104
+ pts: flat,
1105
+ };
1106
+ shapeResizeTargetRef.current = {
1107
+ id: selId,
1108
+ corner: shapeCornerHit.corner,
1109
+ };
1110
+ setShapeResizeId(selId);
1111
+ setMagnifying(true);
847
1112
  return;
848
1113
  }
849
1114
  }
@@ -878,6 +1143,8 @@ export const AnnotationCanvasInner = (props) => {
878
1143
  };
879
1144
  const endSelectDrag = (dx, dy) => {
880
1145
  const st = stateRef.current;
1146
+ // Any precision sub-drag that turned the loupe on is ending now.
1147
+ setMagnifying(false);
881
1148
  // Resize commit: scale the shape by the final drag (clamped in
882
1149
  // buildResizePatch exactly like the live preview).
883
1150
  const rT = resizeTargetRef.current;
@@ -907,6 +1174,19 @@ export const AnnotationCanvasInner = (props) => {
907
1174
  setRectDragId(null);
908
1175
  return;
909
1176
  }
1177
+ // Shape-corner commit: scale the shape by dragging the grabbed corner
1178
+ // (opposite corner fixed); buildShapeCornerPatch clamps as the preview.
1179
+ const shapeRT = shapeResizeTargetRef.current;
1180
+ if (shapeRT) {
1181
+ if (dx !== 0 || dy !== 0) {
1182
+ const patch = cfg.buildShapeCornerPatch?.(st.ctx.document, shapeRT.id, shapeRT.corner, { x: dx, y: dy });
1183
+ if (patch)
1184
+ st.ctx.commit(patch);
1185
+ }
1186
+ shapeResizeTargetRef.current = null;
1187
+ setShapeResizeId(null);
1188
+ return;
1189
+ }
910
1190
  // Endpoint commit: move the grabbed endpoint by the world delta.
911
1191
  const epT = epTargetRef.current;
912
1192
  if (epT) {
@@ -959,6 +1239,7 @@ export const AnnotationCanvasInner = (props) => {
959
1239
  setDraggingId(null);
960
1240
  };
961
1241
  const cancelSelectDrag = () => {
1242
+ setMagnifying(false);
962
1243
  // No commit — dropping the gating ids snaps everything back.
963
1244
  dragTargetRef.current = null;
964
1245
  slideTargetRef.current = null;
@@ -966,12 +1247,14 @@ export const AnnotationCanvasInner = (props) => {
966
1247
  shapeEpTargetRef.current = null;
967
1248
  resizeTargetRef.current = null;
968
1249
  rectTargetRef.current = null;
1250
+ shapeResizeTargetRef.current = null;
969
1251
  setDraggingId(null);
970
1252
  setSlidingId(null);
971
1253
  setEpDragId(null);
972
1254
  setShapeEpDragId(null);
973
1255
  setResizingId(null);
974
1256
  setRectDragId(null);
1257
+ setShapeResizeId(null);
975
1258
  };
976
1259
  return Gesture.Pan()
977
1260
  .minPointers(1)
@@ -981,6 +1264,10 @@ export const AnnotationCanvasInner = (props) => {
981
1264
  dragEnded.value = false;
982
1265
  dragX.value = 0;
983
1266
  dragY.value = 0;
1267
+ // The loupe (turned on by beginSelectDrag only for precision sub-
1268
+ // drags) follows the live finger, not the hit-test origin.
1269
+ magTouchX.value = e.x;
1270
+ magTouchY.value = e.y;
984
1271
  // Hit-test at the touch-down point — onStart fires only after the
985
1272
  // pan threshold, so back out the accumulated translation.
986
1273
  runOnJS(beginSelectDrag)({
@@ -990,6 +1277,8 @@ export const AnnotationCanvasInner = (props) => {
990
1277
  })
991
1278
  .onChange((e) => {
992
1279
  'worklet';
1280
+ magTouchX.value = e.x;
1281
+ magTouchY.value = e.y;
993
1282
  // World-space delta from the drag origin. Applied to the element
994
1283
  // only once draggingId is set; otherwise it affects nothing.
995
1284
  dragX.value = e.translationX / zoom.value;
@@ -1050,72 +1339,89 @@ export const AnnotationCanvasInner = (props) => {
1050
1339
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
1051
1340
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
1052
1341
  const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, resolveStampMeasurement, onMeasurementStampRemove, selection, } = props;
1053
- return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
1054
- width,
1055
- height,
1056
- effectiveCanvas: state.effectiveCanvas,
1057
- worldTransform,
1058
- resolveImageUrl,
1059
- valueFont,
1060
- // Freehand drawing runs on the UI thread via `livePreview`, so the
1061
- // JS-state pen preview is unused on native.
1062
- penDrawingStroke: null,
1063
- livePreview: freehand
1064
- ? {
1065
- path: livePath,
1066
- handoffPaths: [
1067
- handoffPath0,
1068
- handoffPath1,
1069
- handoffPath2,
1070
- handoffPath3,
1071
- ],
1072
- color: freehand.color,
1073
- width: freehand.width,
1074
- cap: freehand.cap ?? 'round',
1075
- dash: freehand.dash ?? false,
1076
- opacity: freehand.variant === 'highlighter' ? 0.3 : 1,
1077
- }
1078
- : null,
1079
- // UI-thread shape rubber-band (live + pending handoffs) — only
1080
- // while a shape tool is active (handoffs always release within a
1081
- // frame or two of the commit, before the tool can change).
1082
- shapePreview: shapeDraw
1083
- ? {
1084
- path: shapeBodyPath,
1085
- headPath: shapeHeadPath,
1086
- color: shapeDraw.color,
1087
- width: shapeDraw.width,
1088
- cap: shapeDraw.cap ?? 'round',
1089
- dash: shapeDraw.dash ?? false,
1090
- }
1091
- : null,
1092
- draggingId,
1093
- dragTransform,
1094
- resizingId,
1095
- resizeTransform,
1096
- selectedId: selection?.ids[0] ?? null,
1097
- endpointDragId: epDragId,
1098
- shapeEndpointDragId: shapeEpDragId,
1099
- liveLineP1,
1100
- liveLineP2,
1101
- rectDragId,
1102
- liveRect: {
1103
- x: liveRectX,
1104
- y: liveRectY,
1105
- width: liveRectW,
1106
- height: liveRectH,
1107
- },
1108
- // Endpoint/corner handles are drag affordances — only the select
1109
- // tool can act on them, so suppress them when the active tool has
1110
- // no drag support (e.g. view mode's pan tool, where a selected
1111
- // measurement still shows its tile chrome but must not advertise
1112
- // draggability).
1113
- handleRadius: activeTool?.dragSelection ? handleRadius : undefined,
1114
- handleRingWidth: activeTool?.dragSelection
1115
- ? handleRingWidth
1116
- : undefined,
1117
- customPreview,
1118
- }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1342
+ // The Skia scene props, shared by the main canvas and the loupe overlay. The
1343
+ // loupe re-runs the SAME scene with a magnifying world transform + a lens
1344
+ // clip (see loupeWrap) in a separate top-most canvas, so it shows live vector
1345
+ // content (the dragged line, handles, strokes) magnified above everything.
1346
+ const skiaProps = {
1347
+ width,
1348
+ height,
1349
+ effectiveCanvas: state.effectiveCanvas,
1350
+ worldTransform,
1351
+ resolveImageUrl,
1352
+ backgroundSkImage,
1353
+ valueFont,
1354
+ // Freehand drawing runs on the UI thread via `livePreview`, so the
1355
+ // JS-state pen preview is unused on native.
1356
+ penDrawingStroke: null,
1357
+ livePreview: freehand
1358
+ ? {
1359
+ path: livePath,
1360
+ handoffPaths: [
1361
+ handoffPath0,
1362
+ handoffPath1,
1363
+ handoffPath2,
1364
+ handoffPath3,
1365
+ ],
1366
+ color: freehand.color,
1367
+ width: freehand.width,
1368
+ cap: freehand.cap ?? 'round',
1369
+ dash: freehand.dash ?? false,
1370
+ opacity: freehand.variant === 'highlighter' ? 0.3 : 1,
1371
+ }
1372
+ : null,
1373
+ // UI-thread shape rubber-band (live + pending handoffs) — only while a
1374
+ // shape tool is active (handoffs always release within a frame or two of
1375
+ // the commit, before the tool can change).
1376
+ shapePreview: shapeDraw
1377
+ ? {
1378
+ path: shapeBodyPath,
1379
+ headPath: shapeHeadPath,
1380
+ color: shapeDraw.color,
1381
+ width: shapeDraw.width,
1382
+ cap: shapeDraw.cap ?? 'round',
1383
+ dash: shapeDraw.dash ?? false,
1384
+ }
1385
+ : null,
1386
+ draggingId,
1387
+ dragTransform,
1388
+ resizingId,
1389
+ resizeTransform,
1390
+ selectedId: selection?.ids[0] ?? null,
1391
+ endpointDragId: epDragId,
1392
+ shapeEndpointDragId: shapeEpDragId,
1393
+ liveLineP1,
1394
+ liveLineP2,
1395
+ rectDragId,
1396
+ liveRect: {
1397
+ x: liveRectX,
1398
+ y: liveRectY,
1399
+ width: liveRectW,
1400
+ height: liveRectH,
1401
+ },
1402
+ shapeResizeId,
1403
+ shapeResizePath,
1404
+ shapeResizeBox: {
1405
+ x: shapeResizeBoxX,
1406
+ y: shapeResizeBoxY,
1407
+ width: shapeResizeBoxW,
1408
+ height: shapeResizeBoxH,
1409
+ },
1410
+ // Endpoint/corner handles are drag affordances — only the select tool can
1411
+ // act on them, so suppress them when the active tool has no drag support
1412
+ // (e.g. view mode's pan tool, where a selected measurement still shows its
1413
+ // tile chrome but must not advertise draggability).
1414
+ handleRadius: activeTool?.dragSelection ? handleRadius : undefined,
1415
+ handleRingWidth: activeTool?.dragSelection ? handleRingWidth : undefined,
1416
+ customPreview,
1417
+ };
1418
+ // Lens window for the loupe: an opaque white base + the magnified scene
1419
+ // clipped to a rounded square, a border, and a crosshair marking the exact
1420
+ // point under the finger. Drawn in the overlay canvas's screen space (the
1421
+ // magnification lives in loupeTransform, applied to `content`).
1422
+ const loupeClip = rrect(rect(loupeX, loupeY, loupeSize, loupeSize), LOUPE_RADIUS, LOUPE_RADIUS);
1423
+ const loupeWrap = (content) => (_jsxs(_Fragment, { children: [_jsxs(Group, { clip: loupeClip, children: [_jsx(Rect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, color: LOUPE_BG_COLOR }), content] }), _jsx(RoundedRect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, r: LOUPE_RADIUS, color: LOUPE_BORDER_COLOR, style: "stroke", strokeWidth: LOUPE_BORDER_WIDTH }), _jsx(Line, { p1: { x: loupeCx - LOUPE_CROSSHAIR_ARM, y: loupeCy }, p2: { x: loupeCx + LOUPE_CROSSHAIR_ARM, y: loupeCy }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 }), _jsx(Line, { p1: { x: loupeCx, y: loupeCy - LOUPE_CROSSHAIR_ARM }, p2: { x: loupeCx, y: loupeCy + LOUPE_CROSSHAIR_ARM }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 })] }));
1424
+ return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia(skiaProps) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1119
1425
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1120
1426
  : (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: () => {
1121
1427
  const defaultRemove = () => {
@@ -1129,7 +1435,11 @@ export const AnnotationCanvasInner = (props) => {
1129
1435
  return;
1130
1436
  }
1131
1437
  defaultRemove();
1132
- } }, placed.id))) }))] }));
1438
+ } }, placed.id))) })), magnifying && (_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: AnnotationCanvasSkia({
1439
+ ...skiaProps,
1440
+ worldTransform: loupeTransform,
1441
+ wrapContent: loupeWrap,
1442
+ }) }))] }));
1133
1443
  };
1134
1444
  const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
1135
1445
  const size = stampTileSize(placed, tileScaleFactor, tileViewportScale);