import { Button, Spinner, Tooltip } from "@digdir/designsystemet-react"; import { ArrowCirclepathIcon, ArrowLeftIcon, ArrowRedoIcon, ArrowUndoIcon, CheckmarkIcon } from "@navikt/aksel-icons"; import { getLocalizedString } from "@olenbetong/appframe-core"; import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from "react"; import "./ImageEditor.css"; import { ResponsiveButton } from "./ResponsiveButton.js"; import type { ActionPlugin, ActionRegistry, EditAction, Plugin, ToolPlugin, ViewPlugin } from "./types.js"; import { useActiveTool } from "./useActiveTool.js"; import { useImageHistory } from "./useImageHistory.js"; import { useIsSmall } from "./useIsSmall.js"; export type ImageEditorRef = { /** Returns the current edited blob, or null if no edits have been applied since the last source image. */ getCurrentBlob: () => Blob | null; /** * Runs a registered action plugin (e.g. `"rotate-cw"`) against the current image. * Use this instead of calling `plugin.execute()` directly so external toolbars and * keyboard shortcuts share the editor's image state and undo history. * Resolves once the resulting image has been applied. */ executeAction: (pluginId: string) => Promise; /** * Resolves once every queued action has been applied. Await this before `getCurrentBlob()` * to be sure an action started moments earlier is included. */ whenIdle: () => Promise; }; interface Props { imageUrl: string; /** Called when the user clicks the Done button. When omitted, the Done button is not shown. */ onConfirm?: (blob: Blob) => void; /** Called when the user wants to exit without saving. Shows a back button in the top bar. */ onCancel?: () => void; plugins: Plugin[]; /** Optional view plugin (e.g. zoom/pan) that wraps the canvas and contributes top-bar controls. */ viewPlugin?: ViewPlugin; } /** * Full-screen image editor shell. * Does not know about specific tools — behaviour is entirely driven by the `plugins` prop. * Tool plugins contribute an overlay and toolbar; action plugins execute immediately. * Supports undo/redo: each applied transformation is recorded with its EditAction list. */ export const ImageEditor = forwardRef(function ImageEditor( { imageUrl, onConfirm, onCancel, plugins, viewPlugin }: Props, ref, ) { let registry = useMemo(() => buildActionRegistry(plugins), [plugins]); let history = useImageHistory(imageUrl, registry); let { activePlugin, activateTool, deactivateTool } = useActiveTool(plugins); let [busy, setBusy] = useState(false); let disabled = busy || history.replaying; let isSmall = useIsSmall(); // Keeps async/imperative callers working against the latest history state instead of // the snapshot captured when their closure was created. let historyRef = useRef(history); historyRef.current = history; // Serialises every action (toolbar clicks and imperative calls alike) so rapid // invocations each build on the previous result instead of racing on a stale image. let actionQueueRef = useRef>(Promise.resolve()); useImperativeHandle(ref, () => ({ getCurrentBlob: () => historyRef.current.getCurrentBlob(), whenIdle: () => actionQueueRef.current, executeAction: (pluginId: string) => { let plugin = plugins.find((p): p is ActionPlugin => p.type === "action" && p.id === pluginId); if (!plugin) { console.warn(`[ImageEditor] No action plugin registered with id "${pluginId}"`); return Promise.resolve(); } return enqueueAction(plugin); }, })); function enqueueAction(plugin: ActionPlugin) { let next = actionQueueRef.current.then(() => executeAction(plugin)); actionQueueRef.current = next.catch(() => {}); return next; } async function executeAction(plugin: ActionPlugin) { setBusy(true); try { let blob = await plugin.execute(historyRef.current.getCurrentImageUrl()); let actions: EditAction[] = [{ type: plugin.id, params: {} }]; historyRef.current.applyActions(blob, actions); } finally { setBusy(false); } } function handleToolApply(blob: Blob, actions: EditAction[]) { history.applyActions(blob, actions); deactivateTool(); } function confirm() { if (!onConfirm) return; if (history.currentBlob) { onConfirm(history.currentBlob); return; } fetch(history.currentImageUrl) .then((r) => r.blob()) .then(onConfirm); } let CanvasWrapper = viewPlugin?.CanvasWrapper; let TopBarControls = viewPlugin?.TopBarControls; let inner = (
{/* Top toolbar — always visible; undo/redo/reset disabled while a tool is active */}
{onCancel && ( <> } label={getLocalizedString("Back")} onClick={onCancel} isSmall={isSmall} /> )} } label={getLocalizedString("Undo")} onClick={history.undo} disabled={disabled || !history.canUndo || !!activePlugin} isSmall={isSmall} /> } label={getLocalizedString("Redo")} onClick={history.redo} disabled={disabled || !history.canRedo || !!activePlugin} isSmall={isSmall} /> } label={getLocalizedString("Reset")} onClick={history.reset} disabled={disabled || !history.canUndo || !!activePlugin} isSmall={isSmall} />
{TopBarControls && (
)}
{/* Canvas area */}
{CanvasWrapper ? ( {activePlugin ? ( ) : ( {getLocalizedString("Image )} ) : activePlugin ? ( ) : ( {getLocalizedString("Image )} {/* Done button floats over the canvas, bottom-right — hidden when a tool is active or no onConfirm */} {!activePlugin && onConfirm && ( )} {/* Replay overlay covers the canvas and sits above the Done button */} {history.replaying && (
)}
{/* Bottom toolbar — tool/action plugin buttons */}
{activePlugin ? ( ) : (
{plugins.map((p) => p.type === "tool" ? ( activateTool(p.id)} isSmall={isSmall} /> ) : ( enqueueAction(p)} isSmall={isSmall} /> ), )}
)}
); // Wrap with the active tool's provider so Overlay and Toolbar share context. if (activePlugin) { let ActiveProvider = activePlugin.Provider; inner = ( {inner} ); } // Wrap with the view plugin's provider (outermost) so both CanvasWrapper and TopBarControls share state. if (viewPlugin) { let ViewProvider = viewPlugin.Provider; return {inner}; } return inner; }); function ToolButton({ plugin, busy, onActivate, isSmall, }: { plugin: ToolPlugin; busy: boolean; onActivate: () => void; isSmall: boolean; }) { let Icon = plugin.Icon; let label = getLocalizedString(plugin.label); if (isSmall) { return ( ); } return ( ); } function ActionButton({ plugin, busy, onExecute, isSmall, }: { plugin: ActionPlugin; busy: boolean; onExecute: () => void; isSmall: boolean; }) { let Icon = plugin.Icon; let label = getLocalizedString(plugin.label); let icon = busy ? : ; if (isSmall) { return ( ); } return ( ); } function ActiveOverlay({ plugin }: { plugin: ToolPlugin }) { let Overlay = plugin.Overlay; return ; } function ActiveToolbar({ plugin }: { plugin: ToolPlugin }) { let Toolbar = plugin.Toolbar; return ; } /** Builds the action registry from all registered plugins. */ function buildActionRegistry(plugins: Plugin[]): ActionRegistry { let registry: ActionRegistry = new Map(); for (let plugin of plugins) { if (plugin.type === "action") { // Action plugins have no params — their execute function IS the replay handler. registry.set(plugin.id, (imageUrl, _params) => plugin.execute(imageUrl)); } else { for (let action of plugin.actions) { registry.set(action.type, action.apply); } } } return registry; }