@reekon-tools/boldr-utils 1.6.13 → 1.6.14

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 (29) hide show
  1. package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
  3. package/dist/annotation/canvas/AnnotationCanvasInner.js +49 -11
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
  5. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +368 -55
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
  7. package/dist/annotation/canvas/AnnotationCanvasSkia.js +2 -2
  8. package/dist/annotation/canvas/Tool.d.ts +10 -0
  9. package/dist/annotation/canvas/elements/ShapeElement.js +41 -8
  10. package/dist/annotation/canvas/measurementGeometry.d.ts +1 -0
  11. package/dist/annotation/canvas/measurementGeometry.js +60 -2
  12. package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
  13. package/dist/annotation/canvas/shapeGeometry.js +116 -0
  14. package/dist/annotation/canvas/tools/panTool.d.ts +1 -0
  15. package/dist/annotation/canvas/tools/panTool.js +38 -5
  16. package/dist/annotation/canvas/tools/penTool.js +5 -1
  17. package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
  18. package/dist/annotation/canvas/tools/polygonTool.js +162 -0
  19. package/dist/annotation/canvas/tools/selectTool.js +34 -73
  20. package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
  21. package/dist/annotation/canvas/tools/shapeTool.js +111 -0
  22. package/dist/annotation/canvas/useAnnotationCanvasState.js +27 -3
  23. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +83 -24
  24. package/dist/exports.d.ts +7 -4
  25. package/dist/exports.js +6 -3
  26. package/dist/formulas/calculateFormula.js +1 -3
  27. package/dist/types/annotation.d.ts +2 -0
  28. package/dist/types/firestore.d.ts +4 -0
  29. package/package.json +1 -1
@@ -1,13 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Skia, useFont } from '@shopify/react-native-skia';
3
- import { useEffect, useMemo, useRef, useState, } from 'react';
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';
6
- import Animated, { runOnJS, useAnimatedStyle, useDerivedValue, useSharedValue, } from 'react-native-reanimated';
6
+ import Animated, { runOnJS, runOnUI, useAnimatedStyle, useDerivedValue, useSharedValue, } from 'react-native-reanimated';
7
7
  import { STAMP_TILE_SIZE } from './stampLayout.js';
8
8
  import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
10
  import { buildRemoveMeasurementOps, } from './measurementGeometry.js';
11
+ import { buildShapeFromDrag } from './tools/shapeTool.js';
11
12
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
12
13
  let strokeCounter = 0;
13
14
  const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++).toString(36)}`;
@@ -53,19 +54,19 @@ export const AnnotationCanvasInner = (props) => {
53
54
  // path off it. React state is untouched until the stroke commits on
54
55
  // pointer-up — so high-frequency drawing never hits the JS thread.
55
56
  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);
57
+ // Finished strokes handed off from `livePoints` but not yet confirmed
58
+ // painted by React. The draw gesture's onEnd moves the live buffer here IN
59
+ // THE SAME WORKLET TICK (before the commit hops to JS), so a new stroke's
60
+ // onBegin can repurpose `livePoints` without ever erasing ink whose
61
+ // committed <StrokeElement> hasn't rendered yet — the gap that used to read
62
+ // as the previous line flickering when the JS thread was busy. Entries are
63
+ // removed (on the UI thread, to never race a concurrent onEnd append) once
64
+ // their stroke is present in `effectiveCanvas`, by the paint-watch effect
65
+ // below. A list, not a single slot: several quick strokes can be awaiting
66
+ // paint at once while JS catches up.
67
+ const handoffStrokes = useSharedValue([]);
68
+ // JS mirror of the handoff ids whose commits we're waiting to see painted.
69
+ const [pendingPaintIds, setPendingPaintIds] = useState([]);
69
70
  const livePath = useDerivedValue(() => {
70
71
  'worklet';
71
72
  const pts = livePoints.value;
@@ -78,26 +79,209 @@ export const AnnotationCanvasInner = (props) => {
78
79
  }
79
80
  return path;
80
81
  });
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.
82
+ // Each handed-off stroke renders as its OWN <Path> (a fixed pool of slots,
83
+ // since hooks can't be dynamic): merging them into one path would rasterize
84
+ // overlaps as single coverage, which visibly under-darkens translucent
85
+ // variants (highlighter) relative to their committed per-stroke draws. The
86
+ // last slot absorbs any overflow — 4+ strokes awaiting paint means a
87
+ // pathological JS stall, where a transient alpha artifact in the overflow
88
+ // is acceptable. Slots use the CURRENT freehand paint; entries normally
89
+ // release within a render, long before a human could restyle the pen.
90
+ const useHandoffSlotPath = (slot, isLast) => useDerivedValue(() => {
91
+ 'worklet';
92
+ const path = Skia.Path.Make();
93
+ const entries = handoffStrokes.value;
94
+ const addPolyline = (pts) => {
95
+ if (pts.length < 2)
96
+ return;
97
+ path.moveTo(pts[0], pts[1]);
98
+ for (let i = 2; i < pts.length; i += 2) {
99
+ path.lineTo(pts[i], pts[i + 1]);
100
+ }
101
+ };
102
+ if (isLast) {
103
+ for (let i = slot; i < entries.length; i++) {
104
+ addPolyline(entries[i].points);
105
+ }
106
+ }
107
+ else if (entries[slot]) {
108
+ addPolyline(entries[slot].points);
109
+ }
110
+ return path;
111
+ });
112
+ const handoffPath0 = useHandoffSlotPath(0, false);
113
+ const handoffPath1 = useHandoffSlotPath(1, false);
114
+ const handoffPath2 = useHandoffSlotPath(2, false);
115
+ const handoffPath3 = useHandoffSlotPath(3, true);
116
+ // Drop handed-off strokes by id, on the UI thread: onEnd appends to
117
+ // `handoffStrokes` from a worklet, so a read-filter-write from the JS thread
118
+ // could interleave with an append and lose the newer stroke. Running the
119
+ // whole filter as one worklet keeps it serialized with the appends.
120
+ const removeHandoffStrokes = useCallback((ids) => {
121
+ runOnUI((toRemove) => {
122
+ 'worklet';
123
+ handoffStrokes.value = handoffStrokes.value.filter((s) => toRemove.indexOf(s.id) === -1);
124
+ })(ids);
125
+ }, [handoffStrokes]);
126
+ // Release each handed-off stroke only once its committed twin is present in
127
+ // the canvas, and one frame LATER, not in this effect body. RN Skia's sksg
128
+ // renderer re-records the scene and dispatches the first draw containing
129
+ // the committed stroke in a MICROTASK after the Canvas's layout effect
130
+ // (SkiaSGRoot.render is async) — while a shared-value write here goes to
131
+ // the UI runtime immediately. If the write wins, the still-active mapper
132
+ // redraws the PREVIOUS picture (no committed stroke) with the handoff
133
+ // already cleared: the stroke visibly vanishes until the new recording
134
+ // lands. A rAF callback runs strictly after this task's microtasks, so the
135
+ // UI queue is guaranteed to receive draw-new-picture before clear-handoff.
136
+ // The extra frame of preview+committed overlap is invisible at opacity 1
137
+ // and a one-frame double-blend for translucent variants.
85
138
  const placedStrokes = state.effectiveCanvas.strokes;
86
139
  useEffect(() => {
87
- if (!pendingClearStrokeId)
140
+ if (pendingPaintIds.length === 0)
88
141
  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]);
142
+ const painted = pendingPaintIds.filter((id) => placedStrokes.some((s) => s.id === id));
143
+ if (painted.length === 0)
144
+ return;
145
+ const raf = requestAnimationFrame(() => {
146
+ removeHandoffStrokes(painted);
147
+ setPendingPaintIds((prev) => prev.filter((id) => !painted.includes(id)));
148
+ });
149
+ return () => cancelAnimationFrame(raf);
150
+ }, [pendingPaintIds, placedStrokes, removeHandoffStrokes]);
98
151
  const freehand = state.activeTool?.freehand ?? null;
152
+ const shapeDraw = state.activeTool?.shapeDraw ?? null;
99
153
  const panViewport = !!state.activeTool?.panViewport;
100
154
  const dragSelection = state.activeTool?.dragSelection ?? null;
155
+ // In-flight shape rubber-band (line/arrow/rect/triangle/circle tools),
156
+ // owned by the UI thread — the shape twin of `livePoints`. The drag worklet
157
+ // tracks the start/current world points; derived paths below render the
158
+ // live shape. React is untouched until the single commit on release.
159
+ const liveShape = useSharedValue({ active: false, ax: 0, ay: 0, bx: 0, by: 0 });
160
+ // Released shapes awaiting their committed paint — the shape twin of
161
+ // `handoffStrokes`, with the same lifecycle: appended in the gesture's
162
+ // onEnd worklet tick, released by the paint-watch effect one rAF after the
163
+ // committed <ShapeElement> shows up in the effective canvas. Geometry
164
+ // (kind/cap) rides per entry so a quick sub-tool switch can't morph a
165
+ // pending shape; paint style comes from the current tool config (entries
166
+ // release within a render — same accepted caveat as stroke handoffs).
167
+ const handoffShapes = useSharedValue([]);
168
+ const [pendingShapePaintIds, setPendingShapePaintIds] = useState([]);
169
+ // Live mirror of the active shape tool's config for the worklet path
170
+ // builders (the derived values are created once, so they can't close over
171
+ // the changing `shapeDraw` prop).
172
+ const shapeCfg = useSharedValue({ kind: 'line', cap: 'round', width: 2 });
173
+ useEffect(() => {
174
+ if (!shapeDraw)
175
+ return;
176
+ shapeCfg.value = {
177
+ kind: shapeDraw.kind,
178
+ cap: shapeDraw.cap ?? 'round',
179
+ width: shapeDraw.width,
180
+ };
181
+ }, [shapeDraw, shapeCfg]);
182
+ // Outline of the live rubber-band + every pending handoff shape, as one
183
+ // stroked path. WORKLET TWIN of shapeGeometry.shapePointsFromDrag +
184
+ // ShapeElement's per-kind rendering — keep in sync. Merging into one path
185
+ // is safe here (unlike stroke handoffs): shape outlines draw at opacity 1.
186
+ const shapeBodyPath = useDerivedValue(() => {
187
+ 'worklet';
188
+ const path = Skia.Path.Make();
189
+ const add = (kind, ax, ay, bx, by) => {
190
+ const minX = Math.min(ax, bx);
191
+ const maxX = Math.max(ax, bx);
192
+ const minY = Math.min(ay, by);
193
+ const maxY = Math.max(ay, by);
194
+ if (kind === 'rect') {
195
+ path.moveTo(minX, minY);
196
+ path.lineTo(maxX, minY);
197
+ path.lineTo(maxX, maxY);
198
+ path.lineTo(minX, maxY);
199
+ path.close();
200
+ }
201
+ else if (kind === 'ellipse') {
202
+ const r = Math.max(maxX - minX, maxY - minY) / 2;
203
+ path.addCircle((ax + bx) / 2, (ay + by) / 2, r);
204
+ }
205
+ else if (kind === 'triangle') {
206
+ path.moveTo((minX + maxX) / 2, minY);
207
+ path.lineTo(maxX, maxY);
208
+ path.lineTo(minX, maxY);
209
+ path.close();
210
+ }
211
+ else {
212
+ path.moveTo(ax, ay);
213
+ path.lineTo(bx, by);
214
+ }
215
+ };
216
+ for (const e of handoffShapes.value) {
217
+ add(e.kind, e.ax, e.ay, e.bx, e.by);
218
+ }
219
+ const s = liveShape.value;
220
+ if (s.active)
221
+ add(shapeCfg.value.kind, s.ax, s.ay, s.bx, s.by);
222
+ return path;
223
+ });
224
+ // Filled arrowheads for 'line' shapes drawn with cap === 'arrow' (the body
225
+ // path is stroked, so heads need their own filled path). WORKLET TWIN of
226
+ // strokeGeometry.arrowheadTriangle — keep in sync.
227
+ const shapeHeadPath = useDerivedValue(() => {
228
+ 'worklet';
229
+ const path = Skia.Path.Make();
230
+ const width = shapeCfg.value.width;
231
+ const addHead = (ax, ay, bx, by) => {
232
+ const dx = bx - ax;
233
+ const dy = by - ay;
234
+ const len = Math.sqrt(dx * dx + dy * dy);
235
+ if (len < 0.01)
236
+ return;
237
+ const ux = dx / len;
238
+ const uy = dy / len;
239
+ const px = -uy;
240
+ const py = ux;
241
+ const head = Math.max(width * 2, 6);
242
+ const half = Math.max(width * 0.9, 4);
243
+ path.moveTo(bx + ux * head, by + uy * head);
244
+ path.lineTo(bx + px * half, by + py * half);
245
+ path.lineTo(bx - px * half, by - py * half);
246
+ path.close();
247
+ };
248
+ for (const e of handoffShapes.value) {
249
+ if (e.kind === 'line' && e.cap === 'arrow') {
250
+ addHead(e.ax, e.ay, e.bx, e.by);
251
+ }
252
+ }
253
+ const s = liveShape.value;
254
+ if (s.active &&
255
+ shapeCfg.value.kind === 'line' &&
256
+ shapeCfg.value.cap === 'arrow') {
257
+ addHead(s.ax, s.ay, s.bx, s.by);
258
+ }
259
+ return path;
260
+ });
261
+ // Shape twin of removeHandoffStrokes — same UI-thread serialization
262
+ // rationale (onEnd appends from a worklet; filtering must not interleave).
263
+ const removeHandoffShapes = useCallback((ids) => {
264
+ runOnUI((toRemove) => {
265
+ 'worklet';
266
+ handoffShapes.value = handoffShapes.value.filter((s) => toRemove.indexOf(s.id) === -1);
267
+ })(ids);
268
+ }, [handoffShapes]);
269
+ // Shape twin of the stroke paint-watch effect above — release a handed-off
270
+ // shape one frame AFTER its committed twin appears, for the same
271
+ // sksg-microtask-vs-shared-value-write race (see that effect's comment).
272
+ const placedShapes = state.effectiveCanvas.shapes;
273
+ useEffect(() => {
274
+ if (pendingShapePaintIds.length === 0)
275
+ return;
276
+ const painted = pendingShapePaintIds.filter((id) => placedShapes.some((s) => s.id === id));
277
+ if (painted.length === 0)
278
+ return;
279
+ const raf = requestAnimationFrame(() => {
280
+ removeHandoffShapes(painted);
281
+ setPendingShapePaintIds((prev) => prev.filter((id) => !painted.includes(id)));
282
+ });
283
+ return () => cancelAnimationFrame(raf);
284
+ }, [pendingShapePaintIds, placedShapes, removeHandoffShapes]);
101
285
  // UI-thread element drag (select tool). `dragX/dragY` is the live world-space
102
286
  // translation the gesture worklet writes; `dragTransform` feeds the Skia
103
287
  // group around the dragged element. `draggingId` (React state) gates which
@@ -380,12 +564,12 @@ export const AnnotationCanvasInner = (props) => {
380
564
  // hop or React render per sample.
381
565
  const buildDrawPan = (fh) => {
382
566
  const minDistSq = fh.minSampleDistance * fh.minSampleDistance;
383
- const commitFreehand = (worldPoints) => {
567
+ const commitFreehand = (id, worldPoints) => {
384
568
  // Need at least two distinct samples to make a stroke.
385
569
  if (worldPoints.length >= 4) {
386
570
  const st = stateRef.current;
387
571
  const stroke = {
388
- id: makeStrokeId(),
572
+ id,
389
573
  layerId: st.ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
390
574
  tool: fh.variant,
391
575
  color: fh.color,
@@ -396,14 +580,14 @@ export const AnnotationCanvasInner = (props) => {
396
580
  createdAt: Date.now(),
397
581
  };
398
582
  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);
583
+ // The handed-off preview stays up until this stroke actually paints
584
+ // (the paint-watch effect releases it), so there's no gap on release.
585
+ setPendingPaintIds((prev) => [...prev, id]);
402
586
  }
403
587
  else {
404
- // Nothing committed (too few samples) — no stroke to wait for, so drop
405
- // the stray preview immediately.
406
- livePoints.value = [];
588
+ // Nothing committed (too few samples) — no paint to wait for, so
589
+ // drop the handed-off entry now.
590
+ removeHandoffStrokes([id]);
407
591
  }
408
592
  };
409
593
  return Gesture.Pan()
@@ -411,9 +595,9 @@ export const AnnotationCanvasInner = (props) => {
411
595
  .maxPointers(1)
412
596
  .onBegin((e) => {
413
597
  'worklet';
414
- drawCommitted.value = false;
415
598
  // screen → world using the live viewport (single finger, so the
416
- // viewport isn't moving here).
599
+ // viewport isn't moving here). Any previous stroke has already moved
600
+ // to `handoffStrokes` (see onEnd), so this can't erase its ink.
417
601
  livePoints.value = [
418
602
  e.x / zoom.value + panX.value,
419
603
  e.y / zoom.value + panY.value,
@@ -435,15 +619,112 @@ export const AnnotationCanvasInner = (props) => {
435
619
  })
436
620
  .onEnd(() => {
437
621
  'worklet';
438
- drawCommitted.value = true;
439
- runOnJS(commitFreehand)([...livePoints.value]);
622
+ const pts = livePoints.value;
623
+ if (pts.length < 2) {
624
+ livePoints.value = [];
625
+ return;
626
+ }
627
+ // Hand the finished stroke off in THIS worklet tick: both shared
628
+ // values feed the same merged livePath, so the swap renders
629
+ // atomically (no flicker), and the buffer is free for the next
630
+ // stroke before the commit ever reaches the JS thread. The id is
631
+ // minted here so the committed stroke and its handed-off preview
632
+ // can be matched up by the paint-watch effect.
633
+ const id = `stroke-${Date.now().toString(36)}-${Math.floor(Math.random() * 0x100000000).toString(36)}`;
634
+ handoffStrokes.value = [...handoffStrokes.value, { id, points: pts }];
635
+ livePoints.value = [];
636
+ runOnJS(commitFreehand)(id, pts);
440
637
  })
441
638
  .onFinalize(() => {
442
639
  '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 = [];
640
+ // Cancel path (no onEnd): drop the in-flight preview. After a normal
641
+ // end the buffer is already empty, so this is a no-op.
642
+ livePoints.value = [];
643
+ });
644
+ };
645
+ // Shape rubber-band (line/arrow/rect/triangle/circle tools) — one finger,
646
+ // UI thread. The drag worklet tracks start/current world points in
647
+ // `liveShape` (the derived paths above render it live) and commits the
648
+ // AnnotationShape once, on release, through the same handoff/paint-watch
649
+ // dance as freehand strokes so the preview can't clear before the
650
+ // committed shape's first paint.
651
+ const buildShapeDrawPan = (cfg) => {
652
+ const minDragSq = 4 * 4; // screen px² — mirrors shapeTool's minDragPx
653
+ const commitShape = (id, a, b) => {
654
+ const st = stateRef.current;
655
+ const shape = buildShapeFromDrag({
656
+ kind: cfg.kind,
657
+ a,
658
+ b,
659
+ color: cfg.color,
660
+ width: cfg.width,
661
+ cap: cfg.cap,
662
+ dash: cfg.dash,
663
+ layerId: st.ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
664
+ id,
665
+ });
666
+ st.ctx.commit({ ops: [{ op: 'addShape', shape }] });
667
+ setPendingShapePaintIds((prev) => [...prev, id]);
668
+ };
669
+ return Gesture.Pan()
670
+ .minPointers(1)
671
+ .maxPointers(1)
672
+ .onBegin((e) => {
673
+ 'worklet';
674
+ const wx = e.x / zoom.value + panX.value;
675
+ const wy = e.y / zoom.value + panY.value;
676
+ liveShape.value = { active: true, ax: wx, ay: wy, bx: wx, by: wy };
677
+ })
678
+ .onChange((e) => {
679
+ 'worklet';
680
+ const s = liveShape.value;
681
+ if (!s.active)
682
+ return;
683
+ liveShape.value = {
684
+ active: true,
685
+ ax: s.ax,
686
+ ay: s.ay,
687
+ bx: e.x / zoom.value + panX.value,
688
+ by: e.y / zoom.value + panY.value,
689
+ };
690
+ })
691
+ .onEnd(() => {
692
+ 'worklet';
693
+ const s = liveShape.value;
694
+ if (!s.active)
695
+ return;
696
+ // Sub-threshold drags are accidental taps — drop them. (A clean tap
697
+ // normally wins the gesture race and never gets here anyway.)
698
+ const dxPx = (s.bx - s.ax) * zoom.value;
699
+ const dyPx = (s.by - s.ay) * zoom.value;
700
+ if (dxPx * dxPx + dyPx * dyPx < minDragSq) {
701
+ liveShape.value = { active: false, ax: 0, ay: 0, bx: 0, by: 0 };
702
+ return;
703
+ }
704
+ // Hand off in THIS worklet tick (see the stroke onEnd): the live
705
+ // slot frees atomically with the handoff append, so the preview
706
+ // never gaps while the commit crosses to the JS thread.
707
+ const id = `shape-${Date.now().toString(36)}-${Math.floor(Math.random() * 0x100000000).toString(36)}`;
708
+ handoffShapes.value = [
709
+ ...handoffShapes.value,
710
+ {
711
+ id,
712
+ kind: cfg.kind,
713
+ cap: cfg.cap ?? 'round',
714
+ ax: s.ax,
715
+ ay: s.ay,
716
+ bx: s.bx,
717
+ by: s.by,
718
+ },
719
+ ];
720
+ liveShape.value = { active: false, ax: 0, ay: 0, bx: 0, by: 0 };
721
+ runOnJS(commitShape)(id, { x: s.ax, y: s.ay }, { x: s.bx, y: s.by });
722
+ })
723
+ .onFinalize(() => {
724
+ 'worklet';
725
+ // Cancel path (no onEnd): drop the in-flight rubber-band. After a
726
+ // normal end the slot is already inactive, so this is a no-op.
727
+ liveShape.value = { active: false, ax: 0, ay: 0, bx: 0, by: 0 };
447
728
  });
448
729
  };
449
730
  // Element drag (select tool) — one finger, UI thread. Hit-tests on the JS
@@ -653,16 +934,19 @@ export const AnnotationCanvasInner = (props) => {
653
934
  };
654
935
  // One finger, by active tool:
655
936
  // - freehand (pen/marker/highlighter) → draw on the UI thread
937
+ // - shape tools (line/rect/…) → rubber-band on the UI thread
656
938
  // - Hand → pan the viewport on the UI thread (live, no JS round-trip)
657
939
  // - select → drag the hit element on the UI thread
658
940
  // - everything else → dispatch pointer events on the JS thread
659
941
  const oneFinger = freehand
660
942
  ? buildDrawPan(freehand)
661
- : panViewport
662
- ? buildViewportPan(1, 1)
663
- : dragSelection
664
- ? buildSelectDragPan(dragSelection)
665
- : toolPan;
943
+ : shapeDraw
944
+ ? buildShapeDrawPan(shapeDraw)
945
+ : panViewport
946
+ ? buildViewportPan(1, 1)
947
+ : dragSelection
948
+ ? buildSelectDragPan(dragSelection)
949
+ : toolPan;
666
950
  return Gesture.Race(tap, Gesture.Simultaneous(viewportPan, pinch), oneFinger);
667
951
  }, [
668
952
  zoom,
@@ -670,7 +954,12 @@ export const AnnotationCanvasInner = (props) => {
670
954
  panY,
671
955
  pinchStartZoom,
672
956
  livePoints,
957
+ handoffStrokes,
958
+ removeHandoffStrokes,
959
+ liveShape,
960
+ handoffShapes,
673
961
  freehand,
962
+ shapeDraw,
674
963
  panViewport,
675
964
  dragSelection,
676
965
  dragX,
@@ -683,7 +972,7 @@ export const AnnotationCanvasInner = (props) => {
683
972
  ]);
684
973
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
685
974
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
686
- const { renderMeasurementStamp, onMeasurementStampPress, selection } = props;
975
+ const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, selection, } = props;
687
976
  return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
688
977
  width,
689
978
  height,
@@ -697,6 +986,12 @@ export const AnnotationCanvasInner = (props) => {
697
986
  livePreview: freehand
698
987
  ? {
699
988
  path: livePath,
989
+ handoffPaths: [
990
+ handoffPath0,
991
+ handoffPath1,
992
+ handoffPath2,
993
+ handoffPath3,
994
+ ],
700
995
  color: freehand.color,
701
996
  width: freehand.width,
702
997
  cap: freehand.cap ?? 'round',
@@ -704,6 +999,19 @@ export const AnnotationCanvasInner = (props) => {
704
999
  opacity: freehand.variant === 'highlighter' ? 0.3 : 1,
705
1000
  }
706
1001
  : null,
1002
+ // UI-thread shape rubber-band (live + pending handoffs) — only
1003
+ // while a shape tool is active (handoffs always release within a
1004
+ // frame or two of the commit, before the tool can change).
1005
+ shapePreview: shapeDraw
1006
+ ? {
1007
+ path: shapeBodyPath,
1008
+ headPath: shapeHeadPath,
1009
+ color: shapeDraw.color,
1010
+ width: shapeDraw.width,
1011
+ cap: shapeDraw.cap ?? 'round',
1012
+ dash: shapeDraw.dash ?? false,
1013
+ }
1014
+ : null,
707
1015
  draggingId,
708
1016
  dragTransform,
709
1017
  resizingId,
@@ -719,18 +1027,23 @@ export const AnnotationCanvasInner = (props) => {
719
1027
  width: liveRectW,
720
1028
  height: liveRectH,
721
1029
  },
722
- handleRadius,
1030
+ // Endpoint/corner handles are drag affordances — only the select
1031
+ // tool can act on them, so suppress them when the active tool has
1032
+ // no drag support (e.g. view mode's pan tool, where a selected
1033
+ // measurement still shows its tile chrome but must not advertise
1034
+ // draggability).
1035
+ handleRadius: activeTool?.dragSelection ? handleRadius : undefined,
723
1036
  customPreview,
724
1037
  }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
725
1038
  ? (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: () => {
1039
+ : 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, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
727
1040
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
728
1041
  state.ctx.commit({ ops });
729
1042
  if (!keepSelection)
730
1043
  state.ctx.setSelection(null);
731
1044
  } }, placed.id))) }))] }));
732
1045
  };
733
- const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, onStampPress, onRemove, }) => {
1046
+ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, onStampPress, onStampLongPress, onRemove, }) => {
734
1047
  const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
735
1048
  const half = size / 2;
736
1049
  const anchorX = placed.anchor.x;
@@ -800,7 +1113,7 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
800
1113
  selected,
801
1114
  size,
802
1115
  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: {
1116
+ }) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", hitSlop: 10, onPress: onRemove, style: {
804
1117
  position: 'absolute',
805
1118
  top: -8,
806
1119
  right: -8,
@@ -27,12 +27,27 @@ export interface AnnotationCanvasSkiaProps {
27
27
  path: SkPath | {
28
28
  value: SkPath;
29
29
  };
30
+ handoffPaths?: (SkPath | {
31
+ value: SkPath;
32
+ })[];
30
33
  color: string;
31
34
  width: number;
32
35
  cap?: StrokeCap;
33
36
  dash?: boolean;
34
37
  opacity: number;
35
38
  } | null;
39
+ shapePreview?: {
40
+ path: SkPath | {
41
+ value: SkPath;
42
+ };
43
+ headPath: SkPath | {
44
+ value: SkPath;
45
+ };
46
+ color: string;
47
+ width: number;
48
+ cap?: StrokeCap;
49
+ dash?: boolean;
50
+ } | null;
36
51
  draggingId?: string | null;
37
52
  dragTransform?: Transforms3d | {
38
53
  value: Transforms3d;
@@ -57,5 +72,5 @@ export interface AnnotationCanvasSkiaProps {
57
72
  };
58
73
  customPreview?: ReactNode;
59
74
  }
60
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
75
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
61
76
  export {};
@@ -86,7 +86,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
86
86
  // since the function-call pattern works identically on native we use it
87
87
  // in both Inners for consistency. Don't add hooks here; this is a plain
88
88
  // JSX-returning helper, not a component.
89
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, 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) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
89
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, 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) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
90
90
  // Rectangle annotation: a stroked border whose center carries the
91
91
  // tile. A corner drag renders from the live geometry (outside the
92
92
  // group translate, like an endpoint drag); otherwise the committed
@@ -155,4 +155,4 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
155
155
  return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isDragging || isResizing, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: _jsx(Circle, { c: resizeGeom.handle, r: handleRadius, color: SELECTION_COLOR }) }))] }));
156
156
  }
157
157
  return null;
158
- })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), 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] }) }));
158
+ })(), 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] }) }));