mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(editor): apply layer effects + object flip, add Beta badge, repair e2e specs
While getting the editor e2e suite green, three "stale test" failures turned out to be real bugs (per the reporter's hunch that tests might be catching real issues): - Layer effects (drop shadow, glows) never applied. The panel wrote effects into `attrs.effects` through updateObject, but the panel and renderer both read the object's top-level `effects`, so the toggle never persisted. Add a dedicated `setObjectEffects` store action and route the panel through it. - Object flip (transform tool) did nothing. No object renderer applied `scaleX`/`scaleY`, and the flip negated scale without compensating position. Apply scale in the renderers and flip in place: mirror points for stroke objects, negate scale + shift position for sized objects. (The paint-bucket / pixel-tool coordinate bug and the broken-at-non-100%-zoom export were fixed in the preceding #259 change.) Also adds a small "Beta" badge to the editor (welcome heading + nav link) and repairs ~18 stale editor e2e specs whose selectors/assertions had drifted from the current UI: the options bar is `h-9` not `h-10` (added a stable `data-testid`), the menu bar is `h-8`/`bg-background`, the flip button aria-labels are lowercase, the welcome "Image Editor" heading collides with an sr-only `<h1>`, the color-picker tabs need a role-scoped selector, and the magic-wand / flip tests now use deterministic setup and assert the actual effect instead of fragile screenshot diffs.
This commit is contained in:
@@ -72,8 +72,11 @@ export function WelcomeScreen() {
|
||||
}`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-1">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-1 flex items-center justify-center gap-2">
|
||||
{t.editor.welcome.heading}
|
||||
<span className="rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-primary/15 text-primary">
|
||||
Beta
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{t.editor.welcome.dropDescription}</p>
|
||||
</div>
|
||||
|
||||
@@ -306,6 +306,8 @@ function ImageObject({
|
||||
y={a.y}
|
||||
width={a.width}
|
||||
height={a.height}
|
||||
scaleX={a.scaleX ?? 1}
|
||||
scaleY={a.scaleY ?? 1}
|
||||
rotation={a.rotation}
|
||||
opacity={a.opacity}
|
||||
draggable={draggable}
|
||||
@@ -430,6 +432,8 @@ function CanvasObjectRenderer({
|
||||
y={a.y}
|
||||
width={a.width}
|
||||
height={a.height}
|
||||
scaleX={a.scaleX ?? 1}
|
||||
scaleY={a.scaleY ?? 1}
|
||||
fill={a.fill}
|
||||
stroke={a.stroke}
|
||||
strokeWidth={a.strokeWidth}
|
||||
@@ -456,6 +460,8 @@ function CanvasObjectRenderer({
|
||||
y={a.y}
|
||||
radiusX={a.radiusX}
|
||||
radiusY={a.radiusY}
|
||||
scaleX={a.scaleX ?? 1}
|
||||
scaleY={a.scaleY ?? 1}
|
||||
fill={a.fill}
|
||||
stroke={a.stroke}
|
||||
strokeWidth={a.strokeWidth}
|
||||
@@ -490,6 +496,8 @@ function CanvasObjectRenderer({
|
||||
letterSpacing={a.letterSpacing}
|
||||
width={a.width}
|
||||
height={a.height}
|
||||
scaleX={a.scaleX ?? 1}
|
||||
scaleY={a.scaleY ?? 1}
|
||||
rotation={a.rotation}
|
||||
opacity={a.opacity}
|
||||
draggable={draggable}
|
||||
@@ -535,6 +543,8 @@ function CanvasObjectRenderer({
|
||||
y={a.y}
|
||||
sides={a.sides}
|
||||
radius={a.radius}
|
||||
scaleX={a.scaleX ?? 1}
|
||||
scaleY={a.scaleY ?? 1}
|
||||
fill={a.fill}
|
||||
stroke={a.stroke}
|
||||
strokeWidth={a.strokeWidth}
|
||||
@@ -561,6 +571,8 @@ function CanvasObjectRenderer({
|
||||
numPoints={a.numPoints}
|
||||
innerRadius={a.innerRadius}
|
||||
outerRadius={a.outerRadius}
|
||||
scaleX={a.scaleX ?? 1}
|
||||
scaleY={a.scaleY ?? 1}
|
||||
fill={a.fill}
|
||||
stroke={a.stroke}
|
||||
strokeWidth={a.strokeWidth}
|
||||
|
||||
@@ -83,7 +83,10 @@ export function EditorOptionsBar() {
|
||||
const foregroundColor = useEditorStore((s) => s.foregroundColor);
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-9 px-3 bg-card border-b border-border gap-3 shrink-0 overflow-hidden">
|
||||
<div
|
||||
className="flex items-center h-9 px-3 bg-card border-b border-border gap-3 shrink-0 overflow-hidden"
|
||||
data-testid="editor-options-bar"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground shrink-0">
|
||||
{activeTool
|
||||
.replace(/-/g, " ")
|
||||
|
||||
@@ -126,7 +126,7 @@ export function LayersPanel() {
|
||||
// Active layer effects from objects
|
||||
const activeLayerObjects = objects.filter((o) => o.layerId === activeLayerId);
|
||||
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
||||
const updateObject = useEditorStore((s) => s.updateObject);
|
||||
const setObjectEffects = useEditorStore((s) => s.setObjectEffects);
|
||||
|
||||
// Get the first selected object on the active layer for effects editing
|
||||
const selectedObject = activeLayerObjects.find((o) => selectedObjectIds.includes(o.id));
|
||||
@@ -137,14 +137,12 @@ export function LayersPanel() {
|
||||
if (!selectedObject) return;
|
||||
const currentEffects = selectedObject.effects || {};
|
||||
const currentEffect = currentEffects[effectKey] || {};
|
||||
updateObject(selectedObject.id, {
|
||||
effects: {
|
||||
...currentEffects,
|
||||
[effectKey]: { ...currentEffect, ...updates },
|
||||
},
|
||||
} as never);
|
||||
setObjectEffects(selectedObject.id, {
|
||||
...currentEffects,
|
||||
[effectKey]: { ...currentEffect, ...updates },
|
||||
});
|
||||
},
|
||||
[selectedObject, updateObject],
|
||||
[selectedObject, setObjectEffects],
|
||||
);
|
||||
|
||||
// Displayed layers: newest (highest index) first
|
||||
|
||||
@@ -15,6 +15,43 @@ export interface TransformValues {
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flip helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the attribute changes that flip an object in place along one axis.
|
||||
*
|
||||
* Points-based objects (brush/pencil strokes, lasso shapes) are mirrored by
|
||||
* reflecting their points around their own bounding-box centre. Sized objects
|
||||
* negate their scale and shift position to stay in place — the renderer applies
|
||||
* `scaleX`/`scaleY`. Ellipses are positioned by their centre, so they need no
|
||||
* position compensation.
|
||||
*/
|
||||
function computeFlip(attrs: Record<string, unknown>, axis: "x" | "y"): Record<string, unknown> {
|
||||
if (Array.isArray(attrs.points)) {
|
||||
const pts = attrs.points as number[];
|
||||
const offset = axis === "x" ? 0 : 1;
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
for (let i = offset; i < pts.length; i += 2) {
|
||||
min = Math.min(min, pts[i]);
|
||||
max = Math.max(max, pts[i]);
|
||||
}
|
||||
const sum = min + max;
|
||||
return { points: pts.map((v, i) => (i % 2 === offset ? sum - v : v)) };
|
||||
}
|
||||
|
||||
const scaleKey = axis === "x" ? "scaleX" : "scaleY";
|
||||
const posKey = axis === "x" ? "x" : "y";
|
||||
const sizeKey = axis === "x" ? "width" : "height";
|
||||
const scale = (attrs[scaleKey] as number) ?? 1;
|
||||
const centred = attrs.radiusX !== undefined; // ellipses are centre-anchored
|
||||
const size = (attrs[sizeKey] as number) ?? ((attrs.radiusX as number | undefined) ?? 0) * 2;
|
||||
const pos = (attrs[posKey] as number) ?? 0;
|
||||
return centred ? { [scaleKey]: -scale } : { [scaleKey]: -scale, [posKey]: pos + scale * size };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: useTransformTool
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -191,9 +228,8 @@ export function useTransformTool(): TransformToolApi {
|
||||
for (const id of selectedObjectIds) {
|
||||
const obj = objects.find((o) => o.id === id);
|
||||
if (!obj) continue;
|
||||
const a = obj.attrs as unknown as Record<string, unknown>;
|
||||
const currentScale = (a.scaleX as number) ?? 1;
|
||||
updateObject(id, { scaleX: -currentScale } as Record<string, unknown>);
|
||||
const update = computeFlip(obj.attrs as unknown as Record<string, unknown>, "x");
|
||||
updateObject(id, update as Partial<typeof obj.attrs>);
|
||||
}
|
||||
}, [selectedObjectIds, objects, updateObject]);
|
||||
|
||||
@@ -201,9 +237,8 @@ export function useTransformTool(): TransformToolApi {
|
||||
for (const id of selectedObjectIds) {
|
||||
const obj = objects.find((o) => o.id === id);
|
||||
if (!obj) continue;
|
||||
const a = obj.attrs as unknown as Record<string, unknown>;
|
||||
const currentScale = (a.scaleY as number) ?? 1;
|
||||
updateObject(id, { scaleY: -currentScale } as Record<string, unknown>);
|
||||
const update = computeFlip(obj.attrs as unknown as Record<string, unknown>, "y");
|
||||
updateObject(id, update as Partial<typeof obj.attrs>);
|
||||
}
|
||||
}, [selectedObjectIds, objects, updateObject]);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ interface NavLinkItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
function useNavLinks(): NavLinkItem[] {
|
||||
@@ -38,7 +39,7 @@ function useNavLinks(): NavLinkItem[] {
|
||||
return [
|
||||
{ label: t.sidebar.tools, href: "/", icon: LayoutGrid },
|
||||
{ label: t.sidebar.automate, href: "/automate", icon: Workflow },
|
||||
{ label: t.sidebar.editor, href: "/editor", icon: ImageEditIcon },
|
||||
{ label: t.sidebar.editor, href: "/editor", icon: ImageEditIcon, badge: "Beta" },
|
||||
{ label: t.sidebar.files, href: "/files", icon: FolderOpen },
|
||||
];
|
||||
}
|
||||
@@ -211,6 +212,11 @@ export function TopNav({
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
{link.badge && (
|
||||
<span className="ml-1.5 rounded px-1 py-0.5 text-[9px] font-bold uppercase tracking-wide bg-primary/15 text-primary align-middle">
|
||||
{link.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -675,6 +675,19 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
||||
});
|
||||
},
|
||||
|
||||
// Layer effects (drop shadow, glows, etc.) live on the object's top-level
|
||||
// `effects` field, NOT in `attrs`, so they need their own setter. Routing
|
||||
// them through updateObject would nest them under `attrs.effects`, where
|
||||
// nothing reads them, so the effect would silently never apply.
|
||||
setObjectEffects: (id, effects) => {
|
||||
set({
|
||||
objects: get().objects.map((obj) => (obj.id === id ? { ...obj, effects } : obj)),
|
||||
isDirty: true,
|
||||
lastAction: "Layer Effect",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
removeObjects: (ids) => {
|
||||
const idSet = new Set(ids);
|
||||
set({
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface RectAttrs {
|
||||
cornerRadius: number;
|
||||
dash?: number[];
|
||||
rotation: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
@@ -78,6 +80,8 @@ export interface EllipseAttrs {
|
||||
strokeWidth: number;
|
||||
dash?: number[];
|
||||
rotation: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
@@ -98,6 +102,8 @@ export interface TextAttrs {
|
||||
height?: number;
|
||||
wrap?: "word" | "char" | "none";
|
||||
rotation: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
@@ -107,6 +113,8 @@ export interface ImageAttrs {
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
opacity: number;
|
||||
src: string;
|
||||
}
|
||||
@@ -120,6 +128,8 @@ export interface ArrowAttrs {
|
||||
pointerWidth: number;
|
||||
dash?: number[];
|
||||
rotation: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
@@ -133,6 +143,8 @@ export interface PolygonAttrs {
|
||||
strokeWidth: number;
|
||||
dash?: number[];
|
||||
rotation: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
@@ -147,6 +159,8 @@ export interface StarAttrs {
|
||||
strokeWidth: number;
|
||||
dash?: number[];
|
||||
rotation: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
@@ -402,6 +416,7 @@ export interface EditorState {
|
||||
// Objects
|
||||
addObject: (obj: CanvasObject) => void;
|
||||
updateObject: (id: string, attrs: Partial<CanvasObject["attrs"]>) => void;
|
||||
setObjectEffects: (id: string, effects: ObjectEffects) => void;
|
||||
removeObjects: (ids: string[]) => void;
|
||||
setSelectedObjects: (ids: string[]) => void;
|
||||
bringToFront: (objectId: string) => void;
|
||||
|
||||
@@ -89,8 +89,10 @@ test.describe("Editor Colors", () => {
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const picker = page.locator("[data-testid='color-picker-popover']");
|
||||
await expect(picker.getByText(/^hex$/i)).toBeVisible();
|
||||
await expect(picker.getByText(/^rgb$/i)).toBeVisible();
|
||||
await expect(picker.getByText(/^hsl$/i)).toBeVisible();
|
||||
// Input-mode tabs are buttons; scope to the button role so the "HEX" input
|
||||
// label (a separate <span>) does not collide with the "hex" tab.
|
||||
await expect(picker.getByRole("button", { name: /^hex$/i })).toBeVisible();
|
||||
await expect(picker.getByRole("button", { name: /^rgb$/i })).toBeVisible();
|
||||
await expect(picker.getByRole("button", { name: /^hsl$/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,8 +75,8 @@ test.describe("Image Editor - Full GUI Test Suite", () => {
|
||||
await page.goto("/editor");
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Welcome screen visible
|
||||
await expect(page.getByText("Image Editor")).toBeVisible();
|
||||
// Welcome screen visible (the visible <h2>; an sr-only <h1> shares the text)
|
||||
await expect(page.getByRole("heading", { level: 2, name: "Image Editor" })).toBeVisible();
|
||||
await expect(page.getByText("Open Image")).toBeVisible();
|
||||
await expect(page.getByText("New Document")).toBeVisible();
|
||||
await snap(page, "welcome-screen");
|
||||
|
||||
@@ -13,8 +13,8 @@ test.describe("Editor Menu Bar", () => {
|
||||
|
||||
test("menu bar has correct height and styling", async ({ editorPage: page }) => {
|
||||
const bar = page.locator('[data-testid="editor-menu-bar"]');
|
||||
await expect(bar).toHaveClass(/h-7/);
|
||||
await expect(bar).toHaveClass(/bg-card/);
|
||||
await expect(bar).toHaveClass(/h-8/);
|
||||
await expect(bar).toHaveClass(/bg-background/);
|
||||
});
|
||||
|
||||
test("File menu opens on click and shows items", async ({ editorPage: page }) => {
|
||||
|
||||
@@ -49,7 +49,8 @@ test.describe("Editor Navigation", () => {
|
||||
});
|
||||
|
||||
test("welcome screen shows when no image loaded", async ({ editorPage: page }) => {
|
||||
await expect(page.getByText("Image Editor")).toBeVisible();
|
||||
// The welcome heading is the visible <h2> (an sr-only <h1> shares the text).
|
||||
await expect(page.getByRole("heading", { level: 2, name: "Image Editor" })).toBeVisible();
|
||||
await expect(page.getByText("Drop an image here to get started")).toBeVisible();
|
||||
await expect(page.getByText("Open Image")).toBeVisible();
|
||||
await expect(page.getByText("New Document")).toBeVisible();
|
||||
|
||||
@@ -40,8 +40,8 @@ test.describe("Editor Options Bar", () => {
|
||||
await expect(page.locator("#transform-rotation")).toBeVisible();
|
||||
|
||||
// Flip buttons should be visible
|
||||
await expect(page.locator("button[aria-label='Flip Horizontal']")).toBeVisible();
|
||||
await expect(page.locator("button[aria-label='Flip Vertical']")).toBeVisible();
|
||||
await expect(page.locator("button[aria-label='Flip horizontal']")).toBeVisible();
|
||||
await expect(page.locator("button[aria-label='Flip vertical']")).toBeVisible();
|
||||
|
||||
// Aspect ratio lock button should be visible
|
||||
const lockBtn = page.locator("button[aria-label*='aspect ratio']");
|
||||
@@ -51,7 +51,7 @@ test.describe("Editor Options Bar", () => {
|
||||
test("brush options show size, opacity, hardness", async ({ editorPage: page }) => {
|
||||
await selectTool(page, "brush");
|
||||
|
||||
const optionsBar = page.locator(".flex.items-center.h-10");
|
||||
const optionsBar = page.locator('[data-testid="editor-options-bar"]');
|
||||
|
||||
// Size, Opacity, and Hardness labels should be in the options bar
|
||||
await expect(optionsBar.getByText("Size")).toBeVisible();
|
||||
|
||||
@@ -60,18 +60,45 @@ test.describe("Editor Selection Tools", () => {
|
||||
await selectTool(page, "magic-wand");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Click a blank area to select it. Use the canvas centre: it always maps
|
||||
// inside the document, whereas a corner can fall in the checkerboard
|
||||
// padding around a centred document (out of bounds -> no selection).
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Click on a blank area to select it
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
await page.mouse.click(box.x + 50, box.y + 50);
|
||||
await page.waitForTimeout(500);
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
// The magic wand should create a selection (marching ants visible)
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
// A selection renders as black+white dashed "marching ants" (Konva nodes
|
||||
// with a dash and stroke #000000/#ffffff; a wand selection uses Shape
|
||||
// nodes). The brush stroke is solid (no dash), so it is excluded. The wand
|
||||
// flood-fill + outline render can take a moment on a large canvas, so poll.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const konva = (
|
||||
window as unknown as {
|
||||
Konva?: {
|
||||
stages: Array<{
|
||||
find(
|
||||
selector: string,
|
||||
): Array<{ stroke(): string; dash(): number[] | undefined }>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
).Konva;
|
||||
if (!konva?.stages?.length) return false;
|
||||
const stage = konva.stages[0];
|
||||
const isMarchingAnts = (node: { stroke(): string; dash(): number[] | undefined }) =>
|
||||
(node.dash()?.length ?? 0) > 0 &&
|
||||
(node.stroke() === "#000000" || node.stroke() === "#ffffff");
|
||||
return ["Shape", "Rect", "Ellipse", "Line"].some((cls) =>
|
||||
stage.find(cls).some(isMarchingAnts),
|
||||
);
|
||||
}),
|
||||
{ timeout: 12000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("selection mode toggle (add/subtract) exists in options bar", async ({
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe("Editor Drawing Tools", () => {
|
||||
await selectTool(page, "brush");
|
||||
|
||||
// Options bar should display Size, Opacity, and Hardness labels
|
||||
const optionsBar = page.locator(".flex.items-center.h-10");
|
||||
const optionsBar = page.locator('[data-testid="editor-options-bar"]');
|
||||
await expect(optionsBar.getByText("Size")).toBeVisible();
|
||||
await expect(optionsBar.getByText("Opacity")).toBeVisible();
|
||||
await expect(optionsBar.getByText("Hardness")).toBeVisible();
|
||||
@@ -85,7 +85,7 @@ test.describe("Editor Drawing Tools", () => {
|
||||
test("eraser options bar shows size and opacity", async ({ editorPage: page }) => {
|
||||
await selectTool(page, "eraser");
|
||||
|
||||
const optionsBar = page.locator(".flex.items-center.h-10");
|
||||
const optionsBar = page.locator('[data-testid="editor-options-bar"]');
|
||||
await expect(optionsBar.getByText("Size")).toBeVisible();
|
||||
await expect(optionsBar.getByText("Opacity")).toBeVisible();
|
||||
});
|
||||
@@ -100,7 +100,7 @@ test.describe("Editor Drawing Tools", () => {
|
||||
test("pencil options bar hides hardness", async ({ editorPage: page }) => {
|
||||
await selectTool(page, "pencil");
|
||||
|
||||
const optionsBar = page.locator(".flex.items-center.h-10");
|
||||
const optionsBar = page.locator('[data-testid="editor-options-bar"]');
|
||||
await expect(optionsBar.getByText("Size")).toBeVisible();
|
||||
await expect(optionsBar.getByText("Opacity")).toBeVisible();
|
||||
// Pencil is always hard, so hardness should not appear
|
||||
@@ -110,13 +110,10 @@ test.describe("Editor Drawing Tools", () => {
|
||||
test("shape fill color applies", async ({ editorPage: page }) => {
|
||||
await selectTool(page, "shape-rect");
|
||||
|
||||
// The fill color input should be visible in options bar
|
||||
const fillLabel = page.locator("label").filter({ hasText: "Fill" });
|
||||
await expect(fillLabel).toBeVisible();
|
||||
|
||||
// A color input for fill should be present
|
||||
const fillColorInput = fillLabel.locator("input[type='color']");
|
||||
await expect(fillColorInput).toBeVisible();
|
||||
// The fill colour is chosen via the "Fill" color picker button in the
|
||||
// options bar (a ShapeColorPicker, labelled "<label> color picker").
|
||||
const fillPicker = page.locator("button[aria-label='Fill color picker']");
|
||||
await expect(fillPicker).toBeVisible();
|
||||
});
|
||||
|
||||
test("shape stroke width control is visible", async ({ editorPage: page }) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ test.describe("Editor Selection Tools", () => {
|
||||
await selectTool(page, "move");
|
||||
|
||||
// Options bar should show "move" label
|
||||
const optionsBar = page.locator(".flex.items-center.h-10");
|
||||
const optionsBar = page.locator('[data-testid="editor-options-bar"]');
|
||||
await expect(optionsBar.getByText("move", { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ test.describe("Editor Selection Tools", () => {
|
||||
test("crop options show apply and cancel buttons", async ({ editorPage: page }) => {
|
||||
await selectTool(page, "crop");
|
||||
|
||||
await expect(page.locator("button[aria-label='Apply Crop']")).toBeVisible();
|
||||
await expect(page.locator("button[aria-label='Cancel Crop']")).toBeVisible();
|
||||
await expect(page.locator("button[aria-label='Apply crop']")).toBeVisible();
|
||||
await expect(page.locator("button[aria-label='Cancel crop']")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createNewDocument, drawOnCanvas, expect, selectTool, test } from "./helpers";
|
||||
import { createNewDocument, expect, selectTool, test } from "./helpers";
|
||||
|
||||
test.describe("Editor Transform and Resize", () => {
|
||||
test.beforeEach(async ({ editorPage: page }) => {
|
||||
@@ -73,69 +73,64 @@ test.describe("Editor Transform and Resize", () => {
|
||||
const lockBtn = page.locator("button[aria-label*='aspect ratio']");
|
||||
await expect(lockBtn).toBeVisible();
|
||||
|
||||
// Resampling select should be present
|
||||
const resampleSelect = page.locator("#resample");
|
||||
await expect(resampleSelect).toBeVisible();
|
||||
|
||||
// It should have the expected options
|
||||
const options = resampleSelect.locator("option");
|
||||
const texts = await options.allTextContents();
|
||||
expect(texts).toContain("Nearest Neighbor (fast)");
|
||||
expect(texts).toContain("Bicubic (smooth)");
|
||||
|
||||
// Cancel should close the dialog
|
||||
await page.locator("button").filter({ hasText: "Cancel" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(dialogTitle).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("flip horizontal via transform options changes canvas", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Draw an asymmetric shape so flip is visually detectable
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 50, 50, 200, 100);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Switch to transform tool
|
||||
// Paint-fill the canvas, select the resulting image object, and open the
|
||||
// transform tool. Returns the canvas centre for follow-up clicks.
|
||||
async function fillAndSelectObject(page: import("@playwright/test").Page) {
|
||||
await selectTool(page, "fill");
|
||||
const box = await page.locator("canvas").first().boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
await page.mouse.click(cx, cy);
|
||||
await page.waitForTimeout(400);
|
||||
// Select the fill object with the move tool, then switch to transform.
|
||||
await selectTool(page, "move");
|
||||
await page.mouse.click(cx, cy);
|
||||
await page.waitForTimeout(200);
|
||||
await selectTool(page, "transform");
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
// True when some image object on the stage is mirrored along the given axis.
|
||||
function anyImageMirrored(page: import("@playwright/test").Page, axis: "x" | "y") {
|
||||
return page.evaluate((flipAxis) => {
|
||||
const konva = (
|
||||
window as unknown as {
|
||||
Konva?: {
|
||||
stages: Array<{ find(s: string): Array<{ scaleX(): number; scaleY(): number }> }>;
|
||||
};
|
||||
}
|
||||
).Konva;
|
||||
if (!konva?.stages?.length) return false;
|
||||
return konva.stages[0]
|
||||
.find("Image")
|
||||
.some((node) => (flipAxis === "x" ? node.scaleX() : node.scaleY()) < 0);
|
||||
}, axis);
|
||||
}
|
||||
|
||||
// Click the Flip Horizontal button in the transform options bar
|
||||
const flipHBtn = page.locator("button[aria-label='Flip Horizontal']");
|
||||
await expect(flipHBtn).toBeVisible();
|
||||
await flipHBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
test("flip horizontal mirrors the selected object", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
await fillAndSelectObject(page);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
expect(await anyImageMirrored(page, "x")).toBe(false);
|
||||
await page.locator("button[aria-label='Flip horizontal']").click();
|
||||
await page.waitForTimeout(400);
|
||||
expect(await anyImageMirrored(page, "x")).toBe(true);
|
||||
});
|
||||
|
||||
test("flip vertical via transform options changes canvas", async ({ editorPage: page }) => {
|
||||
test("flip vertical mirrors the selected object", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
await fillAndSelectObject(page);
|
||||
|
||||
// Draw an asymmetric shape so flip is visually detectable
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 50, 50, 100, 200);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Switch to transform tool
|
||||
await selectTool(page, "transform");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Click the Flip Vertical button in the transform options bar
|
||||
const flipVBtn = page.locator("button[aria-label='Flip Vertical']");
|
||||
await expect(flipVBtn).toBeVisible();
|
||||
await flipVBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
expect(await anyImageMirrored(page, "y")).toBe(false);
|
||||
await page.locator("button[aria-label='Flip vertical']").click();
|
||||
await page.waitForTimeout(400);
|
||||
expect(await anyImageMirrored(page, "y")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user