@reekon-tools/boldr-utils 1.6.10 → 1.6.12

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 (81) hide show
  1. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.d.ts +2 -2
  2. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.js +19 -14
  3. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.native.d.ts +2 -2
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +668 -0
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +46 -0
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +129 -0
  7. package/dist/{canvas → annotation/canvas}/Tool.d.ts +23 -1
  8. package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.d.ts +2 -2
  9. package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.js +13 -6
  10. package/dist/annotation/canvas/elements/ShapeElement.d.ts +7 -0
  11. package/dist/{canvas → annotation/canvas}/elements/ShapeElement.js +5 -3
  12. package/dist/annotation/canvas/elements/StrokeElement.d.ts +7 -0
  13. package/dist/annotation/canvas/elements/StrokeElement.js +40 -0
  14. package/dist/annotation/canvas/measurementGeometry.d.ts +23 -0
  15. package/dist/annotation/canvas/measurementGeometry.js +74 -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 +4 -0
  21. package/dist/annotation/canvas/strokeGeometry.js +33 -0
  22. package/dist/{canvas → annotation/canvas}/tools/measurementStampTool.d.ts +1 -1
  23. package/dist/{canvas → annotation/canvas}/tools/measurementStampTool.js +1 -1
  24. package/dist/{canvas → annotation/canvas}/tools/panTool.js +3 -0
  25. package/dist/{canvas → annotation/canvas}/tools/penTool.d.ts +2 -1
  26. package/dist/{canvas → annotation/canvas}/tools/penTool.js +32 -5
  27. package/dist/annotation/canvas/tools/selectTool.js +310 -0
  28. package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.d.ts +9 -2
  29. package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.js +115 -1
  30. package/dist/{canvas → annotation/canvas}/viewport.d.ts +1 -1
  31. package/dist/{data → annotation/data}/AnnotationDataProvider.d.ts +1 -1
  32. package/dist/{data → annotation/data}/InMemoryAnnotationProvider.d.ts +1 -1
  33. package/dist/{data → annotation/data}/InMemoryAnnotationProvider.js +1 -1
  34. package/dist/{data → annotation/data}/canvasPersistence.d.ts +1 -1
  35. package/dist/{data → annotation/data}/canvasPersistence.js +1 -1
  36. package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.d.ts +1 -1
  37. package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.js +2 -2
  38. package/dist/exports.d.ts +21 -19
  39. package/dist/exports.js +16 -14
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.js +2 -2
  42. package/dist/index.native.d.ts +1 -1
  43. package/dist/index.native.js +1 -1
  44. package/dist/types/annotation.d.ts +15 -3
  45. package/dist/{hooks → utils}/useParseMeasurement.js +1 -1
  46. package/package.json +1 -1
  47. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  48. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  49. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  50. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  51. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  52. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  53. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  54. package/dist/canvas/elements/StrokeElement.js +0 -18
  55. package/dist/canvas/stampLayout.d.ts +0 -5
  56. package/dist/canvas/stampLayout.js +0 -14
  57. package/dist/canvas/tools/selectTool.js +0 -182
  58. package/dist/utils/evaluateFormula.d.ts +0 -20
  59. package/dist/utils/evaluateFormula.js +0 -31
  60. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.d.ts +0 -0
  61. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.js +0 -0
  62. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.native.d.ts +0 -0
  63. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.native.js +0 -0
  64. /package/dist/{canvas → annotation/canvas}/Tool.js +0 -0
  65. /package/dist/{canvas → annotation/canvas}/measurementPicker.js +0 -0
  66. /package/dist/{canvas → annotation/canvas}/measurementStampOverlay.js +0 -0
  67. /package/dist/{canvas → annotation/canvas}/pointerAdapter.d.ts +0 -0
  68. /package/dist/{canvas → annotation/canvas}/pointerAdapter.js +0 -0
  69. /package/dist/{canvas → annotation/canvas}/tools/panTool.d.ts +0 -0
  70. /package/dist/{canvas → annotation/canvas}/tools/selectTool.d.ts +0 -0
  71. /package/dist/{canvas → annotation/canvas}/viewport.js +0 -0
  72. /package/dist/{data → annotation/data}/AnnotationDataContext.d.ts +0 -0
  73. /package/dist/{data → annotation/data}/AnnotationDataContext.js +0 -0
  74. /package/dist/{data → annotation/data}/AnnotationDataProvider.js +0 -0
  75. /package/dist/{data → annotation/data}/hooks/useAnnotationDoc.d.ts +0 -0
  76. /package/dist/{data → annotation/data}/hooks/useAnnotationDoc.js +0 -0
  77. /package/dist/{data → annotation/data}/hooks/useAnnotationList.d.ts +0 -0
  78. /package/dist/{data → annotation/data}/hooks/useAnnotationList.js +0 -0
  79. /package/dist/{data → annotation/data}/hooks/useAnnotationMutations.d.ts +0 -0
  80. /package/dist/{data → annotation/data}/hooks/useAnnotationMutations.js +0 -0
  81. /package/dist/{hooks → utils}/useParseMeasurement.d.ts +0 -0
@@ -0,0 +1,668 @@
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
+ // Per-gesture refs so we always emit a matching down/move/up sequence.
158
+ const pointerIdRef = useRef(1);
159
+ const inFlightRef = useRef(null);
160
+ // >0 while a viewport (pan/zoom) gesture is active. Guards the JS→UI sync
161
+ // effect so an in-flight gesture's shared values are never clobbered by a
162
+ // late-flushing setViewport from a previous gesture.
163
+ const activeViewportGestures = useRef(0);
164
+ // Push JS-originated viewport changes (initial mount, zoomToFit, resetView)
165
+ // onto the UI thread. Gesture-driven changes already live in the shared
166
+ // values, so during a gesture this is skipped; the post-gesture re-sync
167
+ // writes back identical values (no visual jump).
168
+ useEffect(() => {
169
+ if (activeViewportGestures.current > 0)
170
+ return;
171
+ zoom.value = state.viewport.zoom;
172
+ panX.value = state.viewport.pan.x;
173
+ panY.value = state.viewport.pan.y;
174
+ }, [state.viewport, zoom, panX, panY]);
175
+ const gesture = useMemo(() => {
176
+ const buildEvent = (pointerId, screen) => ({
177
+ pointerId,
178
+ screen,
179
+ world: stateRef.current.ctx.viewport.screenToWorld(screen),
180
+ });
181
+ // Commit the final viewport to the JS snapshot once a gesture ends, so
182
+ // tools' screenToWorld/worldToScreen reflect the new pan/zoom.
183
+ const syncViewport = (next) => {
184
+ stateRef.current.setViewport(next);
185
+ };
186
+ const beginViewportGesture = () => {
187
+ activeViewportGestures.current += 1;
188
+ };
189
+ const endViewportGesture = () => {
190
+ activeViewportGestures.current = Math.max(0, activeViewportGestures.current - 1);
191
+ };
192
+ const toolPan = Gesture.Pan()
193
+ .minPointers(1)
194
+ .maxPointers(1)
195
+ .runOnJS(true)
196
+ .onBegin((e) => {
197
+ const id = pointerIdRef.current++;
198
+ const screen = { x: e.x, y: e.y };
199
+ inFlightRef.current = { id, lastScreen: screen };
200
+ stateRef.current.dispatchPointerDown(buildEvent(id, screen));
201
+ })
202
+ .onUpdate((e) => {
203
+ const f = inFlightRef.current;
204
+ if (!f)
205
+ return;
206
+ const screen = { x: e.x, y: e.y };
207
+ f.lastScreen = screen;
208
+ stateRef.current.dispatchPointerMove(buildEvent(f.id, screen));
209
+ })
210
+ .onEnd((e) => {
211
+ const f = inFlightRef.current;
212
+ if (!f)
213
+ return;
214
+ stateRef.current.dispatchPointerUp(buildEvent(f.id, { x: e.x, y: e.y }));
215
+ inFlightRef.current = null;
216
+ })
217
+ .onFinalize(() => {
218
+ if (inFlightRef.current) {
219
+ stateRef.current.dispatchPointerCancel();
220
+ inFlightRef.current = null;
221
+ }
222
+ });
223
+ const tap = Gesture.Tap()
224
+ .maxDuration(250)
225
+ .runOnJS(true)
226
+ .onEnd((e) => {
227
+ const st = stateRef.current;
228
+ const screen = { x: e.x, y: e.y };
229
+ // Freehand tools (pen/marker/highlighter): a tap places a single dot.
230
+ // The draw Pan needs movement to activate, so a pure tap never reaches
231
+ // it — handle the dot here. Commit a degenerate two-point path (the
232
+ // same world point twice); StrokeElement strokes it with a round cap,
233
+ // which renders as a filled dot of diameter = stroke width.
234
+ const fh = st.activeTool?.freehand;
235
+ if (fh) {
236
+ const world = st.ctx.viewport.screenToWorld(screen);
237
+ // A zero-length dot has no direction ('arrow') and is invisible with a
238
+ // 'butt' cap, so coerce both to 'round' (a 'square' dot is fine).
239
+ const cap = fh.cap ?? 'round';
240
+ const stroke = {
241
+ id: makeStrokeId(),
242
+ layerId: st.ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
243
+ tool: fh.variant,
244
+ color: fh.color,
245
+ width: fh.width,
246
+ cap: cap === 'butt' || cap === 'arrow' ? 'round' : cap,
247
+ points: [world.x, world.y, world.x, world.y],
248
+ createdAt: Date.now(),
249
+ };
250
+ st.ctx.commit({ ops: [{ op: 'addStroke', stroke }] });
251
+ return;
252
+ }
253
+ // Otherwise synthesize a down+up sequence so tools that only listen to
254
+ // onPointerUp (e.g. measurement stamp) still fire.
255
+ const id = pointerIdRef.current++;
256
+ st.dispatchPointerDown(buildEvent(id, screen));
257
+ st.dispatchPointerUp(buildEvent(id, screen));
258
+ });
259
+ // Viewport pan — runs on the UI thread (no runOnJS), mutating the shared
260
+ // viewport directly. Mirrors viewport.ts `panBy`. Used for both the
261
+ // two-finger pan (always available) and the one-finger Hand tool.
262
+ const buildViewportPan = (minP, maxP) => Gesture.Pan()
263
+ .minPointers(minP)
264
+ .maxPointers(maxP)
265
+ .onBegin(() => {
266
+ 'worklet';
267
+ runOnJS(beginViewportGesture)();
268
+ })
269
+ .onChange((e) => {
270
+ 'worklet';
271
+ panX.value -= e.changeX / zoom.value;
272
+ panY.value -= e.changeY / zoom.value;
273
+ })
274
+ .onEnd(() => {
275
+ 'worklet';
276
+ runOnJS(syncViewport)({
277
+ zoom: zoom.value,
278
+ pan: { x: panX.value, y: panY.value },
279
+ });
280
+ })
281
+ .onFinalize(() => {
282
+ 'worklet';
283
+ runOnJS(endViewportGesture)();
284
+ });
285
+ const viewportPan = buildViewportPan(2, 2);
286
+ // Pinch-to-zoom about the focal point — UI thread. Mirrors viewport.ts
287
+ // `zoomAt`: keep the world point under the focal screen point fixed.
288
+ const pinch = Gesture.Pinch()
289
+ .onBegin(() => {
290
+ 'worklet';
291
+ pinchStartZoom.value = zoom.value;
292
+ runOnJS(beginViewportGesture)();
293
+ })
294
+ .onUpdate((e) => {
295
+ 'worklet';
296
+ const clamped = Math.min(50, Math.max(0.05, pinchStartZoom.value * e.scale));
297
+ // World point currently under the focal screen point (using the live
298
+ // zoom/pan), then re-anchor pan so it stays put at the new zoom.
299
+ const focalWorldX = e.focalX / zoom.value + panX.value;
300
+ const focalWorldY = e.focalY / zoom.value + panY.value;
301
+ panX.value = focalWorldX - e.focalX / clamped;
302
+ panY.value = focalWorldY - e.focalY / clamped;
303
+ zoom.value = clamped;
304
+ })
305
+ .onEnd(() => {
306
+ 'worklet';
307
+ runOnJS(syncViewport)({
308
+ zoom: zoom.value,
309
+ pan: { x: panX.value, y: panY.value },
310
+ });
311
+ })
312
+ .onFinalize(() => {
313
+ 'worklet';
314
+ runOnJS(endViewportGesture)();
315
+ });
316
+ // Freehand drawing (pen/marker/highlighter) — one finger, UI thread.
317
+ // Accumulates world-space points into `livePoints` (which feeds the live
318
+ // Skia path) and commits the finished stroke once, on pointer-up. No JS
319
+ // hop or React render per sample.
320
+ const buildDrawPan = (fh) => {
321
+ const minDistSq = fh.minSampleDistance * fh.minSampleDistance;
322
+ const commitFreehand = (worldPoints) => {
323
+ // Need at least two distinct samples to make a stroke.
324
+ if (worldPoints.length >= 4) {
325
+ const st = stateRef.current;
326
+ const stroke = {
327
+ id: makeStrokeId(),
328
+ layerId: st.ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
329
+ tool: fh.variant,
330
+ color: fh.color,
331
+ width: fh.width,
332
+ cap: fh.cap ?? 'round',
333
+ points: worldPoints,
334
+ createdAt: Date.now(),
335
+ };
336
+ st.ctx.commit({ ops: [{ op: 'addStroke', stroke }] });
337
+ // Keep the live preview up until this stroke actually paints (handled
338
+ // by the clear-on-paint effect), so there's never a gap on release.
339
+ setPendingClearStrokeId(stroke.id);
340
+ }
341
+ else {
342
+ // Nothing committed (too few samples) — no stroke to wait for, so drop
343
+ // the stray preview immediately.
344
+ livePoints.value = [];
345
+ }
346
+ };
347
+ return Gesture.Pan()
348
+ .minPointers(1)
349
+ .maxPointers(1)
350
+ .onBegin((e) => {
351
+ 'worklet';
352
+ drawCommitted.value = false;
353
+ // screen → world using the live viewport (single finger, so the
354
+ // viewport isn't moving here).
355
+ livePoints.value = [
356
+ e.x / zoom.value + panX.value,
357
+ e.y / zoom.value + panY.value,
358
+ ];
359
+ })
360
+ .onChange((e) => {
361
+ 'worklet';
362
+ const wx = e.x / zoom.value + panX.value;
363
+ const wy = e.y / zoom.value + panY.value;
364
+ const pts = livePoints.value;
365
+ const n = pts.length;
366
+ if (n >= 2) {
367
+ const dx = wx - pts[n - 2];
368
+ const dy = wy - pts[n - 1];
369
+ if (dx * dx + dy * dy < minDistSq)
370
+ return;
371
+ }
372
+ livePoints.value = [...pts, wx, wy];
373
+ })
374
+ .onEnd(() => {
375
+ 'worklet';
376
+ drawCommitted.value = true;
377
+ runOnJS(commitFreehand)([...livePoints.value]);
378
+ })
379
+ .onFinalize(() => {
380
+ 'worklet';
381
+ // Only clear here on cancel (no onEnd). On success, commitFreehand
382
+ // clears once the stroke is committed, avoiding a flicker.
383
+ if (!drawCommitted.value)
384
+ livePoints.value = [];
385
+ });
386
+ };
387
+ // Element drag (select tool) — one finger, UI thread. Hit-tests on the JS
388
+ // thread at drag-start (needs the document), then translates the hit
389
+ // element via a shared-value Skia transform and commits once on release.
390
+ // `draggingId` gates which element gets the transform; clearing it in the
391
+ // same tick as the commit avoids any flicker (see DragSelectionConfig).
392
+ const buildSelectDragPan = (cfg) => {
393
+ const beginSelectDrag = (screen) => {
394
+ const st = stateRef.current;
395
+ const world = st.ctx.viewport.screenToWorld(screen);
396
+ const zoomNow = st.ctx.viewport.state.zoom;
397
+ // Endpoint handles show only on the selected annotation, so check the
398
+ // current selection's handles before the general hit-test.
399
+ const selId = st.ctx.selection?.ids[0];
400
+ if (selId) {
401
+ const handle = cfg.hitTestHandle?.(st.ctx.document, selId, world, zoomNow);
402
+ if (handle) {
403
+ const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
404
+ if (m?.line) {
405
+ epCtx.value = {
406
+ ax: m.line.a.x,
407
+ ay: m.line.a.y,
408
+ bx: m.line.b.x,
409
+ by: m.line.b.y,
410
+ t0: m.linePos ?? 0.5,
411
+ handle: handle === 'a' ? 0 : 1,
412
+ };
413
+ epTargetRef.current = { id: selId, handle };
414
+ setEpDragId(selId);
415
+ return;
416
+ }
417
+ }
418
+ }
419
+ const hit = cfg.hitTest(st.ctx.document, world, zoomNow);
420
+ if (!hit) {
421
+ st.ctx.setSelection(null);
422
+ dragTargetRef.current = null;
423
+ return;
424
+ }
425
+ st.ctx.setSelection({ ids: [hit.id] });
426
+ // Grabbing a line annotation's tile slides it; otherwise group-move.
427
+ const grab = hit.kind === 'measurement'
428
+ ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow)
429
+ : 'move';
430
+ if (grab === 'slide') {
431
+ const m = st.ctx.document.placedMeasurements.find((x) => x.id === hit.id);
432
+ if (m?.line) {
433
+ slideCtx.value = {
434
+ ax: m.line.a.x,
435
+ ay: m.line.a.y,
436
+ bx: m.line.b.x,
437
+ by: m.line.b.y,
438
+ t0: m.linePos ?? 0.5,
439
+ };
440
+ slideTargetRef.current = { id: hit.id };
441
+ setSlidingId(hit.id);
442
+ return;
443
+ }
444
+ }
445
+ dragTargetRef.current = hit;
446
+ setDraggingId(hit.id);
447
+ };
448
+ const endSelectDrag = (dx, dy) => {
449
+ const st = stateRef.current;
450
+ // Endpoint commit: move the grabbed endpoint by the world delta.
451
+ const epT = epTargetRef.current;
452
+ if (epT) {
453
+ if (dx !== 0 || dy !== 0) {
454
+ const patch = cfg.buildEndpointPatch?.(st.ctx.document, epT.id, epT.handle, { x: dx, y: dy });
455
+ if (patch)
456
+ st.ctx.commit(patch);
457
+ }
458
+ epTargetRef.current = null;
459
+ setEpDragId(null);
460
+ return;
461
+ }
462
+ // Slide commit: map the final world delta to a clamped/snapped linePos.
463
+ const slideT = slideTargetRef.current;
464
+ if (slideT) {
465
+ if (dx !== 0 || dy !== 0) {
466
+ const patch = cfg.buildSlidePatch?.(st.ctx.document, slideT.id, { x: dx, y: dy }, st.ctx.viewport.state.zoom);
467
+ if (patch)
468
+ st.ctx.commit(patch);
469
+ }
470
+ slideTargetRef.current = null;
471
+ setSlidingId(null);
472
+ return;
473
+ }
474
+ const target = dragTargetRef.current;
475
+ if (target && (dx !== 0 || dy !== 0)) {
476
+ const patch = cfg.buildTranslatePatch(st.ctx.document, target.id, target.kind, {
477
+ x: dx,
478
+ y: dy,
479
+ });
480
+ if (patch)
481
+ st.ctx.commit(patch);
482
+ }
483
+ // Unwrap in the same render batch as the commit: the element lands at
484
+ // its new baked-in points with no transform. The stale dragX/dragY are
485
+ // never applied while draggingId is null, and are reset on next start.
486
+ dragTargetRef.current = null;
487
+ setDraggingId(null);
488
+ };
489
+ const cancelSelectDrag = () => {
490
+ // No commit — dropping draggingId/slidingId/epDragId snaps everything back.
491
+ dragTargetRef.current = null;
492
+ slideTargetRef.current = null;
493
+ epTargetRef.current = null;
494
+ setDraggingId(null);
495
+ setSlidingId(null);
496
+ setEpDragId(null);
497
+ };
498
+ return Gesture.Pan()
499
+ .minPointers(1)
500
+ .maxPointers(1)
501
+ .onStart((e) => {
502
+ 'worklet';
503
+ dragEnded.value = false;
504
+ dragX.value = 0;
505
+ dragY.value = 0;
506
+ // Hit-test at the touch-down point — onStart fires only after the
507
+ // pan threshold, so back out the accumulated translation.
508
+ runOnJS(beginSelectDrag)({
509
+ x: e.x - e.translationX,
510
+ y: e.y - e.translationY,
511
+ });
512
+ })
513
+ .onChange((e) => {
514
+ 'worklet';
515
+ // World-space delta from the drag origin. Applied to the element
516
+ // only once draggingId is set; otherwise it affects nothing.
517
+ dragX.value = e.translationX / zoom.value;
518
+ dragY.value = e.translationY / zoom.value;
519
+ })
520
+ .onEnd(() => {
521
+ 'worklet';
522
+ dragEnded.value = true;
523
+ runOnJS(endSelectDrag)(dragX.value, dragY.value);
524
+ })
525
+ .onFinalize(() => {
526
+ 'worklet';
527
+ if (!dragEnded.value)
528
+ runOnJS(cancelSelectDrag)();
529
+ });
530
+ };
531
+ // One finger, by active tool:
532
+ // - freehand (pen/marker/highlighter) → draw on the UI thread
533
+ // - Hand → pan the viewport on the UI thread (live, no JS round-trip)
534
+ // - select → drag the hit element on the UI thread
535
+ // - everything else → dispatch pointer events on the JS thread
536
+ const oneFinger = freehand
537
+ ? buildDrawPan(freehand)
538
+ : panViewport
539
+ ? buildViewportPan(1, 1)
540
+ : dragSelection
541
+ ? buildSelectDragPan(dragSelection)
542
+ : toolPan;
543
+ return Gesture.Race(tap, Gesture.Simultaneous(viewportPan, pinch), oneFinger);
544
+ }, [
545
+ zoom,
546
+ panX,
547
+ panY,
548
+ pinchStartZoom,
549
+ livePoints,
550
+ freehand,
551
+ panViewport,
552
+ dragSelection,
553
+ dragX,
554
+ dragY,
555
+ dragEnded,
556
+ slideCtx,
557
+ epCtx,
558
+ ]);
559
+ const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
560
+ const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
561
+ const { renderMeasurementStamp, selection } = props;
562
+ return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
563
+ width,
564
+ height,
565
+ effectiveCanvas: state.effectiveCanvas,
566
+ worldTransform,
567
+ resolveImageUrl,
568
+ valueFont,
569
+ // Freehand drawing runs on the UI thread via `livePreview`, so the
570
+ // JS-state pen preview is unused on native.
571
+ penDrawingStroke: null,
572
+ livePreview: freehand
573
+ ? {
574
+ path: livePath,
575
+ color: freehand.color,
576
+ width: freehand.width,
577
+ cap: freehand.cap ?? 'round',
578
+ opacity: freehand.variant === 'highlighter' ? 0.3 : 1,
579
+ }
580
+ : null,
581
+ draggingId,
582
+ dragTransform,
583
+ selectedId: selection?.ids[0] ?? null,
584
+ endpointDragId: epDragId,
585
+ liveLineP1,
586
+ liveLineP2,
587
+ handleRadius,
588
+ customPreview,
589
+ }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
590
+ ? state.measurementsById.get(placed.measurementId) ?? null
591
+ : null, selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, renderMeasurementStamp: renderMeasurementStamp, onRemove: () => {
592
+ const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
593
+ state.ctx.commit({ ops });
594
+ if (!keepSelection)
595
+ state.ctx.setSelection(null);
596
+ } }, placed.id))) }))] }));
597
+ };
598
+ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, renderMeasurementStamp, onRemove, }) => {
599
+ const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
600
+ const half = size / 2;
601
+ const anchorX = placed.anchor.x;
602
+ const anchorY = placed.anchor.y;
603
+ // doc → screen each frame, on the UI thread. Position is a translate
604
+ // transform (cheap, no layout) so the pin stays glued to its anchor. While
605
+ // dragging, the world-space drag offset is folded in; while sliding, the
606
+ // position is mapped onto the line. `dragging`/`sliding` are captured props,
607
+ // so the moment they flip false (on commit) the worklet drops the live offset
608
+ // in the same render the new anchor arrives — no flicker.
609
+ const animatedStyle = useAnimatedStyle(() => {
610
+ 'worklet';
611
+ let worldX = anchorX;
612
+ let worldY = anchorY;
613
+ if (sliding) {
614
+ // WORKLET TWIN of selectTool.buildSlidePatch — keep in sync.
615
+ // t = clamp(t0 + (delta·ab)/|ab|²), snap to center with a screen-px detent.
616
+ const c = slideCtx.value;
617
+ const abx = c.bx - c.ax;
618
+ const aby = c.by - c.ay;
619
+ const lenSq = abx * abx + aby * aby;
620
+ let t = lenSq === 0
621
+ ? c.t0
622
+ : c.t0 + (dragX.value * abx + dragY.value * aby) / lenSq;
623
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
624
+ const lineLen = Math.sqrt(lenSq);
625
+ const snapT = lineLen > 0 ? 12 / zoom.value / lineLen : 0; // SLIDE_SNAP_PX = 12
626
+ if (Math.abs(t - 0.5) <= snapT)
627
+ t = 0.5;
628
+ worldX = c.ax + abx * t;
629
+ worldY = c.ay + aby * t;
630
+ }
631
+ else if (endpointDragging) {
632
+ // Tile stays at its linePos along the LIVE line: fold the drag delta into
633
+ // the moving endpoint (handle 0 = a, 1 = b), then lerp at t0.
634
+ const c = epCtx.value;
635
+ const ax = c.handle === 0 ? c.ax + dragX.value : c.ax;
636
+ const ay = c.handle === 0 ? c.ay + dragY.value : c.ay;
637
+ const bx = c.handle === 1 ? c.bx + dragX.value : c.bx;
638
+ const by = c.handle === 1 ? c.by + dragY.value : c.by;
639
+ worldX = ax + (bx - ax) * c.t0;
640
+ worldY = ay + (by - ay) * c.t0;
641
+ }
642
+ else if (dragging) {
643
+ worldX = anchorX + dragX.value;
644
+ worldY = anchorY + dragY.value;
645
+ }
646
+ const cx = (worldX - panX.value) * zoom.value;
647
+ const cy = (worldY - panY.value) * zoom.value;
648
+ return {
649
+ transform: [{ translateX: cx - half }, { translateY: cy - half }],
650
+ };
651
+ });
652
+ return (_jsxs(Animated.View, { pointerEvents: "box-none", style: [
653
+ { position: 'absolute', left: 0, top: 0, width: size, height: size },
654
+ animatedStyle,
655
+ ], children: [_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: renderMeasurementStamp({
656
+ placed,
657
+ measurement,
658
+ selected,
659
+ size,
660
+ zoom: zoomSnapshot,
661
+ }) }), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", hitSlop: 10, onPress: onRemove, style: {
662
+ position: 'absolute',
663
+ top: -8,
664
+ right: -8,
665
+ width: 36,
666
+ height: 36,
667
+ } }))] }));
668
+ };