@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.
Files changed (101) hide show
  1. package/es/components/ImageEditor/CropCanvas.d.ts +16 -0
  2. package/es/components/ImageEditor/CropCanvas.js +79 -0
  3. package/es/components/ImageEditor/DragHandle.d.ts +14 -0
  4. package/es/components/ImageEditor/DragHandle.js +40 -0
  5. package/es/components/ImageEditor/EdgeHandle.d.ts +16 -0
  6. package/es/components/ImageEditor/EdgeHandle.js +43 -0
  7. package/es/components/ImageEditor/ImageEditor.css +224 -0
  8. package/es/components/ImageEditor/ImageEditor.d.ts +24 -0
  9. package/es/components/ImageEditor/ImageEditor.js +108 -0
  10. package/es/components/ImageEditor/ResponsiveButton.d.ts +11 -0
  11. package/es/components/ImageEditor/ResponsiveButton.js +9 -0
  12. package/es/components/ImageEditor/defaultPlugins.d.ts +4 -0
  13. package/es/components/ImageEditor/defaultPlugins.js +8 -0
  14. package/es/components/ImageEditor/index.d.ts +6 -0
  15. package/es/components/ImageEditor/index.js +4 -0
  16. package/es/components/ImageEditor/plugins/cropPlugin/CropContext.d.ts +10 -0
  17. package/es/components/ImageEditor/plugins/cropPlugin/CropContext.js +14 -0
  18. package/es/components/ImageEditor/plugins/cropPlugin/CropOverlay.d.ts +2 -0
  19. package/es/components/ImageEditor/plugins/cropPlugin/CropOverlay.js +30 -0
  20. package/es/components/ImageEditor/plugins/cropPlugin/CropToolbar.d.ts +2 -0
  21. package/es/components/ImageEditor/plugins/cropPlugin/CropToolbar.js +16 -0
  22. package/es/components/ImageEditor/plugins/cropPlugin/index.d.ts +6 -0
  23. package/es/components/ImageEditor/plugins/cropPlugin/index.js +16 -0
  24. package/es/components/ImageEditor/plugins/cropPlugin/useCropTool.d.ts +34 -0
  25. package/es/components/ImageEditor/plugins/cropPlugin/useCropTool.js +138 -0
  26. package/es/components/ImageEditor/plugins/redactPlugin/RedactContext.d.ts +10 -0
  27. package/es/components/ImageEditor/plugins/redactPlugin/RedactContext.js +14 -0
  28. package/es/components/ImageEditor/plugins/redactPlugin/RedactOverlay.d.ts +6 -0
  29. package/es/components/ImageEditor/plugins/redactPlugin/RedactOverlay.js +29 -0
  30. package/es/components/ImageEditor/plugins/redactPlugin/RedactToolbar.d.ts +2 -0
  31. package/es/components/ImageEditor/plugins/redactPlugin/RedactToolbar.js +29 -0
  32. package/es/components/ImageEditor/plugins/redactPlugin/index.d.ts +6 -0
  33. package/es/components/ImageEditor/plugins/redactPlugin/index.js +16 -0
  34. package/es/components/ImageEditor/plugins/redactPlugin/useRedactTool.d.ts +49 -0
  35. package/es/components/ImageEditor/plugins/redactPlugin/useRedactTool.js +142 -0
  36. package/es/components/ImageEditor/plugins/rotatePlugin.d.ts +3 -0
  37. package/es/components/ImageEditor/plugins/rotatePlugin.js +39 -0
  38. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.d.ts +20 -0
  39. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.js +91 -0
  40. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomContext.d.ts +6 -0
  41. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomContext.js +14 -0
  42. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomControls.d.ts +6 -0
  43. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomControls.js +11 -0
  44. package/es/components/ImageEditor/plugins/zoomPlugin/index.d.ts +2 -0
  45. package/es/components/ImageEditor/plugins/zoomPlugin/index.js +10 -0
  46. package/es/components/ImageEditor/plugins/zoomPlugin/useZoomPan.d.ts +33 -0
  47. package/es/components/ImageEditor/plugins/zoomPlugin/useZoomPan.js +80 -0
  48. package/es/components/ImageEditor/types.d.ts +69 -0
  49. package/es/components/ImageEditor/types.js +1 -0
  50. package/es/components/ImageEditor/useActiveTool.d.ts +9 -0
  51. package/es/components/ImageEditor/useActiveTool.js +11 -0
  52. package/es/components/ImageEditor/useEdgeDetection.d.ts +18 -0
  53. package/es/components/ImageEditor/useEdgeDetection.js +125 -0
  54. package/es/components/ImageEditor/useImageEditor.d.ts +12 -0
  55. package/es/components/ImageEditor/useImageEditor.js +28 -0
  56. package/es/components/ImageEditor/useImageHistory.d.ts +21 -0
  57. package/es/components/ImageEditor/useImageHistory.js +153 -0
  58. package/es/components/ImageEditor/useOpenCV.d.ts +14 -0
  59. package/es/components/ImageEditor/useOpenCV.js +92 -0
  60. package/es/components/PdfViewer/PdfViewer.css +12 -0
  61. package/es/components/PdfViewer/PdfViewer.d.ts +7 -0
  62. package/es/components/PdfViewer/PdfViewer.js +132 -0
  63. package/es/components/PdfViewer/index.d.ts +1 -0
  64. package/es/components/PdfViewer/index.js +1 -0
  65. package/es/index.d.ts +2 -0
  66. package/es/index.js +2 -0
  67. package/package.json +8 -3
  68. package/src/components/ImageEditor/CropCanvas.tsx +100 -0
  69. package/src/components/ImageEditor/DragHandle.tsx +65 -0
  70. package/src/components/ImageEditor/EdgeHandle.tsx +65 -0
  71. package/src/components/ImageEditor/ImageEditor.css +224 -0
  72. package/src/components/ImageEditor/ImageEditor.tsx +310 -0
  73. package/src/components/ImageEditor/ResponsiveButton.tsx +32 -0
  74. package/src/components/ImageEditor/defaultPlugins.ts +10 -0
  75. package/src/components/ImageEditor/index.ts +6 -0
  76. package/src/components/ImageEditor/plugins/cropPlugin/CropContext.tsx +24 -0
  77. package/src/components/ImageEditor/plugins/cropPlugin/CropOverlay.tsx +119 -0
  78. package/src/components/ImageEditor/plugins/cropPlugin/CropToolbar.tsx +36 -0
  79. package/src/components/ImageEditor/plugins/cropPlugin/index.ts +20 -0
  80. package/src/components/ImageEditor/plugins/cropPlugin/useCropTool.ts +176 -0
  81. package/src/components/ImageEditor/plugins/redactPlugin/RedactContext.tsx +23 -0
  82. package/src/components/ImageEditor/plugins/redactPlugin/RedactOverlay.tsx +144 -0
  83. package/src/components/ImageEditor/plugins/redactPlugin/RedactToolbar.tsx +56 -0
  84. package/src/components/ImageEditor/plugins/redactPlugin/index.ts +20 -0
  85. package/src/components/ImageEditor/plugins/redactPlugin/useRedactTool.ts +197 -0
  86. package/src/components/ImageEditor/plugins/rotatePlugin.ts +43 -0
  87. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.tsx +123 -0
  88. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomContext.tsx +15 -0
  89. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomControls.tsx +43 -0
  90. package/src/components/ImageEditor/plugins/zoomPlugin/index.ts +12 -0
  91. package/src/components/ImageEditor/plugins/zoomPlugin/useZoomPan.ts +126 -0
  92. package/src/components/ImageEditor/types.ts +69 -0
  93. package/src/components/ImageEditor/useActiveTool.ts +21 -0
  94. package/src/components/ImageEditor/useEdgeDetection.ts +167 -0
  95. package/src/components/ImageEditor/useImageEditor.ts +39 -0
  96. package/src/components/ImageEditor/useImageHistory.ts +189 -0
  97. package/src/components/ImageEditor/useOpenCV.ts +134 -0
  98. package/src/components/PdfViewer/PdfViewer.css +12 -0
  99. package/src/components/PdfViewer/PdfViewer.tsx +160 -0
  100. package/src/components/PdfViewer/index.ts +1 -0
  101. package/src/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
+ };