@reekon-tools/boldr-utils 1.6.12 → 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 (41) hide show
  1. package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +5 -2
  3. package/dist/annotation/canvas/AnnotationCanvasInner.js +58 -6
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +5 -2
  5. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +514 -59
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +31 -1
  7. package/dist/annotation/canvas/AnnotationCanvasSkia.js +38 -9
  8. package/dist/annotation/canvas/Tool.d.ts +27 -0
  9. package/dist/annotation/canvas/elements/BackgroundImageElement.js +4 -1
  10. package/dist/annotation/canvas/elements/ShapeElement.js +68 -9
  11. package/dist/annotation/canvas/elements/StrokeElement.js +8 -3
  12. package/dist/annotation/canvas/measurementGeometry.d.ts +21 -0
  13. package/dist/annotation/canvas/measurementGeometry.js +98 -3
  14. package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
  15. package/dist/annotation/canvas/shapeGeometry.js +116 -0
  16. package/dist/annotation/canvas/strokeGeometry.d.ts +1 -0
  17. package/dist/annotation/canvas/strokeGeometry.js +8 -0
  18. package/dist/annotation/canvas/textGeometry.d.ts +24 -0
  19. package/dist/annotation/canvas/textGeometry.js +110 -0
  20. package/dist/annotation/canvas/tools/panTool.d.ts +1 -0
  21. package/dist/annotation/canvas/tools/panTool.js +38 -5
  22. package/dist/annotation/canvas/tools/penTool.d.ts +1 -0
  23. package/dist/annotation/canvas/tools/penTool.js +8 -2
  24. package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
  25. package/dist/annotation/canvas/tools/polygonTool.js +162 -0
  26. package/dist/annotation/canvas/tools/selectTool.js +148 -51
  27. package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
  28. package/dist/annotation/canvas/tools/shapeTool.js +111 -0
  29. package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
  30. package/dist/annotation/canvas/tools/textTool.js +78 -0
  31. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -1
  32. package/dist/annotation/canvas/useAnnotationCanvasState.js +56 -6
  33. package/dist/annotation/data/coalescedRunner.d.ts +1 -0
  34. package/dist/annotation/data/coalescedRunner.js +48 -0
  35. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +118 -38
  36. package/dist/exports.d.ts +9 -4
  37. package/dist/exports.js +8 -3
  38. package/dist/formulas/calculateFormula.js +1 -3
  39. package/dist/types/annotation.d.ts +9 -0
  40. package/dist/types/firestore.d.ts +4 -0
  41. 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';
4
- import { StyleSheet, TouchableOpacity, View } from 'react-native';
3
+ import { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
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
- import { buildRemoveMeasurementOps } from './measurementGeometry.js';
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
@@ -154,6 +338,67 @@ export const AnnotationCanvasInner = (props) => {
154
338
  'worklet';
155
339
  return HANDLE_RADIUS_PX / zoom.value;
156
340
  });
341
+ // Rectangle-annotation corner drag. `rectDragId` (React state) marks which
342
+ // annotation's rect renders from the live geometry; `rectCtx` carries the
343
+ // fixed (opposite) corner and the grabbed corner's start position so the
344
+ // Skia rect + the tile (locked to the live center) are driven on the UI
345
+ // thread from `dragX`/`dragY`.
346
+ const [rectDragId, setRectDragId] = useState(null);
347
+ const rectCtx = useSharedValue({ fx: 0, fy: 0, mx: 0, my: 0 });
348
+ const rectTargetRef = useRef(null);
349
+ // Live normalized rect during a corner drag (the grabbed corner follows the
350
+ // finger, the opposite corner stays put).
351
+ const liveRectX = useDerivedValue(() => {
352
+ 'worklet';
353
+ const c = rectCtx.value;
354
+ return Math.min(c.fx, c.mx + dragX.value);
355
+ });
356
+ const liveRectY = useDerivedValue(() => {
357
+ 'worklet';
358
+ const c = rectCtx.value;
359
+ return Math.min(c.fy, c.my + dragY.value);
360
+ });
361
+ const liveRectW = useDerivedValue(() => {
362
+ 'worklet';
363
+ const c = rectCtx.value;
364
+ return Math.abs(c.mx + dragX.value - c.fx);
365
+ });
366
+ const liveRectH = useDerivedValue(() => {
367
+ 'worklet';
368
+ const c = rectCtx.value;
369
+ return Math.abs(c.my + dragY.value - c.fy);
370
+ });
371
+ // Corner-scale resize (text shapes). `resizingId` (React state) gates which
372
+ // shape gets the live transform; `resizeCtx` carries the pivot (top-left
373
+ // anchor), the handle's grab-start position and the scale clamps (from
374
+ // DragSelectionConfig.hitTestResizeHandle) so the preview is a pure
375
+ // UI-thread scale-about-pivot driven by dragX/dragY. The commit on release
376
+ // goes through buildResizePatch, which clamps identically.
377
+ const [resizingId, setResizingId] = useState(null);
378
+ const resizeCtx = useSharedValue({ px: 0, py: 0, hx: 0, hy: 0, minS: 1, maxS: 1 });
379
+ const resizeTargetRef = useRef(null);
380
+ const resizeTransform = useDerivedValue(() => {
381
+ 'worklet';
382
+ // WORKLET TWIN of textGeometry.resizeScaleFromDrag — keep in sync.
383
+ const c = resizeCtx.value;
384
+ const bx = c.hx - c.px;
385
+ const by = c.hy - c.py;
386
+ const baseLen = Math.sqrt(bx * bx + by * by);
387
+ let s = 1;
388
+ if (baseLen > 0) {
389
+ const nx = bx + dragX.value;
390
+ const ny = by + dragY.value;
391
+ s = Math.sqrt(nx * nx + ny * ny) / baseLen;
392
+ }
393
+ s = Math.min(c.maxS, Math.max(c.minS, s));
394
+ return [
395
+ { translateX: c.px },
396
+ { translateY: c.py },
397
+ { scale: s },
398
+ { translateX: -c.px },
399
+ { translateY: -c.py },
400
+ ];
401
+ });
157
402
  // Per-gesture refs so we always emit a matching down/move/up sequence.
158
403
  const pointerIdRef = useRef(1);
159
404
  const inFlightRef = useRef(null);
@@ -319,29 +564,30 @@ export const AnnotationCanvasInner = (props) => {
319
564
  // hop or React render per sample.
320
565
  const buildDrawPan = (fh) => {
321
566
  const minDistSq = fh.minSampleDistance * fh.minSampleDistance;
322
- const commitFreehand = (worldPoints) => {
567
+ const commitFreehand = (id, worldPoints) => {
323
568
  // Need at least two distinct samples to make a stroke.
324
569
  if (worldPoints.length >= 4) {
325
570
  const st = stateRef.current;
326
571
  const stroke = {
327
- id: makeStrokeId(),
572
+ id,
328
573
  layerId: st.ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
329
574
  tool: fh.variant,
330
575
  color: fh.color,
331
576
  width: fh.width,
332
577
  cap: fh.cap ?? 'round',
578
+ ...(fh.dash && { dash: true }),
333
579
  points: worldPoints,
334
580
  createdAt: Date.now(),
335
581
  };
336
582
  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);
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]);
340
586
  }
341
587
  else {
342
- // Nothing committed (too few samples) — no stroke to wait for, so drop
343
- // the stray preview immediately.
344
- livePoints.value = [];
588
+ // Nothing committed (too few samples) — no paint to wait for, so
589
+ // drop the handed-off entry now.
590
+ removeHandoffStrokes([id]);
345
591
  }
346
592
  };
347
593
  return Gesture.Pan()
@@ -349,9 +595,9 @@ export const AnnotationCanvasInner = (props) => {
349
595
  .maxPointers(1)
350
596
  .onBegin((e) => {
351
597
  'worklet';
352
- drawCommitted.value = false;
353
598
  // screen → world using the live viewport (single finger, so the
354
- // 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.
355
601
  livePoints.value = [
356
602
  e.x / zoom.value + panX.value,
357
603
  e.y / zoom.value + panY.value,
@@ -373,15 +619,112 @@ export const AnnotationCanvasInner = (props) => {
373
619
  })
374
620
  .onEnd(() => {
375
621
  'worklet';
376
- drawCommitted.value = true;
377
- 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);
378
637
  })
379
638
  .onFinalize(() => {
380
639
  '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 = [];
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 };
385
728
  });
386
729
  };
387
730
  // Element drag (select tool) — one finger, UI thread. Hit-tests on the JS
@@ -415,6 +758,34 @@ export const AnnotationCanvasInner = (props) => {
415
758
  return;
416
759
  }
417
760
  }
761
+ // Corner resize handle (text shapes) — also selected-element-only.
762
+ const resizeGeom = cfg.hitTestResizeHandle?.(st.ctx.document, selId, world, zoomNow);
763
+ if (resizeGeom) {
764
+ resizeCtx.value = {
765
+ px: resizeGeom.pivot.x,
766
+ py: resizeGeom.pivot.y,
767
+ hx: resizeGeom.handle.x,
768
+ hy: resizeGeom.handle.y,
769
+ minS: resizeGeom.minScale,
770
+ maxS: resizeGeom.maxScale,
771
+ };
772
+ resizeTargetRef.current = { id: selId };
773
+ setResizingId(selId);
774
+ return;
775
+ }
776
+ // Rectangle-annotation corner handle — also selected-element-only.
777
+ const rectCornerHit = cfg.hitTestRectCorner?.(st.ctx.document, selId, world, zoomNow);
778
+ if (rectCornerHit) {
779
+ rectCtx.value = {
780
+ fx: rectCornerHit.fixed.x,
781
+ fy: rectCornerHit.fixed.y,
782
+ mx: rectCornerHit.moving.x,
783
+ my: rectCornerHit.moving.y,
784
+ };
785
+ rectTargetRef.current = { id: selId, corner: rectCornerHit.corner };
786
+ setRectDragId(selId);
787
+ return;
788
+ }
418
789
  }
419
790
  const hit = cfg.hitTest(st.ctx.document, world, zoomNow);
420
791
  if (!hit) {
@@ -447,6 +818,35 @@ export const AnnotationCanvasInner = (props) => {
447
818
  };
448
819
  const endSelectDrag = (dx, dy) => {
449
820
  const st = stateRef.current;
821
+ // Resize commit: scale the shape by the final drag (clamped in
822
+ // buildResizePatch exactly like the live preview).
823
+ const rT = resizeTargetRef.current;
824
+ if (rT) {
825
+ if (dx !== 0 || dy !== 0) {
826
+ const patch = cfg.buildResizePatch?.(st.ctx.document, rT.id, {
827
+ x: dx,
828
+ y: dy,
829
+ });
830
+ if (patch)
831
+ st.ctx.commit(patch);
832
+ }
833
+ resizeTargetRef.current = null;
834
+ setResizingId(null);
835
+ return;
836
+ }
837
+ // Rect-corner commit: drag the grabbed corner by the world delta
838
+ // (opposite corner fixed); anchor re-centers in the same patch.
839
+ const rectT = rectTargetRef.current;
840
+ if (rectT) {
841
+ if (dx !== 0 || dy !== 0) {
842
+ const patch = cfg.buildRectCornerPatch?.(st.ctx.document, rectT.id, rectT.corner, { x: dx, y: dy });
843
+ if (patch)
844
+ st.ctx.commit(patch);
845
+ }
846
+ rectTargetRef.current = null;
847
+ setRectDragId(null);
848
+ return;
849
+ }
450
850
  // Endpoint commit: move the grabbed endpoint by the world delta.
451
851
  const epT = epTargetRef.current;
452
852
  if (epT) {
@@ -487,13 +887,17 @@ export const AnnotationCanvasInner = (props) => {
487
887
  setDraggingId(null);
488
888
  };
489
889
  const cancelSelectDrag = () => {
490
- // No commit — dropping draggingId/slidingId/epDragId snaps everything back.
890
+ // No commit — dropping the gating ids snaps everything back.
491
891
  dragTargetRef.current = null;
492
892
  slideTargetRef.current = null;
493
893
  epTargetRef.current = null;
894
+ resizeTargetRef.current = null;
895
+ rectTargetRef.current = null;
494
896
  setDraggingId(null);
495
897
  setSlidingId(null);
496
898
  setEpDragId(null);
899
+ setResizingId(null);
900
+ setRectDragId(null);
497
901
  };
498
902
  return Gesture.Pan()
499
903
  .minPointers(1)
@@ -530,16 +934,19 @@ export const AnnotationCanvasInner = (props) => {
530
934
  };
531
935
  // One finger, by active tool:
532
936
  // - freehand (pen/marker/highlighter) → draw on the UI thread
937
+ // - shape tools (line/rect/…) → rubber-band on the UI thread
533
938
  // - Hand → pan the viewport on the UI thread (live, no JS round-trip)
534
939
  // - select → drag the hit element on the UI thread
535
940
  // - everything else → dispatch pointer events on the JS thread
536
941
  const oneFinger = freehand
537
942
  ? buildDrawPan(freehand)
538
- : panViewport
539
- ? buildViewportPan(1, 1)
540
- : dragSelection
541
- ? buildSelectDragPan(dragSelection)
542
- : toolPan;
943
+ : shapeDraw
944
+ ? buildShapeDrawPan(shapeDraw)
945
+ : panViewport
946
+ ? buildViewportPan(1, 1)
947
+ : dragSelection
948
+ ? buildSelectDragPan(dragSelection)
949
+ : toolPan;
543
950
  return Gesture.Race(tap, Gesture.Simultaneous(viewportPan, pinch), oneFinger);
544
951
  }, [
545
952
  zoom,
@@ -547,7 +954,12 @@ export const AnnotationCanvasInner = (props) => {
547
954
  panY,
548
955
  pinchStartZoom,
549
956
  livePoints,
957
+ handoffStrokes,
958
+ removeHandoffStrokes,
959
+ liveShape,
960
+ handoffShapes,
550
961
  freehand,
962
+ shapeDraw,
551
963
  panViewport,
552
964
  dragSelection,
553
965
  dragX,
@@ -555,10 +967,12 @@ export const AnnotationCanvasInner = (props) => {
555
967
  dragEnded,
556
968
  slideCtx,
557
969
  epCtx,
970
+ resizeCtx,
971
+ rectCtx,
558
972
  ]);
559
973
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
560
974
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
561
- const { renderMeasurementStamp, selection } = props;
975
+ const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, selection, } = props;
562
976
  return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
563
977
  width,
564
978
  height,
@@ -572,30 +986,64 @@ export const AnnotationCanvasInner = (props) => {
572
986
  livePreview: freehand
573
987
  ? {
574
988
  path: livePath,
989
+ handoffPaths: [
990
+ handoffPath0,
991
+ handoffPath1,
992
+ handoffPath2,
993
+ handoffPath3,
994
+ ],
575
995
  color: freehand.color,
576
996
  width: freehand.width,
577
997
  cap: freehand.cap ?? 'round',
998
+ dash: freehand.dash ?? false,
578
999
  opacity: freehand.variant === 'highlighter' ? 0.3 : 1,
579
1000
  }
580
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,
581
1015
  draggingId,
582
1016
  dragTransform,
1017
+ resizingId,
1018
+ resizeTransform,
583
1019
  selectedId: selection?.ids[0] ?? null,
584
1020
  endpointDragId: epDragId,
585
1021
  liveLineP1,
586
1022
  liveLineP2,
587
- handleRadius,
1023
+ rectDragId,
1024
+ liveRect: {
1025
+ x: liveRectX,
1026
+ y: liveRectY,
1027
+ width: liveRectW,
1028
+ height: liveRectH,
1029
+ },
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,
588
1036
  customPreview,
589
1037
  }) }) }), 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: () => {
1038
+ ? (state.measurementsById.get(placed.measurementId) ?? null)
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: () => {
592
1040
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
593
1041
  state.ctx.commit({ ops });
594
1042
  if (!keepSelection)
595
1043
  state.ctx.setSelection(null);
596
1044
  } }, placed.id))) }))] }));
597
1045
  };
598
- const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, renderMeasurementStamp, onRemove, }) => {
1046
+ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, onStampPress, onStampLongPress, onRemove, }) => {
599
1047
  const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
600
1048
  const half = size / 2;
601
1049
  const anchorX = placed.anchor.x;
@@ -639,6 +1087,13 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
639
1087
  worldX = ax + (bx - ax) * c.t0;
640
1088
  worldY = ay + (by - ay) * c.t0;
641
1089
  }
1090
+ else if (rectResizing) {
1091
+ // Tile stays at the LIVE rect's center: midpoint of the fixed corner
1092
+ // and the grabbed corner with the drag delta folded in.
1093
+ const c = rectCtx.value;
1094
+ worldX = (c.fx + c.mx + dragX.value) / 2;
1095
+ worldY = (c.fy + c.my + dragY.value) / 2;
1096
+ }
642
1097
  else if (dragging) {
643
1098
  worldX = anchorX + dragX.value;
644
1099
  worldY = anchorY + dragY.value;
@@ -658,7 +1113,7 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
658
1113
  selected,
659
1114
  size,
660
1115
  zoom: zoomSnapshot,
661
- }) }), 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: {
662
1117
  position: 'absolute',
663
1118
  top: -8,
664
1119
  right: -8,