@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.
@@ -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 arrowPath = (() => {
118
- if (!hasArrow)
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
- 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));
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 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);
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 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 })] }))] }));
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
- 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 }) }))] }));
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 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
+ };
@@ -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 renderedDocWidthAtFit: (canvasW: number, canvasH: number, docW: number, docH: number) => number;
9
- export declare const viewportTileScale: (canvasW: number, canvasH: number, docW: number, docH: number) => number;
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
- // --- Viewport-relative tile sizing ------------------------------------------
34
- // A bare-pixel tile is the same size on every device, but the SAME document is
35
- // fit into wildly different canvases (a phone window vs a desktop pane), so the
36
- // drawing renders much larger on desktop and a fixed-px tile reads as a tiny
37
- // fraction of it. To keep a tile a CONSISTENT fraction of the drawing across
38
- // platforms while staying independent of the user's live zoom the tile
39
- // footprint is multiplied by `viewportTileScale`, which tracks how large the
40
- // document renders when fit to the canvas (NOT the live zoom).
41
- // Rendered document width (screen px) at which a tile uses its unscaled base
42
- // size. Calibrated to a large phone's width so phone canvases land at scale 1
43
- // (mobile visually unchanged); larger canvases scale up proportionally.
44
- const TILE_VIEWPORT_REFERENCE_PX = 430;
45
- // Clamp so phone-sized canvases never shrink tiles below base (lower = 1) and
46
- // very large monitors don't produce runaway tiles (upper = 4).
47
- const TILE_VIEWPORT_SCALE_MIN = 1;
48
- const TILE_VIEWPORT_SCALE_MAX = 4;
49
- // Screen-px width the document occupies when fit to the canvas:
50
- // docW * fitZoom, fitZoom = min(canvasW/docW, canvasH/docH)
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). Such stamps use STAMP_INPUT_TILE_SIZE.
77
- export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath;
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`, the document-wide `tileScaleFactor`, and the
81
- // `viewportTileScale` (so tiles read at a consistent fraction of the drawing on
82
- // any canvas). The ONE source of truth for tile footprint render overlay,
83
- // hit-test, and slide-grab classification all call this so the drawn tile and
84
- // its touch box always agree. `tileScaleFactor` is the canvas-level knob
85
- // (default 1, from `AnnotationCanvasState.tileScaleFactor`); `viewportScale`
86
- // (default 1) is the per-canvas multiplier from `viewportTileScale`.
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 *
@@ -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,