@reekon-tools/boldr-utils 1.6.12 → 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.
Files changed (41) hide show
  1. package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +5 -2
  3. package/dist/annotation/canvas/AnnotationCanvasInner.js +58 -6
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +5 -2
  5. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +514 -59
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +31 -1
  7. package/dist/annotation/canvas/AnnotationCanvasSkia.js +38 -9
  8. package/dist/annotation/canvas/Tool.d.ts +27 -0
  9. package/dist/annotation/canvas/elements/BackgroundImageElement.js +4 -1
  10. package/dist/annotation/canvas/elements/ShapeElement.js +68 -9
  11. package/dist/annotation/canvas/elements/StrokeElement.js +8 -3
  12. package/dist/annotation/canvas/measurementGeometry.d.ts +21 -0
  13. package/dist/annotation/canvas/measurementGeometry.js +98 -3
  14. package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
  15. package/dist/annotation/canvas/shapeGeometry.js +116 -0
  16. package/dist/annotation/canvas/strokeGeometry.d.ts +1 -0
  17. package/dist/annotation/canvas/strokeGeometry.js +8 -0
  18. package/dist/annotation/canvas/textGeometry.d.ts +24 -0
  19. package/dist/annotation/canvas/textGeometry.js +110 -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.d.ts +1 -0
  23. package/dist/annotation/canvas/tools/penTool.js +8 -2
  24. package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
  25. package/dist/annotation/canvas/tools/polygonTool.js +162 -0
  26. package/dist/annotation/canvas/tools/selectTool.js +148 -51
  27. package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
  28. package/dist/annotation/canvas/tools/shapeTool.js +111 -0
  29. package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
  30. package/dist/annotation/canvas/tools/textTool.js +78 -0
  31. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -1
  32. package/dist/annotation/canvas/useAnnotationCanvasState.js +56 -6
  33. package/dist/annotation/data/coalescedRunner.d.ts +1 -0
  34. package/dist/annotation/data/coalescedRunner.js +48 -0
  35. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +118 -38
  36. package/dist/exports.d.ts +9 -4
  37. package/dist/exports.js +8 -3
  38. package/dist/formulas/calculateFormula.js +1 -3
  39. package/dist/types/annotation.d.ts +9 -0
  40. package/dist/types/firestore.d.ts +4 -0
  41. package/package.json +1 -1
@@ -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,4 +1,5 @@
1
1
  import type { StrokeCap, Vec2 } from '../../types/annotation.js';
2
2
  export declare const toSkiaStrokeCap: (cap: StrokeCap | undefined) => "butt" | "round" | "square";
3
+ export declare const dashIntervals: (width: number) => [number, number];
3
4
  export declare const arrowheadLength: (width: number) => number;
4
5
  export declare const arrowheadTriangle: (end: Vec2, from: Vec2, width: number) => [Vec2, Vec2, Vec2];
@@ -4,6 +4,14 @@
4
4
  // (we draw an arrowhead separately), so the base line uses a 'round' cap.
5
5
  // Absent === 'round'.
6
6
  export const toSkiaStrokeCap = (cap) => cap === 'butt' || cap === 'square' ? cap : 'round';
7
+ // Dash pattern ([on, off], doc units) for dashed strokes/lines/rect borders.
8
+ // Scales with the stroke width so thick strokes read as dashed rather than
9
+ // dotted, floored so thin strokes still show distinct dashes. Single source
10
+ // for every dashed paint in the canvas — keep all platforms identical.
11
+ export const dashIntervals = (width) => [
12
+ Math.max(width * 3, 6),
13
+ Math.max(width * 2, 4),
14
+ ];
7
15
  // Forward length of the arrowhead (line end → apex), as a multiple of stroke
8
16
  // width (clamped to a minimum so thin strokes still get a visible head). Doc
9
17
  // units. Kept small since the head is a solid filled triangle.
@@ -0,0 +1,24 @@
1
+ import type { AnnotationShape, Vec2 } from '../../types/annotation.js';
2
+ export declare const DEFAULT_TEXT_FONT_SIZE = 24;
3
+ export declare const MIN_TEXT_FONT_SIZE = 8;
4
+ export declare const MAX_TEXT_FONT_SIZE = 200;
5
+ export declare const TEXT_LINE_HEIGHT_FACTOR = 1.2;
6
+ export declare const TEXT_BASELINE_FACTOR = 0.8;
7
+ export declare const SELECTION_PAD = 6;
8
+ export declare const estimateLineWidth: (line: string, fontSize: number) => number;
9
+ export interface TextBounds {
10
+ minX: number;
11
+ minY: number;
12
+ maxX: number;
13
+ maxY: number;
14
+ }
15
+ export declare const textShapeBounds: (shape: AnnotationShape) => TextBounds | null;
16
+ export declare const hitTestTextShape: (shape: AnnotationShape, p: Vec2, padding?: number) => boolean;
17
+ export interface ResizeGeometry {
18
+ pivot: Vec2;
19
+ handle: Vec2;
20
+ minScale: number;
21
+ maxScale: number;
22
+ }
23
+ export declare const textResizeGeometry: (shape: AnnotationShape) => ResizeGeometry | null;
24
+ export declare const resizeScaleFromDrag: (geom: ResizeGeometry, delta: Vec2) => number;
@@ -0,0 +1,110 @@
1
+ // Geometry for `kind: 'text'` shapes. A text shape stores only its TOP-LEFT
2
+ // anchor (`geometry.points[0]`), its content (`text`, '\n'-separated lines) and
3
+ // its size (`style.fontSize`, doc units); everything else — bounds, hit boxes,
4
+ // the resize handle, line layout — derives from those through this module.
5
+ //
6
+ // This module is deliberately Skia-free: tools (selectTool, textTool) need the
7
+ // same bounds the renderer uses, and tools must stay importable without
8
+ // touching @shopify/react-native-skia (see penTool.ts). So bounds are
9
+ // ESTIMATED from per-character advance-width factors rather than measured with
10
+ // a real SkFont. The renderer (ShapeElement) lays glyphs out with the same
11
+ // line-height/baseline factors, so boxes and glyphs agree vertically; widths
12
+ // are approximate, which is fine for selection chrome and hit-testing.
13
+ export const DEFAULT_TEXT_FONT_SIZE = 24;
14
+ export const MIN_TEXT_FONT_SIZE = 8;
15
+ export const MAX_TEXT_FONT_SIZE = 200;
16
+ // Line height and baseline offset (from the line top), as multiples of
17
+ // fontSize. The renderer uses the same factors so the estimated box always
18
+ // brackets the drawn glyphs vertically.
19
+ export const TEXT_LINE_HEIGHT_FACTOR = 1.2;
20
+ export const TEXT_BASELINE_FACTOR = 0.8;
21
+ // Selection-box padding in doc units. Lives here (Skia-free) rather than in
22
+ // AnnotationCanvasSkia so tools can hit-test selection chrome — the text
23
+ // resize handle sits on the padded box corner — without importing the Skia
24
+ // tree. AnnotationCanvasSkia imports this for every selection box.
25
+ export const SELECTION_PAD = 6;
26
+ // Advance width / fontSize per character class, tuned for a bold geometric
27
+ // sans (the app font is Metropolis Bold). Estimates, not measurements — see
28
+ // the module comment.
29
+ const NARROW = new Set("ijl!|.,:;'`".split(''));
30
+ const SLIM = new Set('ftr()[]{}"- '.split(''));
31
+ const WIDE = new Set('mwMW@%'.split(''));
32
+ const charWidthFactor = (ch) => {
33
+ if (NARROW.has(ch))
34
+ return 0.3;
35
+ if (SLIM.has(ch))
36
+ return 0.42;
37
+ if (WIDE.has(ch))
38
+ return 0.92;
39
+ if (ch >= 'A' && ch <= 'Z')
40
+ return 0.72;
41
+ return 0.6;
42
+ };
43
+ export const estimateLineWidth = (line, fontSize) => {
44
+ let w = 0;
45
+ for (const ch of line)
46
+ w += charWidthFactor(ch);
47
+ return w * fontSize;
48
+ };
49
+ const textFontSize = (shape) => shape.style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
50
+ // Estimated bounds of a text shape, or null when it isn't a renderable text
51
+ // shape (wrong kind, no anchor, no text).
52
+ export const textShapeBounds = (shape) => {
53
+ if (shape.kind !== 'text')
54
+ return null;
55
+ const origin = shape.geometry.points[0];
56
+ if (!origin || !shape.text)
57
+ return null;
58
+ const fontSize = textFontSize(shape);
59
+ const lines = shape.text.split('\n');
60
+ let width = 0;
61
+ for (const line of lines) {
62
+ const w = estimateLineWidth(line, fontSize);
63
+ if (w > width)
64
+ width = w;
65
+ }
66
+ return {
67
+ minX: origin.x,
68
+ minY: origin.y,
69
+ maxX: origin.x + width,
70
+ maxY: origin.y + lines.length * TEXT_LINE_HEIGHT_FACTOR * fontSize,
71
+ };
72
+ };
73
+ // Whether a world point is on a text shape (its estimated box + padding).
74
+ export const hitTestTextShape = (shape, p, padding = SELECTION_PAD) => {
75
+ const b = textShapeBounds(shape);
76
+ if (!b)
77
+ return false;
78
+ return (p.x >= b.minX - padding &&
79
+ p.x <= b.maxX + padding &&
80
+ p.y >= b.minY - padding &&
81
+ p.y <= b.maxY + padding);
82
+ };
83
+ export const textResizeGeometry = (shape) => {
84
+ const b = textShapeBounds(shape);
85
+ if (!b)
86
+ return null;
87
+ const fontSize = textFontSize(shape);
88
+ return {
89
+ pivot: { x: b.minX, y: b.minY },
90
+ handle: { x: b.maxX + SELECTION_PAD, y: b.maxY + SELECTION_PAD },
91
+ minScale: MIN_TEXT_FONT_SIZE / fontSize,
92
+ maxScale: MAX_TEXT_FONT_SIZE / fontSize,
93
+ };
94
+ };
95
+ // Uniform scale for a resize drag: how far the handle has moved from the
96
+ // pivot, relative to where it started, clamped to the geometry's scale range.
97
+ // The native resize preview worklet in AnnotationCanvasInner.native.tsx is a
98
+ // WORKLET TWIN of this math — keep them in sync.
99
+ export const resizeScaleFromDrag = (geom, delta) => {
100
+ const bx = geom.handle.x - geom.pivot.x;
101
+ const by = geom.handle.y - geom.pivot.y;
102
+ const baseLen = Math.sqrt(bx * bx + by * by);
103
+ let s = 1;
104
+ if (baseLen > 0) {
105
+ const nx = bx + delta.x;
106
+ const ny = by + delta.y;
107
+ s = Math.sqrt(nx * nx + ny * ny) / baseLen;
108
+ }
109
+ return Math.min(geom.maxScale, Math.max(geom.minScale, s));
110
+ };
@@ -1,5 +1,6 @@
1
1
  import type { Tool } from '../Tool.js';
2
2
  export interface PanToolOptions {
3
3
  cursor?: string;
4
+ selectMeasurementsOnTap?: boolean;
4
5
  }
5
6
  export declare const createPanTool: (options?: PanToolOptions) => Tool;
@@ -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 { kind: 'panning', lastScreen: event.screen };
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
- return { kind: 'panning', lastScreen: event.screen };
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(_event, _ctx, _state) {
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
  });
@@ -4,6 +4,7 @@ export interface PenToolOptions {
4
4
  color?: string;
5
5
  width?: number;
6
6
  cap?: StrokeCap;
7
+ dash?: boolean;
7
8
  minSampleDistance?: number;
8
9
  variant?: 'pen' | 'marker' | 'highlighter';
9
10
  }
@@ -10,6 +10,7 @@ export const createPenTool = (options = {}) => {
10
10
  const color = options.color ?? '#111827';
11
11
  const width = options.width ?? 2;
12
12
  const cap = options.cap ?? 'round';
13
+ const dash = options.dash ?? false;
13
14
  const variant = options.variant ?? 'pen';
14
15
  const minSampleDistance = options.minSampleDistance ?? 1.5;
15
16
  const minSampleDistanceSq = minSampleDistance * minSampleDistance;
@@ -19,11 +20,15 @@ export const createPenTool = (options = {}) => {
19
20
  const dotCap = cap === 'butt' || cap === 'arrow' ? 'round' : cap;
20
21
  return {
21
22
  id: variant,
22
- label: variant === 'pen' ? 'Pen' : variant === 'marker' ? 'Marker' : 'Highlighter',
23
+ label: variant === 'pen'
24
+ ? 'Pen'
25
+ : variant === 'marker'
26
+ ? 'Marker'
27
+ : 'Highlighter',
23
28
  cursor: 'crosshair',
24
29
  // Drives UI-thread drawing on native (see FreehandConfig). The
25
30
  // onPointerDown/Move/Up below remain the web/parity implementation.
26
- freehand: { variant, color, width, cap, minSampleDistance },
31
+ freehand: { variant, color, width, cap, dash, minSampleDistance },
27
32
  onPointerDown(event, ctx) {
28
33
  const stroke = {
29
34
  id: makeId('stroke'),
@@ -32,6 +37,7 @@ export const createPenTool = (options = {}) => {
32
37
  color,
33
38
  width,
34
39
  cap,
40
+ ...(dash && { dash }),
35
41
  points: [event.world.x, event.world.y],
36
42
  pressure: event.pressure !== undefined ? [event.pressure] : undefined,
37
43
  createdAt: Date.now(),
@@ -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
+ };