@reekon-tools/boldr-utils 1.6.24 → 1.6.26

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 (77) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
  2. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
  3. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +179 -78
  4. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +4 -2
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.js +5 -2
  6. package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +4 -1
  7. package/dist/annotation/canvas/elements/BackgroundImageElement.js +21 -19
  8. package/dist/annotation/canvas/useAnnotationCanvasState.js +41 -10
  9. package/dist/annotation/canvas/viewport.d.ts +13 -1
  10. package/dist/annotation/canvas/viewport.js +32 -0
  11. package/dist/canvas/AnnotationCanvas.d.ts +11 -0
  12. package/dist/canvas/AnnotationCanvas.js +10 -0
  13. package/dist/canvas/AnnotationCanvas.native.d.ts +8 -0
  14. package/dist/canvas/AnnotationCanvas.native.js +6 -0
  15. package/dist/canvas/AnnotationCanvasInner.d.ts +39 -0
  16. package/dist/canvas/AnnotationCanvasInner.js +219 -0
  17. package/dist/canvas/AnnotationCanvasInner.native.d.ts +35 -0
  18. package/dist/canvas/AnnotationCanvasInner.native.js +138 -0
  19. package/dist/canvas/AnnotationCanvasSkia.d.ts +27 -0
  20. package/dist/canvas/AnnotationCanvasSkia.js +20 -0
  21. package/dist/canvas/Tool.d.ts +38 -0
  22. package/dist/canvas/Tool.js +1 -0
  23. package/dist/canvas/elements/BackgroundImageElement.d.ts +9 -0
  24. package/dist/canvas/elements/BackgroundImageElement.js +37 -0
  25. package/dist/canvas/elements/MeasurementStampElement.d.ts +13 -0
  26. package/dist/canvas/elements/MeasurementStampElement.js +30 -0
  27. package/dist/canvas/elements/ShapeElement.d.ts +7 -0
  28. package/dist/canvas/elements/ShapeElement.js +62 -0
  29. package/dist/canvas/elements/StrokeElement.d.ts +7 -0
  30. package/dist/canvas/elements/StrokeElement.js +18 -0
  31. package/dist/canvas/measurementPicker.d.ts +10 -0
  32. package/dist/canvas/measurementPicker.js +1 -0
  33. package/dist/canvas/measurementStampOverlay.d.ts +11 -0
  34. package/dist/canvas/measurementStampOverlay.js +1 -0
  35. package/dist/canvas/pointerAdapter.d.ts +3 -0
  36. package/dist/canvas/pointerAdapter.js +19 -0
  37. package/dist/canvas/stampLayout.d.ts +5 -0
  38. package/dist/canvas/stampLayout.js +14 -0
  39. package/dist/canvas/tools/measurementStampTool.d.ts +9 -0
  40. package/dist/canvas/tools/measurementStampTool.js +37 -0
  41. package/dist/canvas/tools/panTool.d.ts +5 -0
  42. package/dist/canvas/tools/panTool.js +25 -0
  43. package/dist/canvas/tools/penTool.d.ts +13 -0
  44. package/dist/canvas/tools/penTool.js +68 -0
  45. package/dist/canvas/tools/selectTool.d.ts +2 -0
  46. package/dist/canvas/tools/selectTool.js +182 -0
  47. package/dist/canvas/useAnnotationCanvasState.d.ts +54 -0
  48. package/dist/canvas/useAnnotationCanvasState.js +210 -0
  49. package/dist/canvas/viewport.d.ts +16 -0
  50. package/dist/canvas/viewport.js +54 -0
  51. package/dist/data/AnnotationDataContext.d.ts +8 -0
  52. package/dist/data/AnnotationDataContext.js +11 -0
  53. package/dist/data/AnnotationDataProvider.d.ts +65 -0
  54. package/dist/data/AnnotationDataProvider.js +4 -0
  55. package/dist/data/InMemoryAnnotationProvider.d.ts +30 -0
  56. package/dist/data/InMemoryAnnotationProvider.js +197 -0
  57. package/dist/data/canvasPersistence.d.ts +3 -0
  58. package/dist/data/canvasPersistence.js +26 -0
  59. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +33 -0
  60. package/dist/data/hooks/useAnnotationCanvasDoc.js +314 -0
  61. package/dist/data/hooks/useAnnotationDoc.d.ts +7 -0
  62. package/dist/data/hooks/useAnnotationDoc.js +33 -0
  63. package/dist/data/hooks/useAnnotationList.d.ts +7 -0
  64. package/dist/data/hooks/useAnnotationList.js +26 -0
  65. package/dist/data/hooks/useAnnotationMutations.d.ts +9 -0
  66. package/dist/data/hooks/useAnnotationMutations.js +11 -0
  67. package/dist/exports.d.ts +2 -1
  68. package/dist/exports.js +2 -1
  69. package/dist/hooks/useParseMeasurement.d.ts +4 -0
  70. package/dist/hooks/useParseMeasurement.js +14 -0
  71. package/dist/utils/evaluateFormula.d.ts +20 -0
  72. package/dist/utils/evaluateFormula.js +31 -0
  73. package/dist/utils/indexLabels.d.ts +3 -0
  74. package/dist/utils/indexLabels.js +40 -0
  75. package/package.json +1 -1
  76. package/dist/annotation/canvas/tools/measurementLineTool.d.ts +0 -12
  77. package/dist/annotation/canvas/tools/measurementLineTool.js +0 -95
@@ -35,6 +35,7 @@ export interface AnnotationCanvasInnerProps {
35
35
  height: number;
36
36
  initialViewport?: ViewportState;
37
37
  style?: CSSProperties;
38
+ magnifierTopOffset?: number;
38
39
  imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
39
40
  }
40
41
  export type PanTrigger = 'middleMouse' | 'rightMouse' | 'space';
@@ -36,6 +36,7 @@ export interface AnnotationCanvasInnerProps {
36
36
  height: number;
37
37
  initialViewport?: ViewportState;
38
38
  style?: ViewStyle;
39
+ magnifierTopOffset?: number;
39
40
  imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
40
41
  }
41
42
  export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;
@@ -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,6 +7,7 @@ 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';
12
13
  import { SELECTION_PAD } from './textGeometry.js';
@@ -29,6 +30,20 @@ const HANDLE_RING_PX = 2;
29
30
  // selectTool's MIN_SHAPE_EXTENT (the live preview must clamp identically to the
30
31
  // buildShapeCornerPatch commit). Keep the two in sync.
31
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
32
47
  // Native fingerprint: one finger drives the active tool, two fingers
33
48
  // pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
34
49
  // like measurement-stamp (which only listen to onPointerUp) work via tap.
@@ -41,9 +56,13 @@ const MIN_SHAPE_EXTENT = 1;
41
56
  // is only re-synced once each gesture ends. The tool/tap path still hops to
42
57
  // JS (`runOnJS(true)`) since drawing/selection are React-state operations.
43
58
  export const AnnotationCanvasInner = (props) => {
44
- const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, width, height, style, } = props;
59
+ const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, } = props;
45
60
  const valueFont = useFont(stampFontSource, stampValueFontSize);
46
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);
47
66
  // Latest state behind a ref so the gesture object can be built once and
48
67
  // never rebuilt mid-gesture — its JS callbacks read `stateRef.current`.
49
68
  const stateRef = useRef(state);
@@ -68,6 +87,40 @@ export const AnnotationCanvasInner = (props) => {
68
87
  { translateY: -panY.value },
69
88
  ];
70
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]);
71
124
  // In-flight freehand stroke, owned by the UI thread. The drawing gesture
72
125
  // appends world-space points to `livePoints`; `livePath` rebuilds the Skia
73
126
  // path off it. React state is untouched until the stroke commits on
@@ -597,6 +650,8 @@ export const AnnotationCanvasInner = (props) => {
597
650
  const id = pointerIdRef.current++;
598
651
  const screen = { x: e.x, y: e.y };
599
652
  inFlightRef.current = { id, lastScreen: screen };
653
+ magTouchX.value = e.x;
654
+ magTouchY.value = e.y;
600
655
  stateRef.current.dispatchPointerDown(buildEvent(id, screen));
601
656
  })
602
657
  .onUpdate((e) => {
@@ -605,6 +660,12 @@ export const AnnotationCanvasInner = (props) => {
605
660
  return;
606
661
  const screen = { x: e.x, y: e.y };
607
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);
608
669
  stateRef.current.dispatchPointerMove(buildEvent(f.id, screen));
609
670
  })
610
671
  .onEnd((e) => {
@@ -615,6 +676,7 @@ export const AnnotationCanvasInner = (props) => {
615
676
  inFlightRef.current = null;
616
677
  })
617
678
  .onFinalize(() => {
679
+ setMagnifying(false);
618
680
  if (inFlightRef.current) {
619
681
  stateRef.current.dispatchPointerCancel();
620
682
  inFlightRef.current = null;
@@ -794,9 +856,14 @@ export const AnnotationCanvasInner = (props) => {
794
856
  e.x / zoom.value + panX.value,
795
857
  e.y / zoom.value + panY.value,
796
858
  ];
859
+ magTouchX.value = e.x;
860
+ magTouchY.value = e.y;
861
+ runOnJS(setMagnifying)(true);
797
862
  })
798
863
  .onChange((e) => {
799
864
  'worklet';
865
+ magTouchX.value = e.x;
866
+ magTouchY.value = e.y;
800
867
  const wx = e.x / zoom.value + panX.value;
801
868
  const wy = e.y / zoom.value + panY.value;
802
869
  const pts = livePoints.value;
@@ -832,6 +899,7 @@ export const AnnotationCanvasInner = (props) => {
832
899
  // Cancel path (no onEnd): drop the in-flight preview. After a normal
833
900
  // end the buffer is already empty, so this is a no-op.
834
901
  livePoints.value = [];
902
+ runOnJS(setMagnifying)(false);
835
903
  });
836
904
  };
837
905
  // Shape rubber-band (line/arrow/rect/triangle/circle tools) — one finger,
@@ -867,12 +935,17 @@ export const AnnotationCanvasInner = (props) => {
867
935
  const wx = e.x / zoom.value + panX.value;
868
936
  const wy = e.y / zoom.value + panY.value;
869
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);
870
941
  })
871
942
  .onChange((e) => {
872
943
  'worklet';
873
944
  const s = liveShape.value;
874
945
  if (!s.active)
875
946
  return;
947
+ magTouchX.value = e.x;
948
+ magTouchY.value = e.y;
876
949
  liveShape.value = {
877
950
  active: true,
878
951
  ax: s.ax,
@@ -919,6 +992,7 @@ export const AnnotationCanvasInner = (props) => {
919
992
  // Cancel path (no onEnd): drop the in-flight rubber-band. After a
920
993
  // normal end the slot is already inactive, so this is a no-op.
921
994
  liveShape.value = { active: false, ax: 0, ay: 0, bx: 0, by: 0 };
995
+ runOnJS(setMagnifying)(false);
922
996
  });
923
997
  };
924
998
  // Element drag (select tool) — one finger, UI thread. Hit-tests on the JS
@@ -953,6 +1027,7 @@ export const AnnotationCanvasInner = (props) => {
953
1027
  };
954
1028
  epTargetRef.current = { id: selId, handle };
955
1029
  setEpDragId(selId);
1030
+ setMagnifying(true);
956
1031
  return;
957
1032
  }
958
1033
  }
@@ -974,6 +1049,7 @@ export const AnnotationCanvasInner = (props) => {
974
1049
  };
975
1050
  shapeEpTargetRef.current = { id: selId, handle: shapeHandle };
976
1051
  setShapeEpDragId(selId);
1052
+ setMagnifying(true);
977
1053
  return;
978
1054
  }
979
1055
  }
@@ -990,6 +1066,7 @@ export const AnnotationCanvasInner = (props) => {
990
1066
  };
991
1067
  resizeTargetRef.current = { id: selId };
992
1068
  setResizingId(selId);
1069
+ setMagnifying(true);
993
1070
  return;
994
1071
  }
995
1072
  // Rectangle-annotation corner handle — also selected-element-only.
@@ -1003,6 +1080,7 @@ export const AnnotationCanvasInner = (props) => {
1003
1080
  };
1004
1081
  rectTargetRef.current = { id: selId, corner: rectCornerHit.corner };
1005
1082
  setRectDragId(selId);
1083
+ setMagnifying(true);
1006
1084
  return;
1007
1085
  }
1008
1086
  // Shape (rect/ellipse/polygon) bounding-box corner handle — the shape
@@ -1030,6 +1108,7 @@ export const AnnotationCanvasInner = (props) => {
1030
1108
  corner: shapeCornerHit.corner,
1031
1109
  };
1032
1110
  setShapeResizeId(selId);
1111
+ setMagnifying(true);
1033
1112
  return;
1034
1113
  }
1035
1114
  }
@@ -1064,6 +1143,8 @@ export const AnnotationCanvasInner = (props) => {
1064
1143
  };
1065
1144
  const endSelectDrag = (dx, dy) => {
1066
1145
  const st = stateRef.current;
1146
+ // Any precision sub-drag that turned the loupe on is ending now.
1147
+ setMagnifying(false);
1067
1148
  // Resize commit: scale the shape by the final drag (clamped in
1068
1149
  // buildResizePatch exactly like the live preview).
1069
1150
  const rT = resizeTargetRef.current;
@@ -1158,6 +1239,7 @@ export const AnnotationCanvasInner = (props) => {
1158
1239
  setDraggingId(null);
1159
1240
  };
1160
1241
  const cancelSelectDrag = () => {
1242
+ setMagnifying(false);
1161
1243
  // No commit — dropping the gating ids snaps everything back.
1162
1244
  dragTargetRef.current = null;
1163
1245
  slideTargetRef.current = null;
@@ -1182,6 +1264,10 @@ export const AnnotationCanvasInner = (props) => {
1182
1264
  dragEnded.value = false;
1183
1265
  dragX.value = 0;
1184
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;
1185
1271
  // Hit-test at the touch-down point — onStart fires only after the
1186
1272
  // pan threshold, so back out the accumulated translation.
1187
1273
  runOnJS(beginSelectDrag)({
@@ -1191,6 +1277,8 @@ export const AnnotationCanvasInner = (props) => {
1191
1277
  })
1192
1278
  .onChange((e) => {
1193
1279
  'worklet';
1280
+ magTouchX.value = e.x;
1281
+ magTouchY.value = e.y;
1194
1282
  // World-space delta from the drag origin. Applied to the element
1195
1283
  // only once draggingId is set; otherwise it affects nothing.
1196
1284
  dragX.value = e.translationX / zoom.value;
@@ -1251,80 +1339,89 @@ export const AnnotationCanvasInner = (props) => {
1251
1339
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
1252
1340
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
1253
1341
  const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, resolveStampMeasurement, onMeasurementStampRemove, selection, } = props;
1254
- return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
1255
- width,
1256
- height,
1257
- effectiveCanvas: state.effectiveCanvas,
1258
- worldTransform,
1259
- resolveImageUrl,
1260
- valueFont,
1261
- // Freehand drawing runs on the UI thread via `livePreview`, so the
1262
- // JS-state pen preview is unused on native.
1263
- penDrawingStroke: null,
1264
- livePreview: freehand
1265
- ? {
1266
- path: livePath,
1267
- handoffPaths: [
1268
- handoffPath0,
1269
- handoffPath1,
1270
- handoffPath2,
1271
- handoffPath3,
1272
- ],
1273
- color: freehand.color,
1274
- width: freehand.width,
1275
- cap: freehand.cap ?? 'round',
1276
- dash: freehand.dash ?? false,
1277
- opacity: freehand.variant === 'highlighter' ? 0.3 : 1,
1278
- }
1279
- : null,
1280
- // UI-thread shape rubber-band (live + pending handoffs) — only
1281
- // while a shape tool is active (handoffs always release within a
1282
- // frame or two of the commit, before the tool can change).
1283
- shapePreview: shapeDraw
1284
- ? {
1285
- path: shapeBodyPath,
1286
- headPath: shapeHeadPath,
1287
- color: shapeDraw.color,
1288
- width: shapeDraw.width,
1289
- cap: shapeDraw.cap ?? 'round',
1290
- dash: shapeDraw.dash ?? false,
1291
- }
1292
- : null,
1293
- draggingId,
1294
- dragTransform,
1295
- resizingId,
1296
- resizeTransform,
1297
- selectedId: selection?.ids[0] ?? null,
1298
- endpointDragId: epDragId,
1299
- shapeEndpointDragId: shapeEpDragId,
1300
- liveLineP1,
1301
- liveLineP2,
1302
- rectDragId,
1303
- liveRect: {
1304
- x: liveRectX,
1305
- y: liveRectY,
1306
- width: liveRectW,
1307
- height: liveRectH,
1308
- },
1309
- shapeResizeId,
1310
- shapeResizePath,
1311
- shapeResizeBox: {
1312
- x: shapeResizeBoxX,
1313
- y: shapeResizeBoxY,
1314
- width: shapeResizeBoxW,
1315
- height: shapeResizeBoxH,
1316
- },
1317
- // Endpoint/corner handles are drag affordances — only the select
1318
- // tool can act on them, so suppress them when the active tool has
1319
- // no drag support (e.g. view mode's pan tool, where a selected
1320
- // measurement still shows its tile chrome but must not advertise
1321
- // draggability).
1322
- handleRadius: activeTool?.dragSelection ? handleRadius : undefined,
1323
- handleRingWidth: activeTool?.dragSelection
1324
- ? handleRingWidth
1325
- : undefined,
1326
- customPreview,
1327
- }) }) }), 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
1328
1425
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1329
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: () => {
1330
1427
  const defaultRemove = () => {
@@ -1338,7 +1435,11 @@ export const AnnotationCanvasInner = (props) => {
1338
1435
  return;
1339
1436
  }
1340
1437
  defaultRemove();
1341
- } }, placed.id))) }))] }));
1438
+ } }, placed.id))) })), magnifying && (_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: AnnotationCanvasSkia({
1439
+ ...skiaProps,
1440
+ worldTransform: loupeTransform,
1441
+ wrapContent: loupeWrap,
1442
+ }) }))] }));
1342
1443
  };
1343
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, }) => {
1344
1445
  const size = stampTileSize(placed, tileScaleFactor, tileViewportScale);
@@ -1,4 +1,4 @@
1
- import { type SkFont, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
1
+ import { type SkFont, type SkImage, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
2
2
  import type { ReactNode } from 'react';
3
3
  import type { AnnotationCanvasState, AnnotationStroke, StrokeCap } from '../../types/annotation.js';
4
4
  type AnimatedPoint = {
@@ -21,6 +21,7 @@ export interface AnnotationCanvasSkiaProps {
21
21
  value: Transforms3d;
22
22
  };
23
23
  resolveImageUrl?: (storagePath: string) => Promise<string>;
24
+ backgroundSkImage?: SkImage | null;
24
25
  valueFont: SkFont | null;
25
26
  textFontMgr?: SkTypefaceFontProvider | null;
26
27
  penDrawingStroke: AnnotationStroke | null;
@@ -86,6 +87,7 @@ export interface AnnotationCanvasSkiaProps {
86
87
  value: number;
87
88
  };
88
89
  customPreview?: ReactNode;
90
+ wrapContent?: (content: ReactNode) => ReactNode;
89
91
  }
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;
92
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
91
93
  export {};
@@ -74,7 +74,8 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
74
74
  // since the function-call pattern works identically on native we use it
75
75
  // in both Inners for consistency. Don't add hooks here; this is a plain
76
76
  // JSX-returning helper, not a component.
77
- export 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, }) => (_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) => {
77
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, }) => {
78
+ const world = (_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, skImage: backgroundSkImage })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
78
79
  // Line/arrow shapes support endpoint editing. When selected they show
79
80
  // grab handles at both ends; during an endpoint drag the line renders
80
81
  // from the live endpoints (one following the finger) — the shape twin
@@ -214,4 +215,6 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
214
215
  return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isLive, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isLive, transform: liveTransform, children: _jsx(Handle, { c: resizeGeom.handle, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }) })), cornerResizable && handleRadius != null && (_jsxs(DraggableElement, { isDragging: isDragging, transform: dragTransform, children: [_jsx(Handle, { c: { x: b.minX, y: b.minY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.maxX, y: b.minY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.minX, y: b.maxY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.maxX, y: b.maxY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR })] }))] }));
215
216
  }
216
217
  return null;
217
- })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), shapePreview && (_jsxs(_Fragment, { children: [_jsx(Path, { path: shapePreview.path, color: shapePreview.color, style: "stroke", strokeWidth: shapePreview.width, strokeCap: toSkiaStrokeCap(shapePreview.cap), strokeJoin: "round", children: shapePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(shapePreview.width) })) }), _jsx(Path, { path: shapePreview.headPath, color: shapePreview.color, style: "fill" })] })), livePreview?.handoffPaths?.map((p, i) => (_jsx(Path, { path: p, 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) })) }, i))), 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] }) }));
218
+ })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), shapePreview && (_jsxs(_Fragment, { children: [_jsx(Path, { path: shapePreview.path, color: shapePreview.color, style: "stroke", strokeWidth: shapePreview.width, strokeCap: toSkiaStrokeCap(shapePreview.cap), strokeJoin: "round", children: shapePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(shapePreview.width) })) }), _jsx(Path, { path: shapePreview.headPath, color: shapePreview.color, style: "fill" })] })), livePreview?.handoffPaths?.map((p, i) => (_jsx(Path, { path: p, 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) })) }, i))), 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] }));
219
+ return (_jsx(Canvas, { style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
220
+ };
@@ -1,9 +1,12 @@
1
+ import { type SkImage } from '@shopify/react-native-skia';
1
2
  import type { AnnotationBackgroundImage, BackgroundFit } from '../../../types/annotation.js';
3
+ export declare const useBackgroundSkImage: (image: AnnotationBackgroundImage | null | undefined, resolveUrl?: (path: string) => Promise<string>) => SkImage | null;
2
4
  export interface BackgroundImageElementProps {
3
5
  image: AnnotationBackgroundImage;
4
6
  docWidth: number;
5
7
  docHeight: number;
6
8
  fit?: BackgroundFit;
7
9
  resolveUrl?: (path: string) => Promise<string>;
10
+ skImage?: SkImage | null;
8
11
  }
9
- export declare const BackgroundImageElement: import("react").MemoExoticComponent<({ image, docWidth, docHeight, fit, resolveUrl, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null>;
12
+ export declare const BackgroundImageElement: import("react").MemoExoticComponent<({ image, docWidth, docHeight, fit, resolveUrl, skImage: skImageProp, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null>;
@@ -1,22 +1,15 @@
1
1
  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
- const computeFit = (imgW, imgH, docW, docH, fit) => {
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') {
9
- return { x: 0, y: 0, width: docW, height: docH };
10
- }
11
- const scale = fit === 'cover'
12
- ? Math.max(docW / imgW, docH / imgH)
13
- : Math.min(docW / imgW, docH / imgH);
14
- const w = imgW * scale;
15
- const h = imgH * scale;
16
- return { x: (docW - w) / 2, y: (docH - h) / 2, width: w, height: h };
17
- };
18
- export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, }) => {
19
- const [url, setUrl] = useState(image.downloadUrl);
4
+ import { imageDocRect } from '../viewport.js';
5
+ // Resolve a background image's (possibly expired) storage URL and decode it to
6
+ // an SkImage. Exported so a consumer can hoist the decode ABOVE the canvas and
7
+ // share one SkImage across multiple canvases (e.g. the main canvas + a
8
+ // magnifier overlay) otherwise each canvas mounts its own loader and flashes
9
+ // a blank while it re-resolves + re-decodes. Returns null until loaded, or when
10
+ // `image` is null/undefined. Hooks run unconditionally regardless of `image`.
11
+ export const useBackgroundSkImage = (image, resolveUrl) => {
12
+ const [url, setUrl] = useState(image?.downloadUrl ?? '');
20
13
  // Hold resolveUrl in a ref so re-resolution is keyed only on the image
21
14
  // identity, not the function's. Consumers commonly pass an inline closure
22
15
  // (a new reference each render); depending on it directly would re-run a
@@ -24,6 +17,8 @@ export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit =
24
17
  const resolveUrlRef = useRef(resolveUrl);
25
18
  resolveUrlRef.current = resolveUrl;
26
19
  useEffect(() => {
20
+ if (!image)
21
+ return;
27
22
  let cancelled = false;
28
23
  const resolve = resolveUrlRef.current;
29
24
  if (resolve) {
@@ -38,10 +33,17 @@ export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit =
38
33
  return () => {
39
34
  cancelled = true;
40
35
  };
41
- }, [image.downloadUrl, image.storagePath]);
42
- const skImage = useImage(url);
36
+ }, [image?.downloadUrl, image?.storagePath]);
37
+ return useImage(url || null);
38
+ };
39
+ export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, skImage: skImageProp, }) => {
40
+ // Self-load only when no pre-decoded image is provided; the hook still runs
41
+ // unconditionally (with a null image, so it no-ops) to satisfy hook rules.
42
+ const provided = skImageProp !== undefined;
43
+ const loaded = useBackgroundSkImage(provided ? null : image, resolveUrl);
44
+ const skImage = provided ? skImageProp : loaded;
43
45
  if (!skImage)
44
46
  return null;
45
- const dims = computeFit(image.widthPx, image.heightPx, docWidth, docHeight, fit);
47
+ const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
46
48
  return (_jsx(Image, { image: skImage, x: dims.x, y: dims.y, width: dims.width, height: dims.height, fit: "fill" }));
47
49
  });