@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.
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +387 -77
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +14 -2
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +55 -42
- package/dist/annotation/canvas/Tool.d.ts +8 -0
- package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +4 -1
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +21 -19
- package/dist/annotation/canvas/elements/ShapeElement.js +45 -25
- package/dist/annotation/canvas/elements/StrokeElement.js +26 -11
- package/dist/annotation/canvas/shapeGeometry.d.ts +7 -0
- package/dist/annotation/canvas/shapeGeometry.js +36 -0
- package/dist/annotation/canvas/tools/penTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/penTool.js +11 -1
- package/dist/annotation/canvas/tools/selectTool.js +118 -1
- package/dist/annotation/canvas/tools/shapeTool.d.ts +2 -0
- package/dist/annotation/canvas/tools/shapeTool.js +13 -2
- package/dist/annotation/canvas/useAnnotationCanvasState.js +41 -10
- package/dist/annotation/canvas/viewport.d.ts +13 -1
- package/dist/annotation/canvas/viewport.js +32 -0
- package/dist/exports.d.ts +1 -1
- package/dist/exports.js +1 -1
- package/dist/types/annotation.d.ts +3 -0
- package/dist/types/annotation.js +8 -2
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// moving finger.
|
|
162
|
-
const
|
|
163
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
42
|
-
|
|
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 =
|
|
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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
69
|
+
else if (kind === 'polygon' && !closed) {
|
|
60
70
|
const n = geometry.points.length;
|
|
61
71
|
if (n >= 2) {
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 }),
|
|
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 }),
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
|
|
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
|
|
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
|
|
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) })) }),
|
|
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
|
+
};
|
|
@@ -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: {
|
|
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,
|