@olenbetong/synergi-react 2.3.8 → 2.4.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.
Files changed (107) hide show
  1. package/es/components/FilePond.d.ts +1 -1
  2. package/es/components/FilePond.js +1 -1
  3. package/es/components/ImageEditor/CropCanvas.d.ts +16 -0
  4. package/es/components/ImageEditor/CropCanvas.js +79 -0
  5. package/es/components/ImageEditor/DragHandle.d.ts +14 -0
  6. package/es/components/ImageEditor/DragHandle.js +40 -0
  7. package/es/components/ImageEditor/EdgeHandle.d.ts +16 -0
  8. package/es/components/ImageEditor/EdgeHandle.js +43 -0
  9. package/es/components/ImageEditor/ImageEditor.css +224 -0
  10. package/es/components/ImageEditor/ImageEditor.d.ts +24 -0
  11. package/es/components/ImageEditor/ImageEditor.js +108 -0
  12. package/es/components/ImageEditor/ResponsiveButton.d.ts +11 -0
  13. package/es/components/ImageEditor/ResponsiveButton.js +9 -0
  14. package/es/components/ImageEditor/defaultPlugins.d.ts +4 -0
  15. package/es/components/ImageEditor/defaultPlugins.js +8 -0
  16. package/es/components/ImageEditor/index.d.ts +6 -0
  17. package/es/components/ImageEditor/index.js +4 -0
  18. package/es/components/ImageEditor/plugins/cropPlugin/CropContext.d.ts +10 -0
  19. package/es/components/ImageEditor/plugins/cropPlugin/CropContext.js +14 -0
  20. package/es/components/ImageEditor/plugins/cropPlugin/CropOverlay.d.ts +2 -0
  21. package/es/components/ImageEditor/plugins/cropPlugin/CropOverlay.js +30 -0
  22. package/es/components/ImageEditor/plugins/cropPlugin/CropToolbar.d.ts +2 -0
  23. package/es/components/ImageEditor/plugins/cropPlugin/CropToolbar.js +16 -0
  24. package/es/components/ImageEditor/plugins/cropPlugin/index.d.ts +6 -0
  25. package/es/components/ImageEditor/plugins/cropPlugin/index.js +16 -0
  26. package/es/components/ImageEditor/plugins/cropPlugin/useCropTool.d.ts +34 -0
  27. package/es/components/ImageEditor/plugins/cropPlugin/useCropTool.js +138 -0
  28. package/es/components/ImageEditor/plugins/redactPlugin/RedactContext.d.ts +10 -0
  29. package/es/components/ImageEditor/plugins/redactPlugin/RedactContext.js +14 -0
  30. package/es/components/ImageEditor/plugins/redactPlugin/RedactOverlay.d.ts +6 -0
  31. package/es/components/ImageEditor/plugins/redactPlugin/RedactOverlay.js +29 -0
  32. package/es/components/ImageEditor/plugins/redactPlugin/RedactToolbar.d.ts +2 -0
  33. package/es/components/ImageEditor/plugins/redactPlugin/RedactToolbar.js +29 -0
  34. package/es/components/ImageEditor/plugins/redactPlugin/index.d.ts +6 -0
  35. package/es/components/ImageEditor/plugins/redactPlugin/index.js +16 -0
  36. package/es/components/ImageEditor/plugins/redactPlugin/useRedactTool.d.ts +49 -0
  37. package/es/components/ImageEditor/plugins/redactPlugin/useRedactTool.js +142 -0
  38. package/es/components/ImageEditor/plugins/rotatePlugin.d.ts +3 -0
  39. package/es/components/ImageEditor/plugins/rotatePlugin.js +39 -0
  40. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.d.ts +20 -0
  41. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.js +91 -0
  42. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomContext.d.ts +6 -0
  43. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomContext.js +14 -0
  44. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomControls.d.ts +6 -0
  45. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomControls.js +11 -0
  46. package/es/components/ImageEditor/plugins/zoomPlugin/index.d.ts +2 -0
  47. package/es/components/ImageEditor/plugins/zoomPlugin/index.js +10 -0
  48. package/es/components/ImageEditor/plugins/zoomPlugin/useZoomPan.d.ts +33 -0
  49. package/es/components/ImageEditor/plugins/zoomPlugin/useZoomPan.js +80 -0
  50. package/es/components/ImageEditor/types.d.ts +69 -0
  51. package/es/components/ImageEditor/types.js +1 -0
  52. package/es/components/ImageEditor/useActiveTool.d.ts +9 -0
  53. package/es/components/ImageEditor/useActiveTool.js +11 -0
  54. package/es/components/ImageEditor/useEdgeDetection.d.ts +18 -0
  55. package/es/components/ImageEditor/useEdgeDetection.js +125 -0
  56. package/es/components/ImageEditor/useImageEditor.d.ts +12 -0
  57. package/es/components/ImageEditor/useImageEditor.js +28 -0
  58. package/es/components/ImageEditor/useImageHistory.d.ts +21 -0
  59. package/es/components/ImageEditor/useImageHistory.js +153 -0
  60. package/es/components/ImageEditor/useOpenCV.d.ts +14 -0
  61. package/es/components/ImageEditor/useOpenCV.js +92 -0
  62. package/es/components/MergeProgressDialog/index.d.ts +8 -0
  63. package/es/components/MergeProgressDialog/index.js +136 -0
  64. package/es/components/PdfViewer/PdfViewer.css +12 -0
  65. package/es/components/PdfViewer/PdfViewer.d.ts +7 -0
  66. package/es/components/PdfViewer/PdfViewer.js +108 -0
  67. package/es/components/PdfViewer/index.d.ts +1 -0
  68. package/es/components/PdfViewer/index.js +1 -0
  69. package/es/index.d.ts +3 -0
  70. package/es/index.js +3 -0
  71. package/package.json +8 -3
  72. package/src/components/FilePond.tsx +1 -1
  73. package/src/components/ImageEditor/CropCanvas.tsx +100 -0
  74. package/src/components/ImageEditor/DragHandle.tsx +65 -0
  75. package/src/components/ImageEditor/EdgeHandle.tsx +65 -0
  76. package/src/components/ImageEditor/ImageEditor.css +224 -0
  77. package/src/components/ImageEditor/ImageEditor.tsx +310 -0
  78. package/src/components/ImageEditor/ResponsiveButton.tsx +32 -0
  79. package/src/components/ImageEditor/defaultPlugins.ts +10 -0
  80. package/src/components/ImageEditor/index.ts +6 -0
  81. package/src/components/ImageEditor/plugins/cropPlugin/CropContext.tsx +24 -0
  82. package/src/components/ImageEditor/plugins/cropPlugin/CropOverlay.tsx +119 -0
  83. package/src/components/ImageEditor/plugins/cropPlugin/CropToolbar.tsx +36 -0
  84. package/src/components/ImageEditor/plugins/cropPlugin/index.ts +20 -0
  85. package/src/components/ImageEditor/plugins/cropPlugin/useCropTool.ts +176 -0
  86. package/src/components/ImageEditor/plugins/redactPlugin/RedactContext.tsx +23 -0
  87. package/src/components/ImageEditor/plugins/redactPlugin/RedactOverlay.tsx +144 -0
  88. package/src/components/ImageEditor/plugins/redactPlugin/RedactToolbar.tsx +56 -0
  89. package/src/components/ImageEditor/plugins/redactPlugin/index.ts +20 -0
  90. package/src/components/ImageEditor/plugins/redactPlugin/useRedactTool.ts +197 -0
  91. package/src/components/ImageEditor/plugins/rotatePlugin.ts +43 -0
  92. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.tsx +123 -0
  93. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomContext.tsx +15 -0
  94. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomControls.tsx +43 -0
  95. package/src/components/ImageEditor/plugins/zoomPlugin/index.ts +12 -0
  96. package/src/components/ImageEditor/plugins/zoomPlugin/useZoomPan.ts +126 -0
  97. package/src/components/ImageEditor/types.ts +69 -0
  98. package/src/components/ImageEditor/useActiveTool.ts +21 -0
  99. package/src/components/ImageEditor/useEdgeDetection.ts +167 -0
  100. package/src/components/ImageEditor/useImageEditor.ts +39 -0
  101. package/src/components/ImageEditor/useImageHistory.ts +189 -0
  102. package/src/components/ImageEditor/useOpenCV.ts +134 -0
  103. package/src/components/MergeProgressDialog/index.tsx +249 -0
  104. package/src/components/PdfViewer/PdfViewer.css +12 -0
  105. package/src/components/PdfViewer/PdfViewer.tsx +139 -0
  106. package/src/components/PdfViewer/index.ts +1 -0
  107. package/src/index.ts +3 -0
@@ -0,0 +1,189 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ import type { ActionRegistry, EditAction } from "./types.js";
4
+
5
+ interface HistoryState {
6
+ past: EditAction[][];
7
+ future: EditAction[][];
8
+ }
9
+
10
+ interface CurrentState {
11
+ url: string;
12
+ blob: Blob | null;
13
+ /** True if `url` is an object URL we created and must eventually revoke. */
14
+ generated: boolean;
15
+ }
16
+
17
+ export interface UseImageHistoryResult {
18
+ currentImageUrl: string;
19
+ currentBlob: Blob | null;
20
+ canUndo: boolean;
21
+ canRedo: boolean;
22
+ /** True while an async replay (undo/redo) is in progress. */
23
+ replaying: boolean;
24
+ applyActions: (blob: Blob, actions: EditAction[]) => void;
25
+ undo: () => void;
26
+ redo: () => void;
27
+ reset: () => void;
28
+ }
29
+
30
+ /**
31
+ * Manages undo/redo history for the image editor using action replay.
32
+ *
33
+ * Only action lists are stored — no intermediate blobs. Undo/redo replays
34
+ * all remaining actions from the source image, keeping memory usage minimal
35
+ * regardless of history depth. Only one blob is in memory at a time.
36
+ */
37
+ export function useImageHistory(sourceUrl: string, registry: ActionRegistry): UseImageHistoryResult {
38
+ let [history, setHistory] = useState<HistoryState>({ past: [], future: [] });
39
+ let [current, setCurrent] = useState<CurrentState>({ url: sourceUrl, blob: null, generated: false });
40
+ let [replaying, setReplaying] = useState(false);
41
+
42
+ let historyRef = useRef(history);
43
+ historyRef.current = history;
44
+ let currentRef = useRef(current);
45
+ currentRef.current = current;
46
+ let isReplaying = useRef(false);
47
+ let replayAbort = useRef<AbortController | null>(null);
48
+
49
+ // When source changes (e.g. component remount with new image), reset fully.
50
+ useEffect(() => {
51
+ replayAbort.current?.abort();
52
+ isReplaying.current = false;
53
+ setReplaying(false);
54
+ setCurrent((prev) => {
55
+ if (prev.generated) URL.revokeObjectURL(prev.url);
56
+ return { url: sourceUrl, blob: null, generated: false };
57
+ });
58
+ setHistory({ past: [], future: [] });
59
+ }, [sourceUrl]);
60
+
61
+ useEffect(() => {
62
+ return () => {
63
+ if (currentRef.current.generated) URL.revokeObjectURL(currentRef.current.url);
64
+ replayAbort.current?.abort();
65
+ };
66
+ }, []);
67
+
68
+ function applyActions(blob: Blob, actions: EditAction[]) {
69
+ // A direct apply always takes priority — abort any in-flight replay.
70
+ replayAbort.current?.abort();
71
+ isReplaying.current = false;
72
+ setReplaying(false);
73
+
74
+ let newUrl = URL.createObjectURL(blob);
75
+ setCurrent((prev) => {
76
+ if (prev.generated) URL.revokeObjectURL(prev.url);
77
+ return { url: newUrl, blob, generated: true };
78
+ });
79
+ setHistory((prev) => ({ past: [...prev.past, actions], future: [] }));
80
+ }
81
+
82
+ async function doReplay(newPast: EditAction[][], newFuture: EditAction[][]) {
83
+ replayAbort.current?.abort();
84
+ let abort = new AbortController();
85
+ replayAbort.current = abort;
86
+ isReplaying.current = true;
87
+ setReplaying(true);
88
+
89
+ try {
90
+ let result = await replayAll(sourceUrl, newPast, registry, abort.signal);
91
+ if (abort.signal.aborted) return;
92
+
93
+ setCurrent((prev) => {
94
+ if (prev.generated) URL.revokeObjectURL(prev.url);
95
+ if (result) return { url: result.url, blob: result.blob, generated: true };
96
+ return { url: sourceUrl, blob: null, generated: false };
97
+ });
98
+ setHistory({ past: newPast, future: newFuture });
99
+ } finally {
100
+ if (!abort.signal.aborted) {
101
+ isReplaying.current = false;
102
+ setReplaying(false);
103
+ }
104
+ }
105
+ }
106
+
107
+ function undo() {
108
+ if (isReplaying.current) return;
109
+ let { past, future } = historyRef.current;
110
+ if (past.length === 0) return;
111
+ doReplay(past.slice(0, -1), [past.at(-1)!, ...future]);
112
+ }
113
+
114
+ function redo() {
115
+ if (isReplaying.current) return;
116
+ let { past, future } = historyRef.current;
117
+ if (future.length === 0) return;
118
+ doReplay([...past, future[0]], future.slice(1));
119
+ }
120
+
121
+ function reset() {
122
+ replayAbort.current?.abort();
123
+ isReplaying.current = false;
124
+ setReplaying(false);
125
+ setCurrent((prev) => {
126
+ if (prev.generated) URL.revokeObjectURL(prev.url);
127
+ return { url: sourceUrl, blob: null, generated: false };
128
+ });
129
+ setHistory({ past: [], future: [] });
130
+ }
131
+
132
+ return {
133
+ currentImageUrl: current.url,
134
+ currentBlob: current.blob,
135
+ canUndo: history.past.length > 0,
136
+ canRedo: history.future.length > 0,
137
+ replaying,
138
+ applyActions,
139
+ undo,
140
+ redo,
141
+ reset,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Replays a sequence of action groups from a source URL.
147
+ * Intermediate object URLs are revoked as soon as a new step succeeds.
148
+ * Returns the final `{ url, blob }` or `null` if there were no actions.
149
+ */
150
+ async function replayAll(
151
+ sourceUrl: string,
152
+ actionGroups: EditAction[][],
153
+ registry: ActionRegistry,
154
+ signal: AbortSignal,
155
+ ): Promise<{ url: string; blob: Blob } | null> {
156
+ let prevUrl = sourceUrl;
157
+ let prevGenerated = false;
158
+ let lastBlob: Blob | null = null;
159
+
160
+ for (let group of actionGroups) {
161
+ for (let action of group) {
162
+ if (signal.aborted) {
163
+ if (prevGenerated) URL.revokeObjectURL(prevUrl);
164
+ return null;
165
+ }
166
+
167
+ let apply = registry.get(action.type);
168
+ if (!apply) {
169
+ console.warn(`[ImageHistory] No replay handler for action type "${action.type}" — skipping`);
170
+ continue;
171
+ }
172
+
173
+ let blob = await apply(prevUrl, action.params);
174
+
175
+ if (signal.aborted) {
176
+ if (prevGenerated) URL.revokeObjectURL(prevUrl);
177
+ return null;
178
+ }
179
+
180
+ if (prevGenerated) URL.revokeObjectURL(prevUrl);
181
+ lastBlob = blob;
182
+ prevUrl = URL.createObjectURL(blob);
183
+ prevGenerated = true;
184
+ }
185
+ }
186
+
187
+ if (!lastBlob || !prevGenerated) return null;
188
+ return { url: prevUrl, blob: lastBlob };
189
+ }
@@ -0,0 +1,134 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ let loadPromise: Promise<unknown> | null = null;
4
+
5
+ declare global {
6
+ interface Window {
7
+ cv: any;
8
+ }
9
+ }
10
+
11
+ function waitForCvMat(timeoutMs = 30_000): Promise<void> {
12
+ return new Promise((resolve, reject) => {
13
+ let elapsed = 0;
14
+ let interval = setInterval(() => {
15
+ console.log("[OpenCV] polling... cv=", typeof window.cv, "cv.Mat=", typeof window.cv?.Mat);
16
+ // cv.Mat signals that the WASM runtime has fully initialised
17
+ if (window.cv?.Mat) {
18
+ clearInterval(interval);
19
+ console.log("[OpenCV] cv.Mat found — ready!");
20
+ resolve(); // callers read window.cv directly
21
+ return;
22
+ }
23
+ elapsed += 100;
24
+ if (elapsed >= timeoutMs) {
25
+ clearInterval(interval);
26
+ reject(new Error("OpenCV.js initialisation timed out"));
27
+ }
28
+ }, 100);
29
+ });
30
+ }
31
+
32
+ export function loadOpenCV(): Promise<unknown> {
33
+ if (loadPromise) {
34
+ console.log("[OpenCV] loadOpenCV() — reusing existing promise");
35
+ return loadPromise;
36
+ }
37
+
38
+ // Already initialised (e.g. hot-reload)
39
+ if (window.cv?.Mat) {
40
+ console.log("[OpenCV] already initialised on window.cv");
41
+ loadPromise = Promise.resolve(window.cv);
42
+ return loadPromise;
43
+ }
44
+
45
+ console.log("[OpenCV] injecting script tag...");
46
+ loadPromise = new Promise<unknown>((resolve, reject) => {
47
+ let script = document.createElement("script");
48
+ script.src = "https://docs.opencv.org/4.10.0/opencv.js";
49
+ script.async = true;
50
+
51
+ script.onload = () => {
52
+ console.log(
53
+ "[OpenCV] script onload fired. typeof window.cv=",
54
+ typeof window.cv,
55
+ "cv?.then=",
56
+ typeof window.cv?.then,
57
+ "cv?.Mat=",
58
+ typeof window.cv?.Mat,
59
+ );
60
+ if (typeof window.cv?.then === "function") {
61
+ console.log("[OpenCV] promise-based build detected, awaiting cv...");
62
+ // Emscripten promise-based build: cv itself is the promise.
63
+ // IMPORTANT: we must NOT pass `instance` into resolve() — if the resolved
64
+ // value is itself thenable, Promise will follow it forever and never settle.
65
+ // Instead we mutate window.cv and signal readiness with undefined.
66
+ (window.cv as Promise<unknown>).then(
67
+ (instance) => {
68
+ console.log(
69
+ "[OpenCV] promise resolved, instance=",
70
+ typeof instance,
71
+ "hasReadMethod=",
72
+ typeof (instance as any)?.imread,
73
+ );
74
+ window.cv = instance;
75
+ resolve(undefined); // signal only; callers read window.cv
76
+ },
77
+ (err) => {
78
+ console.error("[OpenCV] promise rejected", err);
79
+ reject(err);
80
+ },
81
+ );
82
+ } else {
83
+ console.log("[OpenCV] polling-based build, starting waitForCvMat...");
84
+ // Polling build: WASM initialises asynchronously after script loads
85
+ waitForCvMat().then(() => resolve(undefined), reject);
86
+ }
87
+ };
88
+
89
+ script.onerror = (err) => {
90
+ console.error("[OpenCV] script load error", err);
91
+ loadPromise = null;
92
+ reject(new Error("Failed to load OpenCV.js"));
93
+ };
94
+
95
+ document.head.appendChild(script);
96
+ });
97
+
98
+ return loadPromise;
99
+ }
100
+
101
+ interface UseOpenCVResult {
102
+ cv: any | null;
103
+ ready: boolean;
104
+ error: Error | null;
105
+ }
106
+
107
+ /** Lazily loads OpenCV.js from CDN. Starts loading only when `enabled` is true. */
108
+ export function useOpenCV(enabled = true): UseOpenCVResult {
109
+ let [cv, setCv] = useState<any | null>(null);
110
+ let [ready, setReady] = useState(false);
111
+ let [error, setError] = useState<Error | null>(null);
112
+
113
+ useEffect(() => {
114
+ if (!enabled) return;
115
+ console.log("[useOpenCV] enabled=true, calling loadOpenCV()");
116
+ loadOpenCV()
117
+ .then(() => {
118
+ console.log(
119
+ "[useOpenCV] loadOpenCV resolved. window.cv=",
120
+ typeof window.cv,
121
+ "imread=",
122
+ typeof window.cv?.imread,
123
+ );
124
+ setCv(window.cv);
125
+ setReady(true);
126
+ })
127
+ .catch((err: Error) => {
128
+ console.error("[useOpenCV] loadOpenCV rejected:", err);
129
+ setError(err);
130
+ });
131
+ }, [enabled]);
132
+
133
+ return { cv, ready, error };
134
+ }
@@ -0,0 +1,249 @@
1
+ import { Box, Button, DialogActions, LinearProgress, Link, Typography } from "@mui/material";
2
+ import { getLocalizedString } from "@olenbetong/appframe-core";
3
+ import { ResponsiveDialog } from "@olenbetong/appframe-mui";
4
+ import { useEffect, useRef, useState } from "react";
5
+
6
+ export type MergeProgressDialogProps = {
7
+ open: boolean;
8
+ onClose: () => void;
9
+ /** Called once when the dialog opens; should return the merger ID for the start route */
10
+ getMergerId: () => Promise<string | number>;
11
+ mergeType?: "pdf" | "zip";
12
+ };
13
+
14
+ type MergePhase =
15
+ | { kind: "preparing" }
16
+ | { kind: "processing"; completed: number; total: number; currentFile: string; startedAt: number }
17
+ | { kind: "completed"; jobId: string; fileName: string }
18
+ | { kind: "failed"; error: string };
19
+
20
+ function formatRemaining(ms: number) {
21
+ if (ms < 60_000) return `~${Math.ceil(ms / 1000)}s`;
22
+ let mins = Math.floor(ms / 60_000);
23
+ let secs = Math.ceil((ms % 60_000) / 1000);
24
+ return secs > 0 ? `~${mins}m ${secs}s` : `~${mins}m`;
25
+ }
26
+
27
+ export function MergeProgressDialog({ open, onClose, getMergerId, mergeType = "pdf" }: MergeProgressDialogProps) {
28
+ let [phase, setPhase] = useState<MergePhase>({ kind: "preparing" });
29
+ let [now, setNow] = useState(Date.now);
30
+ let esRef = useRef<EventSource | null>(null);
31
+ let doneRef = useRef(false);
32
+
33
+ // Tick every second while processing to recompute the time estimate
34
+ useEffect(() => {
35
+ if (phase.kind !== "processing") return;
36
+ let timer = setInterval(() => setNow(Date.now()), 1000);
37
+ return () => clearInterval(timer);
38
+ }, [phase.kind]);
39
+
40
+ // Start the merge job each time the dialog opens
41
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only re-runs on open change
42
+ useEffect(() => {
43
+ if (!open) return;
44
+
45
+ setPhase({ kind: "preparing" });
46
+ doneRef.current = false;
47
+
48
+ let cancelled = false;
49
+ let es: EventSource | null = null;
50
+
51
+ async function start() {
52
+ try {
53
+ let mergerId = await getMergerId();
54
+ if (cancelled) return;
55
+
56
+ let response = await fetch(`/api/ob/merger/start/${mergerId}/${mergeType}`, { method: "POST" });
57
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
58
+ let { jobId } = await response.json();
59
+ if (cancelled) return;
60
+
61
+ es = new EventSource(`/api/ob/merger/events/${jobId}`);
62
+ esRef.current = es;
63
+
64
+ es.addEventListener("progress", (e) => {
65
+ let d = JSON.parse((e as MessageEvent).data);
66
+ setPhase((prev) => ({
67
+ kind: "processing",
68
+ completed: d.completed,
69
+ total: d.total,
70
+ currentFile: d.currentFile ?? "",
71
+ startedAt: prev.kind === "processing" ? prev.startedAt : Date.now(),
72
+ }));
73
+ setNow(Date.now());
74
+ });
75
+
76
+ es.addEventListener("done", (e) => {
77
+ doneRef.current = true;
78
+ es?.close();
79
+ let d = JSON.parse((e as MessageEvent).data);
80
+ setPhase({ kind: "completed", jobId, fileName: d.fileName ?? "" });
81
+ window.open(`/api/ob/merger/download/${jobId}`);
82
+ });
83
+
84
+ // Catches both server-sent "event: error" and connection errors.
85
+ // After a "done" event the server closes the stream, which also fires error —
86
+ // doneRef guards against treating that as a failure.
87
+ es.addEventListener("error", (e) => {
88
+ if (doneRef.current) return;
89
+ es?.close();
90
+ let message = getLocalizedString("Unknown error");
91
+ let data = (e as MessageEvent).data;
92
+ if (data) {
93
+ try {
94
+ message = JSON.parse(data).message ?? message;
95
+ } catch {}
96
+ }
97
+ setPhase({ kind: "failed", error: message });
98
+ });
99
+ } catch (err: unknown) {
100
+ if (!cancelled) {
101
+ setPhase({ kind: "failed", error: (err as Error)?.message ?? String(err) });
102
+ }
103
+ }
104
+ }
105
+
106
+ start();
107
+
108
+ return () => {
109
+ cancelled = true;
110
+ es?.close();
111
+ esRef.current = null;
112
+ };
113
+ }, [open]);
114
+
115
+ function handleClose() {
116
+ esRef.current?.close();
117
+ esRef.current = null;
118
+ onClose();
119
+ }
120
+
121
+ let pct = 0;
122
+ let remaining: string | null = null;
123
+
124
+ if (phase.kind === "processing" && phase.total > 0) {
125
+ pct = Math.round((phase.completed / phase.total) * 100);
126
+ if (phase.completed > 0) {
127
+ let elapsed = now - phase.startedAt;
128
+ let msPerFile = elapsed / phase.completed;
129
+ let remainingMs = msPerFile * (phase.total - phase.completed);
130
+ remaining = formatRemaining(remainingMs);
131
+ }
132
+ }
133
+
134
+ let title =
135
+ phase.kind === "completed"
136
+ ? getLocalizedString("Merge complete")
137
+ : phase.kind === "failed"
138
+ ? getLocalizedString("Merge failed")
139
+ : getLocalizedString("Merging drawings");
140
+
141
+ let canClose = phase.kind === "completed" || phase.kind === "failed";
142
+
143
+ return (
144
+ <ResponsiveDialog
145
+ open={open}
146
+ onClose={canClose ? handleClose : undefined}
147
+ title={title}
148
+ maxWidth="sm"
149
+ fullWidth
150
+ slotProps={{
151
+ transition: {
152
+ mountOnEnter: true,
153
+ unmountOnExit: true,
154
+ },
155
+ }}
156
+ >
157
+ <Box sx={{ px: 3, pb: 1 }}>
158
+ {(phase.kind === "preparing" || phase.kind === "processing") && (
159
+ <LinearProgress
160
+ variant={phase.kind === "preparing" || phase.total === 0 ? "indeterminate" : "determinate"}
161
+ value={pct}
162
+ sx={{ mb: 1.5 }}
163
+ />
164
+ )}
165
+
166
+ {phase.kind === "preparing" && (
167
+ <Typography
168
+ variant="body2"
169
+ sx={{
170
+ color: "text.secondary",
171
+ }}
172
+ >
173
+ {getLocalizedString("Starting merge job…")}
174
+ </Typography>
175
+ )}
176
+
177
+ {phase.kind === "processing" && (
178
+ <>
179
+ <Typography variant="body2">
180
+ {phase.completed} / {phase.total} {getLocalizedString("files")}
181
+ {remaining && (
182
+ <Typography
183
+ component="span"
184
+ variant="body2"
185
+ sx={{
186
+ color: "text.secondary",
187
+ }}
188
+ >
189
+ {" "}
190
+ — {remaining}
191
+ </Typography>
192
+ )}
193
+ </Typography>
194
+ {phase.currentFile && (
195
+ <Typography
196
+ variant="body2"
197
+ noWrap
198
+ title={phase.currentFile}
199
+ sx={{
200
+ color: "text.secondary",
201
+ mt: 0.5,
202
+ }}
203
+ >
204
+ {phase.currentFile}
205
+ </Typography>
206
+ )}
207
+ </>
208
+ )}
209
+
210
+ {phase.kind === "completed" && (
211
+ <>
212
+ <Typography
213
+ variant="body2"
214
+ sx={{
215
+ color: "success.main",
216
+ }}
217
+ >
218
+ {getLocalizedString("Download started. You can close this dialog.")}
219
+ </Typography>
220
+ <Typography
221
+ variant="body2"
222
+ sx={{
223
+ color: "text.secondary",
224
+ mt: 1,
225
+ }}
226
+ >
227
+ {getLocalizedString("If the file did not open automatically, ")}{" "}
228
+ <Link href={`/api/ob/merger/download/${phase.jobId}`} target="_blank" rel="noopener noreferrer">
229
+ {getLocalizedString("click here to open it")}
230
+ </Link>
231
+ .
232
+ </Typography>
233
+ </>
234
+ )}
235
+
236
+ {phase.kind === "failed" && (
237
+ <Typography variant="body2" color="error">
238
+ {phase.error}
239
+ </Typography>
240
+ )}
241
+ </Box>
242
+ <DialogActions>
243
+ <Button onClick={handleClose} disabled={!canClose}>
244
+ {getLocalizedString("Close")}
245
+ </Button>
246
+ </DialogActions>
247
+ </ResponsiveDialog>
248
+ );
249
+ }
@@ -0,0 +1,12 @@
1
+ .pdfViewerContainer {
2
+ width: 100%;
3
+ height: 100%;
4
+ overflow: auto;
5
+ }
6
+
7
+ .pdfViewerContent {
8
+ width: max-content;
9
+ /* Center the PDF horizontally within the scroll container */
10
+ margin-left: auto;
11
+ margin-right: auto;
12
+ }