fix: restore missing text options and font loader, wire text tool options bar, fix e2e test selectors

This commit is contained in:
SnapOtter
2026-05-07 19:13:36 +08:00
parent 3cc4ec87d7
commit 8208acfb78
4 changed files with 1213 additions and 3 deletions
@@ -0,0 +1,81 @@
// apps/web/src/components/editor/common/font-loader.ts
const SYSTEM_FONTS = [
"Arial",
"Helvetica",
"Georgia",
"Times New Roman",
"Verdana",
"Courier New",
"Trebuchet MS",
"Impact",
"Comic Sans MS",
] as const;
const GOOGLE_FONTS = [
"Inter",
"Roboto",
"Open Sans",
"Lato",
"Montserrat",
"Poppins",
"Source Sans 3",
"Playfair Display",
"Merriweather",
"Raleway",
"Oswald",
"Nunito",
"Ubuntu",
"PT Sans",
"Fira Sans",
"Work Sans",
"Barlow",
"DM Sans",
"Space Grotesk",
"Bebas Neue",
"Caveat",
"Pacifico",
"Dancing Script",
"Permanent Marker",
"Press Start 2P",
] as const;
const loadedFonts = new Set<string>();
export function isSystemFont(name: string): boolean {
return (SYSTEM_FONTS as readonly string[]).includes(name);
}
export function getAllFonts(): { system: string[]; google: string[] } {
return {
system: [...SYSTEM_FONTS],
google: [...GOOGLE_FONTS],
};
}
export async function loadGoogleFont(name: string): Promise<void> {
if (isSystemFont(name) || loadedFonts.has(name)) return;
const slug = name.replace(/ /g, "+");
const url = `https://fonts.googleapis.com/css2?family=${slug}:wght@400;700&display=swap`;
// Add the stylesheet link so the browser fetches the font files
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = url;
document.head.appendChild(link);
// Use the CSS Font Loading API to detect when the font is actually ready
try {
await document.fonts.load(`16px "${name}"`);
loadedFonts.add(name);
} catch {
// Font may still load via the stylesheet even if the API rejects;
// mark as loaded so we don't retry endlessly.
loadedFonts.add(name);
}
}
export function isFontLoaded(name: string): boolean {
return isSystemFont(name) || loadedFonts.has(name);
}
@@ -12,6 +12,7 @@ import { MoveOptions } from "./options/move-options";
import { PixelBrushOptions } from "./options/pixel-brush-options";
import { SelectionOptions } from "./options/selection-options";
import { ShapeOptions } from "./options/shape-options";
import { TextOptions } from "./options/text-options";
function getOptionsComponent(tool: ToolType): React.ComponentType | null {
switch (tool) {
@@ -50,11 +51,12 @@ function getOptionsComponent(tool: ToolType): React.ComponentType | null {
case "shape-polygon":
case "shape-star":
return ShapeOptions;
case "text":
return TextOptions;
case "transform":
case "eyedropper":
case "hand":
case "zoom":
case "eyedropper":
case "text":
case "transform":
return null;
default:
return null;
@@ -0,0 +1,415 @@
// apps/web/src/components/editor/options/text-options.tsx
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Italic,
Strikethrough,
Type,
Underline,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { TextAttrs } from "@/types/editor";
import { getAllFonts, isSystemFont, loadGoogleFont } from "../common/font-loader";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getSelectedTextAttrs(): TextAttrs | null {
const { selectedObjectIds, objects } = useEditorStore.getState();
if (selectedObjectIds.length !== 1) return null;
const obj = objects.find((o) => o.id === selectedObjectIds[0] && o.type === "text");
return obj ? (obj.attrs as TextAttrs) : null;
}
function updateSelected(partial: Partial<TextAttrs>) {
const { selectedObjectIds } = useEditorStore.getState();
for (const id of selectedObjectIds) {
useEditorStore.getState().updateObject(id, partial);
}
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function ToggleButton({
active,
onClick,
label,
children,
}: {
active: boolean;
onClick: () => void;
label: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
aria-pressed={active}
onClick={onClick}
className={cn(
"flex items-center justify-center w-7 h-7 rounded transition-colors",
active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
)}
>
{children}
</button>
);
}
function NumberInput({
value,
min,
max,
step,
label,
onChange,
width = "w-16",
}: {
value: number;
min: number;
max: number;
step: number;
label: string;
onChange: (v: number) => void;
width?: string;
}) {
return (
<input
type="number"
aria-label={label}
title={label}
value={value}
min={min}
max={max}
step={step}
onChange={(e) => {
const n = Number.parseFloat(e.target.value);
if (!Number.isNaN(n)) onChange(Math.max(min, Math.min(max, n)));
}}
className={cn(
"h-7 rounded border border-border bg-background px-1.5 text-xs text-center",
"focus:outline-none focus:ring-1 focus:ring-ring",
width,
)}
/>
);
}
// ---------------------------------------------------------------------------
// Font Dropdown
// ---------------------------------------------------------------------------
function FontDropdown({ value, onChange }: { value: string; onChange: (name: string) => void }) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const fonts = useMemo(() => getAllFonts(), []);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const handleSelect = useCallback(
async (name: string) => {
if (!isSystemFont(name)) {
await loadGoogleFont(name);
}
onChange(name);
setOpen(false);
},
[onChange],
);
return (
<div ref={containerRef} className="relative">
<button
type="button"
aria-label="Font family"
onClick={() => setOpen((v) => !v)}
className={cn(
"flex items-center gap-1.5 h-7 px-2 rounded border border-border bg-background",
"text-xs hover:bg-muted truncate max-w-[160px]",
)}
style={{ fontFamily: value }}
>
<Type size={12} className="shrink-0 text-muted-foreground" />
<span className="truncate">{value}</span>
</button>
{open && (
<div
className={cn(
"absolute top-full left-0 mt-1 z-50 w-56 max-h-72 overflow-y-auto",
"rounded-md border border-border bg-popover shadow-md",
)}
>
{/* System fonts */}
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
System Fonts
</div>
{fonts.system.map((name) => (
<button
type="button"
key={name}
onClick={() => handleSelect(name)}
className={cn(
"w-full text-left px-3 py-1.5 text-sm hover:bg-muted transition-colors",
value === name && "bg-muted font-medium",
)}
style={{ fontFamily: name }}
>
{name}
</button>
))}
<div className="h-px bg-border my-1" />
{/* Google fonts */}
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
Google Fonts
</div>
{fonts.google.map((name) => (
<button
type="button"
key={name}
onClick={() => handleSelect(name)}
className={cn(
"w-full text-left px-3 py-1.5 text-sm hover:bg-muted transition-colors",
value === name && "bg-muted font-medium",
)}
style={{ fontFamily: name }}
>
{name}
</button>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
export function TextOptions() {
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const objects = useEditorStore((s) => s.objects);
// Derive current attrs from the selected text object
const attrs = useMemo(() => {
if (selectedObjectIds.length !== 1) return null;
const obj = objects.find((o) => o.id === selectedObjectIds[0] && o.type === "text");
return obj ? (obj.attrs as TextAttrs) : null;
}, [selectedObjectIds, objects]);
if (!attrs) return null;
const isBold = attrs.fontStyle.includes("bold");
const isItalic = attrs.fontStyle.includes("italic");
const hasUnderline = attrs.textDecoration.includes("underline");
const hasStrikethrough = attrs.textDecoration.includes("line-through");
const isAreaText = attrs.wrap !== undefined;
const toggleBold = () => {
const current = getSelectedTextAttrs();
if (!current) return;
const wasBold = current.fontStyle.includes("bold");
const parts = current.fontStyle.split(" ").filter((p) => p !== "bold" && p !== "");
if (!wasBold) parts.push("bold");
updateSelected({ fontStyle: parts.join(" ") || "normal" });
};
const toggleItalic = () => {
const current = getSelectedTextAttrs();
if (!current) return;
const wasItalic = current.fontStyle.includes("italic");
const parts = current.fontStyle.split(" ").filter((p) => p !== "italic" && p !== "");
if (!wasItalic) parts.push("italic");
updateSelected({ fontStyle: parts.join(" ") || "normal" });
};
const toggleUnderline = () => {
const current = getSelectedTextAttrs();
if (!current) return;
const had = current.textDecoration.includes("underline");
const parts = current.textDecoration.split(" ").filter((p) => p !== "underline" && p !== "");
if (!had) parts.push("underline");
updateSelected({ textDecoration: parts.join(" ") });
};
const toggleStrikethrough = () => {
const current = getSelectedTextAttrs();
if (!current) return;
const had = current.textDecoration.includes("line-through");
const parts = current.textDecoration.split(" ").filter((p) => p !== "line-through" && p !== "");
if (!had) parts.push("line-through");
updateSelected({ textDecoration: parts.join(" ") });
};
const toggleTextMode = () => {
const current = getSelectedTextAttrs();
if (!current) return;
if (current.wrap !== undefined) {
// Switch to point text: remove width/height/wrap
updateSelected({
width: undefined,
height: undefined,
wrap: undefined,
});
} else {
// Switch to area text
updateSelected({
width: 200,
height: 100,
wrap: "word",
});
}
};
return (
<div className="flex items-center gap-2 flex-wrap" data-testid="text-options">
{/* Font family */}
<FontDropdown
value={attrs.fontFamily}
onChange={(name) => updateSelected({ fontFamily: name })}
/>
{/* Font size */}
<NumberInput
value={attrs.fontSize}
min={1}
max={999}
step={1}
label="Font size"
onChange={(v) => updateSelected({ fontSize: v })}
width="w-14"
/>
<div className="h-4 w-px bg-border" />
{/* Bold / Italic / Underline / Strikethrough */}
<div className="flex items-center gap-0.5">
<ToggleButton active={isBold} onClick={toggleBold} label="Bold">
<Bold size={14} />
</ToggleButton>
<ToggleButton active={isItalic} onClick={toggleItalic} label="Italic">
<Italic size={14} />
</ToggleButton>
<ToggleButton active={hasUnderline} onClick={toggleUnderline} label="Underline">
<Underline size={14} />
</ToggleButton>
<ToggleButton active={hasStrikethrough} onClick={toggleStrikethrough} label="Strikethrough">
<Strikethrough size={14} />
</ToggleButton>
</div>
<div className="h-4 w-px bg-border" />
{/* Alignment */}
<div className="flex items-center gap-0.5">
<ToggleButton
active={attrs.align === "left"}
onClick={() => updateSelected({ align: "left" })}
label="Align left"
>
<AlignLeft size={14} />
</ToggleButton>
<ToggleButton
active={attrs.align === "center"}
onClick={() => updateSelected({ align: "center" })}
label="Align center"
>
<AlignCenter size={14} />
</ToggleButton>
<ToggleButton
active={attrs.align === "right"}
onClick={() => updateSelected({ align: "right" })}
label="Align right"
>
<AlignRight size={14} />
</ToggleButton>
</div>
<div className="h-4 w-px bg-border" />
{/* Line height */}
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground">LH</span>
<NumberInput
value={attrs.lineHeight}
min={0.5}
max={3.0}
step={0.1}
label="Line height"
onChange={(v) => updateSelected({ lineHeight: v })}
width="w-14"
/>
</div>
{/* Letter spacing */}
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground">LS</span>
<NumberInput
value={attrs.letterSpacing}
min={-5}
max={20}
step={0.5}
label="Letter spacing"
onChange={(v) => updateSelected({ letterSpacing: v })}
width="w-14"
/>
</div>
<div className="h-4 w-px bg-border" />
{/* Color swatch */}
<label className="relative flex items-center" title="Text color">
<div
className="w-6 h-6 rounded border border-border cursor-pointer"
style={{ backgroundColor: attrs.fill }}
/>
<input
type="color"
aria-label="Text color"
value={attrs.fill}
onChange={(e) => updateSelected({ fill: e.target.value })}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
/>
</label>
<div className="h-4 w-px bg-border" />
{/* Point / Area text toggle */}
<button
type="button"
title={isAreaText ? "Switch to Point Text" : "Switch to Area Text"}
aria-label={isAreaText ? "Switch to Point Text" : "Switch to Area Text"}
onClick={toggleTextMode}
className={cn(
"h-7 px-2 rounded border border-border text-xs transition-colors",
"hover:bg-muted",
)}
>
{isAreaText ? "Area" : "Point"}
</button>
</div>
);
}
@@ -0,0 +1,712 @@
import path from "node:path";
import { expect, type Page, test } from "@playwright/test";
const SCREENSHOT_DIR = path.join(__dirname, "screenshots");
let screenshotIndex = 0;
async function snap(page: Page, name: string) {
screenshotIndex++;
const filename = `${String(screenshotIndex).padStart(2, "0")}-${name}.png`;
await page.screenshot({ path: path.join(SCREENSHOT_DIR, filename) });
}
async function login(page: Page) {
await page.goto("/login");
await page.waitForTimeout(2000);
await page.fill('input[placeholder*="username" i]', "admin");
await page.fill('input[placeholder*="password" i]', "admin");
await page.click('button:has-text("Login")');
await page.waitForURL("**/", { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(2000);
}
async function createNewDocument(page: Page) {
const newDocBtn = page.getByText("New Document");
if (await newDocBtn.isVisible().catch(() => false)) {
await newDocBtn.click();
await page.waitForTimeout(500);
await page.locator('button:has-text("Create")').click();
await page.waitForTimeout(2000);
}
}
async function getCanvasBox(page: Page) {
const canvas = page.locator("canvas").first();
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas not found");
return box;
}
test.describe("Image Editor - Full GUI Test Suite", () => {
test.beforeEach(async ({ page }) => {
screenshotIndex = 0;
await login(page);
});
test("01 - Navigation & Layout", async ({ page }) => {
// Check sidebar has Editor item
await page.goto("/");
await page.waitForTimeout(2000);
const editorLink = page.locator('a:has-text("Editor")');
await expect(editorLink).toBeVisible();
await snap(page, "sidebar-with-editor");
// Navigate to editor
await editorLink.click();
await page.waitForTimeout(2000);
await expect(page).toHaveURL(/\/editor/);
await snap(page, "editor-welcome-screen");
// Verify four-zone layout elements
const toolbar = page.locator('[data-tool="move"]');
await expect(toolbar).toBeVisible();
const layersTab = page.locator('[data-testid="tab-layers"]');
await expect(layersTab).toBeVisible();
const adjustmentsTab = page.locator('[data-testid="tab-adjustments"]');
await expect(adjustmentsTab).toBeVisible();
const historyTab = page.locator('[data-testid="tab-history"]');
await expect(historyTab).toBeVisible();
});
test("02 - Welcome Screen & New Document", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
// Welcome screen visible
await expect(page.getByText("Image Editor")).toBeVisible();
await expect(page.getByText("Open Image")).toBeVisible();
await expect(page.getByText("New Document")).toBeVisible();
await snap(page, "welcome-screen");
// Open new document dialog
await page.getByText("New Document").click();
await page.waitForTimeout(500);
await expect(page.getByText("Preset")).toBeVisible();
await snap(page, "new-doc-dialog");
// Change preset to Instagram
await page.locator("select").first().selectOption("1080x1080 (Instagram)");
await page.waitForTimeout(300);
await snap(page, "new-doc-instagram-preset");
// Select transparent background
await page.getByText("Transparent").click();
await page.waitForTimeout(300);
await snap(page, "new-doc-transparent");
// Create the document
await page.locator('button:has-text("Create")').click();
await page.waitForTimeout(2000);
// Canvas should be visible with checkerboard (transparent)
const canvas = page.locator("canvas").first();
await expect(canvas).toBeVisible();
await snap(page, "canvas-after-create");
});
test("03 - Brush Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Activate brush via toolbar
await page.locator('[data-tool="brush"]').click();
await page.waitForTimeout(300);
await expect(page.locator('[data-tool-active="true"]')).toHaveAttribute("data-tool", "brush");
await snap(page, "brush-tool-active");
// Verify options bar shows size/opacity/hardness
await expect(page.getByText("Size", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Opacity", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Hardness", { exact: true }).first()).toBeVisible();
// Draw a brush stroke
const box = await getCanvasBox(page);
const before = await page.locator("canvas").first().screenshot();
await page.mouse.move(box.x + 100, box.y + 200);
await page.mouse.down();
await page.mouse.move(box.x + 500, box.y + 300, { steps: 20 });
await page.mouse.up();
await page.waitForTimeout(500);
const after = await page.locator("canvas").first().screenshot();
expect(Buffer.compare(before, after)).not.toBe(0);
await snap(page, "brush-stroke-drawn");
// Change brush size via [ and ] keys
await page.keyboard.press("]");
await page.keyboard.press("]");
await page.keyboard.press("]");
await page.waitForTimeout(300);
await snap(page, "brush-size-increased");
});
test("04 - Eraser Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Draw something first with brush
await page.locator('[data-tool="brush"]').click();
await page.waitForTimeout(300);
const box = await getCanvasBox(page);
await page.mouse.move(box.x + 200, box.y + 200);
await page.mouse.down();
await page.mouse.move(box.x + 500, box.y + 200, { steps: 15 });
await page.mouse.up();
await page.waitForTimeout(500);
// Activate eraser via E key
await page.keyboard.press("e");
await page.waitForTimeout(300);
await expect(page.locator('[data-tool-active="true"]')).toHaveAttribute("data-tool", "eraser");
await snap(page, "eraser-tool-active");
// Erase over the stroke
await page.mouse.move(box.x + 300, box.y + 190);
await page.mouse.down();
await page.mouse.move(box.x + 400, box.y + 210, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
await snap(page, "eraser-stroke");
});
test("05 - Shape Tools", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Rectangle
await page.locator('[data-tool="shape-rect"]').click();
await page.waitForTimeout(300);
await snap(page, "shape-rect-options");
await page.mouse.move(box.x + 100, box.y + 100);
await page.mouse.down();
await page.mouse.move(box.x + 300, box.y + 250, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
await snap(page, "shape-rect-drawn");
// Switch to ellipse via shape dropdown if available
const shapeDropdown = page.locator("select").first();
if (await shapeDropdown.isVisible().catch(() => false)) {
await shapeDropdown.selectOption("Ellipse");
await page.waitForTimeout(300);
}
// Draw another shape
await page.mouse.move(box.x + 400, box.y + 100);
await page.mouse.down();
await page.mouse.move(box.x + 600, box.y + 250, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
await snap(page, "shape-ellipse-drawn");
// Select and move with move tool
await page.locator('[data-tool="move"]').click();
await page.waitForTimeout(300);
await page.mouse.click(box.x + 200, box.y + 175);
await page.waitForTimeout(500);
await snap(page, "shape-selected-with-handles");
});
test("06 - Text Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Activate text tool via T key
await page.keyboard.press("t");
await page.waitForTimeout(300);
await expect(page.locator('[data-tool-active="true"]')).toHaveAttribute("data-tool", "text");
await snap(page, "text-tool-active");
// Click on canvas to place text
await page.mouse.click(box.x + 200, box.y + 200);
await page.waitForTimeout(1000);
// Type some text (textarea should be active)
await page.keyboard.type("Hello SnapOtter!");
await page.waitForTimeout(500);
await snap(page, "text-typing");
// Click away to finalize
await page.mouse.click(box.x + 50, box.y + 50);
await page.waitForTimeout(500);
await snap(page, "text-finalized");
});
test("07 - Crop Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Activate crop via C key
await page.keyboard.press("c");
await page.waitForTimeout(500);
await expect(page.locator('[data-tool-active="true"]')).toHaveAttribute("data-tool", "crop");
await snap(page, "crop-tool-active");
// Verify crop options show aspect ratio
const cropOptions = page.getByText(/Free|1:1|4:3|16:9/);
await snap(page, "crop-options-bar");
});
test("08 - Layer System", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Layers tab should be active by default
await expect(page.locator('[data-testid="tab-layers"]')).toBeVisible();
await page.locator('[data-testid="tab-layers"]').click();
await page.waitForTimeout(300);
// Should see Layer 1
await expect(page.getByText("Layer 1")).toBeVisible();
await snap(page, "layers-panel-default");
// Add a new layer via + button
const addLayerBtn = page
.locator("button")
.filter({ has: page.locator("svg") })
.locator("xpath=//button[contains(@class, 'items-center')]")
.first();
// Try clicking the + icon at bottom of layers panel
const plusButtons = page
.locator('[data-testid="tab-layers"]')
.locator("..")
.locator("..")
.locator("button");
await snap(page, "layers-before-add");
// Use keyboard shortcut to add layer
await page.keyboard.press("Control+Shift+n");
await page.waitForTimeout(500);
await snap(page, "layers-after-add");
// Check blend mode dropdown
const blendDropdown = page.locator("select").filter({ hasText: /Normal/ });
if (await blendDropdown.isVisible().catch(() => false)) {
await blendDropdown.click();
await page.waitForTimeout(300);
await snap(page, "blend-mode-dropdown");
}
// Toggle layer visibility
const eyeIcons = page.locator(
'[aria-label*="visibility" i], [aria-label*="toggle" i], [title*="visibility" i]',
);
if (
await eyeIcons
.first()
.isVisible()
.catch(() => false)
) {
await eyeIcons.first().click();
await page.waitForTimeout(300);
await snap(page, "layer-visibility-toggled");
}
});
test("09 - Color System", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Foreground/background swatches should be visible
await expect(page.getByText("Foreground")).toBeVisible();
await snap(page, "color-panel-default");
// Click foreground swatch to open picker
const fgSwatch = page.locator('[data-testid="color-foreground"]').or(
page
.locator("button")
.filter({ hasText: /Foreground/ })
.locator("..")
.locator("button")
.first(),
);
// Try clicking the color swatch area
const colorArea = page.getByText("Foreground").locator("..").locator("button").first();
if (await colorArea.isVisible().catch(() => false)) {
await colorArea.click();
await page.waitForTimeout(500);
await snap(page, "color-picker-open");
}
// Test D key resets to black/white
await page.keyboard.press("d");
await page.waitForTimeout(300);
await snap(page, "colors-reset-to-default");
// Test X key swaps colors
await page.keyboard.press("x");
await page.waitForTimeout(300);
await snap(page, "colors-swapped");
});
test("10 - Adjustments Panel", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Switch to Adjustments tab
await page.locator('[data-testid="tab-adjustments"]').click();
await page.waitForTimeout(500);
await snap(page, "adjustments-panel");
// Verify adjustment sliders present
await expect(page.getByText("Brightness", { exact: true })).toBeVisible();
await expect(page.getByText("Contrast", { exact: true })).toBeVisible();
await expect(page.getByText("Saturation", { exact: true })).toBeVisible();
await snap(page, "adjustments-sliders");
// Verify auto buttons
await expect(page.getByText("Auto Tone")).toBeVisible();
await expect(page.getByText("Auto Contrast")).toBeVisible();
await snap(page, "auto-buttons");
// Scroll to see levels section
const panel = page
.locator('[data-testid="tab-adjustments"]')
.locator("..")
.locator("..")
.locator("div.overflow-y-auto");
if (await panel.isVisible().catch(() => false)) {
await panel.evaluate((el) => (el.scrollTop = 400));
await page.waitForTimeout(500);
await snap(page, "levels-section");
await panel.evaluate((el) => (el.scrollTop = 800));
await page.waitForTimeout(500);
await snap(page, "curves-section");
await panel.evaluate((el) => (el.scrollTop = 1200));
await page.waitForTimeout(500);
await snap(page, "filters-section");
}
});
test("11 - History & Undo/Redo", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Draw a shape
await page.locator('[data-tool="shape-rect"]').click();
await page.waitForTimeout(300);
await page.mouse.move(box.x + 100, box.y + 100);
await page.mouse.down();
await page.mouse.move(box.x + 300, box.y + 250, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
// Draw a brush stroke
await page.keyboard.press("b");
await page.waitForTimeout(300);
await page.mouse.move(box.x + 400, box.y + 150);
await page.mouse.down();
await page.mouse.move(box.x + 600, box.y + 300, { steps: 15 });
await page.mouse.up();
await page.waitForTimeout(500);
// Open history panel
await page.locator('[data-testid="tab-history"]').click();
await page.waitForTimeout(500);
await snap(page, "history-with-actions");
// Verify history entries exist
const historyEntries = page
.locator('[data-testid="tab-history"]')
.locator("..")
.locator("..")
.locator("div.overflow-y-auto");
await snap(page, "history-panel-entries");
// Undo with Ctrl+Z
const beforeUndo = await page.locator("canvas").first().screenshot();
await page.keyboard.press("Control+z");
await page.waitForTimeout(500);
const afterUndo = await page.locator("canvas").first().screenshot();
expect(Buffer.compare(beforeUndo, afterUndo)).not.toBe(0);
await snap(page, "after-undo");
// Redo with Ctrl+Shift+Z
await page.keyboard.press("Control+Shift+z");
await page.waitForTimeout(500);
await snap(page, "after-redo");
});
test("12 - Keyboard Shortcuts", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const toolShortcuts: [string, string][] = [
["v", "move"],
["b", "brush"],
["e", "eraser"],
["u", "shape-rect"],
["t", "text"],
["c", "crop"],
["i", "eyedropper"],
["h", "hand"],
["z", "zoom"],
["m", "marquee-rect"],
["g", "fill"],
];
for (const [key, expectedTool] of toolShortcuts) {
await page.keyboard.press(key);
await page.waitForTimeout(200);
const activeTool = page.locator('[data-tool-active="true"]');
const toolAttr = await activeTool.getAttribute("data-tool");
// Just verify the active tool changed (some tools may have different sub-types)
expect(toolAttr).toBeTruthy();
}
await snap(page, "keyboard-shortcuts-tested");
// Test D resets colors
await page.keyboard.press("d");
await page.waitForTimeout(200);
// Test X swaps
await page.keyboard.press("x");
await page.waitForTimeout(200);
await snap(page, "color-shortcuts-tested");
});
test("13 - Right Panel Toggle", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Panel should be visible
await expect(page.locator('[data-testid="tab-layers"]')).toBeVisible();
await snap(page, "panel-visible");
// Press Tab to toggle panel
await page.keyboard.press("Tab");
await page.waitForTimeout(500);
await snap(page, "panel-collapsed");
// Press Tab again to restore
await page.keyboard.press("Tab");
await page.waitForTimeout(500);
await snap(page, "panel-restored");
});
test("14 - Multiple Shapes & Move", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Draw 3 shapes
await page.locator('[data-tool="shape-rect"]').click();
await page.waitForTimeout(300);
// Shape 1
await page.mouse.move(box.x + 50, box.y + 50);
await page.mouse.down();
await page.mouse.move(box.x + 200, box.y + 150, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
// Shape 2
await page.mouse.move(box.x + 250, box.y + 50);
await page.mouse.down();
await page.mouse.move(box.x + 400, box.y + 150, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
// Shape 3
await page.mouse.move(box.x + 450, box.y + 50);
await page.mouse.down();
await page.mouse.move(box.x + 600, box.y + 150, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300);
await snap(page, "three-shapes-drawn");
// Switch to move tool and select shape
await page.keyboard.press("v");
await page.waitForTimeout(300);
await page.mouse.click(box.x + 125, box.y + 100);
await page.waitForTimeout(500);
await snap(page, "shape-selected");
// Drag it
await page.mouse.move(box.x + 125, box.y + 100);
await page.mouse.down();
await page.mouse.move(box.x + 125, box.y + 300, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
await snap(page, "shape-moved");
// Delete with Delete key
await page.mouse.click(box.x + 325, box.y + 100);
await page.waitForTimeout(300);
await page.keyboard.press("Delete");
await page.waitForTimeout(500);
await snap(page, "shape-deleted");
});
test("15 - Export Dialog", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Draw something
await page.locator('[data-tool="shape-rect"]').click();
await page.waitForTimeout(300);
await page.mouse.move(box.x + 200, box.y + 150);
await page.mouse.down();
await page.mouse.move(box.x + 500, box.y + 350, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
// Open export dialog via Ctrl+Shift+S
await page.keyboard.press("Control+Shift+s");
await page.waitForTimeout(1000);
await snap(page, "export-dialog");
// Check format options
const pngBtn = page.getByText("PNG");
const jpegBtn = page.getByText("JPEG");
const webpBtn = page.getByText("WebP");
if (await pngBtn.isVisible().catch(() => false)) {
await expect(pngBtn).toBeVisible();
await expect(jpegBtn).toBeVisible();
await expect(webpBtn).toBeVisible();
// Select JPEG to see quality slider
await jpegBtn.click();
await page.waitForTimeout(300);
await snap(page, "export-jpeg-quality");
}
});
test("16 - Clone Stamp Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Activate clone stamp via S key
await page.keyboard.press("s");
await page.waitForTimeout(300);
await expect(page.locator('[data-tool-active="true"]')).toHaveAttribute(
"data-tool",
"clone-stamp",
);
await snap(page, "clone-stamp-active");
// Verify options bar
await expect(page.getByText("Size")).toBeVisible();
});
test("17 - Dodge/Burn/Sponge Tool", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Activate dodge via O key
await page.keyboard.press("o");
await page.waitForTimeout(300);
await snap(page, "dodge-tool-active");
// Check options show range and exposure
const optionsBar = page
.locator("div")
.filter({ hasText: /Range|Exposure|Dodge|Burn|Sponge/ })
.first();
await snap(page, "dodge-burn-options");
});
test("18 - Selection Tools", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
const box = await getCanvasBox(page);
// Marquee selection via M key
await page.keyboard.press("m");
await page.waitForTimeout(300);
await snap(page, "marquee-tool-active");
// Draw a selection
await page.mouse.move(box.x + 100, box.y + 100);
await page.mouse.down();
await page.mouse.move(box.x + 400, box.y + 300, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(500);
await snap(page, "marquee-selection-drawn");
// Lasso via L key
await page.keyboard.press("l");
await page.waitForTimeout(300);
await snap(page, "lasso-tool-active");
// Magic wand via W key
await page.keyboard.press("w");
await page.waitForTimeout(300);
await snap(page, "magic-wand-active");
});
test("19 - Gradient & Fill Tools", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Fill tool via G key
await page.keyboard.press("g");
await page.waitForTimeout(300);
await snap(page, "fill-tool-active");
// Check tolerance option
await expect(page.getByText("Tolerance")).toBeVisible();
await snap(page, "fill-options");
});
test("20 - Navigator Panel", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Navigator should show minimap
await page.waitForTimeout(1000);
await snap(page, "navigator-minimap");
});
test("21 - Status Bar", async ({ page }) => {
await page.goto("/editor");
await page.waitForTimeout(2000);
await createNewDocument(page);
// Verify zoom percentage
const zoomInput = page.locator('[data-testid="status-zoom"] input');
if (await zoomInput.isVisible().catch(() => false)) {
const zoomValue = await zoomInput.inputValue();
expect(Number(zoomValue)).toBeGreaterThan(0);
}
// Verify dimensions
const dimensions = page.locator('[data-testid="status-dimensions"]');
if (await dimensions.isVisible().catch(() => false)) {
const text = await dimensions.textContent();
expect(text).toContain("1920");
expect(text).toContain("1080");
}
await snap(page, "status-bar");
});
});