@reekon-tools/boldr-utils 1.6.13 → 1.6.15
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/AnnotationCanvas.native.d.ts +2 -2
- package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.js +51 -13
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +370 -57
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +2 -2
- package/dist/annotation/canvas/Tool.d.ts +10 -0
- package/dist/annotation/canvas/elements/ShapeElement.js +115 -38
- package/dist/annotation/canvas/measurementGeometry.d.ts +1 -0
- package/dist/annotation/canvas/measurementGeometry.js +61 -2
- package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
- package/dist/annotation/canvas/shapeGeometry.js +116 -0
- package/dist/annotation/canvas/stampLayout.d.ts +4 -0
- package/dist/annotation/canvas/stampLayout.js +25 -9
- package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
- package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
- package/dist/annotation/canvas/tools/measurementTool.d.ts +15 -0
- package/dist/annotation/canvas/tools/measurementTool.js +133 -0
- package/dist/annotation/canvas/tools/panTool.d.ts +1 -0
- package/dist/annotation/canvas/tools/panTool.js +38 -5
- package/dist/annotation/canvas/tools/penTool.js +5 -1
- package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
- package/dist/annotation/canvas/tools/polygonTool.js +162 -0
- package/dist/annotation/canvas/tools/selectTool.js +37 -76
- package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
- package/dist/annotation/canvas/tools/shapeTool.js +111 -0
- package/dist/annotation/canvas/tools/textTool.d.ts +3 -1
- package/dist/annotation/canvas/tools/textTool.js +28 -3
- package/dist/annotation/canvas/useAnnotationCanvasState.js +27 -3
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +83 -24
- package/dist/exports.d.ts +8 -4
- package/dist/exports.js +7 -3
- package/dist/formulas/calculateFormula.js +1 -3
- package/dist/types/annotation.d.ts +4 -0
- package/dist/types/firestore.d.ts +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
import { DEFAULT_LINE_POS, recomputeAnchor, rectCenter, } 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 measurement annotation for a drag a→b (or a single point for
|
|
7
|
+
// the bare stamp). The payload mirrors useAnnotationCanvasState's
|
|
8
|
+
// placeAnnotationAtCenter so a drawn annotation is identical to a (legacy)
|
|
9
|
+
// center-placed one once committed.
|
|
10
|
+
const buildMeasurement = (opts) => {
|
|
11
|
+
const { id, layerId, placement, linePos, a, b } = opts;
|
|
12
|
+
const base = {
|
|
13
|
+
id,
|
|
14
|
+
layerId,
|
|
15
|
+
showLabel: true,
|
|
16
|
+
showValue: true,
|
|
17
|
+
createdAt: Date.now(),
|
|
18
|
+
};
|
|
19
|
+
if (placement === 'rectangle') {
|
|
20
|
+
const rect = { a, b };
|
|
21
|
+
return { ...base, placement: 'rectangle', rect, anchor: rectCenter(rect) };
|
|
22
|
+
}
|
|
23
|
+
if (placement === 'none') {
|
|
24
|
+
// Bare value tile at the tap point — no line/rect geometry.
|
|
25
|
+
return { ...base, placement: 'none', anchor: b };
|
|
26
|
+
}
|
|
27
|
+
const line = { a, b };
|
|
28
|
+
return {
|
|
29
|
+
...base,
|
|
30
|
+
placement: 'line',
|
|
31
|
+
line,
|
|
32
|
+
linePos,
|
|
33
|
+
// Tile anchored along the line at linePos; recomputeAnchor keeps it in
|
|
34
|
+
// sync on later edits.
|
|
35
|
+
anchor: recomputeAnchor(line, 'line', linePos, {
|
|
36
|
+
x: (a.x + b.x) / 2,
|
|
37
|
+
y: (a.y + b.y) / 2,
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
// Draw-to-add measurement tool. For 'line'/'rectangle' a press-drag rubber-bands
|
|
42
|
+
// the annotation (a line/rect with a blank value tile) and release commits it;
|
|
43
|
+
// for 'none' a tap drops a bare value tile. Mirrors createShapeTool's
|
|
44
|
+
// interaction so measurements are placed the same way as shapes. The annotation
|
|
45
|
+
// stays blank (no measurement associated) until the user fills it via the tile
|
|
46
|
+
// / keypad. Skia-free; renders its live preview through ctx.preview, so it works
|
|
47
|
+
// on web and (via the generic tool-pan dispatch) on native.
|
|
48
|
+
export const createMeasurementTool = (options = {}) => {
|
|
49
|
+
const placement = options.placement ?? 'line';
|
|
50
|
+
const linePos = options.linePos ?? DEFAULT_LINE_POS;
|
|
51
|
+
const minDragPx = options.minDragPx ?? 4;
|
|
52
|
+
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
53
|
+
const selectToolId = options.selectToolId ?? 'select';
|
|
54
|
+
const place = (ctx, measurement) => {
|
|
55
|
+
ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
56
|
+
ctx.setSelection({ ids: [measurement.id] });
|
|
57
|
+
options.onPlaced?.(measurement);
|
|
58
|
+
if (autoSwitchToSelect)
|
|
59
|
+
options.onAutoSwitch?.(selectToolId);
|
|
60
|
+
};
|
|
61
|
+
// Bare stamp: tap-to-place, no rubber-band.
|
|
62
|
+
if (placement === 'none') {
|
|
63
|
+
return {
|
|
64
|
+
id: options.id ?? 'measure-bare',
|
|
65
|
+
label: options.label ?? 'Measurement stamp',
|
|
66
|
+
cursor: 'copy',
|
|
67
|
+
onPointerUp(event, ctx) {
|
|
68
|
+
place(ctx, buildMeasurement({
|
|
69
|
+
id: makeId(),
|
|
70
|
+
layerId: firstLayerId(ctx.document),
|
|
71
|
+
placement: 'none',
|
|
72
|
+
linePos,
|
|
73
|
+
a: event.world,
|
|
74
|
+
b: event.world,
|
|
75
|
+
}));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const defaultId = placement === 'rectangle' ? 'measure-rect' : 'measure-line';
|
|
80
|
+
const defaultLabel = placement === 'rectangle' ? 'Measurement rectangle' : 'Measurement line';
|
|
81
|
+
return {
|
|
82
|
+
id: options.id ?? defaultId,
|
|
83
|
+
label: options.label ?? defaultLabel,
|
|
84
|
+
cursor: 'crosshair',
|
|
85
|
+
onPointerDown(event) {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'measurement-drawing',
|
|
88
|
+
id: makeId(),
|
|
89
|
+
startWorld: event.world,
|
|
90
|
+
startScreen: event.screen,
|
|
91
|
+
moved: false,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
onPointerMove(event, ctx, state) {
|
|
95
|
+
const s = state;
|
|
96
|
+
if (s?.kind !== 'measurement-drawing')
|
|
97
|
+
return s;
|
|
98
|
+
const measurement = buildMeasurement({
|
|
99
|
+
id: s.id,
|
|
100
|
+
layerId: firstLayerId(ctx.document),
|
|
101
|
+
placement,
|
|
102
|
+
linePos,
|
|
103
|
+
a: s.startWorld,
|
|
104
|
+
b: event.world,
|
|
105
|
+
});
|
|
106
|
+
ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
107
|
+
return { ...s, moved: true };
|
|
108
|
+
},
|
|
109
|
+
onPointerUp(event, ctx, state) {
|
|
110
|
+
const s = state;
|
|
111
|
+
if (s?.kind !== 'measurement-drawing')
|
|
112
|
+
return;
|
|
113
|
+
const dx = event.screen.x - s.startScreen.x;
|
|
114
|
+
const dy = event.screen.y - s.startScreen.y;
|
|
115
|
+
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
116
|
+
// Accidental tap — discard the rubber-band, commit nothing.
|
|
117
|
+
ctx.preview({ ops: [] });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
place(ctx, buildMeasurement({
|
|
121
|
+
id: s.id,
|
|
122
|
+
layerId: firstLayerId(ctx.document),
|
|
123
|
+
placement,
|
|
124
|
+
linePos,
|
|
125
|
+
a: s.startWorld,
|
|
126
|
+
b: event.world,
|
|
127
|
+
}));
|
|
128
|
+
},
|
|
129
|
+
onCancel(_state, ctx) {
|
|
130
|
+
ctx.preview({ ops: [] });
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
};
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { hitPlacedMeasurement } from '../measurementGeometry.js';
|
|
2
|
+
// Screen-px movement beyond which a gesture counts as a pan, not a tap. The
|
|
3
|
+
// native canvas never dispatches pans to JS handlers (they run on the UI
|
|
4
|
+
// thread), so this only guards web's down→jitter→up clicks.
|
|
5
|
+
const TAP_SLOP_PX = 6;
|
|
1
6
|
// Built-in "Hand" tool. When active, any drag pans the viewport. Tools that
|
|
2
7
|
// want one-off pan triggers (middle-mouse, space-drag) get those via the
|
|
3
8
|
// AnnotationCanvas `gestures` prop without needing to switch tools.
|
|
@@ -6,10 +11,16 @@ export const createPanTool = (options = {}) => ({
|
|
|
6
11
|
label: 'Hand',
|
|
7
12
|
cursor: options.cursor ?? 'grab',
|
|
8
13
|
// Native pans the viewport on the UI thread (see Tool.panViewport). The
|
|
9
|
-
// onPointer* handlers below remain the web/parity implementation
|
|
14
|
+
// onPointer* handlers below remain the web/parity implementation; taps
|
|
15
|
+
// still reach them on native via the synthesized down+up dispatch.
|
|
10
16
|
panViewport: true,
|
|
11
17
|
onPointerDown(event, _ctx, _state) {
|
|
12
|
-
return {
|
|
18
|
+
return {
|
|
19
|
+
kind: 'panning',
|
|
20
|
+
startScreen: event.screen,
|
|
21
|
+
lastScreen: event.screen,
|
|
22
|
+
moved: false,
|
|
23
|
+
};
|
|
13
24
|
},
|
|
14
25
|
onPointerMove(event, ctx, state) {
|
|
15
26
|
const s = state;
|
|
@@ -20,9 +31,31 @@ export const createPanTool = (options = {}) => ({
|
|
|
20
31
|
y: event.screen.y - s.lastScreen.y,
|
|
21
32
|
};
|
|
22
33
|
ctx.applyPan(delta);
|
|
23
|
-
|
|
34
|
+
const dx = event.screen.x - s.startScreen.x;
|
|
35
|
+
const dy = event.screen.y - s.startScreen.y;
|
|
36
|
+
return {
|
|
37
|
+
...s,
|
|
38
|
+
lastScreen: event.screen,
|
|
39
|
+
moved: s.moved || dx * dx + dy * dy > TAP_SLOP_PX * TAP_SLOP_PX,
|
|
40
|
+
};
|
|
24
41
|
},
|
|
25
|
-
onPointerUp(
|
|
26
|
-
// No commit — viewport state is canvas-internal, not persisted.
|
|
42
|
+
onPointerUp(event, ctx, state) {
|
|
43
|
+
// No commit — viewport state is canvas-internal, not persisted. A clean
|
|
44
|
+
// tap optionally selects the measurement under it (see options).
|
|
45
|
+
const s = state;
|
|
46
|
+
if (!options.selectMeasurementsOnTap)
|
|
47
|
+
return;
|
|
48
|
+
if (s?.kind === 'panning' && s.moved)
|
|
49
|
+
return;
|
|
50
|
+
const zoom = ctx.viewport.state.zoom;
|
|
51
|
+
const measurements = ctx.document.placedMeasurements;
|
|
52
|
+
for (let i = measurements.length - 1; i >= 0; i--) {
|
|
53
|
+
const m = measurements[i];
|
|
54
|
+
if (hitPlacedMeasurement(m, event.world, zoom)) {
|
|
55
|
+
ctx.setSelection({ ids: [m.id] });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
ctx.setSelection(null);
|
|
27
60
|
},
|
|
28
61
|
});
|
|
@@ -20,7 +20,11 @@ export const createPenTool = (options = {}) => {
|
|
|
20
20
|
const dotCap = cap === 'butt' || cap === 'arrow' ? 'round' : cap;
|
|
21
21
|
return {
|
|
22
22
|
id: variant,
|
|
23
|
-
label: variant === 'pen'
|
|
23
|
+
label: variant === 'pen'
|
|
24
|
+
? 'Pen'
|
|
25
|
+
: variant === 'marker'
|
|
26
|
+
? 'Marker'
|
|
27
|
+
: 'Highlighter',
|
|
24
28
|
cursor: 'crosshair',
|
|
25
29
|
// Drives UI-thread drawing on native (see FreehandConfig). The
|
|
26
30
|
// onPointerDown/Move/Up below remain the web/parity implementation.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AnnotationShape } from '../../../types/annotation.js';
|
|
2
|
+
import type { Tool } from '../Tool.js';
|
|
3
|
+
export interface PolygonToolOptions {
|
|
4
|
+
id?: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
color?: string;
|
|
7
|
+
width?: number;
|
|
8
|
+
dash?: boolean;
|
|
9
|
+
onPlaced?: (shape: AnnotationShape) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare const createPolygonTool: (options?: PolygonToolOptions) => Tool;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
// Screen-px radius for "tapped an existing vertex": the first vertex closes
|
|
3
|
+
// the polygon, the last vertex finishes it open. Converted to doc space via
|
|
4
|
+
// the live zoom so the target is a constant on-screen size.
|
|
5
|
+
const VERTEX_TAP_PX = 18;
|
|
6
|
+
// Screen-px radius of the placed-vertex dots drawn while building (the first
|
|
7
|
+
// vertex grows once the polygon is closable, to advertise the close target).
|
|
8
|
+
const VERTEX_DOT_PX = 5;
|
|
9
|
+
const CLOSE_TARGET_DOT_PX = 8;
|
|
10
|
+
let counter = 0;
|
|
11
|
+
const makeId = () => `polygon-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
12
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
13
|
+
const distSq = (a, b) => {
|
|
14
|
+
const dx = a.x - b.x;
|
|
15
|
+
const dy = a.y - b.y;
|
|
16
|
+
return dx * dx + dy * dy;
|
|
17
|
+
};
|
|
18
|
+
// Tap-to-place polygon tool. Each tap (or drag-release) adds a vertex;
|
|
19
|
+
// tapping the FIRST vertex closes the ring and commits; tapping the LAST
|
|
20
|
+
// vertex again (a double-tap, in practice) commits it as an open polyline.
|
|
21
|
+
// Switching tools mid-build commits the open polyline too (work is never
|
|
22
|
+
// silently discarded) via onDeactivate. In-progress geometry renders through
|
|
23
|
+
// preview patches — the polyline plus a dot per placed vertex — so it needs
|
|
24
|
+
// no platform-specific preview plumbing. Skia-free, like every tool factory.
|
|
25
|
+
export const createPolygonTool = (options = {}) => {
|
|
26
|
+
const color = options.color ?? '#111827';
|
|
27
|
+
const width = options.width ?? 2;
|
|
28
|
+
const dash = options.dash ?? false;
|
|
29
|
+
// In-progress polygon (spans gestures — see PolygonGestureState).
|
|
30
|
+
let vertices = [];
|
|
31
|
+
let shapeId = null;
|
|
32
|
+
const buildShape = (doc, points, closed) => ({
|
|
33
|
+
id: shapeId ?? makeId(),
|
|
34
|
+
layerId: firstLayerId(doc),
|
|
35
|
+
kind: 'polygon',
|
|
36
|
+
// Snapshot the array: `points` is usually the live `vertices` buffer, and
|
|
37
|
+
// downstream memoization (ShapeElement keys its Skia path on
|
|
38
|
+
// geometry.points IDENTITY) must see a new array per emit — handing out
|
|
39
|
+
// the shared reference froze the preview polyline at its first segment.
|
|
40
|
+
geometry: { points: [...points], ...(closed ? {} : { closed: false }) },
|
|
41
|
+
style: { stroke: color, strokeWidth: width, ...(dash && { dash: true }) },
|
|
42
|
+
createdAt: Date.now(),
|
|
43
|
+
});
|
|
44
|
+
// Preview = the open polyline so far (+ the in-drag rubber point) plus a
|
|
45
|
+
// dot per placed vertex. Dots are preview-only ellipse shapes — they ride
|
|
46
|
+
// the same patch and never get committed. Sized in doc units from the live
|
|
47
|
+
// zoom at emit time (close enough; the preview re-emits on every event).
|
|
48
|
+
const previewOps = (doc, zoom, rubber) => {
|
|
49
|
+
if (vertices.length === 0)
|
|
50
|
+
return [];
|
|
51
|
+
const pts = rubber ? [...vertices, rubber] : vertices;
|
|
52
|
+
const ops = [];
|
|
53
|
+
if (pts.length >= 2) {
|
|
54
|
+
ops.push({
|
|
55
|
+
op: 'addShape',
|
|
56
|
+
shape: { ...buildShape(doc, pts, false), id: `${shapeId}-draft` },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const closable = vertices.length >= 3;
|
|
60
|
+
vertices.forEach((v, i) => {
|
|
61
|
+
const px = i === 0 && closable ? CLOSE_TARGET_DOT_PX : VERTEX_DOT_PX;
|
|
62
|
+
const r = px / zoom;
|
|
63
|
+
ops.push({
|
|
64
|
+
op: 'addShape',
|
|
65
|
+
shape: {
|
|
66
|
+
id: `${shapeId}-v${i}`,
|
|
67
|
+
layerId: firstLayerId(doc),
|
|
68
|
+
kind: 'ellipse',
|
|
69
|
+
geometry: {
|
|
70
|
+
points: [
|
|
71
|
+
{ x: v.x - r, y: v.y - r },
|
|
72
|
+
{ x: v.x + r, y: v.y + r },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
style: { fill: color, stroke: '#FFFFFF', strokeWidth: r / 3 },
|
|
76
|
+
createdAt: 0,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
return ops;
|
|
81
|
+
};
|
|
82
|
+
const reset = () => {
|
|
83
|
+
vertices = [];
|
|
84
|
+
shapeId = null;
|
|
85
|
+
};
|
|
86
|
+
const commit = (ctx, closed) => {
|
|
87
|
+
const shape = buildShape(ctx.document, vertices, closed);
|
|
88
|
+
ctx.commit({ ops: [{ op: 'addShape', shape }] });
|
|
89
|
+
reset();
|
|
90
|
+
options.onPlaced?.(shape);
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
id: options.id ?? 'polygon',
|
|
94
|
+
label: options.label ?? 'Polygon',
|
|
95
|
+
cursor: 'crosshair',
|
|
96
|
+
onPointerDown() {
|
|
97
|
+
return { kind: 'polygon-pointing' };
|
|
98
|
+
},
|
|
99
|
+
onPointerMove(event, ctx, state) {
|
|
100
|
+
const s = state;
|
|
101
|
+
if (s?.kind !== 'polygon-pointing')
|
|
102
|
+
return s;
|
|
103
|
+
if (vertices.length === 0)
|
|
104
|
+
return s;
|
|
105
|
+
ctx.preview({
|
|
106
|
+
ops: previewOps(ctx.document, ctx.viewport.state.zoom, event.world),
|
|
107
|
+
});
|
|
108
|
+
return s;
|
|
109
|
+
},
|
|
110
|
+
onPointerUp(event, ctx, state) {
|
|
111
|
+
const s = state;
|
|
112
|
+
if (s?.kind !== 'polygon-pointing')
|
|
113
|
+
return;
|
|
114
|
+
const zoom = ctx.viewport.state.zoom;
|
|
115
|
+
const tol = VERTEX_TAP_PX / zoom;
|
|
116
|
+
const tolSq = tol * tol;
|
|
117
|
+
const p = event.world;
|
|
118
|
+
// Tap on the first vertex → close the ring and commit.
|
|
119
|
+
if (vertices.length >= 3 && distSq(p, vertices[0]) <= tolSq) {
|
|
120
|
+
commit(ctx, true);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Tap on the last vertex (tap-again / double-tap) → finish open.
|
|
124
|
+
if (vertices.length >= 1 &&
|
|
125
|
+
distSq(p, vertices[vertices.length - 1]) <= tolSq) {
|
|
126
|
+
if (vertices.length >= 2) {
|
|
127
|
+
commit(ctx, false);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
// Re-tapped the only vertex — treat as "never mind".
|
|
131
|
+
reset();
|
|
132
|
+
ctx.preview({ ops: [] });
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!shapeId)
|
|
137
|
+
shapeId = makeId();
|
|
138
|
+
// Immutable append (defense in depth alongside buildShape's snapshot):
|
|
139
|
+
// nothing downstream can ever observe this buffer mutating.
|
|
140
|
+
vertices = [...vertices, { x: p.x, y: p.y }];
|
|
141
|
+
ctx.preview({ ops: previewOps(ctx.document, zoom) });
|
|
142
|
+
},
|
|
143
|
+
// Gesture interrupted (e.g. a second finger landed and the viewport pan
|
|
144
|
+
// took over). Keep the placed vertices and re-establish the preview — the
|
|
145
|
+
// canvas clears the preview patch before calling this.
|
|
146
|
+
onCancel(_state, ctx) {
|
|
147
|
+
if (vertices.length === 0)
|
|
148
|
+
return;
|
|
149
|
+
ctx.preview({ ops: previewOps(ctx.document, ctx.viewport.state.zoom) });
|
|
150
|
+
},
|
|
151
|
+
// Switching tools mid-build: commit what exists as an open polyline (two
|
|
152
|
+
// or more vertices make a real shape; a lone vertex is discarded).
|
|
153
|
+
onDeactivate(ctx) {
|
|
154
|
+
if (vertices.length >= 2) {
|
|
155
|
+
commit(ctx, false);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
reset();
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor,
|
|
1
|
+
import { stampTileSize } from '../stampLayout.js';
|
|
2
|
+
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
|
|
3
|
+
import { hitShapeOutline } from '../shapeGeometry.js';
|
|
3
4
|
import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
|
|
4
5
|
const HIT_PADDING = 6;
|
|
5
6
|
// Hit-test in doc-space. Crude but fast — good enough for v1; tools can
|
|
@@ -14,9 +15,9 @@ const hitStroke = (stroke, p) => {
|
|
|
14
15
|
}
|
|
15
16
|
return false;
|
|
16
17
|
};
|
|
17
|
-
// Screen-space grab tolerance (px)
|
|
18
|
-
// to doc space via zoom
|
|
19
|
-
const
|
|
18
|
+
// Screen-space grab tolerance (px) added around a geometric shape's outline
|
|
19
|
+
// (line/arrow/rect/ellipse/polygon), converted to doc space via zoom.
|
|
20
|
+
const SHAPE_GRAB_PX = 12;
|
|
20
21
|
// Screen-px radius of the center snap detent when sliding a tile along its line.
|
|
21
22
|
// Converted to t-space per line via (SNAP_PX / zoom) / lineLength. The native
|
|
22
23
|
// slide worklet inlines the same value — keep them in sync.
|
|
@@ -24,39 +25,6 @@ const SLIDE_SNAP_PX = 12;
|
|
|
24
25
|
// Screen-px grab radius for a line-annotation endpoint handle (converted to doc
|
|
25
26
|
// space via zoom). A bit larger than the drawn handle so it's easy to grab.
|
|
26
27
|
const HANDLE_GRAB_PX = 22;
|
|
27
|
-
const hitMeasurement = (m, p, zoom = 1) => {
|
|
28
|
-
// The stamp renders as a constant *screen*-size square centered on the
|
|
29
|
-
// anchor, so its doc-space footprint shrinks as you zoom in. Convert the
|
|
30
|
-
// screen-space half-extent (+ padding) back to doc space via the zoom so
|
|
31
|
-
// the hit box always matches what's drawn.
|
|
32
|
-
const scale = m.scale ?? 1;
|
|
33
|
-
const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
|
|
34
|
-
const dx = Math.abs(p.x - m.anchor.x);
|
|
35
|
-
const dy = Math.abs(p.y - m.anchor.y);
|
|
36
|
-
if (dx <= half && dy <= half)
|
|
37
|
-
return true;
|
|
38
|
-
// Measurement annotation: also grab anywhere along the line body.
|
|
39
|
-
if (m.line) {
|
|
40
|
-
const r = LINE_GRAB_PX / zoom;
|
|
41
|
-
if (segmentDistanceSq(p.x, p.y, m.line.a.x, m.line.a.y, m.line.b.x, m.line.b.y) <=
|
|
42
|
-
r * r) {
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
// Rectangle annotation: grab anywhere along the border (the interior stays
|
|
47
|
-
// transparent to hits so elements behind the rect remain selectable).
|
|
48
|
-
if (m.rect && placementOf(m) === 'rectangle') {
|
|
49
|
-
const n = normalizeRect(m.rect);
|
|
50
|
-
const r2 = (LINE_GRAB_PX / zoom) ** 2;
|
|
51
|
-
if (segmentDistanceSq(p.x, p.y, n.minX, n.minY, n.maxX, n.minY) <= r2 ||
|
|
52
|
-
segmentDistanceSq(p.x, p.y, n.maxX, n.minY, n.maxX, n.maxY) <= r2 ||
|
|
53
|
-
segmentDistanceSq(p.x, p.y, n.maxX, n.maxY, n.minX, n.maxY) <= r2 ||
|
|
54
|
-
segmentDistanceSq(p.x, p.y, n.minX, n.maxY, n.minX, n.minY) <= r2) {
|
|
55
|
-
return true;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return false;
|
|
59
|
-
};
|
|
60
28
|
const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
|
|
61
29
|
const abx = bx - ax;
|
|
62
30
|
const aby = by - ay;
|
|
@@ -73,48 +41,32 @@ const findHit = (doc, world, zoom) => {
|
|
|
73
41
|
// Hit-test in z-order (top first): measurements > shapes > strokes.
|
|
74
42
|
for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
|
|
75
43
|
const m = doc.placedMeasurements[i];
|
|
76
|
-
if (
|
|
44
|
+
if (hitPlacedMeasurement(m, world, zoom)) {
|
|
77
45
|
return { id: m.id, kind: 'measurement' };
|
|
46
|
+
}
|
|
78
47
|
}
|
|
79
48
|
for (let i = doc.shapes.length - 1; i >= 0; i--) {
|
|
80
|
-
// Default shape hit test: bounding box of the shape's points + padding.
|
|
81
|
-
// Text shapes store only their top-left anchor, so their box comes from
|
|
82
|
-
// the estimated text bounds instead.
|
|
83
49
|
const s = doc.shapes[i];
|
|
84
|
-
let minX;
|
|
85
|
-
let maxX;
|
|
86
|
-
let minY;
|
|
87
|
-
let maxY;
|
|
88
50
|
if (s.kind === 'text') {
|
|
51
|
+
// Text shapes store only their top-left anchor, so their hit box comes
|
|
52
|
+
// from the estimated text bounds.
|
|
89
53
|
const b = textShapeBounds(s);
|
|
90
54
|
if (!b)
|
|
91
55
|
continue;
|
|
92
|
-
(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
continue;
|
|
98
|
-
minX = pts[0].x;
|
|
99
|
-
maxX = pts[0].x;
|
|
100
|
-
minY = pts[0].y;
|
|
101
|
-
maxY = pts[0].y;
|
|
102
|
-
for (let j = 1; j < pts.length; j++) {
|
|
103
|
-
const p = pts[j];
|
|
104
|
-
if (p.x < minX)
|
|
105
|
-
minX = p.x;
|
|
106
|
-
if (p.x > maxX)
|
|
107
|
-
maxX = p.x;
|
|
108
|
-
if (p.y < minY)
|
|
109
|
-
minY = p.y;
|
|
110
|
-
if (p.y > maxY)
|
|
111
|
-
maxY = p.y;
|
|
56
|
+
if (world.x >= b.minX - HIT_PADDING &&
|
|
57
|
+
world.x <= b.maxX + HIT_PADDING &&
|
|
58
|
+
world.y >= b.minY - HIT_PADDING &&
|
|
59
|
+
world.y <= b.maxY + HIT_PADDING) {
|
|
60
|
+
return { id: s.id, kind: 'shape' };
|
|
112
61
|
}
|
|
62
|
+
continue;
|
|
113
63
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
64
|
+
// Geometric shapes hit on their painted outline, not their bounding box —
|
|
65
|
+
// a long diagonal line's box would swallow taps nowhere near the ink, and
|
|
66
|
+
// a shape's interior stays transparent to hits so elements inside remain
|
|
67
|
+
// selectable. Grab radius = half the stroke + a screen-constant pad.
|
|
68
|
+
const tol = (s.style.strokeWidth ?? 2) / 2 + SHAPE_GRAB_PX / zoom;
|
|
69
|
+
if (hitShapeOutline(s, world, tol)) {
|
|
118
70
|
return { id: s.id, kind: 'shape' };
|
|
119
71
|
}
|
|
120
72
|
}
|
|
@@ -180,8 +132,8 @@ const classifyGrab = (doc, id, world, zoom) => {
|
|
|
180
132
|
const m = doc.placedMeasurements.find((x) => x.id === id);
|
|
181
133
|
if (!m)
|
|
182
134
|
return 'move';
|
|
183
|
-
|
|
184
|
-
const half = ((
|
|
135
|
+
// Same footprint the tile is drawn at (smaller for unassociated inputs, #6).
|
|
136
|
+
const half = (stampTileSize(m) / 2 + HIT_PADDING) / zoom;
|
|
185
137
|
const onTile = Math.abs(world.x - m.anchor.x) <= half &&
|
|
186
138
|
Math.abs(world.y - m.anchor.y) <= half;
|
|
187
139
|
return onTile && placementOf(m) === 'line' && m.line ? 'slide' : 'move';
|
|
@@ -203,7 +155,9 @@ const slidePatch = (doc, id, delta, zoom) => {
|
|
|
203
155
|
const snapT = SLIDE_SNAP_PX / zoom / Math.sqrt(lenSq);
|
|
204
156
|
t = snapLinePos(t, snapT);
|
|
205
157
|
const anchor = lerp(m.line.a, m.line.b, t);
|
|
206
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }],
|
|
160
|
+
};
|
|
207
161
|
};
|
|
208
162
|
// Which endpoint handle of a (selected) line annotation is under `world`.
|
|
209
163
|
// Prefers the nearer endpoint when both are within range.
|
|
@@ -271,7 +225,11 @@ const rectCornerPatch = (doc, id, corner, delta) => {
|
|
|
271
225
|
};
|
|
272
226
|
return {
|
|
273
227
|
ops: [
|
|
274
|
-
{
|
|
228
|
+
{
|
|
229
|
+
op: 'updateMeasurement',
|
|
230
|
+
id,
|
|
231
|
+
patch: { rect, anchor: rectCenter(rect) },
|
|
232
|
+
},
|
|
275
233
|
],
|
|
276
234
|
};
|
|
277
235
|
};
|
|
@@ -417,7 +375,10 @@ export const createSelectTool = () => ({
|
|
|
417
375
|
const s = state;
|
|
418
376
|
if (s?.kind !== 'dragging')
|
|
419
377
|
return s;
|
|
420
|
-
const delta = {
|
|
378
|
+
const delta = {
|
|
379
|
+
x: event.world.x - s.start.x,
|
|
380
|
+
y: event.world.y - s.start.y,
|
|
381
|
+
};
|
|
421
382
|
const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
|
|
422
383
|
if (patch)
|
|
423
384
|
ctx.preview(patch);
|
|
@@ -438,7 +399,7 @@ export const createSelectTool = () => ({
|
|
|
438
399
|
},
|
|
439
400
|
hitTest(element, p) {
|
|
440
401
|
if (element.kind === 'measurement')
|
|
441
|
-
return
|
|
402
|
+
return hitPlacedMeasurement(element, p);
|
|
442
403
|
if (element.kind === 'stroke')
|
|
443
404
|
return hitStroke(element, p);
|
|
444
405
|
return false;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AnnotationShape, StrokeCap, Vec2 } from '../../../types/annotation.js';
|
|
2
|
+
import { type ShapeToolKind } from '../shapeGeometry.js';
|
|
3
|
+
import type { Tool } from '../Tool.js';
|
|
4
|
+
export interface ShapeToolOptions {
|
|
5
|
+
kind?: ShapeToolKind;
|
|
6
|
+
id?: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
color?: string;
|
|
9
|
+
width?: number;
|
|
10
|
+
cap?: StrokeCap;
|
|
11
|
+
dash?: boolean;
|
|
12
|
+
minDragPx?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const buildShapeFromDrag: (opts: {
|
|
15
|
+
kind: ShapeToolKind;
|
|
16
|
+
a: Vec2;
|
|
17
|
+
b: Vec2;
|
|
18
|
+
color: string;
|
|
19
|
+
width: number;
|
|
20
|
+
cap?: StrokeCap;
|
|
21
|
+
dash?: boolean;
|
|
22
|
+
layerId: string;
|
|
23
|
+
id?: string;
|
|
24
|
+
}) => AnnotationShape;
|
|
25
|
+
export declare const createShapeTool: (options?: ShapeToolOptions) => Tool;
|