fix: 8 image editor bugs found during Docker-based E2E testing

- fix WebP export silently producing PNG when background is non-transparent
- fix autosave not converting blob: URLs inside image-type canvas objects
- fix project load not resetting selection/crop/clipboard state
- fix rotateCanvas not updating object rotation attributes
- fix flipCanvas not negating object rotation attributes
- fix line shadow props overridden by effect spread ordering
- fix "outside" stroke position rendering same as "center"
- add missing pencil tool keyboard shortcut (N)
- remove misleading resample dropdown from image resize dialog
- fix E2E autosave tests for production builds (no Vite dynamic imports)
- fix color picker test case sensitivity (CSS uppercase vs DOM text)
- add 4 unit tests for rotation attribute transforms
This commit is contained in:
SnapOtter
2026-05-08 20:27:06 +08:00
parent 6c56950f39
commit a1a71e507f
8 changed files with 219 additions and 61 deletions
@@ -154,8 +154,8 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
ctx.drawImage(stageCanvas, 0, 0);
dataUrl = exportCanvas.toDataURL(
`image/${settings.format === "jpeg" ? "jpeg" : "png"}`,
settings.format === "jpeg" ? settings.quality / 100 : undefined,
getMimeType(settings.format),
settings.format === "png" ? undefined : settings.quality / 100,
);
} else {
dataUrl = stage.toDataURL({
@@ -272,6 +272,10 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
sourceImageSize: data.sourceImageSize || null,
foregroundColor: data.foregroundColor || "#000000",
backgroundColor: data.backgroundColor || "#ffffff",
selection: null,
cropState: null,
selectedObjectIds: [],
clipboard: [],
isDirty: false,
lastAction: "Load Project",
_historyVersion: store._historyVersion + 1,
@@ -569,13 +573,23 @@ export async function saveEditorState(): Promise<void> {
// Convert blob URLs to data URLs so they survive localStorage round-trip
const sourceImageUrl = s.sourceImageUrl ? await blobUrlToDataUrl(s.sourceImageUrl) : null;
// Also convert blob URLs inside image-type canvas objects
const objects = await Promise.all(
s.objects.map(async (obj) => {
if (obj.type === "image" && obj.attrs.src?.startsWith("blob:")) {
return { ...obj, attrs: { ...obj.attrs, src: await blobUrlToDataUrl(obj.attrs.src) } };
}
return obj;
}),
);
const data: AutosaveData = {
version: 1,
timestamp: Date.now(),
state: {
canvasSize: s.canvasSize,
layers: s.layers,
objects: s.objects,
objects,
adjustments: s.adjustments,
filters: s.filters,
guides: s.guides,
@@ -3,19 +3,6 @@ import { useCallback, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
type ResampleMethod = "nearest" | "bilinear" | "bicubic" | "lanczos";
// ---------------------------------------------------------------------------
// ImageResizeDialog -- modal with W/H, aspect lock, resampling method
// ---------------------------------------------------------------------------
const RESAMPLE_METHODS: { value: ResampleMethod; label: string }[] = [
{ value: "nearest", label: "Nearest Neighbor (fast)" },
{ value: "bilinear", label: "Bilinear" },
{ value: "bicubic", label: "Bicubic (smooth)" },
{ value: "lanczos", label: "Lanczos (sharp)" },
];
export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const canvasSize = useEditorStore((s) => s.canvasSize);
const resizeImage = useEditorStore((s) => s.resizeImage);
@@ -23,7 +10,6 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: (
const [width, setWidth] = useState(canvasSize.width);
const [height, setHeight] = useState(canvasSize.height);
const [lockAspect, setLockAspect] = useState(true);
const [resample, setResample] = useState<ResampleMethod>("bicubic");
const aspectRatio = canvasSize.width / canvasSize.height;
@@ -58,9 +44,9 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: (
);
const handleApply = useCallback(() => {
resizeImage(width, height, resample);
resizeImage(width, height);
onClose();
}, [width, height, resample, resizeImage, onClose]);
}, [width, height, resizeImage, onClose]);
const pctWidth = canvasSize.width > 0 ? ((width / canvasSize.width) * 100).toFixed(1) : "100.0";
const pctHeight =
@@ -147,27 +133,10 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: (
</div>
</div>
{/* Resampling method */}
<div>
<label htmlFor="resample" className="mb-1 block text-xs text-muted-foreground">
Resampling:
</label>
<select
id="resample"
value={resample}
onChange={(e) => setResample(e.target.value as ResampleMethod)}
className={cn(
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
"focus:border-primary focus:outline-none",
)}
>
{RESAMPLE_METHODS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
{/* Info note */}
<p className="text-[10px] text-muted-foreground">
Scales all objects proportionally to the new dimensions.
</p>
</div>
{/* Footer */}
@@ -354,15 +354,16 @@ function computeEffectProps(effects?: ObjectEffects): Record<string, unknown> {
props.shadowEnabled = true;
}
// Stroke effect (outer or center position -- Konva draws strokes centered by default)
// Stroke effect -- Konva draws strokes centered by default
if (effects.stroke?.enabled) {
const s = effects.stroke;
props.stroke = s.color;
props.strokeWidth = s.position === "inside" ? s.width * 2 : s.width;
props.strokeEnabled = true;
// For "inside" strokes, we double the width and clip via strokeScaleEnabled
if (s.position === "inside") {
if (s.position === "inside" || s.position === "outside") {
props.strokeWidth = s.width * 2;
props.strokeScaleEnabled = false;
} else {
props.strokeWidth = s.width;
}
}
@@ -410,11 +411,11 @@ function CanvasObjectRenderer({
globalCompositeOperation={
a.globalCompositeOperation as "source-over" | "destination-out" | undefined
}
shadowBlur={a.shadowBlur}
shadowColor={a.shadowColor}
shadowOffsetX={a.shadowOffsetX}
shadowOffsetY={a.shadowOffsetY}
{...fx}
shadowBlur={a.shadowBlur ?? (fx.shadowBlur as number | undefined)}
shadowColor={a.shadowColor ?? (fx.shadowColor as string | undefined)}
shadowOffsetX={a.shadowOffsetX ?? (fx.shadowOffsetX as number | undefined)}
shadowOffsetY={a.shadowOffsetY ?? (fx.shadowOffsetY as number | undefined)}
/>
);
}
@@ -169,6 +169,16 @@ export function useEditorShortcuts(callbacks?: {
{ preventDefault: true },
);
// N - Pencil tool
useHotkeys(
"n",
() => {
if (isInputFocused()) return;
useEditorStore.getState().setTool("pencil");
},
{ preventDefault: true },
);
// E - Eraser tool
useHotkeys(
"e",
+9
View File
@@ -433,6 +433,9 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
a.radiusY = oldRx;
}
}
if ("rotation" in attrs) {
a.rotation = ((a.rotation || 0) + degrees) % 360;
}
return { ...obj, attrs } as CanvasObject;
}),
isDirty: true,
@@ -458,6 +461,9 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
const w = centerBased ? 0 : "width" in attrs ? a.width : 0;
a.x = canvasSize.width - a.x - w;
}
if ("rotation" in attrs) {
a.rotation = (360 - (a.rotation || 0)) % 360;
}
return { ...obj, attrs } as CanvasObject;
}),
isDirty: true,
@@ -483,6 +489,9 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
const h = centerBased ? 0 : "height" in attrs ? a.height : 0;
a.y = canvasSize.height - a.y - h;
}
if ("rotation" in attrs) {
a.rotation = (360 - (a.rotation || 0)) % 360;
}
return { ...obj, attrs } as CanvasObject;
}),
isDirty: true,