@reekon-tools/boldr-utils 1.23.0 → 1.25.0

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.
@@ -0,0 +1,353 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { applyPatch, createEmptyCanvasState, } from '../../../types/annotation.js';
3
+ import { canvasStateToPageState, pageStateToCanvasState, pdfCanvasKnobsEqual, pdfCanvasKnobsOf, pdfPageDisplaySize, rotatePageState, rotatePdfCanvasPage, } from '../../canvas/pdfCanvasPages.js';
4
+ import { useAnnotationData } from '../AnnotationDataContext.js';
5
+ import { annotationScopeKey, } from '../AnnotationDataProvider.js';
6
+ import { createCoalescedRunner } from '../coalescedRunner.js';
7
+ import { useAnnotationDoc } from './useAnnotationDoc.js';
8
+ const makeClientId = () => `${Date.now().toString(36)}-${Math.floor(Math.random() * 0x100000000).toString(36)}`;
9
+ const hasContent = (canvas) => canvas.strokes.length > 0 ||
10
+ canvas.shapes.length > 0 ||
11
+ canvas.placedMeasurements.length > 0 ||
12
+ (canvas.planes?.length ?? 0) > 0;
13
+ // Drop undefined keys (Firestore — RN in particular — rejects them) and detach
14
+ // the persisted payload from the working state's object identities.
15
+ const jsonClone = (value) => JSON.parse(JSON.stringify(value));
16
+ // Load + explicit-save orchestration for ONE page of a PdfCanvas file. The
17
+ // canvas engine is page-agnostic: this hook hands it the page's state as an
18
+ // ordinary AnnotationCanvasState (pageStateToCanvasState) and persists it back
19
+ // as a page doc. Mirrors useAnnotationCanvasDoc's echo suppression (a `rev`
20
+ // stamped with this session's clientId) and single-flight flushing.
21
+ export const usePdfCanvasDoc = (options) => {
22
+ const { scope, fileId, pageIndex, onSaveError, captureThumbnail, debugLogging = false, } = options;
23
+ const provider = useAnnotationData();
24
+ const { data: file, loading: fileLoading, error: fileError, } = useAnnotationDoc(scope, fileId);
25
+ const pdfCanvas = file?.fileData.pdfCanvas ?? null;
26
+ // The current page's doc, TAGGED with the page it answers for. On a page
27
+ // turn the previous page's doc is still in state until the new subscription
28
+ // reports, and hydrating the new page from it is exactly how annotations
29
+ // land on the wrong page. `state` null = the page has no doc yet.
30
+ const [pageSnapshot, setPageSnapshot] = useState(null);
31
+ const [pageLoading, setPageLoading] = useState(true);
32
+ const [pageError, setPageError] = useState(null);
33
+ const [working, setWorking] = useState(null);
34
+ const [saveStatus, setSaveStatus] = useState('idle');
35
+ const workingRef = useRef(null);
36
+ const pdfCanvasRef = useRef(null);
37
+ const statusRef = useRef('idle');
38
+ const pageIndexRef = useRef(pageIndex);
39
+ const scopeRef = useRef(scope);
40
+ const fileIdRef = useRef(fileId);
41
+ const onSaveErrorRef = useRef(onSaveError);
42
+ const captureThumbnailRef = useRef(captureThumbnail);
43
+ const debugRef = useRef(debugLogging);
44
+ const clientIdRef = useRef(null);
45
+ if (clientIdRef.current === null)
46
+ clientIdRef.current = makeClientId();
47
+ const writeSeqRef = useRef(0);
48
+ const editSeqRef = useRef(0);
49
+ const thumbnailSavingRef = useRef(false);
50
+ const thumbnailRequestedRef = useRef(false);
51
+ const thumbnailPromiseRef = useRef(undefined);
52
+ // A page whose edits must be persisted although it is no longer the current
53
+ // page (the user turned the page with unsaved edits).
54
+ const outgoingRef = useRef([]);
55
+ workingRef.current = working;
56
+ pdfCanvasRef.current = pdfCanvas;
57
+ statusRef.current = saveStatus;
58
+ scopeRef.current = scope;
59
+ fileIdRef.current = fileId;
60
+ onSaveErrorRef.current = onSaveError;
61
+ captureThumbnailRef.current = captureThumbnail;
62
+ debugRef.current = debugLogging;
63
+ const setStatus = useCallback((next) => {
64
+ statusRef.current = next;
65
+ setSaveStatus(next);
66
+ }, []);
67
+ // Subscribe to the current page's doc.
68
+ useEffect(() => {
69
+ if (!scope || !fileId) {
70
+ setPageSnapshot(null);
71
+ setPageLoading(false);
72
+ return;
73
+ }
74
+ // Drop the previous page's snapshot synchronously so nothing can read it
75
+ // as this page's while the subscription is still connecting.
76
+ setPageSnapshot(null);
77
+ setPageLoading(true);
78
+ setPageError(null);
79
+ const unsubscribe = provider.subscribePage(scope, fileId, pageIndex, (doc) => {
80
+ setPageSnapshot({ pageIndex, state: doc });
81
+ setPageLoading(false);
82
+ }, (err) => {
83
+ setPageError(err);
84
+ setPageLoading(false);
85
+ });
86
+ return unsubscribe;
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
+ }, [provider, annotationScopeKey(scope), fileId, pageIndex]);
89
+ // Persist one page: the page doc, then whatever file-level fields it moved
90
+ // (first-annotation marker, tile knobs). Returns normally on failure after
91
+ // reporting it — callers read the status, not a rejection.
92
+ const persist = useCallback(async (target) => {
93
+ const currentScope = scopeRef.current;
94
+ const currentFileId = fileIdRef.current;
95
+ if (!currentScope || !currentFileId)
96
+ return false;
97
+ const rev = {
98
+ clientId: clientIdRef.current,
99
+ seq: ++writeSeqRef.current,
100
+ };
101
+ const state = jsonClone(canvasStateToPageState(target.canvas, target.pageIndex, {
102
+ updatedAt: Date.now(),
103
+ rev,
104
+ }));
105
+ if (debugRef.current) {
106
+ console.log('[usePdfCanvasDoc] save attempt', {
107
+ fileId: currentFileId,
108
+ pageIndex: target.pageIndex,
109
+ strokes: state.strokes.length,
110
+ shapes: state.shapes.length,
111
+ measurements: state.placedMeasurements.length,
112
+ });
113
+ }
114
+ try {
115
+ await provider.writePage(currentScope, currentFileId, target.pageIndex, state);
116
+ const patch = {};
117
+ if (!target.pdfCanvas.annotatedPages.includes(target.pageIndex)) {
118
+ patch['fileData.pdfCanvas.annotatedPages'] = {
119
+ kind: 'arrayUnion',
120
+ values: [target.pageIndex],
121
+ };
122
+ }
123
+ const knobs = pdfCanvasKnobsOf(target.canvas);
124
+ if (!pdfCanvasKnobsEqual(knobs, pdfCanvasKnobsOf(target.pdfCanvas))) {
125
+ for (const [key, value] of Object.entries(knobs)) {
126
+ patch[`fileData.pdfCanvas.${key}`] = value;
127
+ }
128
+ }
129
+ if (Object.keys(patch).length > 0) {
130
+ await provider.update(currentScope, currentFileId, patch);
131
+ }
132
+ return true;
133
+ }
134
+ catch (e) {
135
+ console.error('[usePdfCanvasDoc] save failed', { fileId: currentFileId, pageIndex: target.pageIndex }, e);
136
+ onSaveErrorRef.current?.(e);
137
+ return false;
138
+ }
139
+ }, [provider]);
140
+ const saveThumbnail = useCallback(async (targetFileId) => {
141
+ const capture = captureThumbnailRef.current;
142
+ const currentScope = scopeRef.current;
143
+ if (!capture || !currentScope || thumbnailSavingRef.current)
144
+ return;
145
+ thumbnailSavingRef.current = true;
146
+ try {
147
+ const blob = await capture();
148
+ if (blob) {
149
+ await provider.uploadImage(currentScope, targetFileId, 'thumbnail', blob);
150
+ }
151
+ }
152
+ catch (e) {
153
+ console.warn('[usePdfCanvasDoc] thumbnail save failed', e);
154
+ }
155
+ finally {
156
+ thumbnailSavingRef.current = false;
157
+ }
158
+ }, [provider]);
159
+ // One flush pass: an outgoing page first (if a page turn left one behind),
160
+ // then the current page when it is dirty.
161
+ const runFlush = useCallback(async () => {
162
+ // Every page left behind with unsaved edits, in the order they were
163
+ // left: two quick turns must not lose the middle page.
164
+ while (outgoingRef.current.length > 0) {
165
+ await persist(outgoingRef.current.shift());
166
+ }
167
+ const canvas = workingRef.current;
168
+ const data = pdfCanvasRef.current;
169
+ if (!canvas ||
170
+ !data ||
171
+ (statusRef.current !== 'dirty' && statusRef.current !== 'error')) {
172
+ return;
173
+ }
174
+ const editSeqAtFlush = editSeqRef.current;
175
+ const wantThumbnail = thumbnailRequestedRef.current;
176
+ thumbnailRequestedRef.current = false;
177
+ const targetPage = pageIndexRef.current;
178
+ setStatus('saving');
179
+ const ok = await persist({
180
+ pageIndex: targetPage,
181
+ canvas,
182
+ pdfCanvas: data,
183
+ });
184
+ if (!ok) {
185
+ setStatus('error');
186
+ return;
187
+ }
188
+ const currentFileId = fileIdRef.current;
189
+ if (wantThumbnail && targetPage === 0 && currentFileId) {
190
+ thumbnailPromiseRef.current = saveThumbnail(currentFileId);
191
+ }
192
+ setStatus(editSeqRef.current !== editSeqAtFlush ? 'dirty' : 'saved');
193
+ }, [persist, saveThumbnail, setStatus]);
194
+ const runFlushRef = useRef(runFlush);
195
+ runFlushRef.current = runFlush;
196
+ const flushRunner = useMemo(() => createCoalescedRunner(() => runFlushRef.current()), []);
197
+ const flush = useCallback(() => flushRunner(), [flushRunner]);
198
+ // Page turn: hand the outgoing page to the flusher if it has unsaved edits,
199
+ // then drop the working state so the next snapshot hydrates the new page.
200
+ useEffect(() => {
201
+ if (pageIndexRef.current === pageIndex)
202
+ return;
203
+ const previous = workingRef.current;
204
+ const data = pdfCanvasRef.current;
205
+ if (previous &&
206
+ data &&
207
+ (statusRef.current === 'dirty' || statusRef.current === 'error')) {
208
+ outgoingRef.current.push({
209
+ pageIndex: pageIndexRef.current,
210
+ canvas: previous,
211
+ pdfCanvas: data,
212
+ });
213
+ void flush();
214
+ }
215
+ pageIndexRef.current = pageIndex;
216
+ workingRef.current = null;
217
+ setWorking(null);
218
+ setStatus('idle');
219
+ }, [pageIndex, flush, setStatus]);
220
+ // Reset on a different file.
221
+ useEffect(() => {
222
+ workingRef.current = null;
223
+ setWorking(null);
224
+ setStatus('idle');
225
+ // eslint-disable-next-line react-hooks/exhaustive-deps
226
+ }, [annotationScopeKey(scope), fileId]);
227
+ // Hydrate / reconcile from the persisted docs.
228
+ useEffect(() => {
229
+ if (!pdfCanvas || pageLoading)
230
+ return;
231
+ if (pageIndex >= pdfCanvas.document.pages.length)
232
+ return;
233
+ // Only a snapshot for THIS page counts (see pageSnapshot).
234
+ if (!pageSnapshot || pageSnapshot.pageIndex !== pageIndex)
235
+ return;
236
+ const pageDoc = pageSnapshot.state;
237
+ if (workingRef.current === null) {
238
+ const next = pageStateToCanvasState(pdfCanvas, pageDoc, pageIndex);
239
+ workingRef.current = next;
240
+ setWorking(next);
241
+ return;
242
+ }
243
+ // Local edits pending or a write in flight: never clobber them.
244
+ if (statusRef.current === 'saving' || statusRef.current === 'dirty')
245
+ return;
246
+ // The echo of this session's own write carries our clientId.
247
+ if (pageDoc?.rev?.clientId === clientIdRef.current)
248
+ return;
249
+ const next = pageStateToCanvasState(pdfCanvas, pageDoc, pageIndex);
250
+ workingRef.current = next;
251
+ setWorking(next);
252
+ // eslint-disable-next-line react-hooks/exhaustive-deps
253
+ }, [pdfCanvas, pageSnapshot, pageLoading, pageIndex]);
254
+ const onCommit = useCallback((patch) => {
255
+ editSeqRef.current += 1;
256
+ setWorking((prev) => {
257
+ if (!prev)
258
+ return prev;
259
+ const next = applyPatch(prev, patch);
260
+ workingRef.current = next;
261
+ return next;
262
+ });
263
+ setStatus('dirty');
264
+ }, [setStatus]);
265
+ const save = useCallback(async () => {
266
+ thumbnailRequestedRef.current = true;
267
+ await flush();
268
+ await thumbnailPromiseRef.current;
269
+ }, [flush]);
270
+ const refreshThumbnail = useCallback(async () => {
271
+ const currentFileId = fileIdRef.current;
272
+ if (!currentFileId)
273
+ return;
274
+ await saveThumbnail(currentFileId);
275
+ }, [saveThumbnail]);
276
+ const rotatePage = useCallback(async (delta) => {
277
+ const currentScope = scopeRef.current;
278
+ const currentFileId = fileIdRef.current;
279
+ // Pending edits first, so the rewrite below starts from persisted truth.
280
+ await flush();
281
+ const data = pdfCanvasRef.current;
282
+ const canvas = workingRef.current;
283
+ const index = pageIndexRef.current;
284
+ if (!currentScope || !currentFileId || !data || !canvas)
285
+ return;
286
+ const page = data.document.pages[index];
287
+ if (!page)
288
+ return;
289
+ const size = pdfPageDisplaySize(page);
290
+ const turnedPage = rotatePdfCanvasPage(page, delta);
291
+ const pages = data.document.pages.map((p, i) => i === index ? turnedPage : p);
292
+ // Knobs the flush just wrote may not have echoed back yet; carry them.
293
+ const nextData = {
294
+ ...data,
295
+ ...pdfCanvasKnobsOf(canvas),
296
+ document: { ...data.document, pages },
297
+ };
298
+ setStatus('saving');
299
+ try {
300
+ const patch = {
301
+ 'fileData.pdfCanvas.document.pages': pages,
302
+ };
303
+ let turnedState = null;
304
+ if (hasContent(canvas)) {
305
+ const rev = {
306
+ clientId: clientIdRef.current,
307
+ seq: ++writeSeqRef.current,
308
+ };
309
+ turnedState = jsonClone(rotatePageState(canvasStateToPageState(canvas, index, {
310
+ updatedAt: Date.now(),
311
+ rev,
312
+ }), size, delta));
313
+ await provider.writePage(currentScope, currentFileId, index, turnedState);
314
+ if (!data.annotatedPages.includes(index)) {
315
+ patch['fileData.pdfCanvas.annotatedPages'] = {
316
+ kind: 'arrayUnion',
317
+ values: [index],
318
+ };
319
+ }
320
+ }
321
+ await provider.update(currentScope, currentFileId, patch);
322
+ const next = pageStateToCanvasState(nextData, turnedState, index);
323
+ workingRef.current = next;
324
+ setWorking(next);
325
+ setStatus('saved');
326
+ }
327
+ catch (e) {
328
+ console.error('[usePdfCanvasDoc] rotate failed', { fileId: currentFileId, pageIndex: index, delta }, e);
329
+ onSaveErrorRef.current?.(e);
330
+ setStatus('error');
331
+ }
332
+ }, [flush, provider, setStatus]);
333
+ const page = pdfCanvas?.document.pages[pageIndex] ?? null;
334
+ const canvas = useMemo(() => working ??
335
+ (page
336
+ ? createEmptyCanvasState(pdfPageDisplaySize(page))
337
+ : createEmptyCanvasState()), [working, page]);
338
+ return {
339
+ file,
340
+ pdfCanvas,
341
+ pageCount: pdfCanvas?.document.pageCount ?? 0,
342
+ page,
343
+ canvas,
344
+ onCommit,
345
+ loading: fileLoading || pageLoading || working === null,
346
+ error: fileError ?? pageError,
347
+ saveStatus,
348
+ hasUnsavedChanges: saveStatus === 'dirty' || saveStatus === 'error',
349
+ save,
350
+ rotatePage,
351
+ refreshThumbnail,
352
+ };
353
+ };
@@ -1,7 +1,7 @@
1
- export { renderCanvasToPdf } from './renderCanvasToPdf.js';
1
+ export { renderCanvasToPdf, renderPdfCanvasToPdf, } from './renderCanvasToPdf.js';
2
2
  export { stripPdfEncryption, type StripPdfEncryptionOptions, type StripPdfEncryptionResult, } from './decrypt.js';
3
3
  export { docToPageMatrix, pageRectsOf, normalizeRotation, rotatedBoxSize, applyMatrix, type AffineMatrix, type PageRotation, type PdfBox, } from './pageMapping.js';
4
4
  export { resolveTileContent, measurementTileFillFor, FORMULA_COLUMN_TILE_FILL, PLANE_COMPUTED_TILE_FILL, type TileFileInfo, type TileResolutionContext, } from './tileContent.js';
5
5
  export { tileFootprint, type TileFootprint } from './tiles.js';
6
6
  export { annotationCount } from './annots.js';
7
- export { PdfExportError, type EmbeddedImage, type PdfExportCalibration, type RenderCanvasToPdfInput, type RenderCanvasToPdfResult, type ResolvedTileContent, type MeasurementTileContent, type LiteralTileContent, type EmptyTileContent, type HeaderTileContent, type ObjectTileContent, type PlaneDimensionTileContent, type InstructionsTileContent, type SkippedItem, } from './types.js';
7
+ export { PdfExportError, type EmbeddedImage, type PdfExportCalibration, type RenderCanvasToPdfInput, type RenderPdfCanvasToPdfInput, type RenderCanvasToPdfResult, type ResolvedTileContent, type MeasurementTileContent, type LiteralTileContent, type EmptyTileContent, type HeaderTileContent, type ObjectTileContent, type PlaneDimensionTileContent, type InstructionsTileContent, type SkippedItem, } from './types.js';
@@ -1,4 +1,4 @@
1
- export { renderCanvasToPdf } from './renderCanvasToPdf.js';
1
+ export { renderCanvasToPdf, renderPdfCanvasToPdf, } from './renderCanvasToPdf.js';
2
2
  export { stripPdfEncryption, } from './decrypt.js';
3
3
  export { docToPageMatrix, pageRectsOf, normalizeRotation, rotatedBoxSize, applyMatrix, } from './pageMapping.js';
4
4
  export { resolveTileContent, measurementTileFillFor, FORMULA_COLUMN_TILE_FILL, PLANE_COMPUTED_TILE_FILL, } from './tileContent.js';
@@ -1,2 +1,10 @@
1
- import { type RenderCanvasToPdfInput, type RenderCanvasToPdfResult } from './types.js';
1
+ import { type RenderCanvasToPdfInput, type RenderCanvasToPdfResult, type RenderPdfCanvasToPdfInput } from './types.js';
2
2
  export declare const renderCanvasToPdf: (input: RenderCanvasToPdfInput) => Promise<RenderCanvasToPdfResult>;
3
+ /**
4
+ * Export a PdfCanvas file. Each page's state is drawn in that page's own doc
5
+ * space (the DISPLAYED page: rotated extents, origin top-left). A user
6
+ * rotation is written into the page's /Rotate — so the recipient sees the
7
+ * page as the user did — and the page matrix undoes the combined rotation.
8
+ * Pages without a state are still rotated.
9
+ */
10
+ export declare const renderPdfCanvasToPdf: (input: RenderPdfCanvasToPdfInput) => Promise<RenderCanvasToPdfResult>;