@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,123 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useRef } from "react";
|
|
2
|
+
import { ZOOM_STEP } from "./useZoomPan.js";
|
|
3
|
+
import { useZoom } from "./ZoomContext.js";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
/**
|
|
8
|
+
* Whether single-pointer drag pans the canvas. Pass false in tool mode so
|
|
9
|
+
* 1-finger events are owned by the tool; 2-finger pinch/drag still works.
|
|
10
|
+
* Defaults to true.
|
|
11
|
+
*/
|
|
12
|
+
panEnabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fills the canvas area, applies zoom/pan transform to its children, and
|
|
17
|
+
* captures pointer events for gestures.
|
|
18
|
+
*
|
|
19
|
+
* • Ctrl/Cmd + scroll: zoom anchored at cursor (desktop)
|
|
20
|
+
* • 2-finger pinch + translate: simultaneous zoom + pan anchored at midpoint
|
|
21
|
+
* • 1-finger drag (when panEnabled): pan
|
|
22
|
+
*/
|
|
23
|
+
export function ZoomCanvas({ children, panEnabled = true }: Props) {
|
|
24
|
+
let { zoom, panX, panY, pan, panAndZoomAtPoint, zoomByDeltaAtPoint } = useZoom();
|
|
25
|
+
|
|
26
|
+
let containerRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
let pointers = useRef(new Map<number, { x: number; y: number }>());
|
|
28
|
+
let lastPinchDist = useRef<number | null>(null);
|
|
29
|
+
let lastPinchMid = useRef<{ x: number; y: number } | null>(null);
|
|
30
|
+
|
|
31
|
+
// Stable refs so the DOM wheel listener never needs re-registration.
|
|
32
|
+
let panRef = useRef(pan);
|
|
33
|
+
let panAndZoomAtPointRef = useRef(panAndZoomAtPoint);
|
|
34
|
+
let zoomByDeltaAtPointRef = useRef(zoomByDeltaAtPoint);
|
|
35
|
+
panRef.current = pan;
|
|
36
|
+
panAndZoomAtPointRef.current = panAndZoomAtPoint;
|
|
37
|
+
zoomByDeltaAtPointRef.current = zoomByDeltaAtPoint;
|
|
38
|
+
|
|
39
|
+
function focalFromClient(clientX: number, clientY: number) {
|
|
40
|
+
let rect = containerRef.current?.getBoundingClientRect();
|
|
41
|
+
if (!rect) return { fx: 0, fy: 0 };
|
|
42
|
+
return { fx: clientX - rect.left - rect.width / 2, fy: clientY - rect.top - rect.height / 2 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let el = containerRef.current;
|
|
47
|
+
if (!el) return;
|
|
48
|
+
|
|
49
|
+
function onWheel(e: WheelEvent) {
|
|
50
|
+
if (!e.ctrlKey && !e.metaKey) return;
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
let { fx, fy } = focalFromClient(e.clientX, e.clientY);
|
|
53
|
+
zoomByDeltaAtPointRef.current(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP, fx, fy);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
57
|
+
return () => el.removeEventListener("wheel", onWheel);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
function onPointerDown(e: React.PointerEvent) {
|
|
61
|
+
// Only capture the pointer for panning. In tool mode (panEnabled=false) the inner
|
|
62
|
+
// tool elements (DragHandle, redact SVG) call setPointerCapture themselves after
|
|
63
|
+
// this event bubbles up — don't override their capture.
|
|
64
|
+
if (panEnabled) {
|
|
65
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
66
|
+
}
|
|
67
|
+
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
68
|
+
lastPinchDist.current = null;
|
|
69
|
+
lastPinchMid.current = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onPointerMove(e: React.PointerEvent) {
|
|
73
|
+
// Ignore hover — only process pointers that went through onPointerDown.
|
|
74
|
+
if (!pointers.current.has(e.pointerId)) return;
|
|
75
|
+
|
|
76
|
+
let prev = pointers.current.get(e.pointerId);
|
|
77
|
+
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
78
|
+
|
|
79
|
+
if (pointers.current.size === 2) {
|
|
80
|
+
let [a, b] = [...pointers.current.values()];
|
|
81
|
+
let dist = Math.hypot(a.x - b.x, a.y - b.y);
|
|
82
|
+
let midX = (a.x + b.x) / 2;
|
|
83
|
+
let midY = (a.y + b.y) / 2;
|
|
84
|
+
|
|
85
|
+
if (lastPinchDist.current !== null && lastPinchDist.current > 0) {
|
|
86
|
+
let { fx, fy } = focalFromClient(midX, midY);
|
|
87
|
+
let scaleFactor = dist / lastPinchDist.current;
|
|
88
|
+
// Translate from midpoint movement + zoom from pinch — both in one state update.
|
|
89
|
+
let dx = lastPinchMid.current ? midX - lastPinchMid.current.x : 0;
|
|
90
|
+
let dy = lastPinchMid.current ? midY - lastPinchMid.current.y : 0;
|
|
91
|
+
panAndZoomAtPointRef.current(dx, dy, scaleFactor, fx, fy);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
lastPinchDist.current = dist;
|
|
95
|
+
lastPinchMid.current = { x: midX, y: midY };
|
|
96
|
+
} else if (pointers.current.size === 1 && prev && panEnabled) {
|
|
97
|
+
panRef.current(e.clientX - prev.x, e.clientY - prev.y);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function onPointerUp(e: React.PointerEvent) {
|
|
102
|
+
pointers.current.delete(e.pointerId);
|
|
103
|
+
if (pointers.current.size < 2) {
|
|
104
|
+
lastPinchDist.current = null;
|
|
105
|
+
lastPinchMid.current = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
ref={containerRef}
|
|
112
|
+
className="zoom-canvas-root"
|
|
113
|
+
onPointerDown={onPointerDown}
|
|
114
|
+
onPointerMove={onPointerMove}
|
|
115
|
+
onPointerUp={onPointerUp}
|
|
116
|
+
onPointerCancel={onPointerUp}
|
|
117
|
+
>
|
|
118
|
+
<div className="zoom-canvas-inner" style={{ transform: `translate(${panX}px, ${panY}px) scale(${zoom})` }}>
|
|
119
|
+
{children}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createContext, type ReactNode, use } from "react";
|
|
2
|
+
import { useZoomPan, type ZoomPanResult } from "./useZoomPan.js";
|
|
3
|
+
|
|
4
|
+
let ZoomContext = createContext<ZoomPanResult | null>(null);
|
|
5
|
+
|
|
6
|
+
export function useZoom(): ZoomPanResult {
|
|
7
|
+
let ctx = use(ZoomContext);
|
|
8
|
+
if (!ctx) throw new Error("useZoom must be used within ZoomProvider");
|
|
9
|
+
return ctx;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ZoomProvider({ children }: { children: ReactNode }) {
|
|
13
|
+
let state = useZoomPan();
|
|
14
|
+
return <ZoomContext value={state}>{children}</ZoomContext>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
|
2
|
+
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
|
3
|
+
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
|
4
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
5
|
+
import { ResponsiveButton } from "../../ResponsiveButton.js";
|
|
6
|
+
import { useZoom } from "./ZoomContext.js";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
isSmall?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ZoomControls({ disabled, isSmall = false }: Props) {
|
|
14
|
+
let { zoom, zoomIn, zoomOut, resetZoom, canZoomIn, canZoomOut, isZoomed } = useZoom();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<ResponsiveButton
|
|
19
|
+
icon={<ZoomOutIcon />}
|
|
20
|
+
label={getLocalizedString("Zoom out")}
|
|
21
|
+
onClick={zoomOut}
|
|
22
|
+
disabled={disabled || !canZoomOut}
|
|
23
|
+
isSmall={isSmall}
|
|
24
|
+
/>
|
|
25
|
+
<span className="image-editor__zoom-level">{Math.round(zoom * 100)}%</span>
|
|
26
|
+
<ResponsiveButton
|
|
27
|
+
icon={<ZoomInIcon />}
|
|
28
|
+
label={getLocalizedString("Zoom in")}
|
|
29
|
+
onClick={zoomIn}
|
|
30
|
+
disabled={disabled || !canZoomIn}
|
|
31
|
+
isSmall={isSmall}
|
|
32
|
+
/>
|
|
33
|
+
{isZoomed && (
|
|
34
|
+
<ResponsiveButton
|
|
35
|
+
icon={<ZoomInMapIcon />}
|
|
36
|
+
label={getLocalizedString("Reset zoom")}
|
|
37
|
+
onClick={resetZoom}
|
|
38
|
+
isSmall={isSmall}
|
|
39
|
+
/>
|
|
40
|
+
)}
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ViewPlugin } from "../../types.js";
|
|
2
|
+
import { ZoomCanvas } from "./ZoomCanvas.js";
|
|
3
|
+
import { ZoomProvider } from "./ZoomContext.js";
|
|
4
|
+
import { ZoomControls } from "./ZoomControls.js";
|
|
5
|
+
|
|
6
|
+
export const zoomPlugin: ViewPlugin = {
|
|
7
|
+
type: "view",
|
|
8
|
+
id: "zoom",
|
|
9
|
+
Provider: ZoomProvider,
|
|
10
|
+
CanvasWrapper: ZoomCanvas,
|
|
11
|
+
TopBarControls: ZoomControls,
|
|
12
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export const MIN_ZOOM = 0.5;
|
|
4
|
+
export const MAX_ZOOM = 5;
|
|
5
|
+
export const ZOOM_STEP = 0.25;
|
|
6
|
+
|
|
7
|
+
interface State {
|
|
8
|
+
zoom: number;
|
|
9
|
+
panX: number;
|
|
10
|
+
panY: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ZoomPanResult {
|
|
14
|
+
zoom: number;
|
|
15
|
+
panX: number;
|
|
16
|
+
panY: number;
|
|
17
|
+
canZoomIn: boolean;
|
|
18
|
+
canZoomOut: boolean;
|
|
19
|
+
isZoomed: boolean;
|
|
20
|
+
zoomIn: () => void;
|
|
21
|
+
zoomOut: () => void;
|
|
22
|
+
resetZoom: () => void;
|
|
23
|
+
/** Translate by (dx, dy) screen pixels. */
|
|
24
|
+
pan: (dx: number, dy: number) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Zoom by a multiplicative scale factor, anchored at a focal point.
|
|
27
|
+
* focalX/focalY are in px relative to the container centre.
|
|
28
|
+
*/
|
|
29
|
+
zoomByScaleAtPoint: (scaleFactor: number, focalX: number, focalY: number) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Zoom by an additive step delta, anchored at a focal point.
|
|
32
|
+
* focalX/focalY are in px relative to the container centre.
|
|
33
|
+
*/
|
|
34
|
+
zoomByDeltaAtPoint: (delta: number, focalX: number, focalY: number) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Simultaneously pan by (dx, dy) and zoom by a multiplicative scale factor,
|
|
37
|
+
* anchored at a focal point. Used for 2-finger gestures where the midpoint
|
|
38
|
+
* may translate while the fingers pinch.
|
|
39
|
+
*/
|
|
40
|
+
panAndZoomAtPoint: (dx: number, dy: number, scaleFactor: number, focalX: number, focalY: number) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useZoomPan(): ZoomPanResult {
|
|
44
|
+
let [{ zoom, panX, panY }, setState] = useState<State>({ zoom: 1, panX: 0, panY: 0 });
|
|
45
|
+
|
|
46
|
+
let zoomIn = useCallback(() => {
|
|
47
|
+
// Zoom from centre (focalX = 0, focalY = 0)
|
|
48
|
+
setState((s) => {
|
|
49
|
+
let newZoom = +Math.min(MAX_ZOOM, s.zoom + ZOOM_STEP).toFixed(2);
|
|
50
|
+
let scale = newZoom / s.zoom;
|
|
51
|
+
return { zoom: newZoom, panX: s.panX * scale, panY: s.panY * scale };
|
|
52
|
+
});
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
let zoomOut = useCallback(() => {
|
|
56
|
+
setState((s) => {
|
|
57
|
+
let newZoom = +Math.max(MIN_ZOOM, s.zoom - ZOOM_STEP).toFixed(2);
|
|
58
|
+
let scale = newZoom / s.zoom;
|
|
59
|
+
return { zoom: newZoom, panX: s.panX * scale, panY: s.panY * scale };
|
|
60
|
+
});
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
let resetZoom = useCallback(() => {
|
|
64
|
+
setState({ zoom: 1, panX: 0, panY: 0 });
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
let pan = useCallback((dx: number, dy: number) => {
|
|
68
|
+
setState((s) => ({ ...s, panX: s.panX + dx, panY: s.panY + dy }));
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
let zoomByScaleAtPoint = useCallback((scaleFactor: number, focalX: number, focalY: number) => {
|
|
72
|
+
setState((s) => {
|
|
73
|
+
let newZoom = +Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, s.zoom * scaleFactor)).toFixed(3);
|
|
74
|
+
let realScale = newZoom / s.zoom;
|
|
75
|
+
return {
|
|
76
|
+
zoom: newZoom,
|
|
77
|
+
// Keep the focal point fixed on screen: pan adjusts to compensate for the zoom shift.
|
|
78
|
+
panX: focalX * (1 - realScale) + s.panX * realScale,
|
|
79
|
+
panY: focalY * (1 - realScale) + s.panY * realScale,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
let zoomByDeltaAtPoint = useCallback((delta: number, focalX: number, focalY: number) => {
|
|
85
|
+
setState((s) => {
|
|
86
|
+
let newZoom = +Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, s.zoom + delta)).toFixed(2);
|
|
87
|
+
let scale = newZoom / s.zoom;
|
|
88
|
+
return {
|
|
89
|
+
zoom: newZoom,
|
|
90
|
+
panX: focalX * (1 - scale) + s.panX * scale,
|
|
91
|
+
panY: focalY * (1 - scale) + s.panY * scale,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
let panAndZoomAtPoint = useCallback((dx: number, dy: number, scaleFactor: number, focalX: number, focalY: number) => {
|
|
97
|
+
setState((s) => {
|
|
98
|
+
let newZoom = +Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, s.zoom * scaleFactor)).toFixed(3);
|
|
99
|
+
let realScale = newZoom / s.zoom;
|
|
100
|
+
// Apply pan first, then zoom around the focal point.
|
|
101
|
+
let panXAfterPan = s.panX + dx;
|
|
102
|
+
let panYAfterPan = s.panY + dy;
|
|
103
|
+
return {
|
|
104
|
+
zoom: newZoom,
|
|
105
|
+
panX: focalX * (1 - realScale) + panXAfterPan * realScale,
|
|
106
|
+
panY: focalY * (1 - realScale) + panYAfterPan * realScale,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
zoom,
|
|
113
|
+
panX,
|
|
114
|
+
panY,
|
|
115
|
+
canZoomIn: zoom < MAX_ZOOM,
|
|
116
|
+
canZoomOut: zoom > MIN_ZOOM,
|
|
117
|
+
isZoomed: zoom !== 1 || panX !== 0 || panY !== 0,
|
|
118
|
+
zoomIn,
|
|
119
|
+
zoomOut,
|
|
120
|
+
resetZoom,
|
|
121
|
+
pan,
|
|
122
|
+
zoomByScaleAtPoint,
|
|
123
|
+
zoomByDeltaAtPoint,
|
|
124
|
+
panAndZoomAtPoint,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** A single atomic edit operation, recorded for undo/redo replay. */
|
|
4
|
+
export interface EditAction {
|
|
5
|
+
type: string;
|
|
6
|
+
params: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registers a replay handler for a specific action type.
|
|
11
|
+
* Must be a pure async function — no React hooks, no side-effect state.
|
|
12
|
+
*/
|
|
13
|
+
export interface ActionHandler {
|
|
14
|
+
type: string;
|
|
15
|
+
apply: (imageUrl: string, params: Record<string, unknown>) => Promise<Blob>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Maps action type → replay function. Built by ImageEditor from the plugins array. */
|
|
19
|
+
export type ActionRegistry = Map<string, ActionHandler["apply"]>;
|
|
20
|
+
|
|
21
|
+
export interface ToolProviderProps {
|
|
22
|
+
imageUrl: string;
|
|
23
|
+
/** Called when the tool has produced a transformed image. */
|
|
24
|
+
onApply: (blob: Blob, actions: EditAction[]) => void;
|
|
25
|
+
onCancel: () => void;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A mode tool: activates a persistent editing state with its own canvas overlay and toolbar.
|
|
31
|
+
* The Provider shares state between Overlay and Toolbar via React context.
|
|
32
|
+
* `actions` registers replay handlers so the history can reconstruct any past state.
|
|
33
|
+
*/
|
|
34
|
+
export interface ToolPlugin {
|
|
35
|
+
type: "tool";
|
|
36
|
+
id: string;
|
|
37
|
+
label: string;
|
|
38
|
+
Icon: ComponentType;
|
|
39
|
+
Provider: ComponentType<ToolProviderProps>;
|
|
40
|
+
Overlay: ComponentType;
|
|
41
|
+
Toolbar: ComponentType;
|
|
42
|
+
/** Replay handlers for every action type this tool can produce. */
|
|
43
|
+
actions: ActionHandler[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An action tool: executes a transform immediately without entering a persistent mode. */
|
|
47
|
+
export interface ActionPlugin {
|
|
48
|
+
type: "action";
|
|
49
|
+
id: string;
|
|
50
|
+
label: string;
|
|
51
|
+
Icon: ComponentType;
|
|
52
|
+
execute: (imageUrl: string) => Promise<Blob>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type Plugin = ToolPlugin | ActionPlugin;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A view plugin: provides zoom/pan or other viewport transforms without modifying the image.
|
|
59
|
+
* Wraps the main image area with gesture handling and contributes controls to the top toolbar.
|
|
60
|
+
*/
|
|
61
|
+
export interface ViewPlugin {
|
|
62
|
+
type: "view";
|
|
63
|
+
id: string;
|
|
64
|
+
Provider: ComponentType<{ children: ReactNode }>;
|
|
65
|
+
/** Wraps the main image; applies transforms and captures pinch/pan gestures. */
|
|
66
|
+
CanvasWrapper: ComponentType<{ children: ReactNode; panEnabled?: boolean }>;
|
|
67
|
+
/** Controls rendered in the top toolbar (e.g. zoom in/out/reset). */
|
|
68
|
+
TopBarControls: ComponentType<{ disabled?: boolean; isSmall?: boolean }>;
|
|
69
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { Plugin, ToolPlugin } from "./types.js";
|
|
4
|
+
|
|
5
|
+
interface UseActiveToolResult {
|
|
6
|
+
activePlugin: ToolPlugin | null;
|
|
7
|
+
activateTool: (id: string) => void;
|
|
8
|
+
deactivateTool: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Manages which tool plugin is currently active. */
|
|
12
|
+
export function useActiveTool(plugins: Plugin[]): UseActiveToolResult {
|
|
13
|
+
let [activeId, setActiveId] = useState<string | null>(null);
|
|
14
|
+
let activePlugin = (plugins.find((p) => p.type === "tool" && p.id === activeId) ?? null) as ToolPlugin | null;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
activePlugin,
|
|
18
|
+
activateTool: setActiveId,
|
|
19
|
+
deactivateTool: () => setActiveId(null),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface Point {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** TL, TR, BR, BL */
|
|
9
|
+
export type Quad = [Point, Point, Point, Point];
|
|
10
|
+
|
|
11
|
+
export function fullImageQuad(width: number, height: number): Quad {
|
|
12
|
+
return [
|
|
13
|
+
{ x: 0, y: 0 },
|
|
14
|
+
{ x: width, y: 0 },
|
|
15
|
+
{ x: width, y: height },
|
|
16
|
+
{ x: 0, y: height },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FindQuadResult {
|
|
21
|
+
quad: Quad | null;
|
|
22
|
+
/** Canvas containing the Canny edge image (with detected quad overlay) for debugging */
|
|
23
|
+
debugCanvas: HTMLCanvasElement;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findLargestQuad(cv: any, src: any, minAreaFraction = 0.1): FindQuadResult {
|
|
27
|
+
let gray = new cv.Mat();
|
|
28
|
+
let blurred = new cv.Mat();
|
|
29
|
+
let edges = new cv.Mat();
|
|
30
|
+
let dilated = new cv.Mat();
|
|
31
|
+
let contours = new cv.MatVector();
|
|
32
|
+
let hierarchy = new cv.Mat();
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
|
|
36
|
+
cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0);
|
|
37
|
+
// Lower thresholds catch document-background boundaries even in low-contrast images
|
|
38
|
+
cv.Canny(blurred, edges, 40, 120);
|
|
39
|
+
|
|
40
|
+
// Dilate to close small gaps along document edges
|
|
41
|
+
let kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3));
|
|
42
|
+
cv.dilate(edges, dilated, kernel);
|
|
43
|
+
kernel.delete();
|
|
44
|
+
|
|
45
|
+
// Build RGBA debug canvas so we can draw a coloured quad overlay
|
|
46
|
+
let debugRgba = new cv.Mat();
|
|
47
|
+
cv.cvtColor(dilated, debugRgba, cv.COLOR_GRAY2RGBA);
|
|
48
|
+
|
|
49
|
+
cv.findContours(dilated, contours, hierarchy, cv.RETR_LIST, cv.CHAIN_APPROX_SIMPLE);
|
|
50
|
+
|
|
51
|
+
let best: unknown = null;
|
|
52
|
+
let bestArea = 0;
|
|
53
|
+
let minArea = src.cols * src.rows * minAreaFraction;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < contours.size(); i++) {
|
|
56
|
+
let contour = contours.get(i);
|
|
57
|
+
let peri = cv.arcLength(contour, true);
|
|
58
|
+
let approx = new cv.Mat();
|
|
59
|
+
// Larger epsilon is more aggressive — helps when the outline isn't perfectly straight
|
|
60
|
+
cv.approxPolyDP(contour, approx, 0.04 * peri, true);
|
|
61
|
+
|
|
62
|
+
if (approx.rows === 4) {
|
|
63
|
+
let area = cv.contourArea(approx);
|
|
64
|
+
if (area > bestArea && area > minArea) {
|
|
65
|
+
bestArea = area;
|
|
66
|
+
(best as any)?.delete();
|
|
67
|
+
best = approx;
|
|
68
|
+
} else {
|
|
69
|
+
approx.delete();
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
approx.delete();
|
|
73
|
+
}
|
|
74
|
+
contour.delete();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let quad: Quad | null = null;
|
|
78
|
+
if (best) {
|
|
79
|
+
let mat = best as any;
|
|
80
|
+
let pts: Point[] = [];
|
|
81
|
+
for (let i = 0; i < 4; i++) {
|
|
82
|
+
pts.push({ x: mat.data32S[i * 2], y: mat.data32S[i * 2 + 1] });
|
|
83
|
+
}
|
|
84
|
+
mat.delete();
|
|
85
|
+
|
|
86
|
+
// Sort into TL, TR, BR, BL
|
|
87
|
+
pts.sort((a, b) => a.x + a.y - (b.x + b.y));
|
|
88
|
+
let tl = pts[0];
|
|
89
|
+
let br = pts[3];
|
|
90
|
+
let remaining = [pts[1], pts[2]];
|
|
91
|
+
let tr = remaining[0].x > remaining[1].x ? remaining[0] : remaining[1];
|
|
92
|
+
let bl = remaining[0].x < remaining[1].x ? remaining[0] : remaining[1];
|
|
93
|
+
quad = [tl, tr, br, bl];
|
|
94
|
+
|
|
95
|
+
// Draw the detected quad on the debug canvas in green
|
|
96
|
+
let green = new cv.Scalar(0, 255, 0, 255);
|
|
97
|
+
let ordered = [tl, tr, br, bl];
|
|
98
|
+
for (let i = 0; i < 4; i++) {
|
|
99
|
+
let a = ordered[i];
|
|
100
|
+
let b = ordered[(i + 1) % 4];
|
|
101
|
+
cv.line(debugRgba, new cv.Point(a.x, a.y), new cv.Point(b.x, b.y), green, 3);
|
|
102
|
+
}
|
|
103
|
+
for (let pt of ordered) {
|
|
104
|
+
cv.circle(debugRgba, new cv.Point(pt.x, pt.y), 8, green, -1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let debugCanvas = document.createElement("canvas");
|
|
109
|
+
cv.imshow(debugCanvas, debugRgba);
|
|
110
|
+
debugRgba.delete();
|
|
111
|
+
|
|
112
|
+
return { quad, debugCanvas };
|
|
113
|
+
} finally {
|
|
114
|
+
gray.delete();
|
|
115
|
+
blurred.delete();
|
|
116
|
+
edges.delete();
|
|
117
|
+
dilated.delete();
|
|
118
|
+
contours.delete();
|
|
119
|
+
hierarchy.delete();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface UseEdgeDetectionResult {
|
|
124
|
+
corners: Quad | null;
|
|
125
|
+
/** Data URL of the Canny edge image, for debug visualisation */
|
|
126
|
+
debugImageUrl: string | null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Runs Canny edge detection on the image to suggest document corners.
|
|
131
|
+
* Falls back to full-image corners if no quadrilateral is found.
|
|
132
|
+
*/
|
|
133
|
+
export function useEdgeDetection(cv: any, canvas: HTMLCanvasElement | null, enabled: boolean): UseEdgeDetectionResult {
|
|
134
|
+
let [corners, setCorners] = useState<Quad | null>(null);
|
|
135
|
+
let [debugImageUrl, setDebugImageUrl] = useState<string | null>(null);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
console.log(
|
|
139
|
+
"[useEdgeDetection] effect: enabled=",
|
|
140
|
+
enabled,
|
|
141
|
+
"cv=",
|
|
142
|
+
typeof cv,
|
|
143
|
+
"canvas=",
|
|
144
|
+
canvas?.width,
|
|
145
|
+
"x",
|
|
146
|
+
canvas?.height,
|
|
147
|
+
);
|
|
148
|
+
if (!enabled || !cv || !canvas) return;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
console.log("[useEdgeDetection] running findLargestQuad...");
|
|
152
|
+
let src = cv.imread(canvas);
|
|
153
|
+
let { quad, debugCanvas } = findLargestQuad(cv, src);
|
|
154
|
+
src.delete();
|
|
155
|
+
console.log("[useEdgeDetection] quad=", quad);
|
|
156
|
+
setDebugImageUrl(debugCanvas.toDataURL());
|
|
157
|
+
setCorners(quad ?? fullImageQuad(canvas.width, canvas.height));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error("[useEdgeDetection] error:", err);
|
|
160
|
+
if (canvas) {
|
|
161
|
+
setCorners(fullImageQuad(canvas.width, canvas.height));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}, [cv, canvas, enabled]);
|
|
165
|
+
|
|
166
|
+
return { corners, debugImageUrl };
|
|
167
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface UseImageEditorResult {
|
|
4
|
+
currentImageUrl: string;
|
|
5
|
+
currentBlob: Blob | null;
|
|
6
|
+
applyImage: (blob: Blob) => string;
|
|
7
|
+
reset: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Manages the image history for the editor.
|
|
12
|
+
* Tracks the current image URL/blob and cleans up generated object URLs on unmount.
|
|
13
|
+
*/
|
|
14
|
+
export function useImageEditor(initialImageUrl: string): UseImageEditorResult {
|
|
15
|
+
let [currentImageUrl, setCurrentImageUrl] = useState(initialImageUrl);
|
|
16
|
+
let [currentBlob, setCurrentBlob] = useState<Blob | null>(null);
|
|
17
|
+
let generatedUrls = useRef<string[]>([]);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
return () => {
|
|
21
|
+
for (let url of generatedUrls.current) URL.revokeObjectURL(url);
|
|
22
|
+
};
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
function applyImage(blob: Blob): string {
|
|
26
|
+
let nextUrl = URL.createObjectURL(blob);
|
|
27
|
+
generatedUrls.current = [...generatedUrls.current, nextUrl];
|
|
28
|
+
setCurrentImageUrl(nextUrl);
|
|
29
|
+
setCurrentBlob(blob);
|
|
30
|
+
return nextUrl;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function reset() {
|
|
34
|
+
setCurrentImageUrl(initialImageUrl);
|
|
35
|
+
setCurrentBlob(null);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { currentImageUrl, currentBlob, applyImage, reset };
|
|
39
|
+
}
|