@reekon-tools/boldr-utils 1.6.13 → 1.6.14
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 +49 -11
- package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +368 -55
- 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 +41 -8
- package/dist/annotation/canvas/measurementGeometry.d.ts +1 -0
- package/dist/annotation/canvas/measurementGeometry.js +60 -2
- package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
- package/dist/annotation/canvas/shapeGeometry.js +116 -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 +34 -73
- package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
- package/dist/annotation/canvas/tools/shapeTool.js +111 -0
- package/dist/annotation/canvas/useAnnotationCanvasState.js +27 -3
- package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +83 -24
- package/dist/exports.d.ts +7 -4
- package/dist/exports.js +6 -3
- package/dist/formulas/calculateFormula.js +1 -3
- package/dist/types/annotation.d.ts +2 -0
- package/dist/types/firestore.d.ts +4 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { STAMP_TILE_SIZE } from '../stampLayout.js';
|
|
2
|
-
import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor,
|
|
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
|
}
|
|
@@ -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;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
+
import { annotationKindFor, shapePointsFromDrag, } from '../shapeGeometry.js';
|
|
3
|
+
let counter = 0;
|
|
4
|
+
const makeId = (prefix) => `${prefix}-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
const DEFAULT_LABELS = {
|
|
6
|
+
line: 'Line',
|
|
7
|
+
rect: 'Rectangle',
|
|
8
|
+
triangle: 'Triangle',
|
|
9
|
+
ellipse: 'Circle',
|
|
10
|
+
};
|
|
11
|
+
// Assemble the committed AnnotationShape for a drag from `a` to `b`. Shared
|
|
12
|
+
// by the web pointer handlers below AND the native UI-thread gesture (which
|
|
13
|
+
// rubber-bands on shared values and calls this once on release) — one source
|
|
14
|
+
// of truth for kind mapping, styling, and the no-undefined-keys rule
|
|
15
|
+
// (Firestore rejects undefined values, so optional style keys are spread in
|
|
16
|
+
// only when set).
|
|
17
|
+
export const buildShapeFromDrag = (opts) => ({
|
|
18
|
+
id: opts.id ?? makeId('shape'),
|
|
19
|
+
layerId: opts.layerId,
|
|
20
|
+
kind: annotationKindFor(opts.kind),
|
|
21
|
+
geometry: { points: shapePointsFromDrag(opts.kind, opts.a, opts.b) },
|
|
22
|
+
style: {
|
|
23
|
+
stroke: opts.color,
|
|
24
|
+
strokeWidth: opts.width,
|
|
25
|
+
...(opts.dash && { dash: true }),
|
|
26
|
+
// Caps only mean something on an open line; 'round' is the implicit
|
|
27
|
+
// default so it stays un-persisted.
|
|
28
|
+
...(opts.kind === 'line' &&
|
|
29
|
+
opts.cap &&
|
|
30
|
+
opts.cap !== 'round' && { cap: opts.cap }),
|
|
31
|
+
},
|
|
32
|
+
createdAt: Date.now(),
|
|
33
|
+
});
|
|
34
|
+
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
35
|
+
// Drag-to-draw shape tool (line/arrow/rect/triangle/circle). Press-drag
|
|
36
|
+
// rubber-bands the shape from the press point; release commits it as an
|
|
37
|
+
// AnnotationShape. On native the drag runs on the UI thread via the
|
|
38
|
+
// `shapeDraw` config; the pointer handlers below are the web/parity
|
|
39
|
+
// implementation. Skia-free, like every tool factory.
|
|
40
|
+
export const createShapeTool = (options = {}) => {
|
|
41
|
+
const kind = options.kind ?? 'line';
|
|
42
|
+
const color = options.color ?? '#111827';
|
|
43
|
+
const width = options.width ?? 2;
|
|
44
|
+
const cap = options.cap;
|
|
45
|
+
const dash = options.dash ?? false;
|
|
46
|
+
const minDragPx = options.minDragPx ?? 4;
|
|
47
|
+
return {
|
|
48
|
+
id: options.id ?? kind,
|
|
49
|
+
label: options.label ?? DEFAULT_LABELS[kind],
|
|
50
|
+
cursor: 'crosshair',
|
|
51
|
+
// Drives UI-thread rubber-banding on native (see ShapeDrawConfig).
|
|
52
|
+
shapeDraw: { kind, color, width, ...(cap && { cap }), dash },
|
|
53
|
+
onPointerDown(event, ctx) {
|
|
54
|
+
return {
|
|
55
|
+
kind: 'shape-drawing',
|
|
56
|
+
shape: buildShapeFromDrag({
|
|
57
|
+
kind,
|
|
58
|
+
a: event.world,
|
|
59
|
+
b: event.world,
|
|
60
|
+
color,
|
|
61
|
+
width,
|
|
62
|
+
cap,
|
|
63
|
+
dash,
|
|
64
|
+
layerId: firstLayerId(ctx.document),
|
|
65
|
+
}),
|
|
66
|
+
startWorld: event.world,
|
|
67
|
+
startScreen: event.screen,
|
|
68
|
+
moved: false,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
onPointerMove(event, ctx, state) {
|
|
72
|
+
const s = state;
|
|
73
|
+
if (s?.kind !== 'shape-drawing')
|
|
74
|
+
return s;
|
|
75
|
+
const next = {
|
|
76
|
+
...s,
|
|
77
|
+
moved: true,
|
|
78
|
+
shape: {
|
|
79
|
+
...s.shape,
|
|
80
|
+
geometry: {
|
|
81
|
+
points: shapePointsFromDrag(kind, s.startWorld, event.world),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
ctx.preview({ ops: [{ op: 'addShape', shape: next.shape }] });
|
|
86
|
+
return next;
|
|
87
|
+
},
|
|
88
|
+
onPointerUp(event, ctx, state) {
|
|
89
|
+
const s = state;
|
|
90
|
+
if (s?.kind !== 'shape-drawing')
|
|
91
|
+
return;
|
|
92
|
+
const dx = event.screen.x - s.startScreen.x;
|
|
93
|
+
const dy = event.screen.y - s.startScreen.y;
|
|
94
|
+
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
95
|
+
// Accidental tap — discard the rubber-band, commit nothing.
|
|
96
|
+
ctx.preview({ ops: [] });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const shape = {
|
|
100
|
+
...s.shape,
|
|
101
|
+
geometry: {
|
|
102
|
+
points: shapePointsFromDrag(kind, s.startWorld, event.world),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
ctx.commit({ ops: [{ op: 'addShape', shape }] });
|
|
106
|
+
},
|
|
107
|
+
onCancel(_state, ctx) {
|
|
108
|
+
ctx.preview({ ops: [] });
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
};
|
|
@@ -42,7 +42,9 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
42
42
|
return pickMeasurement ? pickMeasurement() : Promise.resolve(null);
|
|
43
43
|
},
|
|
44
44
|
requestTextInput(options) {
|
|
45
|
-
return requestTextInput
|
|
45
|
+
return requestTextInput
|
|
46
|
+
? requestTextInput(options)
|
|
47
|
+
: Promise.resolve(null);
|
|
46
48
|
},
|
|
47
49
|
applyPan(deltaScreen) {
|
|
48
50
|
setViewport((v) => panBy(v, deltaScreen));
|
|
@@ -63,6 +65,24 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
63
65
|
// would otherwise capture a stale ctx/viewport).
|
|
64
66
|
const ctxRef = useRef(ctx);
|
|
65
67
|
ctxRef.current = ctx;
|
|
68
|
+
// Tool hand-over. When the active tool's identity changes — a real tool
|
|
69
|
+
// switch OR the consumer rebuilding the tools array (e.g. a style change
|
|
70
|
+
// recreates every factory) — give the outgoing instance a chance to wind
|
|
71
|
+
// down multi-gesture work (the polygon tool commits its in-progress
|
|
72
|
+
// vertices), then drop any leftover gesture state/preview so the incoming
|
|
73
|
+
// tool starts clean. Keyed on object identity, not id: a rebuilt instance
|
|
74
|
+
// has fresh closure state, so the old instance must still wind down.
|
|
75
|
+
const prevToolRef = useRef(null);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const prev = prevToolRef.current;
|
|
78
|
+
prevToolRef.current = activeTool;
|
|
79
|
+
if (!prev || prev === activeTool)
|
|
80
|
+
return;
|
|
81
|
+
prev.onDeactivate?.(ctxRef.current);
|
|
82
|
+
activePointerIdRef.current = null;
|
|
83
|
+
setToolState(undefined);
|
|
84
|
+
setPreviewPatch(null);
|
|
85
|
+
}, [activeTool]);
|
|
66
86
|
const dispatchPointerDown = useCallback((event) => {
|
|
67
87
|
if (!activeTool)
|
|
68
88
|
return;
|
|
@@ -94,11 +114,15 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
94
114
|
setToolState(undefined);
|
|
95
115
|
}, [activeTool, ctx, toolState]);
|
|
96
116
|
const dispatchPointerCancel = useCallback(() => {
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
// Clear FIRST, then let the tool react: state updates batch, so a tool
|
|
118
|
+
// whose onCancel re-emits a preview (the polygon tool keeps its placed
|
|
119
|
+
// vertices visible across an interrupting two-finger pan) wins over the
|
|
120
|
+
// clear instead of being clobbered by it.
|
|
99
121
|
activePointerIdRef.current = null;
|
|
100
122
|
setToolState(undefined);
|
|
101
123
|
setPreviewPatch(null);
|
|
124
|
+
if (activeTool)
|
|
125
|
+
activeTool.onCancel?.(toolState, ctx);
|
|
102
126
|
}, [activeTool, ctx, toolState]);
|
|
103
127
|
const pan = useCallback((deltaScreen) => {
|
|
104
128
|
setViewport((v) => panBy(v, deltaScreen));
|
|
@@ -16,11 +16,16 @@ const EMPTY_SCOPE = {
|
|
|
16
16
|
};
|
|
17
17
|
// Build the persisted fileData, omitting `isLabel` when undefined so the write
|
|
18
18
|
// contains no undefined values (Firestore rejects them on RN).
|
|
19
|
-
const buildFileData = (fileType, isLabel, canvas) => ({
|
|
19
|
+
const buildFileData = (fileType, isLabel, canvas, canvasRev) => ({
|
|
20
20
|
fileType,
|
|
21
21
|
...(isLabel !== undefined ? { isLabel } : {}),
|
|
22
22
|
canvas,
|
|
23
|
+
canvasRev,
|
|
23
24
|
});
|
|
25
|
+
// Random id for this editing session, used to stamp writes (see canvasRev in
|
|
26
|
+
// AnnotationFileData). Uniqueness only needs to hold across the handful of
|
|
27
|
+
// clients that ever touch one annotation doc.
|
|
28
|
+
const makeClientId = () => `${Date.now().toString(36)}-${Math.floor(Math.random() * 0x100000000).toString(36)}`;
|
|
24
29
|
// Orchestrates load + auto-save for the annotation canvas. Hydrates the working
|
|
25
30
|
// state from the persisted doc, applies commits optimistically, and persists
|
|
26
31
|
// (debounced) through the data provider — creating the file on first save when
|
|
@@ -52,10 +57,30 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
52
57
|
const uploadImageRef = useRef(uploadImage);
|
|
53
58
|
// Guards against overlapping thumbnail captures (each save fires one).
|
|
54
59
|
const thumbnailSavingRef = useRef(false);
|
|
60
|
+
// Set by the public `save()` so the next successful flush refreshes the
|
|
61
|
+
// thumbnail; debounced autosaves leave it false (capture is too costly to
|
|
62
|
+
// run mid-session — see captureThumbnail). Consumed (cleared) by each flush
|
|
63
|
+
// pass that attempts a write, so a failed explicit save drops the request
|
|
64
|
+
// instead of leaking the capture into some later autosave.
|
|
65
|
+
const thumbnailRequestedRef = useRef(false);
|
|
66
|
+
// In-flight capture+upload from the latest flush, so `save()` can await it —
|
|
67
|
+
// callers that navigate away right after saving must not unmount the view
|
|
68
|
+
// mid-capture.
|
|
69
|
+
const thumbnailPromiseRef = useRef(undefined);
|
|
55
70
|
const debugRef = useRef(debugLogging);
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
71
|
+
// This session's identity + write counter, stamped into `fileData.canvasRev`
|
|
72
|
+
// on every flush. An incoming snapshot carrying our clientId is the echo of
|
|
73
|
+
// our own write — state we already hold — and must not be re-applied (doing
|
|
74
|
+
// so replaces every object identity in the working canvas, busting all
|
|
75
|
+
// memoization downstream).
|
|
76
|
+
const clientIdRef = useRef(null);
|
|
77
|
+
if (clientIdRef.current === null)
|
|
78
|
+
clientIdRef.current = makeClientId();
|
|
79
|
+
const writeSeqRef = useRef(0);
|
|
80
|
+
// Monotonic local-edit counter: bumped on every commit. A flush snapshots it
|
|
81
|
+
// before writing and compares after, so "did edits land mid-flight?" is an
|
|
82
|
+
// integer comparison instead of re-serializing the whole document.
|
|
83
|
+
const editSeqRef = useRef(0);
|
|
59
84
|
workingRef.current = working;
|
|
60
85
|
dataRef.current = data;
|
|
61
86
|
statusRef.current = saveStatus;
|
|
@@ -106,7 +131,6 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
106
131
|
timerRef.current = null;
|
|
107
132
|
}
|
|
108
133
|
createdIdRef.current = null;
|
|
109
|
-
lastSavedJsonRef.current = null;
|
|
110
134
|
setWorking(null);
|
|
111
135
|
setStatus('idle');
|
|
112
136
|
}, [fileId, setStatus]);
|
|
@@ -115,24 +139,22 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
115
139
|
// First load — always hydrate.
|
|
116
140
|
if (workingRef.current === null) {
|
|
117
141
|
setWorking(hydrateCanvasState(data, fallbackViewport));
|
|
118
|
-
lastSavedJsonRef.current = data?.fileData.canvas
|
|
119
|
-
? JSON.stringify(data.fileData.canvas)
|
|
120
|
-
: null;
|
|
121
142
|
return;
|
|
122
143
|
}
|
|
123
144
|
// A write of ours is queued or in flight — ignore the snapshot; it is
|
|
124
145
|
// either the echo of our write or about to be superseded by it.
|
|
125
146
|
if (statusRef.current === 'saving' || timerRef.current !== null)
|
|
126
147
|
return;
|
|
127
|
-
// Clean locally: accept a genuine remote change, but ignore the echo of
|
|
128
|
-
// our own last write (same content).
|
|
129
148
|
const incoming = data?.fileData.canvas;
|
|
130
149
|
if (!incoming)
|
|
131
150
|
return;
|
|
132
|
-
|
|
133
|
-
|
|
151
|
+
// Ignore the echo of this session's own writes: the doc carries the
|
|
152
|
+
// canvasRev we stamped, so its content is (at most as new as) what we
|
|
153
|
+
// already hold. Only a doc written by ANOTHER client is a genuine remote
|
|
154
|
+
// change worth re-hydrating — which replaces all element identities, so
|
|
155
|
+
// it must never happen on the routine save → echo round-trip.
|
|
156
|
+
if (data?.fileData.canvasRev?.clientId === clientIdRef.current)
|
|
134
157
|
return;
|
|
135
|
-
lastSavedJsonRef.current = incomingJson;
|
|
136
158
|
setWorking(hydrateCanvasState(data, fallbackViewport));
|
|
137
159
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
138
160
|
}, [data]);
|
|
@@ -148,6 +170,17 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
148
170
|
if (!canvas || !scope)
|
|
149
171
|
return;
|
|
150
172
|
const json = JSON.stringify(canvas);
|
|
173
|
+
// Edits up to this point are covered by this write; anything committed
|
|
174
|
+
// while the write is in flight bumps editSeqRef past this snapshot.
|
|
175
|
+
const editSeqAtFlush = editSeqRef.current;
|
|
176
|
+
// Claim the thumbnail request for THIS pass: honored on success, dropped
|
|
177
|
+
// on failure (the next explicit save re-requests it).
|
|
178
|
+
const wantThumbnail = thumbnailRequestedRef.current;
|
|
179
|
+
thumbnailRequestedRef.current = false;
|
|
180
|
+
const canvasRev = {
|
|
181
|
+
clientId: clientIdRef.current,
|
|
182
|
+
seq: ++writeSeqRef.current,
|
|
183
|
+
};
|
|
151
184
|
const id = fileIdRef.current ?? createdIdRef.current;
|
|
152
185
|
const mode = id ? 'update' : 'create';
|
|
153
186
|
const debug = debugRef.current;
|
|
@@ -167,6 +200,7 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
167
200
|
// rejects undefined field values unless ignoreUndefinedProperties is set.
|
|
168
201
|
const canvasPayload = JSON.parse(json);
|
|
169
202
|
setStatus('saving');
|
|
203
|
+
let createdThisPass = false;
|
|
170
204
|
try {
|
|
171
205
|
if (!id) {
|
|
172
206
|
// First save with no file — create the doc seeded with the canvas.
|
|
@@ -174,9 +208,10 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
174
208
|
const newId = await createRef.current({
|
|
175
209
|
type: FileUploadType.Canvas,
|
|
176
210
|
...(seed?.name !== undefined ? { name: seed.name } : {}),
|
|
177
|
-
fileData: buildFileData(seed?.fileType ?? 'sketch', seed?.isLabel, canvasPayload),
|
|
211
|
+
fileData: buildFileData(seed?.fileType ?? 'sketch', seed?.isLabel, canvasPayload, canvasRev),
|
|
178
212
|
});
|
|
179
213
|
createdIdRef.current = newId;
|
|
214
|
+
createdThisPass = true;
|
|
180
215
|
if (debug) {
|
|
181
216
|
console.log('[useAnnotationCanvasDoc] created file', newId);
|
|
182
217
|
}
|
|
@@ -187,22 +222,27 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
187
222
|
await updateRef.current(id, {
|
|
188
223
|
fileData: buildFileData(doc?.fileData.fileType ??
|
|
189
224
|
createSeedRef.current?.fileType ??
|
|
190
|
-
'sketch', doc?.fileData.isLabel ?? createSeedRef.current?.isLabel, canvasPayload),
|
|
225
|
+
'sketch', doc?.fileData.isLabel ?? createSeedRef.current?.isLabel, canvasPayload, canvasRev),
|
|
191
226
|
});
|
|
192
227
|
if (debug) {
|
|
193
228
|
console.log('[useAnnotationCanvasDoc] updated file', id);
|
|
194
229
|
}
|
|
195
230
|
}
|
|
196
|
-
|
|
197
|
-
//
|
|
198
|
-
//
|
|
231
|
+
// Refresh the thumbnail when an explicit save() asked for it, or when
|
|
232
|
+
// this pass CREATED the file — every file gets at least one thumbnail
|
|
233
|
+
// so the grid never shows a blank tile for a canvas the user drew and
|
|
234
|
+
// then backed out of without an explicit save. Routine debounced
|
|
235
|
+
// autosaves skip it: capture means a full view snapshot + encode on the
|
|
236
|
+
// JS thread, which would jank the very drawing session that triggered
|
|
237
|
+
// the save. Fire-and-forget for the flush itself; save() awaits the
|
|
238
|
+
// stashed promise so explicit savers can navigate safely after.
|
|
199
239
|
const savedId = fileIdRef.current ?? createdIdRef.current;
|
|
200
|
-
if (savedId)
|
|
201
|
-
|
|
240
|
+
if (savedId && (wantThumbnail || createdThisPass)) {
|
|
241
|
+
thumbnailPromiseRef.current = saveThumbnail(savedId);
|
|
242
|
+
}
|
|
202
243
|
// If new edits landed mid-flight, stay dirty and let the next debounce
|
|
203
244
|
// (or unmount) flush them.
|
|
204
|
-
|
|
205
|
-
if (latest && JSON.stringify(latest) !== json) {
|
|
245
|
+
if (editSeqRef.current !== editSeqAtFlush) {
|
|
206
246
|
setStatus('dirty');
|
|
207
247
|
}
|
|
208
248
|
else {
|
|
@@ -239,7 +279,18 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
239
279
|
return flushRunner();
|
|
240
280
|
}, [flushRunner]);
|
|
241
281
|
const onCommit = useCallback((patch) => {
|
|
242
|
-
|
|
282
|
+
editSeqRef.current += 1;
|
|
283
|
+
setWorking((prev) => {
|
|
284
|
+
if (!prev)
|
|
285
|
+
return prev;
|
|
286
|
+
const next = applyPatch(prev, patch);
|
|
287
|
+
// Keep the flush snapshot in lockstep with editSeqRef: a flush that
|
|
288
|
+
// starts before React re-renders must not pair this commit's seq bump
|
|
289
|
+
// with the pre-commit canvas (it would mark the edit 'saved' without
|
|
290
|
+
// ever writing it).
|
|
291
|
+
workingRef.current = next;
|
|
292
|
+
return next;
|
|
293
|
+
});
|
|
243
294
|
setStatus('dirty');
|
|
244
295
|
if (timerRef.current)
|
|
245
296
|
clearTimeout(timerRef.current);
|
|
@@ -248,6 +299,14 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
248
299
|
void flush();
|
|
249
300
|
}, debounceMs);
|
|
250
301
|
}, [debounceMs, flush, setStatus]);
|
|
302
|
+
// Explicit save (Save button, "done" actions): also refreshes the file's
|
|
303
|
+
// thumbnail, which autosaves deliberately skip. Resolves only after the
|
|
304
|
+
// capture+upload too, so a caller may unmount the view right after.
|
|
305
|
+
const save = useCallback(async () => {
|
|
306
|
+
thumbnailRequestedRef.current = true;
|
|
307
|
+
await flush();
|
|
308
|
+
await thumbnailPromiseRef.current;
|
|
309
|
+
}, [flush]);
|
|
251
310
|
const ensureFileId = useCallback(async () => {
|
|
252
311
|
const existing = fileIdRef.current ?? createdIdRef.current;
|
|
253
312
|
if (existing)
|
|
@@ -327,7 +386,7 @@ export const useAnnotationCanvasDoc = (options) => {
|
|
|
327
386
|
loading,
|
|
328
387
|
error,
|
|
329
388
|
saveStatus,
|
|
330
|
-
save
|
|
389
|
+
save,
|
|
331
390
|
ensureFileId,
|
|
332
391
|
setBackgroundImage,
|
|
333
392
|
clearBackgroundImage,
|
package/dist/exports.d.ts
CHANGED
|
@@ -18,15 +18,18 @@ export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
|
|
|
18
18
|
export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
|
|
19
19
|
export type { AnnotationCanvasHandle } from './annotation/canvas/useAnnotationCanvasState.js';
|
|
20
20
|
export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './annotation/canvas/AnnotationCanvasInner.js';
|
|
21
|
-
export type { CanvasPointerEvent, RequestTextInput, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
|
|
21
|
+
export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
|
|
22
22
|
export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
|
|
23
23
|
export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
|
|
24
24
|
export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
|
|
25
|
-
export { createPenTool, type PenToolOptions } from './annotation/canvas/tools/penTool.js';
|
|
25
|
+
export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
|
|
26
26
|
export { createSelectTool } from './annotation/canvas/tools/selectTool.js';
|
|
27
27
|
export { createMeasurementStampTool, type MeasurementStampToolOptions, } from './annotation/canvas/tools/measurementStampTool.js';
|
|
28
|
-
export { createPanTool, type PanToolOptions } from './annotation/canvas/tools/panTool.js';
|
|
28
|
+
export { createPanTool, type PanToolOptions, } from './annotation/canvas/tools/panTool.js';
|
|
29
29
|
export { createTextTool, type TextToolOptions, } from './annotation/canvas/tools/textTool.js';
|
|
30
|
+
export { createShapeTool, buildShapeFromDrag, type ShapeToolOptions, } from './annotation/canvas/tools/shapeTool.js';
|
|
31
|
+
export { createPolygonTool, type PolygonToolOptions, } from './annotation/canvas/tools/polygonTool.js';
|
|
32
|
+
export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, type ShapeToolKind, } from './annotation/canvas/shapeGeometry.js';
|
|
30
33
|
export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, type ResizeGeometry, type TextBounds, } from './annotation/canvas/textGeometry.js';
|
|
31
|
-
export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, DEFAULT_LINE_POS, type NormalizedRect, type RectCorner, } from './annotation/canvas/measurementGeometry.js';
|
|
34
|
+
export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, type NormalizedRect, type RectCorner, } from './annotation/canvas/measurementGeometry.js';
|
|
32
35
|
export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
|
package/dist/exports.js
CHANGED
|
@@ -22,11 +22,14 @@ export { useAnnotationCanvasDoc, } from './annotation/data/hooks/useAnnotationCa
|
|
|
22
22
|
export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
|
|
23
23
|
export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
|
|
24
24
|
export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, } from './annotation/canvas/viewport.js';
|
|
25
|
-
export { createPenTool } from './annotation/canvas/tools/penTool.js';
|
|
25
|
+
export { createPenTool, } from './annotation/canvas/tools/penTool.js';
|
|
26
26
|
export { createSelectTool } from './annotation/canvas/tools/selectTool.js';
|
|
27
27
|
export { createMeasurementStampTool, } from './annotation/canvas/tools/measurementStampTool.js';
|
|
28
|
-
export { createPanTool } from './annotation/canvas/tools/panTool.js';
|
|
28
|
+
export { createPanTool, } from './annotation/canvas/tools/panTool.js';
|
|
29
29
|
export { createTextTool, } from './annotation/canvas/tools/textTool.js';
|
|
30
|
+
export { createShapeTool, buildShapeFromDrag, } from './annotation/canvas/tools/shapeTool.js';
|
|
31
|
+
export { createPolygonTool, } from './annotation/canvas/tools/polygonTool.js';
|
|
32
|
+
export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, } from './annotation/canvas/shapeGeometry.js';
|
|
30
33
|
export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, } from './annotation/canvas/textGeometry.js';
|
|
31
|
-
export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
|
|
34
|
+
export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
|
|
32
35
|
export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
|
|
@@ -105,9 +105,7 @@ export const calculateFormula = (formula, formulas, columns, tableConfig, measur
|
|
|
105
105
|
// Validate that all required inputs are filled out
|
|
106
106
|
const missingInputs = [];
|
|
107
107
|
for (const [variable, mapping] of Object.entries(currentMappings)) {
|
|
108
|
-
const mappingObj = typeof mapping === 'string'
|
|
109
|
-
? { id: mapping, type: 'column' }
|
|
110
|
-
: mapping;
|
|
108
|
+
const mappingObj = typeof mapping === 'string' ? { id: mapping, type: 'column' } : mapping;
|
|
111
109
|
// Only check column references (formula references are handled separately)
|
|
112
110
|
if (mappingObj.type === 'column' || !mappingObj.type) {
|
|
113
111
|
const columnId = mappingObj.id;
|
|
@@ -26,6 +26,7 @@ export interface AnnotationShapeStyle {
|
|
|
26
26
|
fontSize?: number;
|
|
27
27
|
fontFamily?: string;
|
|
28
28
|
dash?: boolean;
|
|
29
|
+
cap?: StrokeCap;
|
|
29
30
|
}
|
|
30
31
|
export interface AnnotationShape {
|
|
31
32
|
id: AnnotationElementId;
|
|
@@ -34,6 +35,7 @@ export interface AnnotationShape {
|
|
|
34
35
|
geometry: {
|
|
35
36
|
points: Vec2[];
|
|
36
37
|
rotation?: number;
|
|
38
|
+
closed?: boolean;
|
|
37
39
|
};
|
|
38
40
|
style: AnnotationShapeStyle;
|
|
39
41
|
text?: string;
|
|
@@ -113,6 +113,10 @@ export interface AnnotationFileData {
|
|
|
113
113
|
fileType: 'sketch' | 'document';
|
|
114
114
|
isLabel?: boolean;
|
|
115
115
|
canvas?: AnnotationCanvasState;
|
|
116
|
+
canvasRev?: {
|
|
117
|
+
clientId: string;
|
|
118
|
+
seq: number;
|
|
119
|
+
};
|
|
116
120
|
}
|
|
117
121
|
export interface CalculatorFileData {
|
|
118
122
|
templateId: string;
|