@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
@@ -10,6 +10,9 @@ type AnimatedPoint = {
10
10
  y: number;
11
11
  };
12
12
  };
13
+ type AnimatedNumber = number | {
14
+ value: number;
15
+ };
13
16
  export interface AnnotationCanvasSkiaProps {
14
17
  width: number;
15
18
  height: number;
@@ -24,23 +27,50 @@ export interface AnnotationCanvasSkiaProps {
24
27
  path: SkPath | {
25
28
  value: SkPath;
26
29
  };
30
+ handoffPaths?: (SkPath | {
31
+ value: SkPath;
32
+ })[];
27
33
  color: string;
28
34
  width: number;
29
35
  cap?: StrokeCap;
36
+ dash?: boolean;
30
37
  opacity: number;
31
38
  } | null;
39
+ shapePreview?: {
40
+ path: SkPath | {
41
+ value: SkPath;
42
+ };
43
+ headPath: SkPath | {
44
+ value: SkPath;
45
+ };
46
+ color: string;
47
+ width: number;
48
+ cap?: StrokeCap;
49
+ dash?: boolean;
50
+ } | null;
32
51
  draggingId?: string | null;
33
52
  dragTransform?: Transforms3d | {
34
53
  value: Transforms3d;
35
54
  };
55
+ resizingId?: string | null;
56
+ resizeTransform?: Transforms3d | {
57
+ value: Transforms3d;
58
+ };
36
59
  selectedId?: string | null;
37
60
  endpointDragId?: string | null;
38
61
  liveLineP1?: AnimatedPoint;
39
62
  liveLineP2?: AnimatedPoint;
63
+ rectDragId?: string | null;
64
+ liveRect?: {
65
+ x: AnimatedNumber;
66
+ y: AnimatedNumber;
67
+ width: AnimatedNumber;
68
+ height: AnimatedNumber;
69
+ };
40
70
  handleRadius?: number | {
41
71
  value: number;
42
72
  };
43
73
  customPreview?: ReactNode;
44
74
  }
45
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
75
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
46
76
  export {};
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Canvas, Circle, Group, Line, Path, Rect, Skia, } from '@shopify/react-native-skia';
3
- import { placementOf } from './measurementGeometry.js';
4
- import { arrowheadTriangle, toSkiaStrokeCap } from './strokeGeometry.js';
2
+ import { Canvas, Circle, DashPathEffect, Group, Line, Path, Rect, Skia, } from '@shopify/react-native-skia';
3
+ import { normalizeRect, placementOf } from './measurementGeometry.js';
4
+ import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from './strokeGeometry.js';
5
+ import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeometry.js';
5
6
  import { BackgroundImageElement } from './elements/BackgroundImageElement.js';
6
7
  import { ShapeElement } from './elements/ShapeElement.js';
7
8
  import { StrokeElement } from './elements/StrokeElement.js';
@@ -17,8 +18,9 @@ const MEASUREMENT_HANDLE_COLOR = '#0066FF';
17
18
  // Selection bounding box for strokes/shapes (measurements get handles instead).
18
19
  // Padding + stroke width are in doc units (scale with zoom) — simple and clear;
19
20
  // the box is only a "this is selected" affordance, not a precise gizmo.
21
+ // SELECTION_PAD lives in textGeometry.ts (Skia-free) so the select tool can
22
+ // hit-test the text resize handle, which sits on the padded box corner.
20
23
  const SELECTION_COLOR = '#0066FF';
21
- const SELECTION_PAD = 6;
22
24
  const SELECTION_STROKE = 1.5;
23
25
  // Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
24
26
  const strokeBounds = (points) => {
@@ -84,7 +86,22 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
84
86
  // since the function-call pattern works identically on native we use it
85
87
  // in both Inners for consistency. Don't add hooks here; this is a plain
86
88
  // JSX-returning helper, not a component.
87
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, handleRadius, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId, transform: dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
89
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
90
+ // Rectangle annotation: a stroked border whose center carries the
91
+ // tile. A corner drag renders from the live geometry (outside the
92
+ // group translate, like an endpoint drag); otherwise the committed
93
+ // rect + (when selected) four corner handles, wrapped for group move.
94
+ if (placementOf(placed) === 'rectangle' && placed.rect) {
95
+ const isSelected = placed.id === selectedId;
96
+ const isRectDrag = placed.id === rectDragId;
97
+ const rectColor = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
98
+ const rectWidth = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
99
+ if (isRectDrag && liveRect) {
100
+ return (_jsx(Rect, { x: liveRect.x, y: liveRect.y, width: liveRect.width, height: liveRect.height, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }, placed.id));
101
+ }
102
+ const n = normalizeRect(placed.rect);
103
+ return (_jsxs(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: [_jsx(Rect, { x: n.minX, y: n.minY, width: n.maxX - n.minX, height: n.maxY - n.minY, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: { x: n.minX, y: n.minY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.maxX, y: n.minY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.minX, y: n.maxY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.maxX, y: n.maxY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }, placed.id));
104
+ }
88
105
  if (placementOf(placed) !== 'line' || !placed.line)
89
106
  return null;
90
107
  const isEndpointDrag = placed.id === endpointDragId;
@@ -109,7 +126,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
109
126
  p.close();
110
127
  return p;
111
128
  })();
112
- const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: p1, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: p2, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }));
129
+ const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap), children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(lineWidth) })) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: p1, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: p2, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }));
113
130
  return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
114
131
  }), (() => {
115
132
  if (!selectedId)
@@ -122,8 +139,20 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
122
139
  }
123
140
  const shape = effectiveCanvas.shapes.find((s) => s.id === selectedId);
124
141
  if (shape) {
125
- const b = pointsBounds(shape.geometry.points);
126
- return b ? (_jsx(SelectionBox, { bounds: b, isDragging: isDragging, transform: dragTransform })) : null;
142
+ // Text shapes derive their box from the estimated text bounds (the
143
+ // stored geometry is just the top-left anchor) and add a corner
144
+ // resize handle; both track the live resize transform so the chrome
145
+ // scales with the shape during a native UI-thread resize.
146
+ const isText = shape.kind === 'text';
147
+ const b = isText
148
+ ? textShapeBounds(shape)
149
+ : pointsBounds(shape.geometry.points);
150
+ if (!b)
151
+ return null;
152
+ const isResizing = shape.id === resizingId;
153
+ const liveTransform = isResizing ? resizeTransform : dragTransform;
154
+ const resizeGeom = isText ? textResizeGeometry(shape) : null;
155
+ return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isDragging || isResizing, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: _jsx(Circle, { c: resizeGeom.handle, r: handleRadius, color: SELECTION_COLOR }) }))] }));
127
156
  }
128
157
  return null;
129
- })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity })), customPreview] }) }));
158
+ })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), shapePreview && (_jsxs(_Fragment, { children: [_jsx(Path, { path: shapePreview.path, color: shapePreview.color, style: "stroke", strokeWidth: shapePreview.width, strokeCap: toSkiaStrokeCap(shapePreview.cap), strokeJoin: "round", children: shapePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(shapePreview.width) })) }), _jsx(Path, { path: shapePreview.headPath, color: shapePreview.color, style: "fill" })] })), livePreview?.handoffPaths?.map((p, i) => (_jsx(Path, { path: p, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) }, i))), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) })), customPreview] }) }));
@@ -1,7 +1,13 @@
1
1
  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
+ import type { RectCorner } from './measurementGeometry.js';
5
+ import type { ShapeToolKind } from './shapeGeometry.js';
6
+ import type { ResizeGeometry } from './textGeometry.js';
4
7
  import type { ViewportApi } from './viewport.js';
8
+ export type RequestTextInput = (options?: {
9
+ initialText?: string;
10
+ }) => Promise<string | null>;
5
11
  export interface CanvasPointerEvent {
6
12
  pointerId: number;
7
13
  world: Vec2;
@@ -20,6 +26,9 @@ export interface ToolContext {
20
26
  commit(patch: AnnotationDocumentPatch): void;
21
27
  setSelection(selection: Selection | null): void;
22
28
  requestPickMeasurement(): Promise<MeasurementRef | null>;
29
+ requestTextInput(options?: {
30
+ initialText?: string;
31
+ }): Promise<string | null>;
23
32
  applyPan(deltaScreen: Vec2): void;
24
33
  applyZoom(focalScreen: Vec2, nextZoom: number): void;
25
34
  }
@@ -29,8 +38,16 @@ export interface FreehandConfig {
29
38
  color: string;
30
39
  width: number;
31
40
  cap?: StrokeCap;
41
+ dash?: boolean;
32
42
  minSampleDistance: number;
33
43
  }
44
+ export interface ShapeDrawConfig {
45
+ kind: ShapeToolKind;
46
+ color: string;
47
+ width: number;
48
+ cap?: StrokeCap;
49
+ dash?: boolean;
50
+ }
34
51
  export type DragElementKind = 'stroke' | 'shape' | 'measurement';
35
52
  export interface DragSelectionConfig {
36
53
  hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number): {
@@ -42,6 +59,14 @@ export interface DragSelectionConfig {
42
59
  buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
43
60
  hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
44
61
  buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
62
+ hitTestRectCorner?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): {
63
+ corner: RectCorner;
64
+ moving: Vec2;
65
+ fixed: Vec2;
66
+ } | null;
67
+ buildRectCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
68
+ hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
69
+ buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
45
70
  }
46
71
  export interface Tool {
47
72
  id: string;
@@ -49,12 +74,14 @@ export interface Tool {
49
74
  icon?: ComponentType;
50
75
  cursor?: string;
51
76
  freehand?: FreehandConfig;
77
+ shapeDraw?: ShapeDrawConfig;
52
78
  panViewport?: boolean;
53
79
  dragSelection?: DragSelectionConfig;
54
80
  onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
55
81
  onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
56
82
  onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
57
83
  onCancel?(state: ToolState, ctx: ToolContext): void;
84
+ onDeactivate?(ctx: ToolContext): void;
58
85
  renderPreview?(state: ToolState, ctx: ToolContext): ReactNode;
59
86
  hitTest?(element: AnnotationElement, worldPoint: Vec2): boolean;
60
87
  }
@@ -2,7 +2,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Image, useImage } from '@shopify/react-native-skia';
3
3
  import { memo, useEffect, useRef, useState } from 'react';
4
4
  const computeFit = (imgW, imgH, docW, docH, fit) => {
5
- if (fit === 'stretch') {
5
+ // Guard against unknown/zero image dimensions: dividing by them yields
6
+ // Infinity → NaN geometry, and a <Image> with NaN bounds renders nothing
7
+ // (a silent "background never appears"). Fall back to filling the doc.
8
+ if (!(imgW > 0) || !(imgH > 0) || fit === 'stretch') {
6
9
  return { x: 0, y: 0, width: docW, height: docH };
7
10
  }
8
11
  const scale = fit === 'cover'
@@ -1,7 +1,13 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Circle, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
2
+ import { Circle, DashPathEffect, Group, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
3
3
  import { memo, useMemo } from 'react';
4
- const polygonPath = (points) => {
4
+ import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } 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, closed) => {
5
11
  const path = Skia.Path.Make();
6
12
  if (points.length === 0)
7
13
  return path;
@@ -9,7 +15,8 @@ const polygonPath = (points) => {
9
15
  for (let i = 1; i < points.length; i++) {
10
16
  path.lineTo(points[i].x, points[i].y);
11
17
  }
12
- path.close();
18
+ if (closed)
19
+ path.close();
13
20
  return path;
14
21
  };
15
22
  // Memoized — see StrokeElement. Unchanged shapes keep their identity across
@@ -19,7 +26,39 @@ export const ShapeElement = memo(({ shape, font }) => {
19
26
  const stroke = style.stroke ?? '#000000';
20
27
  const fill = style.fill;
21
28
  const strokeWidth = style.strokeWidth ?? 2;
22
- const polyPath = useMemo(() => (kind === 'polygon' ? polygonPath(geometry.points) : null), [kind, geometry.points]);
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;
23
62
  switch (kind) {
24
63
  case 'rect': {
25
64
  const [a, b] = geometry.points;
@@ -29,7 +68,7 @@ export const ShapeElement = memo(({ shape, font }) => {
29
68
  const y = Math.min(a.y, b.y);
30
69
  const w = Math.abs(b.x - a.x);
31
70
  const h = Math.abs(b.y - a.y);
32
- 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 })] }));
33
72
  }
34
73
  case 'ellipse': {
35
74
  const [a, b] = geometry.points;
@@ -38,27 +77,47 @@ export const ShapeElement = memo(({ shape, font }) => {
38
77
  const cx = (a.x + b.x) / 2;
39
78
  const cy = (a.y + b.y) / 2;
40
79
  const r = Math.max(Math.abs(b.x - a.x), Math.abs(b.y - a.y)) / 2;
41
- 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 })] }));
42
81
  }
43
82
  case 'line':
44
83
  case 'arrow': {
45
84
  const [a, b] = geometry.points;
46
85
  if (!a || !b)
47
86
  return null;
48
- return (_jsx(Line, { p1: a, p2: b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: "round" }));
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" })] }));
49
88
  }
50
89
  case 'polygon': {
51
90
  if (!polyPath)
52
91
  return null;
53
- 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" })] }));
54
93
  }
55
94
  case 'text': {
95
+ // `origin` is the TOP-LEFT of the text block (textGeometry derives
96
+ // bounds/hit boxes from the same anchor + factors, so chrome and glyphs
97
+ // agree). The shared font is loaded at one fixed size; style.fontSize is
98
+ // honored by scaling the glyph outlines about the anchor — Skia text is
99
+ // vector, so this is loss-free. Lines are laid out with the textGeometry
100
+ // factors; Skia <Text> y is the BASELINE, hence the baseline offset.
56
101
  const [origin] = geometry.points;
57
102
  if (!origin || !text)
58
103
  return null;
59
104
  if (!font)
60
105
  return null;
61
- return (_jsx(Text, { x: origin.x, y: origin.y, text: text, font: font, color: stroke }));
106
+ const fontSize = style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
107
+ const baseSize = font.getSize();
108
+ const scale = baseSize > 0 ? fontSize / baseSize : 1;
109
+ // Dash-styled text strokes the glyph outlines (a dash effect is a path
110
+ // effect — it needs stroked geometry, so it can't apply to filled
111
+ // glyphs) and runs the shared dash pattern along them.
112
+ const outlineWidth = baseSize * TEXT_OUTLINE_WIDTH_FACTOR;
113
+ return (_jsx(Group, { transform: [
114
+ { translateX: origin.x },
115
+ { translateY: origin.y },
116
+ { scale },
117
+ ], 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 && {
118
+ style: 'stroke',
119
+ strokeWidth: outlineWidth,
120
+ }), children: style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(outlineWidth) })) }, i))) }));
62
121
  }
63
122
  }
64
123
  });
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Path, Skia } from '@shopify/react-native-skia';
2
+ import { DashPathEffect, Path, Skia, } from '@shopify/react-native-skia';
3
3
  import { memo, useMemo } from 'react';
4
- import { arrowheadTriangle, toSkiaStrokeCap } from '../strokeGeometry.js';
4
+ import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from '../strokeGeometry.js';
5
5
  export const pointsToSkPath = (points) => {
6
6
  const path = Skia.Path.Make();
7
7
  if (points.length < 2)
@@ -36,5 +36,10 @@ export const StrokeElement = memo(({ stroke }) => {
36
36
  const path = useMemo(() => pointsToSkPath(stroke.points), [stroke.points]);
37
37
  const arrow = useMemo(() => arrowheadPath(stroke), [stroke.points, stroke.cap, stroke.width]);
38
38
  const opacity = stroke.tool === 'highlighter' ? 0.3 : 1;
39
- return (_jsxs(_Fragment, { children: [_jsx(Path, { path: path, color: stroke.color, style: "stroke", strokeWidth: stroke.width, strokeCap: toSkiaStrokeCap(stroke.cap), strokeJoin: "round", opacity: opacity }), arrow && (_jsx(Path, { path: arrow, color: stroke.color, style: "fill", opacity: opacity }))] }));
39
+ // A tap-dot is a zero-length two-point path; a dash effect would erase it
40
+ // (the contour has no length to dash), so dots always render solid.
41
+ const isDot = stroke.points.length === 4 &&
42
+ stroke.points[0] === stroke.points[2] &&
43
+ stroke.points[1] === stroke.points[3];
44
+ return (_jsxs(_Fragment, { children: [_jsx(Path, { path: path, color: stroke.color, style: "stroke", strokeWidth: stroke.width, strokeCap: toSkiaStrokeCap(stroke.cap), strokeJoin: "round", opacity: opacity, children: stroke.dash && !isDot && (_jsx(DashPathEffect, { intervals: dashIntervals(stroke.width) })) }), arrow && (_jsx(Path, { path: arrow, color: stroke.color, style: "fill", opacity: opacity }))] }));
40
45
  });
@@ -16,6 +16,27 @@ export declare const lineLength: (line: {
16
16
  a: Vec2;
17
17
  b: Vec2;
18
18
  }) => number;
19
+ export interface NormalizedRect {
20
+ minX: number;
21
+ minY: number;
22
+ maxX: number;
23
+ maxY: number;
24
+ }
25
+ export declare const normalizeRect: (rect: {
26
+ a: Vec2;
27
+ b: Vec2;
28
+ }) => NormalizedRect;
29
+ export declare const rectCenter: (rect: {
30
+ a: Vec2;
31
+ b: Vec2;
32
+ }) => Vec2;
33
+ export type RectCorner = 'tl' | 'tr' | 'bl' | 'br';
34
+ export declare const rectCornerPoint: (rect: {
35
+ a: Vec2;
36
+ b: Vec2;
37
+ }, corner: RectCorner) => Vec2;
38
+ export declare const oppositeRectCorner: (corner: RectCorner) => RectCorner;
39
+ export declare const hitPlacedMeasurement: (m: PlacedMeasurementRef, p: Vec2, zoom?: number) => boolean;
19
40
  export interface RemoveMeasurementResult {
20
41
  ops: AnnotationPatchOp[];
21
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 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) => ({
@@ -48,8 +49,102 @@ export const lineLength = (line) => {
48
49
  const dy = line.b.y - line.a.y;
49
50
  return Math.sqrt(dx * dx + dy * dy);
50
51
  };
52
+ export const normalizeRect = (rect) => ({
53
+ minX: Math.min(rect.a.x, rect.b.x),
54
+ minY: Math.min(rect.a.y, rect.b.y),
55
+ maxX: Math.max(rect.a.x, rect.b.x),
56
+ maxY: Math.max(rect.a.y, rect.b.y),
57
+ });
58
+ // The tile anchor for a rectangle annotation: always the center. Any op that
59
+ // changes `rect` must write this into `anchor` in the same patch.
60
+ export const rectCenter = (rect) => ({
61
+ x: (rect.a.x + rect.b.x) / 2,
62
+ y: (rect.a.y + rect.b.y) / 2,
63
+ });
64
+ export const rectCornerPoint = (rect, corner) => {
65
+ const n = normalizeRect(rect);
66
+ switch (corner) {
67
+ case 'tl':
68
+ return { x: n.minX, y: n.minY };
69
+ case 'tr':
70
+ return { x: n.maxX, y: n.minY };
71
+ case 'bl':
72
+ return { x: n.minX, y: n.maxY };
73
+ case 'br':
74
+ return { x: n.maxX, y: n.maxY };
75
+ }
76
+ };
77
+ export const oppositeRectCorner = (corner) => {
78
+ switch (corner) {
79
+ case 'tl':
80
+ return 'br';
81
+ case 'tr':
82
+ return 'bl';
83
+ case 'bl':
84
+ return 'tr';
85
+ case 'br':
86
+ return 'tl';
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
+ };
51
146
  export const buildRemoveMeasurementOps = (placed) => {
52
- if (placementOf(placed) === 'line' && placed.measurementId) {
147
+ if (placementOf(placed) !== 'none' && placed.measurementId) {
53
148
  return {
54
149
  ops: [
55
150
  {
@@ -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;