@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.
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +216 -7
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +11 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +51 -41
- package/dist/annotation/canvas/Tool.d.ts +8 -0
- package/dist/annotation/canvas/elements/ShapeElement.js +45 -25
- package/dist/annotation/canvas/elements/StrokeElement.js +26 -11
- package/dist/annotation/canvas/shapeGeometry.d.ts +7 -0
- package/dist/annotation/canvas/shapeGeometry.js +36 -0
- package/dist/annotation/canvas/tools/penTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/penTool.js +11 -1
- package/dist/annotation/canvas/tools/selectTool.js +118 -1
- package/dist/annotation/canvas/tools/shapeTool.d.ts +2 -0
- package/dist/annotation/canvas/tools/shapeTool.js +13 -2
- package/dist/types/annotation.d.ts +3 -0
- package/dist/types/annotation.js +8 -2
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
return null;
|
|
120
|
-
const [apex, baseL, baseR] = arrowheadTriangle(b, a, strokeWidth);
|
|
99
|
+
const headPath = (tip, from) => {
|
|
100
|
+
const [apex, baseL, baseR] = arrowheadTriangle(tip, from, strokeWidth);
|
|
121
101
|
const p = Skia.Path.Make();
|
|
122
102
|
p.moveTo(apex.x, apex.y);
|
|
123
103
|
p.lineTo(baseL.x, baseL.y);
|
|
124
104
|
p.lineTo(baseR.x, baseR.y);
|
|
125
105
|
p.close();
|
|
126
106
|
return p;
|
|
127
|
-
}
|
|
128
|
-
|
|
107
|
+
};
|
|
108
|
+
const endArrow = hasArrow ? headPath(b, a) : null;
|
|
109
|
+
const startArrow = shape.style.startCap === 'arrow' ? headPath(a, b) : null;
|
|
110
|
+
return (_jsxs(Group, { children: [_jsx(Line, { p1: liveLineP1 ?? a, p2: liveLineP2 ?? b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(shape.style.cap), children: shape.style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) }), handles, startArrow && (_jsx(Path, { path: startArrow, color: stroke, style: "fill" })), endArrow && (_jsx(Path, { path: endArrow, color: stroke, style: "fill" }))] }, shape.id));
|
|
129
111
|
}
|
|
130
112
|
return (_jsxs(DraggableElement, { isDragging: shape.id === draggingId, transform: dragTransform, children: [_jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }), handles] }, shape.id));
|
|
131
113
|
}
|
|
132
114
|
}
|
|
115
|
+
// Non-line shapes track the live transform of whichever resize is in
|
|
116
|
+
// flight: text scales uniformly via a transform (`resizingId`); a
|
|
117
|
+
// group move translates (`draggingId`). A rect/ellipse/polygon corner
|
|
118
|
+
// resize instead re-renders the SCALED OUTLINE live (`shapeResizePath`)
|
|
119
|
+
// at a constant stroke — a non-uniform scale transform would warp the
|
|
120
|
+
// stroke width (top/bottom edges thicken with scaleY).
|
|
121
|
+
if (shape.id === shapeResizeId && shapeResizePath) {
|
|
122
|
+
const stroke = shape.style.stroke ?? '#000000';
|
|
123
|
+
const strokeWidth = shape.style.strokeWidth ?? 2;
|
|
124
|
+
return (_jsxs(Group, { children: [shape.style.fill && (_jsx(Path, { path: shapeResizePath, color: shape.style.fill, style: "fill" })), _jsx(Path, { path: shapeResizePath, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeJoin: "round", children: shape.style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) })] }, shape.id));
|
|
125
|
+
}
|
|
133
126
|
return (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }) }, shape.id));
|
|
134
127
|
}), effectiveCanvas.placedMeasurements.map((placed) => {
|
|
135
128
|
// Rectangle annotation: a stroked border whose center carries the
|
|
@@ -155,23 +148,26 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
155
148
|
const p2 = isEndpointDrag && liveLineP2 ? liveLineP2 : placed.line.b;
|
|
156
149
|
const lineColor = placed.lineColor ?? MEASUREMENT_LINE_COLOR;
|
|
157
150
|
const lineWidth = placed.lineWidth ?? MEASUREMENT_LINE_WIDTH;
|
|
158
|
-
// Solid filled-triangle
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// moving finger.
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
const [apex, baseL, baseR] = arrowheadTriangle(placed.line.b, placed.line.a, lineWidth);
|
|
151
|
+
// Solid filled-triangle arrowheads at endpoint b (the "end", lineCap)
|
|
152
|
+
// and/or endpoint a (the "start", lineStartCap). Built from the static
|
|
153
|
+
// endpoints, so they're skipped during a live endpoint drag (they
|
|
154
|
+
// reappear on release) rather than lagging the moving finger.
|
|
155
|
+
const headPath = (tip, from) => {
|
|
156
|
+
const [apex, baseL, baseR] = arrowheadTriangle(tip, from, lineWidth);
|
|
167
157
|
const p = Skia.Path.Make();
|
|
168
158
|
p.moveTo(apex.x, apex.y);
|
|
169
159
|
p.lineTo(baseL.x, baseL.y);
|
|
170
160
|
p.lineTo(baseR.x, baseR.y);
|
|
171
161
|
p.close();
|
|
172
162
|
return p;
|
|
173
|
-
}
|
|
174
|
-
const
|
|
163
|
+
};
|
|
164
|
+
const endArrow = placed.line && !isEndpointDrag && placed.lineCap === 'arrow'
|
|
165
|
+
? headPath(placed.line.b, placed.line.a)
|
|
166
|
+
: null;
|
|
167
|
+
const startArrow = placed.line && !isEndpointDrag && placed.lineStartCap === 'arrow'
|
|
168
|
+
? headPath(placed.line.a, placed.line.b)
|
|
169
|
+
: null;
|
|
170
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap), children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(lineWidth) })) }), startArrow && (_jsx(Path, { path: startArrow, color: lineColor, style: "fill" })), endArrow && (_jsx(Path, { path: endArrow, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Handle, { c: p1, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: p2, r: handleRadius, ringWidth: handleRingWidth })] }))] }));
|
|
175
171
|
return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
|
|
176
172
|
}), (() => {
|
|
177
173
|
if (!selectedId)
|
|
@@ -188,20 +184,34 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
188
184
|
// above), not a bounding box — matching the measurement-line UX.
|
|
189
185
|
if (shape.kind === 'line' || shape.kind === 'arrow')
|
|
190
186
|
return null;
|
|
187
|
+
// During a corner resize the box tracks the live scaled bounds
|
|
188
|
+
// (`shapeResizeBox`) at a constant stroke — a transform would warp it
|
|
189
|
+
// like the shape — and the corner handles hide (they'd warp into
|
|
190
|
+
// ellipses under a non-uniform scale; the rect annotation hides its
|
|
191
|
+
// handles mid-drag for the same reason).
|
|
192
|
+
if (shape.id === shapeResizeId && shapeResizeBox) {
|
|
193
|
+
return (_jsx(Rect, { x: shapeResizeBox.x, y: shapeResizeBox.y, width: shapeResizeBox.width, height: shapeResizeBox.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }));
|
|
194
|
+
}
|
|
191
195
|
// Text shapes derive their box from the estimated text bounds (the
|
|
192
196
|
// stored geometry is just the top-left anchor) and add a corner
|
|
193
197
|
// resize handle; both track the live resize transform so the chrome
|
|
194
198
|
// scales with the shape during a native UI-thread resize.
|
|
195
199
|
const isText = shape.kind === 'text';
|
|
196
|
-
const b = isText
|
|
197
|
-
? textShapeBounds(shape)
|
|
198
|
-
: pointsBounds(shape.geometry.points);
|
|
200
|
+
const b = isText ? textShapeBounds(shape) : visualShapeBounds(shape);
|
|
199
201
|
if (!b)
|
|
200
202
|
return null;
|
|
201
203
|
const isResizing = shape.id === resizingId;
|
|
202
204
|
const liveTransform = isResizing ? resizeTransform : dragTransform;
|
|
205
|
+
const isLive = isDragging || isResizing;
|
|
203
206
|
const resizeGeom = isText ? textResizeGeometry(shape) : null;
|
|
204
|
-
|
|
207
|
+
// Rect/ellipse/polygon shapes get four bounding-box corner handles
|
|
208
|
+
// (drawn at the un-padded visual-bounds corners so they line up with
|
|
209
|
+
// the hit-test) to scale length and width — the shape twin of the
|
|
210
|
+
// rectangle-annotation corner handles.
|
|
211
|
+
const cornerResizable = shape.kind === 'rect' ||
|
|
212
|
+
shape.kind === 'ellipse' ||
|
|
213
|
+
shape.kind === 'polygon';
|
|
214
|
+
return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isLive, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isLive, transform: liveTransform, children: _jsx(Handle, { c: resizeGeom.handle, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }) })), cornerResizable && handleRadius != null && (_jsxs(DraggableElement, { isDragging: isDragging, transform: dragTransform, children: [_jsx(Handle, { c: { x: b.minX, y: b.minY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.maxX, y: b.minY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.minX, y: b.maxY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.maxX, y: b.maxY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR })] }))] }));
|
|
205
215
|
}
|
|
206
216
|
return null;
|
|
207
217
|
})(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), shapePreview && (_jsxs(_Fragment, { children: [_jsx(Path, { path: shapePreview.path, color: shapePreview.color, style: "stroke", strokeWidth: shapePreview.width, strokeCap: toSkiaStrokeCap(shapePreview.cap), strokeJoin: "round", children: shapePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(shapePreview.width) })) }), _jsx(Path, { path: shapePreview.headPath, color: shapePreview.color, style: "fill" })] })), livePreview?.handoffPaths?.map((p, i) => (_jsx(Path, { path: p, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) }, i))), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) })), customPreview] }) }));
|
|
@@ -39,6 +39,7 @@ export interface FreehandConfig {
|
|
|
39
39
|
color: string;
|
|
40
40
|
width: number;
|
|
41
41
|
cap?: StrokeCap;
|
|
42
|
+
startCap?: StrokeCap;
|
|
42
43
|
dash?: boolean;
|
|
43
44
|
minSampleDistance: number;
|
|
44
45
|
}
|
|
@@ -47,6 +48,7 @@ export interface ShapeDrawConfig {
|
|
|
47
48
|
color: string;
|
|
48
49
|
width: number;
|
|
49
50
|
cap?: StrokeCap;
|
|
51
|
+
startCap?: StrokeCap;
|
|
50
52
|
dash?: boolean;
|
|
51
53
|
}
|
|
52
54
|
export type DragElementKind = 'stroke' | 'shape' | 'measurement';
|
|
@@ -68,6 +70,12 @@ export interface DragSelectionConfig {
|
|
|
68
70
|
fixed: Vec2;
|
|
69
71
|
} | null;
|
|
70
72
|
buildRectCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
|
|
73
|
+
hitTestShapeCorner?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): {
|
|
74
|
+
corner: RectCorner;
|
|
75
|
+
moving: Vec2;
|
|
76
|
+
fixed: Vec2;
|
|
77
|
+
} | null;
|
|
78
|
+
buildShapeCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
|
|
71
79
|
isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): boolean;
|
|
72
80
|
hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
|
|
73
81
|
buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
|
|
@@ -45,34 +45,54 @@ export const ShapeElement = memo(({ shape, font, textFontMgr }) => {
|
|
|
45
45
|
// historical behavior).
|
|
46
46
|
const closed = geometry.closed !== false;
|
|
47
47
|
const polyPath = useMemo(() => (kind === 'polygon' ? polygonPath(geometry.points, closed) : null), [kind, geometry.points, closed]);
|
|
48
|
-
// Solid filled-triangle
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
48
|
+
// Solid filled-triangle arrowheads. The END head is drawn for the legacy
|
|
49
|
+
// 'arrow' kind, for a 'line' with cap === 'arrow' (the arrow tool and the
|
|
50
|
+
// end-cap editor both produce this), and for an OPEN polygon with cap ===
|
|
51
|
+
// 'arrow'. The START head is drawn for a 'line'/'arrow' or open polygon with
|
|
52
|
+
// startCap === 'arrow' (so a line can be headed at either end or both).
|
|
53
|
+
// Mirrors StrokeElement.
|
|
54
|
+
const arrowHeads = useMemo(() => {
|
|
55
|
+
// The two anchor segments: [endTip, endFrom] points the end head, and
|
|
56
|
+
// [startTip, startFrom] points the start head (pointing back out of the
|
|
57
|
+
// first point). For an open polygon those are the last/first segments.
|
|
58
|
+
let endTip;
|
|
59
|
+
let endFrom;
|
|
60
|
+
let startTip;
|
|
61
|
+
let startFrom;
|
|
62
|
+
if (kind === 'line' || kind === 'arrow') {
|
|
63
|
+
const [a, b] = geometry.points;
|
|
64
|
+
endTip = b;
|
|
65
|
+
endFrom = a;
|
|
66
|
+
startTip = a;
|
|
67
|
+
startFrom = b;
|
|
58
68
|
}
|
|
59
|
-
else if (kind === 'polygon' && !closed
|
|
69
|
+
else if (kind === 'polygon' && !closed) {
|
|
60
70
|
const n = geometry.points.length;
|
|
61
71
|
if (n >= 2) {
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
endTip = geometry.points[n - 1];
|
|
73
|
+
endFrom = geometry.points[n - 2];
|
|
74
|
+
startTip = geometry.points[0];
|
|
75
|
+
startFrom = geometry.points[1];
|
|
64
76
|
}
|
|
65
77
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
const head = (tip, from) => {
|
|
79
|
+
if (!tip || !from || (tip.x === from.x && tip.y === from.y))
|
|
80
|
+
return null;
|
|
81
|
+
const [apex, baseL, baseR] = arrowheadTriangle(tip, from, strokeWidth);
|
|
82
|
+
const p = Skia.Path.Make();
|
|
83
|
+
p.moveTo(apex.x, apex.y);
|
|
84
|
+
p.lineTo(baseL.x, baseL.y);
|
|
85
|
+
p.lineTo(baseR.x, baseR.y);
|
|
86
|
+
p.close();
|
|
87
|
+
return p;
|
|
88
|
+
};
|
|
89
|
+
const wantEnd = kind === 'arrow' || style.cap === 'arrow';
|
|
90
|
+
const wantStart = style.startCap === 'arrow';
|
|
91
|
+
return {
|
|
92
|
+
end: wantEnd ? head(endTip, endFrom) : null,
|
|
93
|
+
start: wantStart ? head(startTip, startFrom) : null,
|
|
94
|
+
};
|
|
95
|
+
}, [kind, geometry.points, closed, style.cap, style.startCap, strokeWidth]);
|
|
76
96
|
// Text shapes render through the Skia Paragraph API so decorations (solid
|
|
77
97
|
// underline / wavy squiggle / highlight band) paint natively. The loaded font
|
|
78
98
|
// is registered into a provider so the Paragraph draws with the same typeface
|
|
@@ -171,12 +191,12 @@ export const ShapeElement = memo(({ shape, font, textFontMgr }) => {
|
|
|
171
191
|
const [a, b] = geometry.points;
|
|
172
192
|
if (!a || !b)
|
|
173
193
|
return null;
|
|
174
|
-
return (_jsxs(_Fragment, { children: [_jsx(Line, { p1: a, p2: b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(style.cap), children: dashEffect }),
|
|
194
|
+
return (_jsxs(_Fragment, { children: [_jsx(Line, { p1: a, p2: b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(style.cap), children: dashEffect }), arrowHeads.start && (_jsx(Path, { path: arrowHeads.start, color: stroke, style: "fill" })), arrowHeads.end && (_jsx(Path, { path: arrowHeads.end, color: stroke, style: "fill" }))] }));
|
|
175
195
|
}
|
|
176
196
|
case 'polygon': {
|
|
177
197
|
if (!polyPath)
|
|
178
198
|
return null;
|
|
179
|
-
return (_jsxs(_Fragment, { children: [fill && closed && _jsx(Path, { path: polyPath, color: fill }), _jsx(Path, { path: polyPath, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(style.cap), strokeJoin: "round", children: dashEffect }),
|
|
199
|
+
return (_jsxs(_Fragment, { children: [fill && closed && _jsx(Path, { path: polyPath, color: fill }), _jsx(Path, { path: polyPath, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(style.cap), strokeJoin: "round", children: dashEffect }), arrowHeads.start && (_jsx(Path, { path: arrowHeads.start, color: stroke, style: "fill" })), arrowHeads.end && (_jsx(Path, { path: arrowHeads.end, color: stroke, style: "fill" }))] }));
|
|
180
200
|
}
|
|
181
201
|
case 'text': {
|
|
182
202
|
// `origin` is the TOP-LEFT of the text block; textGeometry derives the
|
|
@@ -12,16 +12,13 @@ export const pointsToSkPath = (points) => {
|
|
|
12
12
|
}
|
|
13
13
|
return path;
|
|
14
14
|
};
|
|
15
|
-
// A solid filled-triangle arrowhead at
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
if (stroke.cap !== 'arrow' || n < 4)
|
|
15
|
+
// A solid filled-triangle arrowhead at `tip`, with its base back toward `from`,
|
|
16
|
+
// rendered as a separate filled <Path> on top of the stroked line. Null when
|
|
17
|
+
// the two points coincide (e.g. a tap-dot), which has no direction to point.
|
|
18
|
+
const headPath = (tip, from, width) => {
|
|
19
|
+
if (tip.x === from.x && tip.y === from.y)
|
|
21
20
|
return null;
|
|
22
|
-
const
|
|
23
|
-
const from = { x: stroke.points[n - 4], y: stroke.points[n - 3] };
|
|
24
|
-
const [apex, baseL, baseR] = arrowheadTriangle(end, from, stroke.width);
|
|
21
|
+
const [apex, baseL, baseR] = arrowheadTriangle(tip, from, width);
|
|
25
22
|
const path = Skia.Path.Make();
|
|
26
23
|
path.moveTo(apex.x, apex.y);
|
|
27
24
|
path.lineTo(baseL.x, baseL.y);
|
|
@@ -29,17 +26,35 @@ const arrowheadPath = (stroke) => {
|
|
|
29
26
|
path.close();
|
|
30
27
|
return path;
|
|
31
28
|
};
|
|
29
|
+
// Arrowheads at the stroke's end (cap) and/or start (startCap). Each is null
|
|
30
|
+
// unless the corresponding cap is 'arrow'. The start head points back out of
|
|
31
|
+
// the first point (base toward the second).
|
|
32
|
+
const arrowheadPaths = (stroke) => {
|
|
33
|
+
const n = stroke.points.length;
|
|
34
|
+
if (n < 4)
|
|
35
|
+
return { start: null, end: null };
|
|
36
|
+
const first = { x: stroke.points[0], y: stroke.points[1] };
|
|
37
|
+
const second = { x: stroke.points[2], y: stroke.points[3] };
|
|
38
|
+
const last = { x: stroke.points[n - 2], y: stroke.points[n - 1] };
|
|
39
|
+
const penult = { x: stroke.points[n - 4], y: stroke.points[n - 3] };
|
|
40
|
+
return {
|
|
41
|
+
end: stroke.cap === 'arrow' ? headPath(last, penult, stroke.width) : null,
|
|
42
|
+
start: stroke.startCap === 'arrow'
|
|
43
|
+
? headPath(first, second, stroke.width)
|
|
44
|
+
: null,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
32
47
|
// Memoized: the canvas re-renders the whole element list on every commit,
|
|
33
48
|
// preview, and selection change, but applyPatch preserves the identity of
|
|
34
49
|
// unchanged strokes — so memo lets all but the changed stroke bail out.
|
|
35
50
|
export const StrokeElement = memo(({ stroke }) => {
|
|
36
51
|
const path = useMemo(() => pointsToSkPath(stroke.points), [stroke.points]);
|
|
37
|
-
const
|
|
52
|
+
const arrows = useMemo(() => arrowheadPaths(stroke), [stroke.points, stroke.cap, stroke.startCap, stroke.width]);
|
|
38
53
|
const opacity = stroke.tool === 'highlighter' ? 0.3 : 1;
|
|
39
54
|
// A tap-dot is a zero-length two-point path; a dash effect would erase it
|
|
40
55
|
// (the contour has no length to dash), so dots always render solid.
|
|
41
56
|
const isDot = stroke.points.length === 4 &&
|
|
42
57
|
stroke.points[0] === stroke.points[2] &&
|
|
43
58
|
stroke.points[1] === stroke.points[3];
|
|
44
|
-
return (_jsxs(_Fragment, { children: [_jsx(Path, { path: path, color: stroke.color, style: "stroke", strokeWidth: stroke.width, strokeCap: toSkiaStrokeCap(stroke.cap), strokeJoin: "round", opacity: opacity, children: stroke.dash && !isDot && (_jsx(DashPathEffect, { intervals: dashIntervals(stroke.width) })) }),
|
|
59
|
+
return (_jsxs(_Fragment, { children: [_jsx(Path, { path: path, color: stroke.color, style: "stroke", strokeWidth: stroke.width, strokeCap: toSkiaStrokeCap(stroke.cap), strokeJoin: "round", opacity: opacity, children: stroke.dash && !isDot && (_jsx(DashPathEffect, { intervals: dashIntervals(stroke.width) })) }), arrows.start && (_jsx(Path, { path: arrows.start, color: stroke.color, style: "fill", opacity: opacity })), arrows.end && (_jsx(Path, { path: arrows.end, color: stroke.color, style: "fill", opacity: opacity }))] }));
|
|
45
60
|
});
|
|
@@ -3,3 +3,10 @@ export type ShapeToolKind = 'line' | 'rect' | 'triangle' | 'ellipse';
|
|
|
3
3
|
export declare const annotationKindFor: (kind: ShapeToolKind) => AnnotationShapeKind;
|
|
4
4
|
export declare const shapePointsFromDrag: (kind: ShapeToolKind, a: Vec2, b: Vec2) => Vec2[];
|
|
5
5
|
export declare const hitShapeOutline: (shape: AnnotationShape, p: Vec2, tol: number) => boolean;
|
|
6
|
+
export interface ShapeBounds {
|
|
7
|
+
minX: number;
|
|
8
|
+
minY: number;
|
|
9
|
+
maxX: number;
|
|
10
|
+
maxY: number;
|
|
11
|
+
}
|
|
12
|
+
export declare const visualShapeBounds: (shape: AnnotationShape) => ShapeBounds | null;
|
|
@@ -114,3 +114,39 @@ export const hitShapeOutline = (shape, p, tol) => {
|
|
|
114
114
|
return false;
|
|
115
115
|
}
|
|
116
116
|
};
|
|
117
|
+
// Axis-aligned bounds of a shape AS RENDERED — the source of truth for the
|
|
118
|
+
// selection box, its corner-resize handles, and the corner hit-test, so all
|
|
119
|
+
// three agree on where the shape's edges are. For most kinds this is just the
|
|
120
|
+
// bounding box of the geometry points, but an ellipse renders as a CIRCLE
|
|
121
|
+
// (radius = half its larger extent, centered on the drag midpoint), so its
|
|
122
|
+
// visual bounds are the square that circle inscribes — otherwise the selection
|
|
123
|
+
// box hugs the raw (often non-square) drag rect instead of the visible circle.
|
|
124
|
+
// Returns null for text and degenerate (< 2 point) geometry; text shapes box
|
|
125
|
+
// from textGeometry's estimated bounds and lines draw handles on their
|
|
126
|
+
// endpoints, so neither routes through here.
|
|
127
|
+
export const visualShapeBounds = (shape) => {
|
|
128
|
+
const pts = shape.geometry.points;
|
|
129
|
+
if (pts.length < 2)
|
|
130
|
+
return null;
|
|
131
|
+
let minX = Infinity;
|
|
132
|
+
let minY = Infinity;
|
|
133
|
+
let maxX = -Infinity;
|
|
134
|
+
let maxY = -Infinity;
|
|
135
|
+
for (const p of pts) {
|
|
136
|
+
if (p.x < minX)
|
|
137
|
+
minX = p.x;
|
|
138
|
+
if (p.y < minY)
|
|
139
|
+
minY = p.y;
|
|
140
|
+
if (p.x > maxX)
|
|
141
|
+
maxX = p.x;
|
|
142
|
+
if (p.y > maxY)
|
|
143
|
+
maxY = p.y;
|
|
144
|
+
}
|
|
145
|
+
if (shape.kind === 'ellipse') {
|
|
146
|
+
const cx = (minX + maxX) / 2;
|
|
147
|
+
const cy = (minY + maxY) / 2;
|
|
148
|
+
const r = Math.max(maxX - minX, maxY - minY) / 2;
|
|
149
|
+
return { minX: cx - r, minY: cy - r, maxX: cx + r, maxY: cy + r };
|
|
150
|
+
}
|
|
151
|
+
return { minX, minY, maxX, maxY };
|
|
152
|
+
};
|
|
@@ -10,6 +10,7 @@ export const createPenTool = (options = {}) => {
|
|
|
10
10
|
const color = options.color ?? '#111827';
|
|
11
11
|
const width = options.width ?? 2;
|
|
12
12
|
const cap = options.cap ?? 'round';
|
|
13
|
+
const startCap = options.startCap ?? 'round';
|
|
13
14
|
const dash = options.dash ?? false;
|
|
14
15
|
const variant = options.variant ?? 'pen';
|
|
15
16
|
const minSampleDistance = options.minSampleDistance ?? 1.5;
|
|
@@ -28,7 +29,15 @@ export const createPenTool = (options = {}) => {
|
|
|
28
29
|
cursor: 'crosshair',
|
|
29
30
|
// Drives UI-thread drawing on native (see FreehandConfig). The
|
|
30
31
|
// onPointerDown/Move/Up below remain the web/parity implementation.
|
|
31
|
-
freehand: {
|
|
32
|
+
freehand: {
|
|
33
|
+
variant,
|
|
34
|
+
color,
|
|
35
|
+
width,
|
|
36
|
+
cap,
|
|
37
|
+
...(startCap === 'arrow' && { startCap: 'arrow' }),
|
|
38
|
+
dash,
|
|
39
|
+
minSampleDistance,
|
|
40
|
+
},
|
|
32
41
|
onPointerDown(event, ctx) {
|
|
33
42
|
const stroke = {
|
|
34
43
|
id: makeId('stroke'),
|
|
@@ -37,6 +46,7 @@ export const createPenTool = (options = {}) => {
|
|
|
37
46
|
color,
|
|
38
47
|
width,
|
|
39
48
|
cap,
|
|
49
|
+
...(startCap === 'arrow' && { startCap: 'arrow' }),
|
|
40
50
|
...(dash && { dash }),
|
|
41
51
|
points: [event.world.x, event.world.y],
|
|
42
52
|
pressure: event.pressure !== undefined ? [event.pressure] : undefined,
|
|
@@ -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: {
|
|
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;
|
package/dist/types/annotation.js
CHANGED
|
@@ -12,8 +12,14 @@ export const createEmptyCanvasState = (viewport) => ({
|
|
|
12
12
|
viewport: {
|
|
13
13
|
width: viewport?.width ?? 1000,
|
|
14
14
|
height: viewport?.height ?? 1000,
|
|
15
|
-
|
|
16
|
-
|
|
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: [],
|