@reekon-tools/boldr-utils 1.6.11 → 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/{canvas → annotation/canvas}/AnnotationCanvasInner.d.ts +5 -3
- package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.js +36 -17
- package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.native.d.ts +5 -3
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +810 -0
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +61 -0
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +158 -0
- package/dist/annotation/canvas/Tool.d.ts +77 -0
- package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.d.ts +2 -2
- package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.js +17 -7
- package/dist/annotation/canvas/elements/ShapeElement.d.ts +7 -0
- package/dist/{canvas → annotation/canvas}/elements/ShapeElement.js +33 -5
- package/dist/annotation/canvas/elements/StrokeElement.d.ts +7 -0
- package/dist/annotation/canvas/elements/StrokeElement.js +45 -0
- package/dist/annotation/canvas/measurementGeometry.d.ts +43 -0
- package/dist/annotation/canvas/measurementGeometry.js +111 -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 +5 -0
- package/dist/annotation/canvas/strokeGeometry.js +41 -0
- package/dist/annotation/canvas/textGeometry.d.ts +24 -0
- package/dist/annotation/canvas/textGeometry.js +110 -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 +3 -1
- package/dist/{canvas → annotation/canvas}/tools/penTool.js +34 -5
- package/dist/annotation/canvas/tools/selectTool.js +446 -0
- package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
- package/dist/annotation/canvas/tools/textTool.js +78 -0
- package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.d.ts +11 -3
- package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.js +142 -2
- 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/annotation/data/coalescedRunner.d.ts +1 -0
- package/dist/annotation/data/coalescedRunner.js +48 -0
- package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.d.ts +1 -1
- package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.js +37 -16
- package/dist/exports.d.ts +23 -19
- package/dist/exports.js +18 -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 +22 -3
- package/dist/types/firestore.d.ts +0 -1
- 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/Tool.d.ts +0 -38
- 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,61 @@
|
|
|
1
|
+
import { type SkFont, type SkPath, type Transforms3d } from '@shopify/react-native-skia';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import type { AnnotationCanvasState, AnnotationStroke, StrokeCap } from '../../types/annotation.js';
|
|
4
|
+
type AnimatedPoint = {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
} | {
|
|
8
|
+
value: {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
type AnimatedNumber = number | {
|
|
14
|
+
value: number;
|
|
15
|
+
};
|
|
16
|
+
export interface AnnotationCanvasSkiaProps {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
effectiveCanvas: AnnotationCanvasState;
|
|
20
|
+
worldTransform: Transforms3d | {
|
|
21
|
+
value: Transforms3d;
|
|
22
|
+
};
|
|
23
|
+
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
24
|
+
valueFont: SkFont | null;
|
|
25
|
+
penDrawingStroke: AnnotationStroke | null;
|
|
26
|
+
livePreview?: {
|
|
27
|
+
path: SkPath | {
|
|
28
|
+
value: SkPath;
|
|
29
|
+
};
|
|
30
|
+
color: string;
|
|
31
|
+
width: number;
|
|
32
|
+
cap?: StrokeCap;
|
|
33
|
+
dash?: boolean;
|
|
34
|
+
opacity: number;
|
|
35
|
+
} | null;
|
|
36
|
+
draggingId?: string | null;
|
|
37
|
+
dragTransform?: Transforms3d | {
|
|
38
|
+
value: Transforms3d;
|
|
39
|
+
};
|
|
40
|
+
resizingId?: string | null;
|
|
41
|
+
resizeTransform?: Transforms3d | {
|
|
42
|
+
value: Transforms3d;
|
|
43
|
+
};
|
|
44
|
+
selectedId?: string | null;
|
|
45
|
+
endpointDragId?: string | null;
|
|
46
|
+
liveLineP1?: AnimatedPoint;
|
|
47
|
+
liveLineP2?: AnimatedPoint;
|
|
48
|
+
rectDragId?: string | null;
|
|
49
|
+
liveRect?: {
|
|
50
|
+
x: AnimatedNumber;
|
|
51
|
+
y: AnimatedNumber;
|
|
52
|
+
width: AnimatedNumber;
|
|
53
|
+
height: AnimatedNumber;
|
|
54
|
+
};
|
|
55
|
+
handleRadius?: number | {
|
|
56
|
+
value: number;
|
|
57
|
+
};
|
|
58
|
+
customPreview?: ReactNode;
|
|
59
|
+
}
|
|
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;
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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';
|
|
6
|
+
import { BackgroundImageElement } from './elements/BackgroundImageElement.js';
|
|
7
|
+
import { ShapeElement } from './elements/ShapeElement.js';
|
|
8
|
+
import { StrokeElement } from './elements/StrokeElement.js';
|
|
9
|
+
// Default visual constants for the measurement-annotation line (the tile itself
|
|
10
|
+
// is a fixed-size overlay; only the line lives in Skia). Width is in doc units,
|
|
11
|
+
// so it scales with zoom like the drawn line/arrow shapes. These are fallbacks:
|
|
12
|
+
// a placed annotation may override them via `lineColor`/`lineWidth`/`lineCap`.
|
|
13
|
+
const MEASUREMENT_LINE_COLOR = '#0066FF';
|
|
14
|
+
const MEASUREMENT_LINE_WIDTH = 2;
|
|
15
|
+
// Endpoint handle color — constant selection chrome, independent of the (now
|
|
16
|
+
// editable) line color so the handles stay legible on any line color.
|
|
17
|
+
const MEASUREMENT_HANDLE_COLOR = '#0066FF';
|
|
18
|
+
// Selection bounding box for strokes/shapes (measurements get handles instead).
|
|
19
|
+
// Padding + stroke width are in doc units (scale with zoom) — simple and clear;
|
|
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.
|
|
23
|
+
const SELECTION_COLOR = '#0066FF';
|
|
24
|
+
const SELECTION_STROKE = 1.5;
|
|
25
|
+
// Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
|
|
26
|
+
const strokeBounds = (points) => {
|
|
27
|
+
if (points.length < 2)
|
|
28
|
+
return null;
|
|
29
|
+
let minX = points[0];
|
|
30
|
+
let maxX = points[0];
|
|
31
|
+
let minY = points[1];
|
|
32
|
+
let maxY = points[1];
|
|
33
|
+
for (let i = 2; i < points.length; i += 2) {
|
|
34
|
+
const x = points[i];
|
|
35
|
+
const y = points[i + 1];
|
|
36
|
+
if (x < minX)
|
|
37
|
+
minX = x;
|
|
38
|
+
if (x > maxX)
|
|
39
|
+
maxX = x;
|
|
40
|
+
if (y < minY)
|
|
41
|
+
minY = y;
|
|
42
|
+
if (y > maxY)
|
|
43
|
+
maxY = y;
|
|
44
|
+
}
|
|
45
|
+
return { minX, minY, maxX, maxY };
|
|
46
|
+
};
|
|
47
|
+
// Bounds of a Vec2[] (shape geometry), or null if empty.
|
|
48
|
+
const pointsBounds = (pts) => {
|
|
49
|
+
if (pts.length === 0)
|
|
50
|
+
return null;
|
|
51
|
+
let { x: minX, y: minY } = pts[0];
|
|
52
|
+
let { x: maxX, y: maxY } = pts[0];
|
|
53
|
+
for (let i = 1; i < pts.length; i++) {
|
|
54
|
+
const p = pts[i];
|
|
55
|
+
if (p.x < minX)
|
|
56
|
+
minX = p.x;
|
|
57
|
+
if (p.x > maxX)
|
|
58
|
+
maxX = p.x;
|
|
59
|
+
if (p.y < minY)
|
|
60
|
+
minY = p.y;
|
|
61
|
+
if (p.y > maxY)
|
|
62
|
+
maxY = p.y;
|
|
63
|
+
}
|
|
64
|
+
return { minX, minY, maxX, maxY };
|
|
65
|
+
};
|
|
66
|
+
// Wraps a single element in the live drag transform while it's being dragged
|
|
67
|
+
// (native, UI-thread). When not dragging it renders the element untouched, so
|
|
68
|
+
// the element's React.memo still bails out on unrelated re-renders.
|
|
69
|
+
const DraggableElement = ({ isDragging, transform, children, }) => isDragging && transform ? (_jsx(Group, { transform: transform, children: children })) : (_jsx(_Fragment, { children: children }));
|
|
70
|
+
// Bounding-box outline around a selected stroke/shape. Padded in doc space and
|
|
71
|
+
// wrapped in DraggableElement so it tracks the element during a group drag.
|
|
72
|
+
const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElement, { isDragging: isDragging, transform: transform, children: _jsx(Rect, { x: bounds.minX - SELECTION_PAD, y: bounds.minY - SELECTION_PAD, width: bounds.maxX - bounds.minX + SELECTION_PAD * 2, height: bounds.maxY - bounds.minY + SELECTION_PAD * 2, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }) }));
|
|
73
|
+
// Platform-agnostic Skia subtree shared by web and native Inners.
|
|
74
|
+
//
|
|
75
|
+
// Placed measurements are NOT drawn here — they render as a non-interactive
|
|
76
|
+
// RN/DOM overlay of real tile components (`renderMeasurementStamp`) positioned
|
|
77
|
+
// over this canvas by the Inner. This subtree owns only the vector content
|
|
78
|
+
// (background, strokes, shapes) and the live tool previews.
|
|
79
|
+
//
|
|
80
|
+
// Call this as a FUNCTION (`AnnotationCanvasSkia({ ... })`), not as a JSX
|
|
81
|
+
// component (`<AnnotationCanvasSkia ... />`). Used as a component, it adds
|
|
82
|
+
// a React component boundary between the parent and Skia's `<Canvas>` that
|
|
83
|
+
// breaks Skia's reconciler when the page first calls `useFont` — the JS
|
|
84
|
+
// thread hangs in `MakeFreeTypeFaceFromData`. Symptoms only appeared on
|
|
85
|
+
// Vite's dev server with a symlinked boldr-utils + React Refresh, but
|
|
86
|
+
// since the function-call pattern works identically on native we use it
|
|
87
|
+
// in both Inners for consistency. Don't add hooks here; this is a plain
|
|
88
|
+
// JSX-returning helper, not a component.
|
|
89
|
+
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
|
|
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
|
+
}
|
|
105
|
+
if (placementOf(placed) !== 'line' || !placed.line)
|
|
106
|
+
return null;
|
|
107
|
+
const isEndpointDrag = placed.id === endpointDragId;
|
|
108
|
+
const isSelected = placed.id === selectedId;
|
|
109
|
+
const p1 = isEndpointDrag && liveLineP1 ? liveLineP1 : placed.line.a;
|
|
110
|
+
const p2 = isEndpointDrag && liveLineP2 ? liveLineP2 : placed.line.b;
|
|
111
|
+
const lineColor = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
|
|
112
|
+
const lineWidth = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
|
|
113
|
+
// Solid filled-triangle arrowhead at endpoint b (the line's "end").
|
|
114
|
+
// Built from the static endpoints, so it's skipped during a live
|
|
115
|
+
// endpoint drag (it reappears on release) rather than lagging the
|
|
116
|
+
// moving finger.
|
|
117
|
+
const arrowPath = (() => {
|
|
118
|
+
if (placed.lineCap !== 'arrow' || isEndpointDrag || !placed.line) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const [apex, baseL, baseR] = arrowheadTriangle(placed.line.b, placed.line.a, lineWidth);
|
|
122
|
+
const p = Skia.Path.Make();
|
|
123
|
+
p.moveTo(apex.x, apex.y);
|
|
124
|
+
p.lineTo(baseL.x, baseL.y);
|
|
125
|
+
p.lineTo(baseR.x, baseR.y);
|
|
126
|
+
p.close();
|
|
127
|
+
return p;
|
|
128
|
+
})();
|
|
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 })] }))] }));
|
|
130
|
+
return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
|
|
131
|
+
}), (() => {
|
|
132
|
+
if (!selectedId)
|
|
133
|
+
return null;
|
|
134
|
+
const isDragging = selectedId === draggingId;
|
|
135
|
+
const stroke = effectiveCanvas.strokes.find((s) => s.id === selectedId);
|
|
136
|
+
if (stroke) {
|
|
137
|
+
const b = strokeBounds(stroke.points);
|
|
138
|
+
return b ? (_jsx(SelectionBox, { bounds: b, isDragging: isDragging, transform: dragTransform })) : null;
|
|
139
|
+
}
|
|
140
|
+
const shape = effectiveCanvas.shapes.find((s) => s.id === selectedId);
|
|
141
|
+
if (shape) {
|
|
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 }) }))] }));
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
})(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) })), customPreview] }) }));
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ReactNode, ComponentType } from 'react';
|
|
2
|
+
import type { AnnotationCanvasState, AnnotationDocumentPatch, AnnotationElement, AnnotationElementId, Selection, StrokeCap, Vec2 } from '../../types/annotation.js';
|
|
3
|
+
import type { MeasurementRef } from './measurementPicker.js';
|
|
4
|
+
import type { RectCorner } from './measurementGeometry.js';
|
|
5
|
+
import type { ResizeGeometry } from './textGeometry.js';
|
|
6
|
+
import type { ViewportApi } from './viewport.js';
|
|
7
|
+
export type RequestTextInput = (options?: {
|
|
8
|
+
initialText?: string;
|
|
9
|
+
}) => Promise<string | null>;
|
|
10
|
+
export interface CanvasPointerEvent {
|
|
11
|
+
pointerId: number;
|
|
12
|
+
world: Vec2;
|
|
13
|
+
screen: Vec2;
|
|
14
|
+
pressure?: number;
|
|
15
|
+
shiftKey?: boolean;
|
|
16
|
+
altKey?: boolean;
|
|
17
|
+
metaKey?: boolean;
|
|
18
|
+
ctrlKey?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ToolContext {
|
|
21
|
+
document: AnnotationCanvasState;
|
|
22
|
+
selection: Selection | null;
|
|
23
|
+
viewport: ViewportApi;
|
|
24
|
+
preview(patch: AnnotationDocumentPatch): void;
|
|
25
|
+
commit(patch: AnnotationDocumentPatch): void;
|
|
26
|
+
setSelection(selection: Selection | null): void;
|
|
27
|
+
requestPickMeasurement(): Promise<MeasurementRef | null>;
|
|
28
|
+
requestTextInput(options?: {
|
|
29
|
+
initialText?: string;
|
|
30
|
+
}): Promise<string | null>;
|
|
31
|
+
applyPan(deltaScreen: Vec2): void;
|
|
32
|
+
applyZoom(focalScreen: Vec2, nextZoom: number): void;
|
|
33
|
+
}
|
|
34
|
+
export type ToolState = unknown;
|
|
35
|
+
export interface FreehandConfig {
|
|
36
|
+
variant: 'pen' | 'marker' | 'highlighter';
|
|
37
|
+
color: string;
|
|
38
|
+
width: number;
|
|
39
|
+
cap?: StrokeCap;
|
|
40
|
+
dash?: boolean;
|
|
41
|
+
minSampleDistance: number;
|
|
42
|
+
}
|
|
43
|
+
export type DragElementKind = 'stroke' | 'shape' | 'measurement';
|
|
44
|
+
export interface DragSelectionConfig {
|
|
45
|
+
hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number): {
|
|
46
|
+
id: AnnotationElementId;
|
|
47
|
+
kind: DragElementKind;
|
|
48
|
+
} | null;
|
|
49
|
+
buildTranslatePatch(doc: AnnotationCanvasState, id: AnnotationElementId, kind: DragElementKind, delta: Vec2): AnnotationDocumentPatch | null;
|
|
50
|
+
classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'slide' | 'move';
|
|
51
|
+
buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
|
|
52
|
+
hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
|
|
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;
|
|
62
|
+
}
|
|
63
|
+
export interface Tool {
|
|
64
|
+
id: string;
|
|
65
|
+
label: string;
|
|
66
|
+
icon?: ComponentType;
|
|
67
|
+
cursor?: string;
|
|
68
|
+
freehand?: FreehandConfig;
|
|
69
|
+
panViewport?: boolean;
|
|
70
|
+
dragSelection?: DragSelectionConfig;
|
|
71
|
+
onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
72
|
+
onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
73
|
+
onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
74
|
+
onCancel?(state: ToolState, ctx: ToolContext): void;
|
|
75
|
+
renderPreview?(state: ToolState, ctx: ToolContext): ReactNode;
|
|
76
|
+
hitTest?(element: AnnotationElement, worldPoint: Vec2): boolean;
|
|
77
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AnnotationBackgroundImage, BackgroundFit } from '
|
|
1
|
+
import type { AnnotationBackgroundImage, BackgroundFit } from '../../../types/annotation.js';
|
|
2
2
|
export interface BackgroundImageElementProps {
|
|
3
3
|
image: AnnotationBackgroundImage;
|
|
4
4
|
docWidth: number;
|
|
@@ -6,4 +6,4 @@ export interface BackgroundImageElementProps {
|
|
|
6
6
|
fit?: BackgroundFit;
|
|
7
7
|
resolveUrl?: (path: string) => Promise<string>;
|
|
8
8
|
}
|
|
9
|
-
export declare const BackgroundImageElement: ({ image, docWidth, docHeight, fit, resolveUrl, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null
|
|
9
|
+
export declare const BackgroundImageElement: import("react").MemoExoticComponent<({ image, docWidth, docHeight, fit, resolveUrl, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null>;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Image, useImage } from '@shopify/react-native-skia';
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
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'
|
|
@@ -12,12 +15,19 @@ const computeFit = (imgW, imgH, docW, docH, fit) => {
|
|
|
12
15
|
const h = imgH * scale;
|
|
13
16
|
return { x: (docW - w) / 2, y: (docH - h) / 2, width: w, height: h };
|
|
14
17
|
};
|
|
15
|
-
export const BackgroundImageElement = ({ image, docWidth, docHeight, fit = 'contain', resolveUrl, }) => {
|
|
18
|
+
export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, }) => {
|
|
16
19
|
const [url, setUrl] = useState(image.downloadUrl);
|
|
20
|
+
// Hold resolveUrl in a ref so re-resolution is keyed only on the image
|
|
21
|
+
// identity, not the function's. Consumers commonly pass an inline closure
|
|
22
|
+
// (a new reference each render); depending on it directly would re-run a
|
|
23
|
+
// network URL resolve on every render.
|
|
24
|
+
const resolveUrlRef = useRef(resolveUrl);
|
|
25
|
+
resolveUrlRef.current = resolveUrl;
|
|
17
26
|
useEffect(() => {
|
|
18
27
|
let cancelled = false;
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
const resolve = resolveUrlRef.current;
|
|
29
|
+
if (resolve) {
|
|
30
|
+
resolve(image.storagePath).then((next) => {
|
|
21
31
|
if (!cancelled)
|
|
22
32
|
setUrl(next);
|
|
23
33
|
});
|
|
@@ -28,10 +38,10 @@ export const BackgroundImageElement = ({ image, docWidth, docHeight, fit = 'cont
|
|
|
28
38
|
return () => {
|
|
29
39
|
cancelled = true;
|
|
30
40
|
};
|
|
31
|
-
}, [image.downloadUrl, image.storagePath
|
|
41
|
+
}, [image.downloadUrl, image.storagePath]);
|
|
32
42
|
const skImage = useImage(url);
|
|
33
43
|
if (!skImage)
|
|
34
44
|
return null;
|
|
35
45
|
const dims = computeFit(image.widthPx, image.heightPx, docWidth, docHeight, fit);
|
|
36
46
|
return (_jsx(Image, { image: skImage, x: dims.x, y: dims.y, width: dims.width, height: dims.height, fit: "fill" }));
|
|
37
|
-
};
|
|
47
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SkFont } from '@shopify/react-native-skia';
|
|
2
|
+
import type { AnnotationShape } from '../../../types/annotation.js';
|
|
3
|
+
export interface ShapeElementProps {
|
|
4
|
+
shape: AnnotationShape;
|
|
5
|
+
font?: SkFont | null;
|
|
6
|
+
}
|
|
7
|
+
export declare const ShapeElement: import("react").MemoExoticComponent<({ shape, font }: ShapeElementProps) => import("react/jsx-runtime").JSX.Element | null>;
|
|
@@ -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';
|
|
3
|
-
import { useMemo } from 'react';
|
|
2
|
+
import { Circle, DashPathEffect, Group, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
|
|
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)
|
|
@@ -12,7 +18,9 @@ const polygonPath = (points) => {
|
|
|
12
18
|
path.close();
|
|
13
19
|
return path;
|
|
14
20
|
};
|
|
15
|
-
|
|
21
|
+
// Memoized — see StrokeElement. Unchanged shapes keep their identity across
|
|
22
|
+
// applyPatch, so only the edited shape (and the shared font) re-render.
|
|
23
|
+
export const ShapeElement = memo(({ shape, font }) => {
|
|
16
24
|
const { kind, geometry, style, text } = shape;
|
|
17
25
|
const stroke = style.stroke ?? '#000000';
|
|
18
26
|
const fill = style.fill;
|
|
@@ -51,12 +59,32 @@ export const ShapeElement = ({ shape, font }) => {
|
|
|
51
59
|
return (_jsxs(_Fragment, { children: [fill && _jsx(Path, { path: polyPath, color: fill }), _jsx(Path, { path: polyPath, color: stroke, style: "stroke", strokeWidth: strokeWidth })] }));
|
|
52
60
|
}
|
|
53
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.
|
|
54
68
|
const [origin] = geometry.points;
|
|
55
69
|
if (!origin || !text)
|
|
56
70
|
return null;
|
|
57
71
|
if (!font)
|
|
58
72
|
return null;
|
|
59
|
-
|
|
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))) }));
|
|
60
88
|
}
|
|
61
89
|
}
|
|
62
|
-
};
|
|
90
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SkPath } from '@shopify/react-native-skia';
|
|
2
|
+
import type { AnnotationStroke } from '../../../types/annotation.js';
|
|
3
|
+
export declare const pointsToSkPath: (points: number[]) => SkPath;
|
|
4
|
+
export interface StrokeElementProps {
|
|
5
|
+
stroke: AnnotationStroke;
|
|
6
|
+
}
|
|
7
|
+
export declare const StrokeElement: import("react").MemoExoticComponent<({ stroke }: StrokeElementProps) => import("react/jsx-runtime").JSX.Element>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { DashPathEffect, Path, Skia, } from '@shopify/react-native-skia';
|
|
3
|
+
import { memo, useMemo } from 'react';
|
|
4
|
+
import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from '../strokeGeometry.js';
|
|
5
|
+
export const pointsToSkPath = (points) => {
|
|
6
|
+
const path = Skia.Path.Make();
|
|
7
|
+
if (points.length < 2)
|
|
8
|
+
return path;
|
|
9
|
+
path.moveTo(points[0], points[1]);
|
|
10
|
+
for (let i = 2; i < points.length; i += 2) {
|
|
11
|
+
path.lineTo(points[i], points[i + 1]);
|
|
12
|
+
}
|
|
13
|
+
return path;
|
|
14
|
+
};
|
|
15
|
+
// A solid filled-triangle arrowhead at the stroke's end (vertex at the last
|
|
16
|
+
// point, base corners back along the final segment), or null when the cap isn't
|
|
17
|
+
// 'arrow'. Rendered as a separate filled <Path> on top of the stroked line.
|
|
18
|
+
const arrowheadPath = (stroke) => {
|
|
19
|
+
const n = stroke.points.length;
|
|
20
|
+
if (stroke.cap !== 'arrow' || n < 4)
|
|
21
|
+
return null;
|
|
22
|
+
const end = { x: stroke.points[n - 2], y: stroke.points[n - 1] };
|
|
23
|
+
const from = { x: stroke.points[n - 4], y: stroke.points[n - 3] };
|
|
24
|
+
const [apex, baseL, baseR] = arrowheadTriangle(end, from, stroke.width);
|
|
25
|
+
const path = Skia.Path.Make();
|
|
26
|
+
path.moveTo(apex.x, apex.y);
|
|
27
|
+
path.lineTo(baseL.x, baseL.y);
|
|
28
|
+
path.lineTo(baseR.x, baseR.y);
|
|
29
|
+
path.close();
|
|
30
|
+
return path;
|
|
31
|
+
};
|
|
32
|
+
// Memoized: the canvas re-renders the whole element list on every commit,
|
|
33
|
+
// preview, and selection change, but applyPatch preserves the identity of
|
|
34
|
+
// unchanged strokes — so memo lets all but the changed stroke bail out.
|
|
35
|
+
export const StrokeElement = memo(({ stroke }) => {
|
|
36
|
+
const path = useMemo(() => pointsToSkPath(stroke.points), [stroke.points]);
|
|
37
|
+
const arrow = useMemo(() => arrowheadPath(stroke), [stroke.points, stroke.cap, stroke.width]);
|
|
38
|
+
const opacity = stroke.tool === 'highlighter' ? 0.3 : 1;
|
|
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 }))] }));
|
|
45
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AnnotationPatchOp, MeasurementPlacement, PlacedMeasurementRef, Vec2 } from '../../types/annotation.js';
|
|
2
|
+
export declare const DEFAULT_LINE_POS = 0.5;
|
|
3
|
+
export declare const lerp: (a: Vec2, b: Vec2, t: number) => Vec2;
|
|
4
|
+
export declare const placementOf: (m: Pick<PlacedMeasurementRef, "placement">) => MeasurementPlacement;
|
|
5
|
+
export declare const linePosOf: (m: Pick<PlacedMeasurementRef, "linePos">) => number;
|
|
6
|
+
export declare const recomputeAnchor: (line: {
|
|
7
|
+
a: Vec2;
|
|
8
|
+
b: Vec2;
|
|
9
|
+
} | undefined, placement: MeasurementPlacement, linePos: number, fallbackAnchor: Vec2) => Vec2;
|
|
10
|
+
export declare const projectToLinePos: (line: {
|
|
11
|
+
a: Vec2;
|
|
12
|
+
b: Vec2;
|
|
13
|
+
}, world: Vec2) => number;
|
|
14
|
+
export declare const snapLinePos: (t: number, threshold?: number) => number;
|
|
15
|
+
export declare const lineLength: (line: {
|
|
16
|
+
a: Vec2;
|
|
17
|
+
b: Vec2;
|
|
18
|
+
}) => number;
|
|
19
|
+
export interface NormalizedRect {
|
|
20
|
+
minX: number;
|
|
21
|
+
minY: number;
|
|
22
|
+
maxX: number;
|
|
23
|
+
maxY: number;
|
|
24
|
+
}
|
|
25
|
+
export declare const normalizeRect: (rect: {
|
|
26
|
+
a: Vec2;
|
|
27
|
+
b: Vec2;
|
|
28
|
+
}) => NormalizedRect;
|
|
29
|
+
export declare const rectCenter: (rect: {
|
|
30
|
+
a: Vec2;
|
|
31
|
+
b: Vec2;
|
|
32
|
+
}) => Vec2;
|
|
33
|
+
export type RectCorner = 'tl' | 'tr' | 'bl' | 'br';
|
|
34
|
+
export declare const rectCornerPoint: (rect: {
|
|
35
|
+
a: Vec2;
|
|
36
|
+
b: Vec2;
|
|
37
|
+
}, corner: RectCorner) => Vec2;
|
|
38
|
+
export declare const oppositeRectCorner: (corner: RectCorner) => RectCorner;
|
|
39
|
+
export interface RemoveMeasurementResult {
|
|
40
|
+
ops: AnnotationPatchOp[];
|
|
41
|
+
keepSelection: boolean;
|
|
42
|
+
}
|
|
43
|
+
export declare const buildRemoveMeasurementOps: (placed: PlacedMeasurementRef) => RemoveMeasurementResult;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Pure geometry for measurement annotations (the `placement: 'line'` variant
|
|
2
|
+
// of PlacedMeasurementRef). Skia-free and dependency-free so it's safe in tools,
|
|
3
|
+
// hit-tests, and unit tests.
|
|
4
|
+
//
|
|
5
|
+
// NOTE: the native overlay's `useAnimatedStyle` and the live-line derived values
|
|
6
|
+
// run on the UI thread and CANNOT call these functions across the worklet
|
|
7
|
+
// boundary, so they inline the same math. Those inlined copies are the "worklet
|
|
8
|
+
// twins" of these helpers — if you change the math here, change them too (they
|
|
9
|
+
// are commented as such in AnnotationCanvasInner.native.tsx).
|
|
10
|
+
// Default position of the tile along its line when `linePos` is absent: center.
|
|
11
|
+
export const DEFAULT_LINE_POS = 0.5;
|
|
12
|
+
export const lerp = (a, b, t) => ({
|
|
13
|
+
x: a.x + (b.x - a.x) * t,
|
|
14
|
+
y: a.y + (b.y - a.y) * t,
|
|
15
|
+
});
|
|
16
|
+
// Back-compat accessors: a stamp persisted before this feature has neither
|
|
17
|
+
// field, and reads as a bare 'none' stamp centered on its line (if any).
|
|
18
|
+
export const placementOf = (m) => m.placement ?? 'none';
|
|
19
|
+
export const linePosOf = (m) => m.linePos ?? DEFAULT_LINE_POS;
|
|
20
|
+
// The tile anchor implied by a line + placement. For 'line', the tile sits at
|
|
21
|
+
// lerp(a, b, linePos); for everything else the stored anchor stands. Callers
|
|
22
|
+
// must write the result into `anchor` in the same patch that changes
|
|
23
|
+
// `line`/`placement`/`linePos` so the two never drift.
|
|
24
|
+
export const recomputeAnchor = (line, placement, linePos, fallbackAnchor) => {
|
|
25
|
+
if (placement === 'line' && line)
|
|
26
|
+
return lerp(line.a, line.b, linePos);
|
|
27
|
+
return fallbackAnchor;
|
|
28
|
+
};
|
|
29
|
+
// Project a world point onto segment a→b and return the clamped parameter
|
|
30
|
+
// t ∈ [0, 1] (0 = a, 1 = b). A zero-length line returns 0.
|
|
31
|
+
export const projectToLinePos = (line, world) => {
|
|
32
|
+
const abx = line.b.x - line.a.x;
|
|
33
|
+
const aby = line.b.y - line.a.y;
|
|
34
|
+
const lenSq = abx * abx + aby * aby;
|
|
35
|
+
if (lenSq === 0)
|
|
36
|
+
return 0;
|
|
37
|
+
const t = ((world.x - line.a.x) * abx + (world.y - line.a.y) * aby) / lenSq;
|
|
38
|
+
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
39
|
+
};
|
|
40
|
+
// Snap t to center (0.5) when within `threshold` of it. `threshold` is in
|
|
41
|
+
// t-space; for a consistent on-screen detent, callers convert a screen-px
|
|
42
|
+
// radius to t via (radiusPx / zoom) / lineLengthDoc.
|
|
43
|
+
export const snapLinePos = (t, threshold = 0.06) => Math.abs(t - 0.5) <= threshold ? 0.5 : t;
|
|
44
|
+
// Euclidean length of a line in doc space (used to convert a screen-px snap
|
|
45
|
+
// radius into a t-space threshold).
|
|
46
|
+
export const lineLength = (line) => {
|
|
47
|
+
const dx = line.b.x - line.a.x;
|
|
48
|
+
const dy = line.b.y - line.a.y;
|
|
49
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
50
|
+
};
|
|
51
|
+
export const normalizeRect = (rect) => ({
|
|
52
|
+
minX: Math.min(rect.a.x, rect.b.x),
|
|
53
|
+
minY: Math.min(rect.a.y, rect.b.y),
|
|
54
|
+
maxX: Math.max(rect.a.x, rect.b.x),
|
|
55
|
+
maxY: Math.max(rect.a.y, rect.b.y),
|
|
56
|
+
});
|
|
57
|
+
// The tile anchor for a rectangle annotation: always the center. Any op that
|
|
58
|
+
// changes `rect` must write this into `anchor` in the same patch.
|
|
59
|
+
export const rectCenter = (rect) => ({
|
|
60
|
+
x: (rect.a.x + rect.b.x) / 2,
|
|
61
|
+
y: (rect.a.y + rect.b.y) / 2,
|
|
62
|
+
});
|
|
63
|
+
export const rectCornerPoint = (rect, corner) => {
|
|
64
|
+
const n = normalizeRect(rect);
|
|
65
|
+
switch (corner) {
|
|
66
|
+
case 'tl':
|
|
67
|
+
return { x: n.minX, y: n.minY };
|
|
68
|
+
case 'tr':
|
|
69
|
+
return { x: n.maxX, y: n.minY };
|
|
70
|
+
case 'bl':
|
|
71
|
+
return { x: n.minX, y: n.maxY };
|
|
72
|
+
case 'br':
|
|
73
|
+
return { x: n.maxX, y: n.maxY };
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
export const oppositeRectCorner = (corner) => {
|
|
77
|
+
switch (corner) {
|
|
78
|
+
case 'tl':
|
|
79
|
+
return 'br';
|
|
80
|
+
case 'tr':
|
|
81
|
+
return 'bl';
|
|
82
|
+
case 'bl':
|
|
83
|
+
return 'tr';
|
|
84
|
+
case 'br':
|
|
85
|
+
return 'tl';
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
export const buildRemoveMeasurementOps = (placed) => {
|
|
89
|
+
if (placementOf(placed) !== 'none' && placed.measurementId) {
|
|
90
|
+
return {
|
|
91
|
+
ops: [
|
|
92
|
+
{
|
|
93
|
+
op: 'updateMeasurement',
|
|
94
|
+
id: placed.id,
|
|
95
|
+
patch: {
|
|
96
|
+
measurementId: undefined,
|
|
97
|
+
measurementPath: undefined,
|
|
98
|
+
groupId: undefined,
|
|
99
|
+
labelOverride: undefined,
|
|
100
|
+
unitOverride: undefined,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
keepSelection: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
ops: [{ op: 'removeMeasurement', id: placed.id }],
|
|
109
|
+
keepSelection: false,
|
|
110
|
+
};
|
|
111
|
+
};
|