mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user