@reekon-tools/boldr-utils 1.15.3 → 1.15.4
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 +190 -3
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +7 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +17 -3
- package/dist/annotation/canvas/Tool.d.ts +6 -0
- package/dist/annotation/canvas/elements/PlaneElement.d.ts +2 -0
- package/dist/annotation/canvas/elements/PlaneElement.js +5 -2
- package/dist/annotation/canvas/tools/planeTool.js +50 -26
- package/dist/annotation/canvas/useAnnotationCanvasState.js +36 -13
- package/package.json +1 -1
|
@@ -272,6 +272,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
272
272
|
const shapeDraw = state.activeTool?.shapeDraw ?? null;
|
|
273
273
|
const panViewport = !!state.activeTool?.panViewport;
|
|
274
274
|
const dragSelection = state.activeTool?.dragSelection ?? null;
|
|
275
|
+
const planeEdit = state.activeTool?.planeEdit ?? null;
|
|
275
276
|
const longPressEnabled = !!state.activeTool?.onLongPress;
|
|
276
277
|
// In-flight shape rubber-band (line/arrow/rect/triangle/circle tools),
|
|
277
278
|
// owned by the UI thread — the shape twin of `livePoints`. The drag worklet
|
|
@@ -476,6 +477,70 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
476
477
|
'worklet';
|
|
477
478
|
return HANDLE_RING_PX / zoom.value;
|
|
478
479
|
});
|
|
480
|
+
// ---- Plane-calibration handle drag (UI thread; Tool.planeEdit) ----------
|
|
481
|
+
// The plane tool's JS preview path re-rendered the whole canvas (and
|
|
482
|
+
// recomputed the photo-spanning grid) per pointer-move, dropping JS frames
|
|
483
|
+
// on device. Instead the drag runs like the select tool's: the gesture
|
|
484
|
+
// begin hit-tests once on JS, the translation lives in shared values
|
|
485
|
+
// driving a live outline (worklet twin of PlaneElement's outline path), and
|
|
486
|
+
// the drop commits exactly once. The grid + dimension tiles freeze/hide
|
|
487
|
+
// until the commit lands.
|
|
488
|
+
// planeDragMode: 0 idle · 1 handle drag (UI thread) · 2 everything else
|
|
489
|
+
// (marking / blocked-creation), which streams JS pointer events like
|
|
490
|
+
// toolPan would have.
|
|
491
|
+
const planeDragMode = useSharedValue(0);
|
|
492
|
+
const planeDragEnded = useSharedValue(false);
|
|
493
|
+
const planeDragTX = useSharedValue(0);
|
|
494
|
+
const planeDragTY = useSharedValue(0);
|
|
495
|
+
const planeDragStartX = useSharedValue(0);
|
|
496
|
+
const planeDragStartY = useSharedValue(0);
|
|
497
|
+
// React gate for the live render (which plane / which handle); mirrored in
|
|
498
|
+
// a ref for the gesture's JS handlers, which must read the latest value
|
|
499
|
+
// without re-building the gesture.
|
|
500
|
+
const [planeDrag, setPlaneDrag] = useState(null);
|
|
501
|
+
const planeDragRef = useRef(planeDrag);
|
|
502
|
+
const planeJsPointerRef = useRef(null);
|
|
503
|
+
// The dragged handle's live world position: grab-start + screen translation
|
|
504
|
+
// converted to doc units by the live zoom.
|
|
505
|
+
const planeDragHandle = useDerivedValue(() => {
|
|
506
|
+
'worklet';
|
|
507
|
+
return {
|
|
508
|
+
x: planeDragStartX.value + planeDragTX.value / zoom.value,
|
|
509
|
+
y: planeDragStartY.value + planeDragTY.value / zoom.value,
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
// WORKLET TWIN of PlaneElement's outline path (quad ring / reference line;
|
|
513
|
+
// the scale ticks are skipped live and reappear on commit). The committed
|
|
514
|
+
// plane + dragged index are captured from React state — setPlaneDrag
|
|
515
|
+
// renders before any movement applies, so the capture is fresh.
|
|
516
|
+
const planeDragPlane = planeDrag
|
|
517
|
+
? ((state.effectiveCanvas.planes ?? []).find((p) => p.id === planeDrag.planeId) ?? null)
|
|
518
|
+
: null;
|
|
519
|
+
const planeDragIndexVal = planeDrag?.index ?? -1;
|
|
520
|
+
const planeDragPath = useDerivedValue(() => {
|
|
521
|
+
'worklet';
|
|
522
|
+
const path = Skia.Path.Make();
|
|
523
|
+
if (!planeDragPlane || planeDragMode.value !== 1)
|
|
524
|
+
return path;
|
|
525
|
+
const lx = planeDragStartX.value + planeDragTX.value / zoom.value;
|
|
526
|
+
const ly = planeDragStartY.value + planeDragTY.value / zoom.value;
|
|
527
|
+
if (planeDragPlane.mode === 'perspective') {
|
|
528
|
+
const c = planeDragPlane.corners;
|
|
529
|
+
const px = (i) => (i === planeDragIndexVal ? lx : c[i].x);
|
|
530
|
+
const py = (i) => (i === planeDragIndexVal ? ly : c[i].y);
|
|
531
|
+
path.moveTo(px(0), py(0));
|
|
532
|
+
path.lineTo(px(1), py(1));
|
|
533
|
+
path.lineTo(px(2), py(2));
|
|
534
|
+
path.lineTo(px(3), py(3));
|
|
535
|
+
path.close();
|
|
536
|
+
return path;
|
|
537
|
+
}
|
|
538
|
+
const a = planeDragIndexVal === 0 ? { x: lx, y: ly } : planeDragPlane.refLine.a;
|
|
539
|
+
const b = planeDragIndexVal === 1 ? { x: lx, y: ly } : planeDragPlane.refLine.b;
|
|
540
|
+
path.moveTo(a.x, a.y);
|
|
541
|
+
path.lineTo(b.x, b.y);
|
|
542
|
+
return path;
|
|
543
|
+
});
|
|
479
544
|
// Rectangle-annotation corner drag. `rectDragId` (React state) marks which
|
|
480
545
|
// annotation's rect renders from the live geometry; `rectCtx` carries the
|
|
481
546
|
// fixed (opposite) corner and the grabbed corner's start position so the
|
|
@@ -1487,12 +1552,119 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1487
1552
|
runOnJS(cancelSelectDrag)();
|
|
1488
1553
|
});
|
|
1489
1554
|
};
|
|
1555
|
+
// Plane tool: a hybrid pan. Handle drags run on the UI thread (shared
|
|
1556
|
+
// values → live outline; single commit on release — see the planeDrag*
|
|
1557
|
+
// values above); everything else (scale-mode marking, blocked-creation
|
|
1558
|
+
// taps) falls back to streaming JS pointer events exactly like toolPan.
|
|
1559
|
+
// The begin hit-test runs once on JS and decides the mode.
|
|
1560
|
+
const buildPlaneEditPan = (cfg) => {
|
|
1561
|
+
const beginPlaneDrag = (origin) => {
|
|
1562
|
+
const st = stateRef.current;
|
|
1563
|
+
const world = st.ctx.viewport.screenToWorld(origin);
|
|
1564
|
+
const index = cfg.hitHandle(st.ctx.document, world, st.viewport.zoom);
|
|
1565
|
+
const planeId = st.ctx.document.planes?.[0]?.id ?? null;
|
|
1566
|
+
if (index != null && planeId != null) {
|
|
1567
|
+
const start = cfg.handlePoints(st.ctx.document)[index];
|
|
1568
|
+
if (start) {
|
|
1569
|
+
planeDragStartX.value = start.x;
|
|
1570
|
+
planeDragStartY.value = start.y;
|
|
1571
|
+
planeDragMode.value = 1;
|
|
1572
|
+
const drag = { planeId, index };
|
|
1573
|
+
planeDragRef.current = drag;
|
|
1574
|
+
setPlaneDrag(drag);
|
|
1575
|
+
// Precision drop — the loupe follows the finger like the select
|
|
1576
|
+
// tool's sub-drags.
|
|
1577
|
+
setMagnifying(true);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
planeDragMode.value = 2;
|
|
1582
|
+
const id = pointerIdRef.current++;
|
|
1583
|
+
planeJsPointerRef.current = { id };
|
|
1584
|
+
st.dispatchPointerDown(buildEvent(id, origin));
|
|
1585
|
+
};
|
|
1586
|
+
const movePlaneDragJs = (screen) => {
|
|
1587
|
+
const f = planeJsPointerRef.current;
|
|
1588
|
+
if (f)
|
|
1589
|
+
stateRef.current.dispatchPointerMove(buildEvent(f.id, screen));
|
|
1590
|
+
};
|
|
1591
|
+
const endPlaneDrag = (screen) => {
|
|
1592
|
+
const st = stateRef.current;
|
|
1593
|
+
if (planeDragMode.value === 2) {
|
|
1594
|
+
const f = planeJsPointerRef.current;
|
|
1595
|
+
if (f)
|
|
1596
|
+
st.dispatchPointerUp(buildEvent(f.id, screen));
|
|
1597
|
+
}
|
|
1598
|
+
else if (planeDragMode.value === 1 && planeDragRef.current) {
|
|
1599
|
+
const world = st.ctx.viewport.screenToWorld(screen);
|
|
1600
|
+
const patch = cfg.buildHandleDropPatch(st.ctx.document, planeDragRef.current.index, world);
|
|
1601
|
+
// Null = invalid drop (non-convex) — clearing the drag state below
|
|
1602
|
+
// snaps the outline back to the committed plane.
|
|
1603
|
+
if (patch)
|
|
1604
|
+
st.ctx.commit(patch);
|
|
1605
|
+
}
|
|
1606
|
+
planeDragMode.value = 0;
|
|
1607
|
+
planeDragRef.current = null;
|
|
1608
|
+
planeJsPointerRef.current = null;
|
|
1609
|
+
setPlaneDrag(null);
|
|
1610
|
+
setMagnifying(false);
|
|
1611
|
+
};
|
|
1612
|
+
const cancelPlaneDrag = () => {
|
|
1613
|
+
if (planeDragMode.value === 2 && planeJsPointerRef.current) {
|
|
1614
|
+
stateRef.current.dispatchPointerCancel();
|
|
1615
|
+
}
|
|
1616
|
+
planeDragMode.value = 0;
|
|
1617
|
+
planeDragRef.current = null;
|
|
1618
|
+
planeJsPointerRef.current = null;
|
|
1619
|
+
setPlaneDrag(null);
|
|
1620
|
+
setMagnifying(false);
|
|
1621
|
+
};
|
|
1622
|
+
return Gesture.Pan()
|
|
1623
|
+
.minPointers(1)
|
|
1624
|
+
.maxPointers(1)
|
|
1625
|
+
.onStart((e) => {
|
|
1626
|
+
'worklet';
|
|
1627
|
+
planeDragEnded.value = false;
|
|
1628
|
+
planeDragMode.value = 0;
|
|
1629
|
+
planeDragTX.value = e.translationX;
|
|
1630
|
+
planeDragTY.value = e.translationY;
|
|
1631
|
+
magTouchX.value = e.x;
|
|
1632
|
+
magTouchY.value = e.y;
|
|
1633
|
+
// Hit-test at the touch-down point — onStart fires only after the
|
|
1634
|
+
// pan threshold, so back out the accumulated translation.
|
|
1635
|
+
runOnJS(beginPlaneDrag)({
|
|
1636
|
+
x: e.x - e.translationX,
|
|
1637
|
+
y: e.y - e.translationY,
|
|
1638
|
+
});
|
|
1639
|
+
})
|
|
1640
|
+
.onChange((e) => {
|
|
1641
|
+
'worklet';
|
|
1642
|
+
planeDragTX.value = e.translationX;
|
|
1643
|
+
planeDragTY.value = e.translationY;
|
|
1644
|
+
magTouchX.value = e.x;
|
|
1645
|
+
magTouchY.value = e.y;
|
|
1646
|
+
if (planeDragMode.value === 2) {
|
|
1647
|
+
runOnJS(movePlaneDragJs)({ x: e.x, y: e.y });
|
|
1648
|
+
}
|
|
1649
|
+
})
|
|
1650
|
+
.onEnd((e) => {
|
|
1651
|
+
'worklet';
|
|
1652
|
+
planeDragEnded.value = true;
|
|
1653
|
+
runOnJS(endPlaneDrag)({ x: e.x, y: e.y });
|
|
1654
|
+
})
|
|
1655
|
+
.onFinalize(() => {
|
|
1656
|
+
'worklet';
|
|
1657
|
+
if (!planeDragEnded.value)
|
|
1658
|
+
runOnJS(cancelPlaneDrag)();
|
|
1659
|
+
});
|
|
1660
|
+
};
|
|
1490
1661
|
// One finger, by active tool:
|
|
1491
1662
|
// - freehand (pen/marker/highlighter) → draw on the UI thread
|
|
1492
1663
|
// - shape tools (line/rect/…) → rubber-band on the UI thread
|
|
1493
1664
|
// - Hand → no separate gesture: viewportPan above already spans one
|
|
1494
1665
|
// finger (min 1), so panning and pinching stay one uncancelled stream
|
|
1495
1666
|
// - select → drag the hit element on the UI thread
|
|
1667
|
+
// - plane tool → handle drags on the UI thread, marking on the JS thread
|
|
1496
1668
|
// - everything else → dispatch pointer events on the JS thread
|
|
1497
1669
|
const oneFinger = freehand
|
|
1498
1670
|
? buildDrawPan(freehand)
|
|
@@ -1500,9 +1672,11 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1500
1672
|
? buildShapeDrawPan(shapeDraw)
|
|
1501
1673
|
: panViewport
|
|
1502
1674
|
? null
|
|
1503
|
-
:
|
|
1504
|
-
?
|
|
1505
|
-
:
|
|
1675
|
+
: planeEdit
|
|
1676
|
+
? buildPlaneEditPan(planeEdit)
|
|
1677
|
+
: dragSelection
|
|
1678
|
+
? buildSelectDragPan(dragSelection)
|
|
1679
|
+
: toolPan;
|
|
1506
1680
|
// Long-press only joins the race when the active tool acts on it, so it
|
|
1507
1681
|
// never pre-empts a one-finger drag/draw on tools that ignore holds.
|
|
1508
1682
|
return Gesture.Race(tap, ...(longPressEnabled ? [longPress] : []), Gesture.Simultaneous(viewportPan, pinch), ...(oneFinger ? [oneFinger] : []));
|
|
@@ -1520,6 +1694,13 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1520
1694
|
shapeDraw,
|
|
1521
1695
|
panViewport,
|
|
1522
1696
|
dragSelection,
|
|
1697
|
+
planeEdit,
|
|
1698
|
+
planeDragMode,
|
|
1699
|
+
planeDragEnded,
|
|
1700
|
+
planeDragTX,
|
|
1701
|
+
planeDragTY,
|
|
1702
|
+
planeDragStartX,
|
|
1703
|
+
planeDragStartY,
|
|
1523
1704
|
longPressEnabled,
|
|
1524
1705
|
dragX,
|
|
1525
1706
|
dragY,
|
|
@@ -1613,6 +1794,12 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1613
1794
|
? handleRingWidth
|
|
1614
1795
|
: undefined,
|
|
1615
1796
|
showPlaneHandles: activeTool?.planeEditing === true,
|
|
1797
|
+
// UI-thread plane handle drag: while set, the Skia tree swaps the plane's
|
|
1798
|
+
// static element for the live outline + handles (see AnnotationCanvasSkia).
|
|
1799
|
+
planeDragId: planeDrag?.planeId ?? null,
|
|
1800
|
+
planeDragIndex: planeDrag?.index ?? null,
|
|
1801
|
+
planeDragPath,
|
|
1802
|
+
planeDragHandle,
|
|
1616
1803
|
customPreview,
|
|
1617
1804
|
};
|
|
1618
1805
|
// Lens window for the loupe: an opaque white base + the magnified scene
|
|
@@ -89,9 +89,15 @@ export interface AnnotationCanvasSkiaProps {
|
|
|
89
89
|
value: number;
|
|
90
90
|
};
|
|
91
91
|
showPlaneHandles?: boolean;
|
|
92
|
+
planeDragId?: string | null;
|
|
93
|
+
planeDragIndex?: number | null;
|
|
94
|
+
planeDragPath?: SkPath | {
|
|
95
|
+
value: SkPath;
|
|
96
|
+
};
|
|
97
|
+
planeDragHandle?: AnimatedPoint;
|
|
92
98
|
customPreview?: ReactNode;
|
|
93
99
|
wrapContent?: (content: ReactNode) => ReactNode;
|
|
94
100
|
canvasRef?: RefObject<CanvasRef | null>;
|
|
95
101
|
}
|
|
96
|
-
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
102
|
+
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragHandle, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
97
103
|
export {};
|
|
@@ -6,7 +6,7 @@ import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeome
|
|
|
6
6
|
import { visualShapeBounds } from './shapeGeometry.js';
|
|
7
7
|
import { backgroundLayerDocRect, backgroundLayersOf, } from './backgroundLayers.js';
|
|
8
8
|
import { BackgroundLayerElement } from './elements/BackgroundImageElement.js';
|
|
9
|
-
import { PlaneElement } from './elements/PlaneElement.js';
|
|
9
|
+
import { PlaneElement, PLANE_CHROME_COLOR, PLANE_OUTLINE_WIDTH, } from './elements/PlaneElement.js';
|
|
10
10
|
import { ShapeElement } from './elements/ShapeElement.js';
|
|
11
11
|
import { StrokeElement } from './elements/StrokeElement.js';
|
|
12
12
|
// Default visual constants for the measurement-annotation line (the tile itself
|
|
@@ -80,7 +80,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
|
|
|
80
80
|
// since the function-call pattern works identically on native we use it
|
|
81
81
|
// in both Inners for consistency. Don't add hooks here; this is a plain
|
|
82
82
|
// JSX-returning helper, not a component.
|
|
83
|
-
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, customPreview, wrapContent, canvasRef, }) => {
|
|
83
|
+
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragHandle, customPreview, wrapContent, canvasRef, }) => {
|
|
84
84
|
// Doc rect a SCALE-mode plane grid covers: the first background layer (the
|
|
85
85
|
// photo being calibrated), else the document rect. Pinned to layer 0 like
|
|
86
86
|
// the tile-sizing reference — adding a second image must not re-span the
|
|
@@ -94,7 +94,16 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
94
94
|
width: effectiveCanvas.viewport.width,
|
|
95
95
|
height: effectiveCanvas.viewport.height,
|
|
96
96
|
};
|
|
97
|
-
const world = (_jsxs(Group, { transform: worldTransform, children: [backgroundLayersOf(effectiveCanvas.viewport).map((layer) => (_jsx(DraggableElement, { isDragging: layer.id === draggingId || layer.id === resizingId, transform: layer.id === resizingId ? resizeTransform : dragTransform, children: _jsx(BackgroundLayerElement, { layer: layer, resolveUrl: resolveImageUrl, skImage: backgroundSkImages?.[layer.id], viewport: viewport, screenWidth: width, screenHeight: height }) }, layer.id))), (effectiveCanvas.planes ?? []).map((plane) =>
|
|
97
|
+
const world = (_jsxs(Group, { transform: worldTransform, children: [backgroundLayersOf(effectiveCanvas.viewport).map((layer) => (_jsx(DraggableElement, { isDragging: layer.id === draggingId || layer.id === resizingId, transform: layer.id === resizingId ? resizeTransform : dragTransform, children: _jsx(BackgroundLayerElement, { layer: layer, resolveUrl: resolveImageUrl, skImage: backgroundSkImages?.[layer.id], viewport: viewport, screenWidth: width, screenHeight: height }) }, layer.id))), (effectiveCanvas.planes ?? []).map((plane) => {
|
|
98
|
+
if (plane.id === planeDragId && planeDragPath) {
|
|
99
|
+
const statics = plane.mode === 'perspective'
|
|
100
|
+
? plane.corners
|
|
101
|
+
: [plane.refLine.a, plane.refLine.b];
|
|
102
|
+
return (_jsxs(Group, { children: [_jsx(Path, { path: planeDragPath, color: PLANE_CHROME_COLOR, style: "stroke", strokeWidth: PLANE_OUTLINE_WIDTH, strokeJoin: "round" }), handleRadius != null &&
|
|
103
|
+
statics.map((p, i) => i === planeDragIndex ? null : (_jsxs(Group, { children: [_jsx(Circle, { c: p, r: handleRadius, color: PLANE_CHROME_COLOR }), _jsx(Circle, { c: p, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }, i))), handleRadius != null && planeDragHandle && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: planeDragHandle, r: handleRadius, color: PLANE_CHROME_COLOR }), _jsx(Circle, { c: planeDragHandle, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }))] }, plane.id));
|
|
104
|
+
}
|
|
105
|
+
return (_jsx(PlaneElement, { plane: plane, scaleBounds: planeScaleBounds, showHandles: showPlaneHandles === true, handleRadius: showPlaneHandles ? handleRadius : undefined, handleRingWidth: showPlaneHandles ? handleRingWidth : undefined }, plane.id));
|
|
106
|
+
}), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
|
|
98
107
|
// Line/arrow shapes support endpoint editing. When selected they show
|
|
99
108
|
// grab handles at both ends; during an endpoint drag the line renders
|
|
100
109
|
// from the live endpoints (one following the finger) — the shape twin
|
|
@@ -162,6 +171,11 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
162
171
|
}
|
|
163
172
|
if (placementOf(placed) !== 'line' || !placed.line)
|
|
164
173
|
return null;
|
|
174
|
+
// A dimension tile's line IS its calibration edge: while that plane's
|
|
175
|
+
// handle is being live-dragged, the stale committed edge would linger
|
|
176
|
+
// under the moving live outline — hide it until the drop commits.
|
|
177
|
+
if (placed.planeId && placed.planeId === planeDragId)
|
|
178
|
+
return null;
|
|
165
179
|
const isEndpointDrag = placed.id === endpointDragId;
|
|
166
180
|
const isSelected = placed.id === selectedId;
|
|
167
181
|
const p1 = isEndpointDrag && liveLineP1 ? liveLineP1 : placed.line.a;
|
|
@@ -90,6 +90,11 @@ export interface DragSelectionConfig {
|
|
|
90
90
|
hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
|
|
91
91
|
buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
|
|
92
92
|
}
|
|
93
|
+
export interface PlaneEditConfig {
|
|
94
|
+
hitHandle(doc: AnnotationCanvasState, world: Vec2, zoom: number): number | null;
|
|
95
|
+
handlePoints(doc: AnnotationCanvasState): Vec2[];
|
|
96
|
+
buildHandleDropPatch(doc: AnnotationCanvasState, index: number, world: Vec2): AnnotationDocumentPatch | null;
|
|
97
|
+
}
|
|
93
98
|
export interface Tool {
|
|
94
99
|
id: string;
|
|
95
100
|
label: string;
|
|
@@ -100,6 +105,7 @@ export interface Tool {
|
|
|
100
105
|
panViewport?: boolean;
|
|
101
106
|
dragSelection?: DragSelectionConfig;
|
|
102
107
|
planeEditing?: boolean;
|
|
108
|
+
planeEdit?: PlaneEditConfig;
|
|
103
109
|
onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
104
110
|
onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
105
111
|
onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
@@ -8,12 +8,15 @@ import { FormulaColors } from '../../../theme/colors.js';
|
|
|
8
8
|
// information: the formula purple outlines the calibrated quad / reference
|
|
9
9
|
// line (and inks its handles and dimension-tile lines — see planeTool), while
|
|
10
10
|
// the grid it casts gets its own pale pink so the two never blur.
|
|
11
|
-
|
|
11
|
+
// Exported for the live UI-thread handle drag, which redraws the outline in
|
|
12
|
+
// AnnotationCanvasSkia while this element is hidden.
|
|
13
|
+
export const PLANE_CHROME_COLOR = FormulaColors.purple;
|
|
14
|
+
export const PLANE_OUTLINE_WIDTH = 14;
|
|
15
|
+
const PLANE_COLOR = PLANE_CHROME_COLOR;
|
|
12
16
|
const PLANE_GRID_COLOR = '#FFD1F8';
|
|
13
17
|
const PLANE_GRID_OPACITY = 0.9;
|
|
14
18
|
// Doc-space stroke widths (scale with zoom, like the drawn shapes).
|
|
15
19
|
const PLANE_GRID_WIDTH = 2.5;
|
|
16
|
-
const PLANE_OUTLINE_WIDTH = 14;
|
|
17
20
|
// Cross-tick half-length (doc units) marking the ends of a scale-mode
|
|
18
21
|
// reference line, so it reads as "a measured span", not just a line.
|
|
19
22
|
const REF_TICK_LEN = 18;
|
|
@@ -25,6 +25,22 @@ const committedPlane = (doc) => doc.planes?.[0] ?? null;
|
|
|
25
25
|
const planeHandles = (plane) => plane.mode === 'perspective'
|
|
26
26
|
? plane.corners
|
|
27
27
|
: [plane.refLine.a, plane.refLine.b];
|
|
28
|
+
// Index of the committed calibration's handle under `world`, or null —
|
|
29
|
+
// shared by the tool's JS pointer path and the native UI-thread drag
|
|
30
|
+
// (Tool.planeEdit.hitHandle).
|
|
31
|
+
const hitHandleAt = (doc, world, zoom) => {
|
|
32
|
+
const plane = committedPlane(doc);
|
|
33
|
+
if (!plane)
|
|
34
|
+
return null;
|
|
35
|
+
const tol = HANDLE_GRAB_PX / zoom;
|
|
36
|
+
const tolSq = tol * tol;
|
|
37
|
+
const handles = planeHandles(plane);
|
|
38
|
+
for (let i = 0; i < handles.length; i++) {
|
|
39
|
+
if (distSq(world, handles[i]) <= tolSq)
|
|
40
|
+
return i;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
28
44
|
// The committed plane with one handle moved to `p`.
|
|
29
45
|
const withHandleMoved = (plane, index, p) => {
|
|
30
46
|
if (plane.mode === 'perspective') {
|
|
@@ -217,18 +233,26 @@ export const createPlaneTool = (options) => {
|
|
|
217
233
|
return null;
|
|
218
234
|
};
|
|
219
235
|
// Which committed-plane handle a press landed on, if any.
|
|
220
|
-
const hitHandle = (ctx, world) =>
|
|
221
|
-
|
|
236
|
+
const hitHandle = (ctx, world) => hitHandleAt(ctx.document, world, ctx.viewport.state.zoom);
|
|
237
|
+
// Drop patch for a handle drag — shared by the JS pointer path below and
|
|
238
|
+
// the native UI-thread drag (Tool.planeEdit). Null (snap back, host
|
|
239
|
+
// toasted) when the drop would break the quad.
|
|
240
|
+
const handleDropPatch = (doc, index, world) => {
|
|
241
|
+
const plane = committedPlane(doc);
|
|
222
242
|
if (!plane)
|
|
223
243
|
return null;
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return i;
|
|
244
|
+
const next = withHandleMoved(plane, index, world);
|
|
245
|
+
if (next.mode === 'perspective' &&
|
|
246
|
+
!isQuadConvexNonDegenerate(next.corners)) {
|
|
247
|
+
options.onInvalidQuad?.();
|
|
248
|
+
return null;
|
|
230
249
|
}
|
|
231
|
-
return
|
|
250
|
+
return {
|
|
251
|
+
ops: [
|
|
252
|
+
{ op: 'setPlane', plane: next },
|
|
253
|
+
...syncDimensionTileOps(doc, next),
|
|
254
|
+
],
|
|
255
|
+
};
|
|
232
256
|
};
|
|
233
257
|
return {
|
|
234
258
|
id: options.id ?? `plane-${mode}`,
|
|
@@ -236,6 +260,17 @@ export const createPlaneTool = (options) => {
|
|
|
236
260
|
(mode === 'perspective' ? 'Perspective plane' : 'Scale reference'),
|
|
237
261
|
cursor: 'crosshair',
|
|
238
262
|
planeEditing: true,
|
|
263
|
+
// UI-thread handle drags on native (see PlaneEditConfig in Tool.ts): the
|
|
264
|
+
// same pure hit-test and drop-patch the JS pointer path below uses, so
|
|
265
|
+
// both platforms share one source of truth.
|
|
266
|
+
planeEdit: {
|
|
267
|
+
hitHandle: hitHandleAt,
|
|
268
|
+
handlePoints: (doc) => {
|
|
269
|
+
const plane = committedPlane(doc);
|
|
270
|
+
return plane ? planeHandles(plane) : [];
|
|
271
|
+
},
|
|
272
|
+
buildHandleDropPatch: handleDropPatch,
|
|
273
|
+
},
|
|
239
274
|
onPointerDown(event, ctx) {
|
|
240
275
|
// Editing an existing plane wins over starting new geometry — but only
|
|
241
276
|
// when nothing is mid-mark (a quad in progress keeps collecting taps).
|
|
@@ -305,23 +340,12 @@ export const createPlaneTool = (options) => {
|
|
|
305
340
|
ctx.preview({ ops: [] });
|
|
306
341
|
if (!s.moved)
|
|
307
342
|
return;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (next.mode === 'perspective' &&
|
|
315
|
-
!isQuadConvexNonDegenerate(next.corners)) {
|
|
316
|
-
options.onInvalidQuad?.();
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
ctx.commit({
|
|
320
|
-
ops: [
|
|
321
|
-
{ op: 'setPlane', plane: next },
|
|
322
|
-
...syncDimensionTileOps(ctx.document, next),
|
|
323
|
-
],
|
|
324
|
-
});
|
|
343
|
+
// A drop that breaks the quad snaps back (handleDropPatch returns
|
|
344
|
+
// null and raises onInvalidQuad) instead of committing a plane the
|
|
345
|
+
// homography can't invert.
|
|
346
|
+
const patch = handleDropPatch(ctx.document, s.handleIndex, event.world);
|
|
347
|
+
if (patch)
|
|
348
|
+
ctx.commit(patch);
|
|
325
349
|
return;
|
|
326
350
|
}
|
|
327
351
|
// While a calibration exists, a TAP on one of its dimension tiles
|
|
@@ -105,6 +105,18 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
105
105
|
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
|
|
106
106
|
const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
|
|
107
107
|
const [toolState, setToolState] = useState(undefined);
|
|
108
|
+
// Synchronous mirror for the DISPATCHERS. The native tap gesture
|
|
109
|
+
// synthesizes pointerDown + pointerUp inside one JS task, so the up runs
|
|
110
|
+
// before React re-renders — a dispatcher reading the state closure would
|
|
111
|
+
// hand onPointerUp the PRE-tap state (undefined) and tap-driven tools
|
|
112
|
+
// (quad corners, dimension-tile taps) silently no-op. React state remains
|
|
113
|
+
// the render-side source (renderPreview / penDrawingStroke); every write
|
|
114
|
+
// goes through updateToolState so the two never drift.
|
|
115
|
+
const toolStateRef = useRef(undefined);
|
|
116
|
+
const updateToolState = (next) => {
|
|
117
|
+
toolStateRef.current = next;
|
|
118
|
+
setToolState(next);
|
|
119
|
+
};
|
|
108
120
|
const [previewPatch, setPreviewPatch] = useState(null);
|
|
109
121
|
const undoStackRef = useRef([]);
|
|
110
122
|
const redoStackRef = useRef([]);
|
|
@@ -317,17 +329,22 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
317
329
|
return;
|
|
318
330
|
prev.onDeactivate?.(ctxRef.current);
|
|
319
331
|
activePointerIdRef.current = null;
|
|
320
|
-
|
|
332
|
+
updateToolState(undefined);
|
|
321
333
|
setPreviewPatch(null);
|
|
334
|
+
// updateToolState is a stable-in-effect helper (ref + setState); the
|
|
335
|
+
// effect only re-runs on a tool identity change.
|
|
336
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
322
337
|
}, [activeTool]);
|
|
323
338
|
const dispatchPointerDown = useCallback((event) => {
|
|
324
339
|
if (!activeTool)
|
|
325
340
|
return;
|
|
326
341
|
activePointerIdRef.current = event.pointerId;
|
|
327
|
-
const next = activeTool.onPointerDown?.(event, ctx,
|
|
342
|
+
const next = activeTool.onPointerDown?.(event, ctx, toolStateRef.current);
|
|
328
343
|
if (next !== undefined)
|
|
329
|
-
|
|
330
|
-
},
|
|
344
|
+
updateToolState(next);
|
|
345
|
+
},
|
|
346
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
347
|
+
[activeTool, ctx]);
|
|
331
348
|
const dispatchPointerMove = useCallback((event) => {
|
|
332
349
|
if (!activeTool)
|
|
333
350
|
return;
|
|
@@ -335,10 +352,12 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
335
352
|
event.pointerId !== activePointerIdRef.current) {
|
|
336
353
|
return;
|
|
337
354
|
}
|
|
338
|
-
const next = activeTool.onPointerMove?.(event, ctx,
|
|
355
|
+
const next = activeTool.onPointerMove?.(event, ctx, toolStateRef.current);
|
|
339
356
|
if (next !== undefined)
|
|
340
|
-
|
|
341
|
-
},
|
|
357
|
+
updateToolState(next);
|
|
358
|
+
},
|
|
359
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
360
|
+
[activeTool, ctx]);
|
|
342
361
|
const dispatchPointerUp = useCallback((event) => {
|
|
343
362
|
if (!activeTool)
|
|
344
363
|
return;
|
|
@@ -346,10 +365,12 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
346
365
|
event.pointerId !== activePointerIdRef.current) {
|
|
347
366
|
return;
|
|
348
367
|
}
|
|
349
|
-
activeTool.onPointerUp?.(event, ctx,
|
|
368
|
+
activeTool.onPointerUp?.(event, ctx, toolStateRef.current);
|
|
350
369
|
activePointerIdRef.current = null;
|
|
351
|
-
|
|
352
|
-
},
|
|
370
|
+
updateToolState(undefined);
|
|
371
|
+
},
|
|
372
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
373
|
+
[activeTool, ctx]);
|
|
353
374
|
const dispatchLongPress = useCallback((event) => {
|
|
354
375
|
if (!activeTool)
|
|
355
376
|
return;
|
|
@@ -364,11 +385,13 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
364
385
|
// vertices visible across an interrupting two-finger pan) wins over the
|
|
365
386
|
// clear instead of being clobbered by it.
|
|
366
387
|
activePointerIdRef.current = null;
|
|
367
|
-
|
|
388
|
+
const prevToolState = toolStateRef.current;
|
|
389
|
+
updateToolState(undefined);
|
|
368
390
|
setPreviewPatch(null);
|
|
369
391
|
if (activeTool)
|
|
370
|
-
activeTool.onCancel?.(
|
|
371
|
-
|
|
392
|
+
activeTool.onCancel?.(prevToolState, ctx);
|
|
393
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
394
|
+
}, [activeTool, ctx]);
|
|
372
395
|
const pan = useCallback((deltaScreen) => {
|
|
373
396
|
setViewport((v) => panBy(v, deltaScreen));
|
|
374
397
|
}, []);
|