@reekon-tools/boldr-utils 1.19.6 → 1.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.js +10 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +214 -29
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +7 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +11 -2
- package/dist/annotation/canvas/Tool.d.ts +6 -0
- package/dist/annotation/canvas/snapGeometry.d.ts +32 -0
- package/dist/annotation/canvas/snapGeometry.js +168 -0
- package/dist/annotation/canvas/tools/measurementTool.js +38 -4
- package/dist/annotation/canvas/tools/selectTool.js +43 -1
- package/dist/annotation/canvas/tools/shapeTool.js +27 -5
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +16 -1
- package/dist/exports.d.ts +2 -1
- package/dist/exports.js +1 -0
- package/dist/types/firestore.d.ts +1 -0
- package/package.json +1 -1
|
@@ -22,6 +22,7 @@ export interface AnnotationCanvasInnerProps {
|
|
|
22
22
|
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
23
23
|
deferBackgroundToResolver?: boolean;
|
|
24
24
|
hideLinkBadges?: boolean;
|
|
25
|
+
snapToGeometry?: boolean;
|
|
25
26
|
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
26
27
|
requestTextInput?: RequestTextInput;
|
|
27
28
|
renderBackground?: RenderCanvasBackground;
|
|
@@ -6,6 +6,7 @@ import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
|
|
|
6
6
|
import { buildRemoveMeasurementOps, hitPlacedMeasurement, } from './measurementGeometry.js';
|
|
7
7
|
import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
|
|
8
8
|
import { stampTileDims } from './stampLayout.js';
|
|
9
|
+
import { SNAP_RING_PX, SNAP_RING_WIDTH_PX } from './snapGeometry.js';
|
|
9
10
|
// Screen-px radius of a measurement-annotation endpoint handle (matches the
|
|
10
11
|
// native HANDLE_RADIUS_PX). Divided by zoom for a constant on-screen size.
|
|
11
12
|
const HANDLE_PX = 7;
|
|
@@ -338,6 +339,15 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
338
339
|
? HANDLE_RING_PX / state.viewport.zoom
|
|
339
340
|
: undefined,
|
|
340
341
|
showPlaneHandles: activeTool?.planeEditing === true,
|
|
342
|
+
// Magnetic-snap ring for the JS-path drags (select / measurement /
|
|
343
|
+
// shape tools report the target through ctx.snap.setIndicator).
|
|
344
|
+
snapIndicator: state.snapIndicator
|
|
345
|
+
? {
|
|
346
|
+
c: state.snapIndicator,
|
|
347
|
+
r: SNAP_RING_PX / state.viewport.zoom,
|
|
348
|
+
ringWidth: SNAP_RING_WIDTH_PX / state.viewport.zoom,
|
|
349
|
+
}
|
|
350
|
+
: null,
|
|
341
351
|
customPreview,
|
|
342
352
|
}), renderMeasurementStamp && (_jsx("div", { style: {
|
|
343
353
|
position: 'absolute',
|
|
@@ -23,6 +23,7 @@ export interface AnnotationCanvasInnerProps {
|
|
|
23
23
|
resolveImageUrl?: (storagePath: string) => Promise<string>;
|
|
24
24
|
deferBackgroundToResolver?: boolean;
|
|
25
25
|
hideLinkBadges?: boolean;
|
|
26
|
+
snapToGeometry?: boolean;
|
|
26
27
|
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
27
28
|
requestTextInput?: RequestTextInput;
|
|
28
29
|
renderBackground?: RenderCanvasBackground;
|
|
@@ -11,6 +11,7 @@ import { MIN_BACKGROUND_DOC_EXTENT, backgroundLayersOf, backgroundLayerDocRect,
|
|
|
11
11
|
import { BackgroundSkImageLoader } from './elements/BackgroundImageElement.js';
|
|
12
12
|
import { buildRemoveMeasurementOps, linePosOf, } from './measurementGeometry.js';
|
|
13
13
|
import { planeEdgeLine, planeGridSegments, planeGridSegmentsForScale, resolvePlaneGridSpacingUm, } from './planeGeometry.js';
|
|
14
|
+
import { SNAP_RADIUS_PX, SNAP_RING_PX, SNAP_RING_WIDTH_PX, collectSnapTargetsFlat, snapDelta, snapNearest, } from './snapGeometry.js';
|
|
14
15
|
import { buildShapeFromDrag } from './tools/shapeTool.js';
|
|
15
16
|
import { SELECTION_PAD } from './textGeometry.js';
|
|
16
17
|
import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
|
|
@@ -64,7 +65,7 @@ const LOUPE_CROSS_WIDTH_PX = 1.5;
|
|
|
64
65
|
// is only re-synced once each gesture ends. The tool/tap path still hops to
|
|
65
66
|
// JS (`runOnJS(true)`) since drawing/selection are React-state operations.
|
|
66
67
|
export const AnnotationCanvasInner = (props) => {
|
|
67
|
-
const { resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, renderBackground, } = props;
|
|
68
|
+
const { resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, snapToGeometry = false, renderBackground, } = props;
|
|
68
69
|
const valueFont = useFont(stampFontSource, stampValueFontSize);
|
|
69
70
|
// The animated-viewport implementation needs the Reanimated shared values,
|
|
70
71
|
// which are created below AFTER the state hook — so the hook gets a stable
|
|
@@ -293,7 +294,10 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
293
294
|
// owned by the UI thread — the shape twin of `livePoints`. The drag worklet
|
|
294
295
|
// tracks the start/current world points; derived paths below render the
|
|
295
296
|
// live shape. React is untouched until the single commit on release.
|
|
296
|
-
|
|
297
|
+
// `snapR` is the doc-space magnetic-snap radius frozen at press (0 = off)
|
|
298
|
+
// and `snapped` whether the live end is currently locked onto a target
|
|
299
|
+
// (drives the snap ring) — see snapGeometry.ts.
|
|
300
|
+
const liveShape = useSharedValue({ active: false, ax: 0, ay: 0, bx: 0, by: 0, snapR: 0, snapped: false });
|
|
297
301
|
// Released shapes awaiting their committed paint — the shape twin of
|
|
298
302
|
// `handoffStrokes`, with the same lifecycle: appended in the gesture's
|
|
299
303
|
// onEnd worklet tick, released by the paint-watch effect one rAF after the
|
|
@@ -317,6 +321,28 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
317
321
|
width: shapeDraw.width,
|
|
318
322
|
};
|
|
319
323
|
}, [shapeDraw, shapeCfg]);
|
|
324
|
+
// Magnetic snapping for line endpoints (snapGeometry.ts). `snapCfg` mirrors
|
|
325
|
+
// the `snapToGeometry` prop for the worklets (derived values and gesture
|
|
326
|
+
// callbacks are created once and can't close over a changing prop).
|
|
327
|
+
// `snapTargets` is the flat `[x, y, kind, …]` target buffer, written ONCE
|
|
328
|
+
// per drag — from JS in beginSelectDrag for endpoint drags (excluding the
|
|
329
|
+
// grabbed element), and by the effect below for the shape rubber-band,
|
|
330
|
+
// whose onBegin is a worklet with no doc access. The effect only runs while
|
|
331
|
+
// a line-kind shape tool is active, so it can never clobber a select drag's
|
|
332
|
+
// harvest; keyed on the COMMITTED doc so a just-drawn line becomes a target.
|
|
333
|
+
const snapCfg = useSharedValue({
|
|
334
|
+
enabled: false,
|
|
335
|
+
radiusPx: SNAP_RADIUS_PX,
|
|
336
|
+
});
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
snapCfg.value = { enabled: snapToGeometry, radiusPx: SNAP_RADIUS_PX };
|
|
339
|
+
}, [snapToGeometry, snapCfg]);
|
|
340
|
+
const snapTargets = useSharedValue([]);
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (!snapToGeometry || !shapeDraw || shapeDraw.kind !== 'line')
|
|
343
|
+
return;
|
|
344
|
+
snapTargets.value = collectSnapTargetsFlat(state.ctx.document);
|
|
345
|
+
}, [snapToGeometry, shapeDraw, state.ctx.document, snapTargets]);
|
|
320
346
|
// Outline of the live rubber-band + every pending handoff shape, as one
|
|
321
347
|
// stroked path. WORKLET TWIN of shapeGeometry.shapePointsFromDrag +
|
|
322
348
|
// ShapeElement's per-kind rendering — keep in sync. Merging into one path
|
|
@@ -456,7 +482,9 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
456
482
|
// (0 = a, 1 = b) is moving, so the Skia line + overlay tile can be driven on
|
|
457
483
|
// the UI thread from `dragX`/`dragY`.
|
|
458
484
|
const [epDragId, setEpDragId] = useState(null);
|
|
459
|
-
|
|
485
|
+
// `snapR`: doc-space magnetic-snap radius frozen at grab (0 = off) — see
|
|
486
|
+
// epLive below and snapGeometry.ts.
|
|
487
|
+
const epCtx = useSharedValue({ ax: 0, ay: 0, bx: 0, by: 0, t0: 0.5, handle: 0, snapR: 0 });
|
|
460
488
|
const epTargetRef = useRef(null);
|
|
461
489
|
// Endpoint drag of a line/arrow SHAPE (vs. a measurement line above).
|
|
462
490
|
// `shapeEpDragId` (React state) tells the Skia layer to render that shape's
|
|
@@ -468,19 +496,32 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
468
496
|
// Live line endpoints during an endpoint drag: the grabbed handle follows the
|
|
469
497
|
// finger (dragX/dragY); the other stays put. Fed to the Skia <Line> for the
|
|
470
498
|
// dragged annotation.
|
|
499
|
+
// The grabbed endpoint's live position: base + drag, then magnetically
|
|
500
|
+
// snapped to the nearest target within epCtx.snapR (0 = off). The SINGLE
|
|
501
|
+
// source for the live line, the tile overlay's follow math, the loupe
|
|
502
|
+
// crosshair and the snap ring — and the select gesture's onEnd recomputes
|
|
503
|
+
// the same snap (snapDelta, same inputs) so the commit matches it exactly.
|
|
504
|
+
const epLive = useDerivedValue(() => {
|
|
505
|
+
'worklet';
|
|
506
|
+
const c = epCtx.value;
|
|
507
|
+
const mx = (c.handle === 0 ? c.ax : c.bx) + dragX.value;
|
|
508
|
+
const my = (c.handle === 0 ? c.ay : c.by) + dragY.value;
|
|
509
|
+
const hit = c.snapR > 0 ? snapNearest(mx, my, snapTargets.value, c.snapR) : null;
|
|
510
|
+
return hit
|
|
511
|
+
? { x: hit.x, y: hit.y, snapped: true }
|
|
512
|
+
: { x: mx, y: my, snapped: false };
|
|
513
|
+
});
|
|
471
514
|
const liveLineP1 = useDerivedValue(() => {
|
|
472
515
|
'worklet';
|
|
473
516
|
const c = epCtx.value;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
: { x: c.ax, y: c.ay };
|
|
517
|
+
const e = epLive.value;
|
|
518
|
+
return c.handle === 0 ? { x: e.x, y: e.y } : { x: c.ax, y: c.ay };
|
|
477
519
|
});
|
|
478
520
|
const liveLineP2 = useDerivedValue(() => {
|
|
479
521
|
'worklet';
|
|
480
522
|
const c = epCtx.value;
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
: { x: c.bx, y: c.by };
|
|
523
|
+
const e = epLive.value;
|
|
524
|
+
return c.handle === 1 ? { x: e.x, y: e.y } : { x: c.bx, y: c.by };
|
|
484
525
|
});
|
|
485
526
|
// Screen-constant endpoint handle radius: a fixed pixel size divided by the
|
|
486
527
|
// live zoom (the handles are drawn inside the zoom-scaled world group).
|
|
@@ -488,6 +529,45 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
488
529
|
'worklet';
|
|
489
530
|
return HANDLE_RADIUS_PX / zoom.value;
|
|
490
531
|
});
|
|
532
|
+
// Snap ring (magnetic endpoint snapping). Three sources, in priority order:
|
|
533
|
+
// the UI-thread shape rubber-band, the UI-thread endpoint drag (gated off
|
|
534
|
+
// once the gesture ends or cancels), and the JS-path tools (measurement /
|
|
535
|
+
// pointer-path drags), whose ctx.snap.setIndicator lands in
|
|
536
|
+
// state.snapIndicator and is mirrored into a shared value here. Visibility
|
|
537
|
+
// is an opacity so nothing remounts per frame.
|
|
538
|
+
const jsSnapIndicator = useSharedValue(null);
|
|
539
|
+
useEffect(() => {
|
|
540
|
+
jsSnapIndicator.value = state.snapIndicator;
|
|
541
|
+
}, [state.snapIndicator, jsSnapIndicator]);
|
|
542
|
+
const snapIndicatorC = useDerivedValue(() => {
|
|
543
|
+
'worklet';
|
|
544
|
+
const sh = liveShape.value;
|
|
545
|
+
if (sh.active && sh.snapped)
|
|
546
|
+
return { x: sh.bx, y: sh.by };
|
|
547
|
+
const e = epLive.value;
|
|
548
|
+
if (e.snapped && !dragEnded.value)
|
|
549
|
+
return { x: e.x, y: e.y };
|
|
550
|
+
const j = jsSnapIndicator.value;
|
|
551
|
+
return j ? { x: j.x, y: j.y } : { x: 0, y: 0 };
|
|
552
|
+
});
|
|
553
|
+
const snapIndicatorOpacity = useDerivedValue(() => {
|
|
554
|
+
'worklet';
|
|
555
|
+
const sh = liveShape.value;
|
|
556
|
+
if (sh.active && sh.snapped)
|
|
557
|
+
return 1;
|
|
558
|
+
const e = epLive.value;
|
|
559
|
+
if (e.snapped && !dragEnded.value)
|
|
560
|
+
return 1;
|
|
561
|
+
return jsSnapIndicator.value ? 1 : 0;
|
|
562
|
+
});
|
|
563
|
+
const snapRingRadius = useDerivedValue(() => {
|
|
564
|
+
'worklet';
|
|
565
|
+
return SNAP_RING_PX / zoom.value;
|
|
566
|
+
});
|
|
567
|
+
const snapRingWidth = useDerivedValue(() => {
|
|
568
|
+
'worklet';
|
|
569
|
+
return SNAP_RING_WIDTH_PX / zoom.value;
|
|
570
|
+
});
|
|
491
571
|
const handleRingWidth = useDerivedValue(() => {
|
|
492
572
|
'worklet';
|
|
493
573
|
return HANDLE_RING_PX / zoom.value;
|
|
@@ -899,9 +979,9 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
899
979
|
else if (loupeCrossMode === 'endpoint') {
|
|
900
980
|
// Measurement-line and shape line/arrow endpoint drags both stage
|
|
901
981
|
// their line in epCtx; the grabbed end follows the finger.
|
|
902
|
-
const
|
|
903
|
-
wx =
|
|
904
|
-
wy =
|
|
982
|
+
const e = epLive.value;
|
|
983
|
+
wx = e.x;
|
|
984
|
+
wy = e.y;
|
|
905
985
|
}
|
|
906
986
|
else if (loupeCrossMode === 'rect-corner') {
|
|
907
987
|
const c = rectCtx.value;
|
|
@@ -1402,9 +1482,29 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1402
1482
|
.maxPointers(1)
|
|
1403
1483
|
.onBegin((e) => {
|
|
1404
1484
|
'worklet';
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1485
|
+
let wx = e.x / zoom.value + panX.value;
|
|
1486
|
+
let wy = e.y / zoom.value + panY.value;
|
|
1487
|
+
// Magnetic snap (line tool only): freeze the doc-space radius for
|
|
1488
|
+
// this drag and snap the START point too — the WORKLET counterpart
|
|
1489
|
+
// of shapeTool's snapStateForPress/snapWorld (targets come from the
|
|
1490
|
+
// doc-synced `snapTargets` effect).
|
|
1491
|
+
const snapR = cfg.kind === 'line' && snapCfg.value.enabled
|
|
1492
|
+
? snapCfg.value.radiusPx / zoom.value
|
|
1493
|
+
: 0;
|
|
1494
|
+
const hit = snapR > 0 ? snapNearest(wx, wy, snapTargets.value, snapR) : null;
|
|
1495
|
+
if (hit) {
|
|
1496
|
+
wx = hit.x;
|
|
1497
|
+
wy = hit.y;
|
|
1498
|
+
}
|
|
1499
|
+
liveShape.value = {
|
|
1500
|
+
active: true,
|
|
1501
|
+
ax: wx,
|
|
1502
|
+
ay: wy,
|
|
1503
|
+
bx: wx,
|
|
1504
|
+
by: wy,
|
|
1505
|
+
snapR,
|
|
1506
|
+
snapped: hit !== null,
|
|
1507
|
+
};
|
|
1408
1508
|
magTouchX.value = e.x;
|
|
1409
1509
|
magTouchY.value = e.y;
|
|
1410
1510
|
runOnJS(setMagnifying)(true);
|
|
@@ -1416,12 +1516,23 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1416
1516
|
return;
|
|
1417
1517
|
magTouchX.value = e.x;
|
|
1418
1518
|
magTouchY.value = e.y;
|
|
1519
|
+
let bx = e.x / zoom.value + panX.value;
|
|
1520
|
+
let by = e.y / zoom.value + panY.value;
|
|
1521
|
+
const hit = s.snapR > 0
|
|
1522
|
+
? snapNearest(bx, by, snapTargets.value, s.snapR)
|
|
1523
|
+
: null;
|
|
1524
|
+
if (hit) {
|
|
1525
|
+
bx = hit.x;
|
|
1526
|
+
by = hit.y;
|
|
1527
|
+
}
|
|
1419
1528
|
liveShape.value = {
|
|
1420
1529
|
active: true,
|
|
1421
1530
|
ax: s.ax,
|
|
1422
1531
|
ay: s.ay,
|
|
1423
|
-
bx
|
|
1424
|
-
by
|
|
1532
|
+
bx,
|
|
1533
|
+
by,
|
|
1534
|
+
snapR: s.snapR,
|
|
1535
|
+
snapped: hit !== null,
|
|
1425
1536
|
};
|
|
1426
1537
|
})
|
|
1427
1538
|
.onEnd(() => {
|
|
@@ -1434,7 +1545,15 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1434
1545
|
const dxPx = (s.bx - s.ax) * zoom.value;
|
|
1435
1546
|
const dyPx = (s.by - s.ay) * zoom.value;
|
|
1436
1547
|
if (dxPx * dxPx + dyPx * dyPx < minDragSq) {
|
|
1437
|
-
liveShape.value = {
|
|
1548
|
+
liveShape.value = {
|
|
1549
|
+
active: false,
|
|
1550
|
+
ax: 0,
|
|
1551
|
+
ay: 0,
|
|
1552
|
+
bx: 0,
|
|
1553
|
+
by: 0,
|
|
1554
|
+
snapR: 0,
|
|
1555
|
+
snapped: false,
|
|
1556
|
+
};
|
|
1438
1557
|
return;
|
|
1439
1558
|
}
|
|
1440
1559
|
// Hand off in THIS worklet tick (see the stroke onEnd): the live
|
|
@@ -1454,14 +1573,30 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1454
1573
|
by: s.by,
|
|
1455
1574
|
},
|
|
1456
1575
|
];
|
|
1457
|
-
liveShape.value = {
|
|
1576
|
+
liveShape.value = {
|
|
1577
|
+
active: false,
|
|
1578
|
+
ax: 0,
|
|
1579
|
+
ay: 0,
|
|
1580
|
+
bx: 0,
|
|
1581
|
+
by: 0,
|
|
1582
|
+
snapR: 0,
|
|
1583
|
+
snapped: false,
|
|
1584
|
+
};
|
|
1458
1585
|
runOnJS(commitShape)(id, { x: s.ax, y: s.ay }, { x: s.bx, y: s.by });
|
|
1459
1586
|
})
|
|
1460
1587
|
.onFinalize(() => {
|
|
1461
1588
|
'worklet';
|
|
1462
1589
|
// Cancel path (no onEnd): drop the in-flight rubber-band. After a
|
|
1463
1590
|
// normal end the slot is already inactive, so this is a no-op.
|
|
1464
|
-
liveShape.value = {
|
|
1591
|
+
liveShape.value = {
|
|
1592
|
+
active: false,
|
|
1593
|
+
ax: 0,
|
|
1594
|
+
ay: 0,
|
|
1595
|
+
bx: 0,
|
|
1596
|
+
by: 0,
|
|
1597
|
+
snapR: 0,
|
|
1598
|
+
snapped: false,
|
|
1599
|
+
};
|
|
1465
1600
|
runOnJS(setMagnifying)(false);
|
|
1466
1601
|
});
|
|
1467
1602
|
};
|
|
@@ -1487,6 +1622,16 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1487
1622
|
if (handle) {
|
|
1488
1623
|
const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
|
|
1489
1624
|
if (m?.line) {
|
|
1625
|
+
// Magnetic snap: harvest every OTHER element's targets once and
|
|
1626
|
+
// freeze the capture radius in doc space at the grab zoom (the
|
|
1627
|
+
// JS twin is selectTool's snapStateForGrab). Same JS tick as the
|
|
1628
|
+
// epCtx write, so both land in the same UI batch.
|
|
1629
|
+
const snapR = st.ctx.snap?.enabled
|
|
1630
|
+
? st.ctx.snap.radiusPx / zoomNow
|
|
1631
|
+
: 0;
|
|
1632
|
+
if (snapR > 0) {
|
|
1633
|
+
snapTargets.value = collectSnapTargetsFlat(st.ctx.document, selId);
|
|
1634
|
+
}
|
|
1490
1635
|
epCtx.value = {
|
|
1491
1636
|
ax: m.line.a.x,
|
|
1492
1637
|
ay: m.line.a.y,
|
|
@@ -1494,6 +1639,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1494
1639
|
by: m.line.b.y,
|
|
1495
1640
|
t0: m.linePos ?? 0.5,
|
|
1496
1641
|
handle: handle === 'a' ? 0 : 1,
|
|
1642
|
+
snapR,
|
|
1497
1643
|
};
|
|
1498
1644
|
epTargetRef.current = { id: selId, handle };
|
|
1499
1645
|
setEpDragId(selId);
|
|
@@ -1509,6 +1655,12 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1509
1655
|
const a = s?.geometry.points[0];
|
|
1510
1656
|
const b = s?.geometry.points[1];
|
|
1511
1657
|
if (a && b) {
|
|
1658
|
+
const snapR = st.ctx.snap?.enabled
|
|
1659
|
+
? st.ctx.snap.radiusPx / zoomNow
|
|
1660
|
+
: 0;
|
|
1661
|
+
if (snapR > 0) {
|
|
1662
|
+
snapTargets.value = collectSnapTargetsFlat(st.ctx.document, selId);
|
|
1663
|
+
}
|
|
1512
1664
|
epCtx.value = {
|
|
1513
1665
|
ax: a.x,
|
|
1514
1666
|
ay: a.y,
|
|
@@ -1516,6 +1668,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1516
1668
|
by: b.y,
|
|
1517
1669
|
t0: 0.5,
|
|
1518
1670
|
handle: shapeHandle === 'a' ? 0 : 1,
|
|
1671
|
+
snapR,
|
|
1519
1672
|
};
|
|
1520
1673
|
shapeEpTargetRef.current = { id: selId, handle: shapeHandle };
|
|
1521
1674
|
setShapeEpDragId(selId);
|
|
@@ -1794,6 +1947,10 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1794
1947
|
dragEnded.value = false;
|
|
1795
1948
|
dragX.value = 0;
|
|
1796
1949
|
dragY.value = 0;
|
|
1950
|
+
// A stale snap radius from a previous endpoint drag must not bend
|
|
1951
|
+
// a move/slide's delta in onEnd; beginSelectDrag re-arms it below
|
|
1952
|
+
// (queued after this write) when this grab IS an endpoint.
|
|
1953
|
+
epCtx.value = { ...epCtx.value, snapR: 0 };
|
|
1797
1954
|
// The loupe (turned on by beginSelectDrag only for precision sub-
|
|
1798
1955
|
// drags) follows the live finger, not the hit-test origin.
|
|
1799
1956
|
magTouchX.value = e.x;
|
|
@@ -1817,12 +1974,27 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1817
1974
|
.onEnd(() => {
|
|
1818
1975
|
'worklet';
|
|
1819
1976
|
dragEnded.value = true;
|
|
1820
|
-
|
|
1977
|
+
let dx = dragX.value;
|
|
1978
|
+
let dy = dragY.value;
|
|
1979
|
+
// Endpoint drag with snapping armed: hand JS the EFFECTIVE delta
|
|
1980
|
+
// (the one landing on the snapped target) — same inputs as epLive,
|
|
1981
|
+
// so the commit matches the live preview and the delta-based patch
|
|
1982
|
+
// builders stay untouched.
|
|
1983
|
+
const c = epCtx.value;
|
|
1984
|
+
if (c.snapR > 0) {
|
|
1985
|
+
const base = c.handle === 0 ? { x: c.ax, y: c.ay } : { x: c.bx, y: c.by };
|
|
1986
|
+
const r = snapDelta(base, { x: dx, y: dy }, snapTargets.value, c.snapR);
|
|
1987
|
+
dx = r.delta.x;
|
|
1988
|
+
dy = r.delta.y;
|
|
1989
|
+
}
|
|
1990
|
+
runOnJS(endSelectDrag)(dx, dy);
|
|
1821
1991
|
})
|
|
1822
1992
|
.onFinalize(() => {
|
|
1823
1993
|
'worklet';
|
|
1824
1994
|
if (!dragEnded.value)
|
|
1825
1995
|
runOnJS(cancelSelectDrag)();
|
|
1996
|
+
// Also closes the snap-ring gate on the cancel path.
|
|
1997
|
+
dragEnded.value = true;
|
|
1826
1998
|
});
|
|
1827
1999
|
};
|
|
1828
2000
|
// Plane tool: a hybrid pan. Handle drags run on the UI thread (shared
|
|
@@ -2022,6 +2194,8 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
2022
2194
|
epCtx,
|
|
2023
2195
|
resizeCtx,
|
|
2024
2196
|
rectCtx,
|
|
2197
|
+
snapCfg,
|
|
2198
|
+
snapTargets,
|
|
2025
2199
|
]);
|
|
2026
2200
|
const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
|
|
2027
2201
|
const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
|
|
@@ -2099,6 +2273,15 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
2099
2273
|
shapeEndpointDragId: shapeEpDragId,
|
|
2100
2274
|
liveLineP1,
|
|
2101
2275
|
liveLineP2,
|
|
2276
|
+
// Magnetic-snap ring; rides into the loupe pass via the spread below.
|
|
2277
|
+
snapIndicator: snapToGeometry
|
|
2278
|
+
? {
|
|
2279
|
+
c: snapIndicatorC,
|
|
2280
|
+
r: snapRingRadius,
|
|
2281
|
+
ringWidth: snapRingWidth,
|
|
2282
|
+
opacity: snapIndicatorOpacity,
|
|
2283
|
+
}
|
|
2284
|
+
: null,
|
|
2102
2285
|
bgCropId,
|
|
2103
2286
|
liveBgCrop: {
|
|
2104
2287
|
x: liveBgCropX,
|
|
@@ -2157,7 +2340,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
2157
2340
|
// non-fullscreen canvas (e.g. a diagram strip) escape into surrounding UI.
|
|
2158
2341
|
_jsxs(GestureHandlerRootView, { style: [{ width, height, overflow: 'hidden' }, style], children: [backgroundLayers.map((layer) => (_jsx(BackgroundSkImageLoader, { layer: layer, resolveUrl: resolveImageUrl, deferToResolver: deferBackgroundToResolver, onImage: onBackgroundSkImage }, layer.id))), _jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia(skiaProps) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
|
|
2159
2342
|
? (state.measurementsById.get(placed.measurementId) ?? null)
|
|
2160
|
-
: (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, planeEdgeDragging: planeDrag != null && placed.planeId === planeDrag.planeId, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, planeTileCtx: planeTileCtx, planeDragTX: planeDragTX, planeDragTY: planeDragTY, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, headerTileScaleFactor: state.headerTileScaleFactor, fileTileScaleFactor: state.fileTileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
|
|
2343
|
+
: (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, planeEdgeDragging: planeDrag != null && placed.planeId === planeDrag.planeId, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, epLive: epLive, rectCtx: rectCtx, planeTileCtx: planeTileCtx, planeDragTX: planeDragTX, planeDragTY: planeDragTY, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, headerTileScaleFactor: state.headerTileScaleFactor, fileTileScaleFactor: state.fileTileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
|
|
2161
2344
|
? () => {
|
|
2162
2345
|
const defaultRemove = () => {
|
|
2163
2346
|
const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
|
|
@@ -2184,7 +2367,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
2184
2367
|
measurementLineScale: 1 / LOUPE_MAGNIFICATION,
|
|
2185
2368
|
}) }))] }));
|
|
2186
2369
|
};
|
|
2187
|
-
const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, planeEdgeDragging, planeTileCtx, planeDragTX, planeDragTY, renderMeasurementStamp, tileScaleFactor, headerTileScaleFactor, fileTileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
|
|
2370
|
+
const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, epLive, rectCtx, planeEdgeDragging, planeTileCtx, planeDragTX, planeDragTY, renderMeasurementStamp, tileScaleFactor, headerTileScaleFactor, fileTileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
|
|
2188
2371
|
// Square tile or wide group-header pill — one footprint source of truth
|
|
2189
2372
|
// shared with the hit-test (stampTileDims).
|
|
2190
2373
|
const { width, height } = stampTileDims(placed, tileScaleFactor, tileViewportScale, headerTileScaleFactor ?? tileScaleFactor, fileTileScaleFactor ?? tileScaleFactor);
|
|
@@ -2260,13 +2443,15 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
|
|
|
2260
2443
|
worldY = c.ay + aby * t;
|
|
2261
2444
|
}
|
|
2262
2445
|
else if (endpointDragging) {
|
|
2263
|
-
// Tile stays at its linePos along the LIVE line:
|
|
2264
|
-
//
|
|
2446
|
+
// Tile stays at its linePos along the LIVE line: the moving endpoint
|
|
2447
|
+
// (handle 0 = a, 1 = b) comes from epLive — already drag-offset and
|
|
2448
|
+
// magnetically snapped — then lerp at t0.
|
|
2265
2449
|
const c = epCtx.value;
|
|
2266
|
-
const
|
|
2267
|
-
const
|
|
2268
|
-
const
|
|
2269
|
-
const
|
|
2450
|
+
const e = epLive.value;
|
|
2451
|
+
const ax = c.handle === 0 ? e.x : c.ax;
|
|
2452
|
+
const ay = c.handle === 0 ? e.y : c.ay;
|
|
2453
|
+
const bx = c.handle === 1 ? e.x : c.bx;
|
|
2454
|
+
const by = c.handle === 1 ? e.y : c.by;
|
|
2270
2455
|
worldX = ax + (bx - ax) * c.t0;
|
|
2271
2456
|
worldY = ay + (by - ay) * c.t0;
|
|
2272
2457
|
}
|
|
@@ -111,6 +111,12 @@ export interface AnnotationCanvasSkiaProps {
|
|
|
111
111
|
handleRingWidth?: number | {
|
|
112
112
|
value: number;
|
|
113
113
|
};
|
|
114
|
+
snapIndicator?: {
|
|
115
|
+
c: AnimatedPoint;
|
|
116
|
+
r: AnimatedNumber;
|
|
117
|
+
ringWidth: AnimatedNumber;
|
|
118
|
+
opacity?: AnimatedNumber;
|
|
119
|
+
} | null;
|
|
114
120
|
showPlaneHandles?: boolean;
|
|
115
121
|
planeDragId?: string | null;
|
|
116
122
|
planeDragIndex?: number | null;
|
|
@@ -127,5 +133,5 @@ export interface AnnotationCanvasSkiaProps {
|
|
|
127
133
|
wrapContent?: (content: ReactNode) => ReactNode;
|
|
128
134
|
canvasRef?: RefObject<CanvasRef | null>;
|
|
129
135
|
}
|
|
130
|
-
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, bgCropId, liveBgCrop, liveBgCropClip, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale, measurementLineScale, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
136
|
+
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, bgCropId, liveBgCrop, liveBgCropClip, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, snapIndicator, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale, measurementLineScale, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
131
137
|
export {};
|
|
@@ -30,6 +30,10 @@ const MEASUREMENT_HANDLE_COLOR = '#0066FF';
|
|
|
30
30
|
// hit-test the text resize handle, which sits on the padded box corner.
|
|
31
31
|
const SELECTION_COLOR = '#0066FF';
|
|
32
32
|
const SELECTION_STROKE = 1.5;
|
|
33
|
+
// Snap ring (magnetic endpoint snapping, see snapGeometry.ts): drawn around
|
|
34
|
+
// the target the dragged endpoint is currently locked onto. Orange so it reads
|
|
35
|
+
// against both the blue selection chrome and the yellow lock accent.
|
|
36
|
+
const SNAP_INDICATOR_COLOR = '#FF7A00';
|
|
33
37
|
// Outline of a selected LOCKED background layer: the lock accent (the same
|
|
34
38
|
// yellow the selected group-header border uses), distinct from the blue
|
|
35
39
|
// manipulable-selection chrome — locked means "selected, but pinned".
|
|
@@ -60,6 +64,11 @@ const getLinkedGlyphPath = () => (linkedGlyphPath ?? (linkedGlyphPath = Skia.Pat
|
|
|
60
64
|
// a solid-colored handle would camouflage into. `c`/`r`/`ringWidth` may be
|
|
61
65
|
// Reanimated values (native live-drag) or plain numbers (web).
|
|
62
66
|
const Handle = ({ c, r, ringWidth, color = MEASUREMENT_HANDLE_COLOR, }) => (_jsxs(_Fragment, { children: [_jsx(Circle, { c: c, r: r, color: "#FFFFFF" }), _jsx(Circle, { c: c, r: r, color: color, style: "stroke", strokeWidth: ringWidth })] }));
|
|
67
|
+
// The snap indicator: a stroke-only ring (NOT a Handle — its white disc would
|
|
68
|
+
// hide the very pixel being snapped to). Screen-constant like the handles;
|
|
69
|
+
// `opacity` lets the native UI-thread path show/hide it without remounting
|
|
70
|
+
// (a zero radius still paints a strokeWidth-sized dot, so radius can't gate it).
|
|
71
|
+
const SnapRing = ({ c, r, ringWidth, opacity = 1, }) => (_jsx(Circle, { c: c, r: r, color: SNAP_INDICATOR_COLOR, style: "stroke", strokeWidth: ringWidth, opacity: opacity }));
|
|
63
72
|
// Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
|
|
64
73
|
const strokeBounds = (points) => {
|
|
65
74
|
if (points.length < 2)
|
|
@@ -105,7 +114,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
|
|
|
105
114
|
// since the function-call pattern works identically on native we use it
|
|
106
115
|
// in both Inners for consistency. Don't add hooks here; this is a plain
|
|
107
116
|
// JSX-returning helper, not a component.
|
|
108
|
-
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, bgCropId, liveBgCrop, liveBgCropClip, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale = 1, measurementLineScale = 1, customPreview, wrapContent, canvasRef, }) => {
|
|
117
|
+
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, bgCropId, liveBgCrop, liveBgCropClip, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, snapIndicator, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale = 1, measurementLineScale = 1, customPreview, wrapContent, canvasRef, }) => {
|
|
109
118
|
// Doc rect a SCALE-mode plane grid covers: the first background layer (the
|
|
110
119
|
// photo being calibrated), else the document rect. Pinned to layer 0 like
|
|
111
120
|
// the tile-sizing reference — adding a second image must not re-span the
|
|
@@ -340,6 +349,6 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
340
349
|
corners.map((c, i) => (_jsx(Handle, { c: c, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }, i)))] }));
|
|
341
350
|
}
|
|
342
351
|
return null;
|
|
343
|
-
})(), 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] }));
|
|
352
|
+
})(), 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) })) })), snapIndicator && (_jsx(SnapRing, { c: snapIndicator.c, r: snapIndicator.r, ringWidth: snapIndicator.ringWidth, opacity: snapIndicator.opacity })), customPreview] }));
|
|
344
353
|
return (_jsx(Canvas, { ref: canvasRef, style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
|
|
345
354
|
};
|
|
@@ -19,6 +19,11 @@ export interface CanvasPointerEvent {
|
|
|
19
19
|
metaKey?: boolean;
|
|
20
20
|
ctrlKey?: boolean;
|
|
21
21
|
}
|
|
22
|
+
export interface SnapContext {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
radiusPx: number;
|
|
25
|
+
setIndicator(point: Vec2 | null): void;
|
|
26
|
+
}
|
|
22
27
|
export interface ToolContext {
|
|
23
28
|
document: AnnotationCanvasState;
|
|
24
29
|
selection: Selection | null;
|
|
@@ -27,6 +32,7 @@ export interface ToolContext {
|
|
|
27
32
|
tileScaleFactor: number;
|
|
28
33
|
headerTileScaleFactor: number;
|
|
29
34
|
fileTileScaleFactor: number;
|
|
35
|
+
snap?: SnapContext | null;
|
|
30
36
|
preview(patch: AnnotationDocumentPatch): void;
|
|
31
37
|
commit(patch: AnnotationDocumentPatch): void;
|
|
32
38
|
setSelection(selection: Selection | null): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AnnotationCanvasState, AnnotationElementId, Vec2 } from '../../types/annotation.js';
|
|
2
|
+
export declare const SNAP_RADIUS_PX = 16;
|
|
3
|
+
export declare const SNAP_RING_PX = 11;
|
|
4
|
+
export declare const SNAP_RING_WIDTH_PX = 2;
|
|
5
|
+
export declare const SNAP_KIND_POINT = 0;
|
|
6
|
+
export declare const SNAP_KIND_MIDPOINT = 1;
|
|
7
|
+
export type SnapKind = 0 | 1;
|
|
8
|
+
export declare const SNAP_STRIDE = 3;
|
|
9
|
+
export interface SnapTarget {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
kind: SnapKind;
|
|
13
|
+
sourceId: AnnotationElementId;
|
|
14
|
+
}
|
|
15
|
+
export interface SnapHit {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
kind: SnapKind;
|
|
19
|
+
index: number;
|
|
20
|
+
}
|
|
21
|
+
export declare const collectSnapTargets: (doc: AnnotationCanvasState, excludeId?: AnnotationElementId | null) => SnapTarget[];
|
|
22
|
+
export declare const encodeSnapTargets: (targets: readonly SnapTarget[]) => number[];
|
|
23
|
+
export declare const collectSnapTargetsFlat: (doc: AnnotationCanvasState, excludeId?: AnnotationElementId | null) => number[];
|
|
24
|
+
export declare const snapNearest: (x: number, y: number, flat: readonly number[], radiusDoc: number) => SnapHit | null;
|
|
25
|
+
export declare const snapDelta: (base: Vec2, delta: Vec2, flat: readonly number[], radiusDoc: number) => {
|
|
26
|
+
delta: Vec2;
|
|
27
|
+
hit: SnapHit | null;
|
|
28
|
+
};
|
|
29
|
+
export declare const snapPoint: (p: Vec2, flat: readonly number[], radiusDoc: number) => {
|
|
30
|
+
point: Vec2;
|
|
31
|
+
hit: SnapHit | null;
|
|
32
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Magnetic snapping for line endpoints (Asana 1217966180270874). While an
|
|
2
|
+
// endpoint of a measurement line (`placement: 'line'`) or a line/arrow shape is
|
|
3
|
+
// being placed or dragged, it snaps to nearby geometry: other lines' endpoints
|
|
4
|
+
// and midpoints, polygon/rect vertices, and measurement-rectangle corners.
|
|
5
|
+
// Skia-free, safe in tools, hit-tests and unit tests.
|
|
6
|
+
//
|
|
7
|
+
// `snapNearest`/`snapDelta` carry 'worklet' directives: the native Inner calls
|
|
8
|
+
// them per frame on the UI thread (the live endpoint derived values and the
|
|
9
|
+
// shape rubber-band gesture) — the same mechanism planeGeometry's grid
|
|
10
|
+
// generators rely on. Keep them free of closures over non-worklet module
|
|
11
|
+
// functions (numeric constants are fine) so the workletizer can lift them.
|
|
12
|
+
//
|
|
13
|
+
// Targets travel to the UI thread as a FLAT number array (`[x, y, kind, …]`,
|
|
14
|
+
// stride SNAP_STRIDE) written once per drag — see `encodeSnapTargets`.
|
|
15
|
+
import { placementOf, normalizeRect } from './measurementGeometry.js';
|
|
16
|
+
// Screen-px capture radius: the grabbed endpoint jumps to a target once it is
|
|
17
|
+
// within this many pixels of it. Converted to doc space via `/ zoom` at drag
|
|
18
|
+
// begin (frozen for the drag, like the slide detent's SLIDE_SNAP_PX).
|
|
19
|
+
export const SNAP_RADIUS_PX = 16;
|
|
20
|
+
// Screen-px radius / ring width of the snap indicator drawn around the target
|
|
21
|
+
// while a snap is active. Larger than the endpoint handle (HANDLE_RADIUS_PX 7)
|
|
22
|
+
// so the ring frames the handle instead of hiding under it.
|
|
23
|
+
export const SNAP_RING_PX = 11;
|
|
24
|
+
export const SNAP_RING_WIDTH_PX = 2;
|
|
25
|
+
// Target kinds, ordered by priority: on an exact distance tie the LOWER kind
|
|
26
|
+
// wins, so a true endpoint/vertex beats a midpoint sitting on the same pixel.
|
|
27
|
+
export const SNAP_KIND_POINT = 0;
|
|
28
|
+
export const SNAP_KIND_MIDPOINT = 1;
|
|
29
|
+
// Flat encoding stride: [x, y, kind].
|
|
30
|
+
export const SNAP_STRIDE = 3;
|
|
31
|
+
// Every snap target in the document, in doc space. `excludeId` drops the
|
|
32
|
+
// element being edited ENTIRELY (its endpoints and midpoint) — snapping a
|
|
33
|
+
// line's endpoint onto its own other end would collapse it, and onto its own
|
|
34
|
+
// midpoint would halve it. Targets:
|
|
35
|
+
// - measurement `placement:'line'`: a, b (point) + midpoint. Plane-edge
|
|
36
|
+
// tiles included — their endpoints are real geometry even though the
|
|
37
|
+
// select tool refuses to drag them.
|
|
38
|
+
// - measurement `placement:'rectangle'`: the four normalized corners.
|
|
39
|
+
// - shape line/arrow: both endpoints + midpoint.
|
|
40
|
+
// - shape rect: the four normalized corners (stored as two opposite corners).
|
|
41
|
+
// - shape polygon (triangles included): every vertex.
|
|
42
|
+
// - ellipse, text, strokes, backgrounds: nothing.
|
|
43
|
+
// Degenerate (zero-length) lines contribute their coincident endpoints but no
|
|
44
|
+
// midpoint.
|
|
45
|
+
export const collectSnapTargets = (doc, excludeId) => {
|
|
46
|
+
const out = [];
|
|
47
|
+
const point = (p, sourceId) => out.push({ x: p.x, y: p.y, kind: SNAP_KIND_POINT, sourceId });
|
|
48
|
+
const segment = (a, b, sourceId) => {
|
|
49
|
+
point(a, sourceId);
|
|
50
|
+
point(b, sourceId);
|
|
51
|
+
if (a.x !== b.x || a.y !== b.y) {
|
|
52
|
+
out.push({
|
|
53
|
+
x: (a.x + b.x) / 2,
|
|
54
|
+
y: (a.y + b.y) / 2,
|
|
55
|
+
kind: SNAP_KIND_MIDPOINT,
|
|
56
|
+
sourceId,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const corners = (r, sourceId) => {
|
|
61
|
+
const n = normalizeRect(r);
|
|
62
|
+
point({ x: n.minX, y: n.minY }, sourceId);
|
|
63
|
+
point({ x: n.maxX, y: n.minY }, sourceId);
|
|
64
|
+
point({ x: n.maxX, y: n.maxY }, sourceId);
|
|
65
|
+
point({ x: n.minX, y: n.maxY }, sourceId);
|
|
66
|
+
};
|
|
67
|
+
for (const m of doc.placedMeasurements) {
|
|
68
|
+
if (excludeId != null && m.id === excludeId)
|
|
69
|
+
continue;
|
|
70
|
+
const placement = placementOf(m);
|
|
71
|
+
if (placement === 'line' && m.line)
|
|
72
|
+
segment(m.line.a, m.line.b, m.id);
|
|
73
|
+
else if (placement === 'rectangle' && m.rect)
|
|
74
|
+
corners(m.rect, m.id);
|
|
75
|
+
}
|
|
76
|
+
for (const s of doc.shapes) {
|
|
77
|
+
if (excludeId != null && s.id === excludeId)
|
|
78
|
+
continue;
|
|
79
|
+
const pts = s.geometry.points;
|
|
80
|
+
switch (s.kind) {
|
|
81
|
+
case 'line':
|
|
82
|
+
case 'arrow': {
|
|
83
|
+
const [a, b] = pts;
|
|
84
|
+
if (a && b)
|
|
85
|
+
segment(a, b, s.id);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case 'rect': {
|
|
89
|
+
const [a, b] = pts;
|
|
90
|
+
if (a && b)
|
|
91
|
+
corners({ a, b }, s.id);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'polygon':
|
|
95
|
+
for (const p of pts)
|
|
96
|
+
point(p, s.id);
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
};
|
|
104
|
+
// Flat `[x, y, kind, …]` buffer for UI-thread transfer (a plain number[],
|
|
105
|
+
// like the freehand `livePoints` — TypedArrays aren't proven shareable here).
|
|
106
|
+
export const encodeSnapTargets = (targets) => {
|
|
107
|
+
const flat = new Array(targets.length * SNAP_STRIDE);
|
|
108
|
+
for (let i = 0; i < targets.length; i++) {
|
|
109
|
+
const t = targets[i];
|
|
110
|
+
flat[i * SNAP_STRIDE] = t.x;
|
|
111
|
+
flat[i * SNAP_STRIDE + 1] = t.y;
|
|
112
|
+
flat[i * SNAP_STRIDE + 2] = t.kind;
|
|
113
|
+
}
|
|
114
|
+
return flat;
|
|
115
|
+
};
|
|
116
|
+
export const collectSnapTargetsFlat = (doc, excludeId) => encodeSnapTargets(collectSnapTargets(doc, excludeId));
|
|
117
|
+
// Nearest target to (x, y) within `radiusDoc` (inclusive), or null. Ties on
|
|
118
|
+
// distance go to the lower kind (point beats midpoint); remaining ties to the
|
|
119
|
+
// earlier target. `radiusDoc <= 0` disables snapping. Worklet-callable.
|
|
120
|
+
export const snapNearest = (x, y, flat, radiusDoc) => {
|
|
121
|
+
'worklet';
|
|
122
|
+
if (radiusDoc <= 0)
|
|
123
|
+
return null;
|
|
124
|
+
const r2 = radiusDoc * radiusDoc;
|
|
125
|
+
let bestD2 = Infinity;
|
|
126
|
+
let bestKind = Infinity;
|
|
127
|
+
let bestIdx = -1;
|
|
128
|
+
for (let i = 0; i + 2 < flat.length; i += 3) {
|
|
129
|
+
const dx = flat[i] - x;
|
|
130
|
+
const dy = flat[i + 1] - y;
|
|
131
|
+
const d2 = dx * dx + dy * dy;
|
|
132
|
+
if (d2 > r2)
|
|
133
|
+
continue;
|
|
134
|
+
const kind = flat[i + 2];
|
|
135
|
+
if (d2 < bestD2 || (d2 === bestD2 && kind < bestKind)) {
|
|
136
|
+
bestD2 = d2;
|
|
137
|
+
bestKind = kind;
|
|
138
|
+
bestIdx = i;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (bestIdx < 0)
|
|
142
|
+
return null;
|
|
143
|
+
return {
|
|
144
|
+
x: flat[bestIdx],
|
|
145
|
+
y: flat[bestIdx + 1],
|
|
146
|
+
kind: flat[bestIdx + 2] === 1 ? 1 : 0,
|
|
147
|
+
index: bestIdx / 3,
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
// Snap a DRAG: `base` is the grabbed endpoint's committed position and `delta`
|
|
151
|
+
// the raw world-space drag offset. Returns the effective delta that lands the
|
|
152
|
+
// endpoint exactly on the nearest target (or the raw delta when nothing is in
|
|
153
|
+
// range), so the existing delta-based patch builders stay untouched. Worklet-
|
|
154
|
+
// callable — the native select gesture runs it in onEnd so the commit matches
|
|
155
|
+
// the live preview bit-for-bit.
|
|
156
|
+
export const snapDelta = (base, delta, flat, radiusDoc) => {
|
|
157
|
+
'worklet';
|
|
158
|
+
const hit = snapNearest(base.x + delta.x, base.y + delta.y, flat, radiusDoc);
|
|
159
|
+
if (!hit)
|
|
160
|
+
return { delta, hit: null };
|
|
161
|
+
return { delta: { x: hit.x - base.x, y: hit.y - base.y }, hit };
|
|
162
|
+
};
|
|
163
|
+
// Snap an ABSOLUTE point (drag-to-create tools place raw world points).
|
|
164
|
+
export const snapPoint = (p, flat, radiusDoc) => {
|
|
165
|
+
'worklet';
|
|
166
|
+
const hit = snapNearest(p.x, p.y, flat, radiusDoc);
|
|
167
|
+
return hit ? { point: { x: hit.x, y: hit.y }, hit } : { point: p, hit: null };
|
|
168
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
2
|
import { DEFAULT_LINE_POS, recomputeAnchor, rectCenter, } from '../measurementGeometry.js';
|
|
3
|
+
import { collectSnapTargetsFlat, snapPoint } from '../snapGeometry.js';
|
|
3
4
|
// The `line*` style fields for a placed measurement, built from the tool's
|
|
4
5
|
// style options. Undefined fields are dropped (never emitted) so the render
|
|
5
6
|
// defaults apply and Firestore never sees an `undefined` value.
|
|
@@ -22,6 +23,25 @@ const lineStyleFields = (placement, style) => {
|
|
|
22
23
|
}
|
|
23
24
|
return out;
|
|
24
25
|
};
|
|
26
|
+
// Snap state for a fresh line press. The new annotation isn't in the doc yet,
|
|
27
|
+
// so nothing needs excluding. Empty when snapping is off.
|
|
28
|
+
const snapStateForPress = (ctx, placement) => placement === 'line' && ctx.snap?.enabled
|
|
29
|
+
? {
|
|
30
|
+
snap: {
|
|
31
|
+
targets: collectSnapTargetsFlat(ctx.document),
|
|
32
|
+
radiusDoc: ctx.snap.radiusPx / ctx.viewport.state.zoom,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
: {};
|
|
36
|
+
// The world point a drag end should use: snapped when a target is in range.
|
|
37
|
+
// Reports the target (or null) to the indicator.
|
|
38
|
+
const snapEnd = (s, ctx, world) => {
|
|
39
|
+
if (!s.snap)
|
|
40
|
+
return world;
|
|
41
|
+
const r = snapPoint(world, s.snap.targets, s.snap.radiusDoc);
|
|
42
|
+
ctx.snap?.setIndicator(r.hit ? { x: r.hit.x, y: r.hit.y } : null);
|
|
43
|
+
return r.point;
|
|
44
|
+
};
|
|
25
45
|
let counter = 0;
|
|
26
46
|
const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
27
47
|
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
@@ -130,13 +150,21 @@ export const createMeasurementTool = (options = {}) => {
|
|
|
130
150
|
id: options.id ?? defaultId,
|
|
131
151
|
label: options.label ?? defaultLabel,
|
|
132
152
|
cursor: 'crosshair',
|
|
133
|
-
onPointerDown(event) {
|
|
153
|
+
onPointerDown(event, ctx) {
|
|
154
|
+
const snap = snapStateForPress(ctx, placement);
|
|
155
|
+
// The START of a new line snaps too (a leader that begins exactly on a
|
|
156
|
+
// corner is the point of the feature); the ring flashes there until the
|
|
157
|
+
// first move re-evaluates it against the moving end.
|
|
158
|
+
const startWorld = snap.snap
|
|
159
|
+
? snapPoint(event.world, snap.snap.targets, snap.snap.radiusDoc).point
|
|
160
|
+
: event.world;
|
|
134
161
|
return {
|
|
135
162
|
kind: 'measurement-drawing',
|
|
136
163
|
id: makeId(),
|
|
137
|
-
startWorld
|
|
164
|
+
startWorld,
|
|
138
165
|
startScreen: event.screen,
|
|
139
166
|
moved: false,
|
|
167
|
+
...snap,
|
|
140
168
|
};
|
|
141
169
|
},
|
|
142
170
|
onPointerMove(event, ctx, state) {
|
|
@@ -149,7 +177,7 @@ export const createMeasurementTool = (options = {}) => {
|
|
|
149
177
|
placement,
|
|
150
178
|
linePos,
|
|
151
179
|
a: s.startWorld,
|
|
152
|
-
b: event.world,
|
|
180
|
+
b: snapEnd(s, ctx, event.world),
|
|
153
181
|
style,
|
|
154
182
|
valueSource: valueSourceFor(ctx),
|
|
155
183
|
});
|
|
@@ -160,6 +188,7 @@ export const createMeasurementTool = (options = {}) => {
|
|
|
160
188
|
const s = state;
|
|
161
189
|
if (s?.kind !== 'measurement-drawing')
|
|
162
190
|
return;
|
|
191
|
+
ctx.snap?.setIndicator(null);
|
|
163
192
|
const dx = event.screen.x - s.startScreen.x;
|
|
164
193
|
const dy = event.screen.y - s.startScreen.y;
|
|
165
194
|
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
@@ -167,18 +196,23 @@ export const createMeasurementTool = (options = {}) => {
|
|
|
167
196
|
ctx.preview({ ops: [] });
|
|
168
197
|
return;
|
|
169
198
|
}
|
|
199
|
+
// Same snap as the last move, so the commit equals the preview.
|
|
200
|
+
const b = s.snap
|
|
201
|
+
? snapPoint(event.world, s.snap.targets, s.snap.radiusDoc).point
|
|
202
|
+
: event.world;
|
|
170
203
|
place(ctx, buildMeasurement({
|
|
171
204
|
id: s.id,
|
|
172
205
|
layerId: firstLayerId(ctx.document),
|
|
173
206
|
placement,
|
|
174
207
|
linePos,
|
|
175
208
|
a: s.startWorld,
|
|
176
|
-
b
|
|
209
|
+
b,
|
|
177
210
|
style,
|
|
178
211
|
valueSource: valueSourceFor(ctx),
|
|
179
212
|
}));
|
|
180
213
|
},
|
|
181
214
|
onCancel(_state, ctx) {
|
|
215
|
+
ctx.snap?.setIndicator(null);
|
|
182
216
|
ctx.preview({ ops: [] });
|
|
183
217
|
},
|
|
184
218
|
};
|
|
@@ -2,6 +2,7 @@ import { MAX_BACKGROUND_DOC_EXTENT, MIN_BACKGROUND_DOC_EXTENT, backgroundCropBar
|
|
|
2
2
|
import { stampTileDims } from '../stampLayout.js';
|
|
3
3
|
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
|
|
4
4
|
import { hitShapeOutline, visualShapeBounds } from '../shapeGeometry.js';
|
|
5
|
+
import { collectSnapTargetsFlat, snapDelta } from '../snapGeometry.js';
|
|
5
6
|
import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
|
|
6
7
|
import { editTextShape } from './textEditing.js';
|
|
7
8
|
const HIT_PADDING = 6;
|
|
@@ -238,6 +239,32 @@ const slidePatch = (doc, id, delta, zoom) => {
|
|
|
238
239
|
ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }],
|
|
239
240
|
};
|
|
240
241
|
};
|
|
242
|
+
// Committed position of the grabbed endpoint — the base the drag delta (and
|
|
243
|
+
// the snap) is measured from. Measurement lines keep endpoints on `line.a/b`,
|
|
244
|
+
// shape lines on `geometry.points[0/1]`.
|
|
245
|
+
const endpointBase = (doc, id, elementKind, handle) => {
|
|
246
|
+
if (elementKind === 'shape') {
|
|
247
|
+
const sh = doc.shapes.find((x) => x.id === id);
|
|
248
|
+
const p = sh?.geometry.points[handle === 'a' ? 0 : 1];
|
|
249
|
+
return p ?? null;
|
|
250
|
+
}
|
|
251
|
+
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
252
|
+
if (!m?.line)
|
|
253
|
+
return null;
|
|
254
|
+
return handle === 'a' ? m.line.a : m.line.b;
|
|
255
|
+
};
|
|
256
|
+
// Snap bookkeeping for an endpoint grab: harvest every OTHER element's targets
|
|
257
|
+
// once (ctx.document is the committed doc, so in-flight previews never leak
|
|
258
|
+
// in) and freeze the capture radius in doc space at the grab zoom. Empty when
|
|
259
|
+
// snapping is off, so the drag stays a plain delta.
|
|
260
|
+
const snapStateForGrab = (ctx, excludeId, zoom) => ctx.snap?.enabled
|
|
261
|
+
? {
|
|
262
|
+
snap: {
|
|
263
|
+
targets: collectSnapTargetsFlat(ctx.document, excludeId),
|
|
264
|
+
radiusDoc: ctx.snap.radiusPx / zoom,
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
: {};
|
|
241
268
|
// Which endpoint handle of a (selected) line annotation is under `world`.
|
|
242
269
|
// Prefers the nearer endpoint when both are within range.
|
|
243
270
|
const findHandleHit = (doc, id, world, zoom) => {
|
|
@@ -777,6 +804,7 @@ export const createSelectTool = (options = {}) => ({
|
|
|
777
804
|
handle,
|
|
778
805
|
start: world,
|
|
779
806
|
delta: { x: 0, y: 0 },
|
|
807
|
+
...snapStateForGrab(ctx, selId, zoom),
|
|
780
808
|
};
|
|
781
809
|
}
|
|
782
810
|
const shapeHandle = findShapeHandleHit(ctx.document, selId, world, zoom);
|
|
@@ -790,6 +818,7 @@ export const createSelectTool = (options = {}) => ({
|
|
|
790
818
|
handle: shapeHandle,
|
|
791
819
|
start: world,
|
|
792
820
|
delta: { x: 0, y: 0 },
|
|
821
|
+
...snapStateForGrab(ctx, selId, zoom),
|
|
793
822
|
};
|
|
794
823
|
}
|
|
795
824
|
const resizeGeom = findResizeHandleHit(ctx.document, selId, world, zoom);
|
|
@@ -935,7 +964,7 @@ export const createSelectTool = (options = {}) => ({
|
|
|
935
964
|
}
|
|
936
965
|
if (s?.kind !== 'dragging')
|
|
937
966
|
return s;
|
|
938
|
-
|
|
967
|
+
let delta = {
|
|
939
968
|
x: event.world.x - s.start.x,
|
|
940
969
|
y: event.world.y - s.start.y,
|
|
941
970
|
};
|
|
@@ -945,12 +974,24 @@ export const createSelectTool = (options = {}) => ({
|
|
|
945
974
|
pendingTextEditId = null;
|
|
946
975
|
pendingBackgroundDeselectId = null;
|
|
947
976
|
}
|
|
977
|
+
// Magnetic endpoint snap: bend the delta so the grabbed end lands on the
|
|
978
|
+
// nearest target in range, and show the ring there. The snapped delta is
|
|
979
|
+
// what gets stored, so onPointerUp's commit reuses it unchanged.
|
|
980
|
+
if (s.mode === 'endpoint' && s.snap && s.handle) {
|
|
981
|
+
const base = endpointBase(ctx.document, s.id, s.elementKind, s.handle);
|
|
982
|
+
if (base) {
|
|
983
|
+
const r = snapDelta(base, delta, s.snap.targets, s.snap.radiusDoc);
|
|
984
|
+
delta = r.delta;
|
|
985
|
+
ctx.snap?.setIndicator(r.hit ? { x: r.hit.x, y: r.hit.y } : null);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
948
988
|
const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
|
|
949
989
|
if (patch)
|
|
950
990
|
ctx.preview(patch);
|
|
951
991
|
return { ...s, delta };
|
|
952
992
|
},
|
|
953
993
|
onPointerUp(_event, ctx, state) {
|
|
994
|
+
ctx.snap?.setIndicator(null);
|
|
954
995
|
const editId = pendingTextEditId;
|
|
955
996
|
pendingTextEditId = null;
|
|
956
997
|
const deselectId = pendingBackgroundDeselectId;
|
|
@@ -984,6 +1025,7 @@ export const createSelectTool = (options = {}) => ({
|
|
|
984
1025
|
onCancel(_state, ctx) {
|
|
985
1026
|
pendingTextEditId = null;
|
|
986
1027
|
pendingBackgroundDeselectId = null;
|
|
1028
|
+
ctx.snap?.setIndicator(null);
|
|
987
1029
|
ctx.preview({ ops: [] });
|
|
988
1030
|
},
|
|
989
1031
|
hitTest(element, p) {
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
2
|
import { annotationKindFor, shapePointsFromDrag, } from '../shapeGeometry.js';
|
|
3
|
+
import { collectSnapTargetsFlat, snapPoint } from '../snapGeometry.js';
|
|
4
|
+
const snapStateForPress = (ctx, kind) => kind === 'line' && ctx.snap?.enabled
|
|
5
|
+
? {
|
|
6
|
+
snap: {
|
|
7
|
+
targets: collectSnapTargetsFlat(ctx.document),
|
|
8
|
+
radiusDoc: ctx.snap.radiusPx / ctx.viewport.state.zoom,
|
|
9
|
+
},
|
|
10
|
+
}
|
|
11
|
+
: {};
|
|
12
|
+
const snapWorld = (s, world) => s.snap ? snapPoint(world, s.snap.targets, s.snap.radiusDoc) : null;
|
|
3
13
|
let counter = 0;
|
|
4
14
|
const makeId = (prefix) => `${prefix}-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
15
|
const DEFAULT_LABELS = {
|
|
@@ -61,12 +71,14 @@ export const createShapeTool = (options = {}) => {
|
|
|
61
71
|
dash,
|
|
62
72
|
},
|
|
63
73
|
onPointerDown(event, ctx) {
|
|
74
|
+
const snap = snapStateForPress(ctx, kind);
|
|
75
|
+
const startWorld = snapWorld(snap, event.world)?.point ?? event.world;
|
|
64
76
|
return {
|
|
65
77
|
kind: 'shape-drawing',
|
|
66
78
|
shape: buildShapeFromDrag({
|
|
67
79
|
kind,
|
|
68
|
-
a:
|
|
69
|
-
b:
|
|
80
|
+
a: startWorld,
|
|
81
|
+
b: startWorld,
|
|
70
82
|
color,
|
|
71
83
|
width,
|
|
72
84
|
cap,
|
|
@@ -74,22 +86,28 @@ export const createShapeTool = (options = {}) => {
|
|
|
74
86
|
dash,
|
|
75
87
|
layerId: firstLayerId(ctx.document),
|
|
76
88
|
}),
|
|
77
|
-
startWorld
|
|
89
|
+
startWorld,
|
|
78
90
|
startScreen: event.screen,
|
|
79
91
|
moved: false,
|
|
92
|
+
...snap,
|
|
80
93
|
};
|
|
81
94
|
},
|
|
82
95
|
onPointerMove(event, ctx, state) {
|
|
83
96
|
const s = state;
|
|
84
97
|
if (s?.kind !== 'shape-drawing')
|
|
85
98
|
return s;
|
|
99
|
+
const snapped = snapWorld(s, event.world);
|
|
100
|
+
if (s.snap) {
|
|
101
|
+
ctx.snap?.setIndicator(snapped?.hit ? { x: snapped.hit.x, y: snapped.hit.y } : null);
|
|
102
|
+
}
|
|
103
|
+
const end = snapped?.point ?? event.world;
|
|
86
104
|
const next = {
|
|
87
105
|
...s,
|
|
88
106
|
moved: true,
|
|
89
107
|
shape: {
|
|
90
108
|
...s.shape,
|
|
91
109
|
geometry: {
|
|
92
|
-
points: shapePointsFromDrag(kind, s.startWorld,
|
|
110
|
+
points: shapePointsFromDrag(kind, s.startWorld, end),
|
|
93
111
|
},
|
|
94
112
|
},
|
|
95
113
|
};
|
|
@@ -100,6 +118,7 @@ export const createShapeTool = (options = {}) => {
|
|
|
100
118
|
const s = state;
|
|
101
119
|
if (s?.kind !== 'shape-drawing')
|
|
102
120
|
return;
|
|
121
|
+
ctx.snap?.setIndicator(null);
|
|
103
122
|
const dx = event.screen.x - s.startScreen.x;
|
|
104
123
|
const dy = event.screen.y - s.startScreen.y;
|
|
105
124
|
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
@@ -107,15 +126,18 @@ export const createShapeTool = (options = {}) => {
|
|
|
107
126
|
ctx.preview({ ops: [] });
|
|
108
127
|
return;
|
|
109
128
|
}
|
|
129
|
+
// Same snap as the last move, so the commit equals the preview.
|
|
130
|
+
const end = snapWorld(s, event.world)?.point ?? event.world;
|
|
110
131
|
const shape = {
|
|
111
132
|
...s.shape,
|
|
112
133
|
geometry: {
|
|
113
|
-
points: shapePointsFromDrag(kind, s.startWorld,
|
|
134
|
+
points: shapePointsFromDrag(kind, s.startWorld, end),
|
|
114
135
|
},
|
|
115
136
|
};
|
|
116
137
|
ctx.commit({ ops: [{ op: 'addShape', shape }] });
|
|
117
138
|
},
|
|
118
139
|
onCancel(_state, ctx) {
|
|
140
|
+
ctx.snap?.setIndicator(null);
|
|
119
141
|
ctx.preview({ ops: [] });
|
|
120
142
|
},
|
|
121
143
|
};
|
|
@@ -64,6 +64,7 @@ export interface UseAnnotationCanvasStateProps {
|
|
|
64
64
|
measurements?: Measurement[];
|
|
65
65
|
pickMeasurement?: () => Promise<MeasurementRef | null>;
|
|
66
66
|
requestTextInput?: RequestTextInput;
|
|
67
|
+
snapToGeometry?: boolean;
|
|
67
68
|
width: number;
|
|
68
69
|
height: number;
|
|
69
70
|
initialViewport?: ViewportState;
|
|
@@ -94,6 +95,7 @@ export interface AnnotationCanvasStateApi {
|
|
|
94
95
|
fileTileScaleFactor: number;
|
|
95
96
|
penDrawingStroke: AnnotationStroke | null;
|
|
96
97
|
customPreviewState: ToolState;
|
|
98
|
+
snapIndicator: Vec2 | null;
|
|
97
99
|
dispatchPointerDown(event: CanvasPointerEvent): void;
|
|
98
100
|
dispatchPointerMove(event: CanvasPointerEvent): void;
|
|
99
101
|
dispatchPointerUp(event: CanvasPointerEvent): void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
|
|
3
3
|
import { resolveHeaderTileScaleFactor, resolveFileTileScaleFactor, resolveTileScaleFactor, stampTileDims, STAMP_TILE_SIZE, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
|
|
4
|
+
import { SNAP_RADIUS_PX } from './snapGeometry.js';
|
|
4
5
|
import { createViewportApi, fitRectToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, DEFAULT_ZOOM_LIMITS, } from './viewport.js';
|
|
5
6
|
import { computeZoomFloor, pdfFirstPageDocRect } from './pdfDocument.js';
|
|
6
7
|
import { backgroundLayerDocRect, backgroundLayersOf, backgroundLayersUnionRect, } from './backgroundLayers.js';
|
|
@@ -113,7 +114,7 @@ const defaultLeaderLenDoc = (docWidth) => Math.min(800, Math.max(240, docWidth *
|
|
|
113
114
|
// inners share this hook; each wraps it with platform-specific event
|
|
114
115
|
// capture and JSX (div + DOM events vs. GestureDetector + RN Views).
|
|
115
116
|
export const useAnnotationCanvasState = (props) => {
|
|
116
|
-
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
|
|
117
|
+
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, snapToGeometry, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
|
|
117
118
|
const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
|
|
118
119
|
const [toolState, setToolState] = useState(undefined);
|
|
119
120
|
// Synchronous mirror for the DISPATCHERS. The native tap gesture
|
|
@@ -128,6 +129,8 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
128
129
|
toolStateRef.current = next;
|
|
129
130
|
setToolState(next);
|
|
130
131
|
};
|
|
132
|
+
// Snap ring position for JS-path drags (see AnnotationCanvasStateApi).
|
|
133
|
+
const [snapIndicator, setSnapIndicator] = useState(null);
|
|
131
134
|
const [previewPatch, setPreviewPatch] = useState(null);
|
|
132
135
|
const undoStackRef = useRef([]);
|
|
133
136
|
const redoStackRef = useRef([]);
|
|
@@ -228,6 +231,11 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
228
231
|
tileScaleFactor,
|
|
229
232
|
headerTileScaleFactor,
|
|
230
233
|
fileTileScaleFactor,
|
|
234
|
+
snap: {
|
|
235
|
+
enabled: snapToGeometry === true,
|
|
236
|
+
radiusPx: SNAP_RADIUS_PX,
|
|
237
|
+
setIndicator: setSnapIndicator,
|
|
238
|
+
},
|
|
231
239
|
preview(patch) {
|
|
232
240
|
setPreviewPatch(patch);
|
|
233
241
|
},
|
|
@@ -328,6 +336,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
328
336
|
onSelectionChange,
|
|
329
337
|
pickMeasurement,
|
|
330
338
|
requestTextInput,
|
|
339
|
+
snapToGeometry,
|
|
331
340
|
]);
|
|
332
341
|
// Live ctx for imperative handle methods (which are created in an effect and
|
|
333
342
|
// would otherwise capture a stale ctx/viewport).
|
|
@@ -353,6 +362,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
353
362
|
activePointerIdRef.current = null;
|
|
354
363
|
updateToolState(undefined);
|
|
355
364
|
setPreviewPatch(null);
|
|
365
|
+
setSnapIndicator(null);
|
|
356
366
|
// updateToolState is a stable-in-effect helper (ref + setState); the
|
|
357
367
|
// effect only re-runs on a tool identity change.
|
|
358
368
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -390,6 +400,9 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
390
400
|
activeTool.onPointerUp?.(event, ctx, toolStateRef.current);
|
|
391
401
|
activePointerIdRef.current = null;
|
|
392
402
|
updateToolState(undefined);
|
|
403
|
+
// Belt-and-braces: tools clear their own ring on release, but a tool
|
|
404
|
+
// that forgets must not leave a stale ring on the canvas.
|
|
405
|
+
setSnapIndicator(null);
|
|
393
406
|
},
|
|
394
407
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
395
408
|
[activeTool, ctx]);
|
|
@@ -410,6 +423,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
410
423
|
const prevToolState = toolStateRef.current;
|
|
411
424
|
updateToolState(undefined);
|
|
412
425
|
setPreviewPatch(null);
|
|
426
|
+
setSnapIndicator(null);
|
|
413
427
|
if (activeTool)
|
|
414
428
|
activeTool.onCancel?.(prevToolState, ctx);
|
|
415
429
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -903,6 +917,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
903
917
|
fileTileScaleFactor,
|
|
904
918
|
penDrawingStroke,
|
|
905
919
|
customPreviewState: toolState,
|
|
920
|
+
snapIndicator,
|
|
906
921
|
dispatchPointerDown,
|
|
907
922
|
dispatchPointerMove,
|
|
908
923
|
dispatchPointerUp,
|
package/dist/exports.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
|
|
|
28
28
|
export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
|
|
29
29
|
export type { AnnotationCanvasHandle } from './annotation/canvas/useAnnotationCanvasState.js';
|
|
30
30
|
export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './annotation/canvas/AnnotationCanvasInner.js';
|
|
31
|
-
export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
|
|
31
|
+
export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, SnapContext, ToolState, } from './annotation/canvas/Tool.js';
|
|
32
32
|
export type { FileRef, MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
|
|
33
33
|
export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
|
|
34
34
|
export type { CanvasBackgroundSlotArgs, RenderCanvasBackground, } from './annotation/canvas/backgroundSlot.js';
|
|
@@ -52,3 +52,4 @@ export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, type ShapeTool
|
|
|
52
52
|
export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, type ResizeGeometry, type TextBounds, } from './annotation/canvas/textGeometry.js';
|
|
53
53
|
export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, type NormalizedRect, type RectCorner, } from './annotation/canvas/measurementGeometry.js';
|
|
54
54
|
export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
|
|
55
|
+
export { SNAP_RADIUS_PX, SNAP_RING_PX, SNAP_RING_WIDTH_PX, SNAP_KIND_POINT, SNAP_KIND_MIDPOINT, SNAP_STRIDE, collectSnapTargets, collectSnapTargetsFlat, encodeSnapTargets, snapNearest, snapDelta, snapPoint, type SnapHit, type SnapKind, type SnapTarget, } from './annotation/canvas/snapGeometry.js';
|
package/dist/exports.js
CHANGED
|
@@ -56,3 +56,4 @@ export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, } from './anno
|
|
|
56
56
|
export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, } from './annotation/canvas/textGeometry.js';
|
|
57
57
|
export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
|
|
58
58
|
export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
|
|
59
|
+
export { SNAP_RADIUS_PX, SNAP_RING_PX, SNAP_RING_WIDTH_PX, SNAP_KIND_POINT, SNAP_KIND_MIDPOINT, SNAP_STRIDE, collectSnapTargets, collectSnapTargetsFlat, encodeSnapTargets, snapNearest, snapDelta, snapPoint, } from './annotation/canvas/snapGeometry.js';
|