mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'worktree-agent-a39efeb4' into feat/image-editor
# Conflicts: # apps/web/src/components/editor/editor-right-panel.tsx
This commit is contained in:
@@ -0,0 +1,684 @@
|
||||
// apps/web/src/components/editor/common/export-dialog.tsx
|
||||
|
||||
import {
|
||||
Check,
|
||||
ClipboardCopy,
|
||||
Download,
|
||||
FileDown,
|
||||
FileUp,
|
||||
Lock,
|
||||
Save,
|
||||
Unlock,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type {
|
||||
AdjustmentValues,
|
||||
CanvasObject,
|
||||
EditorLayer,
|
||||
FilterConfig,
|
||||
Guide,
|
||||
} from "@/types/editor";
|
||||
|
||||
type ExportFormat = "png" | "jpeg" | "webp";
|
||||
|
||||
interface ExportSettings {
|
||||
format: ExportFormat;
|
||||
quality: number;
|
||||
width: number;
|
||||
height: number;
|
||||
lockAspect: boolean;
|
||||
transparent: boolean;
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS: { value: ExportFormat; label: string; supportsTransparency: boolean }[] = [
|
||||
{ value: "png", label: "PNG", supportsTransparency: true },
|
||||
{ value: "jpeg", label: "JPEG", supportsTransparency: false },
|
||||
{ value: "webp", label: "WebP", supportsTransparency: true },
|
||||
];
|
||||
|
||||
function getMimeType(format: ExportFormat): string {
|
||||
switch (format) {
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "jpeg":
|
||||
return "image/jpeg";
|
||||
case "webp":
|
||||
return "image/webp";
|
||||
}
|
||||
}
|
||||
|
||||
export function ExportDialog({ onClose }: { onClose: () => void }) {
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const markClean = useEditorStore((s) => s.markClean);
|
||||
|
||||
const [settings, setSettings] = useState<ExportSettings>({
|
||||
format: "png",
|
||||
quality: 92,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
lockAspect: true,
|
||||
transparent: true,
|
||||
});
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [copyStatus, setCopyStatus] = useState<"idle" | "copied">("idle");
|
||||
|
||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const generatePreview = useCallback(() => {
|
||||
// Create a thumbnail canvas for preview
|
||||
const canvas = document.createElement("canvas");
|
||||
const maxPreview = 200;
|
||||
const scale = Math.min(maxPreview / canvasSize.width, maxPreview / canvasSize.height);
|
||||
canvas.width = Math.round(canvasSize.width * scale);
|
||||
canvas.height = Math.round(canvasSize.height * scale);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (!settings.transparent || settings.format === "jpeg") {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// Try to draw from the Konva stage if available
|
||||
const stageCanvas = document.querySelector(
|
||||
"[data-testid='editor-canvas'] canvas",
|
||||
) as HTMLCanvasElement | null;
|
||||
if (stageCanvas) {
|
||||
ctx.drawImage(stageCanvas, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
const url = canvas.toDataURL(getMimeType(settings.format), settings.quality / 100);
|
||||
setPreviewUrl(url);
|
||||
}, [canvasSize, settings.format, settings.quality, settings.transparent]);
|
||||
|
||||
// Generate preview thumbnail on format/transparency change
|
||||
useEffect(() => {
|
||||
generatePreview();
|
||||
}, [generatePreview]);
|
||||
|
||||
// Handle width change with aspect lock
|
||||
const handleWidthChange = useCallback(
|
||||
(w: number) => {
|
||||
const newWidth = Math.max(1, w);
|
||||
if (settings.lockAspect) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
width: newWidth,
|
||||
height: Math.round(newWidth / aspectRatio),
|
||||
}));
|
||||
} else {
|
||||
setSettings((prev) => ({ ...prev, width: newWidth }));
|
||||
}
|
||||
},
|
||||
[settings.lockAspect, aspectRatio],
|
||||
);
|
||||
|
||||
// Handle height change with aspect lock
|
||||
const handleHeightChange = useCallback(
|
||||
(h: number) => {
|
||||
const newHeight = Math.max(1, h);
|
||||
if (settings.lockAspect) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
height: newHeight,
|
||||
width: Math.round(newHeight * aspectRatio),
|
||||
}));
|
||||
} else {
|
||||
setSettings((prev) => ({ ...prev, height: newHeight }));
|
||||
}
|
||||
},
|
||||
[settings.lockAspect, aspectRatio],
|
||||
);
|
||||
|
||||
// Export as file download
|
||||
const handleExport = useCallback(() => {
|
||||
const stageCanvas = document.querySelector(
|
||||
"[data-testid='editor-canvas'] canvas",
|
||||
) as HTMLCanvasElement | null;
|
||||
if (!stageCanvas) return;
|
||||
|
||||
// Create export canvas at the requested dimensions
|
||||
const exportCanvas = document.createElement("canvas");
|
||||
exportCanvas.width = settings.width;
|
||||
exportCanvas.height = settings.height;
|
||||
const ctx = exportCanvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (!settings.transparent || settings.format === "jpeg") {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||
}
|
||||
|
||||
ctx.drawImage(stageCanvas, 0, 0, settings.width, settings.height);
|
||||
|
||||
exportCanvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `export.${settings.format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
markClean();
|
||||
},
|
||||
getMimeType(settings.format),
|
||||
settings.format === "png" ? undefined : settings.quality / 100,
|
||||
);
|
||||
}, [settings, markClean]);
|
||||
|
||||
// Copy to clipboard
|
||||
const handleCopyToClipboard = useCallback(async () => {
|
||||
const stageCanvas = document.querySelector(
|
||||
"[data-testid='editor-canvas'] canvas",
|
||||
) as HTMLCanvasElement | null;
|
||||
if (!stageCanvas) return;
|
||||
|
||||
const exportCanvas = document.createElement("canvas");
|
||||
exportCanvas.width = settings.width;
|
||||
exportCanvas.height = settings.height;
|
||||
const ctx = exportCanvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (!settings.transparent || settings.format === "jpeg") {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||
}
|
||||
|
||||
ctx.drawImage(stageCanvas, 0, 0, settings.width, settings.height);
|
||||
|
||||
try {
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
exportCanvas.toBlob(resolve, "image/png"),
|
||||
);
|
||||
if (!blob) return;
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
setCopyStatus("copied");
|
||||
setTimeout(() => setCopyStatus("idle"), 2000);
|
||||
} catch {
|
||||
// Clipboard API may not be available in all contexts
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Project save (.snapotter file)
|
||||
const handleSaveProject = useCallback(() => {
|
||||
const state = useEditorStore.getState();
|
||||
const projectData = {
|
||||
version: 1,
|
||||
canvasSize: state.canvasSize,
|
||||
layers: state.layers,
|
||||
objects: state.objects,
|
||||
adjustments: state.adjustments,
|
||||
filters: state.filters,
|
||||
guides: state.guides,
|
||||
sourceImageUrl: state.sourceImageUrl,
|
||||
sourceImageSize: state.sourceImageSize,
|
||||
foregroundColor: state.foregroundColor,
|
||||
backgroundColor: state.backgroundColor,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(projectData, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "project.snapotter";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
markClean();
|
||||
}, [markClean]);
|
||||
|
||||
// Project load (.snapotter file)
|
||||
const handleLoadProject = useCallback(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".snapotter,.json";
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(reader.result as string);
|
||||
if (!data.version || !data.canvasSize) return;
|
||||
|
||||
const store = useEditorStore.getState();
|
||||
const setState = useEditorStore.setState;
|
||||
|
||||
setState({
|
||||
canvasSize: data.canvasSize,
|
||||
layers: data.layers || store.layers,
|
||||
objects: data.objects || [],
|
||||
adjustments: data.adjustments || store.adjustments,
|
||||
filters: data.filters || store.filters,
|
||||
guides: data.guides || [],
|
||||
sourceImageUrl: data.sourceImageUrl || null,
|
||||
sourceImageSize: data.sourceImageSize || null,
|
||||
foregroundColor: data.foregroundColor || "#000000",
|
||||
backgroundColor: data.backgroundColor || "#ffffff",
|
||||
isDirty: false,
|
||||
lastAction: "Load Project",
|
||||
_historyVersion: store._historyVersion + 1,
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch {
|
||||
// Invalid project file
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
}, [onClose]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on backdrop click
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const supportsQuality = settings.format === "jpeg" || settings.format === "webp";
|
||||
const supportsTransparency = settings.format !== "jpeg";
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: modal backdrop click-to-dismiss uses Escape as keyboard equivalent
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="bg-card border border-border rounded-lg shadow-xl w-full max-w-md mx-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-semibold text-foreground">Export Image</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Preview */}
|
||||
{previewUrl && (
|
||||
<div className="flex justify-center p-2 bg-muted/30 rounded border border-border">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Export preview"
|
||||
className="max-h-[120px] object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Format */}
|
||||
<div>
|
||||
<span className="block text-xs font-medium text-muted-foreground mb-1.5">Format</span>
|
||||
<div className="flex gap-1">
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
format: opt.value,
|
||||
transparent: opt.supportsTransparency ? prev.transparent : false,
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 text-xs font-medium rounded transition-colors",
|
||||
settings.format === opt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality */}
|
||||
{supportsQuality && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Quality</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{settings.quality}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={settings.quality}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, quality: Number.parseInt(e.target.value, 10) }))
|
||||
}
|
||||
className={cn(
|
||||
"w-full h-1.5 appearance-none rounded-full bg-muted",
|
||||
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3",
|
||||
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dimensions */}
|
||||
<div>
|
||||
<span className="block text-xs font-medium text-muted-foreground mb-1.5">
|
||||
Dimensions
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="number"
|
||||
value={settings.width}
|
||||
onChange={(e) => handleWidthChange(Number.parseInt(e.target.value, 10) || 1)}
|
||||
className="w-full px-2 py-1 text-xs bg-muted rounded border border-border text-foreground outline-none focus:border-primary"
|
||||
min={1}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">Width</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettings((prev) => ({ ...prev, lockAspect: !prev.lockAspect }))}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors mt-[-12px]",
|
||||
settings.lockAspect
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label={settings.lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
>
|
||||
{settings.lockAspect ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="number"
|
||||
value={settings.height}
|
||||
onChange={(e) => handleHeightChange(Number.parseInt(e.target.value, 10) || 1)}
|
||||
className="w-full px-2 py-1 text-xs bg-muted rounded border border-border text-foreground outline-none focus:border-primary"
|
||||
min={1}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">Height</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
}))
|
||||
}
|
||||
className="mt-1 text-[10px] text-primary hover:underline"
|
||||
>
|
||||
Reset to original size ({canvasSize.width} x {canvasSize.height})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Transparent background */}
|
||||
{supportsTransparency && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.transparent}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, transparent: e.target.checked }))
|
||||
}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-xs text-foreground">Transparent background</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
{/* Primary export actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Download size={14} />
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyToClipboard}
|
||||
className="flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium bg-muted text-foreground rounded hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
{copyStatus === "copied" ? <Check size={14} /> : <ClipboardCopy size={14} />}
|
||||
{copyStatus === "copied" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Project save/load */}
|
||||
<div className="flex gap-2 pt-1 border-t border-border mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveProject}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<FileDown size={12} />
|
||||
Save Project
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadProject}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<FileUp size={12} />
|
||||
Load Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Autosave utilities (Feature 44) ----
|
||||
|
||||
const AUTOSAVE_KEY = "snapotter-editor-autosave";
|
||||
const AUTOSAVE_INTERVAL_MS = 60_000;
|
||||
|
||||
interface AutosaveState {
|
||||
canvasSize: { width: number; height: number };
|
||||
layers: EditorLayer[];
|
||||
objects: CanvasObject[];
|
||||
adjustments: AdjustmentValues;
|
||||
filters: FilterConfig[];
|
||||
guides: Guide[];
|
||||
sourceImageUrl: string | null;
|
||||
sourceImageSize: { width: number; height: number } | null;
|
||||
foregroundColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
interface AutosaveData {
|
||||
version: 1;
|
||||
timestamp: number;
|
||||
state: AutosaveState;
|
||||
}
|
||||
|
||||
export function saveEditorState(): void {
|
||||
try {
|
||||
const s = useEditorStore.getState();
|
||||
const data: AutosaveData = {
|
||||
version: 1,
|
||||
timestamp: Date.now(),
|
||||
state: {
|
||||
canvasSize: s.canvasSize,
|
||||
layers: s.layers,
|
||||
objects: s.objects,
|
||||
adjustments: s.adjustments,
|
||||
filters: s.filters,
|
||||
guides: s.guides,
|
||||
sourceImageUrl: s.sourceImageUrl,
|
||||
sourceImageSize: s.sourceImageSize,
|
||||
foregroundColor: s.foregroundColor,
|
||||
backgroundColor: s.backgroundColor,
|
||||
},
|
||||
};
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
|
||||
useEditorStore.setState({ lastAutoSave: Date.now() });
|
||||
} catch {
|
||||
// localStorage might be full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAutosaveState(): AutosaveData | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTOSAVE_KEY);
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw) as AutosaveData;
|
||||
if (data.version !== 1 || !data.state?.canvasSize) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAutosave(): void {
|
||||
try {
|
||||
localStorage.removeItem(AUTOSAVE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreAutosave(data: AutosaveData): void {
|
||||
const store = useEditorStore.getState();
|
||||
useEditorStore.setState({
|
||||
...data.state,
|
||||
isDirty: true,
|
||||
lastAction: "Restore Autosave",
|
||||
_historyVersion: store._historyVersion + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to run autosave on an interval. Call this in EditorPage.
|
||||
* Returns recovery state if found on mount.
|
||||
*/
|
||||
export function useAutosave(): {
|
||||
recoveryData: AutosaveData | null;
|
||||
dismissRecovery: () => void;
|
||||
restoreRecovery: () => void;
|
||||
} {
|
||||
const [recoveryData, setRecoveryData] = useState<AutosaveData | null>(null);
|
||||
const isDirty = useEditorStore((s) => s.isDirty);
|
||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||
|
||||
// Check for recovery on mount
|
||||
useEffect(() => {
|
||||
const data = loadAutosaveState();
|
||||
if (data) {
|
||||
setRecoveryData(data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Autosave interval
|
||||
useEffect(() => {
|
||||
if (!sourceImageUrl) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (isDirty) {
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(() => saveEditorState());
|
||||
} else {
|
||||
saveEditorState();
|
||||
}
|
||||
}
|
||||
}, AUTOSAVE_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isDirty, sourceImageUrl]);
|
||||
|
||||
const dismissRecovery = useCallback(() => {
|
||||
clearAutosave();
|
||||
setRecoveryData(null);
|
||||
}, []);
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (recoveryData) {
|
||||
restoreAutosave(recoveryData);
|
||||
clearAutosave();
|
||||
setRecoveryData(null);
|
||||
}
|
||||
}, [recoveryData]);
|
||||
|
||||
return { recoveryData, dismissRecovery, restoreRecovery: handleRestore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery banner component for display at the top of the editor.
|
||||
*/
|
||||
export function AutosaveRecoveryBanner({
|
||||
data,
|
||||
onRestore,
|
||||
onDiscard,
|
||||
}: {
|
||||
data: AutosaveData;
|
||||
onRestore: () => void;
|
||||
onDiscard: () => void;
|
||||
}) {
|
||||
const timeStr = new Date(data.timestamp).toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/30 text-xs">
|
||||
<Save size={14} className="text-yellow-600 shrink-0" />
|
||||
<span className="text-foreground">Recovered unsaved work from {timeStr}.</span>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className="px-2 py-0.5 text-xs font-medium bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscard}
|
||||
className="px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user