@reekon-tools/boldr-utils 1.6.13 → 1.6.15
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.
- package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.js +51 -13
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +370 -57
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +2 -2
- package/dist/annotation/canvas/Tool.d.ts +10 -0
- package/dist/annotation/canvas/elements/ShapeElement.js +115 -38
- package/dist/annotation/canvas/measurementGeometry.d.ts +1 -0
- package/dist/annotation/canvas/measurementGeometry.js +61 -2
- package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
- package/dist/annotation/canvas/shapeGeometry.js +116 -0
- package/dist/annotation/canvas/stampLayout.d.ts +4 -0
- package/dist/annotation/canvas/stampLayout.js +25 -9
- package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
- package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
- package/dist/annotation/canvas/tools/measurementTool.d.ts +15 -0
- package/dist/annotation/canvas/tools/measurementTool.js +133 -0
- package/dist/annotation/canvas/tools/panTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/panTool.js +38 -5
- package/dist/annotation/canvas/tools/penTool.js +5 -1
- package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
- package/dist/annotation/canvas/tools/polygonTool.js +162 -0
- package/dist/annotation/canvas/tools/selectTool.js +37 -76
- package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
- package/dist/annotation/canvas/tools/shapeTool.js +111 -0
- package/dist/annotation/canvas/tools/textTool.d.ts +3 -1
- package/dist/annotation/canvas/tools/textTool.js +28 -3
- package/dist/annotation/canvas/useAnnotationCanvasState.js +27 -3
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +83 -24
- package/dist/exports.d.ts +8 -4
- package/dist/exports.js +7 -3
- package/dist/formulas/calculateFormula.js +1 -3
- package/dist/types/annotation.d.ts +4 -0
- package/dist/types/firestore.d.ts +4 -0
- 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';
|
|
7
|
-
import {
|
|
6
|
+
import Animated, { runOnJS, runOnUI, useAnimatedStyle, useDerivedValue, useSharedValue, } from 'react-native-reanimated';
|
|
7
|
+
import { stampTileSize } 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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
// the
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
const [
|
|
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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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 (
|
|
140
|
+
if (pendingPaintIds.length === 0)
|
|
88
141
|
return;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}, [
|
|
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
|
|
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
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
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
|
|
405
|
-
// the
|
|
406
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
:
|
|
662
|
-
?
|
|
663
|
-
:
|
|
664
|
-
?
|
|
665
|
-
:
|
|
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,19 +1027,24 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
719
1027
|
width: liveRectW,
|
|
720
1028
|
height: liveRectH,
|
|
721
1029
|
},
|
|
722
|
-
|
|
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, }) => {
|
|
734
|
-
const size =
|
|
1046
|
+
const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, onStampPress, onStampLongPress, onRemove, }) => {
|
|
1047
|
+
const size = stampTileSize(placed);
|
|
735
1048
|
const half = size / 2;
|
|
736
1049
|
const anchorX = placed.anchor.x;
|
|
737
1050
|
const anchorY = placed.anchor.y;
|
|
@@ -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] }) }));
|