@olenbetong/synergi-react 2.3.8 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/components/FilePond.d.ts +1 -1
- package/es/components/FilePond.js +1 -1
- 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/MergeProgressDialog/index.d.ts +8 -0
- package/es/components/MergeProgressDialog/index.js +136 -0
- package/es/components/PdfViewer/PdfViewer.css +12 -0
- package/es/components/PdfViewer/PdfViewer.d.ts +7 -0
- package/es/components/PdfViewer/PdfViewer.js +108 -0
- package/es/components/PdfViewer/index.d.ts +1 -0
- package/es/components/PdfViewer/index.js +1 -0
- package/es/index.d.ts +3 -0
- package/es/index.js +3 -0
- package/package.json +8 -3
- package/src/components/FilePond.tsx +1 -1
- 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/MergeProgressDialog/index.tsx +249 -0
- package/src/components/PdfViewer/PdfViewer.css +12 -0
- package/src/components/PdfViewer/PdfViewer.tsx +139 -0
- package/src/components/PdfViewer/index.ts +1 -0
- package/src/index.ts +3 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { EditAction } from "../../types.js";
|
|
4
|
+
|
|
5
|
+
export interface RedactRect {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
w: number;
|
|
9
|
+
h: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Drawing {
|
|
13
|
+
startX: number;
|
|
14
|
+
startY: number;
|
|
15
|
+
currentX: number;
|
|
16
|
+
currentY: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ApplyRedactResult {
|
|
20
|
+
blob: Blob;
|
|
21
|
+
actions: EditAction[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RedactToolState {
|
|
25
|
+
rects: RedactRect[];
|
|
26
|
+
drawing: Drawing | null;
|
|
27
|
+
displaySize: { width: number; height: number };
|
|
28
|
+
handleImageLoad: (e: React.SyntheticEvent<HTMLImageElement>) => void;
|
|
29
|
+
startDrawing: (x: number, y: number) => void;
|
|
30
|
+
updateDrawing: (x: number, y: number) => void;
|
|
31
|
+
finishDrawing: () => void;
|
|
32
|
+
deleteRect: (index: number) => void;
|
|
33
|
+
clearAll: () => void;
|
|
34
|
+
applyRedact: () => Promise<ApplyRedactResult | null>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Manages state and logic for the blur-redaction tool. */
|
|
38
|
+
export function useRedactTool(imageUrl: string): RedactToolState {
|
|
39
|
+
let [rects, setRects] = useState<RedactRect[]>([]);
|
|
40
|
+
let [drawing, setDrawing] = useState<Drawing | null>(null);
|
|
41
|
+
let [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
|
|
42
|
+
let imageUrlRef = useRef(imageUrl);
|
|
43
|
+
imageUrlRef.current = imageUrl;
|
|
44
|
+
|
|
45
|
+
let handleImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
46
|
+
let img = e.currentTarget;
|
|
47
|
+
setDisplaySize({ width: img.clientWidth, height: img.clientHeight });
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
function startDrawing(x: number, y: number) {
|
|
51
|
+
setDrawing({ startX: x, startY: y, currentX: x, currentY: y });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function updateDrawing(x: number, y: number) {
|
|
55
|
+
setDrawing((d) => (d ? { ...d, currentX: x, currentY: y } : null));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function finishDrawing() {
|
|
59
|
+
setDrawing((d) => {
|
|
60
|
+
if (!d) return null;
|
|
61
|
+
let x = Math.min(d.startX, d.currentX);
|
|
62
|
+
let y = Math.min(d.startY, d.currentY);
|
|
63
|
+
let w = Math.abs(d.currentX - d.startX);
|
|
64
|
+
let h = Math.abs(d.currentY - d.startY);
|
|
65
|
+
if (w > 4 && h > 4) {
|
|
66
|
+
setRects((prev) => [...prev, { x, y, w, h }]);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function deleteRect(index: number) {
|
|
73
|
+
setRects((prev) => prev.filter((_, i) => i !== index));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clearAll() {
|
|
77
|
+
setRects([]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function applyRedact(): Promise<ApplyRedactResult | null> {
|
|
81
|
+
if (rects.length === 0) return null;
|
|
82
|
+
let img = await loadImage(imageUrlRef.current);
|
|
83
|
+
let canvas = document.createElement("canvas");
|
|
84
|
+
canvas.width = img.naturalWidth;
|
|
85
|
+
canvas.height = img.naturalHeight;
|
|
86
|
+
let ctx = canvas.getContext("2d")!;
|
|
87
|
+
ctx.drawImage(img, 0, 0);
|
|
88
|
+
|
|
89
|
+
let scaleX = img.naturalWidth / displaySize.width;
|
|
90
|
+
let scaleY = img.naturalHeight / displaySize.height;
|
|
91
|
+
|
|
92
|
+
for (let r of rects) {
|
|
93
|
+
let nx = Math.round(r.x * scaleX);
|
|
94
|
+
let ny = Math.round(r.y * scaleY);
|
|
95
|
+
let nw = Math.round(r.w * scaleX);
|
|
96
|
+
let nh = Math.round(r.h * scaleY);
|
|
97
|
+
blurRegionOnCanvas(ctx, img, nx, ny, nw, nh);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let blob = await new Promise<Blob>((resolve, reject) => {
|
|
101
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92);
|
|
102
|
+
});
|
|
103
|
+
// Store natural-pixel coords so replay doesn't need display size.
|
|
104
|
+
let actions: EditAction[] = rects.map((r) => ({
|
|
105
|
+
type: "redact-blur",
|
|
106
|
+
params: {
|
|
107
|
+
x: Math.round(r.x * scaleX),
|
|
108
|
+
y: Math.round(r.y * scaleY),
|
|
109
|
+
w: Math.round(r.w * scaleX),
|
|
110
|
+
h: Math.round(r.h * scaleY),
|
|
111
|
+
},
|
|
112
|
+
}));
|
|
113
|
+
return { blob, actions };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
rects,
|
|
118
|
+
drawing,
|
|
119
|
+
displaySize,
|
|
120
|
+
handleImageLoad,
|
|
121
|
+
startDrawing,
|
|
122
|
+
updateDrawing,
|
|
123
|
+
finishDrawing,
|
|
124
|
+
deleteRect,
|
|
125
|
+
clearAll,
|
|
126
|
+
applyRedact,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function loadImage(url: string): Promise<HTMLImageElement> {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
let img = new Image();
|
|
133
|
+
img.onload = () => resolve(img);
|
|
134
|
+
img.onerror = () => reject(new Error("Failed to load image"));
|
|
135
|
+
img.src = url;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Standalone replay handler for the "redact-blur" action type. */
|
|
140
|
+
export function replayRedactBlur(imageUrl: string, params: Record<string, unknown>): Promise<Blob> {
|
|
141
|
+
let { x, y, w, h } = params as { x: number; y: number; w: number; h: number };
|
|
142
|
+
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
let img = new Image();
|
|
145
|
+
img.onload = () => {
|
|
146
|
+
let canvas = document.createElement("canvas");
|
|
147
|
+
canvas.width = img.naturalWidth;
|
|
148
|
+
canvas.height = img.naturalHeight;
|
|
149
|
+
let ctx = canvas.getContext("2d")!;
|
|
150
|
+
ctx.drawImage(img, 0, 0);
|
|
151
|
+
blurRegionOnCanvas(ctx, img, x, y, w, h);
|
|
152
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92);
|
|
153
|
+
};
|
|
154
|
+
img.onerror = () => reject(new Error("Failed to load image for redact replay"));
|
|
155
|
+
img.src = imageUrl;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Redacts a region by downscaling it to a tiny thumbnail then scaling back up.
|
|
161
|
+
* This produces a strong pixelation effect that is cross-browser (ctx.filter
|
|
162
|
+
* is unreliable in Firefox).
|
|
163
|
+
*/
|
|
164
|
+
function blurRegionOnCanvas(
|
|
165
|
+
ctx: CanvasRenderingContext2D,
|
|
166
|
+
img: HTMLImageElement,
|
|
167
|
+
x: number,
|
|
168
|
+
y: number,
|
|
169
|
+
w: number,
|
|
170
|
+
h: number,
|
|
171
|
+
) {
|
|
172
|
+
if (w <= 0 || h <= 0) return;
|
|
173
|
+
// Scale down to ~5% — each output "pixel" covers a 20×20 block of the original.
|
|
174
|
+
let smallW = Math.max(1, Math.round(w / 20));
|
|
175
|
+
let smallH = Math.max(1, Math.round(h / 20));
|
|
176
|
+
let off = document.createElement("canvas");
|
|
177
|
+
off.width = smallW;
|
|
178
|
+
off.height = smallH;
|
|
179
|
+
let offCtx = off.getContext("2d")!;
|
|
180
|
+
offCtx.drawImage(img, x, y, w, h, 0, 0, smallW, smallH);
|
|
181
|
+
ctx.imageSmoothingEnabled = true;
|
|
182
|
+
ctx.imageSmoothingQuality = "low";
|
|
183
|
+
ctx.drawImage(off, 0, 0, smallW, smallH, x, y, w, h);
|
|
184
|
+
// Overlay a semi-opaque fill to prevent text from being readable through the pixelation.
|
|
185
|
+
ctx.fillStyle = "rgba(128, 128, 128, 0.85)";
|
|
186
|
+
ctx.fillRect(x, y, w, h);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Normalises a drawing into a positional rect (min x/y, positive w/h). */
|
|
190
|
+
export function normRect(d: { startX: number; startY: number; currentX: number; currentY: number }) {
|
|
191
|
+
return {
|
|
192
|
+
x: Math.min(d.startX, d.currentX),
|
|
193
|
+
y: Math.min(d.startY, d.currentY),
|
|
194
|
+
w: Math.abs(d.currentX - d.startX),
|
|
195
|
+
h: Math.abs(d.currentY - d.startY),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
|
2
|
+
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
|
3
|
+
|
|
4
|
+
import type { ActionPlugin } from "../types.js";
|
|
5
|
+
|
|
6
|
+
function rotateBlob(imageUrl: string, direction: "cw" | "ccw"): Promise<Blob> {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
let img = new Image();
|
|
9
|
+
img.onload = () => {
|
|
10
|
+
let canvas = document.createElement("canvas");
|
|
11
|
+
canvas.width = img.naturalHeight;
|
|
12
|
+
canvas.height = img.naturalWidth;
|
|
13
|
+
let ctx = canvas.getContext("2d")!;
|
|
14
|
+
if (direction === "cw") {
|
|
15
|
+
ctx.translate(canvas.width, 0);
|
|
16
|
+
ctx.rotate(Math.PI / 2);
|
|
17
|
+
} else {
|
|
18
|
+
ctx.translate(0, canvas.height);
|
|
19
|
+
ctx.rotate(-Math.PI / 2);
|
|
20
|
+
}
|
|
21
|
+
ctx.drawImage(img, 0, 0);
|
|
22
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92);
|
|
23
|
+
};
|
|
24
|
+
img.onerror = () => reject(new Error("Failed to load image"));
|
|
25
|
+
img.src = imageUrl;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const rotateCcwPlugin: ActionPlugin = {
|
|
30
|
+
type: "action",
|
|
31
|
+
id: "rotate-ccw",
|
|
32
|
+
label: "Rotate left",
|
|
33
|
+
Icon: RotateLeftIcon,
|
|
34
|
+
execute: (url) => rotateBlob(url, "ccw"),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const rotateCwPlugin: ActionPlugin = {
|
|
38
|
+
type: "action",
|
|
39
|
+
id: "rotate-cw",
|
|
40
|
+
label: "Rotate right",
|
|
41
|
+
Icon: RotateRightIcon,
|
|
42
|
+
execute: (url) => rotateBlob(url, "cw"),
|
|
43
|
+
};
|