@reekon-tools/boldr-utils 1.6.13 → 1.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.js +49 -11
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +368 -55
- 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 +41 -8
- package/dist/annotation/canvas/measurementGeometry.d.ts +1 -0
- package/dist/annotation/canvas/measurementGeometry.js +60 -2
- package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
- package/dist/annotation/canvas/shapeGeometry.js +116 -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 +34 -73
- package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
- package/dist/annotation/canvas/tools/shapeTool.js +111 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +27 -3
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +83 -24
- package/dist/exports.d.ts +7 -4
- package/dist/exports.js +6 -3
- package/dist/formulas/calculateFormula.js +1 -3
- package/dist/types/annotation.d.ts +2 -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,13 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
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';
|
|
4
|
+
import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from '../strokeGeometry.js';
|
|
5
5
|
import { DEFAULT_TEXT_FONT_SIZE, TEXT_BASELINE_FACTOR, TEXT_LINE_HEIGHT_FACTOR, } from '../textGeometry.js';
|
|
6
6
|
// Outline width of dash-styled text, as a fraction of the base font size.
|
|
7
7
|
// Drawn pre-scale (inside the fontSize/baseSize Group), so the outline — and
|
|
8
8
|
// the dash pattern derived from it — scales with the glyphs.
|
|
9
9
|
const TEXT_OUTLINE_WIDTH_FACTOR = 1 / 16;
|
|
10
|
-
const polygonPath = (points) => {
|
|
10
|
+
const polygonPath = (points, closed) => {
|
|
11
11
|
const path = Skia.Path.Make();
|
|
12
12
|
if (points.length === 0)
|
|
13
13
|
return path;
|
|
@@ -15,7 +15,8 @@ const polygonPath = (points) => {
|
|
|
15
15
|
for (let i = 1; i < points.length; i++) {
|
|
16
16
|
path.lineTo(points[i].x, points[i].y);
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
if (closed)
|
|
19
|
+
path.close();
|
|
19
20
|
return path;
|
|
20
21
|
};
|
|
21
22
|
// Memoized — see StrokeElement. Unchanged shapes keep their identity across
|
|
@@ -25,7 +26,39 @@ export const ShapeElement = memo(({ shape, font }) => {
|
|
|
25
26
|
const stroke = style.stroke ?? '#000000';
|
|
26
27
|
const fill = style.fill;
|
|
27
28
|
const strokeWidth = style.strokeWidth ?? 2;
|
|
28
|
-
|
|
29
|
+
// Polygons auto-close unless explicitly left open (absent === closed, the
|
|
30
|
+
// historical behavior).
|
|
31
|
+
const closed = geometry.closed !== false;
|
|
32
|
+
const polyPath = useMemo(() => (kind === 'polygon' ? polygonPath(geometry.points, closed) : null), [kind, geometry.points, closed]);
|
|
33
|
+
// Solid filled-triangle arrowhead at the shape's end point. Drawn for the
|
|
34
|
+
// legacy 'arrow' kind, for a 'line' with cap === 'arrow' (the arrow tool and
|
|
35
|
+
// the end-cap editor both produce this), and for an OPEN polygon with cap
|
|
36
|
+
// === 'arrow' (its last segment has a direction). Mirrors StrokeElement.
|
|
37
|
+
const arrowHead = useMemo(() => {
|
|
38
|
+
let from;
|
|
39
|
+
let end;
|
|
40
|
+
if ((kind === 'line' || kind === 'arrow') &&
|
|
41
|
+
(kind === 'arrow' || style.cap === 'arrow')) {
|
|
42
|
+
[from, end] = geometry.points;
|
|
43
|
+
}
|
|
44
|
+
else if (kind === 'polygon' && !closed && style.cap === 'arrow') {
|
|
45
|
+
const n = geometry.points.length;
|
|
46
|
+
if (n >= 2) {
|
|
47
|
+
from = geometry.points[n - 2];
|
|
48
|
+
end = geometry.points[n - 1];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!from || !end || (from.x === end.x && from.y === end.y))
|
|
52
|
+
return null;
|
|
53
|
+
const [apex, baseL, baseR] = arrowheadTriangle(end, from, strokeWidth);
|
|
54
|
+
const p = Skia.Path.Make();
|
|
55
|
+
p.moveTo(apex.x, apex.y);
|
|
56
|
+
p.lineTo(baseL.x, baseL.y);
|
|
57
|
+
p.lineTo(baseR.x, baseR.y);
|
|
58
|
+
p.close();
|
|
59
|
+
return p;
|
|
60
|
+
}, [kind, geometry.points, closed, style.cap, strokeWidth]);
|
|
61
|
+
const dashEffect = style.dash ? (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) : null;
|
|
29
62
|
switch (kind) {
|
|
30
63
|
case 'rect': {
|
|
31
64
|
const [a, b] = geometry.points;
|
|
@@ -35,7 +68,7 @@ export const ShapeElement = memo(({ shape, font }) => {
|
|
|
35
68
|
const y = Math.min(a.y, b.y);
|
|
36
69
|
const w = Math.abs(b.x - a.x);
|
|
37
70
|
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 })] }));
|
|
71
|
+
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
72
|
}
|
|
40
73
|
case 'ellipse': {
|
|
41
74
|
const [a, b] = geometry.points;
|
|
@@ -44,19 +77,19 @@ export const ShapeElement = memo(({ shape, font }) => {
|
|
|
44
77
|
const cx = (a.x + b.x) / 2;
|
|
45
78
|
const cy = (a.y + b.y) / 2;
|
|
46
79
|
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 })] }));
|
|
80
|
+
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
81
|
}
|
|
49
82
|
case 'line':
|
|
50
83
|
case 'arrow': {
|
|
51
84
|
const [a, b] = geometry.points;
|
|
52
85
|
if (!a || !b)
|
|
53
86
|
return null;
|
|
54
|
-
return (_jsx(Line, { p1: a, p2: b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: "
|
|
87
|
+
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
88
|
}
|
|
56
89
|
case 'polygon': {
|
|
57
90
|
if (!polyPath)
|
|
58
91
|
return null;
|
|
59
|
-
return (_jsxs(_Fragment, { children: [fill && _jsx(Path, { path: polyPath, color: fill }), _jsx(Path, { path: polyPath, color: stroke, style: "stroke", strokeWidth: strokeWidth })] }));
|
|
92
|
+
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
93
|
}
|
|
61
94
|
case 'text': {
|
|
62
95
|
// `origin` is the TOP-LEFT of the text block (textGeometry derives
|
|
@@ -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 the stampLayout
|
|
3
|
+
// constant) so 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 { STAMP_TILE_SIZE } 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,63 @@ 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
|
+
const scale = m.scale ?? 1;
|
|
118
|
+
const half = ((STAMP_TILE_SIZE * scale) / 2 + STAMP_HIT_PADDING) / zoom;
|
|
119
|
+
const dx = Math.abs(p.x - m.anchor.x);
|
|
120
|
+
const dy = Math.abs(p.y - m.anchor.y);
|
|
121
|
+
if (dx <= half && dy <= half)
|
|
122
|
+
return true;
|
|
123
|
+
// Measurement annotation: also grab anywhere along the line body.
|
|
124
|
+
if (m.line) {
|
|
125
|
+
const r = LINE_GRAB_PX / zoom;
|
|
126
|
+
if (segmentDistSq(p, m.line.a, m.line.b) <= r * r)
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
// Rectangle annotation: grab anywhere along the border.
|
|
130
|
+
if (m.rect && placementOf(m) === 'rectangle') {
|
|
131
|
+
const n = normalizeRect(m.rect);
|
|
132
|
+
const r2 = (LINE_GRAB_PX / zoom) ** 2;
|
|
133
|
+
const tl = { x: n.minX, y: n.minY };
|
|
134
|
+
const tr = { x: n.maxX, y: n.minY };
|
|
135
|
+
const br = { x: n.maxX, y: n.maxY };
|
|
136
|
+
const bl = { x: n.minX, y: n.maxY };
|
|
137
|
+
if (segmentDistSq(p, tl, tr) <= r2 ||
|
|
138
|
+
segmentDistSq(p, tr, br) <= r2 ||
|
|
139
|
+
segmentDistSq(p, br, bl) <= r2 ||
|
|
140
|
+
segmentDistSq(p, bl, tl) <= r2) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
};
|
|
88
146
|
export const buildRemoveMeasurementOps = (placed) => {
|
|
89
147
|
if (placementOf(placed) !== 'none' && placed.measurementId) {
|
|
90
148
|
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,3 +1,8 @@
|
|
|
1
|
+
import { hitPlacedMeasurement } from '../measurementGeometry.js';
|
|
2
|
+
// Screen-px movement beyond which a gesture counts as a pan, not a tap. The
|
|
3
|
+
// native canvas never dispatches pans to JS handlers (they run on the UI
|
|
4
|
+
// thread), so this only guards web's down→jitter→up clicks.
|
|
5
|
+
const TAP_SLOP_PX = 6;
|
|
1
6
|
// Built-in "Hand" tool. When active, any drag pans the viewport. Tools that
|
|
2
7
|
// want one-off pan triggers (middle-mouse, space-drag) get those via the
|
|
3
8
|
// AnnotationCanvas `gestures` prop without needing to switch tools.
|
|
@@ -6,10 +11,16 @@ export const createPanTool = (options = {}) => ({
|
|
|
6
11
|
label: 'Hand',
|
|
7
12
|
cursor: options.cursor ?? 'grab',
|
|
8
13
|
// Native pans the viewport on the UI thread (see Tool.panViewport). The
|
|
9
|
-
// onPointer* handlers below remain the web/parity implementation
|
|
14
|
+
// onPointer* handlers below remain the web/parity implementation; taps
|
|
15
|
+
// still reach them on native via the synthesized down+up dispatch.
|
|
10
16
|
panViewport: true,
|
|
11
17
|
onPointerDown(event, _ctx, _state) {
|
|
12
|
-
return {
|
|
18
|
+
return {
|
|
19
|
+
kind: 'panning',
|
|
20
|
+
startScreen: event.screen,
|
|
21
|
+
lastScreen: event.screen,
|
|
22
|
+
moved: false,
|
|
23
|
+
};
|
|
13
24
|
},
|
|
14
25
|
onPointerMove(event, ctx, state) {
|
|
15
26
|
const s = state;
|
|
@@ -20,9 +31,31 @@ export const createPanTool = (options = {}) => ({
|
|
|
20
31
|
y: event.screen.y - s.lastScreen.y,
|
|
21
32
|
};
|
|
22
33
|
ctx.applyPan(delta);
|
|
23
|
-
|
|
34
|
+
const dx = event.screen.x - s.startScreen.x;
|
|
35
|
+
const dy = event.screen.y - s.startScreen.y;
|
|
36
|
+
return {
|
|
37
|
+
...s,
|
|
38
|
+
lastScreen: event.screen,
|
|
39
|
+
moved: s.moved || dx * dx + dy * dy > TAP_SLOP_PX * TAP_SLOP_PX,
|
|
40
|
+
};
|
|
24
41
|
},
|
|
25
|
-
onPointerUp(
|
|
26
|
-
// No commit — viewport state is canvas-internal, not persisted.
|
|
42
|
+
onPointerUp(event, ctx, state) {
|
|
43
|
+
// No commit — viewport state is canvas-internal, not persisted. A clean
|
|
44
|
+
// tap optionally selects the measurement under it (see options).
|
|
45
|
+
const s = state;
|
|
46
|
+
if (!options.selectMeasurementsOnTap)
|
|
47
|
+
return;
|
|
48
|
+
if (s?.kind === 'panning' && s.moved)
|
|
49
|
+
return;
|
|
50
|
+
const zoom = ctx.viewport.state.zoom;
|
|
51
|
+
const measurements = ctx.document.placedMeasurements;
|
|
52
|
+
for (let i = measurements.length - 1; i >= 0; i--) {
|
|
53
|
+
const m = measurements[i];
|
|
54
|
+
if (hitPlacedMeasurement(m, event.world, zoom)) {
|
|
55
|
+
ctx.setSelection({ ids: [m.id] });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
ctx.setSelection(null);
|
|
27
60
|
},
|
|
28
61
|
});
|
|
@@ -20,7 +20,11 @@ export const createPenTool = (options = {}) => {
|
|
|
20
20
|
const dotCap = cap === 'butt' || cap === 'arrow' ? 'round' : cap;
|
|
21
21
|
return {
|
|
22
22
|
id: variant,
|
|
23
|
-
label: variant === 'pen'
|
|
23
|
+
label: variant === 'pen'
|
|
24
|
+
? 'Pen'
|
|
25
|
+
: variant === 'marker'
|
|
26
|
+
? 'Marker'
|
|
27
|
+
: 'Highlighter',
|
|
24
28
|
cursor: 'crosshair',
|
|
25
29
|
// Drives UI-thread drawing on native (see FreehandConfig). The
|
|
26
30
|
// onPointerDown/Move/Up below remain the web/parity implementation.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AnnotationShape } from '../../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export interface PolygonToolOptions {
|
|
4
|
+
id?: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
color?: string;
|
|
7
|
+
width?: number;
|
|
8
|
+
dash?: boolean;
|
|
9
|
+
onPlaced?: (shape: AnnotationShape) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare const createPolygonTool: (options?: PolygonToolOptions) => Tool;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
// Screen-px radius for "tapped an existing vertex": the first vertex closes
|
|
3
|
+
// the polygon, the last vertex finishes it open. Converted to doc space via
|
|
4
|
+
// the live zoom so the target is a constant on-screen size.
|
|
5
|
+
const VERTEX_TAP_PX = 18;
|
|
6
|
+
// Screen-px radius of the placed-vertex dots drawn while building (the first
|
|
7
|
+
// vertex grows once the polygon is closable, to advertise the close target).
|
|
8
|
+
const VERTEX_DOT_PX = 5;
|
|
9
|
+
const CLOSE_TARGET_DOT_PX = 8;
|
|
10
|
+
let counter = 0;
|
|
11
|
+
const makeId = () => `polygon-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
12
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
13
|
+
const distSq = (a, b) => {
|
|
14
|
+
const dx = a.x - b.x;
|
|
15
|
+
const dy = a.y - b.y;
|
|
16
|
+
return dx * dx + dy * dy;
|
|
17
|
+
};
|
|
18
|
+
// Tap-to-place polygon tool. Each tap (or drag-release) adds a vertex;
|
|
19
|
+
// tapping the FIRST vertex closes the ring and commits; tapping the LAST
|
|
20
|
+
// vertex again (a double-tap, in practice) commits it as an open polyline.
|
|
21
|
+
// Switching tools mid-build commits the open polyline too (work is never
|
|
22
|
+
// silently discarded) via onDeactivate. In-progress geometry renders through
|
|
23
|
+
// preview patches — the polyline plus a dot per placed vertex — so it needs
|
|
24
|
+
// no platform-specific preview plumbing. Skia-free, like every tool factory.
|
|
25
|
+
export const createPolygonTool = (options = {}) => {
|
|
26
|
+
const color = options.color ?? '#111827';
|
|
27
|
+
const width = options.width ?? 2;
|
|
28
|
+
const dash = options.dash ?? false;
|
|
29
|
+
// In-progress polygon (spans gestures — see PolygonGestureState).
|
|
30
|
+
let vertices = [];
|
|
31
|
+
let shapeId = null;
|
|
32
|
+
const buildShape = (doc, points, closed) => ({
|
|
33
|
+
id: shapeId ?? makeId(),
|
|
34
|
+
layerId: firstLayerId(doc),
|
|
35
|
+
kind: 'polygon',
|
|
36
|
+
// Snapshot the array: `points` is usually the live `vertices` buffer, and
|
|
37
|
+
// downstream memoization (ShapeElement keys its Skia path on
|
|
38
|
+
// geometry.points IDENTITY) must see a new array per emit — handing out
|
|
39
|
+
// the shared reference froze the preview polyline at its first segment.
|
|
40
|
+
geometry: { points: [...points], ...(closed ? {} : { closed: false }) },
|
|
41
|
+
style: { stroke: color, strokeWidth: width, ...(dash && { dash: true }) },
|
|
42
|
+
createdAt: Date.now(),
|
|
43
|
+
});
|
|
44
|
+
// Preview = the open polyline so far (+ the in-drag rubber point) plus a
|
|
45
|
+
// dot per placed vertex. Dots are preview-only ellipse shapes — they ride
|
|
46
|
+
// the same patch and never get committed. Sized in doc units from the live
|
|
47
|
+
// zoom at emit time (close enough; the preview re-emits on every event).
|
|
48
|
+
const previewOps = (doc, zoom, rubber) => {
|
|
49
|
+
if (vertices.length === 0)
|
|
50
|
+
return [];
|
|
51
|
+
const pts = rubber ? [...vertices, rubber] : vertices;
|
|
52
|
+
const ops = [];
|
|
53
|
+
if (pts.length >= 2) {
|
|
54
|
+
ops.push({
|
|
55
|
+
op: 'addShape',
|
|
56
|
+
shape: { ...buildShape(doc, pts, false), id: `${shapeId}-draft` },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const closable = vertices.length >= 3;
|
|
60
|
+
vertices.forEach((v, i) => {
|
|
61
|
+
const px = i === 0 && closable ? CLOSE_TARGET_DOT_PX : VERTEX_DOT_PX;
|
|
62
|
+
const r = px / zoom;
|
|
63
|
+
ops.push({
|
|
64
|
+
op: 'addShape',
|
|
65
|
+
shape: {
|
|
66
|
+
id: `${shapeId}-v${i}`,
|
|
67
|
+
layerId: firstLayerId(doc),
|
|
68
|
+
kind: 'ellipse',
|
|
69
|
+
geometry: {
|
|
70
|
+
points: [
|
|
71
|
+
{ x: v.x - r, y: v.y - r },
|
|
72
|
+
{ x: v.x + r, y: v.y + r },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
style: { fill: color, stroke: '#FFFFFF', strokeWidth: r / 3 },
|
|
76
|
+
createdAt: 0,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
return ops;
|
|
81
|
+
};
|
|
82
|
+
const reset = () => {
|
|
83
|
+
vertices = [];
|
|
84
|
+
shapeId = null;
|
|
85
|
+
};
|
|
86
|
+
const commit = (ctx, closed) => {
|
|
87
|
+
const shape = buildShape(ctx.document, vertices, closed);
|
|
88
|
+
ctx.commit({ ops: [{ op: 'addShape', shape }] });
|
|
89
|
+
reset();
|
|
90
|
+
options.onPlaced?.(shape);
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
id: options.id ?? 'polygon',
|
|
94
|
+
label: options.label ?? 'Polygon',
|
|
95
|
+
cursor: 'crosshair',
|
|
96
|
+
onPointerDown() {
|
|
97
|
+
return { kind: 'polygon-pointing' };
|
|
98
|
+
},
|
|
99
|
+
onPointerMove(event, ctx, state) {
|
|
100
|
+
const s = state;
|
|
101
|
+
if (s?.kind !== 'polygon-pointing')
|
|
102
|
+
return s;
|
|
103
|
+
if (vertices.length === 0)
|
|
104
|
+
return s;
|
|
105
|
+
ctx.preview({
|
|
106
|
+
ops: previewOps(ctx.document, ctx.viewport.state.zoom, event.world),
|
|
107
|
+
});
|
|
108
|
+
return s;
|
|
109
|
+
},
|
|
110
|
+
onPointerUp(event, ctx, state) {
|
|
111
|
+
const s = state;
|
|
112
|
+
if (s?.kind !== 'polygon-pointing')
|
|
113
|
+
return;
|
|
114
|
+
const zoom = ctx.viewport.state.zoom;
|
|
115
|
+
const tol = VERTEX_TAP_PX / zoom;
|
|
116
|
+
const tolSq = tol * tol;
|
|
117
|
+
const p = event.world;
|
|
118
|
+
// Tap on the first vertex → close the ring and commit.
|
|
119
|
+
if (vertices.length >= 3 && distSq(p, vertices[0]) <= tolSq) {
|
|
120
|
+
commit(ctx, true);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Tap on the last vertex (tap-again / double-tap) → finish open.
|
|
124
|
+
if (vertices.length >= 1 &&
|
|
125
|
+
distSq(p, vertices[vertices.length - 1]) <= tolSq) {
|
|
126
|
+
if (vertices.length >= 2) {
|
|
127
|
+
commit(ctx, false);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
// Re-tapped the only vertex — treat as "never mind".
|
|
131
|
+
reset();
|
|
132
|
+
ctx.preview({ ops: [] });
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!shapeId)
|
|
137
|
+
shapeId = makeId();
|
|
138
|
+
// Immutable append (defense in depth alongside buildShape's snapshot):
|
|
139
|
+
// nothing downstream can ever observe this buffer mutating.
|
|
140
|
+
vertices = [...vertices, { x: p.x, y: p.y }];
|
|
141
|
+
ctx.preview({ ops: previewOps(ctx.document, zoom) });
|
|
142
|
+
},
|
|
143
|
+
// Gesture interrupted (e.g. a second finger landed and the viewport pan
|
|
144
|
+
// took over). Keep the placed vertices and re-establish the preview — the
|
|
145
|
+
// canvas clears the preview patch before calling this.
|
|
146
|
+
onCancel(_state, ctx) {
|
|
147
|
+
if (vertices.length === 0)
|
|
148
|
+
return;
|
|
149
|
+
ctx.preview({ ops: previewOps(ctx.document, ctx.viewport.state.zoom) });
|
|
150
|
+
},
|
|
151
|
+
// Switching tools mid-build: commit what exists as an open polyline (two
|
|
152
|
+
// or more vertices make a real shape; a lone vertex is discarded).
|
|
153
|
+
onDeactivate(ctx) {
|
|
154
|
+
if (vertices.length >= 2) {
|
|
155
|
+
commit(ctx, false);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
reset();
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
};
|