@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.
- package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +5 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.js +58 -6
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +5 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +514 -59
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +31 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +38 -9
- package/dist/annotation/canvas/Tool.d.ts +27 -0
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +4 -1
- package/dist/annotation/canvas/elements/ShapeElement.js +68 -9
- package/dist/annotation/canvas/elements/StrokeElement.js +8 -3
- package/dist/annotation/canvas/measurementGeometry.d.ts +21 -0
- package/dist/annotation/canvas/measurementGeometry.js +98 -3
- package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
- package/dist/annotation/canvas/shapeGeometry.js +116 -0
- package/dist/annotation/canvas/strokeGeometry.d.ts +1 -0
- package/dist/annotation/canvas/strokeGeometry.js +8 -0
- package/dist/annotation/canvas/textGeometry.d.ts +24 -0
- package/dist/annotation/canvas/textGeometry.js +110 -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.d.ts +1 -0
- package/dist/annotation/canvas/tools/penTool.js +8 -2
- 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 +148 -51
- 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 +12 -0
- package/dist/annotation/canvas/tools/textTool.js +78 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -1
- package/dist/annotation/canvas/useAnnotationCanvasState.js +56 -6
- package/dist/annotation/data/coalescedRunner.d.ts +1 -0
- package/dist/annotation/data/coalescedRunner.js +48 -0
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +118 -38
- package/dist/exports.d.ts +9 -4
- package/dist/exports.js +8 -3
- package/dist/formulas/calculateFormula.js +1 -3
- package/dist/types/annotation.d.ts +9 -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';
|
|
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
|
-
//
|
|
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
|
|
@@ -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
|
|
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
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
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
|
|
343
|
-
// the
|
|
344
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
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
|
-
:
|
|
539
|
-
?
|
|
540
|
-
:
|
|
541
|
-
?
|
|
542
|
-
:
|
|
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
|
-
|
|
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,
|