@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,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
|
+
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type { AnnotationShape } from '../../../types/annotation.js';
|
|
1
|
+
import type { AnnotationShape, AnnotationTextDecoration } from '../../../types/annotation.js';
|
|
2
2
|
import type { Tool } from '../Tool.js';
|
|
3
3
|
export interface TextToolOptions {
|
|
4
|
+
id?: string;
|
|
4
5
|
color?: string;
|
|
5
6
|
fontSize?: number;
|
|
6
7
|
dash?: boolean;
|
|
8
|
+
decoration?: AnnotationTextDecoration;
|
|
7
9
|
autoSwitchToSelect?: boolean;
|
|
8
10
|
onPlaced?: (shape: AnnotationShape) => void;
|
|
9
11
|
onAutoSwitch?: (toToolId: string) => void;
|
|
@@ -2,6 +2,10 @@ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
|
2
2
|
import { DEFAULT_TEXT_FONT_SIZE, hitTestTextShape } from '../textGeometry.js';
|
|
3
3
|
let counter = 0;
|
|
4
4
|
const makeId = () => `text-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
+
// Screen-px a press may travel and still count as a tap. Beyond this the
|
|
6
|
+
// gesture is a drag (e.g. an attempt to pan with the text tool active) and must
|
|
7
|
+
// NOT open the text sheet — the cause of the stray "weird popup" on screen.
|
|
8
|
+
const TAP_SLOP_PX = 10;
|
|
5
9
|
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
6
10
|
// Topmost text shape under a world point, for tap-to-edit.
|
|
7
11
|
const findTextShapeAt = (doc, world) => {
|
|
@@ -22,13 +26,29 @@ export const createTextTool = (options = {}) => {
|
|
|
22
26
|
const color = options.color ?? '#111827';
|
|
23
27
|
const fontSize = options.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
24
28
|
const dash = options.dash ?? false;
|
|
29
|
+
const decoration = options.decoration;
|
|
25
30
|
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
26
31
|
const selectToolId = options.selectToolId ?? 'select';
|
|
27
32
|
return {
|
|
28
|
-
id: 'text',
|
|
33
|
+
id: options.id ?? 'text',
|
|
29
34
|
label: 'Text',
|
|
30
35
|
cursor: 'text',
|
|
31
|
-
onPointerUp
|
|
36
|
+
// Arm the press so onPointerUp can tell a tap from a drag. The native tap
|
|
37
|
+
// gesture and the generic tool-pan both dispatch a down before the up.
|
|
38
|
+
onPointerDown(event) {
|
|
39
|
+
return { kind: 'text-armed', startScreen: event.screen };
|
|
40
|
+
},
|
|
41
|
+
onPointerUp(event, ctx, state) {
|
|
42
|
+
// A press that traveled beyond the tap slop is a drag, not a tap — never
|
|
43
|
+
// open the text sheet for it. (If the press wasn't armed, fall back to
|
|
44
|
+
// treating it as a tap so the tool still works.)
|
|
45
|
+
const armed = state;
|
|
46
|
+
if (armed?.kind === 'text-armed') {
|
|
47
|
+
const dx = event.screen.x - armed.startScreen.x;
|
|
48
|
+
const dy = event.screen.y - armed.startScreen.y;
|
|
49
|
+
if (dx * dx + dy * dy > TAP_SLOP_PX * TAP_SLOP_PX)
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
32
52
|
const existing = findTextShapeAt(ctx.document, event.world);
|
|
33
53
|
if (existing) {
|
|
34
54
|
void ctx
|
|
@@ -63,7 +83,12 @@ export const createTextTool = (options = {}) => {
|
|
|
63
83
|
geometry: { points: [anchor] },
|
|
64
84
|
// `...(dash && ...)` keeps the key absent (not `false`) for solid
|
|
65
85
|
// text, matching the stroke convention (absent === solid).
|
|
66
|
-
style: {
|
|
86
|
+
style: {
|
|
87
|
+
stroke: color,
|
|
88
|
+
fontSize,
|
|
89
|
+
...(dash && { dash: true }),
|
|
90
|
+
...(decoration && { textDecoration: decoration }),
|
|
91
|
+
},
|
|
67
92
|
text,
|
|
68
93
|
createdAt: Date.now(),
|
|
69
94
|
};
|
|
@@ -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,19 @@ 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 { createMeasurementTool, type MeasurementToolOptions, type MeasurementToolPlacement, } from './annotation/canvas/tools/measurementTool.js';
|
|
33
|
+
export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, type ShapeToolKind, } from './annotation/canvas/shapeGeometry.js';
|
|
30
34
|
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';
|
|
35
|
+
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
36
|
export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
|
package/dist/exports.js
CHANGED
|
@@ -22,11 +22,15 @@ 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 { createMeasurementTool, } from './annotation/canvas/tools/measurementTool.js';
|
|
33
|
+
export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, } from './annotation/canvas/shapeGeometry.js';
|
|
30
34
|
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';
|
|
35
|
+
export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
|
|
32
36
|
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;
|
|
@@ -19,6 +19,7 @@ export interface AnnotationStroke {
|
|
|
19
19
|
createdAt: number;
|
|
20
20
|
}
|
|
21
21
|
export type AnnotationShapeKind = 'rect' | 'ellipse' | 'line' | 'arrow' | 'polygon' | 'text';
|
|
22
|
+
export type AnnotationTextDecoration = 'underline' | 'squiggle' | 'highlight';
|
|
22
23
|
export interface AnnotationShapeStyle {
|
|
23
24
|
stroke?: string;
|
|
24
25
|
fill?: string;
|
|
@@ -26,6 +27,8 @@ export interface AnnotationShapeStyle {
|
|
|
26
27
|
fontSize?: number;
|
|
27
28
|
fontFamily?: string;
|
|
28
29
|
dash?: boolean;
|
|
30
|
+
textDecoration?: AnnotationTextDecoration;
|
|
31
|
+
cap?: StrokeCap;
|
|
29
32
|
}
|
|
30
33
|
export interface AnnotationShape {
|
|
31
34
|
id: AnnotationElementId;
|
|
@@ -34,6 +37,7 @@ export interface AnnotationShape {
|
|
|
34
37
|
geometry: {
|
|
35
38
|
points: Vec2[];
|
|
36
39
|
rotation?: number;
|
|
40
|
+
closed?: boolean;
|
|
37
41
|
};
|
|
38
42
|
style: AnnotationShapeStyle;
|
|
39
43
|
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;
|