fix: resolve 50+ bugs across editor store, canvas, panels, and tools

- Store: loadImage resets state, applyCrop updates sourceImageSize,
  setTool clears stale cropState, layer ordering preserved across
  layers, rotateCanvas/flipCanvas account for object dimensions,
  trimCanvas implemented, resizeCanvas supports anchor positioning
- Canvas: crop overlay interactive, move tool drag events wired,
  hand tool panning, stage ref effect mount-only, checkerboard
  tracks pan offset, filter cache clearing
- Panels: hslToHex color fix for hue 240-360, adjustments single
  setAdjustment call, curves draggingIndex after sort, history
  panel fresh temporal reads, export transparent setting, canvas
  resize dialog re-sync, hex input respects picker target
- Tools: text tool fixed positioning and event cleanup, shape tool
  deferred addObject, selection mode wired to store, magic wand
  tolerance controlled, eyedropper coordinate transform
- Options: dodge-burn/shape/clone/fill/gradient/pixel-brush use
  proper store actions instead of raw setState
- Added 20+ store actions for tool settings
- Updated tests for corrected rotation/flip/trim behavior
- Excluded e2e-editor from vitest config
This commit is contained in:
SnapOtter
2026-05-07 23:47:08 +08:00
parent d5a03f2a98
commit e99bae428a
39 changed files with 772 additions and 391 deletions
@@ -12,8 +12,6 @@ import type { AdjustmentValues } from "@/types/editor";
// Constants
// ---------------------------------------------------------------------------
const DEBOUNCE_MS = 150;
const ADJUSTMENT_SLIDERS: {
key: keyof AdjustmentValues;
label: string;
@@ -378,28 +376,13 @@ function AutoAdjustmentsSection() {
function AdjustmentsSlidersSection() {
const adjustments = useEditorStore((s) => s.adjustments);
const setAdjustment = useEditorStore((s) => s.setAdjustment);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const handleChange = useCallback(
(key: keyof AdjustmentValues, value: number) => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setAdjustment(key, value);
}, DEBOUNCE_MS);
// Immediately set for responsive UI
setAdjustment(key, value);
},
[setAdjustment],
);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
return (
<div className="flex flex-col gap-1.5">
{ADJUSTMENT_SLIDERS.map(({ key, label, min, max }) => (
@@ -778,6 +761,8 @@ function CurvesSection() {
const pts = [...prev[channel]];
pts[draggingIndex] = pos;
pts.sort((a, b) => a.x - b.x);
const newIndex = pts.findIndex((p) => p.x === pos.x && p.y === pos.y);
if (newIndex !== -1) setDraggingIndex(newIndex);
return { ...prev, [channel]: pts };
});
},
@@ -1058,15 +1043,24 @@ export function AdjustmentsPanel() {
}, [adjustments, filters]);
const handleResetAll = useCallback(() => {
resetAdjustments();
// Reset all filters by toggling off any enabled ones
const store = useEditorStore.getState();
for (const f of store.filters) {
if (f.enabled) {
store.toggleFilter(f.type);
}
}
}, [resetAdjustments]);
useEditorStore.setState({
adjustments: {
brightness: 0,
contrast: 0,
hue: 0,
saturation: 0,
luminance: 0,
exposure: 0,
vibrance: 0,
warmth: 0,
},
filters: store.filters.map((f) => ({ ...f, enabled: false })),
isDirty: true,
lastAction: "Reset All",
_historyVersion: store._historyVersion + 1,
});
}, []);
const handleApply = useCallback(() => {
// Bake adjustments and filters into pixel data
@@ -74,11 +74,13 @@ function hslToHex(h: number, s: number, l: number): string {
gn = x;
bn = c;
} else if (h < 300) {
rn = c;
bn = x;
} else {
rn = x;
gn = 0;
bn = c;
} else {
rn = c;
gn = 0;
bn = x;
}
const r = Math.round((rn + m) * 255);
@@ -305,10 +307,11 @@ export function ColorPanel() {
const [pickerTarget, setPickerTarget] = useState<ColorTarget | null>(null);
const [hexInput, setHexInput] = useState(foregroundColor);
// Keep hex input in sync with store
// Keep hex input in sync with the active color based on pickerTarget
useEffect(() => {
setHexInput(foregroundColor);
}, [foregroundColor]);
const color = pickerTarget === "bg" ? backgroundColor : foregroundColor;
setHexInput(color);
}, [foregroundColor, backgroundColor, pickerTarget]);
const activeColor = pickerTarget === "bg" ? backgroundColor : foregroundColor;
const setActiveColor = pickerTarget === "bg" ? setBackgroundColor : setForegroundColor;
@@ -333,16 +336,17 @@ export function ColorPanel() {
setHexInput(v);
if (!v.startsWith("#")) v = `#${v}`;
if (isValidHex(v)) {
setForegroundColor(v.toLowerCase());
setActiveColor(v.toLowerCase());
}
},
[setForegroundColor],
[setActiveColor],
);
const handleHexInputBlur = useCallback(() => {
// Reset to current foreground if invalid
setHexInput(foregroundColor);
}, [foregroundColor]);
// Reset to current active color if invalid
const color = pickerTarget === "bg" ? backgroundColor : foregroundColor;
setHexInput(color);
}, [foregroundColor, backgroundColor, pickerTarget]);
return (
<div className="p-3" data-testid="color-panel">
@@ -81,9 +81,6 @@ interface HistoryEntry {
export function HistoryPanel() {
const lastAction = useEditorStore((s) => s.lastAction);
const temporalStore = useEditorStore.temporal.getState();
const pastStates = temporalStore.pastStates;
const futureStates = temporalStore.futureStates;
// Force re-render when history changes by subscribing to history version
useEditorStore((s) => s._historyVersion);
@@ -154,10 +151,10 @@ export function HistoryPanel() {
<button
type="button"
onClick={undo}
disabled={pastStates.length === 0}
disabled={useEditorStore.temporal.getState().pastStates.length === 0}
className={cn(
"p-1 rounded transition-colors",
pastStates.length > 0
useEditorStore.temporal.getState().pastStates.length > 0
? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed",
)}
@@ -169,10 +166,10 @@ export function HistoryPanel() {
<button
type="button"
onClick={redo}
disabled={futureStates.length === 0}
disabled={useEditorStore.temporal.getState().futureStates.length === 0}
className={cn(
"p-1 rounded transition-colors",
futureStates.length > 0
useEditorStore.temporal.getState().futureStates.length > 0
? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed",
)}
@@ -181,7 +178,9 @@ export function HistoryPanel() {
>
<Redo2 size={14} />
</button>
<span className="ml-auto text-[10px] text-muted-foreground">{pastStates.length} / 50</span>
<span className="ml-auto text-[10px] text-muted-foreground">
{useEditorStore.temporal.getState().pastStates.length} / 50
</span>
</div>
{/* History list */}
@@ -196,8 +196,8 @@ export function NavigatorPanel() {
const handleZoomSlider = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = Number.parseFloat(e.target.value);
setZoom(val);
const t = Number.parseFloat(e.target.value);
setZoom(MIN_ZOOM * (MAX_ZOOM / MIN_ZOOM) ** t);
},
[setZoom],
);
@@ -252,10 +252,10 @@ export function NavigatorPanel() {
</button>
<input
type="range"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={0.01}
value={zoom}
min={0}
max={1}
step={0.001}
value={Math.log(zoom / MIN_ZOOM) / Math.log(MAX_ZOOM / MIN_ZOOM)}
onChange={handleZoomSlider}
className={cn(
"flex-1 h-1 appearance-none rounded-full bg-muted",