@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.
Files changed (37) hide show
  1. package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
  3. package/dist/annotation/canvas/AnnotationCanvasInner.js +51 -13
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
  5. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +370 -57
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
  7. package/dist/annotation/canvas/AnnotationCanvasSkia.js +2 -2
  8. package/dist/annotation/canvas/Tool.d.ts +10 -0
  9. package/dist/annotation/canvas/elements/ShapeElement.js +115 -38
  10. package/dist/annotation/canvas/measurementGeometry.d.ts +1 -0
  11. package/dist/annotation/canvas/measurementGeometry.js +61 -2
  12. package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
  13. package/dist/annotation/canvas/shapeGeometry.js +116 -0
  14. package/dist/annotation/canvas/stampLayout.d.ts +4 -0
  15. package/dist/annotation/canvas/stampLayout.js +25 -9
  16. package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
  17. package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
  18. package/dist/annotation/canvas/tools/measurementTool.d.ts +15 -0
  19. package/dist/annotation/canvas/tools/measurementTool.js +133 -0
  20. package/dist/annotation/canvas/tools/panTool.d.ts +1 -0
  21. package/dist/annotation/canvas/tools/panTool.js +38 -5
  22. package/dist/annotation/canvas/tools/penTool.js +5 -1
  23. package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
  24. package/dist/annotation/canvas/tools/polygonTool.js +162 -0
  25. package/dist/annotation/canvas/tools/selectTool.js +37 -76
  26. package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
  27. package/dist/annotation/canvas/tools/shapeTool.js +111 -0
  28. package/dist/annotation/canvas/tools/textTool.d.ts +3 -1
  29. package/dist/annotation/canvas/tools/textTool.js +28 -3
  30. package/dist/annotation/canvas/useAnnotationCanvasState.js +27 -3
  31. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +83 -24
  32. package/dist/exports.d.ts +8 -4
  33. package/dist/exports.js +7 -3
  34. package/dist/formulas/calculateFormula.js +1 -3
  35. package/dist/types/annotation.d.ts +4 -0
  36. package/dist/types/firestore.d.ts +4 -0
  37. 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, Group, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
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, 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;
10
- const polygonPath = (points) => {
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
- path.close();
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
- const polyPath = useMemo(() => (kind === 'polygon' ? polygonPath(geometry.points) : null), [kind, geometry.points]);
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: "round" }));
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 (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.
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 || !text)
70
- return null;
71
- if (!font)
162
+ if (!origin || !textParagraph)
72
163
  return null;
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))) }));
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 and dependency-free so it's safe in tools,
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 constant for the placed measurement stamp. Lives in a
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 @shopify/react-native-skia into the
5
- // consumer's static import graph.
6
- // Constant SCREEN-space edge length of a placed measurement rendered as a
7
- // square tile. The tile is a fixed-size pin: its on-screen size is this times
8
- // `placed.scale` and does NOT change with zoom (only its position tracks the
9
- // canvas). Also drives the select-tool hit box, which converts it back to doc
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;