@olenbetong/synergi-react 2.3.8 → 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/index.ts +2 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Alert, CircularProgress, Typography } from "@mui/material";
|
|
2
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
3
|
+
|
|
4
|
+
import { CropCanvas } from "../../CropCanvas.js";
|
|
5
|
+
import { DragHandle } from "../../DragHandle.js";
|
|
6
|
+
import { EdgeHandle } from "../../EdgeHandle.js";
|
|
7
|
+
import { useCropContext } from "./CropContext.js";
|
|
8
|
+
|
|
9
|
+
/** Canvas-area overlay for the crop tool. Reads all state from CropContext. */
|
|
10
|
+
export function CropOverlay() {
|
|
11
|
+
let {
|
|
12
|
+
imageUrl,
|
|
13
|
+
activeCorners,
|
|
14
|
+
scale,
|
|
15
|
+
displaySize,
|
|
16
|
+
canvasRef,
|
|
17
|
+
cvReady,
|
|
18
|
+
cvError,
|
|
19
|
+
cropping,
|
|
20
|
+
error,
|
|
21
|
+
handleCanvasReady,
|
|
22
|
+
updateCorner,
|
|
23
|
+
} = useCropContext();
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="crop-root">
|
|
27
|
+
<div className="crop-inner">
|
|
28
|
+
<CropCanvas ref={canvasRef} imageUrl={imageUrl} corners={activeCorners} onReady={handleCanvasReady} />
|
|
29
|
+
|
|
30
|
+
{displaySize.width > 0 &&
|
|
31
|
+
activeCorners.map((corner, i) => (
|
|
32
|
+
<DragHandle
|
|
33
|
+
key={i}
|
|
34
|
+
x={corner.x * scale}
|
|
35
|
+
y={corner.y * scale}
|
|
36
|
+
onChange={(x, y) => updateCorner(i, x, y)}
|
|
37
|
+
containerWidth={displaySize.width}
|
|
38
|
+
containerHeight={displaySize.height}
|
|
39
|
+
/>
|
|
40
|
+
))}
|
|
41
|
+
|
|
42
|
+
{displaySize.width > 0 &&
|
|
43
|
+
(() => {
|
|
44
|
+
let [tl, tr, br, bl] = activeCorners;
|
|
45
|
+
let s = scale;
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
{/* Top edge — moves TL (0) and TR (1) */}
|
|
49
|
+
<EdgeHandle
|
|
50
|
+
x={((tl.x + tr.x) / 2) * s}
|
|
51
|
+
y={((tl.y + tr.y) / 2) * s}
|
|
52
|
+
axis="y"
|
|
53
|
+
onChange={(dx, dy) => {
|
|
54
|
+
updateCorner(0, tl.x * s + dx, tl.y * s + dy);
|
|
55
|
+
updateCorner(1, tr.x * s + dx, tr.y * s + dy);
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
{/* Right edge — moves TR (1) and BR (2) */}
|
|
59
|
+
<EdgeHandle
|
|
60
|
+
x={((tr.x + br.x) / 2) * s}
|
|
61
|
+
y={((tr.y + br.y) / 2) * s}
|
|
62
|
+
axis="x"
|
|
63
|
+
onChange={(dx, dy) => {
|
|
64
|
+
updateCorner(1, tr.x * s + dx, tr.y * s + dy);
|
|
65
|
+
updateCorner(2, br.x * s + dx, br.y * s + dy);
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
{/* Bottom edge — moves BR (2) and BL (3) */}
|
|
69
|
+
<EdgeHandle
|
|
70
|
+
x={((br.x + bl.x) / 2) * s}
|
|
71
|
+
y={((br.y + bl.y) / 2) * s}
|
|
72
|
+
axis="y"
|
|
73
|
+
onChange={(dx, dy) => {
|
|
74
|
+
updateCorner(2, br.x * s + dx, br.y * s + dy);
|
|
75
|
+
updateCorner(3, bl.x * s + dx, bl.y * s + dy);
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
{/* Left edge — moves BL (3) and TL (0) */}
|
|
79
|
+
<EdgeHandle
|
|
80
|
+
x={((bl.x + tl.x) / 2) * s}
|
|
81
|
+
y={((bl.y + tl.y) / 2) * s}
|
|
82
|
+
axis="x"
|
|
83
|
+
onChange={(dx, dy) => {
|
|
84
|
+
updateCorner(3, bl.x * s + dx, bl.y * s + dy);
|
|
85
|
+
updateCorner(0, tl.x * s + dx, tl.y * s + dy);
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
})()}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{!cvReady && !cvError && (
|
|
94
|
+
<div className="crop-loading">
|
|
95
|
+
<CircularProgress size={18} color="inherit" />
|
|
96
|
+
<Typography variant="body2">{getLocalizedString("Loading OpenCV…")}</Typography>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
{cvError && (
|
|
100
|
+
<div className="crop-loading">
|
|
101
|
+
<Alert severity="error" sx={{ fontSize: "0.8rem" }}>
|
|
102
|
+
{getLocalizedString("Crop tool failed to load")}
|
|
103
|
+
</Alert>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
{(error || cropping) && (
|
|
107
|
+
<div className="crop-loading">
|
|
108
|
+
{cropping ? (
|
|
109
|
+
<CircularProgress size={18} color="inherit" />
|
|
110
|
+
) : (
|
|
111
|
+
<Alert severity="error" sx={{ fontSize: "0.8rem" }}>
|
|
112
|
+
{error}
|
|
113
|
+
</Alert>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import CheckIcon from "@mui/icons-material/Check";
|
|
2
|
+
import CloseIcon from "@mui/icons-material/Close";
|
|
3
|
+
import { Button, CircularProgress } from "@mui/material";
|
|
4
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
5
|
+
|
|
6
|
+
import { useCropContext } from "./CropContext.js";
|
|
7
|
+
|
|
8
|
+
/** Toolbar shown while the crop tool is active. Reads state from CropContext. */
|
|
9
|
+
export function CropToolbar() {
|
|
10
|
+
let { applyCrop, cropping, cvReady, cvError, onApply, onCancel } = useCropContext();
|
|
11
|
+
|
|
12
|
+
async function handleApply() {
|
|
13
|
+
let result = await applyCrop();
|
|
14
|
+
if (result) onApply(result.blob, result.actions);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<div className="image-editor__toolbar-group">
|
|
20
|
+
<Button startIcon={<CloseIcon />} onClick={onCancel} color="inherit" disabled={cropping}>
|
|
21
|
+
{getLocalizedString("Cancel")}
|
|
22
|
+
</Button>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="image-editor__toolbar-group">
|
|
25
|
+
<Button
|
|
26
|
+
variant="contained"
|
|
27
|
+
startIcon={cropping ? <CircularProgress size={18} color="inherit" /> : <CheckIcon />}
|
|
28
|
+
onClick={handleApply}
|
|
29
|
+
disabled={cropping || !cvReady || !!cvError}
|
|
30
|
+
>
|
|
31
|
+
{getLocalizedString("Apply crop")}
|
|
32
|
+
</Button>
|
|
33
|
+
</div>
|
|
34
|
+
</>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import CropIcon from "@mui/icons-material/Crop";
|
|
2
|
+
|
|
3
|
+
import type { ToolPlugin } from "../../types.js";
|
|
4
|
+
import { CropProvider } from "./CropContext.js";
|
|
5
|
+
import { CropOverlay } from "./CropOverlay.js";
|
|
6
|
+
import { CropToolbar } from "./CropToolbar.js";
|
|
7
|
+
import { replayCrop } from "./useCropTool.js";
|
|
8
|
+
|
|
9
|
+
export { CropOverlay, CropProvider, CropToolbar };
|
|
10
|
+
|
|
11
|
+
export const cropPlugin: ToolPlugin = {
|
|
12
|
+
type: "tool",
|
|
13
|
+
id: "crop",
|
|
14
|
+
label: "Crop",
|
|
15
|
+
Icon: CropIcon,
|
|
16
|
+
Provider: CropProvider,
|
|
17
|
+
Overlay: CropOverlay,
|
|
18
|
+
Toolbar: CropToolbar,
|
|
19
|
+
actions: [{ type: "crop", apply: replayCrop }],
|
|
20
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { CropCanvasHandle } from "../../CropCanvas.js";
|
|
4
|
+
import type { EditAction } from "../../types.js";
|
|
5
|
+
import { fullImageQuad, type Point, type Quad, useEdgeDetection } from "../../useEdgeDetection.js";
|
|
6
|
+
import { loadOpenCV, useOpenCV } from "../../useOpenCV.js";
|
|
7
|
+
|
|
8
|
+
export interface ApplyCropResult {
|
|
9
|
+
blob: Blob;
|
|
10
|
+
actions: EditAction[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CropToolState {
|
|
14
|
+
activeCorners: Quad;
|
|
15
|
+
scale: number;
|
|
16
|
+
displaySize: { width: number; height: number };
|
|
17
|
+
canvasRef: React.RefObject<CropCanvasHandle | null>;
|
|
18
|
+
cvReady: boolean;
|
|
19
|
+
cvError: Error | null;
|
|
20
|
+
cropping: boolean;
|
|
21
|
+
error: string | null;
|
|
22
|
+
debugImageUrl: string | null;
|
|
23
|
+
handleCanvasReady: (canvas: HTMLCanvasElement, w: number, h: number) => void;
|
|
24
|
+
updateCorner: (index: number, screenX: number, screenY: number) => void;
|
|
25
|
+
applyCrop: () => Promise<ApplyCropResult | null>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Manages all state and logic for the perspective crop tool.
|
|
30
|
+
* Loads OpenCV lazily, runs edge detection, and performs the warp.
|
|
31
|
+
*/
|
|
32
|
+
export function useCropTool(imageUrl: string, enabled: boolean): CropToolState {
|
|
33
|
+
let { cv, ready: cvReady, error: cvError } = useOpenCV(enabled);
|
|
34
|
+
let [corners, setCorners] = useState<Quad | null>(null);
|
|
35
|
+
let [canvasPixelSize, setCanvasPixelSize] = useState({ width: 1, height: 1 });
|
|
36
|
+
let [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
|
|
37
|
+
let [canvasEl, setCanvasEl] = useState<HTMLCanvasElement | null>(null);
|
|
38
|
+
let [cropping, setCropping] = useState(false);
|
|
39
|
+
let [error, setError] = useState<string | null>(null);
|
|
40
|
+
let canvasRef = useRef<CropCanvasHandle>(null);
|
|
41
|
+
let prevSuggested = useRef<Quad | null>(null);
|
|
42
|
+
|
|
43
|
+
let { corners: suggestedCorners, debugImageUrl } = useEdgeDetection(
|
|
44
|
+
cvReady ? cv : null,
|
|
45
|
+
canvasEl,
|
|
46
|
+
cvReady && enabled,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (suggestedCorners && suggestedCorners !== prevSuggested.current) {
|
|
51
|
+
prevSuggested.current = suggestedCorners;
|
|
52
|
+
setCorners(suggestedCorners);
|
|
53
|
+
}
|
|
54
|
+
}, [suggestedCorners]);
|
|
55
|
+
|
|
56
|
+
// Reset when the source image changes (e.g. after a previous crop or rotate)
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
prevSuggested.current = null;
|
|
59
|
+
setCorners(null);
|
|
60
|
+
setCanvasEl(null);
|
|
61
|
+
}, [imageUrl]);
|
|
62
|
+
|
|
63
|
+
let activeCorners: Quad = corners ?? fullImageQuad(canvasPixelSize.width, canvasPixelSize.height);
|
|
64
|
+
let scale = displaySize.width > 0 ? displaySize.width / canvasPixelSize.width : 1;
|
|
65
|
+
|
|
66
|
+
let handleCanvasReady = useCallback((canvas: HTMLCanvasElement, w: number, h: number) => {
|
|
67
|
+
setCanvasEl(canvas);
|
|
68
|
+
setCanvasPixelSize({ width: w, height: h });
|
|
69
|
+
setDisplaySize({ width: canvas.clientWidth, height: canvas.clientHeight });
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
function updateCorner(index: number, screenX: number, screenY: number) {
|
|
73
|
+
let pixelX = screenX / scale;
|
|
74
|
+
let pixelY = screenY / scale;
|
|
75
|
+
setCorners((prev) => {
|
|
76
|
+
let next = [...(prev ?? activeCorners)] as Quad;
|
|
77
|
+
next[index] = { x: pixelX, y: pixelY } as Point;
|
|
78
|
+
return next;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function applyCrop(): Promise<ApplyCropResult | null> {
|
|
83
|
+
if (!cv) return null;
|
|
84
|
+
setCropping(true);
|
|
85
|
+
setError(null);
|
|
86
|
+
try {
|
|
87
|
+
// Store corners as relative (0–1) coords so replay is resolution-independent.
|
|
88
|
+
// The display canvas may be downscaled (MAX_DISPLAY_WIDTH), so we must map
|
|
89
|
+
// corners back to relative coords and then re-apply at full resolution.
|
|
90
|
+
let relCorners = activeCorners.map((c) => ({
|
|
91
|
+
x: c.x / canvasPixelSize.width,
|
|
92
|
+
y: c.y / canvasPixelSize.height,
|
|
93
|
+
}));
|
|
94
|
+
// Warp the original image at its native resolution to avoid quality loss.
|
|
95
|
+
let blob = await cropImageAtFullResolution(cv, imageUrl, relCorners as Quad);
|
|
96
|
+
let actions: EditAction[] = [{ type: "crop", params: { corners: relCorners } }];
|
|
97
|
+
return { blob, actions };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
setError(err instanceof Error ? err.message : "Crop failed");
|
|
100
|
+
return null;
|
|
101
|
+
} finally {
|
|
102
|
+
setCropping(false);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
activeCorners,
|
|
108
|
+
scale,
|
|
109
|
+
displaySize,
|
|
110
|
+
canvasRef,
|
|
111
|
+
cvReady,
|
|
112
|
+
cvError,
|
|
113
|
+
cropping,
|
|
114
|
+
error,
|
|
115
|
+
debugImageUrl,
|
|
116
|
+
handleCanvasReady,
|
|
117
|
+
updateCorner,
|
|
118
|
+
applyCrop,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Loads the image at native resolution, maps relative corners, and warps. */
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: OpenCV.js types
|
|
124
|
+
function cropImageAtFullResolution(cv: any, imageUrl: string, relCorners: Quad): Promise<Blob> {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
let img = new Image();
|
|
127
|
+
img.onload = () => {
|
|
128
|
+
let canvas = document.createElement("canvas");
|
|
129
|
+
canvas.width = img.naturalWidth;
|
|
130
|
+
canvas.height = img.naturalHeight;
|
|
131
|
+
canvas.getContext("2d")!.drawImage(img, 0, 0);
|
|
132
|
+
let absCorners = relCorners.map((c) => ({
|
|
133
|
+
x: c.x * img.naturalWidth,
|
|
134
|
+
y: c.y * img.naturalHeight,
|
|
135
|
+
})) as Quad;
|
|
136
|
+
perspectiveCrop(cv, canvas, absCorners).then(resolve, reject);
|
|
137
|
+
};
|
|
138
|
+
img.onerror = () => reject(new Error("Failed to load image for crop"));
|
|
139
|
+
img.src = imageUrl;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Performs a perspective warp using OpenCV.js. */
|
|
144
|
+
// biome-ignore lint/suspicious/noExplicitAny: OpenCV.js types
|
|
145
|
+
function perspectiveCrop(cv: any, srcCanvas: HTMLCanvasElement, corners: Quad): Promise<Blob> {
|
|
146
|
+
let [tl, tr, br, bl] = corners;
|
|
147
|
+
let outW = Math.round(Math.max(Math.hypot(tr.x - tl.x, tr.y - tl.y), Math.hypot(br.x - bl.x, br.y - bl.y)));
|
|
148
|
+
let outH = Math.round(Math.max(Math.hypot(bl.x - tl.x, bl.y - tl.y), Math.hypot(br.x - tr.x, br.y - tr.y)));
|
|
149
|
+
|
|
150
|
+
let src = cv.imread(srcCanvas);
|
|
151
|
+
let dst = new cv.Mat();
|
|
152
|
+
let srcPts = cv.matFromArray(4, 1, cv.CV_32FC2, [tl.x, tl.y, tr.x, tr.y, br.x, br.y, bl.x, bl.y]);
|
|
153
|
+
let dstPts = cv.matFromArray(4, 1, cv.CV_32FC2, [0, 0, outW, 0, outW, outH, 0, outH]);
|
|
154
|
+
let M = cv.getPerspectiveTransform(srcPts, dstPts);
|
|
155
|
+
cv.warpPerspective(src, dst, M, new cv.Size(outW, outH));
|
|
156
|
+
|
|
157
|
+
let outCanvas = document.createElement("canvas");
|
|
158
|
+
cv.imshow(outCanvas, dst);
|
|
159
|
+
for (let m of [src, dst, srcPts, dstPts, M]) m.delete();
|
|
160
|
+
|
|
161
|
+
return new Promise<Blob>((resolve, reject) => {
|
|
162
|
+
// PNG is lossless — critical for receipts and screenshots where JPEG
|
|
163
|
+
// compression would make text unreadable.
|
|
164
|
+
outCanvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/png");
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Standalone replay handler for the "crop" action type.
|
|
170
|
+
* Corners are stored as relative (0–1) values so they work at any resolution.
|
|
171
|
+
*/
|
|
172
|
+
export async function replayCrop(imageUrl: string, params: Record<string, unknown>): Promise<Blob> {
|
|
173
|
+
await loadOpenCV();
|
|
174
|
+
let relCorners = params.corners as Quad;
|
|
175
|
+
return cropImageAtFullResolution(window.cv, imageUrl, relCorners);
|
|
176
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
import type { EditAction, ToolProviderProps } from "../../types.js";
|
|
4
|
+
import { type RedactToolState, useRedactTool } from "./useRedactTool.js";
|
|
5
|
+
|
|
6
|
+
interface RedactContextValue extends RedactToolState {
|
|
7
|
+
imageUrl: string;
|
|
8
|
+
onApply: (blob: Blob, actions: EditAction[]) => void;
|
|
9
|
+
onCancel: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let RedactContext = createContext<RedactContextValue | null>(null);
|
|
13
|
+
|
|
14
|
+
export function RedactProvider({ imageUrl, onApply, onCancel, children }: ToolProviderProps) {
|
|
15
|
+
let state = useRedactTool(imageUrl);
|
|
16
|
+
return <RedactContext.Provider value={{ ...state, imageUrl, onApply, onCancel }}>{children}</RedactContext.Provider>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useRedactContext(): RedactContextValue {
|
|
20
|
+
let ctx = useContext(RedactContext);
|
|
21
|
+
if (!ctx) throw new Error("useRedactContext must be used within RedactProvider");
|
|
22
|
+
return ctx;
|
|
23
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useZoom } from "../zoomPlugin/ZoomContext.js";
|
|
2
|
+
import { useRedactContext } from "./RedactContext.js";
|
|
3
|
+
import { normRect } from "./useRedactTool.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Canvas-area overlay for the redact tool.
|
|
7
|
+
* Uses an SVG layer over the image for drawing; blur preview uses SVG feGaussianBlur
|
|
8
|
+
* so the live preview matches the final applied output.
|
|
9
|
+
*/
|
|
10
|
+
export function RedactOverlay() {
|
|
11
|
+
let {
|
|
12
|
+
imageUrl,
|
|
13
|
+
rects,
|
|
14
|
+
drawing,
|
|
15
|
+
displaySize,
|
|
16
|
+
handleImageLoad,
|
|
17
|
+
startDrawing,
|
|
18
|
+
updateDrawing,
|
|
19
|
+
finishDrawing,
|
|
20
|
+
deleteRect,
|
|
21
|
+
} = useRedactContext();
|
|
22
|
+
|
|
23
|
+
let { zoom } = useZoom();
|
|
24
|
+
let inProgress = drawing ? normRect(drawing) : null;
|
|
25
|
+
|
|
26
|
+
function onPointerDown(e: React.PointerEvent<SVGSVGElement>) {
|
|
27
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
28
|
+
let b = e.currentTarget.getBoundingClientRect();
|
|
29
|
+
startDrawing((e.clientX - b.left) / zoom, (e.clientY - b.top) / zoom);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function onPointerMove(e: React.PointerEvent<SVGSVGElement>) {
|
|
33
|
+
if (!(e.buttons & 1)) return;
|
|
34
|
+
let b = e.currentTarget.getBoundingClientRect();
|
|
35
|
+
updateDrawing((e.clientX - b.left) / zoom, (e.clientY - b.top) / zoom);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="redact-root">
|
|
40
|
+
<div className="redact-inner">
|
|
41
|
+
<img src={imageUrl} alt="" className="redact-image" onLoad={handleImageLoad} draggable={false} />
|
|
42
|
+
|
|
43
|
+
{displaySize.width > 0 && (
|
|
44
|
+
<svg
|
|
45
|
+
className="redact-svg"
|
|
46
|
+
width={displaySize.width}
|
|
47
|
+
height={displaySize.height}
|
|
48
|
+
onPointerDown={onPointerDown}
|
|
49
|
+
onPointerMove={onPointerMove}
|
|
50
|
+
onPointerUp={finishDrawing}
|
|
51
|
+
>
|
|
52
|
+
<defs>
|
|
53
|
+
<filter id="redact-blur" x="-50%" y="-50%" width="200%" height="200%">
|
|
54
|
+
<feGaussianBlur stdDeviation="20" />
|
|
55
|
+
</filter>
|
|
56
|
+
{rects.map((_, i) => (
|
|
57
|
+
<clipPath key={i} id={`redact-clip-${i}`}>
|
|
58
|
+
<rect x={rects[i].x} y={rects[i].y} width={rects[i].w} height={rects[i].h} />
|
|
59
|
+
</clipPath>
|
|
60
|
+
))}
|
|
61
|
+
{inProgress && (
|
|
62
|
+
<clipPath id="redact-clip-inprogress">
|
|
63
|
+
<rect x={inProgress.x} y={inProgress.y} width={inProgress.w} height={inProgress.h} />
|
|
64
|
+
</clipPath>
|
|
65
|
+
)}
|
|
66
|
+
</defs>
|
|
67
|
+
|
|
68
|
+
{/* Blur each confirmed rect using the real image, clipped to the rect */}
|
|
69
|
+
{rects.map((r, i) => (
|
|
70
|
+
<g key={i}>
|
|
71
|
+
<image
|
|
72
|
+
href={imageUrl}
|
|
73
|
+
width={displaySize.width}
|
|
74
|
+
height={displaySize.height}
|
|
75
|
+
filter="url(#redact-blur)"
|
|
76
|
+
clipPath={`url(#redact-clip-${i})`}
|
|
77
|
+
style={{ pointerEvents: "none" }}
|
|
78
|
+
/>
|
|
79
|
+
{/* Semi-opaque overlay to prevent text from being readable through the blur */}
|
|
80
|
+
<rect
|
|
81
|
+
x={r.x}
|
|
82
|
+
y={r.y}
|
|
83
|
+
width={r.w}
|
|
84
|
+
height={r.h}
|
|
85
|
+
fill="rgba(128,128,128,0.85)"
|
|
86
|
+
style={{ pointerEvents: "none" }}
|
|
87
|
+
/>
|
|
88
|
+
{/* Dashed border + invisible hit area for deletion */}
|
|
89
|
+
<rect
|
|
90
|
+
x={r.x}
|
|
91
|
+
y={r.y}
|
|
92
|
+
width={r.w}
|
|
93
|
+
height={r.h}
|
|
94
|
+
fill="transparent"
|
|
95
|
+
stroke="rgba(255,255,255,0.6)"
|
|
96
|
+
strokeWidth={1.5}
|
|
97
|
+
strokeDasharray="5 3"
|
|
98
|
+
style={{ cursor: "pointer" }}
|
|
99
|
+
onClick={(e) => {
|
|
100
|
+
e.stopPropagation();
|
|
101
|
+
deleteRect(i);
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
</g>
|
|
105
|
+
))}
|
|
106
|
+
|
|
107
|
+
{/* In-progress rect */}
|
|
108
|
+
{inProgress && (
|
|
109
|
+
<>
|
|
110
|
+
<image
|
|
111
|
+
href={imageUrl}
|
|
112
|
+
width={displaySize.width}
|
|
113
|
+
height={displaySize.height}
|
|
114
|
+
filter="url(#redact-blur)"
|
|
115
|
+
clipPath="url(#redact-clip-inprogress)"
|
|
116
|
+
style={{ pointerEvents: "none" }}
|
|
117
|
+
/>
|
|
118
|
+
<rect
|
|
119
|
+
x={inProgress.x}
|
|
120
|
+
y={inProgress.y}
|
|
121
|
+
width={inProgress.w}
|
|
122
|
+
height={inProgress.h}
|
|
123
|
+
fill="rgba(128,128,128,0.85)"
|
|
124
|
+
style={{ pointerEvents: "none" }}
|
|
125
|
+
/>
|
|
126
|
+
<rect
|
|
127
|
+
x={inProgress.x}
|
|
128
|
+
y={inProgress.y}
|
|
129
|
+
width={inProgress.w}
|
|
130
|
+
height={inProgress.h}
|
|
131
|
+
fill="transparent"
|
|
132
|
+
stroke="rgba(255,255,255,0.6)"
|
|
133
|
+
strokeWidth={1.5}
|
|
134
|
+
strokeDasharray="5 3"
|
|
135
|
+
style={{ pointerEvents: "none" }}
|
|
136
|
+
/>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
</svg>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import CheckIcon from "@mui/icons-material/Check";
|
|
2
|
+
import CloseIcon from "@mui/icons-material/Close";
|
|
3
|
+
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
|
4
|
+
import { Button, CircularProgress } from "@mui/material";
|
|
5
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
|
|
8
|
+
import { useRedactContext } from "./RedactContext.js";
|
|
9
|
+
|
|
10
|
+
/** Toolbar shown while the redact tool is active. Reads state from RedactContext. */
|
|
11
|
+
export function RedactToolbar() {
|
|
12
|
+
let { rects, clearAll, applyRedact, onApply, onCancel } = useRedactContext();
|
|
13
|
+
let [applying, setApplying] = useState(false);
|
|
14
|
+
|
|
15
|
+
async function handleApply() {
|
|
16
|
+
if (rects.length === 0) {
|
|
17
|
+
onCancel();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
setApplying(true);
|
|
21
|
+
try {
|
|
22
|
+
let result = await applyRedact();
|
|
23
|
+
if (result) onApply(result.blob, result.actions);
|
|
24
|
+
} finally {
|
|
25
|
+
setApplying(false);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<div className="image-editor__toolbar-group">
|
|
32
|
+
<Button startIcon={<CloseIcon />} onClick={onCancel} color="inherit" disabled={applying}>
|
|
33
|
+
{getLocalizedString("Cancel")}
|
|
34
|
+
</Button>
|
|
35
|
+
<Button
|
|
36
|
+
startIcon={<DeleteSweepIcon />}
|
|
37
|
+
onClick={clearAll}
|
|
38
|
+
color="inherit"
|
|
39
|
+
disabled={applying || rects.length === 0}
|
|
40
|
+
>
|
|
41
|
+
{getLocalizedString("Clear all")}
|
|
42
|
+
</Button>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="image-editor__toolbar-group">
|
|
45
|
+
<Button
|
|
46
|
+
variant="contained"
|
|
47
|
+
startIcon={applying ? <CircularProgress size={18} color="inherit" /> : <CheckIcon />}
|
|
48
|
+
onClick={handleApply}
|
|
49
|
+
disabled={applying}
|
|
50
|
+
>
|
|
51
|
+
{rects.length === 0 ? getLocalizedString("Done") : getLocalizedString("Apply redact")}
|
|
52
|
+
</Button>
|
|
53
|
+
</div>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import HideSourceIcon from "@mui/icons-material/HideSource";
|
|
2
|
+
|
|
3
|
+
import type { ToolPlugin } from "../../types.js";
|
|
4
|
+
import { RedactProvider } from "./RedactContext.js";
|
|
5
|
+
import { RedactOverlay } from "./RedactOverlay.js";
|
|
6
|
+
import { RedactToolbar } from "./RedactToolbar.js";
|
|
7
|
+
import { replayRedactBlur } from "./useRedactTool.js";
|
|
8
|
+
|
|
9
|
+
export { RedactOverlay, RedactProvider, RedactToolbar };
|
|
10
|
+
|
|
11
|
+
export const redactPlugin: ToolPlugin = {
|
|
12
|
+
type: "tool",
|
|
13
|
+
id: "redact",
|
|
14
|
+
label: "Redact",
|
|
15
|
+
Icon: HideSourceIcon,
|
|
16
|
+
Provider: RedactProvider,
|
|
17
|
+
Overlay: RedactOverlay,
|
|
18
|
+
Toolbar: RedactToolbar,
|
|
19
|
+
actions: [{ type: "redact-blur", apply: replayRedactBlur }],
|
|
20
|
+
};
|