@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.
Files changed (107) hide show
  1. package/es/components/FilePond.d.ts +1 -1
  2. package/es/components/FilePond.js +1 -1
  3. package/es/components/ImageEditor/CropCanvas.d.ts +16 -0
  4. package/es/components/ImageEditor/CropCanvas.js +79 -0
  5. package/es/components/ImageEditor/DragHandle.d.ts +14 -0
  6. package/es/components/ImageEditor/DragHandle.js +40 -0
  7. package/es/components/ImageEditor/EdgeHandle.d.ts +16 -0
  8. package/es/components/ImageEditor/EdgeHandle.js +43 -0
  9. package/es/components/ImageEditor/ImageEditor.css +224 -0
  10. package/es/components/ImageEditor/ImageEditor.d.ts +24 -0
  11. package/es/components/ImageEditor/ImageEditor.js +108 -0
  12. package/es/components/ImageEditor/ResponsiveButton.d.ts +11 -0
  13. package/es/components/ImageEditor/ResponsiveButton.js +9 -0
  14. package/es/components/ImageEditor/defaultPlugins.d.ts +4 -0
  15. package/es/components/ImageEditor/defaultPlugins.js +8 -0
  16. package/es/components/ImageEditor/index.d.ts +6 -0
  17. package/es/components/ImageEditor/index.js +4 -0
  18. package/es/components/ImageEditor/plugins/cropPlugin/CropContext.d.ts +10 -0
  19. package/es/components/ImageEditor/plugins/cropPlugin/CropContext.js +14 -0
  20. package/es/components/ImageEditor/plugins/cropPlugin/CropOverlay.d.ts +2 -0
  21. package/es/components/ImageEditor/plugins/cropPlugin/CropOverlay.js +30 -0
  22. package/es/components/ImageEditor/plugins/cropPlugin/CropToolbar.d.ts +2 -0
  23. package/es/components/ImageEditor/plugins/cropPlugin/CropToolbar.js +16 -0
  24. package/es/components/ImageEditor/plugins/cropPlugin/index.d.ts +6 -0
  25. package/es/components/ImageEditor/plugins/cropPlugin/index.js +16 -0
  26. package/es/components/ImageEditor/plugins/cropPlugin/useCropTool.d.ts +34 -0
  27. package/es/components/ImageEditor/plugins/cropPlugin/useCropTool.js +138 -0
  28. package/es/components/ImageEditor/plugins/redactPlugin/RedactContext.d.ts +10 -0
  29. package/es/components/ImageEditor/plugins/redactPlugin/RedactContext.js +14 -0
  30. package/es/components/ImageEditor/plugins/redactPlugin/RedactOverlay.d.ts +6 -0
  31. package/es/components/ImageEditor/plugins/redactPlugin/RedactOverlay.js +29 -0
  32. package/es/components/ImageEditor/plugins/redactPlugin/RedactToolbar.d.ts +2 -0
  33. package/es/components/ImageEditor/plugins/redactPlugin/RedactToolbar.js +29 -0
  34. package/es/components/ImageEditor/plugins/redactPlugin/index.d.ts +6 -0
  35. package/es/components/ImageEditor/plugins/redactPlugin/index.js +16 -0
  36. package/es/components/ImageEditor/plugins/redactPlugin/useRedactTool.d.ts +49 -0
  37. package/es/components/ImageEditor/plugins/redactPlugin/useRedactTool.js +142 -0
  38. package/es/components/ImageEditor/plugins/rotatePlugin.d.ts +3 -0
  39. package/es/components/ImageEditor/plugins/rotatePlugin.js +39 -0
  40. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.d.ts +20 -0
  41. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.js +91 -0
  42. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomContext.d.ts +6 -0
  43. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomContext.js +14 -0
  44. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomControls.d.ts +6 -0
  45. package/es/components/ImageEditor/plugins/zoomPlugin/ZoomControls.js +11 -0
  46. package/es/components/ImageEditor/plugins/zoomPlugin/index.d.ts +2 -0
  47. package/es/components/ImageEditor/plugins/zoomPlugin/index.js +10 -0
  48. package/es/components/ImageEditor/plugins/zoomPlugin/useZoomPan.d.ts +33 -0
  49. package/es/components/ImageEditor/plugins/zoomPlugin/useZoomPan.js +80 -0
  50. package/es/components/ImageEditor/types.d.ts +69 -0
  51. package/es/components/ImageEditor/types.js +1 -0
  52. package/es/components/ImageEditor/useActiveTool.d.ts +9 -0
  53. package/es/components/ImageEditor/useActiveTool.js +11 -0
  54. package/es/components/ImageEditor/useEdgeDetection.d.ts +18 -0
  55. package/es/components/ImageEditor/useEdgeDetection.js +125 -0
  56. package/es/components/ImageEditor/useImageEditor.d.ts +12 -0
  57. package/es/components/ImageEditor/useImageEditor.js +28 -0
  58. package/es/components/ImageEditor/useImageHistory.d.ts +21 -0
  59. package/es/components/ImageEditor/useImageHistory.js +153 -0
  60. package/es/components/ImageEditor/useOpenCV.d.ts +14 -0
  61. package/es/components/ImageEditor/useOpenCV.js +92 -0
  62. package/es/components/MergeProgressDialog/index.d.ts +8 -0
  63. package/es/components/MergeProgressDialog/index.js +136 -0
  64. package/es/components/PdfViewer/PdfViewer.css +12 -0
  65. package/es/components/PdfViewer/PdfViewer.d.ts +7 -0
  66. package/es/components/PdfViewer/PdfViewer.js +108 -0
  67. package/es/components/PdfViewer/index.d.ts +1 -0
  68. package/es/components/PdfViewer/index.js +1 -0
  69. package/es/index.d.ts +3 -0
  70. package/es/index.js +3 -0
  71. package/package.json +8 -3
  72. package/src/components/FilePond.tsx +1 -1
  73. package/src/components/ImageEditor/CropCanvas.tsx +100 -0
  74. package/src/components/ImageEditor/DragHandle.tsx +65 -0
  75. package/src/components/ImageEditor/EdgeHandle.tsx +65 -0
  76. package/src/components/ImageEditor/ImageEditor.css +224 -0
  77. package/src/components/ImageEditor/ImageEditor.tsx +310 -0
  78. package/src/components/ImageEditor/ResponsiveButton.tsx +32 -0
  79. package/src/components/ImageEditor/defaultPlugins.ts +10 -0
  80. package/src/components/ImageEditor/index.ts +6 -0
  81. package/src/components/ImageEditor/plugins/cropPlugin/CropContext.tsx +24 -0
  82. package/src/components/ImageEditor/plugins/cropPlugin/CropOverlay.tsx +119 -0
  83. package/src/components/ImageEditor/plugins/cropPlugin/CropToolbar.tsx +36 -0
  84. package/src/components/ImageEditor/plugins/cropPlugin/index.ts +20 -0
  85. package/src/components/ImageEditor/plugins/cropPlugin/useCropTool.ts +176 -0
  86. package/src/components/ImageEditor/plugins/redactPlugin/RedactContext.tsx +23 -0
  87. package/src/components/ImageEditor/plugins/redactPlugin/RedactOverlay.tsx +144 -0
  88. package/src/components/ImageEditor/plugins/redactPlugin/RedactToolbar.tsx +56 -0
  89. package/src/components/ImageEditor/plugins/redactPlugin/index.ts +20 -0
  90. package/src/components/ImageEditor/plugins/redactPlugin/useRedactTool.ts +197 -0
  91. package/src/components/ImageEditor/plugins/rotatePlugin.ts +43 -0
  92. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomCanvas.tsx +123 -0
  93. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomContext.tsx +15 -0
  94. package/src/components/ImageEditor/plugins/zoomPlugin/ZoomControls.tsx +43 -0
  95. package/src/components/ImageEditor/plugins/zoomPlugin/index.ts +12 -0
  96. package/src/components/ImageEditor/plugins/zoomPlugin/useZoomPan.ts +126 -0
  97. package/src/components/ImageEditor/types.ts +69 -0
  98. package/src/components/ImageEditor/useActiveTool.ts +21 -0
  99. package/src/components/ImageEditor/useEdgeDetection.ts +167 -0
  100. package/src/components/ImageEditor/useImageEditor.ts +39 -0
  101. package/src/components/ImageEditor/useImageHistory.ts +189 -0
  102. package/src/components/ImageEditor/useOpenCV.ts +134 -0
  103. package/src/components/MergeProgressDialog/index.tsx +249 -0
  104. package/src/components/PdfViewer/PdfViewer.css +12 -0
  105. package/src/components/PdfViewer/PdfViewer.tsx +139 -0
  106. package/src/components/PdfViewer/index.ts +1 -0
  107. package/src/index.ts +3 -0
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ import { useCropTool } from "./useCropTool.js";
4
+ let CropContext = createContext(null);
5
+ export function CropProvider({ imageUrl, onApply, onCancel, children }) {
6
+ let state = useCropTool(imageUrl, true);
7
+ return _jsx(CropContext.Provider, { value: { ...state, imageUrl, onApply, onCancel }, children: children });
8
+ }
9
+ export function useCropContext() {
10
+ let ctx = useContext(CropContext);
11
+ if (!ctx)
12
+ throw new Error("useCropContext must be used within CropProvider");
13
+ return ctx;
14
+ }
@@ -0,0 +1,2 @@
1
+ /** Canvas-area overlay for the crop tool. Reads all state from CropContext. */
2
+ export declare function CropOverlay(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, CircularProgress, Typography } from "@mui/material";
3
+ import { getLocalizedString } from "@olenbetong/appframe-core";
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
+ /** Canvas-area overlay for the crop tool. Reads all state from CropContext. */
9
+ export function CropOverlay() {
10
+ let { imageUrl, activeCorners, scale, displaySize, canvasRef, cvReady, cvError, cropping, error, handleCanvasReady, updateCorner, } = useCropContext();
11
+ return (_jsxs("div", { className: "crop-root", children: [_jsxs("div", { className: "crop-inner", children: [_jsx(CropCanvas, { ref: canvasRef, imageUrl: imageUrl, corners: activeCorners, onReady: handleCanvasReady }), displaySize.width > 0 &&
12
+ activeCorners.map((corner, i) => (_jsx(DragHandle, { x: corner.x * scale, y: corner.y * scale, onChange: (x, y) => updateCorner(i, x, y), containerWidth: displaySize.width, containerHeight: displaySize.height }, i))), displaySize.width > 0 &&
13
+ (() => {
14
+ let [tl, tr, br, bl] = activeCorners;
15
+ let s = scale;
16
+ return (_jsxs(_Fragment, { children: [_jsx(EdgeHandle, { x: ((tl.x + tr.x) / 2) * s, y: ((tl.y + tr.y) / 2) * s, axis: "y", onChange: (dx, dy) => {
17
+ updateCorner(0, tl.x * s + dx, tl.y * s + dy);
18
+ updateCorner(1, tr.x * s + dx, tr.y * s + dy);
19
+ } }), _jsx(EdgeHandle, { x: ((tr.x + br.x) / 2) * s, y: ((tr.y + br.y) / 2) * s, axis: "x", onChange: (dx, dy) => {
20
+ updateCorner(1, tr.x * s + dx, tr.y * s + dy);
21
+ updateCorner(2, br.x * s + dx, br.y * s + dy);
22
+ } }), _jsx(EdgeHandle, { x: ((br.x + bl.x) / 2) * s, y: ((br.y + bl.y) / 2) * s, axis: "y", onChange: (dx, dy) => {
23
+ updateCorner(2, br.x * s + dx, br.y * s + dy);
24
+ updateCorner(3, bl.x * s + dx, bl.y * s + dy);
25
+ } }), _jsx(EdgeHandle, { x: ((bl.x + tl.x) / 2) * s, y: ((bl.y + tl.y) / 2) * s, axis: "x", onChange: (dx, dy) => {
26
+ updateCorner(3, bl.x * s + dx, bl.y * s + dy);
27
+ updateCorner(0, tl.x * s + dx, tl.y * s + dy);
28
+ } })] }));
29
+ })()] }), !cvReady && !cvError && (_jsxs("div", { className: "crop-loading", children: [_jsx(CircularProgress, { size: 18, color: "inherit" }), _jsx(Typography, { variant: "body2", children: getLocalizedString("Loading OpenCV…") })] })), cvError && (_jsx("div", { className: "crop-loading", children: _jsx(Alert, { severity: "error", sx: { fontSize: "0.8rem" }, children: getLocalizedString("Crop tool failed to load") }) })), (error || cropping) && (_jsx("div", { className: "crop-loading", children: cropping ? (_jsx(CircularProgress, { size: 18, color: "inherit" })) : (_jsx(Alert, { severity: "error", sx: { fontSize: "0.8rem" }, children: error })) }))] }));
30
+ }
@@ -0,0 +1,2 @@
1
+ /** Toolbar shown while the crop tool is active. Reads state from CropContext. */
2
+ export declare function CropToolbar(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import CheckIcon from "@mui/icons-material/Check";
3
+ import CloseIcon from "@mui/icons-material/Close";
4
+ import { Button, CircularProgress } from "@mui/material";
5
+ import { getLocalizedString } from "@olenbetong/appframe-core";
6
+ import { useCropContext } from "./CropContext.js";
7
+ /** Toolbar shown while the crop tool is active. Reads state from CropContext. */
8
+ export function CropToolbar() {
9
+ let { applyCrop, cropping, cvReady, cvError, onApply, onCancel } = useCropContext();
10
+ async function handleApply() {
11
+ let result = await applyCrop();
12
+ if (result)
13
+ onApply(result.blob, result.actions);
14
+ }
15
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "image-editor__toolbar-group", children: _jsx(Button, { startIcon: _jsx(CloseIcon, {}), onClick: onCancel, color: "inherit", disabled: cropping, children: getLocalizedString("Cancel") }) }), _jsx("div", { className: "image-editor__toolbar-group", children: _jsx(Button, { variant: "contained", startIcon: cropping ? _jsx(CircularProgress, { size: 18, color: "inherit" }) : _jsx(CheckIcon, {}), onClick: handleApply, disabled: cropping || !cvReady || !!cvError, children: getLocalizedString("Apply crop") }) })] }));
16
+ }
@@ -0,0 +1,6 @@
1
+ import type { ToolPlugin } from "../../types.js";
2
+ import { CropProvider } from "./CropContext.js";
3
+ import { CropOverlay } from "./CropOverlay.js";
4
+ import { CropToolbar } from "./CropToolbar.js";
5
+ export { CropOverlay, CropProvider, CropToolbar };
6
+ export declare const cropPlugin: ToolPlugin;
@@ -0,0 +1,16 @@
1
+ import CropIcon from "@mui/icons-material/Crop";
2
+ import { CropProvider } from "./CropContext.js";
3
+ import { CropOverlay } from "./CropOverlay.js";
4
+ import { CropToolbar } from "./CropToolbar.js";
5
+ import { replayCrop } from "./useCropTool.js";
6
+ export { CropOverlay, CropProvider, CropToolbar };
7
+ export const cropPlugin = {
8
+ type: "tool",
9
+ id: "crop",
10
+ label: "Crop",
11
+ Icon: CropIcon,
12
+ Provider: CropProvider,
13
+ Overlay: CropOverlay,
14
+ Toolbar: CropToolbar,
15
+ actions: [{ type: "crop", apply: replayCrop }],
16
+ };
@@ -0,0 +1,34 @@
1
+ import type { CropCanvasHandle } from "../../CropCanvas.js";
2
+ import type { EditAction } from "../../types.js";
3
+ import { type Quad } from "../../useEdgeDetection.js";
4
+ export interface ApplyCropResult {
5
+ blob: Blob;
6
+ actions: EditAction[];
7
+ }
8
+ export interface CropToolState {
9
+ activeCorners: Quad;
10
+ scale: number;
11
+ displaySize: {
12
+ width: number;
13
+ height: number;
14
+ };
15
+ canvasRef: React.RefObject<CropCanvasHandle | null>;
16
+ cvReady: boolean;
17
+ cvError: Error | null;
18
+ cropping: boolean;
19
+ error: string | null;
20
+ debugImageUrl: string | null;
21
+ handleCanvasReady: (canvas: HTMLCanvasElement, w: number, h: number) => void;
22
+ updateCorner: (index: number, screenX: number, screenY: number) => void;
23
+ applyCrop: () => Promise<ApplyCropResult | null>;
24
+ }
25
+ /**
26
+ * Manages all state and logic for the perspective crop tool.
27
+ * Loads OpenCV lazily, runs edge detection, and performs the warp.
28
+ */
29
+ export declare function useCropTool(imageUrl: string, enabled: boolean): CropToolState;
30
+ /**
31
+ * Standalone replay handler for the "crop" action type.
32
+ * Corners are stored as relative (0–1) values so they work at any resolution.
33
+ */
34
+ export declare function replayCrop(imageUrl: string, params: Record<string, unknown>): Promise<Blob>;
@@ -0,0 +1,138 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { fullImageQuad, useEdgeDetection } from "../../useEdgeDetection.js";
3
+ import { loadOpenCV, useOpenCV } from "../../useOpenCV.js";
4
+ /**
5
+ * Manages all state and logic for the perspective crop tool.
6
+ * Loads OpenCV lazily, runs edge detection, and performs the warp.
7
+ */
8
+ export function useCropTool(imageUrl, enabled) {
9
+ let { cv, ready: cvReady, error: cvError } = useOpenCV(enabled);
10
+ let [corners, setCorners] = useState(null);
11
+ let [canvasPixelSize, setCanvasPixelSize] = useState({ width: 1, height: 1 });
12
+ let [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
13
+ let [canvasEl, setCanvasEl] = useState(null);
14
+ let [cropping, setCropping] = useState(false);
15
+ let [error, setError] = useState(null);
16
+ let canvasRef = useRef(null);
17
+ let prevSuggested = useRef(null);
18
+ let { corners: suggestedCorners, debugImageUrl } = useEdgeDetection(cvReady ? cv : null, canvasEl, cvReady && enabled);
19
+ useEffect(() => {
20
+ if (suggestedCorners && suggestedCorners !== prevSuggested.current) {
21
+ prevSuggested.current = suggestedCorners;
22
+ setCorners(suggestedCorners);
23
+ }
24
+ }, [suggestedCorners]);
25
+ // Reset when the source image changes (e.g. after a previous crop or rotate)
26
+ useEffect(() => {
27
+ prevSuggested.current = null;
28
+ setCorners(null);
29
+ setCanvasEl(null);
30
+ }, [imageUrl]);
31
+ let activeCorners = corners ?? fullImageQuad(canvasPixelSize.width, canvasPixelSize.height);
32
+ let scale = displaySize.width > 0 ? displaySize.width / canvasPixelSize.width : 1;
33
+ let handleCanvasReady = useCallback((canvas, w, h) => {
34
+ setCanvasEl(canvas);
35
+ setCanvasPixelSize({ width: w, height: h });
36
+ setDisplaySize({ width: canvas.clientWidth, height: canvas.clientHeight });
37
+ }, []);
38
+ function updateCorner(index, screenX, screenY) {
39
+ let pixelX = screenX / scale;
40
+ let pixelY = screenY / scale;
41
+ setCorners((prev) => {
42
+ let next = [...(prev ?? activeCorners)];
43
+ next[index] = { x: pixelX, y: pixelY };
44
+ return next;
45
+ });
46
+ }
47
+ async function applyCrop() {
48
+ if (!cv)
49
+ return null;
50
+ setCropping(true);
51
+ setError(null);
52
+ try {
53
+ // Store corners as relative (0–1) coords so replay is resolution-independent.
54
+ // The display canvas may be downscaled (MAX_DISPLAY_WIDTH), so we must map
55
+ // corners back to relative coords and then re-apply at full resolution.
56
+ let relCorners = activeCorners.map((c) => ({
57
+ x: c.x / canvasPixelSize.width,
58
+ y: c.y / canvasPixelSize.height,
59
+ }));
60
+ // Warp the original image at its native resolution to avoid quality loss.
61
+ let blob = await cropImageAtFullResolution(cv, imageUrl, relCorners);
62
+ let actions = [{ type: "crop", params: { corners: relCorners } }];
63
+ return { blob, actions };
64
+ }
65
+ catch (err) {
66
+ setError(err instanceof Error ? err.message : "Crop failed");
67
+ return null;
68
+ }
69
+ finally {
70
+ setCropping(false);
71
+ }
72
+ }
73
+ return {
74
+ activeCorners,
75
+ scale,
76
+ displaySize,
77
+ canvasRef,
78
+ cvReady,
79
+ cvError,
80
+ cropping,
81
+ error,
82
+ debugImageUrl,
83
+ handleCanvasReady,
84
+ updateCorner,
85
+ applyCrop,
86
+ };
87
+ }
88
+ /** Loads the image at native resolution, maps relative corners, and warps. */
89
+ // biome-ignore lint/suspicious/noExplicitAny: OpenCV.js types
90
+ function cropImageAtFullResolution(cv, imageUrl, relCorners) {
91
+ return new Promise((resolve, reject) => {
92
+ let img = new Image();
93
+ img.onload = () => {
94
+ let canvas = document.createElement("canvas");
95
+ canvas.width = img.naturalWidth;
96
+ canvas.height = img.naturalHeight;
97
+ canvas.getContext("2d").drawImage(img, 0, 0);
98
+ let absCorners = relCorners.map((c) => ({
99
+ x: c.x * img.naturalWidth,
100
+ y: c.y * img.naturalHeight,
101
+ }));
102
+ perspectiveCrop(cv, canvas, absCorners).then(resolve, reject);
103
+ };
104
+ img.onerror = () => reject(new Error("Failed to load image for crop"));
105
+ img.src = imageUrl;
106
+ });
107
+ }
108
+ /** Performs a perspective warp using OpenCV.js. */
109
+ // biome-ignore lint/suspicious/noExplicitAny: OpenCV.js types
110
+ function perspectiveCrop(cv, srcCanvas, corners) {
111
+ let [tl, tr, br, bl] = corners;
112
+ 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)));
113
+ 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)));
114
+ let src = cv.imread(srcCanvas);
115
+ let dst = new cv.Mat();
116
+ 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]);
117
+ let dstPts = cv.matFromArray(4, 1, cv.CV_32FC2, [0, 0, outW, 0, outW, outH, 0, outH]);
118
+ let M = cv.getPerspectiveTransform(srcPts, dstPts);
119
+ cv.warpPerspective(src, dst, M, new cv.Size(outW, outH));
120
+ let outCanvas = document.createElement("canvas");
121
+ cv.imshow(outCanvas, dst);
122
+ for (let m of [src, dst, srcPts, dstPts, M])
123
+ m.delete();
124
+ return new Promise((resolve, reject) => {
125
+ // PNG is lossless — critical for receipts and screenshots where JPEG
126
+ // compression would make text unreadable.
127
+ outCanvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/png");
128
+ });
129
+ }
130
+ /**
131
+ * Standalone replay handler for the "crop" action type.
132
+ * Corners are stored as relative (0–1) values so they work at any resolution.
133
+ */
134
+ export async function replayCrop(imageUrl, params) {
135
+ await loadOpenCV();
136
+ let relCorners = params.corners;
137
+ return cropImageAtFullResolution(window.cv, imageUrl, relCorners);
138
+ }
@@ -0,0 +1,10 @@
1
+ import type { EditAction, ToolProviderProps } from "../../types.js";
2
+ import { type RedactToolState } from "./useRedactTool.js";
3
+ interface RedactContextValue extends RedactToolState {
4
+ imageUrl: string;
5
+ onApply: (blob: Blob, actions: EditAction[]) => void;
6
+ onCancel: () => void;
7
+ }
8
+ export declare function RedactProvider({ imageUrl, onApply, onCancel, children }: ToolProviderProps): import("react/jsx-runtime").JSX.Element;
9
+ export declare function useRedactContext(): RedactContextValue;
10
+ export {};
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ import { useRedactTool } from "./useRedactTool.js";
4
+ let RedactContext = createContext(null);
5
+ export function RedactProvider({ imageUrl, onApply, onCancel, children }) {
6
+ let state = useRedactTool(imageUrl);
7
+ return _jsx(RedactContext.Provider, { value: { ...state, imageUrl, onApply, onCancel }, children: children });
8
+ }
9
+ export function useRedactContext() {
10
+ let ctx = useContext(RedactContext);
11
+ if (!ctx)
12
+ throw new Error("useRedactContext must be used within RedactProvider");
13
+ return ctx;
14
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Canvas-area overlay for the redact tool.
3
+ * Uses an SVG layer over the image for drawing; blur preview uses SVG feGaussianBlur
4
+ * so the live preview matches the final applied output.
5
+ */
6
+ export declare function RedactOverlay(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useZoom } from "../zoomPlugin/ZoomContext.js";
3
+ import { useRedactContext } from "./RedactContext.js";
4
+ import { normRect } from "./useRedactTool.js";
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 { imageUrl, rects, drawing, displaySize, handleImageLoad, startDrawing, updateDrawing, finishDrawing, deleteRect, } = useRedactContext();
12
+ let { zoom } = useZoom();
13
+ let inProgress = drawing ? normRect(drawing) : null;
14
+ function onPointerDown(e) {
15
+ e.currentTarget.setPointerCapture(e.pointerId);
16
+ let b = e.currentTarget.getBoundingClientRect();
17
+ startDrawing((e.clientX - b.left) / zoom, (e.clientY - b.top) / zoom);
18
+ }
19
+ function onPointerMove(e) {
20
+ if (!(e.buttons & 1))
21
+ return;
22
+ let b = e.currentTarget.getBoundingClientRect();
23
+ updateDrawing((e.clientX - b.left) / zoom, (e.clientY - b.top) / zoom);
24
+ }
25
+ return (_jsx("div", { className: "redact-root", children: _jsxs("div", { className: "redact-inner", children: [_jsx("img", { src: imageUrl, alt: "", className: "redact-image", onLoad: handleImageLoad, draggable: false }), displaySize.width > 0 && (_jsxs("svg", { className: "redact-svg", width: displaySize.width, height: displaySize.height, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: finishDrawing, children: [_jsxs("defs", { children: [_jsx("filter", { id: "redact-blur", x: "-50%", y: "-50%", width: "200%", height: "200%", children: _jsx("feGaussianBlur", { stdDeviation: "20" }) }), rects.map((_, i) => (_jsx("clipPath", { id: `redact-clip-${i}`, children: _jsx("rect", { x: rects[i].x, y: rects[i].y, width: rects[i].w, height: rects[i].h }) }, i))), inProgress && (_jsx("clipPath", { id: "redact-clip-inprogress", children: _jsx("rect", { x: inProgress.x, y: inProgress.y, width: inProgress.w, height: inProgress.h }) }))] }), rects.map((r, i) => (_jsxs("g", { children: [_jsx("image", { href: imageUrl, width: displaySize.width, height: displaySize.height, filter: "url(#redact-blur)", clipPath: `url(#redact-clip-${i})`, style: { pointerEvents: "none" } }), _jsx("rect", { x: r.x, y: r.y, width: r.w, height: r.h, fill: "rgba(128,128,128,0.85)", style: { pointerEvents: "none" } }), _jsx("rect", { x: r.x, y: r.y, width: r.w, height: r.h, fill: "transparent", stroke: "rgba(255,255,255,0.6)", strokeWidth: 1.5, strokeDasharray: "5 3", style: { cursor: "pointer" }, onClick: (e) => {
26
+ e.stopPropagation();
27
+ deleteRect(i);
28
+ } })] }, i))), inProgress && (_jsxs(_Fragment, { children: [_jsx("image", { href: imageUrl, width: displaySize.width, height: displaySize.height, filter: "url(#redact-blur)", clipPath: "url(#redact-clip-inprogress)", style: { pointerEvents: "none" } }), _jsx("rect", { x: inProgress.x, y: inProgress.y, width: inProgress.w, height: inProgress.h, fill: "rgba(128,128,128,0.85)", style: { pointerEvents: "none" } }), _jsx("rect", { x: inProgress.x, y: inProgress.y, width: inProgress.w, height: inProgress.h, fill: "transparent", stroke: "rgba(255,255,255,0.6)", strokeWidth: 1.5, strokeDasharray: "5 3", style: { pointerEvents: "none" } })] }))] }))] }) }));
29
+ }
@@ -0,0 +1,2 @@
1
+ /** Toolbar shown while the redact tool is active. Reads state from RedactContext. */
2
+ export declare function RedactToolbar(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import CheckIcon from "@mui/icons-material/Check";
3
+ import CloseIcon from "@mui/icons-material/Close";
4
+ import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
5
+ import { Button, CircularProgress } from "@mui/material";
6
+ import { getLocalizedString } from "@olenbetong/appframe-core";
7
+ import { useState } from "react";
8
+ import { useRedactContext } from "./RedactContext.js";
9
+ /** Toolbar shown while the redact tool is active. Reads state from RedactContext. */
10
+ export function RedactToolbar() {
11
+ let { rects, clearAll, applyRedact, onApply, onCancel } = useRedactContext();
12
+ let [applying, setApplying] = useState(false);
13
+ async function handleApply() {
14
+ if (rects.length === 0) {
15
+ onCancel();
16
+ return;
17
+ }
18
+ setApplying(true);
19
+ try {
20
+ let result = await applyRedact();
21
+ if (result)
22
+ onApply(result.blob, result.actions);
23
+ }
24
+ finally {
25
+ setApplying(false);
26
+ }
27
+ }
28
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "image-editor__toolbar-group", children: [_jsx(Button, { startIcon: _jsx(CloseIcon, {}), onClick: onCancel, color: "inherit", disabled: applying, children: getLocalizedString("Cancel") }), _jsx(Button, { startIcon: _jsx(DeleteSweepIcon, {}), onClick: clearAll, color: "inherit", disabled: applying || rects.length === 0, children: getLocalizedString("Clear all") })] }), _jsx("div", { className: "image-editor__toolbar-group", children: _jsx(Button, { variant: "contained", startIcon: applying ? _jsx(CircularProgress, { size: 18, color: "inherit" }) : _jsx(CheckIcon, {}), onClick: handleApply, disabled: applying, children: rects.length === 0 ? getLocalizedString("Done") : getLocalizedString("Apply redact") }) })] }));
29
+ }
@@ -0,0 +1,6 @@
1
+ import type { ToolPlugin } from "../../types.js";
2
+ import { RedactProvider } from "./RedactContext.js";
3
+ import { RedactOverlay } from "./RedactOverlay.js";
4
+ import { RedactToolbar } from "./RedactToolbar.js";
5
+ export { RedactOverlay, RedactProvider, RedactToolbar };
6
+ export declare const redactPlugin: ToolPlugin;
@@ -0,0 +1,16 @@
1
+ import HideSourceIcon from "@mui/icons-material/HideSource";
2
+ import { RedactProvider } from "./RedactContext.js";
3
+ import { RedactOverlay } from "./RedactOverlay.js";
4
+ import { RedactToolbar } from "./RedactToolbar.js";
5
+ import { replayRedactBlur } from "./useRedactTool.js";
6
+ export { RedactOverlay, RedactProvider, RedactToolbar };
7
+ export const redactPlugin = {
8
+ type: "tool",
9
+ id: "redact",
10
+ label: "Redact",
11
+ Icon: HideSourceIcon,
12
+ Provider: RedactProvider,
13
+ Overlay: RedactOverlay,
14
+ Toolbar: RedactToolbar,
15
+ actions: [{ type: "redact-blur", apply: replayRedactBlur }],
16
+ };
@@ -0,0 +1,49 @@
1
+ import type { EditAction } from "../../types.js";
2
+ export interface RedactRect {
3
+ x: number;
4
+ y: number;
5
+ w: number;
6
+ h: number;
7
+ }
8
+ interface Drawing {
9
+ startX: number;
10
+ startY: number;
11
+ currentX: number;
12
+ currentY: number;
13
+ }
14
+ export interface ApplyRedactResult {
15
+ blob: Blob;
16
+ actions: EditAction[];
17
+ }
18
+ export interface RedactToolState {
19
+ rects: RedactRect[];
20
+ drawing: Drawing | null;
21
+ displaySize: {
22
+ width: number;
23
+ height: number;
24
+ };
25
+ handleImageLoad: (e: React.SyntheticEvent<HTMLImageElement>) => void;
26
+ startDrawing: (x: number, y: number) => void;
27
+ updateDrawing: (x: number, y: number) => void;
28
+ finishDrawing: () => void;
29
+ deleteRect: (index: number) => void;
30
+ clearAll: () => void;
31
+ applyRedact: () => Promise<ApplyRedactResult | null>;
32
+ }
33
+ /** Manages state and logic for the blur-redaction tool. */
34
+ export declare function useRedactTool(imageUrl: string): RedactToolState;
35
+ /** Standalone replay handler for the "redact-blur" action type. */
36
+ export declare function replayRedactBlur(imageUrl: string, params: Record<string, unknown>): Promise<Blob>;
37
+ /** Normalises a drawing into a positional rect (min x/y, positive w/h). */
38
+ export declare function normRect(d: {
39
+ startX: number;
40
+ startY: number;
41
+ currentX: number;
42
+ currentY: number;
43
+ }): {
44
+ x: number;
45
+ y: number;
46
+ w: number;
47
+ h: number;
48
+ };
49
+ export {};
@@ -0,0 +1,142 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ /** Manages state and logic for the blur-redaction tool. */
3
+ export function useRedactTool(imageUrl) {
4
+ let [rects, setRects] = useState([]);
5
+ let [drawing, setDrawing] = useState(null);
6
+ let [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
7
+ let imageUrlRef = useRef(imageUrl);
8
+ imageUrlRef.current = imageUrl;
9
+ let handleImageLoad = useCallback((e) => {
10
+ let img = e.currentTarget;
11
+ setDisplaySize({ width: img.clientWidth, height: img.clientHeight });
12
+ }, []);
13
+ function startDrawing(x, y) {
14
+ setDrawing({ startX: x, startY: y, currentX: x, currentY: y });
15
+ }
16
+ function updateDrawing(x, y) {
17
+ setDrawing((d) => (d ? { ...d, currentX: x, currentY: y } : null));
18
+ }
19
+ function finishDrawing() {
20
+ setDrawing((d) => {
21
+ if (!d)
22
+ return null;
23
+ let x = Math.min(d.startX, d.currentX);
24
+ let y = Math.min(d.startY, d.currentY);
25
+ let w = Math.abs(d.currentX - d.startX);
26
+ let h = Math.abs(d.currentY - d.startY);
27
+ if (w > 4 && h > 4) {
28
+ setRects((prev) => [...prev, { x, y, w, h }]);
29
+ }
30
+ return null;
31
+ });
32
+ }
33
+ function deleteRect(index) {
34
+ setRects((prev) => prev.filter((_, i) => i !== index));
35
+ }
36
+ function clearAll() {
37
+ setRects([]);
38
+ }
39
+ async function applyRedact() {
40
+ if (rects.length === 0)
41
+ return null;
42
+ let img = await loadImage(imageUrlRef.current);
43
+ let canvas = document.createElement("canvas");
44
+ canvas.width = img.naturalWidth;
45
+ canvas.height = img.naturalHeight;
46
+ let ctx = canvas.getContext("2d");
47
+ ctx.drawImage(img, 0, 0);
48
+ let scaleX = img.naturalWidth / displaySize.width;
49
+ let scaleY = img.naturalHeight / displaySize.height;
50
+ for (let r of rects) {
51
+ let nx = Math.round(r.x * scaleX);
52
+ let ny = Math.round(r.y * scaleY);
53
+ let nw = Math.round(r.w * scaleX);
54
+ let nh = Math.round(r.h * scaleY);
55
+ blurRegionOnCanvas(ctx, img, nx, ny, nw, nh);
56
+ }
57
+ let blob = await new Promise((resolve, reject) => {
58
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92);
59
+ });
60
+ // Store natural-pixel coords so replay doesn't need display size.
61
+ let actions = rects.map((r) => ({
62
+ type: "redact-blur",
63
+ params: {
64
+ x: Math.round(r.x * scaleX),
65
+ y: Math.round(r.y * scaleY),
66
+ w: Math.round(r.w * scaleX),
67
+ h: Math.round(r.h * scaleY),
68
+ },
69
+ }));
70
+ return { blob, actions };
71
+ }
72
+ return {
73
+ rects,
74
+ drawing,
75
+ displaySize,
76
+ handleImageLoad,
77
+ startDrawing,
78
+ updateDrawing,
79
+ finishDrawing,
80
+ deleteRect,
81
+ clearAll,
82
+ applyRedact,
83
+ };
84
+ }
85
+ function loadImage(url) {
86
+ return new Promise((resolve, reject) => {
87
+ let img = new Image();
88
+ img.onload = () => resolve(img);
89
+ img.onerror = () => reject(new Error("Failed to load image"));
90
+ img.src = url;
91
+ });
92
+ }
93
+ /** Standalone replay handler for the "redact-blur" action type. */
94
+ export function replayRedactBlur(imageUrl, params) {
95
+ let { x, y, w, h } = params;
96
+ return new Promise((resolve, reject) => {
97
+ let img = new Image();
98
+ img.onload = () => {
99
+ let canvas = document.createElement("canvas");
100
+ canvas.width = img.naturalWidth;
101
+ canvas.height = img.naturalHeight;
102
+ let ctx = canvas.getContext("2d");
103
+ ctx.drawImage(img, 0, 0);
104
+ blurRegionOnCanvas(ctx, img, x, y, w, h);
105
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92);
106
+ };
107
+ img.onerror = () => reject(new Error("Failed to load image for redact replay"));
108
+ img.src = imageUrl;
109
+ });
110
+ }
111
+ /**
112
+ * Redacts a region by downscaling it to a tiny thumbnail then scaling back up.
113
+ * This produces a strong pixelation effect that is cross-browser (ctx.filter
114
+ * is unreliable in Firefox).
115
+ */
116
+ function blurRegionOnCanvas(ctx, img, x, y, w, h) {
117
+ if (w <= 0 || h <= 0)
118
+ return;
119
+ // Scale down to ~5% — each output "pixel" covers a 20×20 block of the original.
120
+ let smallW = Math.max(1, Math.round(w / 20));
121
+ let smallH = Math.max(1, Math.round(h / 20));
122
+ let off = document.createElement("canvas");
123
+ off.width = smallW;
124
+ off.height = smallH;
125
+ let offCtx = off.getContext("2d");
126
+ offCtx.drawImage(img, x, y, w, h, 0, 0, smallW, smallH);
127
+ ctx.imageSmoothingEnabled = true;
128
+ ctx.imageSmoothingQuality = "low";
129
+ ctx.drawImage(off, 0, 0, smallW, smallH, x, y, w, h);
130
+ // Overlay a semi-opaque fill to prevent text from being readable through the pixelation.
131
+ ctx.fillStyle = "rgba(128, 128, 128, 0.85)";
132
+ ctx.fillRect(x, y, w, h);
133
+ }
134
+ /** Normalises a drawing into a positional rect (min x/y, positive w/h). */
135
+ export function normRect(d) {
136
+ return {
137
+ x: Math.min(d.startX, d.currentX),
138
+ y: Math.min(d.startY, d.currentY),
139
+ w: Math.abs(d.currentX - d.startX),
140
+ h: Math.abs(d.currentY - d.startY),
141
+ };
142
+ }
@@ -0,0 +1,3 @@
1
+ import type { ActionPlugin } from "../types.js";
2
+ export declare const rotateCcwPlugin: ActionPlugin;
3
+ export declare const rotateCwPlugin: ActionPlugin;
@@ -0,0 +1,39 @@
1
+ import RotateLeftIcon from "@mui/icons-material/RotateLeft";
2
+ import RotateRightIcon from "@mui/icons-material/RotateRight";
3
+ function rotateBlob(imageUrl, direction) {
4
+ return new Promise((resolve, reject) => {
5
+ let img = new Image();
6
+ img.onload = () => {
7
+ let canvas = document.createElement("canvas");
8
+ canvas.width = img.naturalHeight;
9
+ canvas.height = img.naturalWidth;
10
+ let ctx = canvas.getContext("2d");
11
+ if (direction === "cw") {
12
+ ctx.translate(canvas.width, 0);
13
+ ctx.rotate(Math.PI / 2);
14
+ }
15
+ else {
16
+ ctx.translate(0, canvas.height);
17
+ ctx.rotate(-Math.PI / 2);
18
+ }
19
+ ctx.drawImage(img, 0, 0);
20
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92);
21
+ };
22
+ img.onerror = () => reject(new Error("Failed to load image"));
23
+ img.src = imageUrl;
24
+ });
25
+ }
26
+ export const rotateCcwPlugin = {
27
+ type: "action",
28
+ id: "rotate-ccw",
29
+ label: "Rotate left",
30
+ Icon: RotateLeftIcon,
31
+ execute: (url) => rotateBlob(url, "ccw"),
32
+ };
33
+ export const rotateCwPlugin = {
34
+ type: "action",
35
+ id: "rotate-cw",
36
+ label: "Rotate right",
37
+ Icon: RotateRightIcon,
38
+ execute: (url) => rotateBlob(url, "cw"),
39
+ };