diff --git a/apps/web/src/components/editor/common/new-document-dialog.tsx b/apps/web/src/components/editor/common/new-document-dialog.tsx new file mode 100644 index 00000000..7e429def --- /dev/null +++ b/apps/web/src/components/editor/common/new-document-dialog.tsx @@ -0,0 +1,164 @@ +// apps/web/src/components/editor/common/new-document-dialog.tsx +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +const PRESETS = [ + { label: "Custom", width: 1920, height: 1080 }, + { label: "1920x1080 (HD)", width: 1920, height: 1080 }, + { label: "3840x2160 (4K)", width: 3840, height: 2160 }, + { label: "1080x1080 (Instagram)", width: 1080, height: 1080 }, + { label: "1200x628 (Facebook)", width: 1200, height: 628 }, + { label: "800x600", width: 800, height: 600 }, + { label: "1280x720", width: 1280, height: 720 }, +]; + +const BACKGROUNDS = ["White", "Black", "Transparent"] as const; + +interface NewDocumentDialogProps { + open: boolean; + onClose: () => void; +} + +export function NewDocumentDialog({ open, onClose }: NewDocumentDialogProps) { + const [width, setWidth] = useState(1920); + const [height, setHeight] = useState(1080); + const [preset, setPreset] = useState("1920x1080 (HD)"); + const [background, setBackground] = useState<(typeof BACKGROUNDS)[number]>("White"); + const loadImage = useEditorStore((s) => s.loadImage); + + if (!open) return null; + + const handlePresetChange = (e: React.ChangeEvent) => { + const selected = PRESETS.find((p) => p.label === e.target.value); + if (selected) { + setPreset(selected.label); + if (selected.label !== "Custom") { + setWidth(selected.width); + setHeight(selected.height); + } + } + }; + + const handleCreate = () => { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (ctx) { + if (background === "White") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + } else if (background === "Black") { + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, width, height); + } + } + const url = canvas.toDataURL("image/png"); + loadImage(url, width, height); + onClose(); + }; + + return ( +
+
+

New Document

+ +
+
+ + +
+ +
+
+ + { + setWidth(Number(e.target.value)); + setPreset("Custom"); + }} + className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground" + min={1} + max={10000} + /> +
+
+ + { + setHeight(Number(e.target.value)); + setPreset("Custom"); + }} + className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground" + min={1} + max={10000} + /> +
+
+ +
+ Background +
+ {BACKGROUNDS.map((bg) => ( + + ))} +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/welcome-screen.tsx b/apps/web/src/components/editor/common/welcome-screen.tsx new file mode 100644 index 00000000..2c7ffe68 --- /dev/null +++ b/apps/web/src/components/editor/common/welcome-screen.tsx @@ -0,0 +1,104 @@ +// apps/web/src/components/editor/common/welcome-screen.tsx + +import { FilePlus, ImagePlus } from "lucide-react"; +import { useCallback, useState } from "react"; +import { useEditorStore } from "@/stores/editor-store"; +import { NewDocumentDialog } from "./new-document-dialog"; + +const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg"; + +export function WelcomeScreen() { + const [showNewDoc, setShowNewDoc] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const loadImage = useEditorStore((s) => s.loadImage); + + const handleFile = useCallback( + (file: File) => { + if (!file.type.startsWith("image/")) { + return; + } + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + loadImage(url, img.naturalWidth, img.naturalHeight); + }; + img.src = url; + }, + [loadImage], + ); + + const handleOpenFile = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ACCEPTED_TYPES; + input.onchange = () => { + const file = input.files?.[0]; + if (file) handleFile(file); + }; + input.click(); + }; + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }, + [handleFile], + ); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = () => setIsDragOver(false); + + return ( + <> +
+
+
+

Image Editor

+

Drop an image here to get started

+
+ +
+ + + +
+ +

Or paste from clipboard (Ctrl+V)

+
+
+ + setShowNewDoc(false)} /> + + ); +} diff --git a/apps/web/src/components/editor/editor-options-bar.tsx b/apps/web/src/components/editor/editor-options-bar.tsx new file mode 100644 index 00000000..f02288ff --- /dev/null +++ b/apps/web/src/components/editor/editor-options-bar.tsx @@ -0,0 +1,17 @@ +// apps/web/src/components/editor/editor-options-bar.tsx +import { useEditorStore } from "@/stores/editor-store"; + +export function EditorOptionsBar() { + const activeTool = useEditorStore((s) => s.activeTool); + + return ( +
+ + {activeTool.replace(/-/g, " ").replace(/^shape /, "")} + +
+ {/* Tool-specific option components are rendered here by each agent */} +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-right-panel.tsx b/apps/web/src/components/editor/editor-right-panel.tsx new file mode 100644 index 00000000..db63ace7 --- /dev/null +++ b/apps/web/src/components/editor/editor-right-panel.tsx @@ -0,0 +1,67 @@ +// apps/web/src/components/editor/editor-right-panel.tsx +import { ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; + +const TABS = [ + { id: "layers" as const, label: "Layers" }, + { id: "adjustments" as const, label: "Adjustments" }, + { id: "history" as const, label: "History" }, +]; + +export function EditorRightPanel() { + const visible = useEditorStore((s) => s.rightPanelVisible); + const activeTab = useEditorStore((s) => s.rightPanelTab); + const setTab = useEditorStore((s) => s.setRightPanelTab); + const togglePanel = useEditorStore((s) => s.toggleRightPanel); + + if (!visible) { + return ( + + ); + } + + return ( +
+
+ {TABS.map((tab) => ( + + ))} + +
+
+ {/* Tab content rendered by agents: layers-panel, adjustments-panel, history-panel */} +
+
+ {/* Color panel always visible at bottom (Agent 5) */} +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-status-bar.tsx b/apps/web/src/components/editor/editor-status-bar.tsx new file mode 100644 index 00000000..0dae20af --- /dev/null +++ b/apps/web/src/components/editor/editor-status-bar.tsx @@ -0,0 +1,42 @@ +// apps/web/src/components/editor/editor-status-bar.tsx +import { useEditorStore } from "@/stores/editor-store"; + +export function EditorStatusBar() { + const cursorPosition = useEditorStore((s) => s.cursorPosition); + const canvasSize = useEditorStore((s) => s.canvasSize); + const zoom = useEditorStore((s) => s.zoom); + const setZoom = useEditorStore((s) => s.setZoom); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + const zoomPercent = Math.round(zoom * 100); + + return ( +
+
+ {sourceImageUrl && ( + <> + X: {cursorPosition.x} + Y: {cursorPosition.y} + + )} +
+
+ {sourceImageUrl && `${canvasSize.width} x ${canvasSize.height} px`} +
+
+ { + const val = Number.parseInt(e.target.value, 10); + if (!Number.isNaN(val) && val > 0) setZoom(val / 100); + }} + className="w-14 bg-transparent text-right text-xs border-none outline-none" + min={1} + max={6400} + /> + % +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-toolbar.tsx b/apps/web/src/components/editor/editor-toolbar.tsx new file mode 100644 index 00000000..e128edec --- /dev/null +++ b/apps/web/src/components/editor/editor-toolbar.tsx @@ -0,0 +1,183 @@ +// apps/web/src/components/editor/editor-toolbar.tsx +import { + ArrowUpRight, + Crop, + Eraser, + Hand, + MousePointer2, + Move, + PaintBucket, + Paintbrush, + Pen, + Pencil, + Pipette, + ScanLine, + Square, + Stamp, + Sun, + Type, + Wand2, + ZoomIn, +} from "lucide-react"; +import { useEditorStore } from "@/stores/editor-store"; +import type { ToolType } from "@/types/editor"; +import { IconButton } from "./common/icon-button"; + +interface ToolGroup { + tools: { + tool: ToolType; + icon: typeof MousePointer2; + label: string; + shortcut: string; + }[]; +} + +const TOOL_GROUPS: ToolGroup[] = [ + { + // Group 1: Move + Free Transform + tools: [ + { tool: "move", icon: MousePointer2, label: "Move", shortcut: "V" }, + { + tool: "transform", + icon: Move, + label: "Free Transform", + shortcut: "Ctrl+T", + }, + ], + }, + { + // Group 2: Selection tools + tools: [ + { + tool: "marquee-rect", + icon: Square, + label: "Marquee", + shortcut: "M", + }, + { tool: "lasso-free", icon: Pen, label: "Lasso", shortcut: "L" }, + { + tool: "magic-wand", + icon: Wand2, + label: "Magic Wand", + shortcut: "W", + }, + ], + }, + { + // Group 3: Crop + tools: [{ tool: "crop", icon: Crop, label: "Crop", shortcut: "C" }], + }, + { + // Group 4: Eyedropper + tools: [ + { + tool: "eyedropper", + icon: Pipette, + label: "Eyedropper", + shortcut: "I", + }, + ], + }, + { + // Group 5: Brush, Eraser, Pencil + tools: [ + { tool: "brush", icon: Paintbrush, label: "Brush", shortcut: "B" }, + { tool: "eraser", icon: Eraser, label: "Eraser", shortcut: "E" }, + { tool: "pencil", icon: Pencil, label: "Pencil", shortcut: "N" }, + ], + }, + { + // Group 6: Clone Stamp + tools: [ + { + tool: "clone-stamp", + icon: Stamp, + label: "Clone Stamp", + shortcut: "S", + }, + ], + }, + { + // Group 7: Dodge, Burn, Sponge + tools: [ + { tool: "dodge", icon: Sun, label: "Dodge", shortcut: "O" }, + { tool: "burn", icon: Sun, label: "Burn", shortcut: "Shift+O" }, + { tool: "sponge", icon: Sun, label: "Sponge", shortcut: "Shift+O" }, + ], + }, + { + // Group 8: Blur brush, Sharpen brush, Smudge + tools: [ + { tool: "blur-brush", icon: ScanLine, label: "Blur Brush", shortcut: "" }, + { + tool: "sharpen-brush", + icon: ScanLine, + label: "Sharpen Brush", + shortcut: "", + }, + { tool: "smudge", icon: ScanLine, label: "Smudge", shortcut: "" }, + ], + }, + { + // Group 9: Paint Bucket, Gradient + tools: [ + { + tool: "fill", + icon: PaintBucket, + label: "Paint Bucket", + shortcut: "G", + }, + { + tool: "gradient", + icon: ArrowUpRight, + label: "Gradient", + shortcut: "Shift+G", + }, + ], + }, + { + // Group 10: Shapes + tools: [{ tool: "shape-rect", icon: Square, label: "Shape", shortcut: "U" }], + }, + { + // Group 11: Text + tools: [{ tool: "text", icon: Type, label: "Text", shortcut: "T" }], + }, + { + // Group 12: Hand, Zoom + tools: [ + { tool: "hand", icon: Hand, label: "Hand", shortcut: "H" }, + { tool: "zoom", icon: ZoomIn, label: "Zoom", shortcut: "Z" }, + ], + }, +]; + +export function EditorToolbar() { + const activeTool = useEditorStore((s) => s.activeTool); + const setTool = useEditorStore((s) => s.setTool); + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + + return ( +
+ {TOOL_GROUPS.map((group, gi) => ( +
+ {gi > 0 &&
} + {group.tools.map((t) => ( + setTool(t.tool)} + data-testid={`tool-${t.tool}`} + data-tool={t.tool} + data-tool-active={String(activeTool === t.tool)} + /> + ))} +
+ ))} +
+ ); +} diff --git a/apps/web/src/pages/editor-page.tsx b/apps/web/src/pages/editor-page.tsx new file mode 100644 index 00000000..afeb9534 --- /dev/null +++ b/apps/web/src/pages/editor-page.tsx @@ -0,0 +1,76 @@ +// apps/web/src/pages/editor-page.tsx +import { useCallback, useEffect } from "react"; +import { WelcomeScreen } from "@/components/editor/common/welcome-screen"; +import { EditorCanvas } from "@/components/editor/editor-canvas"; +import { EditorOptionsBar } from "@/components/editor/editor-options-bar"; +import { EditorRightPanel } from "@/components/editor/editor-right-panel"; +import { EditorStatusBar } from "@/components/editor/editor-status-bar"; +import { EditorToolbar } from "@/components/editor/editor-toolbar"; +import { useEditorStore } from "@/stores/editor-store"; + +export function EditorPage() { + const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); + const isDirty = useEditorStore((s) => s.isDirty); + const loadImage = useEditorStore((s) => s.loadImage); + + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (isDirty) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + + const handlePaste = useCallback( + (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith("image/")) { + e.preventDefault(); + const blob = item.getAsFile(); + if (!blob) return; + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight); + img.src = url; + return; + } + } + }, + [loadImage], + ); + + useEffect(() => { + document.addEventListener("paste", handlePaste); + return () => document.removeEventListener("paste", handlePaste); + }, [handlePaste]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const url = params.get("url"); + if (url) { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight); + img.src = url; + } + }, [loadImage]); + + return ( +
+ +
+ +
+ + {!sourceImageUrl && } +
+ +
+ +
+ ); +}