@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,310 @@
1
+ import ArrowBackIcon from "@mui/icons-material/ArrowBack";
2
+ import CheckIcon from "@mui/icons-material/Check";
3
+ import RedoIcon from "@mui/icons-material/Redo";
4
+ import RestartAltIcon from "@mui/icons-material/RestartAlt";
5
+ import UndoIcon from "@mui/icons-material/Undo";
6
+ import { Button, CircularProgress, Fab, IconButton, Tooltip, useMediaQuery } from "@mui/material";
7
+ import { getLocalizedString } from "@olenbetong/appframe-core";
8
+ import { forwardRef, useImperativeHandle, useMemo, useState } from "react";
9
+
10
+ import "./ImageEditor.css";
11
+ import { ResponsiveButton } from "./ResponsiveButton.js";
12
+ import type { ActionPlugin, ActionRegistry, EditAction, Plugin, ToolPlugin, ViewPlugin } from "./types.js";
13
+ import { useActiveTool } from "./useActiveTool.js";
14
+ import { useImageHistory } from "./useImageHistory.js";
15
+
16
+ export type ImageEditorRef = {
17
+ /** Returns the current edited blob, or null if no edits have been applied since the last source image. */
18
+ getCurrentBlob: () => Blob | null;
19
+ };
20
+
21
+ interface Props {
22
+ imageUrl: string;
23
+ /** Called when the user clicks the Done button. When omitted, the Done button is not shown. */
24
+ onConfirm?: (blob: Blob) => void;
25
+ /** Called when the user wants to exit without saving. Shows a back button in the top bar. */
26
+ onCancel?: () => void;
27
+ plugins: Plugin[];
28
+ /** Optional view plugin (e.g. zoom/pan) that wraps the canvas and contributes top-bar controls. */
29
+ viewPlugin?: ViewPlugin;
30
+ }
31
+
32
+ /**
33
+ * Full-screen image editor shell.
34
+ * Does not know about specific tools — behaviour is entirely driven by the `plugins` prop.
35
+ * Tool plugins contribute an overlay and toolbar; action plugins execute immediately.
36
+ * Supports undo/redo: each applied transformation is recorded with its EditAction list.
37
+ */
38
+ export const ImageEditor = forwardRef<ImageEditorRef, Props>(function ImageEditor(
39
+ { imageUrl, onConfirm, onCancel, plugins, viewPlugin }: Props,
40
+ ref,
41
+ ) {
42
+ let registry = useMemo(() => buildActionRegistry(plugins), [plugins]);
43
+ let history = useImageHistory(imageUrl, registry);
44
+ let { activePlugin, activateTool, deactivateTool } = useActiveTool(plugins);
45
+ let [busy, setBusy] = useState(false);
46
+ let disabled = busy || history.replaying;
47
+ let isSmall = useMediaQuery("(max-width: 600px)");
48
+
49
+ useImperativeHandle(ref, () => ({
50
+ getCurrentBlob: () => history.currentBlob,
51
+ }));
52
+
53
+ async function executeAction(plugin: ActionPlugin) {
54
+ setBusy(true);
55
+ try {
56
+ let blob = await plugin.execute(history.currentImageUrl);
57
+ let actions: EditAction[] = [{ type: plugin.id, params: {} }];
58
+ history.applyActions(blob, actions);
59
+ } finally {
60
+ setBusy(false);
61
+ }
62
+ }
63
+
64
+ function handleToolApply(blob: Blob, actions: EditAction[]) {
65
+ history.applyActions(blob, actions);
66
+ deactivateTool();
67
+ }
68
+
69
+ function confirm() {
70
+ if (!onConfirm) return;
71
+ if (history.currentBlob) {
72
+ onConfirm(history.currentBlob);
73
+ return;
74
+ }
75
+ fetch(history.currentImageUrl)
76
+ .then((r) => r.blob())
77
+ .then(onConfirm);
78
+ }
79
+
80
+ let CanvasWrapper = viewPlugin?.CanvasWrapper;
81
+ let TopBarControls = viewPlugin?.TopBarControls;
82
+
83
+ let inner = (
84
+ <div className="image-editor">
85
+ {/* Top toolbar — always visible; undo/redo/reset disabled while a tool is active */}
86
+ <div className="image-editor__topbar">
87
+ <div className="image-editor__toolbar-group">
88
+ {onCancel && (
89
+ <>
90
+ <ResponsiveButton
91
+ icon={<ArrowBackIcon />}
92
+ label={getLocalizedString("Back")}
93
+ onClick={onCancel}
94
+ isSmall={isSmall}
95
+ />
96
+ <span className="image-editor__topbar-divider" aria-hidden />
97
+ </>
98
+ )}
99
+ <ResponsiveButton
100
+ icon={<UndoIcon />}
101
+ label={getLocalizedString("Undo")}
102
+ onClick={history.undo}
103
+ disabled={disabled || !history.canUndo || !!activePlugin}
104
+ isSmall={isSmall}
105
+ />
106
+ <ResponsiveButton
107
+ icon={<RedoIcon />}
108
+ label={getLocalizedString("Redo")}
109
+ onClick={history.redo}
110
+ disabled={disabled || !history.canRedo || !!activePlugin}
111
+ isSmall={isSmall}
112
+ />
113
+ <ResponsiveButton
114
+ icon={<RestartAltIcon />}
115
+ label={getLocalizedString("Reset")}
116
+ onClick={history.reset}
117
+ disabled={disabled || !history.canUndo || !!activePlugin}
118
+ isSmall={isSmall}
119
+ />
120
+ </div>
121
+
122
+ {TopBarControls && (
123
+ <div className="image-editor__toolbar-group">
124
+ <TopBarControls disabled={disabled} isSmall={isSmall} />
125
+ </div>
126
+ )}
127
+ </div>
128
+
129
+ {/* Canvas area */}
130
+ <div className="image-editor__canvas">
131
+ {CanvasWrapper ? (
132
+ <CanvasWrapper panEnabled={!activePlugin}>
133
+ {activePlugin ? (
134
+ <ActiveOverlay plugin={activePlugin} />
135
+ ) : (
136
+ <img
137
+ src={history.currentImageUrl}
138
+ alt={getLocalizedString("Image to edit")}
139
+ className="image-editor__img"
140
+ />
141
+ )}
142
+ </CanvasWrapper>
143
+ ) : activePlugin ? (
144
+ <ActiveOverlay plugin={activePlugin} />
145
+ ) : (
146
+ <img src={history.currentImageUrl} alt={getLocalizedString("Image to edit")} className="image-editor__img" />
147
+ )}
148
+
149
+ {/* Done button floats over the canvas, bottom-right — hidden when a tool is active or no onConfirm */}
150
+ {!activePlugin && onConfirm && (
151
+ <Fab
152
+ color="primary"
153
+ variant={isSmall ? "circular" : "extended"}
154
+ onClick={confirm}
155
+ disabled={disabled}
156
+ sx={{ position: "absolute", bottom: "1rem", right: "1rem", zIndex: 5 }}
157
+ >
158
+ <CheckIcon sx={isSmall ? undefined : { mr: 1 }} />
159
+ {!isSmall && getLocalizedString("Done")}
160
+ </Fab>
161
+ )}
162
+
163
+ {/* Replay overlay covers the canvas and sits above the Done button */}
164
+ {history.replaying && (
165
+ <div className="image-editor__replay-overlay">
166
+ <CircularProgress color="inherit" />
167
+ </div>
168
+ )}
169
+ </div>
170
+
171
+ {/* Bottom toolbar — tool/action plugin buttons */}
172
+ <div className="image-editor__toolbar">
173
+ {activePlugin ? (
174
+ <ActiveToolbar plugin={activePlugin} />
175
+ ) : (
176
+ <div className="image-editor__toolbar-group">
177
+ {plugins.map((p) =>
178
+ p.type === "tool" ? (
179
+ <ToolButton
180
+ key={p.id}
181
+ plugin={p}
182
+ busy={disabled}
183
+ onActivate={() => activateTool(p.id)}
184
+ isSmall={isSmall}
185
+ />
186
+ ) : (
187
+ <ActionButton
188
+ key={p.id}
189
+ plugin={p}
190
+ busy={disabled}
191
+ onExecute={() => executeAction(p)}
192
+ isSmall={isSmall}
193
+ />
194
+ ),
195
+ )}
196
+ </div>
197
+ )}
198
+ </div>
199
+ </div>
200
+ );
201
+
202
+ // Wrap with the active tool's provider so Overlay and Toolbar share context.
203
+ if (activePlugin) {
204
+ let ActiveProvider = activePlugin.Provider;
205
+ inner = (
206
+ <ActiveProvider imageUrl={history.currentImageUrl} onApply={handleToolApply} onCancel={deactivateTool}>
207
+ {inner}
208
+ </ActiveProvider>
209
+ );
210
+ }
211
+
212
+ // Wrap with the view plugin's provider (outermost) so both CanvasWrapper and TopBarControls share state.
213
+ if (viewPlugin) {
214
+ let ViewProvider = viewPlugin.Provider;
215
+ return <ViewProvider>{inner}</ViewProvider>;
216
+ }
217
+
218
+ return inner;
219
+ });
220
+
221
+ function ToolButton({
222
+ plugin,
223
+ busy,
224
+ onActivate,
225
+ isSmall,
226
+ }: {
227
+ plugin: ToolPlugin;
228
+ busy: boolean;
229
+ onActivate: () => void;
230
+ isSmall: boolean;
231
+ }) {
232
+ let Icon = plugin.Icon;
233
+ if (isSmall) {
234
+ return (
235
+ <Tooltip title={getLocalizedString(plugin.label)}>
236
+ <span>
237
+ <IconButton onClick={onActivate} disabled={busy} color="inherit">
238
+ <Icon />
239
+ </IconButton>
240
+ </span>
241
+ </Tooltip>
242
+ );
243
+ }
244
+ return (
245
+ <Button startIcon={<Icon />} onClick={onActivate} disabled={busy} variant="outlined" color="inherit">
246
+ {getLocalizedString(plugin.label)}
247
+ </Button>
248
+ );
249
+ }
250
+
251
+ function ActionButton({
252
+ plugin,
253
+ busy,
254
+ onExecute,
255
+ isSmall,
256
+ }: {
257
+ plugin: ActionPlugin;
258
+ busy: boolean;
259
+ onExecute: () => void;
260
+ isSmall: boolean;
261
+ }) {
262
+ let Icon = plugin.Icon;
263
+ if (isSmall) {
264
+ return (
265
+ <Tooltip title={getLocalizedString(plugin.label)}>
266
+ <span>
267
+ <IconButton onClick={onExecute} disabled={busy} color="inherit">
268
+ {busy ? <CircularProgress size={18} color="inherit" /> : <Icon />}
269
+ </IconButton>
270
+ </span>
271
+ </Tooltip>
272
+ );
273
+ }
274
+ return (
275
+ <Button
276
+ startIcon={busy ? <CircularProgress size={18} color="inherit" /> : <Icon />}
277
+ onClick={onExecute}
278
+ disabled={busy}
279
+ color="inherit"
280
+ >
281
+ {getLocalizedString(plugin.label)}
282
+ </Button>
283
+ );
284
+ }
285
+
286
+ function ActiveOverlay({ plugin }: { plugin: ToolPlugin }) {
287
+ let Overlay = plugin.Overlay;
288
+ return <Overlay />;
289
+ }
290
+
291
+ function ActiveToolbar({ plugin }: { plugin: ToolPlugin }) {
292
+ let Toolbar = plugin.Toolbar;
293
+ return <Toolbar />;
294
+ }
295
+
296
+ /** Builds the action registry from all registered plugins. */
297
+ function buildActionRegistry(plugins: Plugin[]): ActionRegistry {
298
+ let registry: ActionRegistry = new Map();
299
+ for (let plugin of plugins) {
300
+ if (plugin.type === "action") {
301
+ // Action plugins have no params — their execute function IS the replay handler.
302
+ registry.set(plugin.id, (imageUrl, _params) => plugin.execute(imageUrl));
303
+ } else {
304
+ for (let action of plugin.actions) {
305
+ registry.set(action.type, action.apply);
306
+ }
307
+ }
308
+ }
309
+ return registry;
310
+ }
@@ -0,0 +1,32 @@
1
+ import { Button, IconButton, Tooltip } from "@mui/material";
2
+ import type { ReactNode } from "react";
3
+
4
+ interface Props {
5
+ icon: ReactNode;
6
+ label: string;
7
+ onClick: () => void;
8
+ disabled?: boolean;
9
+ isSmall: boolean;
10
+ }
11
+
12
+ /** Renders a labelled Button on large screens and a Tooltip-wrapped IconButton on small ones. */
13
+ export function ResponsiveButton({ icon, label, onClick, disabled, isSmall }: Props) {
14
+ if (isSmall) {
15
+ return (
16
+ <Tooltip title={label}>
17
+ {/* span needed so Tooltip can attach to a disabled button */}
18
+ <span>
19
+ <IconButton onClick={onClick} disabled={disabled} color="inherit" size="small">
20
+ {icon}
21
+ </IconButton>
22
+ </span>
23
+ </Tooltip>
24
+ );
25
+ }
26
+
27
+ return (
28
+ <Button startIcon={icon} onClick={onClick} color="inherit" disabled={disabled}>
29
+ {label}
30
+ </Button>
31
+ );
32
+ }
@@ -0,0 +1,10 @@
1
+ import { cropPlugin } from "./plugins/cropPlugin/index.js";
2
+ import { redactPlugin } from "./plugins/redactPlugin/index.js";
3
+ import { rotateCcwPlugin, rotateCwPlugin } from "./plugins/rotatePlugin.js";
4
+ import { zoomPlugin } from "./plugins/zoomPlugin/index.js";
5
+
6
+ /** Default set of image editing plugins in display order. */
7
+ export const defaultPlugins = [rotateCcwPlugin, rotateCwPlugin, cropPlugin, redactPlugin];
8
+
9
+ /** Default view plugin providing zoom and pan. */
10
+ export const defaultViewPlugin = zoomPlugin;
@@ -0,0 +1,6 @@
1
+ export { defaultPlugins, defaultViewPlugin } from "./defaultPlugins.js";
2
+ export type { ImageEditorRef } from "./ImageEditor.js";
3
+ export { ImageEditor } from "./ImageEditor.js";
4
+ export { cropPlugin } from "./plugins/cropPlugin/index.js";
5
+ export { rotateCcwPlugin, rotateCwPlugin } from "./plugins/rotatePlugin.js";
6
+ export type { ActionPlugin, EditAction, Plugin, ToolPlugin, ViewPlugin } from "./types.js";
@@ -0,0 +1,24 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ import type { EditAction, ToolProviderProps } from "../../types.js";
4
+ import type { CropToolState } from "./useCropTool.js";
5
+ import { useCropTool } from "./useCropTool.js";
6
+
7
+ interface CropContextValue extends CropToolState {
8
+ imageUrl: string;
9
+ onApply: (blob: Blob, actions: EditAction[]) => void;
10
+ onCancel: () => void;
11
+ }
12
+
13
+ let CropContext = createContext<CropContextValue | null>(null);
14
+
15
+ export function CropProvider({ imageUrl, onApply, onCancel, children }: ToolProviderProps) {
16
+ let state = useCropTool(imageUrl, true);
17
+ return <CropContext.Provider value={{ ...state, imageUrl, onApply, onCancel }}>{children}</CropContext.Provider>;
18
+ }
19
+
20
+ export function useCropContext(): CropContextValue {
21
+ let ctx = useContext(CropContext);
22
+ if (!ctx) throw new Error("useCropContext must be used within CropProvider");
23
+ return ctx;
24
+ }
@@ -0,0 +1,119 @@
1
+ import { Alert, CircularProgress, Typography } from "@mui/material";
2
+ import { getLocalizedString } from "@olenbetong/appframe-core";
3
+
4
+ import { CropCanvas } from "../../CropCanvas.js";
5
+ import { DragHandle } from "../../DragHandle.js";
6
+ import { EdgeHandle } from "../../EdgeHandle.js";
7
+ import { useCropContext } from "./CropContext.js";
8
+
9
+ /** Canvas-area overlay for the crop tool. Reads all state from CropContext. */
10
+ export function CropOverlay() {
11
+ let {
12
+ imageUrl,
13
+ activeCorners,
14
+ scale,
15
+ displaySize,
16
+ canvasRef,
17
+ cvReady,
18
+ cvError,
19
+ cropping,
20
+ error,
21
+ handleCanvasReady,
22
+ updateCorner,
23
+ } = useCropContext();
24
+
25
+ return (
26
+ <div className="crop-root">
27
+ <div className="crop-inner">
28
+ <CropCanvas ref={canvasRef} imageUrl={imageUrl} corners={activeCorners} onReady={handleCanvasReady} />
29
+
30
+ {displaySize.width > 0 &&
31
+ activeCorners.map((corner, i) => (
32
+ <DragHandle
33
+ key={i}
34
+ x={corner.x * scale}
35
+ y={corner.y * scale}
36
+ onChange={(x, y) => updateCorner(i, x, y)}
37
+ containerWidth={displaySize.width}
38
+ containerHeight={displaySize.height}
39
+ />
40
+ ))}
41
+
42
+ {displaySize.width > 0 &&
43
+ (() => {
44
+ let [tl, tr, br, bl] = activeCorners;
45
+ let s = scale;
46
+ return (
47
+ <>
48
+ {/* Top edge — moves TL (0) and TR (1) */}
49
+ <EdgeHandle
50
+ x={((tl.x + tr.x) / 2) * s}
51
+ y={((tl.y + tr.y) / 2) * s}
52
+ axis="y"
53
+ onChange={(dx, dy) => {
54
+ updateCorner(0, tl.x * s + dx, tl.y * s + dy);
55
+ updateCorner(1, tr.x * s + dx, tr.y * s + dy);
56
+ }}
57
+ />
58
+ {/* Right edge — moves TR (1) and BR (2) */}
59
+ <EdgeHandle
60
+ x={((tr.x + br.x) / 2) * s}
61
+ y={((tr.y + br.y) / 2) * s}
62
+ axis="x"
63
+ onChange={(dx, dy) => {
64
+ updateCorner(1, tr.x * s + dx, tr.y * s + dy);
65
+ updateCorner(2, br.x * s + dx, br.y * s + dy);
66
+ }}
67
+ />
68
+ {/* Bottom edge — moves BR (2) and BL (3) */}
69
+ <EdgeHandle
70
+ x={((br.x + bl.x) / 2) * s}
71
+ y={((br.y + bl.y) / 2) * s}
72
+ axis="y"
73
+ onChange={(dx, dy) => {
74
+ updateCorner(2, br.x * s + dx, br.y * s + dy);
75
+ updateCorner(3, bl.x * s + dx, bl.y * s + dy);
76
+ }}
77
+ />
78
+ {/* Left edge — moves BL (3) and TL (0) */}
79
+ <EdgeHandle
80
+ x={((bl.x + tl.x) / 2) * s}
81
+ y={((bl.y + tl.y) / 2) * s}
82
+ axis="x"
83
+ onChange={(dx, dy) => {
84
+ updateCorner(3, bl.x * s + dx, bl.y * s + dy);
85
+ updateCorner(0, tl.x * s + dx, tl.y * s + dy);
86
+ }}
87
+ />
88
+ </>
89
+ );
90
+ })()}
91
+ </div>
92
+
93
+ {!cvReady && !cvError && (
94
+ <div className="crop-loading">
95
+ <CircularProgress size={18} color="inherit" />
96
+ <Typography variant="body2">{getLocalizedString("Loading OpenCV…")}</Typography>
97
+ </div>
98
+ )}
99
+ {cvError && (
100
+ <div className="crop-loading">
101
+ <Alert severity="error" sx={{ fontSize: "0.8rem" }}>
102
+ {getLocalizedString("Crop tool failed to load")}
103
+ </Alert>
104
+ </div>
105
+ )}
106
+ {(error || cropping) && (
107
+ <div className="crop-loading">
108
+ {cropping ? (
109
+ <CircularProgress size={18} color="inherit" />
110
+ ) : (
111
+ <Alert severity="error" sx={{ fontSize: "0.8rem" }}>
112
+ {error}
113
+ </Alert>
114
+ )}
115
+ </div>
116
+ )}
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,36 @@
1
+ import CheckIcon from "@mui/icons-material/Check";
2
+ import CloseIcon from "@mui/icons-material/Close";
3
+ import { Button, CircularProgress } from "@mui/material";
4
+ import { getLocalizedString } from "@olenbetong/appframe-core";
5
+
6
+ import { useCropContext } from "./CropContext.js";
7
+
8
+ /** Toolbar shown while the crop tool is active. Reads state from CropContext. */
9
+ export function CropToolbar() {
10
+ let { applyCrop, cropping, cvReady, cvError, onApply, onCancel } = useCropContext();
11
+
12
+ async function handleApply() {
13
+ let result = await applyCrop();
14
+ if (result) onApply(result.blob, result.actions);
15
+ }
16
+
17
+ return (
18
+ <>
19
+ <div className="image-editor__toolbar-group">
20
+ <Button startIcon={<CloseIcon />} onClick={onCancel} color="inherit" disabled={cropping}>
21
+ {getLocalizedString("Cancel")}
22
+ </Button>
23
+ </div>
24
+ <div className="image-editor__toolbar-group">
25
+ <Button
26
+ variant="contained"
27
+ startIcon={cropping ? <CircularProgress size={18} color="inherit" /> : <CheckIcon />}
28
+ onClick={handleApply}
29
+ disabled={cropping || !cvReady || !!cvError}
30
+ >
31
+ {getLocalizedString("Apply crop")}
32
+ </Button>
33
+ </div>
34
+ </>
35
+ );
36
+ }
@@ -0,0 +1,20 @@
1
+ import CropIcon from "@mui/icons-material/Crop";
2
+
3
+ import type { ToolPlugin } from "../../types.js";
4
+ import { CropProvider } from "./CropContext.js";
5
+ import { CropOverlay } from "./CropOverlay.js";
6
+ import { CropToolbar } from "./CropToolbar.js";
7
+ import { replayCrop } from "./useCropTool.js";
8
+
9
+ export { CropOverlay, CropProvider, CropToolbar };
10
+
11
+ export const cropPlugin: ToolPlugin = {
12
+ type: "tool",
13
+ id: "crop",
14
+ label: "Crop",
15
+ Icon: CropIcon,
16
+ Provider: CropProvider,
17
+ Overlay: CropOverlay,
18
+ Toolbar: CropToolbar,
19
+ actions: [{ type: "crop", apply: replayCrop }],
20
+ };