@reekon-tools/boldr-utils 1.6.12 → 1.6.13
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/AnnotationCanvasInner.d.ts +4 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.js +19 -5
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +4 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +150 -8
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +38 -9
- package/dist/annotation/canvas/Tool.d.ts +17 -0
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +4 -1
- package/dist/annotation/canvas/elements/ShapeElement.js +28 -2
- package/dist/annotation/canvas/elements/StrokeElement.js +8 -3
- package/dist/annotation/canvas/measurementGeometry.d.ts +20 -0
- package/dist/annotation/canvas/measurementGeometry.js +38 -1
- 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/penTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/penTool.js +3 -1
- package/dist/annotation/canvas/tools/selectTool.js +155 -19
- 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 +30 -4
- package/dist/annotation/data/coalescedRunner.d.ts +1 -0
- package/dist/annotation/data/coalescedRunner.js +48 -0
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +35 -14
- package/dist/exports.d.ts +4 -2
- package/dist/exports.js +3 -1
- package/dist/types/annotation.d.ts +7 -0
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type CSSProperties, type MutableRefObject } from 'react';
|
|
2
2
|
import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../../types/firestore.js';
|
|
3
|
-
import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../../types/annotation.js';
|
|
3
|
+
import type { AnnotationCanvasState, AnnotationDocumentPatch, PlacedMeasurementRef, Selection } from '../../types/annotation.js';
|
|
4
4
|
import type { MeasurementRef } from './measurementPicker.js';
|
|
5
|
-
import type { Tool } from './Tool.js';
|
|
5
|
+
import type { RequestTextInput, Tool } from './Tool.js';
|
|
6
6
|
import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
|
|
7
7
|
import { type ViewportState } from './viewport.js';
|
|
8
8
|
import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
|
|
@@ -20,7 +20,9 @@ export interface AnnotationCanvasInnerProps {
|
|
|
20
20
|
decimalTolerance?: DecimalTolerance;
|
|
21
21
|
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
22
22
|
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
23
|
+
requestTextInput?: RequestTextInput;
|
|
23
24
|
renderMeasurementStamp?: RenderMeasurementStamp;
|
|
25
|
+
onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
|
|
24
26
|
stampFontSource?: unknown;
|
|
25
27
|
stampValueFontSize?: number;
|
|
26
28
|
stampLabelFontSize?: number;
|
|
@@ -55,7 +55,10 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
55
55
|
y: event.clientY - (rect?.top ?? 0),
|
|
56
56
|
};
|
|
57
57
|
if (isPanTriggerDown(event)) {
|
|
58
|
-
panGestureRef.current = {
|
|
58
|
+
panGestureRef.current = {
|
|
59
|
+
pointerId: event.pointerId,
|
|
60
|
+
lastScreen: screen,
|
|
61
|
+
};
|
|
59
62
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
60
63
|
event.preventDefault();
|
|
61
64
|
return;
|
|
@@ -75,7 +78,10 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
75
78
|
x: screen.x - pan.lastScreen.x,
|
|
76
79
|
y: screen.y - pan.lastScreen.y,
|
|
77
80
|
});
|
|
78
|
-
panGestureRef.current = {
|
|
81
|
+
panGestureRef.current = {
|
|
82
|
+
pointerId: pan.pointerId,
|
|
83
|
+
lastScreen: screen,
|
|
84
|
+
};
|
|
79
85
|
return;
|
|
80
86
|
}
|
|
81
87
|
state.dispatchPointerMove(toCanvasPointer(event));
|
|
@@ -165,7 +171,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
165
171
|
...style,
|
|
166
172
|
};
|
|
167
173
|
const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
|
|
168
|
-
const { renderMeasurementStamp, selection } = props;
|
|
174
|
+
const { renderMeasurementStamp, onMeasurementStampPress, selection } = props;
|
|
169
175
|
return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
|
|
170
176
|
width,
|
|
171
177
|
height,
|
|
@@ -190,7 +196,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
190
196
|
const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
|
|
191
197
|
const isSelected = selection?.ids.includes(placed.id) ?? false;
|
|
192
198
|
const measurement = placed.measurementId
|
|
193
|
-
? state.measurementsById.get(placed.measurementId) ?? null
|
|
199
|
+
? (state.measurementsById.get(placed.measurementId) ?? null)
|
|
194
200
|
: null;
|
|
195
201
|
return (_jsxs("div", { style: {
|
|
196
202
|
position: 'absolute',
|
|
@@ -205,7 +211,15 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
205
211
|
selected: isSelected,
|
|
206
212
|
size,
|
|
207
213
|
zoom: state.viewport.zoom,
|
|
208
|
-
}),
|
|
214
|
+
}), onMeasurementStampPress && (_jsx("button", { "aria-label": "Open measurement", onPointerDown: (e) => e.stopPropagation(), onClick: () => onMeasurementStampPress(placed), style: {
|
|
215
|
+
position: 'absolute',
|
|
216
|
+
inset: 0,
|
|
217
|
+
pointerEvents: 'auto',
|
|
218
|
+
cursor: 'pointer',
|
|
219
|
+
background: 'transparent',
|
|
220
|
+
border: 'none',
|
|
221
|
+
padding: 0,
|
|
222
|
+
} })), isSelected && measurement && (_jsx("div", { role: "button", "aria-label": "Remove measurement", onPointerDown: (e) => {
|
|
209
223
|
e.stopPropagation();
|
|
210
224
|
const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
|
|
211
225
|
state.ctx.commit({ ops });
|
|
@@ -2,9 +2,9 @@ import { type MutableRefObject } from 'react';
|
|
|
2
2
|
import { type ViewStyle } from 'react-native';
|
|
3
3
|
import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
|
|
4
4
|
import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../../types/firestore.js';
|
|
5
|
-
import { type AnnotationCanvasState, type AnnotationDocumentPatch, type Selection } from '../../types/annotation.js';
|
|
5
|
+
import { type AnnotationCanvasState, type AnnotationDocumentPatch, type PlacedMeasurementRef, type Selection } from '../../types/annotation.js';
|
|
6
6
|
import type { MeasurementRef } from './measurementPicker.js';
|
|
7
|
-
import type { Tool } from './Tool.js';
|
|
7
|
+
import type { RequestTextInput, Tool } from './Tool.js';
|
|
8
8
|
import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
|
|
9
9
|
import type { ViewportState } from './viewport.js';
|
|
10
10
|
export type { AnnotationCanvasHandle };
|
|
@@ -21,7 +21,9 @@ export interface AnnotationCanvasInnerProps {
|
|
|
21
21
|
decimalTolerance?: DecimalTolerance;
|
|
22
22
|
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
23
23
|
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
24
|
+
requestTextInput?: RequestTextInput;
|
|
24
25
|
renderMeasurementStamp?: RenderMeasurementStamp;
|
|
26
|
+
onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
|
|
25
27
|
stampFontSource?: unknown;
|
|
26
28
|
stampValueFontSize?: number;
|
|
27
29
|
stampLabelFontSize?: number;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Skia, useFont } from '@shopify/react-native-skia';
|
|
3
3
|
import { useEffect, useMemo, useRef, useState, } from 'react';
|
|
4
|
-
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
|
4
|
+
import { StyleSheet, TouchableOpacity, View, } from 'react-native';
|
|
5
5
|
import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
|
|
6
6
|
import Animated, { runOnJS, 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
11
|
import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
|
|
12
12
|
let strokeCounter = 0;
|
|
13
13
|
const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++).toString(36)}`;
|
|
@@ -154,6 +154,67 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
154
154
|
'worklet';
|
|
155
155
|
return HANDLE_RADIUS_PX / zoom.value;
|
|
156
156
|
});
|
|
157
|
+
// Rectangle-annotation corner drag. `rectDragId` (React state) marks which
|
|
158
|
+
// annotation's rect renders from the live geometry; `rectCtx` carries the
|
|
159
|
+
// fixed (opposite) corner and the grabbed corner's start position so the
|
|
160
|
+
// Skia rect + the tile (locked to the live center) are driven on the UI
|
|
161
|
+
// thread from `dragX`/`dragY`.
|
|
162
|
+
const [rectDragId, setRectDragId] = useState(null);
|
|
163
|
+
const rectCtx = useSharedValue({ fx: 0, fy: 0, mx: 0, my: 0 });
|
|
164
|
+
const rectTargetRef = useRef(null);
|
|
165
|
+
// Live normalized rect during a corner drag (the grabbed corner follows the
|
|
166
|
+
// finger, the opposite corner stays put).
|
|
167
|
+
const liveRectX = useDerivedValue(() => {
|
|
168
|
+
'worklet';
|
|
169
|
+
const c = rectCtx.value;
|
|
170
|
+
return Math.min(c.fx, c.mx + dragX.value);
|
|
171
|
+
});
|
|
172
|
+
const liveRectY = useDerivedValue(() => {
|
|
173
|
+
'worklet';
|
|
174
|
+
const c = rectCtx.value;
|
|
175
|
+
return Math.min(c.fy, c.my + dragY.value);
|
|
176
|
+
});
|
|
177
|
+
const liveRectW = useDerivedValue(() => {
|
|
178
|
+
'worklet';
|
|
179
|
+
const c = rectCtx.value;
|
|
180
|
+
return Math.abs(c.mx + dragX.value - c.fx);
|
|
181
|
+
});
|
|
182
|
+
const liveRectH = useDerivedValue(() => {
|
|
183
|
+
'worklet';
|
|
184
|
+
const c = rectCtx.value;
|
|
185
|
+
return Math.abs(c.my + dragY.value - c.fy);
|
|
186
|
+
});
|
|
187
|
+
// Corner-scale resize (text shapes). `resizingId` (React state) gates which
|
|
188
|
+
// shape gets the live transform; `resizeCtx` carries the pivot (top-left
|
|
189
|
+
// anchor), the handle's grab-start position and the scale clamps (from
|
|
190
|
+
// DragSelectionConfig.hitTestResizeHandle) so the preview is a pure
|
|
191
|
+
// UI-thread scale-about-pivot driven by dragX/dragY. The commit on release
|
|
192
|
+
// goes through buildResizePatch, which clamps identically.
|
|
193
|
+
const [resizingId, setResizingId] = useState(null);
|
|
194
|
+
const resizeCtx = useSharedValue({ px: 0, py: 0, hx: 0, hy: 0, minS: 1, maxS: 1 });
|
|
195
|
+
const resizeTargetRef = useRef(null);
|
|
196
|
+
const resizeTransform = useDerivedValue(() => {
|
|
197
|
+
'worklet';
|
|
198
|
+
// WORKLET TWIN of textGeometry.resizeScaleFromDrag — keep in sync.
|
|
199
|
+
const c = resizeCtx.value;
|
|
200
|
+
const bx = c.hx - c.px;
|
|
201
|
+
const by = c.hy - c.py;
|
|
202
|
+
const baseLen = Math.sqrt(bx * bx + by * by);
|
|
203
|
+
let s = 1;
|
|
204
|
+
if (baseLen > 0) {
|
|
205
|
+
const nx = bx + dragX.value;
|
|
206
|
+
const ny = by + dragY.value;
|
|
207
|
+
s = Math.sqrt(nx * nx + ny * ny) / baseLen;
|
|
208
|
+
}
|
|
209
|
+
s = Math.min(c.maxS, Math.max(c.minS, s));
|
|
210
|
+
return [
|
|
211
|
+
{ translateX: c.px },
|
|
212
|
+
{ translateY: c.py },
|
|
213
|
+
{ scale: s },
|
|
214
|
+
{ translateX: -c.px },
|
|
215
|
+
{ translateY: -c.py },
|
|
216
|
+
];
|
|
217
|
+
});
|
|
157
218
|
// Per-gesture refs so we always emit a matching down/move/up sequence.
|
|
158
219
|
const pointerIdRef = useRef(1);
|
|
159
220
|
const inFlightRef = useRef(null);
|
|
@@ -330,6 +391,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
330
391
|
color: fh.color,
|
|
331
392
|
width: fh.width,
|
|
332
393
|
cap: fh.cap ?? 'round',
|
|
394
|
+
...(fh.dash && { dash: true }),
|
|
333
395
|
points: worldPoints,
|
|
334
396
|
createdAt: Date.now(),
|
|
335
397
|
};
|
|
@@ -415,6 +477,34 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
415
477
|
return;
|
|
416
478
|
}
|
|
417
479
|
}
|
|
480
|
+
// Corner resize handle (text shapes) — also selected-element-only.
|
|
481
|
+
const resizeGeom = cfg.hitTestResizeHandle?.(st.ctx.document, selId, world, zoomNow);
|
|
482
|
+
if (resizeGeom) {
|
|
483
|
+
resizeCtx.value = {
|
|
484
|
+
px: resizeGeom.pivot.x,
|
|
485
|
+
py: resizeGeom.pivot.y,
|
|
486
|
+
hx: resizeGeom.handle.x,
|
|
487
|
+
hy: resizeGeom.handle.y,
|
|
488
|
+
minS: resizeGeom.minScale,
|
|
489
|
+
maxS: resizeGeom.maxScale,
|
|
490
|
+
};
|
|
491
|
+
resizeTargetRef.current = { id: selId };
|
|
492
|
+
setResizingId(selId);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// Rectangle-annotation corner handle — also selected-element-only.
|
|
496
|
+
const rectCornerHit = cfg.hitTestRectCorner?.(st.ctx.document, selId, world, zoomNow);
|
|
497
|
+
if (rectCornerHit) {
|
|
498
|
+
rectCtx.value = {
|
|
499
|
+
fx: rectCornerHit.fixed.x,
|
|
500
|
+
fy: rectCornerHit.fixed.y,
|
|
501
|
+
mx: rectCornerHit.moving.x,
|
|
502
|
+
my: rectCornerHit.moving.y,
|
|
503
|
+
};
|
|
504
|
+
rectTargetRef.current = { id: selId, corner: rectCornerHit.corner };
|
|
505
|
+
setRectDragId(selId);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
418
508
|
}
|
|
419
509
|
const hit = cfg.hitTest(st.ctx.document, world, zoomNow);
|
|
420
510
|
if (!hit) {
|
|
@@ -447,6 +537,35 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
447
537
|
};
|
|
448
538
|
const endSelectDrag = (dx, dy) => {
|
|
449
539
|
const st = stateRef.current;
|
|
540
|
+
// Resize commit: scale the shape by the final drag (clamped in
|
|
541
|
+
// buildResizePatch exactly like the live preview).
|
|
542
|
+
const rT = resizeTargetRef.current;
|
|
543
|
+
if (rT) {
|
|
544
|
+
if (dx !== 0 || dy !== 0) {
|
|
545
|
+
const patch = cfg.buildResizePatch?.(st.ctx.document, rT.id, {
|
|
546
|
+
x: dx,
|
|
547
|
+
y: dy,
|
|
548
|
+
});
|
|
549
|
+
if (patch)
|
|
550
|
+
st.ctx.commit(patch);
|
|
551
|
+
}
|
|
552
|
+
resizeTargetRef.current = null;
|
|
553
|
+
setResizingId(null);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
// Rect-corner commit: drag the grabbed corner by the world delta
|
|
557
|
+
// (opposite corner fixed); anchor re-centers in the same patch.
|
|
558
|
+
const rectT = rectTargetRef.current;
|
|
559
|
+
if (rectT) {
|
|
560
|
+
if (dx !== 0 || dy !== 0) {
|
|
561
|
+
const patch = cfg.buildRectCornerPatch?.(st.ctx.document, rectT.id, rectT.corner, { x: dx, y: dy });
|
|
562
|
+
if (patch)
|
|
563
|
+
st.ctx.commit(patch);
|
|
564
|
+
}
|
|
565
|
+
rectTargetRef.current = null;
|
|
566
|
+
setRectDragId(null);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
450
569
|
// Endpoint commit: move the grabbed endpoint by the world delta.
|
|
451
570
|
const epT = epTargetRef.current;
|
|
452
571
|
if (epT) {
|
|
@@ -487,13 +606,17 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
487
606
|
setDraggingId(null);
|
|
488
607
|
};
|
|
489
608
|
const cancelSelectDrag = () => {
|
|
490
|
-
// No commit — dropping
|
|
609
|
+
// No commit — dropping the gating ids snaps everything back.
|
|
491
610
|
dragTargetRef.current = null;
|
|
492
611
|
slideTargetRef.current = null;
|
|
493
612
|
epTargetRef.current = null;
|
|
613
|
+
resizeTargetRef.current = null;
|
|
614
|
+
rectTargetRef.current = null;
|
|
494
615
|
setDraggingId(null);
|
|
495
616
|
setSlidingId(null);
|
|
496
617
|
setEpDragId(null);
|
|
618
|
+
setResizingId(null);
|
|
619
|
+
setRectDragId(null);
|
|
497
620
|
};
|
|
498
621
|
return Gesture.Pan()
|
|
499
622
|
.minPointers(1)
|
|
@@ -555,10 +678,12 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
555
678
|
dragEnded,
|
|
556
679
|
slideCtx,
|
|
557
680
|
epCtx,
|
|
681
|
+
resizeCtx,
|
|
682
|
+
rectCtx,
|
|
558
683
|
]);
|
|
559
684
|
const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
|
|
560
685
|
const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
|
|
561
|
-
const { renderMeasurementStamp, selection } = props;
|
|
686
|
+
const { renderMeasurementStamp, onMeasurementStampPress, selection } = props;
|
|
562
687
|
return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
|
|
563
688
|
width,
|
|
564
689
|
height,
|
|
@@ -575,27 +700,37 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
575
700
|
color: freehand.color,
|
|
576
701
|
width: freehand.width,
|
|
577
702
|
cap: freehand.cap ?? 'round',
|
|
703
|
+
dash: freehand.dash ?? false,
|
|
578
704
|
opacity: freehand.variant === 'highlighter' ? 0.3 : 1,
|
|
579
705
|
}
|
|
580
706
|
: null,
|
|
581
707
|
draggingId,
|
|
582
708
|
dragTransform,
|
|
709
|
+
resizingId,
|
|
710
|
+
resizeTransform,
|
|
583
711
|
selectedId: selection?.ids[0] ?? null,
|
|
584
712
|
endpointDragId: epDragId,
|
|
585
713
|
liveLineP1,
|
|
586
714
|
liveLineP2,
|
|
715
|
+
rectDragId,
|
|
716
|
+
liveRect: {
|
|
717
|
+
x: liveRectX,
|
|
718
|
+
y: liveRectY,
|
|
719
|
+
width: liveRectW,
|
|
720
|
+
height: liveRectH,
|
|
721
|
+
},
|
|
587
722
|
handleRadius,
|
|
588
723
|
customPreview,
|
|
589
724
|
}) }) }), 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: () => {
|
|
725
|
+
? (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: () => {
|
|
592
727
|
const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
|
|
593
728
|
state.ctx.commit({ ops });
|
|
594
729
|
if (!keepSelection)
|
|
595
730
|
state.ctx.setSelection(null);
|
|
596
731
|
} }, placed.id))) }))] }));
|
|
597
732
|
};
|
|
598
|
-
const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, renderMeasurementStamp, onRemove, }) => {
|
|
733
|
+
const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, onStampPress, onRemove, }) => {
|
|
599
734
|
const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
|
|
600
735
|
const half = size / 2;
|
|
601
736
|
const anchorX = placed.anchor.x;
|
|
@@ -639,6 +774,13 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
|
|
|
639
774
|
worldX = ax + (bx - ax) * c.t0;
|
|
640
775
|
worldY = ay + (by - ay) * c.t0;
|
|
641
776
|
}
|
|
777
|
+
else if (rectResizing) {
|
|
778
|
+
// Tile stays at the LIVE rect's center: midpoint of the fixed corner
|
|
779
|
+
// and the grabbed corner with the drag delta folded in.
|
|
780
|
+
const c = rectCtx.value;
|
|
781
|
+
worldX = (c.fx + c.mx + dragX.value) / 2;
|
|
782
|
+
worldY = (c.fy + c.my + dragY.value) / 2;
|
|
783
|
+
}
|
|
642
784
|
else if (dragging) {
|
|
643
785
|
worldX = anchorX + dragX.value;
|
|
644
786
|
worldY = anchorY + dragY.value;
|
|
@@ -658,7 +800,7 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
|
|
|
658
800
|
selected,
|
|
659
801
|
size,
|
|
660
802
|
zoom: zoomSnapshot,
|
|
661
|
-
}) }), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", hitSlop: 10, onPress: onRemove, style: {
|
|
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: {
|
|
662
804
|
position: 'absolute',
|
|
663
805
|
top: -8,
|
|
664
806
|
right: -8,
|
|
@@ -10,6 +10,9 @@ type AnimatedPoint = {
|
|
|
10
10
|
y: number;
|
|
11
11
|
};
|
|
12
12
|
};
|
|
13
|
+
type AnimatedNumber = number | {
|
|
14
|
+
value: number;
|
|
15
|
+
};
|
|
13
16
|
export interface AnnotationCanvasSkiaProps {
|
|
14
17
|
width: number;
|
|
15
18
|
height: number;
|
|
@@ -27,20 +30,32 @@ export interface AnnotationCanvasSkiaProps {
|
|
|
27
30
|
color: string;
|
|
28
31
|
width: number;
|
|
29
32
|
cap?: StrokeCap;
|
|
33
|
+
dash?: boolean;
|
|
30
34
|
opacity: number;
|
|
31
35
|
} | null;
|
|
32
36
|
draggingId?: string | null;
|
|
33
37
|
dragTransform?: Transforms3d | {
|
|
34
38
|
value: Transforms3d;
|
|
35
39
|
};
|
|
40
|
+
resizingId?: string | null;
|
|
41
|
+
resizeTransform?: Transforms3d | {
|
|
42
|
+
value: Transforms3d;
|
|
43
|
+
};
|
|
36
44
|
selectedId?: string | null;
|
|
37
45
|
endpointDragId?: string | null;
|
|
38
46
|
liveLineP1?: AnimatedPoint;
|
|
39
47
|
liveLineP2?: AnimatedPoint;
|
|
48
|
+
rectDragId?: string | null;
|
|
49
|
+
liveRect?: {
|
|
50
|
+
x: AnimatedNumber;
|
|
51
|
+
y: AnimatedNumber;
|
|
52
|
+
width: AnimatedNumber;
|
|
53
|
+
height: AnimatedNumber;
|
|
54
|
+
};
|
|
40
55
|
handleRadius?: number | {
|
|
41
56
|
value: number;
|
|
42
57
|
};
|
|
43
58
|
customPreview?: ReactNode;
|
|
44
59
|
}
|
|
45
|
-
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
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;
|
|
46
61
|
export {};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Canvas, Circle, Group, Line, Path, Rect, Skia, } from '@shopify/react-native-skia';
|
|
3
|
-
import { placementOf } from './measurementGeometry.js';
|
|
4
|
-
import { arrowheadTriangle, toSkiaStrokeCap } from './strokeGeometry.js';
|
|
2
|
+
import { Canvas, Circle, DashPathEffect, Group, Line, Path, Rect, Skia, } from '@shopify/react-native-skia';
|
|
3
|
+
import { normalizeRect, placementOf } from './measurementGeometry.js';
|
|
4
|
+
import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from './strokeGeometry.js';
|
|
5
|
+
import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeometry.js';
|
|
5
6
|
import { BackgroundImageElement } from './elements/BackgroundImageElement.js';
|
|
6
7
|
import { ShapeElement } from './elements/ShapeElement.js';
|
|
7
8
|
import { StrokeElement } from './elements/StrokeElement.js';
|
|
@@ -17,8 +18,9 @@ const MEASUREMENT_HANDLE_COLOR = '#0066FF';
|
|
|
17
18
|
// Selection bounding box for strokes/shapes (measurements get handles instead).
|
|
18
19
|
// Padding + stroke width are in doc units (scale with zoom) — simple and clear;
|
|
19
20
|
// the box is only a "this is selected" affordance, not a precise gizmo.
|
|
21
|
+
// SELECTION_PAD lives in textGeometry.ts (Skia-free) so the select tool can
|
|
22
|
+
// hit-test the text resize handle, which sits on the padded box corner.
|
|
20
23
|
const SELECTION_COLOR = '#0066FF';
|
|
21
|
-
const SELECTION_PAD = 6;
|
|
22
24
|
const SELECTION_STROKE = 1.5;
|
|
23
25
|
// Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
|
|
24
26
|
const strokeBounds = (points) => {
|
|
@@ -84,7 +86,22 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
|
|
|
84
86
|
// since the function-call pattern works identically on native we use it
|
|
85
87
|
// in both Inners for consistency. Don't add hooks here; this is a plain
|
|
86
88
|
// JSX-returning helper, not a component.
|
|
87
|
-
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, 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, transform: 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, 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
|
+
// Rectangle annotation: a stroked border whose center carries the
|
|
91
|
+
// tile. A corner drag renders from the live geometry (outside the
|
|
92
|
+
// group translate, like an endpoint drag); otherwise the committed
|
|
93
|
+
// rect + (when selected) four corner handles, wrapped for group move.
|
|
94
|
+
if (placementOf(placed) === 'rectangle' && placed.rect) {
|
|
95
|
+
const isSelected = placed.id === selectedId;
|
|
96
|
+
const isRectDrag = placed.id === rectDragId;
|
|
97
|
+
const rectColor = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
|
|
98
|
+
const rectWidth = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
|
|
99
|
+
if (isRectDrag && liveRect) {
|
|
100
|
+
return (_jsx(Rect, { x: liveRect.x, y: liveRect.y, width: liveRect.width, height: liveRect.height, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }, placed.id));
|
|
101
|
+
}
|
|
102
|
+
const n = normalizeRect(placed.rect);
|
|
103
|
+
return (_jsxs(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: [_jsx(Rect, { x: n.minX, y: n.minY, width: n.maxX - n.minX, height: n.maxY - n.minY, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: { x: n.minX, y: n.minY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.maxX, y: n.minY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.minX, y: n.maxY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.maxX, y: n.maxY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }, placed.id));
|
|
104
|
+
}
|
|
88
105
|
if (placementOf(placed) !== 'line' || !placed.line)
|
|
89
106
|
return null;
|
|
90
107
|
const isEndpointDrag = placed.id === endpointDragId;
|
|
@@ -109,7 +126,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
109
126
|
p.close();
|
|
110
127
|
return p;
|
|
111
128
|
})();
|
|
112
|
-
const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: p1, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: p2, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }));
|
|
129
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap), children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(lineWidth) })) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: p1, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: p2, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }));
|
|
113
130
|
return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
|
|
114
131
|
}), (() => {
|
|
115
132
|
if (!selectedId)
|
|
@@ -122,8 +139,20 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
122
139
|
}
|
|
123
140
|
const shape = effectiveCanvas.shapes.find((s) => s.id === selectedId);
|
|
124
141
|
if (shape) {
|
|
125
|
-
|
|
126
|
-
|
|
142
|
+
// Text shapes derive their box from the estimated text bounds (the
|
|
143
|
+
// stored geometry is just the top-left anchor) and add a corner
|
|
144
|
+
// resize handle; both track the live resize transform so the chrome
|
|
145
|
+
// scales with the shape during a native UI-thread resize.
|
|
146
|
+
const isText = shape.kind === 'text';
|
|
147
|
+
const b = isText
|
|
148
|
+
? textShapeBounds(shape)
|
|
149
|
+
: pointsBounds(shape.geometry.points);
|
|
150
|
+
if (!b)
|
|
151
|
+
return null;
|
|
152
|
+
const isResizing = shape.id === resizingId;
|
|
153
|
+
const liveTransform = isResizing ? resizeTransform : dragTransform;
|
|
154
|
+
const resizeGeom = isText ? textResizeGeometry(shape) : null;
|
|
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 }) }))] }));
|
|
127
156
|
}
|
|
128
157
|
return null;
|
|
129
|
-
})(), 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 })), customPreview] }) }));
|
|
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] }) }));
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { ReactNode, ComponentType } from 'react';
|
|
2
2
|
import type { AnnotationCanvasState, AnnotationDocumentPatch, AnnotationElement, AnnotationElementId, Selection, StrokeCap, Vec2 } from '../../types/annotation.js';
|
|
3
3
|
import type { MeasurementRef } from './measurementPicker.js';
|
|
4
|
+
import type { RectCorner } from './measurementGeometry.js';
|
|
5
|
+
import type { ResizeGeometry } from './textGeometry.js';
|
|
4
6
|
import type { ViewportApi } from './viewport.js';
|
|
7
|
+
export type RequestTextInput = (options?: {
|
|
8
|
+
initialText?: string;
|
|
9
|
+
}) => Promise<string | null>;
|
|
5
10
|
export interface CanvasPointerEvent {
|
|
6
11
|
pointerId: number;
|
|
7
12
|
world: Vec2;
|
|
@@ -20,6 +25,9 @@ export interface ToolContext {
|
|
|
20
25
|
commit(patch: AnnotationDocumentPatch): void;
|
|
21
26
|
setSelection(selection: Selection | null): void;
|
|
22
27
|
requestPickMeasurement(): Promise<MeasurementRef | null>;
|
|
28
|
+
requestTextInput(options?: {
|
|
29
|
+
initialText?: string;
|
|
30
|
+
}): Promise<string | null>;
|
|
23
31
|
applyPan(deltaScreen: Vec2): void;
|
|
24
32
|
applyZoom(focalScreen: Vec2, nextZoom: number): void;
|
|
25
33
|
}
|
|
@@ -29,6 +37,7 @@ export interface FreehandConfig {
|
|
|
29
37
|
color: string;
|
|
30
38
|
width: number;
|
|
31
39
|
cap?: StrokeCap;
|
|
40
|
+
dash?: boolean;
|
|
32
41
|
minSampleDistance: number;
|
|
33
42
|
}
|
|
34
43
|
export type DragElementKind = 'stroke' | 'shape' | 'measurement';
|
|
@@ -42,6 +51,14 @@ export interface DragSelectionConfig {
|
|
|
42
51
|
buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
|
|
43
52
|
hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
|
|
44
53
|
buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
|
|
54
|
+
hitTestRectCorner?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): {
|
|
55
|
+
corner: RectCorner;
|
|
56
|
+
moving: Vec2;
|
|
57
|
+
fixed: Vec2;
|
|
58
|
+
} | null;
|
|
59
|
+
buildRectCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
|
|
60
|
+
hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
|
|
61
|
+
buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
|
|
45
62
|
}
|
|
46
63
|
export interface Tool {
|
|
47
64
|
id: string;
|
|
@@ -2,7 +2,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Image, useImage } from '@shopify/react-native-skia';
|
|
3
3
|
import { memo, useEffect, useRef, useState } from 'react';
|
|
4
4
|
const computeFit = (imgW, imgH, docW, docH, fit) => {
|
|
5
|
-
|
|
5
|
+
// Guard against unknown/zero image dimensions: dividing by them yields
|
|
6
|
+
// Infinity → NaN geometry, and a <Image> with NaN bounds renders nothing
|
|
7
|
+
// (a silent "background never appears"). Fall back to filling the doc.
|
|
8
|
+
if (!(imgW > 0) || !(imgH > 0) || fit === 'stretch') {
|
|
6
9
|
return { x: 0, y: 0, width: docW, height: docH };
|
|
7
10
|
}
|
|
8
11
|
const scale = fit === 'cover'
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Circle, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
|
|
2
|
+
import { Circle, DashPathEffect, Group, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
|
|
3
3
|
import { memo, useMemo } from 'react';
|
|
4
|
+
import { dashIntervals } from '../strokeGeometry.js';
|
|
5
|
+
import { DEFAULT_TEXT_FONT_SIZE, TEXT_BASELINE_FACTOR, TEXT_LINE_HEIGHT_FACTOR, } from '../textGeometry.js';
|
|
6
|
+
// Outline width of dash-styled text, as a fraction of the base font size.
|
|
7
|
+
// Drawn pre-scale (inside the fontSize/baseSize Group), so the outline — and
|
|
8
|
+
// the dash pattern derived from it — scales with the glyphs.
|
|
9
|
+
const TEXT_OUTLINE_WIDTH_FACTOR = 1 / 16;
|
|
4
10
|
const polygonPath = (points) => {
|
|
5
11
|
const path = Skia.Path.Make();
|
|
6
12
|
if (points.length === 0)
|
|
@@ -53,12 +59,32 @@ export const ShapeElement = memo(({ shape, font }) => {
|
|
|
53
59
|
return (_jsxs(_Fragment, { children: [fill && _jsx(Path, { path: polyPath, color: fill }), _jsx(Path, { path: polyPath, color: stroke, style: "stroke", strokeWidth: strokeWidth })] }));
|
|
54
60
|
}
|
|
55
61
|
case 'text': {
|
|
62
|
+
// `origin` is the TOP-LEFT of the text block (textGeometry derives
|
|
63
|
+
// bounds/hit boxes from the same anchor + factors, so chrome and glyphs
|
|
64
|
+
// agree). The shared font is loaded at one fixed size; style.fontSize is
|
|
65
|
+
// honored by scaling the glyph outlines about the anchor — Skia text is
|
|
66
|
+
// vector, so this is loss-free. Lines are laid out with the textGeometry
|
|
67
|
+
// factors; Skia <Text> y is the BASELINE, hence the baseline offset.
|
|
56
68
|
const [origin] = geometry.points;
|
|
57
69
|
if (!origin || !text)
|
|
58
70
|
return null;
|
|
59
71
|
if (!font)
|
|
60
72
|
return null;
|
|
61
|
-
|
|
73
|
+
const fontSize = style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
74
|
+
const baseSize = font.getSize();
|
|
75
|
+
const scale = baseSize > 0 ? fontSize / baseSize : 1;
|
|
76
|
+
// Dash-styled text strokes the glyph outlines (a dash effect is a path
|
|
77
|
+
// effect — it needs stroked geometry, so it can't apply to filled
|
|
78
|
+
// glyphs) and runs the shared dash pattern along them.
|
|
79
|
+
const outlineWidth = baseSize * TEXT_OUTLINE_WIDTH_FACTOR;
|
|
80
|
+
return (_jsx(Group, { transform: [
|
|
81
|
+
{ translateX: origin.x },
|
|
82
|
+
{ translateY: origin.y },
|
|
83
|
+
{ scale },
|
|
84
|
+
], children: text.split('\n').map((line, i) => (_jsx(Text, { x: 0, y: (i * TEXT_LINE_HEIGHT_FACTOR + TEXT_BASELINE_FACTOR) * baseSize, text: line, font: font, color: stroke, ...(style.dash && {
|
|
85
|
+
style: 'stroke',
|
|
86
|
+
strokeWidth: outlineWidth,
|
|
87
|
+
}), children: style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(outlineWidth) })) }, i))) }));
|
|
62
88
|
}
|
|
63
89
|
}
|
|
64
90
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Path, Skia } from '@shopify/react-native-skia';
|
|
2
|
+
import { DashPathEffect, Path, Skia, } from '@shopify/react-native-skia';
|
|
3
3
|
import { memo, useMemo } from 'react';
|
|
4
|
-
import { arrowheadTriangle, toSkiaStrokeCap } from '../strokeGeometry.js';
|
|
4
|
+
import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from '../strokeGeometry.js';
|
|
5
5
|
export const pointsToSkPath = (points) => {
|
|
6
6
|
const path = Skia.Path.Make();
|
|
7
7
|
if (points.length < 2)
|
|
@@ -36,5 +36,10 @@ export const StrokeElement = memo(({ stroke }) => {
|
|
|
36
36
|
const path = useMemo(() => pointsToSkPath(stroke.points), [stroke.points]);
|
|
37
37
|
const arrow = useMemo(() => arrowheadPath(stroke), [stroke.points, stroke.cap, stroke.width]);
|
|
38
38
|
const opacity = stroke.tool === 'highlighter' ? 0.3 : 1;
|
|
39
|
-
|
|
39
|
+
// A tap-dot is a zero-length two-point path; a dash effect would erase it
|
|
40
|
+
// (the contour has no length to dash), so dots always render solid.
|
|
41
|
+
const isDot = stroke.points.length === 4 &&
|
|
42
|
+
stroke.points[0] === stroke.points[2] &&
|
|
43
|
+
stroke.points[1] === stroke.points[3];
|
|
44
|
+
return (_jsxs(_Fragment, { children: [_jsx(Path, { path: path, color: stroke.color, style: "stroke", strokeWidth: stroke.width, strokeCap: toSkiaStrokeCap(stroke.cap), strokeJoin: "round", opacity: opacity, children: stroke.dash && !isDot && (_jsx(DashPathEffect, { intervals: dashIntervals(stroke.width) })) }), arrow && (_jsx(Path, { path: arrow, color: stroke.color, style: "fill", opacity: opacity }))] }));
|
|
40
45
|
});
|