@reekon-tools/boldr-utils 1.6.39 → 1.7.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.native.js +71 -2
- package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
- package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +11 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +97 -28
- package/dist/annotation/data/AnnotationDataProvider.d.ts +6 -1
- package/dist/annotation/data/AnnotationDataProvider.js +4 -1
- package/dist/calculator/evaluate.d.ts +49 -0
- package/dist/calculator/evaluate.js +207 -0
- package/dist/calculator/index.d.ts +6 -0
- package/dist/calculator/index.js +10 -0
- package/dist/calculator/instance.d.ts +36 -0
- package/dist/calculator/schema.d.ts +163 -0
- package/dist/calculator/schema.js +129 -0
- package/dist/calculator/solve.d.ts +46 -0
- package/dist/calculator/solve.js +227 -0
- package/dist/calculator/units.d.ts +50 -0
- package/dist/calculator/units.js +137 -0
- package/dist/calculator/validate.d.ts +192 -0
- package/dist/calculator/validate.js +370 -0
- package/dist/exports.d.ts +2 -1
- package/dist/exports.js +4 -1
- package/package.json +3 -2
- package/dist/canvas/AnnotationCanvas.d.ts +0 -11
- package/dist/canvas/AnnotationCanvas.js +0 -10
- package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
- package/dist/canvas/AnnotationCanvas.native.js +0 -6
- package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
- package/dist/canvas/AnnotationCanvasInner.js +0 -219
- package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
- package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
- package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
- package/dist/canvas/AnnotationCanvasSkia.js +0 -20
- package/dist/canvas/Tool.d.ts +0 -38
- package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
- package/dist/canvas/elements/BackgroundImageElement.js +0 -37
- package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
- package/dist/canvas/elements/MeasurementStampElement.js +0 -30
- package/dist/canvas/elements/ShapeElement.d.ts +0 -7
- package/dist/canvas/elements/ShapeElement.js +0 -62
- package/dist/canvas/elements/StrokeElement.d.ts +0 -7
- package/dist/canvas/elements/StrokeElement.js +0 -18
- package/dist/canvas/measurementPicker.d.ts +0 -10
- package/dist/canvas/measurementPicker.js +0 -1
- package/dist/canvas/measurementStampOverlay.d.ts +0 -11
- package/dist/canvas/measurementStampOverlay.js +0 -1
- package/dist/canvas/pointerAdapter.d.ts +0 -3
- package/dist/canvas/pointerAdapter.js +0 -19
- package/dist/canvas/stampLayout.d.ts +0 -5
- package/dist/canvas/stampLayout.js +0 -14
- package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
- package/dist/canvas/tools/measurementStampTool.js +0 -37
- package/dist/canvas/tools/panTool.d.ts +0 -5
- package/dist/canvas/tools/panTool.js +0 -25
- package/dist/canvas/tools/penTool.d.ts +0 -13
- package/dist/canvas/tools/penTool.js +0 -68
- package/dist/canvas/tools/selectTool.d.ts +0 -2
- package/dist/canvas/tools/selectTool.js +0 -182
- package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
- package/dist/canvas/useAnnotationCanvasState.js +0 -210
- package/dist/canvas/viewport.d.ts +0 -16
- package/dist/canvas/viewport.js +0 -54
- package/dist/data/AnnotationDataContext.d.ts +0 -8
- package/dist/data/AnnotationDataContext.js +0 -11
- package/dist/data/AnnotationDataProvider.d.ts +0 -65
- package/dist/data/AnnotationDataProvider.js +0 -4
- package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
- package/dist/data/InMemoryAnnotationProvider.js +0 -197
- package/dist/data/canvasPersistence.d.ts +0 -3
- package/dist/data/canvasPersistence.js +0 -26
- package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
- package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
- package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
- package/dist/data/hooks/useAnnotationDoc.js +0 -33
- package/dist/data/hooks/useAnnotationList.d.ts +0 -7
- package/dist/data/hooks/useAnnotationList.js +0 -26
- package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
- package/dist/data/hooks/useAnnotationMutations.js +0 -11
- package/dist/hooks/useParseMeasurement.d.ts +0 -4
- package/dist/hooks/useParseMeasurement.js +0 -14
- package/dist/utils/evaluateFormula.d.ts +0 -20
- package/dist/utils/evaluateFormula.js +0 -31
- /package/dist/{canvas/Tool.js → calculator/instance.js} +0 -0
|
@@ -58,12 +58,20 @@ const LOUPE_CROSSHAIR_ARM = 9; // half-length of each crosshair arm, points
|
|
|
58
58
|
export const AnnotationCanvasInner = (props) => {
|
|
59
59
|
const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, } = props;
|
|
60
60
|
const valueFont = useFont(stampFontSource, stampValueFontSize);
|
|
61
|
+
// The animated-viewport implementation needs the Reanimated shared values,
|
|
62
|
+
// which are created below AFTER the state hook — so the hook gets a stable
|
|
63
|
+
// trampoline into a ref the implementation fills in each render.
|
|
64
|
+
const animateViewportImplRef = useRef(null);
|
|
65
|
+
const animateViewport = useCallback((target, durationMs) => {
|
|
66
|
+
animateViewportImplRef.current?.(target, durationMs);
|
|
67
|
+
}, []);
|
|
61
68
|
// This is the native (mobile) renderer, so the canvas reads mobile's
|
|
62
69
|
// tile-scale knob (web and mobile persist separate values — see
|
|
63
70
|
// resolveTileScaleFactor).
|
|
64
71
|
const state = useAnnotationCanvasState({
|
|
65
72
|
...props,
|
|
66
73
|
tileScalePlatform: 'mobile',
|
|
74
|
+
animateViewport,
|
|
67
75
|
});
|
|
68
76
|
// Decode the background image ONCE here and share it with both the main
|
|
69
77
|
// canvas and the magnifier loupe. If each canvas loaded its own, the loupe
|
|
@@ -620,17 +628,73 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
620
628
|
// effect so an in-flight gesture's shared values are never clobbered by a
|
|
621
629
|
// late-flushing setViewport from a previous gesture.
|
|
622
630
|
const activeViewportGestures = useRef(0);
|
|
631
|
+
// >0 while an animated viewport transition (fitContentToRect) is in
|
|
632
|
+
// flight. Guards the JS→UI sync effect the same way the gesture counter
|
|
633
|
+
// does: mid-glide re-renders must not snap the shared values back to the
|
|
634
|
+
// (still-old) JS snapshot.
|
|
635
|
+
const viewportAnimations = useRef(0);
|
|
623
636
|
// Push JS-originated viewport changes (initial mount, zoomToFit, resetView)
|
|
624
637
|
// onto the UI thread. Gesture-driven changes already live in the shared
|
|
625
638
|
// values, so during a gesture this is skipped; the post-gesture re-sync
|
|
626
|
-
// writes back identical values (no visual jump).
|
|
639
|
+
// writes back identical values (no visual jump). Ditto animated transitions.
|
|
627
640
|
useEffect(() => {
|
|
628
641
|
if (activeViewportGestures.current > 0)
|
|
629
642
|
return;
|
|
643
|
+
if (viewportAnimations.current > 0)
|
|
644
|
+
return;
|
|
630
645
|
zoom.value = state.viewport.zoom;
|
|
631
646
|
panX.value = state.viewport.pan.x;
|
|
632
647
|
panY.value = state.viewport.pan.y;
|
|
633
648
|
}, [state.viewport, zoom, panX, panY]);
|
|
649
|
+
// Animated viewport transitions (fitContentToRect with `animated`): a JS
|
|
650
|
+
// rAF driver eases the same shared values gestures write — DIRECT writes,
|
|
651
|
+
// three per frame, no React involvement until the single end-of-motion
|
|
652
|
+
// commit. Direct `.value =` writes are the proven repaint channel for the
|
|
653
|
+
// Skia consumers (it's exactly how gestures render); driving these values
|
|
654
|
+
// through reanimated's animation path (withTiming) completed without ever
|
|
655
|
+
// repainting on device, so it is deliberately not used here. Mid-flight
|
|
656
|
+
// re-renders can't stomp the drive (the sync effect above is guarded), and
|
|
657
|
+
// a viewport gesture starting mid-flight ends the drive on its next frame
|
|
658
|
+
// — before it writes — and owns the viewport from there. A new drive takes
|
|
659
|
+
// over from the live values, so interrupted transitions resume from where
|
|
660
|
+
// they actually are.
|
|
661
|
+
const viewportDriveRef = useRef(null);
|
|
662
|
+
const cancelViewportDrive = useCallback(() => {
|
|
663
|
+
if (viewportDriveRef.current != null) {
|
|
664
|
+
cancelAnimationFrame(viewportDriveRef.current);
|
|
665
|
+
viewportDriveRef.current = null;
|
|
666
|
+
viewportAnimations.current = 0;
|
|
667
|
+
}
|
|
668
|
+
}, []);
|
|
669
|
+
useEffect(() => cancelViewportDrive, [cancelViewportDrive]);
|
|
670
|
+
animateViewportImplRef.current = (target, durationMs) => {
|
|
671
|
+
cancelViewportDrive();
|
|
672
|
+
const from = { zoom: zoom.value, panX: panX.value, panY: panY.value };
|
|
673
|
+
const start = Date.now();
|
|
674
|
+
const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - (2 - 2 * t) ** 3 / 2;
|
|
675
|
+
viewportAnimations.current = 1;
|
|
676
|
+
const step = () => {
|
|
677
|
+
if (activeViewportGestures.current > 0) {
|
|
678
|
+
viewportDriveRef.current = null;
|
|
679
|
+
viewportAnimations.current = 0;
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const t = Math.min(1, (Date.now() - start) / durationMs);
|
|
683
|
+
const eased = easeInOutCubic(t);
|
|
684
|
+
zoom.value = from.zoom + (target.zoom - from.zoom) * eased;
|
|
685
|
+
panX.value = from.panX + (target.pan.x - from.panX) * eased;
|
|
686
|
+
panY.value = from.panY + (target.pan.y - from.panY) * eased;
|
|
687
|
+
if (t < 1) {
|
|
688
|
+
viewportDriveRef.current = requestAnimationFrame(step);
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
viewportDriveRef.current = null;
|
|
692
|
+
viewportAnimations.current = 0;
|
|
693
|
+
stateRef.current.setViewport(target);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
viewportDriveRef.current = requestAnimationFrame(step);
|
|
697
|
+
};
|
|
634
698
|
const gesture = useMemo(() => {
|
|
635
699
|
const buildEvent = (pointerId, screen) => ({
|
|
636
700
|
pointerId,
|
|
@@ -1427,7 +1491,12 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1427
1491
|
// magnification lives in loupeTransform, applied to `content`).
|
|
1428
1492
|
const loupeClip = rrect(rect(loupeX, loupeY, loupeSize, loupeSize), LOUPE_RADIUS, LOUPE_RADIUS);
|
|
1429
1493
|
const loupeWrap = (content) => (_jsxs(_Fragment, { children: [_jsxs(Group, { clip: loupeClip, children: [_jsx(Rect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, color: LOUPE_BG_COLOR }), content] }), _jsx(RoundedRect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, r: LOUPE_RADIUS, color: LOUPE_BORDER_COLOR, style: "stroke", strokeWidth: LOUPE_BORDER_WIDTH }), _jsx(Line, { p1: { x: loupeCx - LOUPE_CROSSHAIR_ARM, y: loupeCy }, p2: { x: loupeCx + LOUPE_CROSSHAIR_ARM, y: loupeCy }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 }), _jsx(Line, { p1: { x: loupeCx, y: loupeCy - LOUPE_CROSSHAIR_ARM }, p2: { x: loupeCx, y: loupeCy + LOUPE_CROSSHAIR_ARM }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 })] }));
|
|
1430
|
-
return (
|
|
1494
|
+
return (
|
|
1495
|
+
// overflow hidden mirrors the web container: the Skia surface clips its
|
|
1496
|
+
// drawing to the canvas box by construction, but the stamp overlay is
|
|
1497
|
+
// plain RN views — without clipping, tiles panned past the edge of a
|
|
1498
|
+
// non-fullscreen canvas (e.g. a diagram strip) escape into surrounding UI.
|
|
1499
|
+
_jsxs(GestureHandlerRootView, { style: [{ width, height, overflow: 'hidden' }, style], children: [_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
|
|
1431
1500
|
? (state.measurementsById.get(placed.measurementId) ?? null)
|
|
1432
1501
|
: (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, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
|
|
1433
1502
|
? () => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PlacedMeasurementRef } from '../../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export interface MeasurementLineToolOptions {
|
|
4
|
+
id?: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
minDragPx?: number;
|
|
7
|
+
autoSwitchToSelect?: boolean;
|
|
8
|
+
selectToolId?: string;
|
|
9
|
+
onAutoSwitch?: (toToolId: string) => void;
|
|
10
|
+
onPlaced?: (measurement: PlacedMeasurementRef) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare const createMeasurementLineTool: (options?: MeasurementLineToolOptions) => Tool;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
import { DEFAULT_LINE_POS, recomputeAnchor } from '../measurementGeometry.js';
|
|
3
|
+
let counter = 0;
|
|
4
|
+
const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
6
|
+
// Build the blank line-measurement annotation for a drag from `a` to `b`: a
|
|
7
|
+
// 2-point line with a value tile magnetized to its center (linePos 0.5). The
|
|
8
|
+
// payload mirrors useAnnotationCanvasState's placeAnnotationAtCenter so a drawn
|
|
9
|
+
// line and a (legacy) center-placed one are byte-identical once committed.
|
|
10
|
+
const buildLineMeasurement = (opts) => {
|
|
11
|
+
const line = { a: opts.a, b: opts.b };
|
|
12
|
+
return {
|
|
13
|
+
id: opts.id,
|
|
14
|
+
layerId: opts.layerId,
|
|
15
|
+
placement: 'line',
|
|
16
|
+
line,
|
|
17
|
+
linePos: DEFAULT_LINE_POS,
|
|
18
|
+
// Center of the line; recomputeAnchor keeps this in sync on edits.
|
|
19
|
+
anchor: recomputeAnchor(line, 'line', DEFAULT_LINE_POS, {
|
|
20
|
+
x: (opts.a.x + opts.b.x) / 2,
|
|
21
|
+
y: (opts.a.y + opts.b.y) / 2,
|
|
22
|
+
}),
|
|
23
|
+
showLabel: true,
|
|
24
|
+
showValue: true,
|
|
25
|
+
createdAt: Date.now(),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
// Drag-to-draw measurement-line tool. Press-drag rubber-bands a measurement
|
|
29
|
+
// annotation (a line with a blank value tile at its center); release commits
|
|
30
|
+
// it. Mirrors createShapeTool's interaction so "input lines" are placed the
|
|
31
|
+
// same way as shapes — draw to add, not tap-the-icon-to-add — replacing the
|
|
32
|
+
// old center-place affordance. The annotation stays blank (no measurement
|
|
33
|
+
// associated) until the user fills it via the tile / picker. Skia-free, like
|
|
34
|
+
// every tool factory; it renders its live preview through ctx.preview, so it
|
|
35
|
+
// works on web and (via the generic tool-pan dispatch) on native.
|
|
36
|
+
export const createMeasurementLineTool = (options = {}) => {
|
|
37
|
+
const minDragPx = options.minDragPx ?? 4;
|
|
38
|
+
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
39
|
+
const selectToolId = options.selectToolId ?? 'select';
|
|
40
|
+
return {
|
|
41
|
+
id: options.id ?? 'measure-line',
|
|
42
|
+
label: options.label ?? 'Measurement line',
|
|
43
|
+
cursor: 'crosshair',
|
|
44
|
+
onPointerDown(event) {
|
|
45
|
+
return {
|
|
46
|
+
kind: 'measurement-line-drawing',
|
|
47
|
+
id: makeId(),
|
|
48
|
+
startWorld: event.world,
|
|
49
|
+
startScreen: event.screen,
|
|
50
|
+
moved: false,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
onPointerMove(event, ctx, state) {
|
|
54
|
+
const s = state;
|
|
55
|
+
if (s?.kind !== 'measurement-line-drawing')
|
|
56
|
+
return s;
|
|
57
|
+
const measurement = buildLineMeasurement({
|
|
58
|
+
id: s.id,
|
|
59
|
+
layerId: firstLayerId(ctx.document),
|
|
60
|
+
a: s.startWorld,
|
|
61
|
+
b: event.world,
|
|
62
|
+
});
|
|
63
|
+
ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
64
|
+
return { ...s, moved: true };
|
|
65
|
+
},
|
|
66
|
+
onPointerUp(event, ctx, state) {
|
|
67
|
+
const s = state;
|
|
68
|
+
if (s?.kind !== 'measurement-line-drawing')
|
|
69
|
+
return;
|
|
70
|
+
const dx = event.screen.x - s.startScreen.x;
|
|
71
|
+
const dy = event.screen.y - s.startScreen.y;
|
|
72
|
+
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
73
|
+
// Accidental tap — discard the rubber-band, commit nothing.
|
|
74
|
+
ctx.preview({ ops: [] });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const measurement = buildLineMeasurement({
|
|
78
|
+
id: s.id,
|
|
79
|
+
layerId: firstLayerId(ctx.document),
|
|
80
|
+
a: s.startWorld,
|
|
81
|
+
b: event.world,
|
|
82
|
+
});
|
|
83
|
+
ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
84
|
+
// Leave it selected (and hand back to select) so the blank line can be
|
|
85
|
+
// filled / moved straight away — the same flow as the text tool.
|
|
86
|
+
ctx.setSelection({ ids: [measurement.id] });
|
|
87
|
+
options.onPlaced?.(measurement);
|
|
88
|
+
if (autoSwitchToSelect)
|
|
89
|
+
options.onAutoSwitch?.(selectToolId);
|
|
90
|
+
},
|
|
91
|
+
onCancel(_state, ctx) {
|
|
92
|
+
ctx.preview({ ops: [] });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
};
|
|
@@ -3,6 +3,12 @@ import { type AnnotationCanvasState, type AnnotationDocumentPatch, type Annotati
|
|
|
3
3
|
import type { MeasurementRef } from './measurementPicker.js';
|
|
4
4
|
import type { CanvasPointerEvent, RequestTextInput, Tool, ToolContext, ToolState } from './Tool.js';
|
|
5
5
|
import { type ViewportState } from './viewport.js';
|
|
6
|
+
export interface CanvasScreenRect {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
6
12
|
export interface AnnotationCanvasHandle {
|
|
7
13
|
undo(): void;
|
|
8
14
|
redo(): void;
|
|
@@ -10,6 +16,10 @@ export interface AnnotationCanvasHandle {
|
|
|
10
16
|
canRedo(): boolean;
|
|
11
17
|
zoomToFit(): void;
|
|
12
18
|
resetView(): void;
|
|
19
|
+
fitContentToRect(rect: CanvasScreenRect, opts?: {
|
|
20
|
+
animated?: boolean;
|
|
21
|
+
durationMs?: number;
|
|
22
|
+
}): void;
|
|
13
23
|
getTileScaleFactor(): number;
|
|
14
24
|
getTileDocSize(factor: number): number;
|
|
15
25
|
ensureMeasurementVisible(id: AnnotationElementId): void;
|
|
@@ -49,6 +59,7 @@ export interface UseAnnotationCanvasStateProps {
|
|
|
49
59
|
height: number;
|
|
50
60
|
initialViewport?: ViewportState;
|
|
51
61
|
tileScalePlatform?: TileScalePlatform;
|
|
62
|
+
animateViewport?: (target: ViewportState, durationMs: number) => void;
|
|
52
63
|
imperativeRef?: {
|
|
53
64
|
current: AnnotationCanvasHandle | null;
|
|
54
65
|
};
|
|
@@ -1,24 +1,68 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
|
|
3
|
-
import { resolveTileScaleFactor, stampTileDims, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
|
|
3
|
+
import { resolveTileScaleFactor, stampTileDims, STAMP_TILE_SIZE, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
|
|
4
4
|
import { createViewportApi, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
|
|
5
5
|
import { buildRemoveMeasurementOps, recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
|
|
6
|
-
// The viewport that frames the document's content
|
|
7
|
-
// image is present we fit its rendered rect (so
|
|
8
|
-
// the
|
|
9
|
-
// top-left crop a 1:1 viewport gives a high-res
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
// The viewport that frames the document's content inside a screen rect of the
|
|
7
|
+
// canvas box. When a background image is present we fit its rendered rect (so
|
|
8
|
+
// the whole image shows, filling the rect regardless of its pixel resolution —
|
|
9
|
+
// never the native-resolution top-left crop a 1:1 viewport gives a high-res
|
|
10
|
+
// image); otherwise we fit the document rect. Placed tiles count as content
|
|
11
|
+
// too: authors routinely drop them AROUND the image (dimension labels sit
|
|
12
|
+
// outside the object they measure), and a fit framed on the image alone opens
|
|
13
|
+
// with those tiles off-screen — so the rect is widened to the union of the
|
|
14
|
+
// base rect and every placed tile's doc geometry. Tiles are screen-constant
|
|
15
|
+
// boxes centered on that geometry, so when the union extends past the base
|
|
16
|
+
// rect their half-extent is reserved as screen-space padding; a canvas whose
|
|
17
|
+
// tiles all sit inside the image keeps the exact image fit it had before.
|
|
18
|
+
// Shared by the load-time auto-fit and the zoomToFit/resetView/
|
|
19
|
+
// fitContentToRect handle methods so they stay in lockstep.
|
|
20
|
+
const computeContentFitRect = (canvas, rect) => {
|
|
21
|
+
if (!(rect.width > 0) || !(rect.height > 0))
|
|
14
22
|
return DEFAULT_VIEWPORT;
|
|
15
23
|
const { viewport } = canvas;
|
|
16
24
|
const bg = viewport.backgroundImage;
|
|
17
|
-
const
|
|
25
|
+
const baseRect = bg
|
|
18
26
|
? imageDocRect(bg.widthPx, bg.heightPx, viewport.width, viewport.height, viewport.backgroundFit ?? 'contain')
|
|
19
27
|
: { x: 0, y: 0, width: viewport.width, height: viewport.height };
|
|
20
|
-
|
|
28
|
+
let minX = baseRect.x;
|
|
29
|
+
let minY = baseRect.y;
|
|
30
|
+
let maxX = baseRect.x + baseRect.width;
|
|
31
|
+
let maxY = baseRect.y + baseRect.height;
|
|
32
|
+
const extend = (p) => {
|
|
33
|
+
minX = Math.min(minX, p.x);
|
|
34
|
+
minY = Math.min(minY, p.y);
|
|
35
|
+
maxX = Math.max(maxX, p.x);
|
|
36
|
+
maxY = Math.max(maxY, p.y);
|
|
37
|
+
};
|
|
38
|
+
for (const placed of canvas.placedMeasurements) {
|
|
39
|
+
extend(placed.anchor);
|
|
40
|
+
if (placed.line) {
|
|
41
|
+
extend(placed.line.a);
|
|
42
|
+
extend(placed.line.b);
|
|
43
|
+
}
|
|
44
|
+
if (placed.rect) {
|
|
45
|
+
extend(placed.rect.a);
|
|
46
|
+
extend(placed.rect.b);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const extended = minX < baseRect.x ||
|
|
50
|
+
minY < baseRect.y ||
|
|
51
|
+
maxX > baseRect.x + baseRect.width ||
|
|
52
|
+
maxY > baseRect.y + baseRect.height;
|
|
53
|
+
const fit = fitRectToScreen({ x: minX, y: minY, width: maxX - minX, height: maxY - minY }, rect.width, rect.height, extended ? STAMP_TILE_SIZE / 2 : 0);
|
|
54
|
+
// fitRectToScreen frames within a box anchored at the origin; shift the
|
|
55
|
+
// window so the framed content lands in the sub-rect instead
|
|
56
|
+
// (screen = (world − pan) · zoom, so a +x screen offset is −x/zoom of pan).
|
|
57
|
+
return {
|
|
58
|
+
zoom: fit.zoom,
|
|
59
|
+
pan: {
|
|
60
|
+
x: fit.pan.x - rect.x / fit.zoom,
|
|
61
|
+
y: fit.pan.y - rect.y / fit.zoom,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
21
64
|
};
|
|
65
|
+
const computeContentFit = (canvas, width, height) => computeContentFitRect(canvas, { x: 0, y: 0, width, height });
|
|
22
66
|
// Default leader length (doc units) for a placed measurement when no explicit
|
|
23
67
|
// length is given. A generous fraction of the document width, clamped, so the
|
|
24
68
|
// leader — and its draggable endcaps — stay clear of the fixed-size value tile
|
|
@@ -31,7 +75,7 @@ const defaultLeaderLenDoc = (docWidth) => Math.min(800, Math.max(240, docWidth *
|
|
|
31
75
|
// inners share this hook; each wraps it with platform-specific event
|
|
32
76
|
// capture and JSX (div + DOM events vs. GestureDetector + RN Views).
|
|
33
77
|
export const useAnnotationCanvasState = (props) => {
|
|
34
|
-
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, imperativeRef, } = props;
|
|
78
|
+
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
|
|
35
79
|
const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
|
|
36
80
|
const [toolState, setToolState] = useState(undefined);
|
|
37
81
|
const [previewPatch, setPreviewPatch] = useState(null);
|
|
@@ -265,6 +309,32 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
265
309
|
],
|
|
266
310
|
});
|
|
267
311
|
};
|
|
312
|
+
// Consecutive place-at-center drops would all land dead-center, stacking
|
|
313
|
+
// each new tile exactly on top of the previous one. Cascade instead: walk
|
|
314
|
+
// down-right in fixed screen-px steps from center until a slot with no
|
|
315
|
+
// existing anchor is found. Occupancy is derived from the live placed
|
|
316
|
+
// measurements (not a drop counter), so moving or deleting a tile frees
|
|
317
|
+
// its slot automatically. Screen space is the right frame — tiles are
|
|
318
|
+
// screen-constant boxes — and mirrors the Nutrient file-viewer's cascade.
|
|
319
|
+
const cascadeCenterWorld = () => {
|
|
320
|
+
const c = ctxRef.current;
|
|
321
|
+
const CASCADE_STEP = 30;
|
|
322
|
+
const anchorsScreen = c.document.placedMeasurements.map((m) => c.viewport.worldToScreen(m.anchor));
|
|
323
|
+
const slotTaken = (x, y) => anchorsScreen.some((p) => Math.abs(p.x - x) < CASCADE_STEP / 2 &&
|
|
324
|
+
Math.abs(p.y - y) < CASCADE_STEP / 2);
|
|
325
|
+
// Stop cascading before the tile's right/bottom edge would leave the
|
|
326
|
+
// screen (tiles are centered on their anchor).
|
|
327
|
+
const halfTile = (STAMP_TILE_SIZE / 2) * c.tileScaleFactor * c.tileViewportScale;
|
|
328
|
+
let cx = width / 2;
|
|
329
|
+
let cy = height / 2;
|
|
330
|
+
while (slotTaken(cx, cy) &&
|
|
331
|
+
cx + halfTile + CASCADE_STEP <= width &&
|
|
332
|
+
cy + halfTile + CASCADE_STEP <= height) {
|
|
333
|
+
cx += CASCADE_STEP;
|
|
334
|
+
cy += CASCADE_STEP;
|
|
335
|
+
}
|
|
336
|
+
return c.viewport.screenToWorld({ x: cx, y: cy });
|
|
337
|
+
};
|
|
268
338
|
imperativeRef.current = {
|
|
269
339
|
undo() {
|
|
270
340
|
const entry = undoStackRef.current.pop();
|
|
@@ -295,6 +365,17 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
295
365
|
// for a high-res background is the very crop this is meant to escape.
|
|
296
366
|
setViewport(computeContentFit(canvas, width, height));
|
|
297
367
|
},
|
|
368
|
+
fitContentToRect(rect, opts) {
|
|
369
|
+
if (!(rect.width > 0) || !(rect.height > 0))
|
|
370
|
+
return;
|
|
371
|
+
const target = computeContentFitRect(canvas, rect);
|
|
372
|
+
if (opts?.animated && animateViewport) {
|
|
373
|
+
animateViewport(target, opts.durationMs ?? 300);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
setViewport(target);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
298
379
|
getTileScaleFactor() {
|
|
299
380
|
return ctxRef.current.tileScaleFactor;
|
|
300
381
|
},
|
|
@@ -328,10 +409,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
328
409
|
},
|
|
329
410
|
placeMeasurementAtCenter(ref) {
|
|
330
411
|
const c = ctxRef.current;
|
|
331
|
-
const anchor =
|
|
332
|
-
x: width / 2,
|
|
333
|
-
y: height / 2,
|
|
334
|
-
});
|
|
412
|
+
const anchor = cascadeCenterWorld();
|
|
335
413
|
const placed = {
|
|
336
414
|
id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
|
|
337
415
|
layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
|
|
@@ -352,10 +430,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
352
430
|
},
|
|
353
431
|
placeAnnotationAtCenter(opts) {
|
|
354
432
|
const c = ctxRef.current;
|
|
355
|
-
const center =
|
|
356
|
-
x: width / 2,
|
|
357
|
-
y: height / 2,
|
|
358
|
-
});
|
|
433
|
+
const center = cascadeCenterWorld();
|
|
359
434
|
// Default line length: a generous fraction of the doc width, clamped,
|
|
360
435
|
// so the leader and its endcaps clear the fixed-size tile at full-fit.
|
|
361
436
|
const len = opts?.defaultLengthDoc ?? defaultLeaderLenDoc(canvas.viewport.width);
|
|
@@ -492,10 +567,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
492
567
|
},
|
|
493
568
|
placeColumnTileAtCenter(binding) {
|
|
494
569
|
const c = ctxRef.current;
|
|
495
|
-
const anchor =
|
|
496
|
-
x: width / 2,
|
|
497
|
-
y: height / 2,
|
|
498
|
-
});
|
|
570
|
+
const anchor = cascadeCenterWorld();
|
|
499
571
|
const placed = {
|
|
500
572
|
id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
|
|
501
573
|
layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
|
|
@@ -513,10 +585,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
513
585
|
},
|
|
514
586
|
placeGroupHeaderAtCenter(ref) {
|
|
515
587
|
const c = ctxRef.current;
|
|
516
|
-
const anchor =
|
|
517
|
-
x: width / 2,
|
|
518
|
-
y: height / 2,
|
|
519
|
-
});
|
|
588
|
+
const anchor = cascadeCenterWorld();
|
|
520
589
|
// No showLabel/showValue/placement — a header is not an input; it
|
|
521
590
|
// renders the group's name/index, resolved live by the consumer.
|
|
522
591
|
const placed = {
|
|
@@ -12,8 +12,13 @@ export interface TemplateScope {
|
|
|
12
12
|
orgId?: string;
|
|
13
13
|
templateId: string;
|
|
14
14
|
}
|
|
15
|
-
export
|
|
15
|
+
export interface CalculatorScope {
|
|
16
|
+
kind: 'calculator';
|
|
17
|
+
calculatorId: string;
|
|
18
|
+
}
|
|
19
|
+
export type AnnotationScope = JobGroupScope | TemplateScope | CalculatorScope;
|
|
16
20
|
export declare const isTemplateScope: (s: AnnotationScope) => s is TemplateScope;
|
|
21
|
+
export declare const isCalculatorScope: (s: AnnotationScope) => s is CalculatorScope;
|
|
17
22
|
export declare const annotationScopeKey: (s: AnnotationScope | null) => string;
|
|
18
23
|
export type Unsubscribe = () => void;
|
|
19
24
|
export type FieldOp = {
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
export const isTemplateScope = (s) => 'kind' in s && s.kind === 'template';
|
|
2
|
+
export const isCalculatorScope = (s) => 'kind' in s && s.kind === 'calculator';
|
|
2
3
|
// Stable string identity for a scope, for React hook dep arrays — scope
|
|
3
4
|
// objects are typically rebuilt every render.
|
|
4
5
|
export const annotationScopeKey = (s) => s == null
|
|
5
6
|
? ''
|
|
6
7
|
: isTemplateScope(s)
|
|
7
8
|
? `template/${s.orgId ?? ''}/${s.templateId}`
|
|
8
|
-
:
|
|
9
|
+
: isCalculatorScope(s)
|
|
10
|
+
? `calculator/${s.calculatorId}`
|
|
11
|
+
: `job/${s.orgId}/${s.projectId}/${s.jobId}/${s.groupId}`;
|
|
9
12
|
export const isFieldOp = (v) => !!v &&
|
|
10
13
|
typeof v === 'object' &&
|
|
11
14
|
'kind' in v &&
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CalculatorDefinition, CalculatorEquation, CalculatorField, CalculatorFieldValue, CalculatorValues } from './schema.js';
|
|
2
|
+
export type ExpressionResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
value: number;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
error: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The unit-agnostic expression core: compile + evaluate + finiteness checks,
|
|
11
|
+
* no unit coercion. Scope values and the result share whatever unit system
|
|
12
|
+
* the caller established (canonical, for calculator equations).
|
|
13
|
+
*/
|
|
14
|
+
export declare const evaluateExpression: (expression: string, scope: Record<string, number>) => ExpressionResult;
|
|
15
|
+
/**
|
|
16
|
+
* The canonical numeric value a field contributes to an equation scope, or
|
|
17
|
+
* null when the field has no usable value. Select and instructions fields are
|
|
18
|
+
* non-computational (validation rejects mapping them into equations).
|
|
19
|
+
*/
|
|
20
|
+
export declare const numericFieldValue: (field: CalculatorField, value: CalculatorFieldValue | undefined) => number | null;
|
|
21
|
+
export type EquationResult = {
|
|
22
|
+
ok: true;
|
|
23
|
+
value: number;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
reason: 'missing-inputs';
|
|
27
|
+
missingFieldIds: string[];
|
|
28
|
+
} | {
|
|
29
|
+
ok: false;
|
|
30
|
+
reason: 'error';
|
|
31
|
+
error: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Evaluate one equation against canonical field values. Variables referencing
|
|
35
|
+
* other output fields recurse into their equations (cycle-guarded).
|
|
36
|
+
*/
|
|
37
|
+
export declare const evaluateEquation: ({ definition, equation, values, }: {
|
|
38
|
+
definition: Pick<CalculatorDefinition, "fields" | "equations">;
|
|
39
|
+
equation: CalculatorEquation;
|
|
40
|
+
values: CalculatorValues;
|
|
41
|
+
}) => EquationResult;
|
|
42
|
+
/**
|
|
43
|
+
* Evaluate every equation in the definition. Returns a result per target
|
|
44
|
+
* field id — the live-preview / runtime "recompute all outputs" entry point.
|
|
45
|
+
*/
|
|
46
|
+
export declare const evaluateOutputs: ({ definition, values, }: {
|
|
47
|
+
definition: Pick<CalculatorDefinition, "fields" | "equations">;
|
|
48
|
+
values: CalculatorValues;
|
|
49
|
+
}) => Record<string, EquationResult>;
|