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:
SnapOtter
2026-06-17 14:21:35 +08:00
parent 81e16d7ce6
commit 3120e6708d
17 changed files with 206 additions and 99 deletions
@@ -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]);
+7 -1
View File
@@ -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>
);
})}
+13
View File
@@ -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({
+15
View File
@@ -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;