diff --git a/components/editor/editor-layout.tsx b/components/editor/editor-layout.tsx
index 08d8b66..637ba60 100644
--- a/components/editor/editor-layout.tsx
+++ b/components/editor/editor-layout.tsx
@@ -6,21 +6,32 @@ import { useHistoryStore } from '@/store/history-store';
import { CanvasWithTools } from '@/components/canvas/canvas-with-tools';
import { LayersPanel } from '@/components/layers/layers-panel';
import { HistoryPanel } from './history-panel';
+import { FileMenu } from './file-menu';
import { ToolPalette, ToolSettings } from '@/components/tools';
import { ColorPanel } from '@/components/colors';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
+import { useFileOperations } from '@/hooks/use-file-operations';
+import { useDragDrop } from '@/hooks/use-drag-drop';
+import { useClipboard } from '@/hooks/use-clipboard';
import { createLayerWithHistory } from '@/lib/layer-operations';
-import { Plus, ZoomIn, ZoomOut, Maximize, Undo, Redo } from 'lucide-react';
+import { Plus, ZoomIn, ZoomOut, Maximize, Undo, Redo, Upload } from 'lucide-react';
import { cn } from '@/lib/utils';
export function EditorLayout() {
const { zoom, zoomIn, zoomOut, zoomToFit } = useCanvasStore();
const { layers } = useLayerStore();
const { undo, redo, canUndo, canRedo } = useHistoryStore();
+ const { handleDataTransfer } = useFileOperations();
// Enable keyboard shortcuts
useKeyboardShortcuts();
+ // Enable drag & drop
+ const { isDragging, handleDragOver, handleDragLeave, handleDrop } = useDragDrop(handleDataTransfer);
+
+ // Enable clipboard paste
+ useClipboard(handleDataTransfer);
+
// Initialize with a default layer (without history)
useEffect(() => {
if (layers.length === 0) {
@@ -50,13 +61,34 @@ export function EditorLayout() {
};
return (
-
+
+ {/* Drag overlay */}
+ {isDragging && (
+
+
+
+
+ Drop image or project file
+
+
+ Supports: PNG, JPG, WEBP, .paint
+
+
+
+ )}
+
{/* Toolbar */}
-
+
Paint UI
+
{/* History controls */}
diff --git a/components/editor/file-menu.tsx b/components/editor/file-menu.tsx
new file mode 100644
index 0000000..d06004c
--- /dev/null
+++ b/components/editor/file-menu.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import { useState, useRef } from 'react';
+import { useFileOperations } from '@/hooks/use-file-operations';
+import { ExportDialog } from '@/components/modals/export-dialog';
+import { NewImageDialog } from '@/components/modals/new-image-dialog';
+import {
+ FileImage,
+ FolderOpen,
+ Download,
+ Save,
+ ChevronDown,
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export function FileMenu() {
+ const {
+ createNewImage,
+ handleFileInput,
+ exportImage,
+ saveProject,
+ } = useFileOperations();
+
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [isNewDialogOpen, setIsNewDialogOpen] = useState(false);
+ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
+ const fileInputRef = useRef
(null);
+
+ const handleOpenFile = () => {
+ fileInputRef.current?.click();
+ setIsMenuOpen(false);
+ };
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ await handleFileInput(file);
+ }
+ // Reset input
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ };
+
+ const handleSaveProject = () => {
+ saveProject('project');
+ setIsMenuOpen(false);
+ };
+
+ return (
+ <>
+
+
+
+ {isMenuOpen && (
+ <>
+
setIsMenuOpen(false)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ {/* Hidden file input */}
+
+
+ {/* Dialogs */}
+
setIsNewDialogOpen(false)}
+ onCreate={createNewImage}
+ />
+
+ setIsExportDialogOpen(false)}
+ onExport={exportImage}
+ />
+ >
+ );
+}
diff --git a/components/editor/index.ts b/components/editor/index.ts
index 880992f..3359c63 100644
--- a/components/editor/index.ts
+++ b/components/editor/index.ts
@@ -1,2 +1,3 @@
export * from './editor-layout';
export * from './history-panel';
+export * from './file-menu';
diff --git a/components/modals/export-dialog.tsx b/components/modals/export-dialog.tsx
new file mode 100644
index 0000000..333674c
--- /dev/null
+++ b/components/modals/export-dialog.tsx
@@ -0,0 +1,134 @@
+'use client';
+
+import { useState } from 'react';
+import { X, Download } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface ExportDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onExport: (format: 'png' | 'jpeg' | 'webp', quality: number, filename: string) => void;
+ defaultFilename?: string;
+}
+
+export function ExportDialog({
+ isOpen,
+ onClose,
+ onExport,
+ defaultFilename = 'image',
+}: ExportDialogProps) {
+ const [format, setFormat] = useState<'png' | 'jpeg' | 'webp'>('png');
+ const [quality, setQuality] = useState(100);
+ const [filename, setFilename] = useState(defaultFilename);
+
+ if (!isOpen) return null;
+
+ const handleExport = () => {
+ onExport(format, quality / 100, filename);
+ onClose();
+ };
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
Export Image
+
+
+
+ {/* Content */}
+
+ {/* Filename */}
+
+
+ setFilename(e.target.value)}
+ className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground"
+ placeholder="Enter filename"
+ />
+
+
+ {/* Format */}
+
+
+
+ {(['png', 'jpeg', 'webp'] as const).map((fmt) => (
+
+ ))}
+
+
+
+ {/* Quality (for JPEG/WEBP) */}
+ {(format === 'jpeg' || format === 'webp') && (
+
+
+
+ {quality}%
+
+
setQuality(Number(e.target.value))}
+ className="w-full"
+ />
+
+ )}
+
+ {/* Info */}
+
+ {format === 'png' && '• PNG: Lossless, supports transparency'}
+ {format === 'jpeg' && '• JPEG: Lossy, smaller file size, no transparency'}
+ {format === 'webp' && '• WEBP: Modern format, smaller size, supports transparency'}
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+ );
+}
diff --git a/components/modals/index.ts b/components/modals/index.ts
new file mode 100644
index 0000000..7f08025
--- /dev/null
+++ b/components/modals/index.ts
@@ -0,0 +1,2 @@
+export * from './export-dialog';
+export * from './new-image-dialog';
diff --git a/components/modals/new-image-dialog.tsx b/components/modals/new-image-dialog.tsx
new file mode 100644
index 0000000..6596aaa
--- /dev/null
+++ b/components/modals/new-image-dialog.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import { useState } from 'react';
+import { X, Image } from 'lucide-react';
+
+interface NewImageDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onCreate: (width: number, height: number, backgroundColor: string) => void;
+}
+
+const PRESETS = [
+ { name: 'Custom', width: 800, height: 600 },
+ { name: '1920×1080 (Full HD)', width: 1920, height: 1080 },
+ { name: '1280×720 (HD)', width: 1280, height: 720 },
+ { name: '800×600', width: 800, height: 600 },
+ { name: '640×480', width: 640, height: 480 },
+];
+
+export function NewImageDialog({ isOpen, onClose, onCreate }: NewImageDialogProps) {
+ const [width, setWidth] = useState(800);
+ const [height, setHeight] = useState(600);
+ const [backgroundColor, setBackgroundColor] = useState('#ffffff');
+
+ if (!isOpen) return null;
+
+ const handleCreate = () => {
+ onCreate(width, height, backgroundColor);
+ onClose();
+ };
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
New Image
+
+
+
+ {/* Content */}
+
+ {/* Presets */}
+
+
+
+
+
+ {/* Dimensions */}
+
+
+ {/* Background Color */}
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+ );
+}
diff --git a/hooks/use-clipboard.ts b/hooks/use-clipboard.ts
new file mode 100644
index 0000000..c297a7c
--- /dev/null
+++ b/hooks/use-clipboard.ts
@@ -0,0 +1,29 @@
+import { useEffect } from 'react';
+
+export function useClipboard(onPaste: (dataTransfer: DataTransfer) => void) {
+ useEffect(() => {
+ const handlePaste = async (e: ClipboardEvent) => {
+ // Don't interfere with text input fields
+ const target = e.target as HTMLElement;
+ if (
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+
+ if (e.clipboardData) {
+ onPaste(e.clipboardData);
+ }
+ };
+
+ window.addEventListener('paste', handlePaste);
+
+ return () => {
+ window.removeEventListener('paste', handlePaste);
+ };
+ }, [onPaste]);
+}
diff --git a/hooks/use-drag-drop.ts b/hooks/use-drag-drop.ts
new file mode 100644
index 0000000..791b99a
--- /dev/null
+++ b/hooks/use-drag-drop.ts
@@ -0,0 +1,35 @@
+import { useCallback, useState } from 'react';
+
+export function useDragDrop(onDrop: (dataTransfer: DataTransfer) => void) {
+ const [isDragging, setIsDragging] = useState(false);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+ }, []);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ onDrop(e.dataTransfer);
+ },
+ [onDrop]
+ );
+
+ return {
+ isDragging,
+ handleDragOver,
+ handleDragLeave,
+ handleDrop,
+ };
+}
diff --git a/hooks/use-file-operations.ts b/hooks/use-file-operations.ts
new file mode 100644
index 0000000..00b2510
--- /dev/null
+++ b/hooks/use-file-operations.ts
@@ -0,0 +1,197 @@
+import { useCallback } from 'react';
+import { useCanvasStore, useLayerStore } from '@/store';
+import { useHistoryStore } from '@/store/history-store';
+import {
+ openImageFile,
+ exportCanvasAsImage,
+ exportProject,
+ loadProject,
+ createCanvasFromDataURL,
+ extractImageFromDataTransfer,
+ isImageFile,
+ isProjectFile,
+} from '@/lib/file-utils';
+import type { Layer } from '@/types';
+
+export function useFileOperations() {
+ const { width, height, setDimensions } = useCanvasStore();
+ const { layers, createLayer, clearLayers } = useLayerStore();
+ const { clearHistory } = useHistoryStore();
+
+ /**
+ * Create new image
+ */
+ const createNewImage = useCallback(
+ (newWidth: number, newHeight: number, backgroundColor: string) => {
+ clearLayers();
+ clearHistory();
+ setDimensions(newWidth, newHeight);
+
+ createLayer({
+ name: 'Background',
+ width: newWidth,
+ height: newHeight,
+ fillColor: backgroundColor,
+ });
+ },
+ [clearLayers, clearHistory, setDimensions, createLayer]
+ );
+
+ /**
+ * Open image file
+ */
+ const openImage = useCallback(
+ async (file: File) => {
+ try {
+ const img = await openImageFile(file);
+
+ // Create new image with loaded dimensions
+ clearLayers();
+ clearHistory();
+ setDimensions(img.width, img.height);
+
+ // Create layer with loaded image
+ const layer = createLayer({
+ name: file.name,
+ width: img.width,
+ height: img.height,
+ });
+
+ if (layer.canvas) {
+ const ctx = layer.canvas.getContext('2d');
+ if (ctx) {
+ ctx.drawImage(img, 0, 0);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to open image:', error);
+ alert('Failed to open image file');
+ }
+ },
+ [clearLayers, clearHistory, setDimensions, createLayer]
+ );
+
+ /**
+ * Open project file
+ */
+ const openProject = useCallback(
+ async (file: File) => {
+ try {
+ const projectData = await loadProject(file);
+
+ clearLayers();
+ clearHistory();
+ setDimensions(projectData.width, projectData.height);
+
+ // Recreate layers
+ for (const layerData of projectData.layers) {
+ const layer = createLayer({
+ name: layerData.name,
+ width: layerData.width,
+ height: layerData.height,
+ opacity: layerData.opacity,
+ blendMode: layerData.blendMode as any,
+ });
+
+ // Load image data
+ if (layerData.imageData && layer.canvas) {
+ const canvas = await createCanvasFromDataURL(
+ layerData.imageData,
+ layerData.width,
+ layerData.height
+ );
+ const ctx = layer.canvas.getContext('2d');
+ if (ctx) {
+ ctx.drawImage(canvas, 0, 0);
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Failed to open project:', error);
+ alert('Failed to open project file');
+ }
+ },
+ [clearLayers, clearHistory, setDimensions, createLayer]
+ );
+
+ /**
+ * Export current view as image
+ */
+ const exportImage = useCallback(
+ async (format: 'png' | 'jpeg' | 'webp', quality: number, filename: string) => {
+ // Create temporary canvas with all layers
+ const tempCanvas = document.createElement('canvas');
+ tempCanvas.width = width;
+ tempCanvas.height = height;
+ const ctx = tempCanvas.getContext('2d');
+
+ if (!ctx) return;
+
+ // Draw all visible layers
+ layers
+ .filter((layer) => layer.visible && layer.canvas)
+ .sort((a, b) => a.order - b.order)
+ .forEach((layer) => {
+ if (!layer.canvas) return;
+ ctx.globalAlpha = layer.opacity;
+ ctx.globalCompositeOperation = layer.blendMode as GlobalCompositeOperation;
+ ctx.drawImage(layer.canvas, layer.x, layer.y);
+ });
+
+ ctx.globalAlpha = 1;
+ ctx.globalCompositeOperation = 'source-over';
+
+ await exportCanvasAsImage(tempCanvas, format, quality, filename);
+ },
+ [layers, width, height]
+ );
+
+ /**
+ * Save project file
+ */
+ const saveProject = useCallback(
+ async (filename: string) => {
+ await exportProject(layers, width, height, filename);
+ },
+ [layers, width, height]
+ );
+
+ /**
+ * Handle file drop or paste
+ */
+ const handleFileInput = useCallback(
+ async (file: File) => {
+ if (isProjectFile(file)) {
+ await openProject(file);
+ } else if (isImageFile(file)) {
+ await openImage(file);
+ } else {
+ alert('Unsupported file type');
+ }
+ },
+ [openProject, openImage]
+ );
+
+ /**
+ * Handle data transfer (drag & drop or paste)
+ */
+ const handleDataTransfer = useCallback(
+ async (dataTransfer: DataTransfer) => {
+ const file = extractImageFromDataTransfer(dataTransfer);
+ if (file) {
+ await handleFileInput(file);
+ }
+ },
+ [handleFileInput]
+ );
+
+ return {
+ createNewImage,
+ openImage,
+ openProject,
+ exportImage,
+ saveProject,
+ handleFileInput,
+ handleDataTransfer,
+ };
+}
diff --git a/lib/file-utils.ts b/lib/file-utils.ts
new file mode 100644
index 0000000..54243b5
--- /dev/null
+++ b/lib/file-utils.ts
@@ -0,0 +1,204 @@
+import { saveAs } from 'file-saver';
+import type { Layer } from '@/types';
+import { loadImageFromFile } from './canvas-utils';
+
+/**
+ * Project file format
+ */
+export interface ProjectData {
+ version: string;
+ width: number;
+ height: number;
+ layers: {
+ id: string;
+ name: string;
+ visible: boolean;
+ opacity: number;
+ blendMode: string;
+ order: number;
+ locked: boolean;
+ width: number;
+ height: number;
+ x: number;
+ y: number;
+ imageData?: string; // Base64 encoded PNG
+ }[];
+ metadata: {
+ createdAt: number;
+ modifiedAt: number;
+ };
+}
+
+/**
+ * Open image file and return HTMLImageElement
+ */
+export async function openImageFile(file: File): Promise {
+ return loadImageFromFile(file);
+}
+
+/**
+ * Handle drag & drop or paste files
+ */
+export function extractImageFromDataTransfer(
+ dataTransfer: DataTransfer
+): File | null {
+ const items = Array.from(dataTransfer.items);
+ const files = Array.from(dataTransfer.files);
+
+ // Check for image in items first
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ const file = item.getAsFile();
+ if (file) return file;
+ }
+ }
+
+ // Check files
+ for (const file of files) {
+ if (file.type.startsWith('image/')) {
+ return file;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Export canvas as image file
+ */
+export async function exportCanvasAsImage(
+ canvas: HTMLCanvasElement,
+ format: 'png' | 'jpeg' | 'webp' = 'png',
+ quality = 1,
+ filename = 'image'
+): Promise {
+ const mimeType = `image/${format}`;
+ const extension = format === 'jpeg' ? 'jpg' : format;
+
+ const blob = await new Promise((resolve) => {
+ canvas.toBlob((blob) => resolve(blob), mimeType, quality);
+ });
+
+ if (blob) {
+ saveAs(blob, `${filename}.${extension}`);
+ }
+}
+
+/**
+ * Export project as JSON with layer data
+ */
+export async function exportProject(
+ layers: Layer[],
+ width: number,
+ height: number,
+ filename = 'project'
+): Promise {
+ const projectData: ProjectData = {
+ version: '1.0.0',
+ width,
+ height,
+ layers: await Promise.all(
+ layers.map(async (layer) => ({
+ id: layer.id,
+ name: layer.name,
+ visible: layer.visible,
+ opacity: layer.opacity,
+ blendMode: layer.blendMode,
+ order: layer.order,
+ locked: layer.locked,
+ width: layer.width,
+ height: layer.height,
+ x: layer.x,
+ y: layer.y,
+ imageData: layer.canvas
+ ? layer.canvas.toDataURL('image/png')
+ : undefined,
+ }))
+ ),
+ metadata: {
+ createdAt: Date.now(),
+ modifiedAt: Date.now(),
+ },
+ };
+
+ const json = JSON.stringify(projectData, null, 2);
+ const blob = new Blob([json], { type: 'application/json' });
+ saveAs(blob, `${filename}.paint`);
+}
+
+/**
+ * Load project from JSON file
+ */
+export async function loadProject(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+
+ reader.onload = (e) => {
+ try {
+ const json = e.target?.result as string;
+ const data: ProjectData = JSON.parse(json);
+ resolve(data);
+ } catch (error) {
+ reject(new Error('Invalid project file'));
+ }
+ };
+
+ reader.onerror = () => reject(new Error('Failed to read file'));
+ reader.readAsText(file);
+ });
+}
+
+/**
+ * Create canvas from base64 image data
+ */
+export async function createCanvasFromDataURL(
+ dataURL: string,
+ width: number,
+ height: number
+): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+
+ if (ctx) {
+ ctx.drawImage(img, 0, 0);
+ resolve(canvas);
+ } else {
+ reject(new Error('Failed to get 2D context'));
+ }
+ };
+
+ img.onerror = () => reject(new Error('Failed to load image'));
+ img.src = dataURL;
+ });
+}
+
+/**
+ * Get file extension from filename
+ */
+export function getFileExtension(filename: string): string {
+ const parts = filename.split('.');
+ return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
+}
+
+/**
+ * Validate file type
+ */
+export function isImageFile(file: File): boolean {
+ return file.type.startsWith('image/');
+}
+
+/**
+ * Validate project file
+ */
+export function isProjectFile(file: File): boolean {
+ return (
+ file.type === 'application/json' ||
+ getFileExtension(file.name) === 'paint'
+ );
+}