@reekon-tools/boldr-utils 1.6.11 → 1.6.13

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 (89) hide show
  1. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.d.ts +5 -3
  2. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.js +36 -17
  3. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.native.d.ts +5 -3
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +810 -0
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +61 -0
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +158 -0
  7. package/dist/annotation/canvas/Tool.d.ts +77 -0
  8. package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.d.ts +2 -2
  9. package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.js +17 -7
  10. package/dist/annotation/canvas/elements/ShapeElement.d.ts +7 -0
  11. package/dist/{canvas → annotation/canvas}/elements/ShapeElement.js +33 -5
  12. package/dist/annotation/canvas/elements/StrokeElement.d.ts +7 -0
  13. package/dist/annotation/canvas/elements/StrokeElement.js +45 -0
  14. package/dist/annotation/canvas/measurementGeometry.d.ts +43 -0
  15. package/dist/annotation/canvas/measurementGeometry.js +111 -0
  16. package/dist/{canvas → annotation/canvas}/measurementPicker.d.ts +1 -1
  17. package/dist/{canvas → annotation/canvas}/measurementStampOverlay.d.ts +2 -2
  18. package/dist/annotation/canvas/stampLayout.d.ts +1 -0
  19. package/dist/annotation/canvas/stampLayout.js +11 -0
  20. package/dist/annotation/canvas/strokeGeometry.d.ts +5 -0
  21. package/dist/annotation/canvas/strokeGeometry.js +41 -0
  22. package/dist/annotation/canvas/textGeometry.d.ts +24 -0
  23. package/dist/annotation/canvas/textGeometry.js +110 -0
  24. package/dist/{canvas → annotation/canvas}/tools/measurementStampTool.d.ts +1 -1
  25. package/dist/{canvas → annotation/canvas}/tools/measurementStampTool.js +1 -1
  26. package/dist/{canvas → annotation/canvas}/tools/panTool.js +3 -0
  27. package/dist/{canvas → annotation/canvas}/tools/penTool.d.ts +3 -1
  28. package/dist/{canvas → annotation/canvas}/tools/penTool.js +34 -5
  29. package/dist/annotation/canvas/tools/selectTool.js +446 -0
  30. package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
  31. package/dist/annotation/canvas/tools/textTool.js +78 -0
  32. package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.d.ts +11 -3
  33. package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.js +142 -2
  34. package/dist/{canvas → annotation/canvas}/viewport.d.ts +1 -1
  35. package/dist/{data → annotation/data}/AnnotationDataProvider.d.ts +1 -1
  36. package/dist/{data → annotation/data}/InMemoryAnnotationProvider.d.ts +1 -1
  37. package/dist/{data → annotation/data}/InMemoryAnnotationProvider.js +1 -1
  38. package/dist/{data → annotation/data}/canvasPersistence.d.ts +1 -1
  39. package/dist/{data → annotation/data}/canvasPersistence.js +1 -1
  40. package/dist/annotation/data/coalescedRunner.d.ts +1 -0
  41. package/dist/annotation/data/coalescedRunner.js +48 -0
  42. package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.d.ts +1 -1
  43. package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.js +37 -16
  44. package/dist/exports.d.ts +23 -19
  45. package/dist/exports.js +18 -14
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.js +2 -2
  48. package/dist/index.native.d.ts +1 -1
  49. package/dist/index.native.js +1 -1
  50. package/dist/types/annotation.d.ts +22 -3
  51. package/dist/types/firestore.d.ts +0 -1
  52. package/dist/{hooks → utils}/useParseMeasurement.js +1 -1
  53. package/package.json +1 -1
  54. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  55. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  56. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  57. package/dist/canvas/Tool.d.ts +0 -38
  58. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  59. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  60. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  61. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  62. package/dist/canvas/elements/StrokeElement.js +0 -18
  63. package/dist/canvas/stampLayout.d.ts +0 -5
  64. package/dist/canvas/stampLayout.js +0 -14
  65. package/dist/canvas/tools/selectTool.js +0 -182
  66. package/dist/utils/evaluateFormula.d.ts +0 -20
  67. package/dist/utils/evaluateFormula.js +0 -31
  68. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.d.ts +0 -0
  69. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.js +0 -0
  70. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.native.d.ts +0 -0
  71. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.native.js +0 -0
  72. /package/dist/{canvas → annotation/canvas}/Tool.js +0 -0
  73. /package/dist/{canvas → annotation/canvas}/measurementPicker.js +0 -0
  74. /package/dist/{canvas → annotation/canvas}/measurementStampOverlay.js +0 -0
  75. /package/dist/{canvas → annotation/canvas}/pointerAdapter.d.ts +0 -0
  76. /package/dist/{canvas → annotation/canvas}/pointerAdapter.js +0 -0
  77. /package/dist/{canvas → annotation/canvas}/tools/panTool.d.ts +0 -0
  78. /package/dist/{canvas → annotation/canvas}/tools/selectTool.d.ts +0 -0
  79. /package/dist/{canvas → annotation/canvas}/viewport.js +0 -0
  80. /package/dist/{data → annotation/data}/AnnotationDataContext.d.ts +0 -0
  81. /package/dist/{data → annotation/data}/AnnotationDataContext.js +0 -0
  82. /package/dist/{data → annotation/data}/AnnotationDataProvider.js +0 -0
  83. /package/dist/{data → annotation/data}/hooks/useAnnotationDoc.d.ts +0 -0
  84. /package/dist/{data → annotation/data}/hooks/useAnnotationDoc.js +0 -0
  85. /package/dist/{data → annotation/data}/hooks/useAnnotationList.d.ts +0 -0
  86. /package/dist/{data → annotation/data}/hooks/useAnnotationList.js +0 -0
  87. /package/dist/{data → annotation/data}/hooks/useAnnotationMutations.d.ts +0 -0
  88. /package/dist/{data → annotation/data}/hooks/useAnnotationMutations.js +0 -0
  89. /package/dist/{hooks → utils}/useParseMeasurement.d.ts +0 -0
@@ -0,0 +1,61 @@
1
+ import { type SkFont, type SkPath, type Transforms3d } from '@shopify/react-native-skia';
2
+ import type { ReactNode } from 'react';
3
+ import type { AnnotationCanvasState, AnnotationStroke, StrokeCap } from '../../types/annotation.js';
4
+ type AnimatedPoint = {
5
+ x: number;
6
+ y: number;
7
+ } | {
8
+ value: {
9
+ x: number;
10
+ y: number;
11
+ };
12
+ };
13
+ type AnimatedNumber = number | {
14
+ value: number;
15
+ };
16
+ export interface AnnotationCanvasSkiaProps {
17
+ width: number;
18
+ height: number;
19
+ effectiveCanvas: AnnotationCanvasState;
20
+ worldTransform: Transforms3d | {
21
+ value: Transforms3d;
22
+ };
23
+ resolveImageUrl?: (storagePath: string) => Promise<string>;
24
+ valueFont: SkFont | null;
25
+ penDrawingStroke: AnnotationStroke | null;
26
+ livePreview?: {
27
+ path: SkPath | {
28
+ value: SkPath;
29
+ };
30
+ color: string;
31
+ width: number;
32
+ cap?: StrokeCap;
33
+ dash?: boolean;
34
+ opacity: number;
35
+ } | null;
36
+ draggingId?: string | null;
37
+ dragTransform?: Transforms3d | {
38
+ value: Transforms3d;
39
+ };
40
+ resizingId?: string | null;
41
+ resizeTransform?: Transforms3d | {
42
+ value: Transforms3d;
43
+ };
44
+ selectedId?: string | null;
45
+ endpointDragId?: string | null;
46
+ liveLineP1?: AnimatedPoint;
47
+ liveLineP2?: AnimatedPoint;
48
+ rectDragId?: string | null;
49
+ liveRect?: {
50
+ x: AnimatedNumber;
51
+ y: AnimatedNumber;
52
+ width: AnimatedNumber;
53
+ height: AnimatedNumber;
54
+ };
55
+ handleRadius?: number | {
56
+ value: number;
57
+ };
58
+ customPreview?: ReactNode;
59
+ }
60
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
61
+ export {};
@@ -0,0 +1,158 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
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';
6
+ import { BackgroundImageElement } from './elements/BackgroundImageElement.js';
7
+ import { ShapeElement } from './elements/ShapeElement.js';
8
+ import { StrokeElement } from './elements/StrokeElement.js';
9
+ // Default visual constants for the measurement-annotation line (the tile itself
10
+ // is a fixed-size overlay; only the line lives in Skia). Width is in doc units,
11
+ // so it scales with zoom like the drawn line/arrow shapes. These are fallbacks:
12
+ // a placed annotation may override them via `lineColor`/`lineWidth`/`lineCap`.
13
+ const MEASUREMENT_LINE_COLOR = '#0066FF';
14
+ const MEASUREMENT_LINE_WIDTH = 2;
15
+ // Endpoint handle color — constant selection chrome, independent of the (now
16
+ // editable) line color so the handles stay legible on any line color.
17
+ const MEASUREMENT_HANDLE_COLOR = '#0066FF';
18
+ // Selection bounding box for strokes/shapes (measurements get handles instead).
19
+ // Padding + stroke width are in doc units (scale with zoom) — simple and clear;
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.
23
+ const SELECTION_COLOR = '#0066FF';
24
+ const SELECTION_STROKE = 1.5;
25
+ // Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
26
+ const strokeBounds = (points) => {
27
+ if (points.length < 2)
28
+ return null;
29
+ let minX = points[0];
30
+ let maxX = points[0];
31
+ let minY = points[1];
32
+ let maxY = points[1];
33
+ for (let i = 2; i < points.length; i += 2) {
34
+ const x = points[i];
35
+ const y = points[i + 1];
36
+ if (x < minX)
37
+ minX = x;
38
+ if (x > maxX)
39
+ maxX = x;
40
+ if (y < minY)
41
+ minY = y;
42
+ if (y > maxY)
43
+ maxY = y;
44
+ }
45
+ return { minX, minY, maxX, maxY };
46
+ };
47
+ // Bounds of a Vec2[] (shape geometry), or null if empty.
48
+ const pointsBounds = (pts) => {
49
+ if (pts.length === 0)
50
+ return null;
51
+ let { x: minX, y: minY } = pts[0];
52
+ let { x: maxX, y: maxY } = pts[0];
53
+ for (let i = 1; i < pts.length; i++) {
54
+ const p = pts[i];
55
+ if (p.x < minX)
56
+ minX = p.x;
57
+ if (p.x > maxX)
58
+ maxX = p.x;
59
+ if (p.y < minY)
60
+ minY = p.y;
61
+ if (p.y > maxY)
62
+ maxY = p.y;
63
+ }
64
+ return { minX, minY, maxX, maxY };
65
+ };
66
+ // Wraps a single element in the live drag transform while it's being dragged
67
+ // (native, UI-thread). When not dragging it renders the element untouched, so
68
+ // the element's React.memo still bails out on unrelated re-renders.
69
+ const DraggableElement = ({ isDragging, transform, children, }) => isDragging && transform ? (_jsx(Group, { transform: transform, children: children })) : (_jsx(_Fragment, { children: children }));
70
+ // Bounding-box outline around a selected stroke/shape. Padded in doc space and
71
+ // wrapped in DraggableElement so it tracks the element during a group drag.
72
+ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElement, { isDragging: isDragging, transform: transform, children: _jsx(Rect, { x: bounds.minX - SELECTION_PAD, y: bounds.minY - SELECTION_PAD, width: bounds.maxX - bounds.minX + SELECTION_PAD * 2, height: bounds.maxY - bounds.minY + SELECTION_PAD * 2, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }) }));
73
+ // Platform-agnostic Skia subtree shared by web and native Inners.
74
+ //
75
+ // Placed measurements are NOT drawn here — they render as a non-interactive
76
+ // RN/DOM overlay of real tile components (`renderMeasurementStamp`) positioned
77
+ // over this canvas by the Inner. This subtree owns only the vector content
78
+ // (background, strokes, shapes) and the live tool previews.
79
+ //
80
+ // Call this as a FUNCTION (`AnnotationCanvasSkia({ ... })`), not as a JSX
81
+ // component (`<AnnotationCanvasSkia ... />`). Used as a component, it adds
82
+ // a React component boundary between the parent and Skia's `<Canvas>` that
83
+ // breaks Skia's reconciler when the page first calls `useFont` — the JS
84
+ // thread hangs in `MakeFreeTypeFaceFromData`. Symptoms only appeared on
85
+ // Vite's dev server with a symlinked boldr-utils + React Refresh, but
86
+ // since the function-call pattern works identically on native we use it
87
+ // in both Inners for consistency. Don't add hooks here; this is a plain
88
+ // JSX-returning helper, not a component.
89
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, penDrawingStroke, livePreview, 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
+ }
105
+ if (placementOf(placed) !== 'line' || !placed.line)
106
+ return null;
107
+ const isEndpointDrag = placed.id === endpointDragId;
108
+ const isSelected = placed.id === selectedId;
109
+ const p1 = isEndpointDrag && liveLineP1 ? liveLineP1 : placed.line.a;
110
+ const p2 = isEndpointDrag && liveLineP2 ? liveLineP2 : placed.line.b;
111
+ const lineColor = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
112
+ const lineWidth = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
113
+ // Solid filled-triangle arrowhead at endpoint b (the line's "end").
114
+ // Built from the static endpoints, so it's skipped during a live
115
+ // endpoint drag (it reappears on release) rather than lagging the
116
+ // moving finger.
117
+ const arrowPath = (() => {
118
+ if (placed.lineCap !== 'arrow' || isEndpointDrag || !placed.line) {
119
+ return null;
120
+ }
121
+ const [apex, baseL, baseR] = arrowheadTriangle(placed.line.b, placed.line.a, lineWidth);
122
+ const p = Skia.Path.Make();
123
+ p.moveTo(apex.x, apex.y);
124
+ p.lineTo(baseL.x, baseL.y);
125
+ p.lineTo(baseR.x, baseR.y);
126
+ p.close();
127
+ return p;
128
+ })();
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 })] }))] }));
130
+ return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
131
+ }), (() => {
132
+ if (!selectedId)
133
+ return null;
134
+ const isDragging = selectedId === draggingId;
135
+ const stroke = effectiveCanvas.strokes.find((s) => s.id === selectedId);
136
+ if (stroke) {
137
+ const b = strokeBounds(stroke.points);
138
+ return b ? (_jsx(SelectionBox, { bounds: b, isDragging: isDragging, transform: dragTransform })) : null;
139
+ }
140
+ const shape = effectiveCanvas.shapes.find((s) => s.id === selectedId);
141
+ if (shape) {
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 }) }))] }));
156
+ }
157
+ return null;
158
+ })(), 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, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) })), customPreview] }) }));
@@ -0,0 +1,77 @@
1
+ import type { ReactNode, ComponentType } from 'react';
2
+ import type { AnnotationCanvasState, AnnotationDocumentPatch, AnnotationElement, AnnotationElementId, Selection, StrokeCap, Vec2 } from '../../types/annotation.js';
3
+ import type { MeasurementRef } from './measurementPicker.js';
4
+ import type { RectCorner } from './measurementGeometry.js';
5
+ import type { ResizeGeometry } from './textGeometry.js';
6
+ import type { ViewportApi } from './viewport.js';
7
+ export type RequestTextInput = (options?: {
8
+ initialText?: string;
9
+ }) => Promise<string | null>;
10
+ export interface CanvasPointerEvent {
11
+ pointerId: number;
12
+ world: Vec2;
13
+ screen: Vec2;
14
+ pressure?: number;
15
+ shiftKey?: boolean;
16
+ altKey?: boolean;
17
+ metaKey?: boolean;
18
+ ctrlKey?: boolean;
19
+ }
20
+ export interface ToolContext {
21
+ document: AnnotationCanvasState;
22
+ selection: Selection | null;
23
+ viewport: ViewportApi;
24
+ preview(patch: AnnotationDocumentPatch): void;
25
+ commit(patch: AnnotationDocumentPatch): void;
26
+ setSelection(selection: Selection | null): void;
27
+ requestPickMeasurement(): Promise<MeasurementRef | null>;
28
+ requestTextInput(options?: {
29
+ initialText?: string;
30
+ }): Promise<string | null>;
31
+ applyPan(deltaScreen: Vec2): void;
32
+ applyZoom(focalScreen: Vec2, nextZoom: number): void;
33
+ }
34
+ export type ToolState = unknown;
35
+ export interface FreehandConfig {
36
+ variant: 'pen' | 'marker' | 'highlighter';
37
+ color: string;
38
+ width: number;
39
+ cap?: StrokeCap;
40
+ dash?: boolean;
41
+ minSampleDistance: number;
42
+ }
43
+ export type DragElementKind = 'stroke' | 'shape' | 'measurement';
44
+ export interface DragSelectionConfig {
45
+ hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number): {
46
+ id: AnnotationElementId;
47
+ kind: DragElementKind;
48
+ } | null;
49
+ buildTranslatePatch(doc: AnnotationCanvasState, id: AnnotationElementId, kind: DragElementKind, delta: Vec2): AnnotationDocumentPatch | null;
50
+ classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'slide' | 'move';
51
+ buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
52
+ hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
53
+ buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
54
+ hitTestRectCorner?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): {
55
+ corner: RectCorner;
56
+ moving: Vec2;
57
+ fixed: Vec2;
58
+ } | null;
59
+ buildRectCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
60
+ hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
61
+ buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
62
+ }
63
+ export interface Tool {
64
+ id: string;
65
+ label: string;
66
+ icon?: ComponentType;
67
+ cursor?: string;
68
+ freehand?: FreehandConfig;
69
+ panViewport?: boolean;
70
+ dragSelection?: DragSelectionConfig;
71
+ onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
72
+ onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
73
+ onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
74
+ onCancel?(state: ToolState, ctx: ToolContext): void;
75
+ renderPreview?(state: ToolState, ctx: ToolContext): ReactNode;
76
+ hitTest?(element: AnnotationElement, worldPoint: Vec2): boolean;
77
+ }
@@ -1,4 +1,4 @@
1
- import type { AnnotationBackgroundImage, BackgroundFit } from '../../types/annotation.js';
1
+ import type { AnnotationBackgroundImage, BackgroundFit } from '../../../types/annotation.js';
2
2
  export interface BackgroundImageElementProps {
3
3
  image: AnnotationBackgroundImage;
4
4
  docWidth: number;
@@ -6,4 +6,4 @@ export interface BackgroundImageElementProps {
6
6
  fit?: BackgroundFit;
7
7
  resolveUrl?: (path: string) => Promise<string>;
8
8
  }
9
- export declare const BackgroundImageElement: ({ image, docWidth, docHeight, fit, resolveUrl, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null;
9
+ export declare const BackgroundImageElement: import("react").MemoExoticComponent<({ image, docWidth, docHeight, fit, resolveUrl, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null>;
@@ -1,8 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Image, useImage } from '@shopify/react-native-skia';
3
- import { useEffect, useState } from 'react';
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'
@@ -12,12 +15,19 @@ const computeFit = (imgW, imgH, docW, docH, fit) => {
12
15
  const h = imgH * scale;
13
16
  return { x: (docW - w) / 2, y: (docH - h) / 2, width: w, height: h };
14
17
  };
15
- export const BackgroundImageElement = ({ image, docWidth, docHeight, fit = 'contain', resolveUrl, }) => {
18
+ export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, }) => {
16
19
  const [url, setUrl] = useState(image.downloadUrl);
20
+ // Hold resolveUrl in a ref so re-resolution is keyed only on the image
21
+ // identity, not the function's. Consumers commonly pass an inline closure
22
+ // (a new reference each render); depending on it directly would re-run a
23
+ // network URL resolve on every render.
24
+ const resolveUrlRef = useRef(resolveUrl);
25
+ resolveUrlRef.current = resolveUrl;
17
26
  useEffect(() => {
18
27
  let cancelled = false;
19
- if (resolveUrl) {
20
- resolveUrl(image.storagePath).then((next) => {
28
+ const resolve = resolveUrlRef.current;
29
+ if (resolve) {
30
+ resolve(image.storagePath).then((next) => {
21
31
  if (!cancelled)
22
32
  setUrl(next);
23
33
  });
@@ -28,10 +38,10 @@ export const BackgroundImageElement = ({ image, docWidth, docHeight, fit = 'cont
28
38
  return () => {
29
39
  cancelled = true;
30
40
  };
31
- }, [image.downloadUrl, image.storagePath, resolveUrl]);
41
+ }, [image.downloadUrl, image.storagePath]);
32
42
  const skImage = useImage(url);
33
43
  if (!skImage)
34
44
  return null;
35
45
  const dims = computeFit(image.widthPx, image.heightPx, docWidth, docHeight, fit);
36
46
  return (_jsx(Image, { image: skImage, x: dims.x, y: dims.y, width: dims.width, height: dims.height, fit: "fill" }));
37
- };
47
+ });
@@ -0,0 +1,7 @@
1
+ import { type SkFont } from '@shopify/react-native-skia';
2
+ import type { AnnotationShape } from '../../../types/annotation.js';
3
+ export interface ShapeElementProps {
4
+ shape: AnnotationShape;
5
+ font?: SkFont | null;
6
+ }
7
+ export declare const ShapeElement: import("react").MemoExoticComponent<({ shape, font }: ShapeElementProps) => import("react/jsx-runtime").JSX.Element | null>;
@@ -1,6 +1,12 @@
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';
3
- import { useMemo } from 'react';
2
+ import { Circle, DashPathEffect, Group, Line, Path, Rect, Skia, Text, } from '@shopify/react-native-skia';
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;
4
10
  const polygonPath = (points) => {
5
11
  const path = Skia.Path.Make();
6
12
  if (points.length === 0)
@@ -12,7 +18,9 @@ const polygonPath = (points) => {
12
18
  path.close();
13
19
  return path;
14
20
  };
15
- export const ShapeElement = ({ shape, font }) => {
21
+ // Memoized see StrokeElement. Unchanged shapes keep their identity across
22
+ // applyPatch, so only the edited shape (and the shared font) re-render.
23
+ export const ShapeElement = memo(({ shape, font }) => {
16
24
  const { kind, geometry, style, text } = shape;
17
25
  const stroke = style.stroke ?? '#000000';
18
26
  const fill = style.fill;
@@ -51,12 +59,32 @@ export const ShapeElement = ({ shape, font }) => {
51
59
  return (_jsxs(_Fragment, { children: [fill && _jsx(Path, { path: polyPath, color: fill }), _jsx(Path, { path: polyPath, color: stroke, style: "stroke", strokeWidth: strokeWidth })] }));
52
60
  }
53
61
  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.
54
68
  const [origin] = geometry.points;
55
69
  if (!origin || !text)
56
70
  return null;
57
71
  if (!font)
58
72
  return null;
59
- return (_jsx(Text, { x: origin.x, y: origin.y, text: text, font: font, color: stroke }));
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))) }));
60
88
  }
61
89
  }
62
- };
90
+ });
@@ -0,0 +1,7 @@
1
+ import { type SkPath } from '@shopify/react-native-skia';
2
+ import type { AnnotationStroke } from '../../../types/annotation.js';
3
+ export declare const pointsToSkPath: (points: number[]) => SkPath;
4
+ export interface StrokeElementProps {
5
+ stroke: AnnotationStroke;
6
+ }
7
+ export declare const StrokeElement: import("react").MemoExoticComponent<({ stroke }: StrokeElementProps) => import("react/jsx-runtime").JSX.Element>;
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DashPathEffect, Path, Skia, } from '@shopify/react-native-skia';
3
+ import { memo, useMemo } from 'react';
4
+ import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from '../strokeGeometry.js';
5
+ export const pointsToSkPath = (points) => {
6
+ const path = Skia.Path.Make();
7
+ if (points.length < 2)
8
+ return path;
9
+ path.moveTo(points[0], points[1]);
10
+ for (let i = 2; i < points.length; i += 2) {
11
+ path.lineTo(points[i], points[i + 1]);
12
+ }
13
+ return path;
14
+ };
15
+ // A solid filled-triangle arrowhead at the stroke's end (vertex at the last
16
+ // point, base corners back along the final segment), or null when the cap isn't
17
+ // 'arrow'. Rendered as a separate filled <Path> on top of the stroked line.
18
+ const arrowheadPath = (stroke) => {
19
+ const n = stroke.points.length;
20
+ if (stroke.cap !== 'arrow' || n < 4)
21
+ return null;
22
+ const end = { x: stroke.points[n - 2], y: stroke.points[n - 1] };
23
+ const from = { x: stroke.points[n - 4], y: stroke.points[n - 3] };
24
+ const [apex, baseL, baseR] = arrowheadTriangle(end, from, stroke.width);
25
+ const path = Skia.Path.Make();
26
+ path.moveTo(apex.x, apex.y);
27
+ path.lineTo(baseL.x, baseL.y);
28
+ path.lineTo(baseR.x, baseR.y);
29
+ path.close();
30
+ return path;
31
+ };
32
+ // Memoized: the canvas re-renders the whole element list on every commit,
33
+ // preview, and selection change, but applyPatch preserves the identity of
34
+ // unchanged strokes — so memo lets all but the changed stroke bail out.
35
+ export const StrokeElement = memo(({ stroke }) => {
36
+ const path = useMemo(() => pointsToSkPath(stroke.points), [stroke.points]);
37
+ const arrow = useMemo(() => arrowheadPath(stroke), [stroke.points, stroke.cap, stroke.width]);
38
+ const opacity = stroke.tool === 'highlighter' ? 0.3 : 1;
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 }))] }));
45
+ });
@@ -0,0 +1,43 @@
1
+ import type { AnnotationPatchOp, MeasurementPlacement, PlacedMeasurementRef, Vec2 } from '../../types/annotation.js';
2
+ export declare const DEFAULT_LINE_POS = 0.5;
3
+ export declare const lerp: (a: Vec2, b: Vec2, t: number) => Vec2;
4
+ export declare const placementOf: (m: Pick<PlacedMeasurementRef, "placement">) => MeasurementPlacement;
5
+ export declare const linePosOf: (m: Pick<PlacedMeasurementRef, "linePos">) => number;
6
+ export declare const recomputeAnchor: (line: {
7
+ a: Vec2;
8
+ b: Vec2;
9
+ } | undefined, placement: MeasurementPlacement, linePos: number, fallbackAnchor: Vec2) => Vec2;
10
+ export declare const projectToLinePos: (line: {
11
+ a: Vec2;
12
+ b: Vec2;
13
+ }, world: Vec2) => number;
14
+ export declare const snapLinePos: (t: number, threshold?: number) => number;
15
+ export declare const lineLength: (line: {
16
+ a: Vec2;
17
+ b: Vec2;
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 interface RemoveMeasurementResult {
40
+ ops: AnnotationPatchOp[];
41
+ keepSelection: boolean;
42
+ }
43
+ export declare const buildRemoveMeasurementOps: (placed: PlacedMeasurementRef) => RemoveMeasurementResult;
@@ -0,0 +1,111 @@
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.
4
+ //
5
+ // NOTE: the native overlay's `useAnimatedStyle` and the live-line derived values
6
+ // run on the UI thread and CANNOT call these functions across the worklet
7
+ // boundary, so they inline the same math. Those inlined copies are the "worklet
8
+ // twins" of these helpers — if you change the math here, change them too (they
9
+ // are commented as such in AnnotationCanvasInner.native.tsx).
10
+ // Default position of the tile along its line when `linePos` is absent: center.
11
+ export const DEFAULT_LINE_POS = 0.5;
12
+ export const lerp = (a, b, t) => ({
13
+ x: a.x + (b.x - a.x) * t,
14
+ y: a.y + (b.y - a.y) * t,
15
+ });
16
+ // Back-compat accessors: a stamp persisted before this feature has neither
17
+ // field, and reads as a bare 'none' stamp centered on its line (if any).
18
+ export const placementOf = (m) => m.placement ?? 'none';
19
+ export const linePosOf = (m) => m.linePos ?? DEFAULT_LINE_POS;
20
+ // The tile anchor implied by a line + placement. For 'line', the tile sits at
21
+ // lerp(a, b, linePos); for everything else the stored anchor stands. Callers
22
+ // must write the result into `anchor` in the same patch that changes
23
+ // `line`/`placement`/`linePos` so the two never drift.
24
+ export const recomputeAnchor = (line, placement, linePos, fallbackAnchor) => {
25
+ if (placement === 'line' && line)
26
+ return lerp(line.a, line.b, linePos);
27
+ return fallbackAnchor;
28
+ };
29
+ // Project a world point onto segment a→b and return the clamped parameter
30
+ // t ∈ [0, 1] (0 = a, 1 = b). A zero-length line returns 0.
31
+ export const projectToLinePos = (line, world) => {
32
+ const abx = line.b.x - line.a.x;
33
+ const aby = line.b.y - line.a.y;
34
+ const lenSq = abx * abx + aby * aby;
35
+ if (lenSq === 0)
36
+ return 0;
37
+ const t = ((world.x - line.a.x) * abx + (world.y - line.a.y) * aby) / lenSq;
38
+ return t < 0 ? 0 : t > 1 ? 1 : t;
39
+ };
40
+ // Snap t to center (0.5) when within `threshold` of it. `threshold` is in
41
+ // t-space; for a consistent on-screen detent, callers convert a screen-px
42
+ // radius to t via (radiusPx / zoom) / lineLengthDoc.
43
+ export const snapLinePos = (t, threshold = 0.06) => Math.abs(t - 0.5) <= threshold ? 0.5 : t;
44
+ // Euclidean length of a line in doc space (used to convert a screen-px snap
45
+ // radius into a t-space threshold).
46
+ export const lineLength = (line) => {
47
+ const dx = line.b.x - line.a.x;
48
+ const dy = line.b.y - line.a.y;
49
+ return Math.sqrt(dx * dx + dy * dy);
50
+ };
51
+ export const normalizeRect = (rect) => ({
52
+ minX: Math.min(rect.a.x, rect.b.x),
53
+ minY: Math.min(rect.a.y, rect.b.y),
54
+ maxX: Math.max(rect.a.x, rect.b.x),
55
+ maxY: Math.max(rect.a.y, rect.b.y),
56
+ });
57
+ // The tile anchor for a rectangle annotation: always the center. Any op that
58
+ // changes `rect` must write this into `anchor` in the same patch.
59
+ export const rectCenter = (rect) => ({
60
+ x: (rect.a.x + rect.b.x) / 2,
61
+ y: (rect.a.y + rect.b.y) / 2,
62
+ });
63
+ export const rectCornerPoint = (rect, corner) => {
64
+ const n = normalizeRect(rect);
65
+ switch (corner) {
66
+ case 'tl':
67
+ return { x: n.minX, y: n.minY };
68
+ case 'tr':
69
+ return { x: n.maxX, y: n.minY };
70
+ case 'bl':
71
+ return { x: n.minX, y: n.maxY };
72
+ case 'br':
73
+ return { x: n.maxX, y: n.maxY };
74
+ }
75
+ };
76
+ export const oppositeRectCorner = (corner) => {
77
+ switch (corner) {
78
+ case 'tl':
79
+ return 'br';
80
+ case 'tr':
81
+ return 'bl';
82
+ case 'bl':
83
+ return 'tr';
84
+ case 'br':
85
+ return 'tl';
86
+ }
87
+ };
88
+ export const buildRemoveMeasurementOps = (placed) => {
89
+ if (placementOf(placed) !== 'none' && placed.measurementId) {
90
+ return {
91
+ ops: [
92
+ {
93
+ op: 'updateMeasurement',
94
+ id: placed.id,
95
+ patch: {
96
+ measurementId: undefined,
97
+ measurementPath: undefined,
98
+ groupId: undefined,
99
+ labelOverride: undefined,
100
+ unitOverride: undefined,
101
+ },
102
+ },
103
+ ],
104
+ keepSelection: true,
105
+ };
106
+ }
107
+ return {
108
+ ops: [{ op: 'removeMeasurement', id: placed.id }],
109
+ keepSelection: false,
110
+ };
111
+ };
@@ -1,4 +1,4 @@
1
- import type { Units } from '../types/firestore.js';
1
+ import type { Units } from '../../types/firestore.js';
2
2
  export interface MeasurementRef {
3
3
  measurementId: string;
4
4
  measurementPath: string;