mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add editor layout components, welcome screen, and editor page
Create the four-zone editor layout shell (toolbar, options bar, status bar, right panel), welcome screen with drag-and-drop and new document dialog, and the main editor page with clipboard paste and URL loading.
This commit is contained in:
@@ -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<HTMLSelectElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-lg p-6 w-96">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-4">New Document</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="new-doc-preset" className="text-xs text-muted-foreground">
|
||||||
|
Preset
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="new-doc-preset"
|
||||||
|
value={preset}
|
||||||
|
onChange={handlePresetChange}
|
||||||
|
className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground"
|
||||||
|
>
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<option key={p.label} value={p.label}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="new-doc-width" className="text-xs text-muted-foreground">
|
||||||
|
Width (px)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-doc-width"
|
||||||
|
type="number"
|
||||||
|
value={width}
|
||||||
|
onChange={(e) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="new-doc-height" className="text-xs text-muted-foreground">
|
||||||
|
Height (px)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-doc-height"
|
||||||
|
type="number"
|
||||||
|
value={height}
|
||||||
|
onChange={(e) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">Background</span>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
{BACKGROUNDS.map((bg) => (
|
||||||
|
<button
|
||||||
|
key={bg}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBackground(bg)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-1.5 text-xs rounded border transition-colors",
|
||||||
|
background === bg
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-muted text-muted-foreground border-border hover:bg-muted/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bg}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="px-4 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
aria-label="Image drop zone"
|
||||||
|
className="absolute inset-0 flex items-center justify-center z-10"
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center gap-6 p-12 bg-card border-2 border-dashed rounded-xl max-w-md transition-colors ${
|
||||||
|
isDragOver ? "border-primary bg-primary/5" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-1">Image Editor</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Drop an image here to get started</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenFile}
|
||||||
|
className="flex items-center gap-3 w-full px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<ImagePlus size={20} />
|
||||||
|
<span className="text-sm font-medium">Open Image</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewDoc(true)}
|
||||||
|
className="flex items-center gap-3 w-full px-4 py-3 bg-muted text-foreground rounded-lg hover:bg-muted/80 transition-colors"
|
||||||
|
>
|
||||||
|
<FilePlus size={20} />
|
||||||
|
<span className="text-sm font-medium">New Document</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">Or paste from clipboard (Ctrl+V)</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NewDocumentDialog open={showNewDoc} onClose={() => setShowNewDoc(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center h-10 px-3 bg-card border-b border-border gap-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground capitalize">
|
||||||
|
{activeTool.replace(/-/g, " ").replace(/^shape /, "")}
|
||||||
|
</span>
|
||||||
|
<div className="h-4 w-px bg-border" />
|
||||||
|
{/* Tool-specific option components are rendered here by each agent */}
|
||||||
|
<div id="editor-options-content" className="flex items-center gap-2 flex-1" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePanel}
|
||||||
|
className="flex items-center justify-center w-6 bg-card border-l border-border"
|
||||||
|
aria-label="Expand panel"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} className="text-muted-foreground rotate-180" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-[280px] bg-card border-l border-border">
|
||||||
|
<div className="flex items-center border-b border-border">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2 text-xs font-medium text-center transition-colors",
|
||||||
|
activeTab === tab.id
|
||||||
|
? "text-foreground border-b-2 border-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
data-testid={`tab-${tab.id}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePanel}
|
||||||
|
className="px-1.5 py-2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Collapse panel"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{/* Tab content rendered by agents: layers-panel, adjustments-panel, history-panel */}
|
||||||
|
<div id={`editor-panel-${activeTab}`} />
|
||||||
|
</div>
|
||||||
|
{/* Color panel always visible at bottom (Agent 5) */}
|
||||||
|
<div id="editor-color-panel" className="border-t border-border" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center justify-between h-7 px-3 bg-card border-t border-border text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-3" data-testid="status-cursor">
|
||||||
|
{sourceImageUrl && (
|
||||||
|
<>
|
||||||
|
<span data-testid="status-cursor-x">X: {cursorPosition.x}</span>
|
||||||
|
<span data-testid="status-cursor-y">Y: {cursorPosition.y}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div data-testid="status-dimensions">
|
||||||
|
{sourceImageUrl && `${canvasSize.width} x ${canvasSize.height} px`}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1" data-testid="status-zoom">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={zoomPercent}
|
||||||
|
onChange={(e) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<span>%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col items-center w-12 bg-card border-r border-border py-2 gap-0.5 overflow-y-auto">
|
||||||
|
{TOOL_GROUPS.map((group, gi) => (
|
||||||
|
<div key={group.tools[0].tool}>
|
||||||
|
{gi > 0 && <div className="w-6 h-px bg-border mx-auto my-1" />}
|
||||||
|
{group.tools.map((t) => (
|
||||||
|
<IconButton
|
||||||
|
key={t.tool}
|
||||||
|
icon={t.icon}
|
||||||
|
label={t.label}
|
||||||
|
shortcut={t.shortcut}
|
||||||
|
active={activeTool === t.tool}
|
||||||
|
disabled={!sourceImageUrl && t.tool !== "hand" && t.tool !== "zoom"}
|
||||||
|
onClick={() => setTool(t.tool)}
|
||||||
|
data-testid={`tool-${t.tool}`}
|
||||||
|
data-tool={t.tool}
|
||||||
|
data-tool-active={String(activeTool === t.tool)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
<EditorOptionsBar />
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<EditorToolbar />
|
||||||
|
<div className="relative flex-1 overflow-hidden bg-muted/30">
|
||||||
|
<EditorCanvas />
|
||||||
|
{!sourceImageUrl && <WelcomeScreen />}
|
||||||
|
</div>
|
||||||
|
<EditorRightPanel />
|
||||||
|
</div>
|
||||||
|
<EditorStatusBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user