@olenbetong/synergi-react 2.3.7 → 2.3.9

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 (102) hide show
  1. package/es/components/ImageEditor/CropCanvas.d.ts +16 -0
  2. package/es/components/ImageEditor/CropCanvas.js +79 -0
  3. package/es/components/ImageEditor/DragHandle.d.ts +14 -0
  4. package/es/components/ImageEditor/DragHandle.js +40 -0
  5. package/es/components/ImageEditor/EdgeHandle.d.ts +16 -0
  6. package/es/components/ImageEditor/EdgeHandle.js +43 -0
  7. package/es/components/ImageEditor/ImageEditor.css +224 -0
  8. package/es/components/ImageEditor/ImageEditor.d.ts +24 -0
  9. package/es/components/ImageEditor/ImageEditor.js +108 -0
  10. package/es/components/ImageEditor/ResponsiveButton.d.ts +11 -0
  11. package/es/components/ImageEditor/ResponsiveButton.js +9 -0
  12. package/es/components/ImageEditor/defaultPlugins.d.ts +4 -0
  13. package/es/components/ImageEditor/defaultPlugins.js +8 -0
  14. package/es/components/ImageEditor/index.d.ts +6 -0
  15. package/es/components/ImageEditor/index.js +4 -0
  16. package/es/components/ImageEditor/plugins/cropPlugin/CropContext.d.ts +10 -0
  17. package/es/components/ImageEditor/plugins/cropPlugin/CropContext.js +14 -0
  18. package/es/components/ImageEditor/plugins/cropPlugin/CropOverlay.d.ts +2 -0
  19. package/es/components/ImageEditor/plugins/cropPlugin/CropOverlay.js +30 -0
  20. package/es/components/ImageEditor/plugins/cropPlugin/CropToolbar.d.ts +2 -0
  21. package/es/components/ImageEditor/plugins/cropPlugin/CropToolbar.js +16 -0
  22. package/es/components/ImageEditor/plugins/cropPlugin/index.d.ts +6 -0
  23. package/es/components/ImageEditor/plugins/cropPlugin/index.js +16 -0
  24. package/es/components/ImageEditor/plugins/cropPlugin/useCropTool.d.ts +34 -0
  25. package/es/components/ImageEditor/plugins/cropPlugin/useCropTool.js +138 -0
  26. package/es/components/ImageEditor/plugins/redactPlugin/RedactContext.d.ts +10 -0
  27. package/es/components/ImageEditor/plugins/redactPlugin/RedactContext.js +14 -0
  28. package/es/components/ImageEditor/plugins/redactPlugin/RedactOverlay.d.ts +6 -0
  29. package/es/components/ImageEditor/plugins/redactPlugin/RedactOverlay.js +29 -0
  30. package/es/components/ImageEditor/plugins/redactPlugin/RedactToolbar.d.ts +2 -0
  31. package/es/components/ImageEditor/plugins/redactPlugin/RedactToolbar.js +29 -0
  32. package/es/components/ImageEditor/plugins/redactPlugin/index.d.ts +6 -0
  33. package/es/components/ImageEditor/plugins/redactPlugin/index.js +16 -0
  34. package/es/components/ImageEditor/plugins/redactPlugin/useRedactTool.d.ts +49 -0
  35. package/es/components/ImageEditor/plugins/redactPlugin/useRedactTool.js +142 -0
  36. package/es/components/ImageEditor/plugins/rotatePlugin.d.ts +3 -0
  37. package/es/components/ImageEditor/plugins/rotatePlugin.js +39 -0
  38. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.d.ts +20 -0
  39. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.js +91 -0
  40. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomContext.d.ts +6 -0
  41. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomContext.js +14 -0
  42. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomControls.d.ts +6 -0
  43. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomControls.js +11 -0
  44. package/es/components/ImageEditor/plugins/zoomPlugin/index.d.ts +2 -0
  45. package/es/components/ImageEditor/plugins/zoomPlugin/index.js +10 -0
  46. package/es/components/ImageEditor/plugins/zoomPlugin/useZoomPan.d.ts +33 -0
  47. package/es/components/ImageEditor/plugins/zoomPlugin/useZoomPan.js +80 -0
  48. package/es/components/ImageEditor/types.d.ts +69 -0
  49. package/es/components/ImageEditor/types.js +1 -0
  50. package/es/components/ImageEditor/useActiveTool.d.ts +9 -0
  51. package/es/components/ImageEditor/useActiveTool.js +11 -0
  52. package/es/components/ImageEditor/useEdgeDetection.d.ts +18 -0
  53. package/es/components/ImageEditor/useEdgeDetection.js +125 -0
  54. package/es/components/ImageEditor/useImageEditor.d.ts +12 -0
  55. package/es/components/ImageEditor/useImageEditor.js +28 -0
  56. package/es/components/ImageEditor/useImageHistory.d.ts +21 -0
  57. package/es/components/ImageEditor/useImageHistory.js +153 -0
  58. package/es/components/ImageEditor/useOpenCV.d.ts +14 -0
  59. package/es/components/ImageEditor/useOpenCV.js +92 -0
  60. package/es/components/PdfViewer/PdfViewer.css +12 -0
  61. package/es/components/PdfViewer/PdfViewer.d.ts +7 -0
  62. package/es/components/PdfViewer/PdfViewer.js +132 -0
  63. package/es/components/PdfViewer/index.d.ts +1 -0
  64. package/es/components/PdfViewer/index.js +1 -0
  65. package/es/index.d.ts +2 -0
  66. package/es/index.js +2 -0
  67. package/package.json +8 -3
  68. package/src/components/ImageEditor/CropCanvas.tsx +100 -0
  69. package/src/components/ImageEditor/DragHandle.tsx +65 -0
  70. package/src/components/ImageEditor/EdgeHandle.tsx +65 -0
  71. package/src/components/ImageEditor/ImageEditor.css +224 -0
  72. package/src/components/ImageEditor/ImageEditor.tsx +310 -0
  73. package/src/components/ImageEditor/ResponsiveButton.tsx +32 -0
  74. package/src/components/ImageEditor/defaultPlugins.ts +10 -0
  75. package/src/components/ImageEditor/index.ts +6 -0
  76. package/src/components/ImageEditor/plugins/cropPlugin/CropContext.tsx +24 -0
  77. package/src/components/ImageEditor/plugins/cropPlugin/CropOverlay.tsx +119 -0
  78. package/src/components/ImageEditor/plugins/cropPlugin/CropToolbar.tsx +36 -0
  79. package/src/components/ImageEditor/plugins/cropPlugin/index.ts +20 -0
  80. package/src/components/ImageEditor/plugins/cropPlugin/useCropTool.ts +176 -0
  81. package/src/components/ImageEditor/plugins/redactPlugin/RedactContext.tsx +23 -0
  82. package/src/components/ImageEditor/plugins/redactPlugin/RedactOverlay.tsx +144 -0
  83. package/src/components/ImageEditor/plugins/redactPlugin/RedactToolbar.tsx +56 -0
  84. package/src/components/ImageEditor/plugins/redactPlugin/index.ts +20 -0
  85. package/src/components/ImageEditor/plugins/redactPlugin/useRedactTool.ts +197 -0
  86. package/src/components/ImageEditor/plugins/rotatePlugin.ts +43 -0
  87. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.tsx +123 -0
  88. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomContext.tsx +15 -0
  89. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomControls.tsx +43 -0
  90. package/src/components/ImageEditor/plugins/zoomPlugin/index.ts +12 -0
  91. package/src/components/ImageEditor/plugins/zoomPlugin/useZoomPan.ts +126 -0
  92. package/src/components/ImageEditor/types.ts +69 -0
  93. package/src/components/ImageEditor/useActiveTool.ts +21 -0
  94. package/src/components/ImageEditor/useEdgeDetection.ts +167 -0
  95. package/src/components/ImageEditor/useImageEditor.ts +39 -0
  96. package/src/components/ImageEditor/useImageHistory.ts +189 -0
  97. package/src/components/ImageEditor/useOpenCV.ts +134 -0
  98. package/src/components/PdfViewer/PdfViewer.css +12 -0
  99. package/src/components/PdfViewer/PdfViewer.tsx +160 -0
  100. package/src/components/PdfViewer/index.ts +1 -0
  101. package/src/elements/ElementLookupDialog/HIDScanner/useHIDScanner.ts +1 -1
  102. package/src/index.ts +2 -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,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,132 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import rawPdfWorkerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?raw";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { Document, Page, pdfjs } from "react-pdf";
5
+ import "./PdfViewer.css";
6
+ // Clear any leftover workerPort from previous mounts/HMR to avoid using a terminated worker
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ const opts = pdfjs.GlobalWorkerOptions;
9
+ if (opts.workerPort && typeof opts.workerPort === "object" && typeof opts.workerPort.terminate === "function") {
10
+ try {
11
+ opts.workerPort.terminate();
12
+ }
13
+ catch { }
14
+ }
15
+ // Remove the property entirely to avoid type checks on undefined
16
+ try {
17
+ delete opts.workerPort;
18
+ }
19
+ catch {
20
+ opts.workerPort = null;
21
+ }
22
+ // Provide a dedicated Worker instance via blob; pdf.js prefers workerPort over workerSrc
23
+ try {
24
+ const blob = new Blob([rawPdfWorkerSrc], { type: "text/javascript" });
25
+ const worker = new Worker(URL.createObjectURL(blob), { type: "module" });
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ pdfjs.GlobalWorkerOptions.workerPort = worker;
28
+ }
29
+ catch {
30
+ // Ignore – pdf.js may fallback to fake worker in extreme cases
31
+ }
32
+ export function PdfViewer({ url, file }) {
33
+ let [numPages, setNumPages] = useState(0);
34
+ let [error, setError] = useState(null);
35
+ let containerRef = useRef(null);
36
+ let [containerWidth, setContainerWidth] = useState(0);
37
+ let [containerHeight, setContainerHeight] = useState(0);
38
+ let [scale, setScale] = useState(1);
39
+ let [naturalPageSize, setNaturalPageSize] = useState(null);
40
+ const pages = useMemo(() => Array.from({ length: numPages }, (_, i) => i + 1), [numPages]);
41
+ // Observe container size to support fit-to-page calculations
42
+ useEffect(() => {
43
+ let el = containerRef.current;
44
+ if (!el)
45
+ return;
46
+ let ro = new ResizeObserver((entries) => {
47
+ let cr = entries[0].contentRect;
48
+ setContainerWidth(Math.max(1, Math.floor(cr.width)));
49
+ setContainerHeight(Math.max(1, Math.floor(cr.height)));
50
+ });
51
+ ro.observe(el);
52
+ setContainerWidth(Math.max(1, Math.floor(el.clientWidth)));
53
+ setContainerHeight(Math.max(1, Math.floor(el.clientHeight)));
54
+ return () => ro.disconnect();
55
+ }, []);
56
+ // Intercept browser zoom gestures and zoom the PDF instead
57
+ useEffect(() => {
58
+ let el = containerRef.current;
59
+ if (!el)
60
+ return;
61
+ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
62
+ const zoomBy = (delta) => {
63
+ // Smooth exponential zoom for trackpads/mice
64
+ const factor = Math.exp(delta * 0.001);
65
+ setScale((s) => clamp(s * factor, 0.25, 5));
66
+ };
67
+ const onWheel = (e) => {
68
+ if (e.ctrlKey) {
69
+ // ctrl+wheel is usually browser zoom; override for PDF zoom
70
+ e.preventDefault();
71
+ zoomBy(-e.deltaY);
72
+ }
73
+ };
74
+ const onKeyDown = (e) => {
75
+ if (!e.ctrlKey)
76
+ return;
77
+ const key = e.key;
78
+ if (key === "+" || key === "=" || key === "Add") {
79
+ e.preventDefault();
80
+ setScale((s) => clamp(s * 1.1, 0.25, 5));
81
+ }
82
+ else if (key === "-" || key === "_" || key === "Subtract") {
83
+ e.preventDefault();
84
+ setScale((s) => clamp(s / 1.1, 0.25, 5));
85
+ }
86
+ else if (key === "0") {
87
+ e.preventDefault();
88
+ setScale(1);
89
+ }
90
+ };
91
+ el.addEventListener("wheel", onWheel, { passive: false });
92
+ window.addEventListener("keydown", onKeyDown, { passive: false });
93
+ return () => {
94
+ el.removeEventListener("wheel", onWheel);
95
+ window.removeEventListener("keydown", onKeyDown);
96
+ };
97
+ }, []);
98
+ // When source changes, reset auto-fit baseline
99
+ useEffect(() => {
100
+ setNaturalPageSize(null);
101
+ setScale(1);
102
+ }, [url, file]);
103
+ // Compute base width to fit full page into container, then apply zoom scale.
104
+ // If we don't yet know page's natural size, default to fit-to-width.
105
+ const fitBaseWidth = useMemo(() => {
106
+ if (!naturalPageSize)
107
+ return containerWidth || 1000;
108
+ const { w, h } = naturalPageSize;
109
+ if (w <= 0 || h <= 0)
110
+ return containerWidth || 1000;
111
+ // width to fit by height: containerHeight * (w/h)
112
+ const widthByHeight = (containerHeight || 1000) * (w / h);
113
+ // Fit to page means respecting both constraints
114
+ return Math.min(Math.max(1, containerWidth || 1000), Math.max(1, widthByHeight));
115
+ }, [naturalPageSize, containerWidth, containerHeight]);
116
+ const pageWidth = Math.max(1, Math.round(fitBaseWidth * scale));
117
+ 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) => {
118
+ // Capture natural page size once for fit-to-page calculation
119
+ try {
120
+ // pdf.js viewport at scale 1
121
+ const vp = p.getViewport({ scale: 1 });
122
+ const w = Number(vp?.width) || Number(p.width) || 0;
123
+ const h = Number(vp?.height) || Number(p.height) || 0;
124
+ if (w > 0 && h > 0 && !naturalPageSize) {
125
+ setNaturalPageSize({ w, h });
126
+ }
127
+ }
128
+ catch {
129
+ // Ignore, fallback to fit-to-width when unknown
130
+ }
131
+ } }, pageNumber))) }) }), error && _jsxs("div", { style: { color: "#b00020", padding: 8 }, children: ["Error loading PDF: ", error] })] }));
132
+ }
@@ -0,0 +1 @@
1
+ export { PdfViewer } from "./PdfViewer.js";
@@ -0,0 +1 @@
1
+ export { PdfViewer } from "./PdfViewer.js";
package/es/index.d.ts CHANGED
@@ -11,3 +11,5 @@ export { Spinner, type SpinnerProps } from "./components/Spinner/index.js";
11
11
  export { SplitContainer, type SplitContainerProps } from "./components/SplitContainer/index.js";
12
12
  export { ValueToggleGroup, type ValueToggleGroupProps } from "./components/ValueToggleGroup/index.js";
13
13
  export { useTranslation } from "./useTranslation/index.js";
14
+ export * from "./components/ImageEditor/index.js";
15
+ export * from "./components/PdfViewer/index.js";
package/es/index.js CHANGED
@@ -11,3 +11,5 @@ export { Spinner } from "./components/Spinner/index.js";
11
11
  export { SplitContainer } from "./components/SplitContainer/index.js";
12
12
  export { ValueToggleGroup } from "./components/ValueToggleGroup/index.js";
13
13
  export { useTranslation } from "./useTranslation/index.js";
14
+ export * from "./components/ImageEditor/index.js";
15
+ export * from "./components/PdfViewer/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/synergi-react",
3
- "version": "2.3.7",
3
+ "version": "2.3.9",
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-react": "1.21.23",
43
- "@olenbetong/appframe-mui": "6.0.11"
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": {},
@@ -0,0 +1,100 @@
1
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
2
+
3
+ import type { Quad } from "./useEdgeDetection.js";
4
+
5
+ const MAX_DISPLAY_WIDTH = 1600;
6
+
7
+ export interface CropCanvasHandle {
8
+ /** Returns a canvas with the clean image (no overlay) for use with cv.imread. */
9
+ getCleanCanvas(): HTMLCanvasElement | null;
10
+ }
11
+
12
+ interface Props {
13
+ imageUrl: string;
14
+ corners: Quad;
15
+ onReady?: (canvas: HTMLCanvasElement, width: number, height: number) => void;
16
+ }
17
+
18
+ function drawOverlay(ctx: CanvasRenderingContext2D, width: number, height: number, corners: Quad) {
19
+ let [tl, tr, br, bl] = corners;
20
+
21
+ ctx.save();
22
+
23
+ // Dark overlay with polygon cutout (even-odd fill rule creates a "window")
24
+ ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
25
+ ctx.beginPath();
26
+ ctx.rect(0, 0, width, height);
27
+ ctx.moveTo(tl.x, tl.y);
28
+ ctx.lineTo(tr.x, tr.y);
29
+ ctx.lineTo(br.x, br.y);
30
+ ctx.lineTo(bl.x, bl.y);
31
+ ctx.closePath();
32
+ ctx.fill("evenodd");
33
+
34
+ // Selection border
35
+ ctx.strokeStyle = "#2196f3";
36
+ ctx.lineWidth = Math.max(2, width * 0.003);
37
+ ctx.beginPath();
38
+ ctx.moveTo(tl.x, tl.y);
39
+ ctx.lineTo(tr.x, tr.y);
40
+ ctx.lineTo(br.x, br.y);
41
+ ctx.lineTo(bl.x, bl.y);
42
+ ctx.closePath();
43
+ ctx.stroke();
44
+
45
+ ctx.restore();
46
+ }
47
+
48
+ /**
49
+ * Renders the image on a canvas with a perspective-crop overlay.
50
+ * Exposes `getImageData()` so callers can extract pixels for off-thread processing.
51
+ */
52
+ export let CropCanvas = forwardRef<CropCanvasHandle, Props>(function CropCanvas({ imageUrl, corners, onReady }, ref) {
53
+ let canvasRef = useRef<HTMLCanvasElement>(null);
54
+ let imageRef = useRef<HTMLImageElement | null>(null);
55
+
56
+ useEffect(() => {
57
+ let img = new Image();
58
+ img.onload = () => {
59
+ imageRef.current = img;
60
+ let canvas = canvasRef.current;
61
+ if (!canvas) return;
62
+
63
+ let scale = Math.min(1, MAX_DISPLAY_WIDTH / img.naturalWidth);
64
+ canvas.width = Math.round(img.naturalWidth * scale);
65
+ canvas.height = Math.round(img.naturalHeight * scale);
66
+
67
+ let ctx = canvas.getContext("2d");
68
+ ctx?.drawImage(img, 0, 0, canvas.width, canvas.height);
69
+ drawOverlay(ctx!, canvas.width, canvas.height, corners);
70
+ onReady?.(canvas, canvas.width, canvas.height);
71
+ };
72
+ img.src = imageUrl;
73
+ }, [imageUrl]); // eslint-disable-line react-hooks/exhaustive-deps
74
+
75
+ // Redraw overlay whenever corners change
76
+ useEffect(() => {
77
+ let canvas = canvasRef.current;
78
+ let img = imageRef.current;
79
+ if (!canvas || !img) return;
80
+ let ctx = canvas.getContext("2d");
81
+ if (!ctx) return;
82
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
83
+ drawOverlay(ctx, canvas.width, canvas.height, corners);
84
+ }, [corners]);
85
+
86
+ useImperativeHandle(ref, () => ({
87
+ getCleanCanvas(): HTMLCanvasElement | null {
88
+ let canvas = canvasRef.current;
89
+ let img = imageRef.current;
90
+ if (!canvas || !img) return null;
91
+ let clean = document.createElement("canvas");
92
+ clean.width = canvas.width;
93
+ clean.height = canvas.height;
94
+ clean.getContext("2d")?.drawImage(img, 0, 0, canvas.width, canvas.height);
95
+ return clean;
96
+ },
97
+ }));
98
+
99
+ return <canvas ref={canvasRef} style={{ display: "block", maxWidth: "100%", height: "auto" }} />;
100
+ });
@@ -0,0 +1,65 @@
1
+ import { useZoom } from "./plugins/zoomPlugin/ZoomContext.js";
2
+
3
+ interface Props {
4
+ x: number;
5
+ y: number;
6
+ /** Called with new position in pixels (relative to container). */
7
+ onChange: (x: number, y: number) => void;
8
+ containerWidth: number;
9
+ containerHeight: number;
10
+ }
11
+
12
+ /**
13
+ * Absolutely-positioned draggable corner handle.
14
+ * Position is in CSS pixels relative to the nearest `position: relative` ancestor.
15
+ */
16
+ export function DragHandle({ x, y, onChange, containerWidth, containerHeight }: Props) {
17
+ let { zoom } = useZoom();
18
+
19
+ function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
20
+ e.currentTarget.setPointerCapture(e.pointerId);
21
+ }
22
+
23
+ function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
24
+ if (!(e.buttons & 1)) return;
25
+
26
+ let container = e.currentTarget.parentElement;
27
+ if (!container) return;
28
+
29
+ // getBoundingClientRect() returns screen-space coords (affected by CSS transform/zoom).
30
+ // Divide by zoom to convert back to CSS-pixel space (the space onChange expects).
31
+ let rect = container.getBoundingClientRect();
32
+ let newX = Math.max(0, Math.min(containerWidth, (e.clientX - rect.left) / zoom));
33
+ let newY = Math.max(0, Math.min(containerHeight, (e.clientY - rect.top) / zoom));
34
+ onChange(newX, newY);
35
+ }
36
+
37
+ return (
38
+ <div
39
+ tabIndex={0}
40
+ role="slider"
41
+ aria-label="Corner handle"
42
+ aria-valuenow={Math.round(x)}
43
+ aria-valuemin={0}
44
+ aria-valuemax={containerWidth}
45
+ className="drag-handle"
46
+ style={{
47
+ position: "absolute",
48
+ left: x,
49
+ top: y,
50
+ transform: "translate(-50%, -50%)",
51
+ width: 28,
52
+ height: 28,
53
+ borderRadius: "50%",
54
+ background: "rgba(33, 150, 243, 0.9)",
55
+ border: "3px solid white",
56
+ boxShadow: "0 2px 8px rgba(0,0,0,0.4)",
57
+ cursor: "grab",
58
+ touchAction: "none",
59
+ zIndex: 10,
60
+ }}
61
+ onPointerDown={onPointerDown}
62
+ onPointerMove={onPointerMove}
63
+ />
64
+ );
65
+ }