@reekon-tools/boldr-utils 1.6.23 → 1.6.25

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.
@@ -1,4 +1,4 @@
1
- import { type SkFont, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
1
+ import { type SkFont, type SkImage, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
2
2
  import type { ReactNode } from 'react';
3
3
  import type { AnnotationCanvasState, AnnotationStroke, StrokeCap } from '../../types/annotation.js';
4
4
  type AnimatedPoint = {
@@ -21,6 +21,7 @@ export interface AnnotationCanvasSkiaProps {
21
21
  value: Transforms3d;
22
22
  };
23
23
  resolveImageUrl?: (storagePath: string) => Promise<string>;
24
+ backgroundSkImage?: SkImage | null;
24
25
  valueFont: SkFont | null;
25
26
  textFontMgr?: SkTypefaceFontProvider | null;
26
27
  penDrawingStroke: AnnotationStroke | null;
@@ -69,6 +70,16 @@ export interface AnnotationCanvasSkiaProps {
69
70
  width: AnimatedNumber;
70
71
  height: AnimatedNumber;
71
72
  };
73
+ shapeResizeId?: string | null;
74
+ shapeResizePath?: SkPath | {
75
+ value: SkPath;
76
+ };
77
+ shapeResizeBox?: {
78
+ x: AnimatedNumber;
79
+ y: AnimatedNumber;
80
+ width: AnimatedNumber;
81
+ height: AnimatedNumber;
82
+ };
72
83
  handleRadius?: number | {
73
84
  value: number;
74
85
  };
@@ -76,6 +87,7 @@ export interface AnnotationCanvasSkiaProps {
76
87
  value: number;
77
88
  };
78
89
  customPreview?: ReactNode;
90
+ wrapContent?: (content: ReactNode) => ReactNode;
79
91
  }
80
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, handleRingWidth, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
92
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
81
93
  export {};
@@ -3,6 +3,7 @@ import { Canvas, Circle, DashPathEffect, Group, Line, Path, Rect, Skia, } from '
3
3
  import { normalizeRect, placementOf } from './measurementGeometry.js';
4
4
  import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from './strokeGeometry.js';
5
5
  import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeometry.js';
6
+ import { visualShapeBounds } from './shapeGeometry.js';
6
7
  import { BackgroundImageElement } from './elements/BackgroundImageElement.js';
7
8
  import { ShapeElement } from './elements/ShapeElement.js';
8
9
  import { StrokeElement } from './elements/StrokeElement.js';
@@ -50,25 +51,6 @@ const strokeBounds = (points) => {
50
51
  }
51
52
  return { minX, minY, maxX, maxY };
52
53
  };
53
- // Bounds of a Vec2[] (shape geometry), or null if empty.
54
- const pointsBounds = (pts) => {
55
- if (pts.length === 0)
56
- return null;
57
- let { x: minX, y: minY } = pts[0];
58
- let { x: maxX, y: maxY } = pts[0];
59
- for (let i = 1; i < pts.length; i++) {
60
- const p = pts[i];
61
- if (p.x < minX)
62
- minX = p.x;
63
- if (p.x > maxX)
64
- maxX = p.x;
65
- if (p.y < minY)
66
- minY = p.y;
67
- if (p.y > maxY)
68
- maxY = p.y;
69
- }
70
- return { minX, minY, maxX, maxY };
71
- };
72
54
  // Wraps a single element in the live drag transform while it's being dragged
73
55
  // (native, UI-thread). When not dragging it renders the element untouched, so
74
56
  // the element's React.memo still bails out on unrelated re-renders.
@@ -92,7 +74,8 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
92
74
  // since the function-call pattern works identically on native we use it
93
75
  // in both Inners for consistency. Don't add hooks here; this is a plain
94
76
  // JSX-returning helper, not a component.
95
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, handleRingWidth, 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) => {
77
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, }) => {
78
+ const world = (_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, skImage: backgroundSkImage })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
96
79
  // Line/arrow shapes support endpoint editing. When selected they show
97
80
  // grab handles at both ends; during an endpoint drag the line renders
98
81
  // from the live endpoints (one following the finger) — the shape twin
@@ -114,22 +97,33 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
114
97
  // but idle: reuse ShapeElement for the body (caps/arrowhead/dash)
115
98
  // and overlay the handles, both wrapped so a group move tracks.
116
99
  if (isEndpointDrag) {
117
- const arrowPath = (() => {
118
- if (!hasArrow)
119
- return null;
120
- const [apex, baseL, baseR] = arrowheadTriangle(b, a, strokeWidth);
100
+ const headPath = (tip, from) => {
101
+ const [apex, baseL, baseR] = arrowheadTriangle(tip, from, strokeWidth);
121
102
  const p = Skia.Path.Make();
122
103
  p.moveTo(apex.x, apex.y);
123
104
  p.lineTo(baseL.x, baseL.y);
124
105
  p.lineTo(baseR.x, baseR.y);
125
106
  p.close();
126
107
  return p;
127
- })();
128
- return (_jsxs(Group, { children: [_jsx(Line, { p1: liveLineP1 ?? a, p2: liveLineP2 ?? b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(shape.style.cap), children: shape.style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) }), handles, arrowPath && (_jsx(Path, { path: arrowPath, color: stroke, style: "fill" }))] }, shape.id));
108
+ };
109
+ const endArrow = hasArrow ? headPath(b, a) : null;
110
+ const startArrow = shape.style.startCap === 'arrow' ? headPath(a, b) : null;
111
+ return (_jsxs(Group, { children: [_jsx(Line, { p1: liveLineP1 ?? a, p2: liveLineP2 ?? b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(shape.style.cap), children: shape.style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) }), handles, startArrow && (_jsx(Path, { path: startArrow, color: stroke, style: "fill" })), endArrow && (_jsx(Path, { path: endArrow, color: stroke, style: "fill" }))] }, shape.id));
129
112
  }
130
113
  return (_jsxs(DraggableElement, { isDragging: shape.id === draggingId, transform: dragTransform, children: [_jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }), handles] }, shape.id));
131
114
  }
132
115
  }
116
+ // Non-line shapes track the live transform of whichever resize is in
117
+ // flight: text scales uniformly via a transform (`resizingId`); a
118
+ // group move translates (`draggingId`). A rect/ellipse/polygon corner
119
+ // resize instead re-renders the SCALED OUTLINE live (`shapeResizePath`)
120
+ // at a constant stroke — a non-uniform scale transform would warp the
121
+ // stroke width (top/bottom edges thicken with scaleY).
122
+ if (shape.id === shapeResizeId && shapeResizePath) {
123
+ const stroke = shape.style.stroke ?? '#000000';
124
+ const strokeWidth = shape.style.strokeWidth ?? 2;
125
+ return (_jsxs(Group, { children: [shape.style.fill && (_jsx(Path, { path: shapeResizePath, color: shape.style.fill, style: "fill" })), _jsx(Path, { path: shapeResizePath, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeJoin: "round", children: shape.style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) })] }, shape.id));
126
+ }
133
127
  return (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }) }, shape.id));
134
128
  }), effectiveCanvas.placedMeasurements.map((placed) => {
135
129
  // Rectangle annotation: a stroked border whose center carries the
@@ -155,23 +149,26 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
155
149
  const p2 = isEndpointDrag && liveLineP2 ? liveLineP2 : placed.line.b;
156
150
  const lineColor = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
157
151
  const lineWidth = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
158
- // Solid filled-triangle arrowhead at endpoint b (the line's "end").
159
- // Built from the static endpoints, so it's skipped during a live
160
- // endpoint drag (it reappears on release) rather than lagging the
161
- // moving finger.
162
- const arrowPath = (() => {
163
- if (placed.lineCap !== 'arrow' || isEndpointDrag || !placed.line) {
164
- return null;
165
- }
166
- const [apex, baseL, baseR] = arrowheadTriangle(placed.line.b, placed.line.a, lineWidth);
152
+ // Solid filled-triangle arrowheads at endpoint b (the "end", lineCap)
153
+ // and/or endpoint a (the "start", lineStartCap). Built from the static
154
+ // endpoints, so they're skipped during a live endpoint drag (they
155
+ // reappear on release) rather than lagging the moving finger.
156
+ const headPath = (tip, from) => {
157
+ const [apex, baseL, baseR] = arrowheadTriangle(tip, from, lineWidth);
167
158
  const p = Skia.Path.Make();
168
159
  p.moveTo(apex.x, apex.y);
169
160
  p.lineTo(baseL.x, baseL.y);
170
161
  p.lineTo(baseR.x, baseR.y);
171
162
  p.close();
172
163
  return p;
173
- })();
174
- 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(Handle, { c: p1, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: p2, r: handleRadius, ringWidth: handleRingWidth })] }))] }));
164
+ };
165
+ const endArrow = placed.line && !isEndpointDrag && placed.lineCap === 'arrow'
166
+ ? headPath(placed.line.b, placed.line.a)
167
+ : null;
168
+ const startArrow = placed.line && !isEndpointDrag && placed.lineStartCap === 'arrow'
169
+ ? headPath(placed.line.a, placed.line.b)
170
+ : null;
171
+ 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) })) }), startArrow && (_jsx(Path, { path: startArrow, color: lineColor, style: "fill" })), endArrow && (_jsx(Path, { path: endArrow, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Handle, { c: p1, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: p2, r: handleRadius, ringWidth: handleRingWidth })] }))] }));
175
172
  return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
176
173
  }), (() => {
177
174
  if (!selectedId)
@@ -188,20 +185,36 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
188
185
  // above), not a bounding box — matching the measurement-line UX.
189
186
  if (shape.kind === 'line' || shape.kind === 'arrow')
190
187
  return null;
188
+ // During a corner resize the box tracks the live scaled bounds
189
+ // (`shapeResizeBox`) at a constant stroke — a transform would warp it
190
+ // like the shape — and the corner handles hide (they'd warp into
191
+ // ellipses under a non-uniform scale; the rect annotation hides its
192
+ // handles mid-drag for the same reason).
193
+ if (shape.id === shapeResizeId && shapeResizeBox) {
194
+ return (_jsx(Rect, { x: shapeResizeBox.x, y: shapeResizeBox.y, width: shapeResizeBox.width, height: shapeResizeBox.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }));
195
+ }
191
196
  // Text shapes derive their box from the estimated text bounds (the
192
197
  // stored geometry is just the top-left anchor) and add a corner
193
198
  // resize handle; both track the live resize transform so the chrome
194
199
  // scales with the shape during a native UI-thread resize.
195
200
  const isText = shape.kind === 'text';
196
- const b = isText
197
- ? textShapeBounds(shape)
198
- : pointsBounds(shape.geometry.points);
201
+ const b = isText ? textShapeBounds(shape) : visualShapeBounds(shape);
199
202
  if (!b)
200
203
  return null;
201
204
  const isResizing = shape.id === resizingId;
202
205
  const liveTransform = isResizing ? resizeTransform : dragTransform;
206
+ const isLive = isDragging || isResizing;
203
207
  const resizeGeom = isText ? textResizeGeometry(shape) : null;
204
- 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(Handle, { c: resizeGeom.handle, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }) }))] }));
208
+ // Rect/ellipse/polygon shapes get four bounding-box corner handles
209
+ // (drawn at the un-padded visual-bounds corners so they line up with
210
+ // the hit-test) to scale length and width — the shape twin of the
211
+ // rectangle-annotation corner handles.
212
+ const cornerResizable = shape.kind === 'rect' ||
213
+ shape.kind === 'ellipse' ||
214
+ shape.kind === 'polygon';
215
+ return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isLive, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isLive, transform: liveTransform, children: _jsx(Handle, { c: resizeGeom.handle, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }) })), cornerResizable && handleRadius != null && (_jsxs(DraggableElement, { isDragging: isDragging, transform: dragTransform, children: [_jsx(Handle, { c: { x: b.minX, y: b.minY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.maxX, y: b.minY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.minX, y: b.maxY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.maxX, y: b.maxY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR })] }))] }));
205
216
  }
206
217
  return null;
207
- })(), 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] }) }));
218
+ })(), 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] }));
219
+ return (_jsx(Canvas, { style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
220
+ };
@@ -39,6 +39,7 @@ export interface FreehandConfig {
39
39
  color: string;
40
40
  width: number;
41
41
  cap?: StrokeCap;
42
+ startCap?: StrokeCap;
42
43
  dash?: boolean;
43
44
  minSampleDistance: number;
44
45
  }
@@ -47,6 +48,7 @@ export interface ShapeDrawConfig {
47
48
  color: string;
48
49
  width: number;
49
50
  cap?: StrokeCap;
51
+ startCap?: StrokeCap;
50
52
  dash?: boolean;
51
53
  }
52
54
  export type DragElementKind = 'stroke' | 'shape' | 'measurement';
@@ -68,6 +70,12 @@ export interface DragSelectionConfig {
68
70
  fixed: Vec2;
69
71
  } | null;
70
72
  buildRectCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
73
+ hitTestShapeCorner?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): {
74
+ corner: RectCorner;
75
+ moving: Vec2;
76
+ fixed: Vec2;
77
+ } | null;
78
+ buildShapeCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
71
79
  isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): boolean;
72
80
  hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
73
81
  buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
@@ -1,9 +1,12 @@
1
+ import { type SkImage } from '@shopify/react-native-skia';
1
2
  import type { AnnotationBackgroundImage, BackgroundFit } from '../../../types/annotation.js';
3
+ export declare const useBackgroundSkImage: (image: AnnotationBackgroundImage | null | undefined, resolveUrl?: (path: string) => Promise<string>) => SkImage | null;
2
4
  export interface BackgroundImageElementProps {
3
5
  image: AnnotationBackgroundImage;
4
6
  docWidth: number;
5
7
  docHeight: number;
6
8
  fit?: BackgroundFit;
7
9
  resolveUrl?: (path: string) => Promise<string>;
10
+ skImage?: SkImage | null;
8
11
  }
9
- export declare const BackgroundImageElement: import("react").MemoExoticComponent<({ image, docWidth, docHeight, fit, resolveUrl, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null>;
12
+ export declare const BackgroundImageElement: import("react").MemoExoticComponent<({ image, docWidth, docHeight, fit, resolveUrl, skImage: skImageProp, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null>;
@@ -1,22 +1,15 @@
1
1
  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
- const computeFit = (imgW, imgH, docW, docH, fit) => {
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') {
9
- return { x: 0, y: 0, width: docW, height: docH };
10
- }
11
- const scale = fit === 'cover'
12
- ? Math.max(docW / imgW, docH / imgH)
13
- : Math.min(docW / imgW, docH / imgH);
14
- const w = imgW * scale;
15
- const h = imgH * scale;
16
- return { x: (docW - w) / 2, y: (docH - h) / 2, width: w, height: h };
17
- };
18
- export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, }) => {
19
- const [url, setUrl] = useState(image.downloadUrl);
4
+ import { imageDocRect } from '../viewport.js';
5
+ // Resolve a background image's (possibly expired) storage URL and decode it to
6
+ // an SkImage. Exported so a consumer can hoist the decode ABOVE the canvas and
7
+ // share one SkImage across multiple canvases (e.g. the main canvas + a
8
+ // magnifier overlay) otherwise each canvas mounts its own loader and flashes
9
+ // a blank while it re-resolves + re-decodes. Returns null until loaded, or when
10
+ // `image` is null/undefined. Hooks run unconditionally regardless of `image`.
11
+ export const useBackgroundSkImage = (image, resolveUrl) => {
12
+ const [url, setUrl] = useState(image?.downloadUrl ?? '');
20
13
  // Hold resolveUrl in a ref so re-resolution is keyed only on the image
21
14
  // identity, not the function's. Consumers commonly pass an inline closure
22
15
  // (a new reference each render); depending on it directly would re-run a
@@ -24,6 +17,8 @@ export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit =
24
17
  const resolveUrlRef = useRef(resolveUrl);
25
18
  resolveUrlRef.current = resolveUrl;
26
19
  useEffect(() => {
20
+ if (!image)
21
+ return;
27
22
  let cancelled = false;
28
23
  const resolve = resolveUrlRef.current;
29
24
  if (resolve) {
@@ -38,10 +33,17 @@ export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit =
38
33
  return () => {
39
34
  cancelled = true;
40
35
  };
41
- }, [image.downloadUrl, image.storagePath]);
42
- const skImage = useImage(url);
36
+ }, [image?.downloadUrl, image?.storagePath]);
37
+ return useImage(url || null);
38
+ };
39
+ export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, skImage: skImageProp, }) => {
40
+ // Self-load only when no pre-decoded image is provided; the hook still runs
41
+ // unconditionally (with a null image, so it no-ops) to satisfy hook rules.
42
+ const provided = skImageProp !== undefined;
43
+ const loaded = useBackgroundSkImage(provided ? null : image, resolveUrl);
44
+ const skImage = provided ? skImageProp : loaded;
43
45
  if (!skImage)
44
46
  return null;
45
- const dims = computeFit(image.widthPx, image.heightPx, docWidth, docHeight, fit);
47
+ const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
46
48
  return (_jsx(Image, { image: skImage, x: dims.x, y: dims.y, width: dims.width, height: dims.height, fit: "fill" }));
47
49
  });
@@ -45,34 +45,54 @@ export const ShapeElement = memo(({ shape, font, textFontMgr }) => {
45
45
  // historical behavior).
46
46
  const closed = geometry.closed !== false;
47
47
  const polyPath = useMemo(() => (kind === 'polygon' ? polygonPath(geometry.points, closed) : null), [kind, geometry.points, closed]);
48
- // Solid filled-triangle arrowhead at the shape's end point. Drawn for the
49
- // legacy 'arrow' kind, for a 'line' with cap === 'arrow' (the arrow tool and
50
- // the end-cap editor both produce this), and for an OPEN polygon with cap
51
- // === 'arrow' (its last segment has a direction). Mirrors StrokeElement.
52
- const arrowHead = useMemo(() => {
53
- let from;
54
- let end;
55
- if ((kind === 'line' || kind === 'arrow') &&
56
- (kind === 'arrow' || style.cap === 'arrow')) {
57
- [from, end] = geometry.points;
48
+ // Solid filled-triangle arrowheads. The END head is drawn for the legacy
49
+ // 'arrow' kind, for a 'line' with cap === 'arrow' (the arrow tool and the
50
+ // end-cap editor both produce this), and for an OPEN polygon with cap ===
51
+ // 'arrow'. The START head is drawn for a 'line'/'arrow' or open polygon with
52
+ // startCap === 'arrow' (so a line can be headed at either end or both).
53
+ // Mirrors StrokeElement.
54
+ const arrowHeads = useMemo(() => {
55
+ // The two anchor segments: [endTip, endFrom] points the end head, and
56
+ // [startTip, startFrom] points the start head (pointing back out of the
57
+ // first point). For an open polygon those are the last/first segments.
58
+ let endTip;
59
+ let endFrom;
60
+ let startTip;
61
+ let startFrom;
62
+ if (kind === 'line' || kind === 'arrow') {
63
+ const [a, b] = geometry.points;
64
+ endTip = b;
65
+ endFrom = a;
66
+ startTip = a;
67
+ startFrom = b;
58
68
  }
59
- else if (kind === 'polygon' && !closed && style.cap === 'arrow') {
69
+ else if (kind === 'polygon' && !closed) {
60
70
  const n = geometry.points.length;
61
71
  if (n >= 2) {
62
- from = geometry.points[n - 2];
63
- end = geometry.points[n - 1];
72
+ endTip = geometry.points[n - 1];
73
+ endFrom = geometry.points[n - 2];
74
+ startTip = geometry.points[0];
75
+ startFrom = geometry.points[1];
64
76
  }
65
77
  }
66
- if (!from || !end || (from.x === end.x && from.y === end.y))
67
- return null;
68
- const [apex, baseL, baseR] = arrowheadTriangle(end, from, strokeWidth);
69
- const p = Skia.Path.Make();
70
- p.moveTo(apex.x, apex.y);
71
- p.lineTo(baseL.x, baseL.y);
72
- p.lineTo(baseR.x, baseR.y);
73
- p.close();
74
- return p;
75
- }, [kind, geometry.points, closed, style.cap, strokeWidth]);
78
+ const head = (tip, from) => {
79
+ if (!tip || !from || (tip.x === from.x && tip.y === from.y))
80
+ return null;
81
+ const [apex, baseL, baseR] = arrowheadTriangle(tip, from, strokeWidth);
82
+ const p = Skia.Path.Make();
83
+ p.moveTo(apex.x, apex.y);
84
+ p.lineTo(baseL.x, baseL.y);
85
+ p.lineTo(baseR.x, baseR.y);
86
+ p.close();
87
+ return p;
88
+ };
89
+ const wantEnd = kind === 'arrow' || style.cap === 'arrow';
90
+ const wantStart = style.startCap === 'arrow';
91
+ return {
92
+ end: wantEnd ? head(endTip, endFrom) : null,
93
+ start: wantStart ? head(startTip, startFrom) : null,
94
+ };
95
+ }, [kind, geometry.points, closed, style.cap, style.startCap, strokeWidth]);
76
96
  // Text shapes render through the Skia Paragraph API so decorations (solid
77
97
  // underline / wavy squiggle / highlight band) paint natively. The loaded font
78
98
  // is registered into a provider so the Paragraph draws with the same typeface
@@ -171,12 +191,12 @@ export const ShapeElement = memo(({ shape, font, textFontMgr }) => {
171
191
  const [a, b] = geometry.points;
172
192
  if (!a || !b)
173
193
  return null;
174
- 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" })] }));
194
+ return (_jsxs(_Fragment, { children: [_jsx(Line, { p1: a, p2: b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(style.cap), children: dashEffect }), arrowHeads.start && (_jsx(Path, { path: arrowHeads.start, color: stroke, style: "fill" })), arrowHeads.end && (_jsx(Path, { path: arrowHeads.end, color: stroke, style: "fill" }))] }));
175
195
  }
176
196
  case 'polygon': {
177
197
  if (!polyPath)
178
198
  return null;
179
- 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" })] }));
199
+ 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 }), arrowHeads.start && (_jsx(Path, { path: arrowHeads.start, color: stroke, style: "fill" })), arrowHeads.end && (_jsx(Path, { path: arrowHeads.end, color: stroke, style: "fill" }))] }));
180
200
  }
181
201
  case 'text': {
182
202
  // `origin` is the TOP-LEFT of the text block; textGeometry derives the
@@ -12,16 +12,13 @@ export const pointsToSkPath = (points) => {
12
12
  }
13
13
  return path;
14
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)
15
+ // A solid filled-triangle arrowhead at `tip`, with its base back toward `from`,
16
+ // rendered as a separate filled <Path> on top of the stroked line. Null when
17
+ // the two points coincide (e.g. a tap-dot), which has no direction to point.
18
+ const headPath = (tip, from, width) => {
19
+ if (tip.x === from.x && tip.y === from.y)
21
20
  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);
21
+ const [apex, baseL, baseR] = arrowheadTriangle(tip, from, width);
25
22
  const path = Skia.Path.Make();
26
23
  path.moveTo(apex.x, apex.y);
27
24
  path.lineTo(baseL.x, baseL.y);
@@ -29,17 +26,35 @@ const arrowheadPath = (stroke) => {
29
26
  path.close();
30
27
  return path;
31
28
  };
29
+ // Arrowheads at the stroke's end (cap) and/or start (startCap). Each is null
30
+ // unless the corresponding cap is 'arrow'. The start head points back out of
31
+ // the first point (base toward the second).
32
+ const arrowheadPaths = (stroke) => {
33
+ const n = stroke.points.length;
34
+ if (n < 4)
35
+ return { start: null, end: null };
36
+ const first = { x: stroke.points[0], y: stroke.points[1] };
37
+ const second = { x: stroke.points[2], y: stroke.points[3] };
38
+ const last = { x: stroke.points[n - 2], y: stroke.points[n - 1] };
39
+ const penult = { x: stroke.points[n - 4], y: stroke.points[n - 3] };
40
+ return {
41
+ end: stroke.cap === 'arrow' ? headPath(last, penult, stroke.width) : null,
42
+ start: stroke.startCap === 'arrow'
43
+ ? headPath(first, second, stroke.width)
44
+ : null,
45
+ };
46
+ };
32
47
  // Memoized: the canvas re-renders the whole element list on every commit,
33
48
  // preview, and selection change, but applyPatch preserves the identity of
34
49
  // unchanged strokes — so memo lets all but the changed stroke bail out.
35
50
  export const StrokeElement = memo(({ stroke }) => {
36
51
  const path = useMemo(() => pointsToSkPath(stroke.points), [stroke.points]);
37
- const arrow = useMemo(() => arrowheadPath(stroke), [stroke.points, stroke.cap, stroke.width]);
52
+ const arrows = useMemo(() => arrowheadPaths(stroke), [stroke.points, stroke.cap, stroke.startCap, stroke.width]);
38
53
  const opacity = stroke.tool === 'highlighter' ? 0.3 : 1;
39
54
  // A tap-dot is a zero-length two-point path; a dash effect would erase it
40
55
  // (the contour has no length to dash), so dots always render solid.
41
56
  const isDot = stroke.points.length === 4 &&
42
57
  stroke.points[0] === stroke.points[2] &&
43
58
  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 }))] }));
59
+ 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) })) }), arrows.start && (_jsx(Path, { path: arrows.start, color: stroke.color, style: "fill", opacity: opacity })), arrows.end && (_jsx(Path, { path: arrows.end, color: stroke.color, style: "fill", opacity: opacity }))] }));
45
60
  });
@@ -3,3 +3,10 @@ export type ShapeToolKind = 'line' | 'rect' | 'triangle' | 'ellipse';
3
3
  export declare const annotationKindFor: (kind: ShapeToolKind) => AnnotationShapeKind;
4
4
  export declare const shapePointsFromDrag: (kind: ShapeToolKind, a: Vec2, b: Vec2) => Vec2[];
5
5
  export declare const hitShapeOutline: (shape: AnnotationShape, p: Vec2, tol: number) => boolean;
6
+ export interface ShapeBounds {
7
+ minX: number;
8
+ minY: number;
9
+ maxX: number;
10
+ maxY: number;
11
+ }
12
+ export declare const visualShapeBounds: (shape: AnnotationShape) => ShapeBounds | null;
@@ -114,3 +114,39 @@ export const hitShapeOutline = (shape, p, tol) => {
114
114
  return false;
115
115
  }
116
116
  };
117
+ // Axis-aligned bounds of a shape AS RENDERED — the source of truth for the
118
+ // selection box, its corner-resize handles, and the corner hit-test, so all
119
+ // three agree on where the shape's edges are. For most kinds this is just the
120
+ // bounding box of the geometry points, but an ellipse renders as a CIRCLE
121
+ // (radius = half its larger extent, centered on the drag midpoint), so its
122
+ // visual bounds are the square that circle inscribes — otherwise the selection
123
+ // box hugs the raw (often non-square) drag rect instead of the visible circle.
124
+ // Returns null for text and degenerate (< 2 point) geometry; text shapes box
125
+ // from textGeometry's estimated bounds and lines draw handles on their
126
+ // endpoints, so neither routes through here.
127
+ export const visualShapeBounds = (shape) => {
128
+ const pts = shape.geometry.points;
129
+ if (pts.length < 2)
130
+ return null;
131
+ let minX = Infinity;
132
+ let minY = Infinity;
133
+ let maxX = -Infinity;
134
+ let maxY = -Infinity;
135
+ for (const p of pts) {
136
+ if (p.x < minX)
137
+ minX = p.x;
138
+ if (p.y < minY)
139
+ minY = p.y;
140
+ if (p.x > maxX)
141
+ maxX = p.x;
142
+ if (p.y > maxY)
143
+ maxY = p.y;
144
+ }
145
+ if (shape.kind === 'ellipse') {
146
+ const cx = (minX + maxX) / 2;
147
+ const cy = (minY + maxY) / 2;
148
+ const r = Math.max(maxX - minX, maxY - minY) / 2;
149
+ return { minX: cx - r, minY: cy - r, maxX: cx + r, maxY: cy + r };
150
+ }
151
+ return { minX, minY, maxX, maxY };
152
+ };
@@ -4,6 +4,7 @@ export interface PenToolOptions {
4
4
  color?: string;
5
5
  width?: number;
6
6
  cap?: StrokeCap;
7
+ startCap?: StrokeCap;
7
8
  dash?: boolean;
8
9
  minSampleDistance?: number;
9
10
  variant?: 'pen' | 'marker' | 'highlighter';
@@ -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 startCap = options.startCap ?? 'round';
13
14
  const dash = options.dash ?? false;
14
15
  const variant = options.variant ?? 'pen';
15
16
  const minSampleDistance = options.minSampleDistance ?? 1.5;
@@ -28,7 +29,15 @@ export const createPenTool = (options = {}) => {
28
29
  cursor: 'crosshair',
29
30
  // Drives UI-thread drawing on native (see FreehandConfig). The
30
31
  // onPointerDown/Move/Up below remain the web/parity implementation.
31
- freehand: { variant, color, width, cap, dash, minSampleDistance },
32
+ freehand: {
33
+ variant,
34
+ color,
35
+ width,
36
+ cap,
37
+ ...(startCap === 'arrow' && { startCap: 'arrow' }),
38
+ dash,
39
+ minSampleDistance,
40
+ },
32
41
  onPointerDown(event, ctx) {
33
42
  const stroke = {
34
43
  id: makeId('stroke'),
@@ -37,6 +46,7 @@ export const createPenTool = (options = {}) => {
37
46
  color,
38
47
  width,
39
48
  cap,
49
+ ...(startCap === 'arrow' && { startCap: 'arrow' }),
40
50
  ...(dash && { dash }),
41
51
  points: [event.world.x, event.world.y],
42
52
  pressure: event.pressure !== undefined ? [event.pressure] : undefined,