@reekon-tools/boldr-utils 1.6.11 → 1.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.d.ts +5 -3
  2. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.js +36 -17
  3. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.native.d.ts +5 -3
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +810 -0
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +61 -0
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +158 -0
  7. package/dist/annotation/canvas/Tool.d.ts +77 -0
  8. package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.d.ts +2 -2
  9. package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.js +17 -7
  10. package/dist/annotation/canvas/elements/ShapeElement.d.ts +7 -0
  11. package/dist/{canvas → annotation/canvas}/elements/ShapeElement.js +33 -5
  12. package/dist/annotation/canvas/elements/StrokeElement.d.ts +7 -0
  13. package/dist/annotation/canvas/elements/StrokeElement.js +45 -0
  14. package/dist/annotation/canvas/measurementGeometry.d.ts +43 -0
  15. package/dist/annotation/canvas/measurementGeometry.js +111 -0
  16. package/dist/{canvas → annotation/canvas}/measurementPicker.d.ts +1 -1
  17. package/dist/{canvas → annotation/canvas}/measurementStampOverlay.d.ts +2 -2
  18. package/dist/annotation/canvas/stampLayout.d.ts +1 -0
  19. package/dist/annotation/canvas/stampLayout.js +11 -0
  20. package/dist/annotation/canvas/strokeGeometry.d.ts +5 -0
  21. package/dist/annotation/canvas/strokeGeometry.js +41 -0
  22. package/dist/annotation/canvas/textGeometry.d.ts +24 -0
  23. package/dist/annotation/canvas/textGeometry.js +110 -0
  24. package/dist/{canvas → annotation/canvas}/tools/measurementStampTool.d.ts +1 -1
  25. package/dist/{canvas → annotation/canvas}/tools/measurementStampTool.js +1 -1
  26. package/dist/{canvas → annotation/canvas}/tools/panTool.js +3 -0
  27. package/dist/{canvas → annotation/canvas}/tools/penTool.d.ts +3 -1
  28. package/dist/{canvas → annotation/canvas}/tools/penTool.js +34 -5
  29. package/dist/annotation/canvas/tools/selectTool.js +446 -0
  30. package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
  31. package/dist/annotation/canvas/tools/textTool.js +78 -0
  32. package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.d.ts +11 -3
  33. package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.js +142 -2
  34. package/dist/{canvas → annotation/canvas}/viewport.d.ts +1 -1
  35. package/dist/{data → annotation/data}/AnnotationDataProvider.d.ts +1 -1
  36. package/dist/{data → annotation/data}/InMemoryAnnotationProvider.d.ts +1 -1
  37. package/dist/{data → annotation/data}/InMemoryAnnotationProvider.js +1 -1
  38. package/dist/{data → annotation/data}/canvasPersistence.d.ts +1 -1
  39. package/dist/{data → annotation/data}/canvasPersistence.js +1 -1
  40. package/dist/annotation/data/coalescedRunner.d.ts +1 -0
  41. package/dist/annotation/data/coalescedRunner.js +48 -0
  42. package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.d.ts +1 -1
  43. package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.js +37 -16
  44. package/dist/exports.d.ts +23 -19
  45. package/dist/exports.js +18 -14
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.js +2 -2
  48. package/dist/index.native.d.ts +1 -1
  49. package/dist/index.native.js +1 -1
  50. package/dist/types/annotation.d.ts +22 -3
  51. package/dist/types/firestore.d.ts +0 -1
  52. package/dist/{hooks → utils}/useParseMeasurement.js +1 -1
  53. package/package.json +1 -1
  54. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  55. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  56. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  57. package/dist/canvas/Tool.d.ts +0 -38
  58. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  59. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  60. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  61. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  62. package/dist/canvas/elements/StrokeElement.js +0 -18
  63. package/dist/canvas/stampLayout.d.ts +0 -5
  64. package/dist/canvas/stampLayout.js +0 -14
  65. package/dist/canvas/tools/selectTool.js +0 -182
  66. package/dist/utils/evaluateFormula.d.ts +0 -20
  67. package/dist/utils/evaluateFormula.js +0 -31
  68. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.d.ts +0 -0
  69. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.js +0 -0
  70. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.native.d.ts +0 -0
  71. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.native.js +0 -0
  72. /package/dist/{canvas → annotation/canvas}/Tool.js +0 -0
  73. /package/dist/{canvas → annotation/canvas}/measurementPicker.js +0 -0
  74. /package/dist/{canvas → annotation/canvas}/measurementStampOverlay.js +0 -0
  75. /package/dist/{canvas → annotation/canvas}/pointerAdapter.d.ts +0 -0
  76. /package/dist/{canvas → annotation/canvas}/pointerAdapter.js +0 -0
  77. /package/dist/{canvas → annotation/canvas}/tools/panTool.d.ts +0 -0
  78. /package/dist/{canvas → annotation/canvas}/tools/selectTool.d.ts +0 -0
  79. /package/dist/{canvas → annotation/canvas}/viewport.js +0 -0
  80. /package/dist/{data → annotation/data}/AnnotationDataContext.d.ts +0 -0
  81. /package/dist/{data → annotation/data}/AnnotationDataContext.js +0 -0
  82. /package/dist/{data → annotation/data}/AnnotationDataProvider.js +0 -0
  83. /package/dist/{data → annotation/data}/hooks/useAnnotationDoc.d.ts +0 -0
  84. /package/dist/{data → annotation/data}/hooks/useAnnotationDoc.js +0 -0
  85. /package/dist/{data → annotation/data}/hooks/useAnnotationList.d.ts +0 -0
  86. /package/dist/{data → annotation/data}/hooks/useAnnotationList.js +0 -0
  87. /package/dist/{data → annotation/data}/hooks/useAnnotationMutations.d.ts +0 -0
  88. /package/dist/{data → annotation/data}/hooks/useAnnotationMutations.js +0 -0
  89. /package/dist/{hooks → utils}/useParseMeasurement.d.ts +0 -0
@@ -0,0 +1,810 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Skia, useFont } from '@shopify/react-native-skia';
3
+ import { useEffect, useMemo, useRef, useState, } from 'react';
4
+ import { StyleSheet, TouchableOpacity, View, } from 'react-native';
5
+ import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
6
+ import Animated, { runOnJS, useAnimatedStyle, useDerivedValue, useSharedValue, } from 'react-native-reanimated';
7
+ import { STAMP_TILE_SIZE } from './stampLayout.js';
8
+ import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
+ import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
+ import { buildRemoveMeasurementOps, } from './measurementGeometry.js';
11
+ import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
12
+ let strokeCounter = 0;
13
+ const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++).toString(36)}`;
14
+ // Screen-px radius of a measurement-annotation endpoint handle dot. Divided by
15
+ // the live zoom so the handle is a constant on-screen size.
16
+ const HANDLE_RADIUS_PX = 7;
17
+ // Native fingerprint: one finger drives the active tool, two fingers
18
+ // pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
19
+ // like measurement-stamp (which only listen to onPointerUp) work via tap.
20
+ //
21
+ // Performance model: the viewport lives in Reanimated shared values
22
+ // (`zoom`/`panX`/`panY`) so two-finger pan/zoom runs entirely on the UI
23
+ // thread — the gesture worklets mutate the shared values and a derived
24
+ // transform feeds Skia's <Group> with zero React renders per frame. The JS
25
+ // snapshot in `useAnnotationCanvasState` (which tools' screenToWorld reads)
26
+ // is only re-synced once each gesture ends. The tool/tap path still hops to
27
+ // JS (`runOnJS(true)`) since drawing/selection are React-state operations.
28
+ export const AnnotationCanvasInner = (props) => {
29
+ const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, width, height, style, } = props;
30
+ const valueFont = useFont(stampFontSource, stampValueFontSize);
31
+ const state = useAnnotationCanvasState(props);
32
+ // Latest state behind a ref so the gesture object can be built once and
33
+ // never rebuilt mid-gesture — its JS callbacks read `stateRef.current`.
34
+ const stateRef = useRef(state);
35
+ stateRef.current = state;
36
+ // Live viewport on the UI thread. Initialised from the JS snapshot; kept in
37
+ // sync from JS only when not actively gesturing (see the effect below).
38
+ const zoom = useSharedValue(state.viewport.zoom);
39
+ const panX = useSharedValue(state.viewport.pan.x);
40
+ const panY = useSharedValue(state.viewport.pan.y);
41
+ const pinchStartZoom = useSharedValue(state.viewport.zoom);
42
+ // Skia consumes this directly and re-renders the transform on the UI thread.
43
+ const worldTransform = useDerivedValue(() => {
44
+ 'worklet';
45
+ return [
46
+ { scale: zoom.value },
47
+ { translateX: -panX.value },
48
+ { translateY: -panY.value },
49
+ ];
50
+ });
51
+ // In-flight freehand stroke, owned by the UI thread. The drawing gesture
52
+ // appends world-space points to `livePoints`; `livePath` rebuilds the Skia
53
+ // path off it. React state is untouched until the stroke commits on
54
+ // pointer-up — so high-frequency drawing never hits the JS thread.
55
+ const livePoints = useSharedValue([]);
56
+ // Set on a successful stroke end so onFinalize doesn't clear the live path
57
+ // out from under the (async) commit — the committed stroke replaces it once
58
+ // it lands in React state. Cleared paths only flicker if there's a gap, so
59
+ // we keep the live path until commit, and only onFinalize-clear on cancel.
60
+ const drawCommitted = useSharedValue(false);
61
+ // Id of a just-committed stroke whose live preview is still showing. We hold
62
+ // the preview until that stroke is actually present in `effectiveCanvas`
63
+ // (i.e. rendered + painted), THEN clear it — so the preview and the committed
64
+ // <StrokeElement> overlap for one frame instead of leaving a gap. Clearing
65
+ // synchronously in the commit callback would race the (async) React re-render
66
+ // and erase the preview a frame or two before the committed stroke paints,
67
+ // which reads as a flash on release. See the clear-on-paint effect below.
68
+ const [pendingClearStrokeId, setPendingClearStrokeId] = useState(null);
69
+ const livePath = useDerivedValue(() => {
70
+ 'worklet';
71
+ const pts = livePoints.value;
72
+ const path = Skia.Path.Make();
73
+ if (pts.length >= 2) {
74
+ path.moveTo(pts[0], pts[1]);
75
+ for (let i = 2; i < pts.length; i += 2) {
76
+ path.lineTo(pts[i], pts[i + 1]);
77
+ }
78
+ }
79
+ return path;
80
+ });
81
+ // Clear the freehand preview only once the committed stroke is present in the
82
+ // canvas (this effect runs after that render has painted), so the preview and
83
+ // the real <StrokeElement> overlap for a frame rather than leaving a gap —
84
+ // killing the flash that a synchronous clear caused on release.
85
+ const placedStrokes = state.effectiveCanvas.strokes;
86
+ useEffect(() => {
87
+ if (!pendingClearStrokeId)
88
+ return;
89
+ if (placedStrokes.some((s) => s.id === pendingClearStrokeId)) {
90
+ // Guard: only wipe the preview if a NEW stroke hasn't already begun (its
91
+ // onBegin flips drawCommitted back to false and has already repurposed
92
+ // livePoints for the new stroke — clearing here would erase it).
93
+ if (drawCommitted.value)
94
+ livePoints.value = [];
95
+ setPendingClearStrokeId(null);
96
+ }
97
+ }, [pendingClearStrokeId, placedStrokes, livePoints, drawCommitted]);
98
+ const freehand = state.activeTool?.freehand ?? null;
99
+ const panViewport = !!state.activeTool?.panViewport;
100
+ const dragSelection = state.activeTool?.dragSelection ?? null;
101
+ // UI-thread element drag (select tool). `dragX/dragY` is the live world-space
102
+ // translation the gesture worklet writes; `dragTransform` feeds the Skia
103
+ // group around the dragged element. `draggingId` (React state) gates which
104
+ // element gets the transform — flipping it to null in the same commit that
105
+ // bakes the translation into the element's points avoids any flicker.
106
+ const dragX = useSharedValue(0);
107
+ const dragY = useSharedValue(0);
108
+ const dragTransform = useDerivedValue(() => {
109
+ 'worklet';
110
+ return [{ translateX: dragX.value }, { translateY: dragY.value }];
111
+ });
112
+ const [draggingId, setDraggingId] = useState(null);
113
+ // The element grabbed at drag-start, carried to drag-end to build the commit.
114
+ const dragTargetRef = useRef(null);
115
+ // Set on a real drag end so onFinalize doesn't double-clear on the cancel path.
116
+ const dragEnded = useSharedValue(false);
117
+ // Slide-along-line (measurement annotation tile grabbed). `slidingId` (React
118
+ // state) gates which tile slides; `slideCtx` carries the line endpoints + the
119
+ // tile's position-at-grab so the overlay worklet can map the live drag delta
120
+ // to a clamped/snapped position along the line — pure UI-thread math, no
121
+ // per-frame JS. The Skia line stays static (draggingId is NOT set), only the
122
+ // tile moves.
123
+ const [slidingId, setSlidingId] = useState(null);
124
+ const slideCtx = useSharedValue({ ax: 0, ay: 0, bx: 0, by: 0, t0: 0.5 });
125
+ const slideTargetRef = useRef(null);
126
+ // Endpoint drag (resize/rotate a measurement annotation's line). `epDragId`
127
+ // (React state) marks which annotation's line renders from the live endpoints;
128
+ // `epCtx` carries the base endpoints, the tile's linePos, and which handle
129
+ // (0 = a, 1 = b) is moving, so the Skia line + overlay tile can be driven on
130
+ // the UI thread from `dragX`/`dragY`.
131
+ const [epDragId, setEpDragId] = useState(null);
132
+ const epCtx = useSharedValue({ ax: 0, ay: 0, bx: 0, by: 0, t0: 0.5, handle: 0 });
133
+ const epTargetRef = useRef(null);
134
+ // Live line endpoints during an endpoint drag: the grabbed handle follows the
135
+ // finger (dragX/dragY); the other stays put. Fed to the Skia <Line> for the
136
+ // dragged annotation.
137
+ const liveLineP1 = useDerivedValue(() => {
138
+ 'worklet';
139
+ const c = epCtx.value;
140
+ return c.handle === 0
141
+ ? { x: c.ax + dragX.value, y: c.ay + dragY.value }
142
+ : { x: c.ax, y: c.ay };
143
+ });
144
+ const liveLineP2 = useDerivedValue(() => {
145
+ 'worklet';
146
+ const c = epCtx.value;
147
+ return c.handle === 1
148
+ ? { x: c.bx + dragX.value, y: c.by + dragY.value }
149
+ : { x: c.bx, y: c.by };
150
+ });
151
+ // Screen-constant endpoint handle radius: a fixed pixel size divided by the
152
+ // live zoom (the handles are drawn inside the zoom-scaled world group).
153
+ const handleRadius = useDerivedValue(() => {
154
+ 'worklet';
155
+ return HANDLE_RADIUS_PX / zoom.value;
156
+ });
157
+ // Rectangle-annotation corner drag. `rectDragId` (React state) marks which
158
+ // annotation's rect renders from the live geometry; `rectCtx` carries the
159
+ // fixed (opposite) corner and the grabbed corner's start position so the
160
+ // Skia rect + the tile (locked to the live center) are driven on the UI
161
+ // thread from `dragX`/`dragY`.
162
+ const [rectDragId, setRectDragId] = useState(null);
163
+ const rectCtx = useSharedValue({ fx: 0, fy: 0, mx: 0, my: 0 });
164
+ const rectTargetRef = useRef(null);
165
+ // Live normalized rect during a corner drag (the grabbed corner follows the
166
+ // finger, the opposite corner stays put).
167
+ const liveRectX = useDerivedValue(() => {
168
+ 'worklet';
169
+ const c = rectCtx.value;
170
+ return Math.min(c.fx, c.mx + dragX.value);
171
+ });
172
+ const liveRectY = useDerivedValue(() => {
173
+ 'worklet';
174
+ const c = rectCtx.value;
175
+ return Math.min(c.fy, c.my + dragY.value);
176
+ });
177
+ const liveRectW = useDerivedValue(() => {
178
+ 'worklet';
179
+ const c = rectCtx.value;
180
+ return Math.abs(c.mx + dragX.value - c.fx);
181
+ });
182
+ const liveRectH = useDerivedValue(() => {
183
+ 'worklet';
184
+ const c = rectCtx.value;
185
+ return Math.abs(c.my + dragY.value - c.fy);
186
+ });
187
+ // Corner-scale resize (text shapes). `resizingId` (React state) gates which
188
+ // shape gets the live transform; `resizeCtx` carries the pivot (top-left
189
+ // anchor), the handle's grab-start position and the scale clamps (from
190
+ // DragSelectionConfig.hitTestResizeHandle) so the preview is a pure
191
+ // UI-thread scale-about-pivot driven by dragX/dragY. The commit on release
192
+ // goes through buildResizePatch, which clamps identically.
193
+ const [resizingId, setResizingId] = useState(null);
194
+ const resizeCtx = useSharedValue({ px: 0, py: 0, hx: 0, hy: 0, minS: 1, maxS: 1 });
195
+ const resizeTargetRef = useRef(null);
196
+ const resizeTransform = useDerivedValue(() => {
197
+ 'worklet';
198
+ // WORKLET TWIN of textGeometry.resizeScaleFromDrag — keep in sync.
199
+ const c = resizeCtx.value;
200
+ const bx = c.hx - c.px;
201
+ const by = c.hy - c.py;
202
+ const baseLen = Math.sqrt(bx * bx + by * by);
203
+ let s = 1;
204
+ if (baseLen > 0) {
205
+ const nx = bx + dragX.value;
206
+ const ny = by + dragY.value;
207
+ s = Math.sqrt(nx * nx + ny * ny) / baseLen;
208
+ }
209
+ s = Math.min(c.maxS, Math.max(c.minS, s));
210
+ return [
211
+ { translateX: c.px },
212
+ { translateY: c.py },
213
+ { scale: s },
214
+ { translateX: -c.px },
215
+ { translateY: -c.py },
216
+ ];
217
+ });
218
+ // Per-gesture refs so we always emit a matching down/move/up sequence.
219
+ const pointerIdRef = useRef(1);
220
+ const inFlightRef = useRef(null);
221
+ // >0 while a viewport (pan/zoom) gesture is active. Guards the JS→UI sync
222
+ // effect so an in-flight gesture's shared values are never clobbered by a
223
+ // late-flushing setViewport from a previous gesture.
224
+ const activeViewportGestures = useRef(0);
225
+ // Push JS-originated viewport changes (initial mount, zoomToFit, resetView)
226
+ // onto the UI thread. Gesture-driven changes already live in the shared
227
+ // values, so during a gesture this is skipped; the post-gesture re-sync
228
+ // writes back identical values (no visual jump).
229
+ useEffect(() => {
230
+ if (activeViewportGestures.current > 0)
231
+ return;
232
+ zoom.value = state.viewport.zoom;
233
+ panX.value = state.viewport.pan.x;
234
+ panY.value = state.viewport.pan.y;
235
+ }, [state.viewport, zoom, panX, panY]);
236
+ const gesture = useMemo(() => {
237
+ const buildEvent = (pointerId, screen) => ({
238
+ pointerId,
239
+ screen,
240
+ world: stateRef.current.ctx.viewport.screenToWorld(screen),
241
+ });
242
+ // Commit the final viewport to the JS snapshot once a gesture ends, so
243
+ // tools' screenToWorld/worldToScreen reflect the new pan/zoom.
244
+ const syncViewport = (next) => {
245
+ stateRef.current.setViewport(next);
246
+ };
247
+ const beginViewportGesture = () => {
248
+ activeViewportGestures.current += 1;
249
+ };
250
+ const endViewportGesture = () => {
251
+ activeViewportGestures.current = Math.max(0, activeViewportGestures.current - 1);
252
+ };
253
+ const toolPan = Gesture.Pan()
254
+ .minPointers(1)
255
+ .maxPointers(1)
256
+ .runOnJS(true)
257
+ .onBegin((e) => {
258
+ const id = pointerIdRef.current++;
259
+ const screen = { x: e.x, y: e.y };
260
+ inFlightRef.current = { id, lastScreen: screen };
261
+ stateRef.current.dispatchPointerDown(buildEvent(id, screen));
262
+ })
263
+ .onUpdate((e) => {
264
+ const f = inFlightRef.current;
265
+ if (!f)
266
+ return;
267
+ const screen = { x: e.x, y: e.y };
268
+ f.lastScreen = screen;
269
+ stateRef.current.dispatchPointerMove(buildEvent(f.id, screen));
270
+ })
271
+ .onEnd((e) => {
272
+ const f = inFlightRef.current;
273
+ if (!f)
274
+ return;
275
+ stateRef.current.dispatchPointerUp(buildEvent(f.id, { x: e.x, y: e.y }));
276
+ inFlightRef.current = null;
277
+ })
278
+ .onFinalize(() => {
279
+ if (inFlightRef.current) {
280
+ stateRef.current.dispatchPointerCancel();
281
+ inFlightRef.current = null;
282
+ }
283
+ });
284
+ const tap = Gesture.Tap()
285
+ .maxDuration(250)
286
+ .runOnJS(true)
287
+ .onEnd((e) => {
288
+ const st = stateRef.current;
289
+ const screen = { x: e.x, y: e.y };
290
+ // Freehand tools (pen/marker/highlighter): a tap places a single dot.
291
+ // The draw Pan needs movement to activate, so a pure tap never reaches
292
+ // it — handle the dot here. Commit a degenerate two-point path (the
293
+ // same world point twice); StrokeElement strokes it with a round cap,
294
+ // which renders as a filled dot of diameter = stroke width.
295
+ const fh = st.activeTool?.freehand;
296
+ if (fh) {
297
+ const world = st.ctx.viewport.screenToWorld(screen);
298
+ // A zero-length dot has no direction ('arrow') and is invisible with a
299
+ // 'butt' cap, so coerce both to 'round' (a 'square' dot is fine).
300
+ const cap = fh.cap ?? 'round';
301
+ const stroke = {
302
+ id: makeStrokeId(),
303
+ layerId: st.ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
304
+ tool: fh.variant,
305
+ color: fh.color,
306
+ width: fh.width,
307
+ cap: cap === 'butt' || cap === 'arrow' ? 'round' : cap,
308
+ points: [world.x, world.y, world.x, world.y],
309
+ createdAt: Date.now(),
310
+ };
311
+ st.ctx.commit({ ops: [{ op: 'addStroke', stroke }] });
312
+ return;
313
+ }
314
+ // Otherwise synthesize a down+up sequence so tools that only listen to
315
+ // onPointerUp (e.g. measurement stamp) still fire.
316
+ const id = pointerIdRef.current++;
317
+ st.dispatchPointerDown(buildEvent(id, screen));
318
+ st.dispatchPointerUp(buildEvent(id, screen));
319
+ });
320
+ // Viewport pan — runs on the UI thread (no runOnJS), mutating the shared
321
+ // viewport directly. Mirrors viewport.ts `panBy`. Used for both the
322
+ // two-finger pan (always available) and the one-finger Hand tool.
323
+ const buildViewportPan = (minP, maxP) => Gesture.Pan()
324
+ .minPointers(minP)
325
+ .maxPointers(maxP)
326
+ .onBegin(() => {
327
+ 'worklet';
328
+ runOnJS(beginViewportGesture)();
329
+ })
330
+ .onChange((e) => {
331
+ 'worklet';
332
+ panX.value -= e.changeX / zoom.value;
333
+ panY.value -= e.changeY / zoom.value;
334
+ })
335
+ .onEnd(() => {
336
+ 'worklet';
337
+ runOnJS(syncViewport)({
338
+ zoom: zoom.value,
339
+ pan: { x: panX.value, y: panY.value },
340
+ });
341
+ })
342
+ .onFinalize(() => {
343
+ 'worklet';
344
+ runOnJS(endViewportGesture)();
345
+ });
346
+ const viewportPan = buildViewportPan(2, 2);
347
+ // Pinch-to-zoom about the focal point — UI thread. Mirrors viewport.ts
348
+ // `zoomAt`: keep the world point under the focal screen point fixed.
349
+ const pinch = Gesture.Pinch()
350
+ .onBegin(() => {
351
+ 'worklet';
352
+ pinchStartZoom.value = zoom.value;
353
+ runOnJS(beginViewportGesture)();
354
+ })
355
+ .onUpdate((e) => {
356
+ 'worklet';
357
+ const clamped = Math.min(50, Math.max(0.05, pinchStartZoom.value * e.scale));
358
+ // World point currently under the focal screen point (using the live
359
+ // zoom/pan), then re-anchor pan so it stays put at the new zoom.
360
+ const focalWorldX = e.focalX / zoom.value + panX.value;
361
+ const focalWorldY = e.focalY / zoom.value + panY.value;
362
+ panX.value = focalWorldX - e.focalX / clamped;
363
+ panY.value = focalWorldY - e.focalY / clamped;
364
+ zoom.value = clamped;
365
+ })
366
+ .onEnd(() => {
367
+ 'worklet';
368
+ runOnJS(syncViewport)({
369
+ zoom: zoom.value,
370
+ pan: { x: panX.value, y: panY.value },
371
+ });
372
+ })
373
+ .onFinalize(() => {
374
+ 'worklet';
375
+ runOnJS(endViewportGesture)();
376
+ });
377
+ // Freehand drawing (pen/marker/highlighter) — one finger, UI thread.
378
+ // Accumulates world-space points into `livePoints` (which feeds the live
379
+ // Skia path) and commits the finished stroke once, on pointer-up. No JS
380
+ // hop or React render per sample.
381
+ const buildDrawPan = (fh) => {
382
+ const minDistSq = fh.minSampleDistance * fh.minSampleDistance;
383
+ const commitFreehand = (worldPoints) => {
384
+ // Need at least two distinct samples to make a stroke.
385
+ if (worldPoints.length >= 4) {
386
+ const st = stateRef.current;
387
+ const stroke = {
388
+ id: makeStrokeId(),
389
+ layerId: st.ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
390
+ tool: fh.variant,
391
+ color: fh.color,
392
+ width: fh.width,
393
+ cap: fh.cap ?? 'round',
394
+ ...(fh.dash && { dash: true }),
395
+ points: worldPoints,
396
+ createdAt: Date.now(),
397
+ };
398
+ st.ctx.commit({ ops: [{ op: 'addStroke', stroke }] });
399
+ // Keep the live preview up until this stroke actually paints (handled
400
+ // by the clear-on-paint effect), so there's never a gap on release.
401
+ setPendingClearStrokeId(stroke.id);
402
+ }
403
+ else {
404
+ // Nothing committed (too few samples) — no stroke to wait for, so drop
405
+ // the stray preview immediately.
406
+ livePoints.value = [];
407
+ }
408
+ };
409
+ return Gesture.Pan()
410
+ .minPointers(1)
411
+ .maxPointers(1)
412
+ .onBegin((e) => {
413
+ 'worklet';
414
+ drawCommitted.value = false;
415
+ // screen → world using the live viewport (single finger, so the
416
+ // viewport isn't moving here).
417
+ livePoints.value = [
418
+ e.x / zoom.value + panX.value,
419
+ e.y / zoom.value + panY.value,
420
+ ];
421
+ })
422
+ .onChange((e) => {
423
+ 'worklet';
424
+ const wx = e.x / zoom.value + panX.value;
425
+ const wy = e.y / zoom.value + panY.value;
426
+ const pts = livePoints.value;
427
+ const n = pts.length;
428
+ if (n >= 2) {
429
+ const dx = wx - pts[n - 2];
430
+ const dy = wy - pts[n - 1];
431
+ if (dx * dx + dy * dy < minDistSq)
432
+ return;
433
+ }
434
+ livePoints.value = [...pts, wx, wy];
435
+ })
436
+ .onEnd(() => {
437
+ 'worklet';
438
+ drawCommitted.value = true;
439
+ runOnJS(commitFreehand)([...livePoints.value]);
440
+ })
441
+ .onFinalize(() => {
442
+ 'worklet';
443
+ // Only clear here on cancel (no onEnd). On success, commitFreehand
444
+ // clears once the stroke is committed, avoiding a flicker.
445
+ if (!drawCommitted.value)
446
+ livePoints.value = [];
447
+ });
448
+ };
449
+ // Element drag (select tool) — one finger, UI thread. Hit-tests on the JS
450
+ // thread at drag-start (needs the document), then translates the hit
451
+ // element via a shared-value Skia transform and commits once on release.
452
+ // `draggingId` gates which element gets the transform; clearing it in the
453
+ // same tick as the commit avoids any flicker (see DragSelectionConfig).
454
+ const buildSelectDragPan = (cfg) => {
455
+ const beginSelectDrag = (screen) => {
456
+ const st = stateRef.current;
457
+ const world = st.ctx.viewport.screenToWorld(screen);
458
+ const zoomNow = st.ctx.viewport.state.zoom;
459
+ // Endpoint handles show only on the selected annotation, so check the
460
+ // current selection's handles before the general hit-test.
461
+ const selId = st.ctx.selection?.ids[0];
462
+ if (selId) {
463
+ const handle = cfg.hitTestHandle?.(st.ctx.document, selId, world, zoomNow);
464
+ if (handle) {
465
+ const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
466
+ if (m?.line) {
467
+ epCtx.value = {
468
+ ax: m.line.a.x,
469
+ ay: m.line.a.y,
470
+ bx: m.line.b.x,
471
+ by: m.line.b.y,
472
+ t0: m.linePos ?? 0.5,
473
+ handle: handle === 'a' ? 0 : 1,
474
+ };
475
+ epTargetRef.current = { id: selId, handle };
476
+ setEpDragId(selId);
477
+ return;
478
+ }
479
+ }
480
+ // Corner resize handle (text shapes) — also selected-element-only.
481
+ const resizeGeom = cfg.hitTestResizeHandle?.(st.ctx.document, selId, world, zoomNow);
482
+ if (resizeGeom) {
483
+ resizeCtx.value = {
484
+ px: resizeGeom.pivot.x,
485
+ py: resizeGeom.pivot.y,
486
+ hx: resizeGeom.handle.x,
487
+ hy: resizeGeom.handle.y,
488
+ minS: resizeGeom.minScale,
489
+ maxS: resizeGeom.maxScale,
490
+ };
491
+ resizeTargetRef.current = { id: selId };
492
+ setResizingId(selId);
493
+ return;
494
+ }
495
+ // Rectangle-annotation corner handle — also selected-element-only.
496
+ const rectCornerHit = cfg.hitTestRectCorner?.(st.ctx.document, selId, world, zoomNow);
497
+ if (rectCornerHit) {
498
+ rectCtx.value = {
499
+ fx: rectCornerHit.fixed.x,
500
+ fy: rectCornerHit.fixed.y,
501
+ mx: rectCornerHit.moving.x,
502
+ my: rectCornerHit.moving.y,
503
+ };
504
+ rectTargetRef.current = { id: selId, corner: rectCornerHit.corner };
505
+ setRectDragId(selId);
506
+ return;
507
+ }
508
+ }
509
+ const hit = cfg.hitTest(st.ctx.document, world, zoomNow);
510
+ if (!hit) {
511
+ st.ctx.setSelection(null);
512
+ dragTargetRef.current = null;
513
+ return;
514
+ }
515
+ st.ctx.setSelection({ ids: [hit.id] });
516
+ // Grabbing a line annotation's tile slides it; otherwise group-move.
517
+ const grab = hit.kind === 'measurement'
518
+ ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow)
519
+ : 'move';
520
+ if (grab === 'slide') {
521
+ const m = st.ctx.document.placedMeasurements.find((x) => x.id === hit.id);
522
+ if (m?.line) {
523
+ slideCtx.value = {
524
+ ax: m.line.a.x,
525
+ ay: m.line.a.y,
526
+ bx: m.line.b.x,
527
+ by: m.line.b.y,
528
+ t0: m.linePos ?? 0.5,
529
+ };
530
+ slideTargetRef.current = { id: hit.id };
531
+ setSlidingId(hit.id);
532
+ return;
533
+ }
534
+ }
535
+ dragTargetRef.current = hit;
536
+ setDraggingId(hit.id);
537
+ };
538
+ const endSelectDrag = (dx, dy) => {
539
+ const st = stateRef.current;
540
+ // Resize commit: scale the shape by the final drag (clamped in
541
+ // buildResizePatch exactly like the live preview).
542
+ const rT = resizeTargetRef.current;
543
+ if (rT) {
544
+ if (dx !== 0 || dy !== 0) {
545
+ const patch = cfg.buildResizePatch?.(st.ctx.document, rT.id, {
546
+ x: dx,
547
+ y: dy,
548
+ });
549
+ if (patch)
550
+ st.ctx.commit(patch);
551
+ }
552
+ resizeTargetRef.current = null;
553
+ setResizingId(null);
554
+ return;
555
+ }
556
+ // Rect-corner commit: drag the grabbed corner by the world delta
557
+ // (opposite corner fixed); anchor re-centers in the same patch.
558
+ const rectT = rectTargetRef.current;
559
+ if (rectT) {
560
+ if (dx !== 0 || dy !== 0) {
561
+ const patch = cfg.buildRectCornerPatch?.(st.ctx.document, rectT.id, rectT.corner, { x: dx, y: dy });
562
+ if (patch)
563
+ st.ctx.commit(patch);
564
+ }
565
+ rectTargetRef.current = null;
566
+ setRectDragId(null);
567
+ return;
568
+ }
569
+ // Endpoint commit: move the grabbed endpoint by the world delta.
570
+ const epT = epTargetRef.current;
571
+ if (epT) {
572
+ if (dx !== 0 || dy !== 0) {
573
+ const patch = cfg.buildEndpointPatch?.(st.ctx.document, epT.id, epT.handle, { x: dx, y: dy });
574
+ if (patch)
575
+ st.ctx.commit(patch);
576
+ }
577
+ epTargetRef.current = null;
578
+ setEpDragId(null);
579
+ return;
580
+ }
581
+ // Slide commit: map the final world delta to a clamped/snapped linePos.
582
+ const slideT = slideTargetRef.current;
583
+ if (slideT) {
584
+ if (dx !== 0 || dy !== 0) {
585
+ const patch = cfg.buildSlidePatch?.(st.ctx.document, slideT.id, { x: dx, y: dy }, st.ctx.viewport.state.zoom);
586
+ if (patch)
587
+ st.ctx.commit(patch);
588
+ }
589
+ slideTargetRef.current = null;
590
+ setSlidingId(null);
591
+ return;
592
+ }
593
+ const target = dragTargetRef.current;
594
+ if (target && (dx !== 0 || dy !== 0)) {
595
+ const patch = cfg.buildTranslatePatch(st.ctx.document, target.id, target.kind, {
596
+ x: dx,
597
+ y: dy,
598
+ });
599
+ if (patch)
600
+ st.ctx.commit(patch);
601
+ }
602
+ // Unwrap in the same render batch as the commit: the element lands at
603
+ // its new baked-in points with no transform. The stale dragX/dragY are
604
+ // never applied while draggingId is null, and are reset on next start.
605
+ dragTargetRef.current = null;
606
+ setDraggingId(null);
607
+ };
608
+ const cancelSelectDrag = () => {
609
+ // No commit — dropping the gating ids snaps everything back.
610
+ dragTargetRef.current = null;
611
+ slideTargetRef.current = null;
612
+ epTargetRef.current = null;
613
+ resizeTargetRef.current = null;
614
+ rectTargetRef.current = null;
615
+ setDraggingId(null);
616
+ setSlidingId(null);
617
+ setEpDragId(null);
618
+ setResizingId(null);
619
+ setRectDragId(null);
620
+ };
621
+ return Gesture.Pan()
622
+ .minPointers(1)
623
+ .maxPointers(1)
624
+ .onStart((e) => {
625
+ 'worklet';
626
+ dragEnded.value = false;
627
+ dragX.value = 0;
628
+ dragY.value = 0;
629
+ // Hit-test at the touch-down point — onStart fires only after the
630
+ // pan threshold, so back out the accumulated translation.
631
+ runOnJS(beginSelectDrag)({
632
+ x: e.x - e.translationX,
633
+ y: e.y - e.translationY,
634
+ });
635
+ })
636
+ .onChange((e) => {
637
+ 'worklet';
638
+ // World-space delta from the drag origin. Applied to the element
639
+ // only once draggingId is set; otherwise it affects nothing.
640
+ dragX.value = e.translationX / zoom.value;
641
+ dragY.value = e.translationY / zoom.value;
642
+ })
643
+ .onEnd(() => {
644
+ 'worklet';
645
+ dragEnded.value = true;
646
+ runOnJS(endSelectDrag)(dragX.value, dragY.value);
647
+ })
648
+ .onFinalize(() => {
649
+ 'worklet';
650
+ if (!dragEnded.value)
651
+ runOnJS(cancelSelectDrag)();
652
+ });
653
+ };
654
+ // One finger, by active tool:
655
+ // - freehand (pen/marker/highlighter) → draw on the UI thread
656
+ // - Hand → pan the viewport on the UI thread (live, no JS round-trip)
657
+ // - select → drag the hit element on the UI thread
658
+ // - everything else → dispatch pointer events on the JS thread
659
+ const oneFinger = freehand
660
+ ? buildDrawPan(freehand)
661
+ : panViewport
662
+ ? buildViewportPan(1, 1)
663
+ : dragSelection
664
+ ? buildSelectDragPan(dragSelection)
665
+ : toolPan;
666
+ return Gesture.Race(tap, Gesture.Simultaneous(viewportPan, pinch), oneFinger);
667
+ }, [
668
+ zoom,
669
+ panX,
670
+ panY,
671
+ pinchStartZoom,
672
+ livePoints,
673
+ freehand,
674
+ panViewport,
675
+ dragSelection,
676
+ dragX,
677
+ dragY,
678
+ dragEnded,
679
+ slideCtx,
680
+ epCtx,
681
+ resizeCtx,
682
+ rectCtx,
683
+ ]);
684
+ const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
685
+ const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
686
+ const { renderMeasurementStamp, onMeasurementStampPress, selection } = props;
687
+ return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
688
+ width,
689
+ height,
690
+ effectiveCanvas: state.effectiveCanvas,
691
+ worldTransform,
692
+ resolveImageUrl,
693
+ valueFont,
694
+ // Freehand drawing runs on the UI thread via `livePreview`, so the
695
+ // JS-state pen preview is unused on native.
696
+ penDrawingStroke: null,
697
+ livePreview: freehand
698
+ ? {
699
+ path: livePath,
700
+ color: freehand.color,
701
+ width: freehand.width,
702
+ cap: freehand.cap ?? 'round',
703
+ dash: freehand.dash ?? false,
704
+ opacity: freehand.variant === 'highlighter' ? 0.3 : 1,
705
+ }
706
+ : null,
707
+ draggingId,
708
+ dragTransform,
709
+ resizingId,
710
+ resizeTransform,
711
+ selectedId: selection?.ids[0] ?? null,
712
+ endpointDragId: epDragId,
713
+ liveLineP1,
714
+ liveLineP2,
715
+ rectDragId,
716
+ liveRect: {
717
+ x: liveRectX,
718
+ y: liveRectY,
719
+ width: liveRectW,
720
+ height: liveRectH,
721
+ },
722
+ handleRadius,
723
+ customPreview,
724
+ }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
725
+ ? (state.measurementsById.get(placed.measurementId) ?? null)
726
+ : null, selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, onStampPress: onMeasurementStampPress, onRemove: () => {
727
+ const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
728
+ state.ctx.commit({ ops });
729
+ if (!keepSelection)
730
+ state.ctx.setSelection(null);
731
+ } }, placed.id))) }))] }));
732
+ };
733
+ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, onStampPress, onRemove, }) => {
734
+ const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
735
+ const half = size / 2;
736
+ const anchorX = placed.anchor.x;
737
+ const anchorY = placed.anchor.y;
738
+ // doc → screen each frame, on the UI thread. Position is a translate
739
+ // transform (cheap, no layout) so the pin stays glued to its anchor. While
740
+ // dragging, the world-space drag offset is folded in; while sliding, the
741
+ // position is mapped onto the line. `dragging`/`sliding` are captured props,
742
+ // so the moment they flip false (on commit) the worklet drops the live offset
743
+ // in the same render the new anchor arrives — no flicker.
744
+ const animatedStyle = useAnimatedStyle(() => {
745
+ 'worklet';
746
+ let worldX = anchorX;
747
+ let worldY = anchorY;
748
+ if (sliding) {
749
+ // WORKLET TWIN of selectTool.buildSlidePatch — keep in sync.
750
+ // t = clamp(t0 + (delta·ab)/|ab|²), snap to center with a screen-px detent.
751
+ const c = slideCtx.value;
752
+ const abx = c.bx - c.ax;
753
+ const aby = c.by - c.ay;
754
+ const lenSq = abx * abx + aby * aby;
755
+ let t = lenSq === 0
756
+ ? c.t0
757
+ : c.t0 + (dragX.value * abx + dragY.value * aby) / lenSq;
758
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
759
+ const lineLen = Math.sqrt(lenSq);
760
+ const snapT = lineLen > 0 ? 12 / zoom.value / lineLen : 0; // SLIDE_SNAP_PX = 12
761
+ if (Math.abs(t - 0.5) <= snapT)
762
+ t = 0.5;
763
+ worldX = c.ax + abx * t;
764
+ worldY = c.ay + aby * t;
765
+ }
766
+ else if (endpointDragging) {
767
+ // Tile stays at its linePos along the LIVE line: fold the drag delta into
768
+ // the moving endpoint (handle 0 = a, 1 = b), then lerp at t0.
769
+ const c = epCtx.value;
770
+ const ax = c.handle === 0 ? c.ax + dragX.value : c.ax;
771
+ const ay = c.handle === 0 ? c.ay + dragY.value : c.ay;
772
+ const bx = c.handle === 1 ? c.bx + dragX.value : c.bx;
773
+ const by = c.handle === 1 ? c.by + dragY.value : c.by;
774
+ worldX = ax + (bx - ax) * c.t0;
775
+ worldY = ay + (by - ay) * c.t0;
776
+ }
777
+ else if (rectResizing) {
778
+ // Tile stays at the LIVE rect's center: midpoint of the fixed corner
779
+ // and the grabbed corner with the drag delta folded in.
780
+ const c = rectCtx.value;
781
+ worldX = (c.fx + c.mx + dragX.value) / 2;
782
+ worldY = (c.fy + c.my + dragY.value) / 2;
783
+ }
784
+ else if (dragging) {
785
+ worldX = anchorX + dragX.value;
786
+ worldY = anchorY + dragY.value;
787
+ }
788
+ const cx = (worldX - panX.value) * zoom.value;
789
+ const cy = (worldY - panY.value) * zoom.value;
790
+ return {
791
+ transform: [{ translateX: cx - half }, { translateY: cy - half }],
792
+ };
793
+ });
794
+ return (_jsxs(Animated.View, { pointerEvents: "box-none", style: [
795
+ { position: 'absolute', left: 0, top: 0, width: size, height: size },
796
+ animatedStyle,
797
+ ], children: [_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: renderMeasurementStamp({
798
+ placed,
799
+ measurement,
800
+ selected,
801
+ size,
802
+ zoom: zoomSnapshot,
803
+ }) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), style: StyleSheet.absoluteFill })), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", hitSlop: 10, onPress: onRemove, style: {
804
+ position: 'absolute',
805
+ top: -8,
806
+ right: -8,
807
+ width: 36,
808
+ height: 36,
809
+ } }))] }));
810
+ };