@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.
- package/es/components/ImageEditor/CropCanvas.d.ts +16 -0
- package/es/components/ImageEditor/CropCanvas.js +79 -0
- package/es/components/ImageEditor/DragHandle.d.ts +14 -0
- package/es/components/ImageEditor/DragHandle.js +40 -0
- package/es/components/ImageEditor/EdgeHandle.d.ts +16 -0
- package/es/components/ImageEditor/EdgeHandle.js +43 -0
- package/es/components/ImageEditor/ImageEditor.css +224 -0
- package/es/components/ImageEditor/ImageEditor.d.ts +24 -0
- package/es/components/ImageEditor/ImageEditor.js +108 -0
- package/es/components/ImageEditor/ResponsiveButton.d.ts +11 -0
- package/es/components/ImageEditor/ResponsiveButton.js +9 -0
- package/es/components/ImageEditor/defaultPlugins.d.ts +4 -0
- package/es/components/ImageEditor/defaultPlugins.js +8 -0
- package/es/components/ImageEditor/index.d.ts +6 -0
- package/es/components/ImageEditor/index.js +4 -0
- package/es/components/ImageEditor/plugins/cropPlugin/CropContext.d.ts +10 -0
- package/es/components/ImageEditor/plugins/cropPlugin/CropContext.js +14 -0
- package/es/components/ImageEditor/plugins/cropPlugin/CropOverlay.d.ts +2 -0
- package/es/components/ImageEditor/plugins/cropPlugin/CropOverlay.js +30 -0
- package/es/components/ImageEditor/plugins/cropPlugin/CropToolbar.d.ts +2 -0
- package/es/components/ImageEditor/plugins/cropPlugin/CropToolbar.js +16 -0
- package/es/components/ImageEditor/plugins/cropPlugin/index.d.ts +6 -0
- package/es/components/ImageEditor/plugins/cropPlugin/index.js +16 -0
- package/es/components/ImageEditor/plugins/cropPlugin/useCropTool.d.ts +34 -0
- package/es/components/ImageEditor/plugins/cropPlugin/useCropTool.js +138 -0
- package/es/components/ImageEditor/plugins/redactPlugin/RedactContext.d.ts +10 -0
- package/es/components/ImageEditor/plugins/redactPlugin/RedactContext.js +14 -0
- package/es/components/ImageEditor/plugins/redactPlugin/RedactOverlay.d.ts +6 -0
- package/es/components/ImageEditor/plugins/redactPlugin/RedactOverlay.js +29 -0
- package/es/components/ImageEditor/plugins/redactPlugin/RedactToolbar.d.ts +2 -0
- package/es/components/ImageEditor/plugins/redactPlugin/RedactToolbar.js +29 -0
- package/es/components/ImageEditor/plugins/redactPlugin/index.d.ts +6 -0
- package/es/components/ImageEditor/plugins/redactPlugin/index.js +16 -0
- package/es/components/ImageEditor/plugins/redactPlugin/useRedactTool.d.ts +49 -0
- package/es/components/ImageEditor/plugins/redactPlugin/useRedactTool.js +142 -0
- package/es/components/ImageEditor/plugins/rotatePlugin.d.ts +3 -0
- package/es/components/ImageEditor/plugins/rotatePlugin.js +39 -0
- package/es/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.d.ts +20 -0
- package/es/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.js +91 -0
- package/es/components/ImageEditor/plugins/zoomPlugin/ZoomContext.d.ts +6 -0
- package/es/components/ImageEditor/plugins/zoomPlugin/ZoomContext.js +14 -0
- package/es/components/ImageEditor/plugins/zoomPlugin/ZoomControls.d.ts +6 -0
- package/es/components/ImageEditor/plugins/zoomPlugin/ZoomControls.js +11 -0
- package/es/components/ImageEditor/plugins/zoomPlugin/index.d.ts +2 -0
- package/es/components/ImageEditor/plugins/zoomPlugin/index.js +10 -0
- package/es/components/ImageEditor/plugins/zoomPlugin/useZoomPan.d.ts +33 -0
- package/es/components/ImageEditor/plugins/zoomPlugin/useZoomPan.js +80 -0
- package/es/components/ImageEditor/types.d.ts +69 -0
- package/es/components/ImageEditor/types.js +1 -0
- package/es/components/ImageEditor/useActiveTool.d.ts +9 -0
- package/es/components/ImageEditor/useActiveTool.js +11 -0
- package/es/components/ImageEditor/useEdgeDetection.d.ts +18 -0
- package/es/components/ImageEditor/useEdgeDetection.js +125 -0
- package/es/components/ImageEditor/useImageEditor.d.ts +12 -0
- package/es/components/ImageEditor/useImageEditor.js +28 -0
- package/es/components/ImageEditor/useImageHistory.d.ts +21 -0
- package/es/components/ImageEditor/useImageHistory.js +153 -0
- package/es/components/ImageEditor/useOpenCV.d.ts +14 -0
- package/es/components/ImageEditor/useOpenCV.js +92 -0
- package/es/components/PdfViewer/PdfViewer.css +12 -0
- package/es/components/PdfViewer/PdfViewer.d.ts +7 -0
- package/es/components/PdfViewer/PdfViewer.js +132 -0
- package/es/components/PdfViewer/index.d.ts +1 -0
- package/es/components/PdfViewer/index.js +1 -0
- package/es/index.d.ts +2 -0
- package/es/index.js +2 -0
- package/package.json +8 -3
- package/src/components/ImageEditor/CropCanvas.tsx +100 -0
- package/src/components/ImageEditor/DragHandle.tsx +65 -0
- package/src/components/ImageEditor/EdgeHandle.tsx +65 -0
- package/src/components/ImageEditor/ImageEditor.css +224 -0
- package/src/components/ImageEditor/ImageEditor.tsx +310 -0
- package/src/components/ImageEditor/ResponsiveButton.tsx +32 -0
- package/src/components/ImageEditor/defaultPlugins.ts +10 -0
- package/src/components/ImageEditor/index.ts +6 -0
- package/src/components/ImageEditor/plugins/cropPlugin/CropContext.tsx +24 -0
- package/src/components/ImageEditor/plugins/cropPlugin/CropOverlay.tsx +119 -0
- package/src/components/ImageEditor/plugins/cropPlugin/CropToolbar.tsx +36 -0
- package/src/components/ImageEditor/plugins/cropPlugin/index.ts +20 -0
- package/src/components/ImageEditor/plugins/cropPlugin/useCropTool.ts +176 -0
- package/src/components/ImageEditor/plugins/redactPlugin/RedactContext.tsx +23 -0
- package/src/components/ImageEditor/plugins/redactPlugin/RedactOverlay.tsx +144 -0
- package/src/components/ImageEditor/plugins/redactPlugin/RedactToolbar.tsx +56 -0
- package/src/components/ImageEditor/plugins/redactPlugin/index.ts +20 -0
- package/src/components/ImageEditor/plugins/redactPlugin/useRedactTool.ts +197 -0
- package/src/components/ImageEditor/plugins/rotatePlugin.ts +43 -0
- package/src/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.tsx +123 -0
- package/src/components/ImageEditor/plugins/zoomPlugin/ZoomContext.tsx +15 -0
- package/src/components/ImageEditor/plugins/zoomPlugin/ZoomControls.tsx +43 -0
- package/src/components/ImageEditor/plugins/zoomPlugin/index.ts +12 -0
- package/src/components/ImageEditor/plugins/zoomPlugin/useZoomPan.ts +126 -0
- package/src/components/ImageEditor/types.ts +69 -0
- package/src/components/ImageEditor/useActiveTool.ts +21 -0
- package/src/components/ImageEditor/useEdgeDetection.ts +167 -0
- package/src/components/ImageEditor/useImageEditor.ts +39 -0
- package/src/components/ImageEditor/useImageHistory.ts +189 -0
- package/src/components/ImageEditor/useOpenCV.ts +134 -0
- package/src/components/PdfViewer/PdfViewer.css +12 -0
- package/src/components/PdfViewer/PdfViewer.tsx +160 -0
- package/src/components/PdfViewer/index.ts +1 -0
- package/src/elements/ElementLookupDialog/HIDScanner/useHIDScanner.ts +1 -1
- package/src/index.ts +2 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface Point {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** TL, TR, BR, BL */
|
|
9
|
+
export type Quad = [Point, Point, Point, Point];
|
|
10
|
+
|
|
11
|
+
export function fullImageQuad(width: number, height: number): Quad {
|
|
12
|
+
return [
|
|
13
|
+
{ x: 0, y: 0 },
|
|
14
|
+
{ x: width, y: 0 },
|
|
15
|
+
{ x: width, y: height },
|
|
16
|
+
{ x: 0, y: height },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FindQuadResult {
|
|
21
|
+
quad: Quad | null;
|
|
22
|
+
/** Canvas containing the Canny edge image (with detected quad overlay) for debugging */
|
|
23
|
+
debugCanvas: HTMLCanvasElement;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findLargestQuad(cv: any, src: any, minAreaFraction = 0.1): FindQuadResult {
|
|
27
|
+
let gray = new cv.Mat();
|
|
28
|
+
let blurred = new cv.Mat();
|
|
29
|
+
let edges = new cv.Mat();
|
|
30
|
+
let dilated = new cv.Mat();
|
|
31
|
+
let contours = new cv.MatVector();
|
|
32
|
+
let hierarchy = new cv.Mat();
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
|
|
36
|
+
cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0);
|
|
37
|
+
// Lower thresholds catch document-background boundaries even in low-contrast images
|
|
38
|
+
cv.Canny(blurred, edges, 40, 120);
|
|
39
|
+
|
|
40
|
+
// Dilate to close small gaps along document edges
|
|
41
|
+
let kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3));
|
|
42
|
+
cv.dilate(edges, dilated, kernel);
|
|
43
|
+
kernel.delete();
|
|
44
|
+
|
|
45
|
+
// Build RGBA debug canvas so we can draw a coloured quad overlay
|
|
46
|
+
let debugRgba = new cv.Mat();
|
|
47
|
+
cv.cvtColor(dilated, debugRgba, cv.COLOR_GRAY2RGBA);
|
|
48
|
+
|
|
49
|
+
cv.findContours(dilated, contours, hierarchy, cv.RETR_LIST, cv.CHAIN_APPROX_SIMPLE);
|
|
50
|
+
|
|
51
|
+
let best: unknown = null;
|
|
52
|
+
let bestArea = 0;
|
|
53
|
+
let minArea = src.cols * src.rows * minAreaFraction;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < contours.size(); i++) {
|
|
56
|
+
let contour = contours.get(i);
|
|
57
|
+
let peri = cv.arcLength(contour, true);
|
|
58
|
+
let approx = new cv.Mat();
|
|
59
|
+
// Larger epsilon is more aggressive — helps when the outline isn't perfectly straight
|
|
60
|
+
cv.approxPolyDP(contour, approx, 0.04 * peri, true);
|
|
61
|
+
|
|
62
|
+
if (approx.rows === 4) {
|
|
63
|
+
let area = cv.contourArea(approx);
|
|
64
|
+
if (area > bestArea && area > minArea) {
|
|
65
|
+
bestArea = area;
|
|
66
|
+
(best as any)?.delete();
|
|
67
|
+
best = approx;
|
|
68
|
+
} else {
|
|
69
|
+
approx.delete();
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
approx.delete();
|
|
73
|
+
}
|
|
74
|
+
contour.delete();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let quad: Quad | null = null;
|
|
78
|
+
if (best) {
|
|
79
|
+
let mat = best as any;
|
|
80
|
+
let pts: Point[] = [];
|
|
81
|
+
for (let i = 0; i < 4; i++) {
|
|
82
|
+
pts.push({ x: mat.data32S[i * 2], y: mat.data32S[i * 2 + 1] });
|
|
83
|
+
}
|
|
84
|
+
mat.delete();
|
|
85
|
+
|
|
86
|
+
// Sort into TL, TR, BR, BL
|
|
87
|
+
pts.sort((a, b) => a.x + a.y - (b.x + b.y));
|
|
88
|
+
let tl = pts[0];
|
|
89
|
+
let br = pts[3];
|
|
90
|
+
let remaining = [pts[1], pts[2]];
|
|
91
|
+
let tr = remaining[0].x > remaining[1].x ? remaining[0] : remaining[1];
|
|
92
|
+
let bl = remaining[0].x < remaining[1].x ? remaining[0] : remaining[1];
|
|
93
|
+
quad = [tl, tr, br, bl];
|
|
94
|
+
|
|
95
|
+
// Draw the detected quad on the debug canvas in green
|
|
96
|
+
let green = new cv.Scalar(0, 255, 0, 255);
|
|
97
|
+
let ordered = [tl, tr, br, bl];
|
|
98
|
+
for (let i = 0; i < 4; i++) {
|
|
99
|
+
let a = ordered[i];
|
|
100
|
+
let b = ordered[(i + 1) % 4];
|
|
101
|
+
cv.line(debugRgba, new cv.Point(a.x, a.y), new cv.Point(b.x, b.y), green, 3);
|
|
102
|
+
}
|
|
103
|
+
for (let pt of ordered) {
|
|
104
|
+
cv.circle(debugRgba, new cv.Point(pt.x, pt.y), 8, green, -1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let debugCanvas = document.createElement("canvas");
|
|
109
|
+
cv.imshow(debugCanvas, debugRgba);
|
|
110
|
+
debugRgba.delete();
|
|
111
|
+
|
|
112
|
+
return { quad, debugCanvas };
|
|
113
|
+
} finally {
|
|
114
|
+
gray.delete();
|
|
115
|
+
blurred.delete();
|
|
116
|
+
edges.delete();
|
|
117
|
+
dilated.delete();
|
|
118
|
+
contours.delete();
|
|
119
|
+
hierarchy.delete();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface UseEdgeDetectionResult {
|
|
124
|
+
corners: Quad | null;
|
|
125
|
+
/** Data URL of the Canny edge image, for debug visualisation */
|
|
126
|
+
debugImageUrl: string | null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Runs Canny edge detection on the image to suggest document corners.
|
|
131
|
+
* Falls back to full-image corners if no quadrilateral is found.
|
|
132
|
+
*/
|
|
133
|
+
export function useEdgeDetection(cv: any, canvas: HTMLCanvasElement | null, enabled: boolean): UseEdgeDetectionResult {
|
|
134
|
+
let [corners, setCorners] = useState<Quad | null>(null);
|
|
135
|
+
let [debugImageUrl, setDebugImageUrl] = useState<string | null>(null);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
console.log(
|
|
139
|
+
"[useEdgeDetection] effect: enabled=",
|
|
140
|
+
enabled,
|
|
141
|
+
"cv=",
|
|
142
|
+
typeof cv,
|
|
143
|
+
"canvas=",
|
|
144
|
+
canvas?.width,
|
|
145
|
+
"x",
|
|
146
|
+
canvas?.height,
|
|
147
|
+
);
|
|
148
|
+
if (!enabled || !cv || !canvas) return;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
console.log("[useEdgeDetection] running findLargestQuad...");
|
|
152
|
+
let src = cv.imread(canvas);
|
|
153
|
+
let { quad, debugCanvas } = findLargestQuad(cv, src);
|
|
154
|
+
src.delete();
|
|
155
|
+
console.log("[useEdgeDetection] quad=", quad);
|
|
156
|
+
setDebugImageUrl(debugCanvas.toDataURL());
|
|
157
|
+
setCorners(quad ?? fullImageQuad(canvas.width, canvas.height));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error("[useEdgeDetection] error:", err);
|
|
160
|
+
if (canvas) {
|
|
161
|
+
setCorners(fullImageQuad(canvas.width, canvas.height));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}, [cv, canvas, enabled]);
|
|
165
|
+
|
|
166
|
+
return { corners, debugImageUrl };
|
|
167
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface UseImageEditorResult {
|
|
4
|
+
currentImageUrl: string;
|
|
5
|
+
currentBlob: Blob | null;
|
|
6
|
+
applyImage: (blob: Blob) => string;
|
|
7
|
+
reset: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Manages the image history for the editor.
|
|
12
|
+
* Tracks the current image URL/blob and cleans up generated object URLs on unmount.
|
|
13
|
+
*/
|
|
14
|
+
export function useImageEditor(initialImageUrl: string): UseImageEditorResult {
|
|
15
|
+
let [currentImageUrl, setCurrentImageUrl] = useState(initialImageUrl);
|
|
16
|
+
let [currentBlob, setCurrentBlob] = useState<Blob | null>(null);
|
|
17
|
+
let generatedUrls = useRef<string[]>([]);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
return () => {
|
|
21
|
+
for (let url of generatedUrls.current) URL.revokeObjectURL(url);
|
|
22
|
+
};
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
function applyImage(blob: Blob): string {
|
|
26
|
+
let nextUrl = URL.createObjectURL(blob);
|
|
27
|
+
generatedUrls.current = [...generatedUrls.current, nextUrl];
|
|
28
|
+
setCurrentImageUrl(nextUrl);
|
|
29
|
+
setCurrentBlob(blob);
|
|
30
|
+
return nextUrl;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function reset() {
|
|
34
|
+
setCurrentImageUrl(initialImageUrl);
|
|
35
|
+
setCurrentBlob(null);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { currentImageUrl, currentBlob, applyImage, reset };
|
|
39
|
+
}
|
|
@@ -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,160 @@
|
|
|
1
|
+
import rawPdfWorkerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?raw";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Document, Page, pdfjs } from "react-pdf";
|
|
4
|
+
import "./PdfViewer.css";
|
|
5
|
+
|
|
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: any = pdfjs.GlobalWorkerOptions as any;
|
|
9
|
+
if (opts.workerPort && typeof opts.workerPort === "object" && typeof opts.workerPort.terminate === "function") {
|
|
10
|
+
try {
|
|
11
|
+
opts.workerPort.terminate();
|
|
12
|
+
} catch {}
|
|
13
|
+
}
|
|
14
|
+
// Remove the property entirely to avoid type checks on undefined
|
|
15
|
+
try {
|
|
16
|
+
delete opts.workerPort;
|
|
17
|
+
} catch {
|
|
18
|
+
opts.workerPort = null;
|
|
19
|
+
}
|
|
20
|
+
// Provide a dedicated Worker instance via blob; pdf.js prefers workerPort over workerSrc
|
|
21
|
+
try {
|
|
22
|
+
const blob = new Blob([rawPdfWorkerSrc], { type: "text/javascript" });
|
|
23
|
+
const worker = new Worker(URL.createObjectURL(blob), { type: "module" });
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
(pdfjs.GlobalWorkerOptions as any).workerPort = worker;
|
|
26
|
+
} catch {
|
|
27
|
+
// Ignore – pdf.js may fallback to fake worker in extreme cases
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type PdfViewerProps = { url?: string; file?: string | Blob | ArrayBuffer };
|
|
31
|
+
|
|
32
|
+
export function PdfViewer({ url, file }: PdfViewerProps) {
|
|
33
|
+
let [numPages, setNumPages] = useState(0);
|
|
34
|
+
let [error, setError] = useState<string | null>(null);
|
|
35
|
+
let containerRef = useRef<HTMLDivElement | null>(null);
|
|
36
|
+
let [containerWidth, setContainerWidth] = useState<number>(0);
|
|
37
|
+
let [containerHeight, setContainerHeight] = useState<number>(0);
|
|
38
|
+
let [scale, setScale] = useState<number>(1);
|
|
39
|
+
let [naturalPageSize, setNaturalPageSize] = useState<{
|
|
40
|
+
w: number;
|
|
41
|
+
h: number;
|
|
42
|
+
} | null>(null);
|
|
43
|
+
|
|
44
|
+
const pages = useMemo(() => Array.from({ length: numPages }, (_, i) => i + 1), [numPages]);
|
|
45
|
+
|
|
46
|
+
// Observe container size to support fit-to-page calculations
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
let el = containerRef.current;
|
|
49
|
+
if (!el) return;
|
|
50
|
+
let ro = new ResizeObserver((entries) => {
|
|
51
|
+
let cr = entries[0].contentRect;
|
|
52
|
+
setContainerWidth(Math.max(1, Math.floor(cr.width)));
|
|
53
|
+
setContainerHeight(Math.max(1, Math.floor(cr.height)));
|
|
54
|
+
});
|
|
55
|
+
ro.observe(el);
|
|
56
|
+
setContainerWidth(Math.max(1, Math.floor(el.clientWidth)));
|
|
57
|
+
setContainerHeight(Math.max(1, Math.floor(el.clientHeight)));
|
|
58
|
+
return () => ro.disconnect();
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// Intercept browser zoom gestures and zoom the PDF instead
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
let el = containerRef.current;
|
|
64
|
+
if (!el) return;
|
|
65
|
+
|
|
66
|
+
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
|
|
67
|
+
const zoomBy = (delta: number) => {
|
|
68
|
+
// Smooth exponential zoom for trackpads/mice
|
|
69
|
+
const factor = Math.exp(delta * 0.001);
|
|
70
|
+
setScale((s) => clamp(s * factor, 0.25, 5));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const onWheel = (e: WheelEvent) => {
|
|
74
|
+
if (e.ctrlKey) {
|
|
75
|
+
// ctrl+wheel is usually browser zoom; override for PDF zoom
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
zoomBy(-e.deltaY);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
82
|
+
if (!e.ctrlKey) return;
|
|
83
|
+
const key = e.key;
|
|
84
|
+
if (key === "+" || key === "=" || key === "Add") {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
setScale((s) => clamp(s * 1.1, 0.25, 5));
|
|
87
|
+
} else if (key === "-" || key === "_" || key === "Subtract") {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
setScale((s) => clamp(s / 1.1, 0.25, 5));
|
|
90
|
+
} else if (key === "0") {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
setScale(1);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
97
|
+
window.addEventListener("keydown", onKeyDown, { passive: false });
|
|
98
|
+
return () => {
|
|
99
|
+
el.removeEventListener("wheel", onWheel as EventListener);
|
|
100
|
+
window.removeEventListener("keydown", onKeyDown as EventListener);
|
|
101
|
+
};
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
// When source changes, reset auto-fit baseline
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
setNaturalPageSize(null);
|
|
107
|
+
setScale(1);
|
|
108
|
+
}, [url, file]);
|
|
109
|
+
|
|
110
|
+
// Compute base width to fit full page into container, then apply zoom scale.
|
|
111
|
+
// If we don't yet know page's natural size, default to fit-to-width.
|
|
112
|
+
const fitBaseWidth = useMemo(() => {
|
|
113
|
+
if (!naturalPageSize) return containerWidth || 1000;
|
|
114
|
+
const { w, h } = naturalPageSize;
|
|
115
|
+
if (w <= 0 || h <= 0) return containerWidth || 1000;
|
|
116
|
+
// width to fit by height: containerHeight * (w/h)
|
|
117
|
+
const widthByHeight = (containerHeight || 1000) * (w / h);
|
|
118
|
+
// Fit to page means respecting both constraints
|
|
119
|
+
return Math.min(Math.max(1, containerWidth || 1000), Math.max(1, widthByHeight));
|
|
120
|
+
}, [naturalPageSize, containerWidth, containerHeight]);
|
|
121
|
+
|
|
122
|
+
const pageWidth = Math.max(1, Math.round(fitBaseWidth * scale));
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div ref={containerRef} className="pdfViewerContainer">
|
|
126
|
+
<div className="pdfViewerContent">
|
|
127
|
+
<Document
|
|
128
|
+
file={file ?? url}
|
|
129
|
+
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
|
|
130
|
+
onLoadError={(e) => setError((e as Error)?.message ?? "Failed to load PDF")}
|
|
131
|
+
>
|
|
132
|
+
{pages.map((pageNumber) => (
|
|
133
|
+
<Page
|
|
134
|
+
key={pageNumber}
|
|
135
|
+
pageNumber={pageNumber}
|
|
136
|
+
renderTextLayer={false}
|
|
137
|
+
renderAnnotationLayer={false}
|
|
138
|
+
width={pageWidth}
|
|
139
|
+
onLoadSuccess={(p) => {
|
|
140
|
+
// Capture natural page size once for fit-to-page calculation
|
|
141
|
+
try {
|
|
142
|
+
// pdf.js viewport at scale 1
|
|
143
|
+
const vp = p.getViewport({ scale: 1 });
|
|
144
|
+
const w = Number(vp?.width) || Number((p as any).width) || 0;
|
|
145
|
+
const h = Number(vp?.height) || Number((p as any).height) || 0;
|
|
146
|
+
if (w > 0 && h > 0 && !naturalPageSize) {
|
|
147
|
+
setNaturalPageSize({ w, h });
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Ignore, fallback to fit-to-width when unknown
|
|
151
|
+
}
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
))}
|
|
155
|
+
</Document>
|
|
156
|
+
</div>
|
|
157
|
+
{error && <div style={{ color: "#b00020", padding: 8 }}>Error loading PDF: {error}</div>}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PdfViewer } from "./PdfViewer.js";
|