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