@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,153 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ /**
3
+ * Manages undo/redo history for the image editor using action replay.
4
+ *
5
+ * Only action lists are stored — no intermediate blobs. Undo/redo replays
6
+ * all remaining actions from the source image, keeping memory usage minimal
7
+ * regardless of history depth. Only one blob is in memory at a time.
8
+ */
9
+ export function useImageHistory(sourceUrl, registry) {
10
+ let [history, setHistory] = useState({ past: [], future: [] });
11
+ let [current, setCurrent] = useState({ url: sourceUrl, blob: null, generated: false });
12
+ let [replaying, setReplaying] = useState(false);
13
+ let historyRef = useRef(history);
14
+ historyRef.current = history;
15
+ let currentRef = useRef(current);
16
+ currentRef.current = current;
17
+ let isReplaying = useRef(false);
18
+ let replayAbort = useRef(null);
19
+ // When source changes (e.g. component remount with new image), reset fully.
20
+ useEffect(() => {
21
+ replayAbort.current?.abort();
22
+ isReplaying.current = false;
23
+ setReplaying(false);
24
+ setCurrent((prev) => {
25
+ if (prev.generated)
26
+ URL.revokeObjectURL(prev.url);
27
+ return { url: sourceUrl, blob: null, generated: false };
28
+ });
29
+ setHistory({ past: [], future: [] });
30
+ }, [sourceUrl]);
31
+ useEffect(() => {
32
+ return () => {
33
+ if (currentRef.current.generated)
34
+ URL.revokeObjectURL(currentRef.current.url);
35
+ replayAbort.current?.abort();
36
+ };
37
+ }, []);
38
+ function applyActions(blob, actions) {
39
+ // A direct apply always takes priority — abort any in-flight replay.
40
+ replayAbort.current?.abort();
41
+ isReplaying.current = false;
42
+ setReplaying(false);
43
+ let newUrl = URL.createObjectURL(blob);
44
+ setCurrent((prev) => {
45
+ if (prev.generated)
46
+ URL.revokeObjectURL(prev.url);
47
+ return { url: newUrl, blob, generated: true };
48
+ });
49
+ setHistory((prev) => ({ past: [...prev.past, actions], future: [] }));
50
+ }
51
+ async function doReplay(newPast, newFuture) {
52
+ replayAbort.current?.abort();
53
+ let abort = new AbortController();
54
+ replayAbort.current = abort;
55
+ isReplaying.current = true;
56
+ setReplaying(true);
57
+ try {
58
+ let result = await replayAll(sourceUrl, newPast, registry, abort.signal);
59
+ if (abort.signal.aborted)
60
+ return;
61
+ setCurrent((prev) => {
62
+ if (prev.generated)
63
+ URL.revokeObjectURL(prev.url);
64
+ if (result)
65
+ return { url: result.url, blob: result.blob, generated: true };
66
+ return { url: sourceUrl, blob: null, generated: false };
67
+ });
68
+ setHistory({ past: newPast, future: newFuture });
69
+ }
70
+ finally {
71
+ if (!abort.signal.aborted) {
72
+ isReplaying.current = false;
73
+ setReplaying(false);
74
+ }
75
+ }
76
+ }
77
+ function undo() {
78
+ if (isReplaying.current)
79
+ return;
80
+ let { past, future } = historyRef.current;
81
+ if (past.length === 0)
82
+ return;
83
+ doReplay(past.slice(0, -1), [past.at(-1), ...future]);
84
+ }
85
+ function redo() {
86
+ if (isReplaying.current)
87
+ return;
88
+ let { past, future } = historyRef.current;
89
+ if (future.length === 0)
90
+ return;
91
+ doReplay([...past, future[0]], future.slice(1));
92
+ }
93
+ function reset() {
94
+ replayAbort.current?.abort();
95
+ isReplaying.current = false;
96
+ setReplaying(false);
97
+ setCurrent((prev) => {
98
+ if (prev.generated)
99
+ URL.revokeObjectURL(prev.url);
100
+ return { url: sourceUrl, blob: null, generated: false };
101
+ });
102
+ setHistory({ past: [], future: [] });
103
+ }
104
+ return {
105
+ currentImageUrl: current.url,
106
+ currentBlob: current.blob,
107
+ canUndo: history.past.length > 0,
108
+ canRedo: history.future.length > 0,
109
+ replaying,
110
+ applyActions,
111
+ undo,
112
+ redo,
113
+ reset,
114
+ };
115
+ }
116
+ /**
117
+ * Replays a sequence of action groups from a source URL.
118
+ * Intermediate object URLs are revoked as soon as a new step succeeds.
119
+ * Returns the final `{ url, blob }` or `null` if there were no actions.
120
+ */
121
+ async function replayAll(sourceUrl, actionGroups, registry, signal) {
122
+ let prevUrl = sourceUrl;
123
+ let prevGenerated = false;
124
+ let lastBlob = null;
125
+ for (let group of actionGroups) {
126
+ for (let action of group) {
127
+ if (signal.aborted) {
128
+ if (prevGenerated)
129
+ URL.revokeObjectURL(prevUrl);
130
+ return null;
131
+ }
132
+ let apply = registry.get(action.type);
133
+ if (!apply) {
134
+ console.warn(`[ImageHistory] No replay handler for action type "${action.type}" — skipping`);
135
+ continue;
136
+ }
137
+ let blob = await apply(prevUrl, action.params);
138
+ if (signal.aborted) {
139
+ if (prevGenerated)
140
+ URL.revokeObjectURL(prevUrl);
141
+ return null;
142
+ }
143
+ if (prevGenerated)
144
+ URL.revokeObjectURL(prevUrl);
145
+ lastBlob = blob;
146
+ prevUrl = URL.createObjectURL(blob);
147
+ prevGenerated = true;
148
+ }
149
+ }
150
+ if (!lastBlob || !prevGenerated)
151
+ return null;
152
+ return { url: prevUrl, blob: lastBlob };
153
+ }
@@ -0,0 +1,14 @@
1
+ declare global {
2
+ interface Window {
3
+ cv: any;
4
+ }
5
+ }
6
+ export declare function loadOpenCV(): Promise<unknown>;
7
+ interface UseOpenCVResult {
8
+ cv: any | null;
9
+ ready: boolean;
10
+ error: Error | null;
11
+ }
12
+ /** Lazily loads OpenCV.js from CDN. Starts loading only when `enabled` is true. */
13
+ export declare function useOpenCV(enabled?: boolean): UseOpenCVResult;
14
+ export {};
@@ -0,0 +1,92 @@
1
+ import { useEffect, useState } from "react";
2
+ let loadPromise = null;
3
+ function waitForCvMat(timeoutMs = 30_000) {
4
+ return new Promise((resolve, reject) => {
5
+ let elapsed = 0;
6
+ let interval = setInterval(() => {
7
+ console.log("[OpenCV] polling... cv=", typeof window.cv, "cv.Mat=", typeof window.cv?.Mat);
8
+ // cv.Mat signals that the WASM runtime has fully initialised
9
+ if (window.cv?.Mat) {
10
+ clearInterval(interval);
11
+ console.log("[OpenCV] cv.Mat found — ready!");
12
+ resolve(); // callers read window.cv directly
13
+ return;
14
+ }
15
+ elapsed += 100;
16
+ if (elapsed >= timeoutMs) {
17
+ clearInterval(interval);
18
+ reject(new Error("OpenCV.js initialisation timed out"));
19
+ }
20
+ }, 100);
21
+ });
22
+ }
23
+ export function loadOpenCV() {
24
+ if (loadPromise) {
25
+ console.log("[OpenCV] loadOpenCV() — reusing existing promise");
26
+ return loadPromise;
27
+ }
28
+ // Already initialised (e.g. hot-reload)
29
+ if (window.cv?.Mat) {
30
+ console.log("[OpenCV] already initialised on window.cv");
31
+ loadPromise = Promise.resolve(window.cv);
32
+ return loadPromise;
33
+ }
34
+ console.log("[OpenCV] injecting script tag...");
35
+ loadPromise = new Promise((resolve, reject) => {
36
+ let script = document.createElement("script");
37
+ script.src = "https://docs.opencv.org/4.10.0/opencv.js";
38
+ script.async = true;
39
+ script.onload = () => {
40
+ console.log("[OpenCV] script onload fired. typeof window.cv=", typeof window.cv, "cv?.then=", typeof window.cv?.then, "cv?.Mat=", typeof window.cv?.Mat);
41
+ if (typeof window.cv?.then === "function") {
42
+ console.log("[OpenCV] promise-based build detected, awaiting cv...");
43
+ // Emscripten promise-based build: cv itself is the promise.
44
+ // IMPORTANT: we must NOT pass `instance` into resolve() — if the resolved
45
+ // value is itself thenable, Promise will follow it forever and never settle.
46
+ // Instead we mutate window.cv and signal readiness with undefined.
47
+ window.cv.then((instance) => {
48
+ console.log("[OpenCV] promise resolved, instance=", typeof instance, "hasReadMethod=", typeof instance?.imread);
49
+ window.cv = instance;
50
+ resolve(undefined); // signal only; callers read window.cv
51
+ }, (err) => {
52
+ console.error("[OpenCV] promise rejected", err);
53
+ reject(err);
54
+ });
55
+ }
56
+ else {
57
+ console.log("[OpenCV] polling-based build, starting waitForCvMat...");
58
+ // Polling build: WASM initialises asynchronously after script loads
59
+ waitForCvMat().then(() => resolve(undefined), reject);
60
+ }
61
+ };
62
+ script.onerror = (err) => {
63
+ console.error("[OpenCV] script load error", err);
64
+ loadPromise = null;
65
+ reject(new Error("Failed to load OpenCV.js"));
66
+ };
67
+ document.head.appendChild(script);
68
+ });
69
+ return loadPromise;
70
+ }
71
+ /** Lazily loads OpenCV.js from CDN. Starts loading only when `enabled` is true. */
72
+ export function useOpenCV(enabled = true) {
73
+ let [cv, setCv] = useState(null);
74
+ let [ready, setReady] = useState(false);
75
+ let [error, setError] = useState(null);
76
+ useEffect(() => {
77
+ if (!enabled)
78
+ return;
79
+ console.log("[useOpenCV] enabled=true, calling loadOpenCV()");
80
+ loadOpenCV()
81
+ .then(() => {
82
+ console.log("[useOpenCV] loadOpenCV resolved. window.cv=", typeof window.cv, "imread=", typeof window.cv?.imread);
83
+ setCv(window.cv);
84
+ setReady(true);
85
+ })
86
+ .catch((err) => {
87
+ console.error("[useOpenCV] loadOpenCV rejected:", err);
88
+ setError(err);
89
+ });
90
+ }, [enabled]);
91
+ return { cv, ready, error };
92
+ }
@@ -0,0 +1,8 @@
1
+ export type MergeProgressDialogProps = {
2
+ open: boolean;
3
+ onClose: () => void;
4
+ /** Called once when the dialog opens; should return the merger ID for the start route */
5
+ getMergerId: () => Promise<string | number>;
6
+ mergeType?: "pdf" | "zip";
7
+ };
8
+ export declare function MergeProgressDialog({ open, onClose, getMergerId, mergeType }: MergeProgressDialogProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,136 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Button, DialogActions, LinearProgress, Link, Typography } from "@mui/material";
3
+ import { getLocalizedString } from "@olenbetong/appframe-core";
4
+ import { ResponsiveDialog } from "@olenbetong/appframe-mui";
5
+ import { useEffect, useRef, useState } from "react";
6
+ function formatRemaining(ms) {
7
+ if (ms < 60_000)
8
+ return `~${Math.ceil(ms / 1000)}s`;
9
+ let mins = Math.floor(ms / 60_000);
10
+ let secs = Math.ceil((ms % 60_000) / 1000);
11
+ return secs > 0 ? `~${mins}m ${secs}s` : `~${mins}m`;
12
+ }
13
+ export function MergeProgressDialog({ open, onClose, getMergerId, mergeType = "pdf" }) {
14
+ let [phase, setPhase] = useState({ kind: "preparing" });
15
+ let [now, setNow] = useState(Date.now);
16
+ let esRef = useRef(null);
17
+ let doneRef = useRef(false);
18
+ // Tick every second while processing to recompute the time estimate
19
+ useEffect(() => {
20
+ if (phase.kind !== "processing")
21
+ return;
22
+ let timer = setInterval(() => setNow(Date.now()), 1000);
23
+ return () => clearInterval(timer);
24
+ }, [phase.kind]);
25
+ // Start the merge job each time the dialog opens
26
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only re-runs on open change
27
+ useEffect(() => {
28
+ if (!open)
29
+ return;
30
+ setPhase({ kind: "preparing" });
31
+ doneRef.current = false;
32
+ let cancelled = false;
33
+ let es = null;
34
+ async function start() {
35
+ try {
36
+ let mergerId = await getMergerId();
37
+ if (cancelled)
38
+ return;
39
+ let response = await fetch(`/api/ob/merger/start/${mergerId}/${mergeType}`, { method: "POST" });
40
+ if (!response.ok)
41
+ throw new Error(`${response.status} ${response.statusText}`);
42
+ let { jobId } = await response.json();
43
+ if (cancelled)
44
+ return;
45
+ es = new EventSource(`/api/ob/merger/events/${jobId}`);
46
+ esRef.current = es;
47
+ es.addEventListener("progress", (e) => {
48
+ let d = JSON.parse(e.data);
49
+ setPhase((prev) => ({
50
+ kind: "processing",
51
+ completed: d.completed,
52
+ total: d.total,
53
+ currentFile: d.currentFile ?? "",
54
+ startedAt: prev.kind === "processing" ? prev.startedAt : Date.now(),
55
+ }));
56
+ setNow(Date.now());
57
+ });
58
+ es.addEventListener("done", (e) => {
59
+ doneRef.current = true;
60
+ es?.close();
61
+ let d = JSON.parse(e.data);
62
+ setPhase({ kind: "completed", jobId, fileName: d.fileName ?? "" });
63
+ window.open(`/api/ob/merger/download/${jobId}`);
64
+ });
65
+ // Catches both server-sent "event: error" and connection errors.
66
+ // After a "done" event the server closes the stream, which also fires error —
67
+ // doneRef guards against treating that as a failure.
68
+ es.addEventListener("error", (e) => {
69
+ if (doneRef.current)
70
+ return;
71
+ es?.close();
72
+ let message = getLocalizedString("Unknown error");
73
+ let data = e.data;
74
+ if (data) {
75
+ try {
76
+ message = JSON.parse(data).message ?? message;
77
+ }
78
+ catch { }
79
+ }
80
+ setPhase({ kind: "failed", error: message });
81
+ });
82
+ }
83
+ catch (err) {
84
+ if (!cancelled) {
85
+ setPhase({ kind: "failed", error: err?.message ?? String(err) });
86
+ }
87
+ }
88
+ }
89
+ start();
90
+ return () => {
91
+ cancelled = true;
92
+ es?.close();
93
+ esRef.current = null;
94
+ };
95
+ }, [open]);
96
+ function handleClose() {
97
+ esRef.current?.close();
98
+ esRef.current = null;
99
+ onClose();
100
+ }
101
+ let pct = 0;
102
+ let remaining = null;
103
+ if (phase.kind === "processing" && phase.total > 0) {
104
+ pct = Math.round((phase.completed / phase.total) * 100);
105
+ if (phase.completed > 0) {
106
+ let elapsed = now - phase.startedAt;
107
+ let msPerFile = elapsed / phase.completed;
108
+ let remainingMs = msPerFile * (phase.total - phase.completed);
109
+ remaining = formatRemaining(remainingMs);
110
+ }
111
+ }
112
+ let title = phase.kind === "completed"
113
+ ? getLocalizedString("Merge complete")
114
+ : phase.kind === "failed"
115
+ ? getLocalizedString("Merge failed")
116
+ : getLocalizedString("Merging drawings");
117
+ let canClose = phase.kind === "completed" || phase.kind === "failed";
118
+ return (_jsxs(ResponsiveDialog, { open: open, onClose: canClose ? handleClose : undefined, title: title, maxWidth: "sm", fullWidth: true, slotProps: {
119
+ transition: {
120
+ mountOnEnter: true,
121
+ unmountOnExit: true,
122
+ },
123
+ }, children: [_jsxs(Box, { sx: { px: 3, pb: 1 }, children: [(phase.kind === "preparing" || phase.kind === "processing") && (_jsx(LinearProgress, { variant: phase.kind === "preparing" || phase.total === 0 ? "indeterminate" : "determinate", value: pct, sx: { mb: 1.5 } })), phase.kind === "preparing" && (_jsx(Typography, { variant: "body2", sx: {
124
+ color: "text.secondary",
125
+ }, children: getLocalizedString("Starting merge job…") })), phase.kind === "processing" && (_jsxs(_Fragment, { children: [_jsxs(Typography, { variant: "body2", children: [phase.completed, " / ", phase.total, " ", getLocalizedString("files"), remaining && (_jsxs(Typography, { component: "span", variant: "body2", sx: {
126
+ color: "text.secondary",
127
+ }, children: [" ", "\u2014 ", remaining] }))] }), phase.currentFile && (_jsx(Typography, { variant: "body2", noWrap: true, title: phase.currentFile, sx: {
128
+ color: "text.secondary",
129
+ mt: 0.5,
130
+ }, children: phase.currentFile }))] })), phase.kind === "completed" && (_jsxs(_Fragment, { children: [_jsx(Typography, { variant: "body2", sx: {
131
+ color: "success.main",
132
+ }, children: getLocalizedString("Download started. You can close this dialog.") }), _jsxs(Typography, { variant: "body2", sx: {
133
+ color: "text.secondary",
134
+ mt: 1,
135
+ }, children: [getLocalizedString("If the file did not open automatically, "), " ", _jsx(Link, { href: `/api/ob/merger/download/${phase.jobId}`, target: "_blank", rel: "noopener noreferrer", children: getLocalizedString("click here to open it") }), "."] })] })), phase.kind === "failed" && (_jsx(Typography, { variant: "body2", color: "error", children: phase.error }))] }), _jsx(DialogActions, { children: _jsx(Button, { onClick: handleClose, disabled: !canClose, children: getLocalizedString("Close") }) })] }));
136
+ }
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ import "./PdfViewer.css";
2
+ type PdfViewerProps = {
3
+ url?: string;
4
+ file?: string | Blob | ArrayBuffer;
5
+ };
6
+ export declare function PdfViewer({ url, file }: PdfViewerProps): import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { Document, Page, pdfjs } from "react-pdf";
4
+ import "./PdfViewer.css";
5
+ // Use new URL(..., import.meta.url) — understood by Rolldown/Rollup on all platforms,
6
+ // including Windows where ?raw query strings are invalid in file paths.
7
+ pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
8
+ export function PdfViewer({ url, file }) {
9
+ let [numPages, setNumPages] = useState(0);
10
+ let [error, setError] = useState(null);
11
+ let containerRef = useRef(null);
12
+ let [containerWidth, setContainerWidth] = useState(0);
13
+ let [containerHeight, setContainerHeight] = useState(0);
14
+ let [scale, setScale] = useState(1);
15
+ let [naturalPageSize, setNaturalPageSize] = useState(null);
16
+ const pages = useMemo(() => Array.from({ length: numPages }, (_, i) => i + 1), [numPages]);
17
+ // Observe container size to support fit-to-page calculations
18
+ useEffect(() => {
19
+ let el = containerRef.current;
20
+ if (!el)
21
+ return;
22
+ let ro = new ResizeObserver((entries) => {
23
+ let cr = entries[0].contentRect;
24
+ setContainerWidth(Math.max(1, Math.floor(cr.width)));
25
+ setContainerHeight(Math.max(1, Math.floor(cr.height)));
26
+ });
27
+ ro.observe(el);
28
+ setContainerWidth(Math.max(1, Math.floor(el.clientWidth)));
29
+ setContainerHeight(Math.max(1, Math.floor(el.clientHeight)));
30
+ return () => ro.disconnect();
31
+ }, []);
32
+ // Intercept browser zoom gestures and zoom the PDF instead
33
+ useEffect(() => {
34
+ let el = containerRef.current;
35
+ if (!el)
36
+ return;
37
+ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
38
+ const zoomBy = (delta) => {
39
+ // Smooth exponential zoom for trackpads/mice
40
+ const factor = Math.exp(delta * 0.001);
41
+ setScale((s) => clamp(s * factor, 0.25, 5));
42
+ };
43
+ const onWheel = (e) => {
44
+ if (e.ctrlKey) {
45
+ // ctrl+wheel is usually browser zoom; override for PDF zoom
46
+ e.preventDefault();
47
+ zoomBy(-e.deltaY);
48
+ }
49
+ };
50
+ const onKeyDown = (e) => {
51
+ if (!e.ctrlKey)
52
+ return;
53
+ const key = e.key;
54
+ if (key === "+" || key === "=" || key === "Add") {
55
+ e.preventDefault();
56
+ setScale((s) => clamp(s * 1.1, 0.25, 5));
57
+ }
58
+ else if (key === "-" || key === "_" || key === "Subtract") {
59
+ e.preventDefault();
60
+ setScale((s) => clamp(s / 1.1, 0.25, 5));
61
+ }
62
+ else if (key === "0") {
63
+ e.preventDefault();
64
+ setScale(1);
65
+ }
66
+ };
67
+ el.addEventListener("wheel", onWheel, { passive: false });
68
+ window.addEventListener("keydown", onKeyDown, { passive: false });
69
+ return () => {
70
+ el.removeEventListener("wheel", onWheel);
71
+ window.removeEventListener("keydown", onKeyDown);
72
+ };
73
+ }, []);
74
+ // When source changes, reset auto-fit baseline
75
+ useEffect(() => {
76
+ setNaturalPageSize(null);
77
+ setScale(1);
78
+ }, [url, file]);
79
+ // Compute base width to fit full page into container, then apply zoom scale.
80
+ // If we don't yet know page's natural size, default to fit-to-width.
81
+ const fitBaseWidth = useMemo(() => {
82
+ if (!naturalPageSize)
83
+ return containerWidth || 1000;
84
+ const { w, h } = naturalPageSize;
85
+ if (w <= 0 || h <= 0)
86
+ return containerWidth || 1000;
87
+ // width to fit by height: containerHeight * (w/h)
88
+ const widthByHeight = (containerHeight || 1000) * (w / h);
89
+ // Fit to page means respecting both constraints
90
+ return Math.min(Math.max(1, containerWidth || 1000), Math.max(1, widthByHeight));
91
+ }, [naturalPageSize, containerWidth, containerHeight]);
92
+ const pageWidth = Math.max(1, Math.round(fitBaseWidth * scale));
93
+ return (_jsxs("div", { ref: containerRef, className: "pdfViewerContainer", children: [_jsx("div", { className: "pdfViewerContent", children: _jsx(Document, { file: file ?? url, onLoadSuccess: ({ numPages }) => setNumPages(numPages), onLoadError: (e) => setError(e?.message ?? "Failed to load PDF"), children: pages.map((pageNumber) => (_jsx(Page, { pageNumber: pageNumber, renderTextLayer: false, renderAnnotationLayer: false, width: pageWidth, onLoadSuccess: (p) => {
94
+ // Capture natural page size once for fit-to-page calculation
95
+ try {
96
+ // pdf.js viewport at scale 1
97
+ const vp = p.getViewport({ scale: 1 });
98
+ const w = Number(vp?.width) || Number(p.width) || 0;
99
+ const h = Number(vp?.height) || Number(p.height) || 0;
100
+ if (w > 0 && h > 0 && !naturalPageSize) {
101
+ setNaturalPageSize({ w, h });
102
+ }
103
+ }
104
+ catch {
105
+ // Ignore, fallback to fit-to-width when unknown
106
+ }
107
+ } }, pageNumber))) }) }), error && _jsxs("div", { style: { color: "#b00020", padding: 8 }, children: ["Error loading PDF: ", error] })] }));
108
+ }
@@ -0,0 +1 @@
1
+ export { PdfViewer } from "./PdfViewer.js";
@@ -0,0 +1 @@
1
+ export { PdfViewer } from "./PdfViewer.js";
package/es/index.d.ts CHANGED
@@ -2,8 +2,11 @@ export { Checkbox, type CheckboxProps } from "./components/Checkbox/index.js";
2
2
  export { CardColumn, CardDetail, CardLabel, CardStep, CardStepList, ColorCard } from "./components/ColorCard/index.js";
3
3
  export { DateNavigator } from "./components/DateNavigator/index.js";
4
4
  export * from "./components/FilePond.js";
5
+ export * from "./components/ImageEditor/index.js";
5
6
  export { Card, LinkedList, LinkedListItem } from "./components/LinkedCardList/index.js";
7
+ export { MergeProgressDialog, type MergeProgressDialogProps } from "./components/MergeProgressDialog/index.js";
6
8
  export { BannerImage as PageBannerImage, type BannerImageProps, PageBanner, type PageBannerProps, Tab as PageBannerTab, TabList as PageBannerTabList, type TabListProps as PageBannerTabListProps, type TabProps as PageBannerTabProps, Title as PageBannerTitle, type TitleProps as PageBannerTitleProps, } from "./components/PageBanner/index.js";
9
+ export * from "./components/PdfViewer/index.js";
7
10
  export { Portal } from "./components/Portal/index.js";
8
11
  export { ProgressBar } from "./components/ProgressBar/index.js";
9
12
  export { Sidebar, type SidebarProps } from "./components/Sidebar/index.js";
package/es/index.js CHANGED
@@ -2,8 +2,11 @@ export { Checkbox } from "./components/Checkbox/index.js";
2
2
  export { CardColumn, CardDetail, CardLabel, CardStep, CardStepList, ColorCard } from "./components/ColorCard/index.js";
3
3
  export { DateNavigator } from "./components/DateNavigator/index.js";
4
4
  export * from "./components/FilePond.js";
5
+ export * from "./components/ImageEditor/index.js";
5
6
  export { Card, LinkedList, LinkedListItem } from "./components/LinkedCardList/index.js";
7
+ export { MergeProgressDialog } from "./components/MergeProgressDialog/index.js";
6
8
  export { BannerImage as PageBannerImage, PageBanner, Tab as PageBannerTab, TabList as PageBannerTabList, Title as PageBannerTitle, } from "./components/PageBanner/index.js";
9
+ export * from "./components/PdfViewer/index.js";
7
10
  export { Portal } from "./components/Portal/index.js";
8
11
  export { ProgressBar } from "./components/ProgressBar/index.js";
9
12
  export { Sidebar } from "./components/Sidebar/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/synergi-react",
3
- "version": "2.3.8",
3
+ "version": "2.4.0",
4
4
  "description": "Standalone React component for SynergiWeb and Partner Portal",
5
5
  "type": "module",
6
6
  "exports": {
@@ -38,9 +38,12 @@
38
38
  "filepond-plugin-image-transform": "^3.8.7",
39
39
  "localforage": "^1.10.0",
40
40
  "mitt": "^3.0.1",
41
+ "pdf-lib": "^1.17.1",
42
+ "pdfjs-dist": "5.4.296",
43
+ "react-pdf": "^10.2.0",
41
44
  "@olenbetong/appframe-core": "2.11.10",
42
- "@olenbetong/appframe-mui": "6.0.12",
43
- "@olenbetong/appframe-react": "1.21.24"
45
+ "@olenbetong/appframe-react": "1.21.25",
46
+ "@olenbetong/appframe-mui": "6.1.0"
44
47
  },
45
48
  "repository": {
46
49
  "type": "git",
@@ -54,6 +57,8 @@
54
57
  ]
55
58
  },
56
59
  "devDependencies": {
60
+ "react": "19.2.5",
61
+ "react-dom": "19.2.5",
57
62
  "replace-in-files": "^3.0.0"
58
63
  },
59
64
  "optionalDependencies": {},
@@ -22,7 +22,7 @@ import FilePondPluginImageResize from "filepond-plugin-image-resize";
22
22
  import FilePondPluginImageTransform from "filepond-plugin-image-transform";
23
23
  import { type RefObject, useEffect, useMemo, useRef } from "react";
24
24
 
25
- export { registerPlugin, FileStatus };
25
+ export { FileStatus, registerPlugin };
26
26
 
27
27
  registerPlugin(
28
28
  FilePondPluginImageExifOrientation,