@reekon-tools/boldr-utils 1.6.20 → 1.6.24
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 +3 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.js +35 -8
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +3 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +229 -13
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +11 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +51 -41
- package/dist/annotation/canvas/Tool.d.ts +8 -0
- 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/stampLayout.d.ts +2 -4
- package/dist/annotation/canvas/stampLayout.js +31 -51
- 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 +165 -16
- package/dist/annotation/canvas/tools/shapeTool.d.ts +2 -0
- package/dist/annotation/canvas/tools/shapeTool.js +13 -2
- package/dist/annotation/canvas/tools/textEditing.js +2 -2
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +8 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +40 -6
- package/dist/types/annotation.d.ts +4 -0
- package/dist/types/annotation.js +8 -2
- package/package.json +1 -1
|
@@ -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,7 @@ 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, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, 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) => {
|
|
96
78
|
// Line/arrow shapes support endpoint editing. When selected they show
|
|
97
79
|
// grab handles at both ends; during an endpoint drag the line renders
|
|
98
80
|
// from the live endpoints (one following the finger) — the shape twin
|
|
@@ -114,22 +96,33 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
114
96
|
// but idle: reuse ShapeElement for the body (caps/arrowhead/dash)
|
|
115
97
|
// and overlay the handles, both wrapped so a group move tracks.
|
|
116
98
|
if (isEndpointDrag) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
return null;
|
|
120
|
-
const [apex, baseL, baseR] = arrowheadTriangle(b, a, strokeWidth);
|
|
99
|
+
const headPath = (tip, from) => {
|
|
100
|
+
const [apex, baseL, baseR] = arrowheadTriangle(tip, from, strokeWidth);
|
|
121
101
|
const p = Skia.Path.Make();
|
|
122
102
|
p.moveTo(apex.x, apex.y);
|
|
123
103
|
p.lineTo(baseL.x, baseL.y);
|
|
124
104
|
p.lineTo(baseR.x, baseR.y);
|
|
125
105
|
p.close();
|
|
126
106
|
return p;
|
|
127
|
-
}
|
|
128
|
-
|
|
107
|
+
};
|
|
108
|
+
const endArrow = hasArrow ? headPath(b, a) : null;
|
|
109
|
+
const startArrow = shape.style.startCap === 'arrow' ? headPath(a, b) : null;
|
|
110
|
+
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
111
|
}
|
|
130
112
|
return (_jsxs(DraggableElement, { isDragging: shape.id === draggingId, transform: dragTransform, children: [_jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }), handles] }, shape.id));
|
|
131
113
|
}
|
|
132
114
|
}
|
|
115
|
+
// Non-line shapes track the live transform of whichever resize is in
|
|
116
|
+
// flight: text scales uniformly via a transform (`resizingId`); a
|
|
117
|
+
// group move translates (`draggingId`). A rect/ellipse/polygon corner
|
|
118
|
+
// resize instead re-renders the SCALED OUTLINE live (`shapeResizePath`)
|
|
119
|
+
// at a constant stroke — a non-uniform scale transform would warp the
|
|
120
|
+
// stroke width (top/bottom edges thicken with scaleY).
|
|
121
|
+
if (shape.id === shapeResizeId && shapeResizePath) {
|
|
122
|
+
const stroke = shape.style.stroke ?? '#000000';
|
|
123
|
+
const strokeWidth = shape.style.strokeWidth ?? 2;
|
|
124
|
+
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));
|
|
125
|
+
}
|
|
133
126
|
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
127
|
}), effectiveCanvas.placedMeasurements.map((placed) => {
|
|
135
128
|
// Rectangle annotation: a stroked border whose center carries the
|
|
@@ -155,23 +148,26 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
155
148
|
const p2 = isEndpointDrag && liveLineP2 ? liveLineP2 : placed.line.b;
|
|
156
149
|
const lineColor = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
|
|
157
150
|
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);
|
|
151
|
+
// Solid filled-triangle arrowheads at endpoint b (the "end", lineCap)
|
|
152
|
+
// and/or endpoint a (the "start", lineStartCap). Built from the static
|
|
153
|
+
// endpoints, so they're skipped during a live endpoint drag (they
|
|
154
|
+
// reappear on release) rather than lagging the moving finger.
|
|
155
|
+
const headPath = (tip, from) => {
|
|
156
|
+
const [apex, baseL, baseR] = arrowheadTriangle(tip, from, lineWidth);
|
|
167
157
|
const p = Skia.Path.Make();
|
|
168
158
|
p.moveTo(apex.x, apex.y);
|
|
169
159
|
p.lineTo(baseL.x, baseL.y);
|
|
170
160
|
p.lineTo(baseR.x, baseR.y);
|
|
171
161
|
p.close();
|
|
172
162
|
return p;
|
|
173
|
-
}
|
|
174
|
-
const
|
|
163
|
+
};
|
|
164
|
+
const endArrow = placed.line && !isEndpointDrag && placed.lineCap === 'arrow'
|
|
165
|
+
? headPath(placed.line.b, placed.line.a)
|
|
166
|
+
: null;
|
|
167
|
+
const startArrow = placed.line && !isEndpointDrag && placed.lineStartCap === 'arrow'
|
|
168
|
+
? headPath(placed.line.a, placed.line.b)
|
|
169
|
+
: null;
|
|
170
|
+
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
171
|
return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
|
|
176
172
|
}), (() => {
|
|
177
173
|
if (!selectedId)
|
|
@@ -188,20 +184,34 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
188
184
|
// above), not a bounding box — matching the measurement-line UX.
|
|
189
185
|
if (shape.kind === 'line' || shape.kind === 'arrow')
|
|
190
186
|
return null;
|
|
187
|
+
// During a corner resize the box tracks the live scaled bounds
|
|
188
|
+
// (`shapeResizeBox`) at a constant stroke — a transform would warp it
|
|
189
|
+
// like the shape — and the corner handles hide (they'd warp into
|
|
190
|
+
// ellipses under a non-uniform scale; the rect annotation hides its
|
|
191
|
+
// handles mid-drag for the same reason).
|
|
192
|
+
if (shape.id === shapeResizeId && shapeResizeBox) {
|
|
193
|
+
return (_jsx(Rect, { x: shapeResizeBox.x, y: shapeResizeBox.y, width: shapeResizeBox.width, height: shapeResizeBox.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }));
|
|
194
|
+
}
|
|
191
195
|
// Text shapes derive their box from the estimated text bounds (the
|
|
192
196
|
// stored geometry is just the top-left anchor) and add a corner
|
|
193
197
|
// resize handle; both track the live resize transform so the chrome
|
|
194
198
|
// scales with the shape during a native UI-thread resize.
|
|
195
199
|
const isText = shape.kind === 'text';
|
|
196
|
-
const b = isText
|
|
197
|
-
? textShapeBounds(shape)
|
|
198
|
-
: pointsBounds(shape.geometry.points);
|
|
200
|
+
const b = isText ? textShapeBounds(shape) : visualShapeBounds(shape);
|
|
199
201
|
if (!b)
|
|
200
202
|
return null;
|
|
201
203
|
const isResizing = shape.id === resizingId;
|
|
202
204
|
const liveTransform = isResizing ? resizeTransform : dragTransform;
|
|
205
|
+
const isLive = isDragging || isResizing;
|
|
203
206
|
const resizeGeom = isText ? textResizeGeometry(shape) : null;
|
|
204
|
-
|
|
207
|
+
// Rect/ellipse/polygon shapes get four bounding-box corner handles
|
|
208
|
+
// (drawn at the un-padded visual-bounds corners so they line up with
|
|
209
|
+
// the hit-test) to scale length and width — the shape twin of the
|
|
210
|
+
// rectangle-annotation corner handles.
|
|
211
|
+
const cornerResizable = shape.kind === 'rect' ||
|
|
212
|
+
shape.kind === 'ellipse' ||
|
|
213
|
+
shape.kind === 'polygon';
|
|
214
|
+
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
215
|
}
|
|
206
216
|
return null;
|
|
207
217
|
})(), 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] }) }));
|
|
@@ -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;
|
|
@@ -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
|
+
};
|
|
@@ -5,7 +5,5 @@ export declare const DEFAULT_TILE_SCALE = 1;
|
|
|
5
5
|
export declare const TILE_SCALE_MIN = 0.4;
|
|
6
6
|
export declare const TILE_SCALE_MAX = 2;
|
|
7
7
|
export declare const clampTileScale: (v: number) => number;
|
|
8
|
-
export declare const
|
|
9
|
-
export declare const
|
|
10
|
-
export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath">) => boolean;
|
|
11
|
-
export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
|
|
8
|
+
export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId">) => boolean;
|
|
9
|
+
export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId" | "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
|
|
@@ -30,60 +30,40 @@ export const TILE_SCALE_MAX = 2;
|
|
|
30
30
|
// Clamp a tile-scale-factor candidate to the supported range. The single guard
|
|
31
31
|
// for the value before it lands in the document (slider input, restored docs).
|
|
32
32
|
export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
|
|
33
|
-
// ---
|
|
34
|
-
// A
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// = min(canvasW, docW * canvasH / docH)
|
|
52
|
-
// This — not the raw canvas size — governs the tile's fraction of the drawing,
|
|
53
|
-
// so the same document at the same canvas aspect yields the same value on web
|
|
54
|
-
// and native. Falls back to canvasW when doc dimensions are unknown.
|
|
55
|
-
export const renderedDocWidthAtFit = (canvasW, canvasH, docW, docH) => {
|
|
56
|
-
if (!(docW > 0) || !(docH > 0) || !(canvasW > 0) || !(canvasH > 0)) {
|
|
57
|
-
return canvasW > 0 ? canvasW : TILE_VIEWPORT_REFERENCE_PX;
|
|
58
|
-
}
|
|
59
|
-
return Math.min(canvasW, (docW * canvasH) / docH);
|
|
60
|
-
};
|
|
61
|
-
// Multiplier folded into the tile footprint so tiles are a consistent fraction
|
|
62
|
-
// of the rendered drawing on any canvas. Zoom-INDEPENDENT (a function of canvas
|
|
63
|
-
// + doc dimensions only), so the tile still never changes size as the user
|
|
64
|
-
// pinches/wheels. Defaults to 1 (callers without canvas dimensions are
|
|
65
|
-
// unaffected — e.g. legacy tests).
|
|
66
|
-
export const viewportTileScale = (canvasW, canvasH, docW, docH) => {
|
|
67
|
-
const s = renderedDocWidthAtFit(canvasW, canvasH, docW, docH) /
|
|
68
|
-
TILE_VIEWPORT_REFERENCE_PX;
|
|
69
|
-
return s < TILE_VIEWPORT_SCALE_MIN
|
|
70
|
-
? TILE_VIEWPORT_SCALE_MIN
|
|
71
|
-
: s > TILE_VIEWPORT_SCALE_MAX
|
|
72
|
-
? TILE_VIEWPORT_SCALE_MAX
|
|
73
|
-
: s;
|
|
74
|
-
};
|
|
33
|
+
// --- Tile sizing is independent of canvas size ------------------------------
|
|
34
|
+
// A measurement tile's footprint is base × per-tile `scale` × the document-wide
|
|
35
|
+
// `tileScaleFactor` (below) — and NOTHING tied to the canvas's pixel size, so
|
|
36
|
+
// the same document shows the same tiles at any canvas/window size.
|
|
37
|
+
//
|
|
38
|
+
// Earlier builds multiplied the footprint by a per-canvas `viewportTileScale`
|
|
39
|
+
// derived from how large the document rendered when fit to the canvas, to keep a
|
|
40
|
+
// tile a constant fraction of the drawing across devices. But that made a tile's
|
|
41
|
+
// size change whenever the canvas was resized (e.g. dragging a desktop pane) —
|
|
42
|
+
// the same document read with different-sized tiles at different window sizes.
|
|
43
|
+
// That auto-multiplier is retired in favor of the user-driven "Tile size"
|
|
44
|
+
// slider (the document-wide `tileScaleFactor`), which is explicit, persisted,
|
|
45
|
+
// and consistent everywhere. Mobile is unchanged — its old factor was already
|
|
46
|
+
// pinned at 1, calibrated to a phone's width.
|
|
47
|
+
//
|
|
48
|
+
// `stampTileSize` keeps its `viewportScale` parameter (default 1) only so the
|
|
49
|
+
// render overlay, hit-test, and tools that thread it stay call-compatible; it is
|
|
50
|
+
// now always 1.
|
|
75
51
|
// A placed measurement is an unassociated input until a measurement reference
|
|
76
|
-
// is attached (id or path)
|
|
77
|
-
|
|
52
|
+
// is attached (id or path) OR it is bound to a form column (`columnId`). Such
|
|
53
|
+
// stamps use STAMP_INPUT_TILE_SIZE. A column-bound tile is a full editable
|
|
54
|
+
// input *card* (it shows the column's label + value editor), so it takes the
|
|
55
|
+
// full STAMP_TILE_SIZE like an associated tile — only the truly blank "+"
|
|
56
|
+
// placeholder uses the compact input size.
|
|
57
|
+
export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath && !m.columnId;
|
|
78
58
|
// Screen-space edge length for a placed stamp: the compact input size while
|
|
79
59
|
// unassociated, full size once a measurement is attached, then scaled by the
|
|
80
|
-
// per-stamp `scale
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
60
|
+
// per-stamp `scale` and the document-wide `tileScaleFactor`. The ONE source of
|
|
61
|
+
// truth for tile footprint — render overlay, hit-test, and slide-grab
|
|
62
|
+
// classification all call this so the drawn tile and its touch box always agree.
|
|
63
|
+
// `tileScaleFactor` is the document-wide knob (default 1, from
|
|
64
|
+
// `AnnotationCanvasState.tileScaleFactor`, driven by the "Tile size" slider).
|
|
65
|
+
// `viewportScale` is retained for call compatibility and is always 1 — the old
|
|
66
|
+
// per-canvas multiplier was retired (see the note above). Independent of zoom.
|
|
87
67
|
export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
|
|
88
68
|
(m.scale ?? 1) *
|
|
89
69
|
tileScaleFactor *
|
|
@@ -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,
|