@reekon-tools/boldr-utils 1.6.23 → 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.
@@ -9,6 +9,7 @@ import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
10
  import { buildRemoveMeasurementOps, } from './measurementGeometry.js';
11
11
  import { buildShapeFromDrag } from './tools/shapeTool.js';
12
+ import { SELECTION_PAD } from './textGeometry.js';
12
13
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
13
14
  let strokeCounter = 0;
14
15
  const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++).toString(36)}`;
@@ -24,6 +25,10 @@ const HANDLE_RADIUS_PX = 7;
24
25
  // Screen-px stroke width of the handle's colored ring (white-disc + ring, so the
25
26
  // knob stays legible over a line of any color). Also zoom-divided.
26
27
  const HANDLE_RING_PX = 2;
28
+ // Doc-space floor on a shape-corner resize's width/height — the worklet twin of
29
+ // selectTool's MIN_SHAPE_EXTENT (the live preview must clamp identically to the
30
+ // buildShapeCornerPatch commit). Keep the two in sync.
31
+ const MIN_SHAPE_EXTENT = 1;
27
32
  // Native fingerprint: one finger drives the active tool, two fingers
28
33
  // pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
29
34
  // like measurement-stamp (which only listen to onPointerUp) work via tap.
@@ -43,6 +48,11 @@ export const AnnotationCanvasInner = (props) => {
43
48
  // never rebuilt mid-gesture — its JS callbacks read `stateRef.current`.
44
49
  const stateRef = useRef(state);
45
50
  stateRef.current = state;
51
+ // Open-value-entry callback (edit mode). Rides a ref so the gesture needn't
52
+ // rebuild when it changes; the second-tap-on-selected detection lives in the
53
+ // tap gesture below (it compares against the selection captured pre-tap).
54
+ const onStampDoubleTapRef = useRef(props.onMeasurementStampDoubleTap);
55
+ onStampDoubleTapRef.current = props.onMeasurementStampDoubleTap;
46
56
  // Live viewport on the UI thread. Initialised from the JS snapshot; kept in
47
57
  // sync from JS only when not actively gesturing (see the effect below).
48
58
  const zoom = useSharedValue(state.viewport.zoom);
@@ -179,13 +189,14 @@ export const AnnotationCanvasInner = (props) => {
179
189
  // Live mirror of the active shape tool's config for the worklet path
180
190
  // builders (the derived values are created once, so they can't close over
181
191
  // the changing `shapeDraw` prop).
182
- const shapeCfg = useSharedValue({ kind: 'line', cap: 'round', width: 2 });
192
+ const shapeCfg = useSharedValue({ kind: 'line', cap: 'round', startCap: 'round', width: 2 });
183
193
  useEffect(() => {
184
194
  if (!shapeDraw)
185
195
  return;
186
196
  shapeCfg.value = {
187
197
  kind: shapeDraw.kind,
188
198
  cap: shapeDraw.cap ?? 'round',
199
+ startCap: shapeDraw.startCap ?? 'round',
189
200
  width: shapeDraw.width,
190
201
  };
191
202
  }, [shapeDraw, shapeCfg]);
@@ -256,15 +267,20 @@ export const AnnotationCanvasInner = (props) => {
256
267
  path.close();
257
268
  };
258
269
  for (const e of handoffShapes.value) {
259
- if (e.kind === 'line' && e.cap === 'arrow') {
270
+ if (e.kind !== 'line')
271
+ continue;
272
+ // End head at b (points a→b); start head at a (points b→a, args swapped).
273
+ if (e.cap === 'arrow')
260
274
  addHead(e.ax, e.ay, e.bx, e.by);
261
- }
275
+ if (e.startCap === 'arrow')
276
+ addHead(e.bx, e.by, e.ax, e.ay);
262
277
  }
263
278
  const s = liveShape.value;
264
- if (s.active &&
265
- shapeCfg.value.kind === 'line' &&
266
- shapeCfg.value.cap === 'arrow') {
267
- addHead(s.ax, s.ay, s.bx, s.by);
279
+ if (s.active && shapeCfg.value.kind === 'line') {
280
+ if (shapeCfg.value.cap === 'arrow')
281
+ addHead(s.ax, s.ay, s.bx, s.by);
282
+ if (shapeCfg.value.startCap === 'arrow')
283
+ addHead(s.bx, s.by, s.ax, s.ay);
268
284
  }
269
285
  return path;
270
286
  });
@@ -420,6 +436,124 @@ export const AnnotationCanvasInner = (props) => {
420
436
  { translateY: -c.py },
421
437
  ];
422
438
  });
439
+ // Geometric-shape corner resize (rect/ellipse/polygon). `shapeResizeId`
440
+ // (React state) gates which shape renders live; `shapeResizeCtx` carries the
441
+ // fixed (opposite) corner and the grabbed corner's start position (from
442
+ // DragSelectionConfig.hitTestShapeCorner), and `shapeResizePts` the shape's
443
+ // start geometry. The preview re-renders the shape from live GEOMETRY (a
444
+ // derived path) rather than a scale transform — a non-uniform scale transform
445
+ // would warp the stroke width (top/bottom edges thicken with scaleY); drawing
446
+ // the scaled outline at a constant stroke keeps it crisp. The commit on
447
+ // release goes through buildShapeCornerPatch, which scales identically.
448
+ const [shapeResizeId, setShapeResizeId] = useState(null);
449
+ const shapeResizeCtx = useSharedValue({ fx: 0, fy: 0, mx: 0, my: 0, uni: 0 });
450
+ // The resized shape's start geometry: kind + flat [x,y,x,y,…] points.
451
+ const shapeResizePts = useSharedValue({
452
+ kind: '',
453
+ pts: [],
454
+ });
455
+ const shapeResizeTargetRef = useRef(null);
456
+ // Live scale factors about the fixed corner. WORKLET TWIN of
457
+ // selectTool.shapeCornerPatch — keep the clamp + the ellipse uniform-scale
458
+ // branch in sync. Read by the path + box derived values below.
459
+ const shapeResizeScale = useDerivedValue(() => {
460
+ 'worklet';
461
+ const c = shapeResizeCtx.value;
462
+ const denomX = c.mx - c.fx;
463
+ const denomY = c.my - c.fy;
464
+ let offX = c.mx + dragX.value - c.fx;
465
+ let offY = c.my + dragY.value - c.fy;
466
+ offX =
467
+ denomX >= 0
468
+ ? Math.max(MIN_SHAPE_EXTENT, offX)
469
+ : Math.min(-MIN_SHAPE_EXTENT, offX);
470
+ offY =
471
+ denomY >= 0
472
+ ? Math.max(MIN_SHAPE_EXTENT, offY)
473
+ : Math.min(-MIN_SHAPE_EXTENT, offY);
474
+ let sx = denomX !== 0 ? offX / denomX : 1;
475
+ let sy = denomY !== 0 ? offY / denomY : 1;
476
+ if (c.uni === 1) {
477
+ const oldDiag = Math.sqrt(denomX * denomX + denomY * denomY);
478
+ const s0 = oldDiag !== 0 ? Math.sqrt(offX * offX + offY * offY) / oldDiag : 1;
479
+ sx = s0;
480
+ sy = s0;
481
+ }
482
+ return { sx, sy };
483
+ });
484
+ // The shape's outline scaled about the fixed corner, rebuilt per frame.
485
+ // WORKLET TWIN of ShapeElement's per-kind rendering (rect/ellipse/polygon) —
486
+ // keep in sync.
487
+ const shapeResizePath = useDerivedValue(() => {
488
+ 'worklet';
489
+ const path = Skia.Path.Make();
490
+ const g = shapeResizePts.value;
491
+ const c = shapeResizeCtx.value;
492
+ const { sx, sy } = shapeResizeScale.value;
493
+ const n = g.pts.length;
494
+ if (n < 4)
495
+ return path;
496
+ const px = (i) => c.fx + (g.pts[i] - c.fx) * sx;
497
+ const py = (i) => c.fy + (g.pts[i + 1] - c.fy) * sy;
498
+ if (g.kind === 'rect' || g.kind === 'ellipse') {
499
+ const ax = px(0);
500
+ const ay = py(0);
501
+ const bx = px(2);
502
+ const by = py(2);
503
+ if (g.kind === 'ellipse') {
504
+ const r = Math.max(Math.abs(bx - ax), Math.abs(by - ay)) / 2;
505
+ path.addCircle((ax + bx) / 2, (ay + by) / 2, r);
506
+ }
507
+ else {
508
+ const minX = Math.min(ax, bx);
509
+ const maxX = Math.max(ax, bx);
510
+ const minY = Math.min(ay, by);
511
+ const maxY = Math.max(ay, by);
512
+ path.moveTo(minX, minY);
513
+ path.lineTo(maxX, minY);
514
+ path.lineTo(maxX, maxY);
515
+ path.lineTo(minX, maxY);
516
+ path.close();
517
+ }
518
+ }
519
+ else {
520
+ // polygon (incl. triangle): the scaled outline through every point.
521
+ path.moveTo(px(0), py(0));
522
+ for (let i = 2; i < n; i += 2)
523
+ path.lineTo(px(i), py(i));
524
+ path.close();
525
+ }
526
+ return path;
527
+ });
528
+ // Live selection box: the scaled visual bounds (fixed corner ↔ scaled moving
529
+ // corner) padded by SELECTION_PAD, so the box tracks the resize at a constant
530
+ // stroke too (the transform would have warped it like the shape). Four
531
+ // separate derived values so each feeds its own animated <Rect> prop (Skia
532
+ // animates props individually — see `liveRect`).
533
+ const shapeResizeBoxX = useDerivedValue(() => {
534
+ 'worklet';
535
+ const c = shapeResizeCtx.value;
536
+ const nmx = c.fx + (c.mx - c.fx) * shapeResizeScale.value.sx;
537
+ return Math.min(c.fx, nmx) - SELECTION_PAD;
538
+ });
539
+ const shapeResizeBoxY = useDerivedValue(() => {
540
+ 'worklet';
541
+ const c = shapeResizeCtx.value;
542
+ const nmy = c.fy + (c.my - c.fy) * shapeResizeScale.value.sy;
543
+ return Math.min(c.fy, nmy) - SELECTION_PAD;
544
+ });
545
+ const shapeResizeBoxW = useDerivedValue(() => {
546
+ 'worklet';
547
+ const c = shapeResizeCtx.value;
548
+ const nmx = c.fx + (c.mx - c.fx) * shapeResizeScale.value.sx;
549
+ return Math.abs(nmx - c.fx) + SELECTION_PAD * 2;
550
+ });
551
+ const shapeResizeBoxH = useDerivedValue(() => {
552
+ 'worklet';
553
+ const c = shapeResizeCtx.value;
554
+ const nmy = c.fy + (c.my - c.fy) * shapeResizeScale.value.sy;
555
+ return Math.abs(nmy - c.fy) + SELECTION_PAD * 2;
556
+ });
423
557
  // Per-gesture refs so we always emit a matching down/move/up sequence.
424
558
  const pointerIdRef = useRef(1);
425
559
  const inFlightRef = useRef(null);
@@ -530,11 +664,33 @@ export const AnnotationCanvasInner = (props) => {
530
664
  st.ctx.commit({ ops: [{ op: 'addStroke', stroke }] });
531
665
  return;
532
666
  }
667
+ // Capture the selection BEFORE this tap re-selects, so we can tell a
668
+ // first tap (select) from a second tap on the already-selected tile.
669
+ const prevSelectedId = st.ctx.selection?.ids[0] ?? null;
533
670
  // Otherwise synthesize a down+up sequence so tools that only listen to
534
671
  // onPointerUp (e.g. measurement stamp) still fire.
535
672
  const id = pointerIdRef.current++;
536
673
  st.dispatchPointerDown(buildEvent(id, screen));
537
674
  st.dispatchPointerUp(buildEvent(id, screen));
675
+ // Edit-mode "tap the selected tile to open value entry": when the select
676
+ // tool is active the stamp overlay is non-interactive (so drag/select
677
+ // keep working), which means the view-mode TouchableOpacity can't catch
678
+ // the tap. Mirror handleViewStampPress here — a first tap only selects, a
679
+ // second tap on the same (already-selected) tile fires the consumer
680
+ // callback. Not a timed double-tap: the tile must already have been
681
+ // selected by a previous tap.
682
+ const onActivate = onStampDoubleTapRef.current;
683
+ if (!onActivate || !dragSelection || !prevSelectedId)
684
+ return;
685
+ const world = st.ctx.viewport.screenToWorld(screen);
686
+ const zoomNow = st.ctx.viewport.state.zoom;
687
+ const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
688
+ if (!hit || hit.kind !== 'measurement' || hit.id !== prevSelectedId) {
689
+ return;
690
+ }
691
+ const placed = st.ctx.document.placedMeasurements.find((p) => p.id === hit.id);
692
+ if (placed)
693
+ onActivate(placed);
538
694
  });
539
695
  // Viewport pan — runs on the UI thread (no runOnJS), mutating the shared
540
696
  // viewport directly. Mirrors viewport.ts `panBy`. Used for both the
@@ -610,6 +766,7 @@ export const AnnotationCanvasInner = (props) => {
610
766
  color: fh.color,
611
767
  width: fh.width,
612
768
  cap: fh.cap ?? 'round',
769
+ ...(fh.startCap === 'arrow' && { startCap: 'arrow' }),
613
770
  ...(fh.dash && { dash: true }),
614
771
  points: worldPoints,
615
772
  createdAt: Date.now(),
@@ -694,6 +851,7 @@ export const AnnotationCanvasInner = (props) => {
694
851
  color: cfg.color,
695
852
  width: cfg.width,
696
853
  cap: cfg.cap,
854
+ startCap: cfg.startCap,
697
855
  dash: cfg.dash,
698
856
  layerId: st.ctx.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
699
857
  id,
@@ -746,6 +904,7 @@ export const AnnotationCanvasInner = (props) => {
746
904
  id,
747
905
  kind: cfg.kind,
748
906
  cap: cfg.cap ?? 'round',
907
+ startCap: cfg.startCap ?? 'round',
749
908
  ax: s.ax,
750
909
  ay: s.ay,
751
910
  bx: s.bx,
@@ -846,6 +1005,33 @@ export const AnnotationCanvasInner = (props) => {
846
1005
  setRectDragId(selId);
847
1006
  return;
848
1007
  }
1008
+ // Shape (rect/ellipse/polygon) bounding-box corner handle — the shape
1009
+ // twin of the rect-annotation corner above; scales length and width.
1010
+ const shapeCornerHit = cfg.hitTestShapeCorner?.(st.ctx.document, selId, world, zoomNow);
1011
+ if (shapeCornerHit) {
1012
+ const cornerShape = st.ctx.document.shapes.find((x) => x.id === selId);
1013
+ shapeResizeCtx.value = {
1014
+ fx: shapeCornerHit.fixed.x,
1015
+ fy: shapeCornerHit.fixed.y,
1016
+ mx: shapeCornerHit.moving.x,
1017
+ my: shapeCornerHit.moving.y,
1018
+ uni: cornerShape?.kind === 'ellipse' ? 1 : 0,
1019
+ };
1020
+ const flat = [];
1021
+ for (const p of cornerShape?.geometry.points ?? []) {
1022
+ flat.push(p.x, p.y);
1023
+ }
1024
+ shapeResizePts.value = {
1025
+ kind: cornerShape?.kind ?? '',
1026
+ pts: flat,
1027
+ };
1028
+ shapeResizeTargetRef.current = {
1029
+ id: selId,
1030
+ corner: shapeCornerHit.corner,
1031
+ };
1032
+ setShapeResizeId(selId);
1033
+ return;
1034
+ }
849
1035
  }
850
1036
  const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
851
1037
  if (!hit) {
@@ -907,6 +1093,19 @@ export const AnnotationCanvasInner = (props) => {
907
1093
  setRectDragId(null);
908
1094
  return;
909
1095
  }
1096
+ // Shape-corner commit: scale the shape by dragging the grabbed corner
1097
+ // (opposite corner fixed); buildShapeCornerPatch clamps as the preview.
1098
+ const shapeRT = shapeResizeTargetRef.current;
1099
+ if (shapeRT) {
1100
+ if (dx !== 0 || dy !== 0) {
1101
+ const patch = cfg.buildShapeCornerPatch?.(st.ctx.document, shapeRT.id, shapeRT.corner, { x: dx, y: dy });
1102
+ if (patch)
1103
+ st.ctx.commit(patch);
1104
+ }
1105
+ shapeResizeTargetRef.current = null;
1106
+ setShapeResizeId(null);
1107
+ return;
1108
+ }
910
1109
  // Endpoint commit: move the grabbed endpoint by the world delta.
911
1110
  const epT = epTargetRef.current;
912
1111
  if (epT) {
@@ -966,12 +1165,14 @@ export const AnnotationCanvasInner = (props) => {
966
1165
  shapeEpTargetRef.current = null;
967
1166
  resizeTargetRef.current = null;
968
1167
  rectTargetRef.current = null;
1168
+ shapeResizeTargetRef.current = null;
969
1169
  setDraggingId(null);
970
1170
  setSlidingId(null);
971
1171
  setEpDragId(null);
972
1172
  setShapeEpDragId(null);
973
1173
  setResizingId(null);
974
1174
  setRectDragId(null);
1175
+ setShapeResizeId(null);
975
1176
  };
976
1177
  return Gesture.Pan()
977
1178
  .minPointers(1)
@@ -1105,6 +1306,14 @@ export const AnnotationCanvasInner = (props) => {
1105
1306
  width: liveRectW,
1106
1307
  height: liveRectH,
1107
1308
  },
1309
+ shapeResizeId,
1310
+ shapeResizePath,
1311
+ shapeResizeBox: {
1312
+ x: shapeResizeBoxX,
1313
+ y: shapeResizeBoxY,
1314
+ width: shapeResizeBoxW,
1315
+ height: shapeResizeBoxH,
1316
+ },
1108
1317
  // Endpoint/corner handles are drag affordances — only the select
1109
1318
  // tool can act on them, so suppress them when the active tool has
1110
1319
  // no drag support (e.g. view mode's pan tool, where a selected
@@ -69,6 +69,16 @@ export interface AnnotationCanvasSkiaProps {
69
69
  width: AnimatedNumber;
70
70
  height: AnimatedNumber;
71
71
  };
72
+ shapeResizeId?: string | null;
73
+ shapeResizePath?: SkPath | {
74
+ value: SkPath;
75
+ };
76
+ shapeResizeBox?: {
77
+ x: AnimatedNumber;
78
+ y: AnimatedNumber;
79
+ width: AnimatedNumber;
80
+ height: AnimatedNumber;
81
+ };
72
82
  handleRadius?: number | {
73
83
  value: number;
74
84
  };
@@ -77,5 +87,5 @@ export interface AnnotationCanvasSkiaProps {
77
87
  };
78
88
  customPreview?: ReactNode;
79
89
  }
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;
90
+ 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, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
81
91
  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,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
+ };
@@ -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,
@@ -1,6 +1,6 @@
1
1
  import { stampTileSize } from '../stampLayout.js';
2
2
  import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
3
- import { hitShapeOutline } from '../shapeGeometry.js';
3
+ import { hitShapeOutline, visualShapeBounds } from '../shapeGeometry.js';
4
4
  import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
5
5
  import { editTextShape } from './textEditing.js';
6
6
  const HIT_PADDING = 6;
@@ -315,6 +315,105 @@ const rectCornerPatch = (doc, id, corner, delta) => {
315
315
  ],
316
316
  };
317
317
  };
318
+ // --- Geometric-shape corner resize (rect/ellipse/polygon). The shape twin of
319
+ // the rectangle-annotation corner drag above: drag one bounding-box corner to
320
+ // scale length and width about the opposite (fixed) corner. Shared by the
321
+ // native UI-thread drag via DragSelectionConfig AND the web pointer handlers. ---
322
+ // Doc-space floor on a resized shape's width/height. Keeps a corner drag from
323
+ // collapsing a shape to a line (and from crossing the fixed corner, which would
324
+ // mirror a triangle), so the scale stays positive on both axes. The native
325
+ // shapeResizeScale worklet inlines the same value — keep them in sync.
326
+ const MIN_SHAPE_EXTENT = 1;
327
+ // Which shapes get the corner-scale handles: rect (two-corner geometry, scaled
328
+ // exactly like the rectangle annotation), polygon/triangle (all points scaled
329
+ // about the fixed corner) and ellipse. Lines/arrows resize via their endpoint
330
+ // handles and text via its own font-size handle, so both are excluded here.
331
+ const isCornerResizableShape = (s) => (s.kind === 'rect' || s.kind === 'ellipse' || s.kind === 'polygon') &&
332
+ s.geometry.points.length >= 2;
333
+ // The shape's visual bounds expressed as the {a,b} rect the rectCornerPoint/
334
+ // oppositeRectCorner helpers consume (so shapes reuse the exact corner math the
335
+ // rectangle annotation already uses). Uses visualShapeBounds, so the corner
336
+ // handles/hit-test sit on the same box the selection outline draws — for an
337
+ // ellipse that's the circle's bounding square, not the raw drag rect.
338
+ const shapeCornerRect = (s) => {
339
+ const vb = visualShapeBounds(s);
340
+ if (!vb)
341
+ return null;
342
+ return { a: { x: vb.minX, y: vb.minY }, b: { x: vb.maxX, y: vb.maxY } };
343
+ };
344
+ // Which bounding-box corner of a (selected) resizable shape is under `world`,
345
+ // or null. Mirrors findRectCornerHit but reads the box from the shape's points.
346
+ const findShapeCornerHit = (doc, id, world, zoom) => {
347
+ const s = doc.shapes.find((x) => x.id === id);
348
+ if (!s || !isCornerResizableShape(s))
349
+ return null;
350
+ const rect = shapeCornerRect(s);
351
+ if (!rect)
352
+ return null;
353
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
354
+ let best = null;
355
+ for (const corner of ['tl', 'tr', 'bl', 'br']) {
356
+ const p = rectCornerPoint(rect, corner);
357
+ const d = (world.x - p.x) ** 2 + (world.y - p.y) ** 2;
358
+ if (d <= r2 && (!best || d < best.d))
359
+ best = { corner, d };
360
+ }
361
+ if (!best)
362
+ return null;
363
+ return {
364
+ corner: best.corner,
365
+ moving: rectCornerPoint(rect, best.corner),
366
+ fixed: rectCornerPoint(rect, oppositeRectCorner(best.corner)),
367
+ };
368
+ };
369
+ // Scale every geometry point about the fixed (opposite) corner so the grabbed
370
+ // corner follows the drag. The moving corner is clamped so it can't cross or
371
+ // collapse onto the fixed corner (min extent on each axis), keeping the scale
372
+ // positive — no flip, no zero-size shape.
373
+ //
374
+ // rect/polygon scale length and width INDEPENDENTLY (a two-point rect's grabbed
375
+ // corner tracks the finger; a polygon's whole outline scales). An ellipse
376
+ // renders as a circle (radius = half its larger extent), so it scales
377
+ // UNIFORMLY off the diagonal instead — otherwise a non-uniform drag would
378
+ // preview as an oval and then snap back to a circle on commit.
379
+ const shapeCornerPatch = (doc, id, corner, delta) => {
380
+ const s = doc.shapes.find((x) => x.id === id);
381
+ if (!s || !isCornerResizableShape(s))
382
+ return null;
383
+ const rect = shapeCornerRect(s);
384
+ if (!rect)
385
+ return null;
386
+ const fixed = rectCornerPoint(rect, oppositeRectCorner(corner));
387
+ const moving = rectCornerPoint(rect, corner);
388
+ const denomX = moving.x - fixed.x;
389
+ const denomY = moving.y - fixed.y;
390
+ // Clamp the dragged offset to the same side of the fixed corner, magnitude
391
+ // >= MIN_SHAPE_EXTENT, so the scale factor never flips sign or hits zero.
392
+ const clampOffset = (offset, sign) => sign >= 0
393
+ ? Math.max(MIN_SHAPE_EXTENT, offset)
394
+ : Math.min(-MIN_SHAPE_EXTENT, offset);
395
+ const offX = clampOffset(moving.x + delta.x - fixed.x, denomX >= 0 ? 1 : -1);
396
+ const offY = clampOffset(moving.y + delta.y - fixed.y, denomY >= 0 ? 1 : -1);
397
+ let sx = denomX !== 0 ? offX / denomX : 1;
398
+ let sy = denomY !== 0 ? offY / denomY : 1;
399
+ if (s.kind === 'ellipse') {
400
+ // Uniform: ratio of the new corner-distance to the old, so the circle
401
+ // grows/shrinks about the fixed corner without distorting.
402
+ const oldDiag = Math.hypot(denomX, denomY);
403
+ const s0 = oldDiag !== 0 ? Math.hypot(offX, offY) / oldDiag : 1;
404
+ sx = s0;
405
+ sy = s0;
406
+ }
407
+ const points = s.geometry.points.map((p) => ({
408
+ x: fixed.x + (p.x - fixed.x) * sx,
409
+ y: fixed.y + (p.y - fixed.y) * sy,
410
+ }));
411
+ return {
412
+ ops: [
413
+ { op: 'updateShape', id, patch: { geometry: { ...s.geometry, points } } },
414
+ ],
415
+ };
416
+ };
318
417
  // --- Text-shape resize (corner-scale about the top-left anchor; shared by the
319
418
  // native UI-thread drag via DragSelectionConfig AND the web pointer handlers) ---
320
419
  // Resize geometry when the (selected) text shape's corner handle is under
@@ -367,6 +466,9 @@ const dragPatch = (s, doc, delta, zoom) => {
367
466
  if (s.mode === 'rect-corner' && s.corner) {
368
467
  return rectCornerPatch(doc, s.id, s.corner, delta);
369
468
  }
469
+ if (s.mode === 'shape-corner' && s.corner) {
470
+ return shapeCornerPatch(doc, s.id, s.corner, delta);
471
+ }
370
472
  const op = translatePatch(s.elementKind, s.id, doc, delta);
371
473
  return op ? { ops: [op] } : null;
372
474
  };
@@ -394,6 +496,8 @@ export const createSelectTool = () => ({
394
496
  buildResizePatch: resizePatch,
395
497
  hitTestRectCorner: findRectCornerHit,
396
498
  buildRectCornerPatch: rectCornerPatch,
499
+ hitTestShapeCorner: findShapeCornerHit,
500
+ buildShapeCornerPatch: shapeCornerPatch,
397
501
  },
398
502
  // Web pointer path. Mirrors the native UI-thread drag using the same shared
399
503
  // helpers: an endpoint handle on the selected annotation resizes the line;
@@ -448,6 +552,19 @@ export const createSelectTool = () => ({
448
552
  delta: { x: 0, y: 0 },
449
553
  };
450
554
  }
555
+ const shapeCorner = findShapeCornerHit(ctx.document, selId, world, zoom);
556
+ if (shapeCorner) {
557
+ ctx.setSelection({ ids: [selId] });
558
+ return {
559
+ kind: 'dragging',
560
+ id: selId,
561
+ elementKind: 'shape',
562
+ mode: 'shape-corner',
563
+ corner: shapeCorner.corner,
564
+ start: world,
565
+ delta: { x: 0, y: 0 },
566
+ };
567
+ }
451
568
  const rectCorner = findRectCornerHit(ctx.document, selId, world, zoom);
452
569
  if (rectCorner) {
453
570
  ctx.setSelection({ ids: [selId] });
@@ -8,6 +8,7 @@ export interface ShapeToolOptions {
8
8
  color?: string;
9
9
  width?: number;
10
10
  cap?: StrokeCap;
11
+ startCap?: StrokeCap;
11
12
  dash?: boolean;
12
13
  minDragPx?: number;
13
14
  }
@@ -18,6 +19,7 @@ export declare const buildShapeFromDrag: (opts: {
18
19
  color: string;
19
20
  width: number;
20
21
  cap?: StrokeCap;
22
+ startCap?: StrokeCap;
21
23
  dash?: boolean;
22
24
  layerId: string;
23
25
  id?: string;
@@ -24,10 +24,12 @@ export const buildShapeFromDrag = (opts) => ({
24
24
  strokeWidth: opts.width,
25
25
  ...(opts.dash && { dash: true }),
26
26
  // Caps only mean something on an open line; 'round' is the implicit
27
- // default so it stays un-persisted.
27
+ // default so it stays un-persisted. Only 'arrow' matters for the start.
28
28
  ...(opts.kind === 'line' &&
29
29
  opts.cap &&
30
30
  opts.cap !== 'round' && { cap: opts.cap }),
31
+ ...(opts.kind === 'line' &&
32
+ opts.startCap === 'arrow' && { startCap: 'arrow' }),
31
33
  },
32
34
  createdAt: Date.now(),
33
35
  });
@@ -42,6 +44,7 @@ export const createShapeTool = (options = {}) => {
42
44
  const color = options.color ?? '#111827';
43
45
  const width = options.width ?? 2;
44
46
  const cap = options.cap;
47
+ const startCap = options.startCap;
45
48
  const dash = options.dash ?? false;
46
49
  const minDragPx = options.minDragPx ?? 4;
47
50
  return {
@@ -49,7 +52,14 @@ export const createShapeTool = (options = {}) => {
49
52
  label: options.label ?? DEFAULT_LABELS[kind],
50
53
  cursor: 'crosshair',
51
54
  // Drives UI-thread rubber-banding on native (see ShapeDrawConfig).
52
- shapeDraw: { kind, color, width, ...(cap && { cap }), dash },
55
+ shapeDraw: {
56
+ kind,
57
+ color,
58
+ width,
59
+ ...(cap && { cap }),
60
+ ...(startCap === 'arrow' && { startCap: 'arrow' }),
61
+ dash,
62
+ },
53
63
  onPointerDown(event, ctx) {
54
64
  return {
55
65
  kind: 'shape-drawing',
@@ -60,6 +70,7 @@ export const createShapeTool = (options = {}) => {
60
70
  color,
61
71
  width,
62
72
  cap,
73
+ startCap,
63
74
  dash,
64
75
  layerId: firstLayerId(ctx.document),
65
76
  }),
@@ -13,6 +13,7 @@ export interface AnnotationStroke {
13
13
  color: string;
14
14
  width: number;
15
15
  cap?: StrokeCap;
16
+ startCap?: StrokeCap;
16
17
  dash?: boolean;
17
18
  points: number[];
18
19
  pressure?: number[];
@@ -29,6 +30,7 @@ export interface AnnotationShapeStyle {
29
30
  dash?: boolean;
30
31
  textDecoration?: AnnotationTextDecoration;
31
32
  cap?: StrokeCap;
33
+ startCap?: StrokeCap;
32
34
  }
33
35
  export interface AnnotationShape {
34
36
  id: AnnotationElementId;
@@ -65,6 +67,7 @@ export interface PlacedMeasurementRef {
65
67
  lineColor?: string;
66
68
  lineWidth?: number;
67
69
  lineCap?: StrokeCap;
70
+ lineStartCap?: StrokeCap;
68
71
  lineDash?: boolean;
69
72
  leader?: {
70
73
  from: Vec2;
@@ -12,8 +12,14 @@ export const createEmptyCanvasState = (viewport) => ({
12
12
  viewport: {
13
13
  width: viewport?.width ?? 1000,
14
14
  height: viewport?.height ?? 1000,
15
- backgroundImage: viewport?.backgroundImage,
16
- backgroundFit: viewport?.backgroundFit,
15
+ // Only include the optional keys when defined — emitting explicit
16
+ // `undefined` values breaks Firestore writes (RN rejects undefined fields).
17
+ ...(viewport?.backgroundImage !== undefined
18
+ ? { backgroundImage: viewport.backgroundImage }
19
+ : {}),
20
+ ...(viewport?.backgroundFit !== undefined
21
+ ? { backgroundFit: viewport.backgroundFit }
22
+ : {}),
17
23
  },
18
24
  strokes: [],
19
25
  shapes: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.23",
3
+ "version": "1.6.24",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",