@reekon-tools/boldr-utils 1.6.13 → 1.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.js +51 -13
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +370 -57
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +2 -2
- package/dist/annotation/canvas/Tool.d.ts +10 -0
- package/dist/annotation/canvas/elements/ShapeElement.js +115 -38
- package/dist/annotation/canvas/measurementGeometry.d.ts +1 -0
- package/dist/annotation/canvas/measurementGeometry.js +61 -2
- package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
- package/dist/annotation/canvas/shapeGeometry.js +116 -0
- package/dist/annotation/canvas/stampLayout.d.ts +4 -0
- package/dist/annotation/canvas/stampLayout.js +25 -9
- package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
- package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
- package/dist/annotation/canvas/tools/measurementTool.d.ts +15 -0
- package/dist/annotation/canvas/tools/measurementTool.js +133 -0
- package/dist/annotation/canvas/tools/panTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/panTool.js +38 -5
- package/dist/annotation/canvas/tools/penTool.js +5 -1
- package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
- package/dist/annotation/canvas/tools/polygonTool.js +162 -0
- package/dist/annotation/canvas/tools/selectTool.js +37 -76
- package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
- package/dist/annotation/canvas/tools/shapeTool.js +111 -0
- package/dist/annotation/canvas/tools/textTool.d.ts +3 -1
- package/dist/annotation/canvas/tools/textTool.js +28 -3
- package/dist/annotation/canvas/useAnnotationCanvasState.js +27 -3
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +83 -24
- package/dist/exports.d.ts +8 -4
- package/dist/exports.js +7 -3
- package/dist/formulas/calculateFormula.js +1 -3
- package/dist/types/annotation.d.ts +4 -0
- package/dist/types/firestore.d.ts +4 -0
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ 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
4
|
import type { RectCorner } from './measurementGeometry.js';
|
|
5
|
+
import type { ShapeToolKind } from './shapeGeometry.js';
|
|
5
6
|
import type { ResizeGeometry } from './textGeometry.js';
|
|
6
7
|
import type { ViewportApi } from './viewport.js';
|
|
7
8
|
export type RequestTextInput = (options?: {
|
|
@@ -40,6 +41,13 @@ export interface FreehandConfig {
|
|
|
40
41
|
dash?: boolean;
|
|
41
42
|
minSampleDistance: number;
|
|
42
43
|
}
|
|
44
|
+
export interface ShapeDrawConfig {
|
|
45
|
+
kind: ShapeToolKind;
|
|
46
|
+
color: string;
|
|
47
|
+
width: number;
|
|
48
|
+
cap?: StrokeCap;
|
|
49
|
+
dash?: boolean;
|
|
50
|
+
}
|
|
43
51
|
export type DragElementKind = 'stroke' | 'shape' | 'measurement';
|
|
44
52
|
export interface DragSelectionConfig {
|
|
45
53
|
hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number): {
|
|
@@ -66,12 +74,14 @@ export interface Tool {
|
|
|
66
74
|
icon?: ComponentType;
|
|
67
75
|
cursor?: string;
|
|
68
76
|
freehand?: FreehandConfig;
|
|
77
|
+
shapeDraw?: ShapeDrawConfig;
|
|
69
78
|
panViewport?: boolean;
|
|
70
79
|
dragSelection?: DragSelectionConfig;
|
|
71
80
|
onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
72
81
|
onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
73
82
|
onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
74
83
|
onCancel?(state: ToolState, ctx: ToolContext): void;
|
|
84
|
+
onDeactivate?(ctx: ToolContext): void;
|
|
75
85
|
renderPreview?(state: ToolState, ctx: ToolContext): ReactNode;
|
|
76
86
|
hitTest?(element: AnnotationElement, worldPoint: Vec2): boolean;
|
|
77
87
|
}
|
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Circle, DashPathEffect,
|
|
2
|
+
import { Circle, DashPathEffect, Line, Paragraph, Path, Rect, Skia, TextDecoration, TextDecorationStyle, } from '@shopify/react-native-skia';
|
|
3
3
|
import { memo, useMemo } from 'react';
|
|
4
|
-
import { dashIntervals } from '../strokeGeometry.js';
|
|
5
|
-
import { DEFAULT_TEXT_FONT_SIZE,
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the
|
|
9
|
-
const
|
|
10
|
-
|
|
4
|
+
import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from '../strokeGeometry.js';
|
|
5
|
+
import { DEFAULT_TEXT_FONT_SIZE, TEXT_LINE_HEIGHT_FACTOR, } from '../textGeometry.js';
|
|
6
|
+
// Family the loaded annotation typeface is registered under in the per-shape
|
|
7
|
+
// TypefaceFontProvider that backs the text Paragraph (reusing the same typeface
|
|
8
|
+
// the rest of the canvas draws with).
|
|
9
|
+
const TEXT_FONT_FAMILY = 'annotation-text';
|
|
10
|
+
// A wide layout width so text only breaks on explicit '\n' (never auto-wraps).
|
|
11
|
+
// Left-aligned, so the unused width is never visible.
|
|
12
|
+
const TEXT_LAYOUT_WIDTH = 100000;
|
|
13
|
+
// Readable glyph color for highlighted text: the picked color becomes the
|
|
14
|
+
// highlight band, so the text itself flips to black/white by luminance.
|
|
15
|
+
const highlightTextColor = (hex) => {
|
|
16
|
+
const h = hex.replace('#', '');
|
|
17
|
+
if (h.length < 6)
|
|
18
|
+
return '#1A1A1A';
|
|
19
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
20
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
21
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
22
|
+
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
23
|
+
return lum > 0.6 ? '#1A1A1A' : '#FFFFFF';
|
|
24
|
+
};
|
|
25
|
+
const polygonPath = (points, closed) => {
|
|
11
26
|
const path = Skia.Path.Make();
|
|
12
27
|
if (points.length === 0)
|
|
13
28
|
return path;
|
|
@@ -15,7 +30,8 @@ const polygonPath = (points) => {
|
|
|
15
30
|
for (let i = 1; i < points.length; i++) {
|
|
16
31
|
path.lineTo(points[i].x, points[i].y);
|
|
17
32
|
}
|
|
18
|
-
|
|
33
|
+
if (closed)
|
|
34
|
+
path.close();
|
|
19
35
|
return path;
|
|
20
36
|
};
|
|
21
37
|
// Memoized — see StrokeElement. Unchanged shapes keep their identity across
|
|
@@ -25,7 +41,86 @@ export const ShapeElement = memo(({ shape, font }) => {
|
|
|
25
41
|
const stroke = style.stroke ?? '#000000';
|
|
26
42
|
const fill = style.fill;
|
|
27
43
|
const strokeWidth = style.strokeWidth ?? 2;
|
|
28
|
-
|
|
44
|
+
// Polygons auto-close unless explicitly left open (absent === closed, the
|
|
45
|
+
// historical behavior).
|
|
46
|
+
const closed = geometry.closed !== false;
|
|
47
|
+
const polyPath = useMemo(() => (kind === 'polygon' ? polygonPath(geometry.points, closed) : null), [kind, geometry.points, closed]);
|
|
48
|
+
// Solid filled-triangle arrowhead at the shape's end point. Drawn for the
|
|
49
|
+
// legacy 'arrow' kind, for a 'line' with cap === 'arrow' (the arrow tool and
|
|
50
|
+
// the end-cap editor both produce this), and for an OPEN polygon with cap
|
|
51
|
+
// === 'arrow' (its last segment has a direction). Mirrors StrokeElement.
|
|
52
|
+
const arrowHead = useMemo(() => {
|
|
53
|
+
let from;
|
|
54
|
+
let end;
|
|
55
|
+
if ((kind === 'line' || kind === 'arrow') &&
|
|
56
|
+
(kind === 'arrow' || style.cap === 'arrow')) {
|
|
57
|
+
[from, end] = geometry.points;
|
|
58
|
+
}
|
|
59
|
+
else if (kind === 'polygon' && !closed && style.cap === 'arrow') {
|
|
60
|
+
const n = geometry.points.length;
|
|
61
|
+
if (n >= 2) {
|
|
62
|
+
from = geometry.points[n - 2];
|
|
63
|
+
end = geometry.points[n - 1];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!from || !end || (from.x === end.x && from.y === end.y))
|
|
67
|
+
return null;
|
|
68
|
+
const [apex, baseL, baseR] = arrowheadTriangle(end, from, strokeWidth);
|
|
69
|
+
const p = Skia.Path.Make();
|
|
70
|
+
p.moveTo(apex.x, apex.y);
|
|
71
|
+
p.lineTo(baseL.x, baseL.y);
|
|
72
|
+
p.lineTo(baseR.x, baseR.y);
|
|
73
|
+
p.close();
|
|
74
|
+
return p;
|
|
75
|
+
}, [kind, geometry.points, closed, style.cap, strokeWidth]);
|
|
76
|
+
// Text shapes render through the Skia Paragraph API so decorations (solid
|
|
77
|
+
// underline / wavy squiggle / highlight band) paint natively. The loaded font
|
|
78
|
+
// is registered into a provider so the Paragraph draws with the same typeface
|
|
79
|
+
// as the rest of the canvas. Built only for text shapes.
|
|
80
|
+
const textParagraph = useMemo(() => {
|
|
81
|
+
if (kind !== 'text' || !text || !font)
|
|
82
|
+
return null;
|
|
83
|
+
const typeface = font.getTypeface();
|
|
84
|
+
if (!typeface)
|
|
85
|
+
return null;
|
|
86
|
+
const fontSize = style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
87
|
+
const decoration = style.textDecoration;
|
|
88
|
+
const colorStr = style.stroke ?? '#000000';
|
|
89
|
+
const textStyle = {
|
|
90
|
+
color: Skia.Color(decoration === 'highlight' ? highlightTextColor(colorStr) : colorStr),
|
|
91
|
+
fontFamilies: [TEXT_FONT_FAMILY],
|
|
92
|
+
fontSize,
|
|
93
|
+
// Match textGeometry's line-height factor so the (Skia-free) selection
|
|
94
|
+
// box keeps bracketing the rendered glyphs vertically.
|
|
95
|
+
heightMultiplier: TEXT_LINE_HEIGHT_FACTOR,
|
|
96
|
+
};
|
|
97
|
+
if (decoration === 'underline' || decoration === 'squiggle') {
|
|
98
|
+
textStyle.decoration = TextDecoration.Underline;
|
|
99
|
+
textStyle.decorationStyle =
|
|
100
|
+
decoration === 'squiggle'
|
|
101
|
+
? TextDecorationStyle.Wavy
|
|
102
|
+
: TextDecorationStyle.Solid;
|
|
103
|
+
textStyle.decorationColor = Skia.Color(colorStr);
|
|
104
|
+
}
|
|
105
|
+
else if (decoration === 'highlight') {
|
|
106
|
+
textStyle.backgroundColor = Skia.Color(colorStr);
|
|
107
|
+
}
|
|
108
|
+
const provider = Skia.TypefaceFontProvider.Make();
|
|
109
|
+
provider.registerFont(typeface, TEXT_FONT_FAMILY);
|
|
110
|
+
const builder = Skia.ParagraphBuilder.Make({
|
|
111
|
+
textStyle,
|
|
112
|
+
strutStyle: {
|
|
113
|
+
strutEnabled: true,
|
|
114
|
+
fontSize,
|
|
115
|
+
heightMultiplier: TEXT_LINE_HEIGHT_FACTOR,
|
|
116
|
+
forceStrutHeight: true,
|
|
117
|
+
},
|
|
118
|
+
}, provider);
|
|
119
|
+
builder.pushStyle(textStyle);
|
|
120
|
+
builder.addText(text);
|
|
121
|
+
return builder.build();
|
|
122
|
+
}, [kind, text, font, style.fontSize, style.textDecoration, style.stroke]);
|
|
123
|
+
const dashEffect = style.dash ? (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) : null;
|
|
29
124
|
switch (kind) {
|
|
30
125
|
case 'rect': {
|
|
31
126
|
const [a, b] = geometry.points;
|
|
@@ -35,7 +130,7 @@ export const ShapeElement = memo(({ shape, font }) => {
|
|
|
35
130
|
const y = Math.min(a.y, b.y);
|
|
36
131
|
const w = Math.abs(b.x - a.x);
|
|
37
132
|
const h = Math.abs(b.y - a.y);
|
|
38
|
-
return (_jsxs(_Fragment, { children: [fill && _jsx(Rect, { x: x, y: y, width: w, height: h, color: fill }), _jsx(Rect, { x: x, y: y, width: w, height: h, color: stroke, style: "stroke", strokeWidth: strokeWidth })] }));
|
|
133
|
+
return (_jsxs(_Fragment, { children: [fill && _jsx(Rect, { x: x, y: y, width: w, height: h, color: fill }), _jsx(Rect, { x: x, y: y, width: w, height: h, color: stroke, style: "stroke", strokeWidth: strokeWidth, children: dashEffect })] }));
|
|
39
134
|
}
|
|
40
135
|
case 'ellipse': {
|
|
41
136
|
const [a, b] = geometry.points;
|
|
@@ -44,47 +139,29 @@ export const ShapeElement = memo(({ shape, font }) => {
|
|
|
44
139
|
const cx = (a.x + b.x) / 2;
|
|
45
140
|
const cy = (a.y + b.y) / 2;
|
|
46
141
|
const r = Math.max(Math.abs(b.x - a.x), Math.abs(b.y - a.y)) / 2;
|
|
47
|
-
return (_jsxs(_Fragment, { children: [fill && _jsx(Circle, { cx: cx, cy: cy, r: r, color: fill }), _jsx(Circle, { cx: cx, cy: cy, r: r, color: stroke, style: "stroke", strokeWidth: strokeWidth })] }));
|
|
142
|
+
return (_jsxs(_Fragment, { children: [fill && _jsx(Circle, { cx: cx, cy: cy, r: r, color: fill }), _jsx(Circle, { cx: cx, cy: cy, r: r, color: stroke, style: "stroke", strokeWidth: strokeWidth, children: dashEffect })] }));
|
|
48
143
|
}
|
|
49
144
|
case 'line':
|
|
50
145
|
case 'arrow': {
|
|
51
146
|
const [a, b] = geometry.points;
|
|
52
147
|
if (!a || !b)
|
|
53
148
|
return null;
|
|
54
|
-
return (_jsx(Line, { p1: a, p2: b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: "
|
|
149
|
+
return (_jsxs(_Fragment, { children: [_jsx(Line, { p1: a, p2: b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(style.cap), children: dashEffect }), arrowHead && _jsx(Path, { path: arrowHead, color: stroke, style: "fill" })] }));
|
|
55
150
|
}
|
|
56
151
|
case 'polygon': {
|
|
57
152
|
if (!polyPath)
|
|
58
153
|
return null;
|
|
59
|
-
return (_jsxs(_Fragment, { children: [fill && _jsx(Path, { path: polyPath, color: fill }), _jsx(Path, { path: polyPath, color: stroke, style: "stroke", strokeWidth: strokeWidth })] }));
|
|
154
|
+
return (_jsxs(_Fragment, { children: [fill && closed && _jsx(Path, { path: polyPath, color: fill }), _jsx(Path, { path: polyPath, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(style.cap), strokeJoin: "round", children: dashEffect }), arrowHead && _jsx(Path, { path: arrowHead, color: stroke, style: "fill" })] }));
|
|
60
155
|
}
|
|
61
156
|
case 'text': {
|
|
62
|
-
// `origin` is the TOP-LEFT of the text block
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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.
|
|
157
|
+
// `origin` is the TOP-LEFT of the text block; textGeometry derives the
|
|
158
|
+
// selection box from the same anchor + line-height factor used to build
|
|
159
|
+
// the paragraph, so chrome brackets the glyphs. fontSize is set directly
|
|
160
|
+
// on the paragraph (world units), so no manual glyph scaling is needed.
|
|
68
161
|
const [origin] = geometry.points;
|
|
69
|
-
if (!origin || !
|
|
70
|
-
return null;
|
|
71
|
-
if (!font)
|
|
162
|
+
if (!origin || !textParagraph)
|
|
72
163
|
return null;
|
|
73
|
-
|
|
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))) }));
|
|
164
|
+
return (_jsx(Paragraph, { paragraph: textParagraph, x: origin.x, y: origin.y, width: TEXT_LAYOUT_WIDTH }));
|
|
88
165
|
}
|
|
89
166
|
}
|
|
90
167
|
});
|
|
@@ -36,6 +36,7 @@ export declare const rectCornerPoint: (rect: {
|
|
|
36
36
|
b: Vec2;
|
|
37
37
|
}, corner: RectCorner) => Vec2;
|
|
38
38
|
export declare const oppositeRectCorner: (corner: RectCorner) => RectCorner;
|
|
39
|
+
export declare const hitPlacedMeasurement: (m: PlacedMeasurementRef, p: Vec2, zoom?: number) => boolean;
|
|
39
40
|
export interface RemoveMeasurementResult {
|
|
40
41
|
ops: AnnotationPatchOp[];
|
|
41
42
|
keepSelection: boolean;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Pure geometry for measurement annotations (the `placement: 'line'` variant
|
|
2
|
-
// of PlacedMeasurementRef). Skia-free
|
|
3
|
-
// hit-tests, and unit tests.
|
|
2
|
+
// of PlacedMeasurementRef). Skia-free (its only import is from stampLayout) so
|
|
3
|
+
// it's safe in tools, hit-tests, and unit tests.
|
|
4
4
|
//
|
|
5
5
|
// NOTE: the native overlay's `useAnimatedStyle` and the live-line derived values
|
|
6
6
|
// run on the UI thread and CANNOT call these functions across the worklet
|
|
7
7
|
// boundary, so they inline the same math. Those inlined copies are the "worklet
|
|
8
8
|
// twins" of these helpers — if you change the math here, change them too (they
|
|
9
9
|
// are commented as such in AnnotationCanvasInner.native.tsx).
|
|
10
|
+
import { stampTileSize } from './stampLayout.js';
|
|
10
11
|
// Default position of the tile along its line when `linePos` is absent: center.
|
|
11
12
|
export const DEFAULT_LINE_POS = 0.5;
|
|
12
13
|
export const lerp = (a, b, t) => ({
|
|
@@ -85,6 +86,64 @@ export const oppositeRectCorner = (corner) => {
|
|
|
85
86
|
return 'tl';
|
|
86
87
|
}
|
|
87
88
|
};
|
|
89
|
+
// --- Hit-testing a placed measurement (tile + line/rect body). Shared by the
|
|
90
|
+
// select tool and the pan tool's view-mode tap-select. ---
|
|
91
|
+
// Doc-space padding around hit boxes (matches the select tool's stroke pad).
|
|
92
|
+
const STAMP_HIT_PADDING = 6;
|
|
93
|
+
// Screen-space grab tolerance (px) for a measurement-annotation line or rect
|
|
94
|
+
// border, converted to doc space via zoom (the body is a thin world-space
|
|
95
|
+
// stroke). Matches the select tool's drag-grab tolerance so tap-select and
|
|
96
|
+
// drag-grab agree on what counts as "on the measurement".
|
|
97
|
+
const LINE_GRAB_PX = 12;
|
|
98
|
+
const segmentDistSq = (p, a, b) => {
|
|
99
|
+
const abx = b.x - a.x;
|
|
100
|
+
const aby = b.y - a.y;
|
|
101
|
+
const lenSq = abx * abx + aby * aby;
|
|
102
|
+
let t = lenSq === 0 ? 0 : ((p.x - a.x) * abx + (p.y - a.y) * aby) / lenSq;
|
|
103
|
+
t = Math.max(0, Math.min(1, t));
|
|
104
|
+
const dx = p.x - (a.x + t * abx);
|
|
105
|
+
const dy = p.y - (a.y + t * aby);
|
|
106
|
+
return dx * dx + dy * dy;
|
|
107
|
+
};
|
|
108
|
+
// Whether a world-space point hits a placed measurement: the (screen-constant)
|
|
109
|
+
// stamp tile around the anchor, the line body of a line annotation, or the
|
|
110
|
+
// border ring of a rectangle annotation (interiors stay transparent to hits so
|
|
111
|
+
// elements inside remain reachable).
|
|
112
|
+
export const hitPlacedMeasurement = (m, p, zoom = 1) => {
|
|
113
|
+
// The stamp renders as a constant *screen*-size square centered on the
|
|
114
|
+
// anchor, so its doc-space footprint shrinks as you zoom in. Convert the
|
|
115
|
+
// screen-space half-extent (+ padding) back to doc space via the zoom so
|
|
116
|
+
// the hit box always matches what's drawn.
|
|
117
|
+
// Unassociated inputs use the smaller input-tile footprint (#6); the helper
|
|
118
|
+
// folds in scale + the associated/blank size so the hit box matches the draw.
|
|
119
|
+
const half = (stampTileSize(m) / 2 + STAMP_HIT_PADDING) / zoom;
|
|
120
|
+
const dx = Math.abs(p.x - m.anchor.x);
|
|
121
|
+
const dy = Math.abs(p.y - m.anchor.y);
|
|
122
|
+
if (dx <= half && dy <= half)
|
|
123
|
+
return true;
|
|
124
|
+
// Measurement annotation: also grab anywhere along the line body.
|
|
125
|
+
if (m.line) {
|
|
126
|
+
const r = LINE_GRAB_PX / zoom;
|
|
127
|
+
if (segmentDistSq(p, m.line.a, m.line.b) <= r * r)
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
// Rectangle annotation: grab anywhere along the border.
|
|
131
|
+
if (m.rect && placementOf(m) === 'rectangle') {
|
|
132
|
+
const n = normalizeRect(m.rect);
|
|
133
|
+
const r2 = (LINE_GRAB_PX / zoom) ** 2;
|
|
134
|
+
const tl = { x: n.minX, y: n.minY };
|
|
135
|
+
const tr = { x: n.maxX, y: n.minY };
|
|
136
|
+
const br = { x: n.maxX, y: n.maxY };
|
|
137
|
+
const bl = { x: n.minX, y: n.maxY };
|
|
138
|
+
if (segmentDistSq(p, tl, tr) <= r2 ||
|
|
139
|
+
segmentDistSq(p, tr, br) <= r2 ||
|
|
140
|
+
segmentDistSq(p, br, bl) <= r2 ||
|
|
141
|
+
segmentDistSq(p, bl, tl) <= r2) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
};
|
|
88
147
|
export const buildRemoveMeasurementOps = (placed) => {
|
|
89
148
|
if (placementOf(placed) !== 'none' && placed.measurementId) {
|
|
90
149
|
return {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AnnotationShape, AnnotationShapeKind, Vec2 } from '../../types/annotation.js';
|
|
2
|
+
export type ShapeToolKind = 'line' | 'rect' | 'triangle' | 'ellipse';
|
|
3
|
+
export declare const annotationKindFor: (kind: ShapeToolKind) => AnnotationShapeKind;
|
|
4
|
+
export declare const shapePointsFromDrag: (kind: ShapeToolKind, a: Vec2, b: Vec2) => Vec2[];
|
|
5
|
+
export declare const hitShapeOutline: (shape: AnnotationShape, p: Vec2, tol: number) => boolean;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Pure geometry for the drag-to-draw shape tools (line/arrow/rect/triangle/
|
|
2
|
+
// circle) and the polygon tool. Skia-free and dependency-free so the tool
|
|
3
|
+
// factories — and the consumer's static import graph — never touch
|
|
4
|
+
// @shopify/react-native-skia, and so the native inner's worklet twins have a
|
|
5
|
+
// single JS source of truth to mirror.
|
|
6
|
+
// Persisted AnnotationShapeKind for a tool kind. Triangles are stored as
|
|
7
|
+
// 3-point closed polygons so they reuse the polygon renderer/hit-testing
|
|
8
|
+
// wholesale (there is no 'triangle' kind in the schema).
|
|
9
|
+
export const annotationKindFor = (kind) => kind === 'triangle' ? 'polygon' : kind;
|
|
10
|
+
// Geometry points for a shape dragged from `a` to `b` (world space).
|
|
11
|
+
// - line: the two endpoints (direction preserved — caps/arrowheads point
|
|
12
|
+
// from a toward b).
|
|
13
|
+
// - rect: the two opposite corners (ShapeElement normalizes).
|
|
14
|
+
// - ellipse: the two opposite corners of the bounding drag (rendered as a
|
|
15
|
+
// circle centered on the midpoint — see ShapeElement).
|
|
16
|
+
// - triangle: isoceles inscribed in the normalized drag rect — apex at the
|
|
17
|
+
// top-center, base across the bottom.
|
|
18
|
+
export const shapePointsFromDrag = (kind, a, b) => {
|
|
19
|
+
if (kind === 'triangle') {
|
|
20
|
+
const minX = Math.min(a.x, b.x);
|
|
21
|
+
const maxX = Math.max(a.x, b.x);
|
|
22
|
+
const minY = Math.min(a.y, b.y);
|
|
23
|
+
const maxY = Math.max(a.y, b.y);
|
|
24
|
+
return [
|
|
25
|
+
{ x: (minX + maxX) / 2, y: minY },
|
|
26
|
+
{ x: maxX, y: maxY },
|
|
27
|
+
{ x: minX, y: maxY },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
return [
|
|
31
|
+
{ x: a.x, y: a.y },
|
|
32
|
+
{ x: b.x, y: b.y },
|
|
33
|
+
];
|
|
34
|
+
};
|
|
35
|
+
// --- Per-kind hit testing (doc space) -------------------------------------
|
|
36
|
+
// Geometric shapes hit on their painted outline (plus a tolerance), NOT their
|
|
37
|
+
// bounding box — a long diagonal line's box would swallow taps nowhere near
|
|
38
|
+
// the ink, and a rect's interior must stay transparent to hits so elements
|
|
39
|
+
// inside it remain selectable. `tol` is the doc-space grab radius (the caller
|
|
40
|
+
// derives it from stroke width + a screen-constant padding / zoom).
|
|
41
|
+
const segDistSq = (px, py, ax, ay, bx, by) => {
|
|
42
|
+
const abx = bx - ax;
|
|
43
|
+
const aby = by - ay;
|
|
44
|
+
const lenSq = abx * abx + aby * aby;
|
|
45
|
+
let t = lenSq === 0 ? 0 : ((px - ax) * abx + (py - ay) * aby) / lenSq;
|
|
46
|
+
t = Math.max(0, Math.min(1, t));
|
|
47
|
+
const dx = px - (ax + t * abx);
|
|
48
|
+
const dy = py - (ay + t * aby);
|
|
49
|
+
return dx * dx + dy * dy;
|
|
50
|
+
};
|
|
51
|
+
// Distance from `p` to the polyline through `pts` (closing edge included when
|
|
52
|
+
// `closed`), squared. Infinity for fewer than 2 points.
|
|
53
|
+
const polylineDistSq = (pts, p, closed) => {
|
|
54
|
+
if (pts.length < 2)
|
|
55
|
+
return Infinity;
|
|
56
|
+
let best = Infinity;
|
|
57
|
+
const n = closed ? pts.length : pts.length - 1;
|
|
58
|
+
for (let i = 0; i < n; i++) {
|
|
59
|
+
const a = pts[i];
|
|
60
|
+
const b = pts[(i + 1) % pts.length];
|
|
61
|
+
const d = segDistSq(p.x, p.y, a.x, a.y, b.x, b.y);
|
|
62
|
+
if (d < best)
|
|
63
|
+
best = d;
|
|
64
|
+
}
|
|
65
|
+
return best;
|
|
66
|
+
};
|
|
67
|
+
// Whether `p` is within `tol` of the shape's painted outline. Returns false
|
|
68
|
+
// for text shapes — their hit box comes from textGeometry (the stored
|
|
69
|
+
// geometry is just an anchor), so callers keep using textShapeBounds.
|
|
70
|
+
export const hitShapeOutline = (shape, p, tol) => {
|
|
71
|
+
const pts = shape.geometry.points;
|
|
72
|
+
const tolSq = tol * tol;
|
|
73
|
+
switch (shape.kind) {
|
|
74
|
+
case 'line':
|
|
75
|
+
case 'arrow': {
|
|
76
|
+
const [a, b] = pts;
|
|
77
|
+
if (!a || !b)
|
|
78
|
+
return false;
|
|
79
|
+
return segDistSq(p.x, p.y, a.x, a.y, b.x, b.y) <= tolSq;
|
|
80
|
+
}
|
|
81
|
+
case 'rect': {
|
|
82
|
+
const [a, b] = pts;
|
|
83
|
+
if (!a || !b)
|
|
84
|
+
return false;
|
|
85
|
+
const minX = Math.min(a.x, b.x);
|
|
86
|
+
const maxX = Math.max(a.x, b.x);
|
|
87
|
+
const minY = Math.min(a.y, b.y);
|
|
88
|
+
const maxY = Math.max(a.y, b.y);
|
|
89
|
+
const ring = [
|
|
90
|
+
{ x: minX, y: minY },
|
|
91
|
+
{ x: maxX, y: minY },
|
|
92
|
+
{ x: maxX, y: maxY },
|
|
93
|
+
{ x: minX, y: maxY },
|
|
94
|
+
];
|
|
95
|
+
return polylineDistSq(ring, p, true) <= tolSq;
|
|
96
|
+
}
|
|
97
|
+
case 'ellipse': {
|
|
98
|
+
// Rendered as a circle: center = midpoint, r = half the larger extent
|
|
99
|
+
// (mirror of ShapeElement). Hit when within tol of the ring.
|
|
100
|
+
const [a, b] = pts;
|
|
101
|
+
if (!a || !b)
|
|
102
|
+
return false;
|
|
103
|
+
const cx = (a.x + b.x) / 2;
|
|
104
|
+
const cy = (a.y + b.y) / 2;
|
|
105
|
+
const r = Math.max(Math.abs(b.x - a.x), Math.abs(b.y - a.y)) / 2;
|
|
106
|
+
const d = Math.hypot(p.x - cx, p.y - cy);
|
|
107
|
+
return Math.abs(d - r) <= tol;
|
|
108
|
+
}
|
|
109
|
+
case 'polygon': {
|
|
110
|
+
const closed = shape.geometry.closed !== false;
|
|
111
|
+
return polylineDistSq(pts, p, closed) <= tolSq;
|
|
112
|
+
}
|
|
113
|
+
case 'text':
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
@@ -1 +1,5 @@
|
|
|
1
|
+
import type { PlacedMeasurementRef } from '../../types/annotation.js';
|
|
1
2
|
export declare const STAMP_TILE_SIZE = 96;
|
|
3
|
+
export declare const STAMP_INPUT_TILE_SIZE = 56;
|
|
4
|
+
export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath">) => boolean;
|
|
5
|
+
export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "scale">) => number;
|
|
@@ -1,11 +1,27 @@
|
|
|
1
|
-
// Shared layout
|
|
1
|
+
// Shared layout constants for the placed measurement stamp. Lives in a
|
|
2
2
|
// Skia-free module so both the render path (the `renderMeasurementStamp`
|
|
3
|
-
// overlay — see measurementStampOverlay.ts) and the hit-test (selectTool
|
|
4
|
-
// can import it without dragging
|
|
5
|
-
// consumer's static import graph.
|
|
6
|
-
// Constant SCREEN-space edge length of a placed measurement
|
|
7
|
-
// square tile. The tile is a fixed-size
|
|
8
|
-
// `placed.scale` and does NOT change with
|
|
9
|
-
// canvas). Also drives the select-tool hit
|
|
10
|
-
// space via the zoom.
|
|
3
|
+
// overlay — see measurementStampOverlay.ts) and the hit-test (selectTool,
|
|
4
|
+
// measurementGeometry) can import it without dragging
|
|
5
|
+
// @shopify/react-native-skia into the consumer's static import graph.
|
|
6
|
+
// Constant SCREEN-space edge length of a placed measurement that HAS a
|
|
7
|
+
// measurement associated, rendered as a square tile. The tile is a fixed-size
|
|
8
|
+
// pin: its on-screen size is this times `placed.scale` and does NOT change with
|
|
9
|
+
// zoom (only its position tracks the canvas). Also drives the select-tool hit
|
|
10
|
+
// box, which converts it back to doc space via the zoom.
|
|
11
11
|
export const STAMP_TILE_SIZE = 96;
|
|
12
|
+
// Edge length for an UNASSOCIATED stamp — a measurement annotation with no
|
|
13
|
+
// measurement picked yet, which renders as a compact "+" input placeholder
|
|
14
|
+
// rather than a full readable tile. Smaller than STAMP_TILE_SIZE so empty
|
|
15
|
+
// inputs read as lightweight tap targets; associated tiles keep STAMP_TILE_SIZE
|
|
16
|
+
// so existing saved annotations are visually unchanged. The single knob for #6.
|
|
17
|
+
export const STAMP_INPUT_TILE_SIZE = 56;
|
|
18
|
+
// A placed measurement is an unassociated input until a measurement reference
|
|
19
|
+
// is attached (id or path). Such stamps use STAMP_INPUT_TILE_SIZE.
|
|
20
|
+
export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath;
|
|
21
|
+
// Screen-space edge length for a placed stamp: the compact input size while
|
|
22
|
+
// unassociated, full size once a measurement is attached, then scaled by the
|
|
23
|
+
// per-stamp `scale`. The ONE source of truth for tile footprint — render
|
|
24
|
+
// overlay, hit-test, and slide-grab classification all call this so the drawn
|
|
25
|
+
// tile and its touch box always agree.
|
|
26
|
+
export const stampTileSize = (m) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
|
|
27
|
+
(m.scale ?? 1);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PlacedMeasurementRef } from '../../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export interface MeasurementLineToolOptions {
|
|
4
|
+
id?: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
minDragPx?: number;
|
|
7
|
+
autoSwitchToSelect?: boolean;
|
|
8
|
+
selectToolId?: string;
|
|
9
|
+
onAutoSwitch?: (toToolId: string) => void;
|
|
10
|
+
onPlaced?: (measurement: PlacedMeasurementRef) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare const createMeasurementLineTool: (options?: MeasurementLineToolOptions) => Tool;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
import { DEFAULT_LINE_POS, recomputeAnchor } from '../measurementGeometry.js';
|
|
3
|
+
let counter = 0;
|
|
4
|
+
const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
6
|
+
// Build the blank line-measurement annotation for a drag from `a` to `b`: a
|
|
7
|
+
// 2-point line with a value tile magnetized to its center (linePos 0.5). The
|
|
8
|
+
// payload mirrors useAnnotationCanvasState's placeAnnotationAtCenter so a drawn
|
|
9
|
+
// line and a (legacy) center-placed one are byte-identical once committed.
|
|
10
|
+
const buildLineMeasurement = (opts) => {
|
|
11
|
+
const line = { a: opts.a, b: opts.b };
|
|
12
|
+
return {
|
|
13
|
+
id: opts.id,
|
|
14
|
+
layerId: opts.layerId,
|
|
15
|
+
placement: 'line',
|
|
16
|
+
line,
|
|
17
|
+
linePos: DEFAULT_LINE_POS,
|
|
18
|
+
// Center of the line; recomputeAnchor keeps this in sync on edits.
|
|
19
|
+
anchor: recomputeAnchor(line, 'line', DEFAULT_LINE_POS, {
|
|
20
|
+
x: (opts.a.x + opts.b.x) / 2,
|
|
21
|
+
y: (opts.a.y + opts.b.y) / 2,
|
|
22
|
+
}),
|
|
23
|
+
showLabel: true,
|
|
24
|
+
showValue: true,
|
|
25
|
+
createdAt: Date.now(),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
// Drag-to-draw measurement-line tool. Press-drag rubber-bands a measurement
|
|
29
|
+
// annotation (a line with a blank value tile at its center); release commits
|
|
30
|
+
// it. Mirrors createShapeTool's interaction so "input lines" are placed the
|
|
31
|
+
// same way as shapes — draw to add, not tap-the-icon-to-add — replacing the
|
|
32
|
+
// old center-place affordance. The annotation stays blank (no measurement
|
|
33
|
+
// associated) until the user fills it via the tile / picker. Skia-free, like
|
|
34
|
+
// every tool factory; it renders its live preview through ctx.preview, so it
|
|
35
|
+
// works on web and (via the generic tool-pan dispatch) on native.
|
|
36
|
+
export const createMeasurementLineTool = (options = {}) => {
|
|
37
|
+
const minDragPx = options.minDragPx ?? 4;
|
|
38
|
+
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
39
|
+
const selectToolId = options.selectToolId ?? 'select';
|
|
40
|
+
return {
|
|
41
|
+
id: options.id ?? 'measure-line',
|
|
42
|
+
label: options.label ?? 'Measurement line',
|
|
43
|
+
cursor: 'crosshair',
|
|
44
|
+
onPointerDown(event) {
|
|
45
|
+
return {
|
|
46
|
+
kind: 'measurement-line-drawing',
|
|
47
|
+
id: makeId(),
|
|
48
|
+
startWorld: event.world,
|
|
49
|
+
startScreen: event.screen,
|
|
50
|
+
moved: false,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
onPointerMove(event, ctx, state) {
|
|
54
|
+
const s = state;
|
|
55
|
+
if (s?.kind !== 'measurement-line-drawing')
|
|
56
|
+
return s;
|
|
57
|
+
const measurement = buildLineMeasurement({
|
|
58
|
+
id: s.id,
|
|
59
|
+
layerId: firstLayerId(ctx.document),
|
|
60
|
+
a: s.startWorld,
|
|
61
|
+
b: event.world,
|
|
62
|
+
});
|
|
63
|
+
ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
64
|
+
return { ...s, moved: true };
|
|
65
|
+
},
|
|
66
|
+
onPointerUp(event, ctx, state) {
|
|
67
|
+
const s = state;
|
|
68
|
+
if (s?.kind !== 'measurement-line-drawing')
|
|
69
|
+
return;
|
|
70
|
+
const dx = event.screen.x - s.startScreen.x;
|
|
71
|
+
const dy = event.screen.y - s.startScreen.y;
|
|
72
|
+
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
73
|
+
// Accidental tap — discard the rubber-band, commit nothing.
|
|
74
|
+
ctx.preview({ ops: [] });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const measurement = buildLineMeasurement({
|
|
78
|
+
id: s.id,
|
|
79
|
+
layerId: firstLayerId(ctx.document),
|
|
80
|
+
a: s.startWorld,
|
|
81
|
+
b: event.world,
|
|
82
|
+
});
|
|
83
|
+
ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
84
|
+
// Leave it selected (and hand back to select) so the blank line can be
|
|
85
|
+
// filled / moved straight away — the same flow as the text tool.
|
|
86
|
+
ctx.setSelection({ ids: [measurement.id] });
|
|
87
|
+
options.onPlaced?.(measurement);
|
|
88
|
+
if (autoSwitchToSelect)
|
|
89
|
+
options.onAutoSwitch?.(selectToolId);
|
|
90
|
+
},
|
|
91
|
+
onCancel(_state, ctx) {
|
|
92
|
+
ctx.preview({ ops: [] });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PlacedMeasurementRef } from '../../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export type MeasurementToolPlacement = 'line' | 'rectangle' | 'none';
|
|
4
|
+
export interface MeasurementToolOptions {
|
|
5
|
+
id?: string;
|
|
6
|
+
label?: string;
|
|
7
|
+
placement?: MeasurementToolPlacement;
|
|
8
|
+
linePos?: number;
|
|
9
|
+
minDragPx?: number;
|
|
10
|
+
autoSwitchToSelect?: boolean;
|
|
11
|
+
selectToolId?: string;
|
|
12
|
+
onAutoSwitch?: (toToolId: string) => void;
|
|
13
|
+
onPlaced?: (measurement: PlacedMeasurementRef) => void;
|
|
14
|
+
}
|
|
15
|
+
export declare const createMeasurementTool: (options?: MeasurementToolOptions) => Tool;
|