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