fix(editor): repair rotate/flip/resize, levels/curves, filters, and layer lock (#597)

Fixes rotate/flip/image-size raster ops, wires Levels/Curves into the render pipeline, corrects Posterize/Threshold, seeds visible Filter-menu defaults, enforces layer lock, refreshes the histogram on edits, stops revoking in-history blob URLs, and removes the dead selection Feather control. Verified: editor e2e 168/168, web typecheck + biome clean, 226 editor unit tests pass.
This commit is contained in:
SnapOtter
2026-07-21 14:51:59 +08:00
committed by GitHub
parent 73df107758
commit a6bce6825a
7 changed files with 573 additions and 131 deletions
@@ -0,0 +1,207 @@
// apps/web/src/components/editor/adjustment-lut.ts
//
// Shared lookup-table math for the Levels and Curves adjustments. The panel uses
// these to draw its graphs; SourceImage uses composeChannelLuts() to build the
// per-channel LUTs it feeds to a Konva filter so the adjustments actually reach
// the pixels.
export type ToneChannel = "rgb" | "red" | "green" | "blue";
export interface LevelsValues {
blackPoint: number;
whitePoint: number;
gamma: number;
outBlack: number;
outWhite: number;
}
export interface CurvePoint {
x: number;
y: number;
}
export type LevelsState = Record<ToneChannel, LevelsValues>;
export type CurvesState = Record<ToneChannel, CurvePoint[]>;
export const IDENTITY_LEVELS: LevelsValues = {
blackPoint: 0,
whitePoint: 255,
gamma: 1,
outBlack: 0,
outWhite: 255,
};
export const IDENTITY_CURVE: CurvePoint[] = [
{ x: 0, y: 0 },
{ x: 255, y: 255 },
];
export function defaultLevelsState(): LevelsState {
return {
rgb: { ...IDENTITY_LEVELS },
red: { ...IDENTITY_LEVELS },
green: { ...IDENTITY_LEVELS },
blue: { ...IDENTITY_LEVELS },
};
}
export function defaultCurvesState(): CurvesState {
return {
rgb: [...IDENTITY_CURVE],
red: [...IDENTITY_CURVE],
green: [...IDENTITY_CURVE],
blue: [...IDENTITY_CURVE],
};
}
/** Build a 256-entry LUT for one Levels channel. */
export function levelsLut(v: LevelsValues): number[] {
const lut = new Array<number>(256);
const range = Math.max(1, v.whitePoint - v.blackPoint);
const invGamma = 1 / Math.max(0.01, v.gamma);
for (let i = 0; i < 256; i++) {
let t = (i - v.blackPoint) / range;
t = Math.max(0, Math.min(1, t));
t = t ** invGamma;
const o = v.outBlack + t * (v.outWhite - v.outBlack);
lut[i] = Math.max(0, Math.min(255, Math.round(o)));
}
return lut;
}
/** Build a 256-entry LUT for one Curves channel via natural cubic spline. */
export function curveLut(points: CurvePoint[]): number[] {
const lut = new Array<number>(256).fill(0);
if (points.length < 2) {
for (let i = 0; i < 256; i++) lut[i] = i;
return lut;
}
const sorted = [...points].sort((a, b) => a.x - b.x);
const n = sorted.length;
if (n === 2) {
const [p0, p1] = sorted;
const dx = p1.x - p0.x;
for (let i = 0; i < 256; i++) {
if (i <= p0.x) {
lut[i] = Math.round(p0.y);
} else if (i >= p1.x) {
lut[i] = Math.round(p1.y);
} else {
const t = (i - p0.x) / dx;
lut[i] = Math.round(p0.y + t * (p1.y - p0.y));
}
lut[i] = Math.max(0, Math.min(255, lut[i]));
}
return lut;
}
const xs = sorted.map((p) => p.x);
const ys = sorted.map((p) => p.y);
const h: number[] = [];
const alpha: number[] = [0];
for (let i = 0; i < n - 1; i++) {
h[i] = xs[i + 1] - xs[i];
}
for (let i = 1; i < n - 1; i++) {
alpha[i] = (3 / h[i]) * (ys[i + 1] - ys[i]) - (3 / h[i - 1]) * (ys[i] - ys[i - 1]);
}
const c = new Array(n).fill(0);
const l = new Array(n).fill(1);
const mu = new Array(n).fill(0);
const z = new Array(n).fill(0);
for (let i = 1; i < n - 1; i++) {
l[i] = 2 * (xs[i + 1] - xs[i - 1]) - h[i - 1] * mu[i - 1];
mu[i] = h[i] / l[i];
z[i] = (alpha[i] - h[i - 1] * z[i - 1]) / l[i];
}
const b = new Array(n).fill(0);
const d = new Array(n).fill(0);
for (let j = n - 2; j >= 0; j--) {
c[j] = z[j] - mu[j] * c[j + 1];
b[j] = (ys[j + 1] - ys[j]) / h[j] - (h[j] * (c[j + 1] + 2 * c[j])) / 3;
d[j] = (c[j + 1] - c[j]) / (3 * h[j]);
}
for (let i = 0; i < 256; i++) {
if (i <= xs[0]) {
lut[i] = Math.round(ys[0]);
} else if (i >= xs[n - 1]) {
lut[i] = Math.round(ys[n - 1]);
} else {
let seg = 0;
for (let j = 0; j < n - 1; j++) {
if (i >= xs[j] && i <= xs[j + 1]) {
seg = j;
break;
}
}
const dx = i - xs[seg];
lut[i] = Math.round(ys[seg] + b[seg] * dx + c[seg] * dx * dx + d[seg] * dx * dx * dx);
}
lut[i] = Math.max(0, Math.min(255, lut[i]));
}
return lut;
}
function isLevelsIdentity(v: LevelsValues): boolean {
return (
v.blackPoint === 0 &&
v.whitePoint === 255 &&
v.gamma === 1 &&
v.outBlack === 0 &&
v.outWhite === 255
);
}
function isCurveIdentity(points: CurvePoint[]): boolean {
return (
points.length === 2 &&
points[0].x === 0 &&
points[0].y === 0 &&
points[1].x === 255 &&
points[1].y === 255
);
}
export function hasLevelsAdjustments(levels: LevelsState): boolean {
return (["rgb", "red", "green", "blue"] as ToneChannel[]).some(
(c) => !isLevelsIdentity(levels[c]),
);
}
export function hasCurvesAdjustments(curves: CurvesState): boolean {
return (["rgb", "red", "green", "blue"] as ToneChannel[]).some(
(c) => !isCurveIdentity(curves[c]),
);
}
/**
* Compose the full Levels+Curves pipeline into one LUT per output channel.
* Order matches stacked Photoshop adjustment layers: RGB levels, then per-channel
* levels, then RGB curve, then per-channel curve.
*/
export function composeChannelLuts(
levels: LevelsState,
curves: CurvesState,
): { r: number[]; g: number[]; b: number[] } {
const lRgb = levelsLut(levels.rgb);
const cRgb = curveLut(curves.rgb);
const build = (channel: "red" | "green" | "blue") => {
const lCh = levelsLut(levels[channel]);
const cCh = curveLut(curves[channel]);
const out = new Array<number>(256);
for (let i = 0; i < 256; i++) {
out[i] = cCh[cRgb[lCh[lRgb[i]]]];
}
return out;
};
return { r: build("red"), g: build("green"), b: build("blue") };
}
@@ -28,11 +28,19 @@ import type {
ImageAttrs,
ObjectEffects,
} from "@/types/editor";
import {
type CurvesState,
composeChannelLuts,
hasCurvesAdjustments,
hasLevelsAdjustments,
type LevelsState,
} from "./adjustment-lut";
import { ContextMenu, useContextMenu } from "./common/context-menu";
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
import { LoadingOverlay } from "./common/loading-overlay";
import { SmartGuidesOverlay } from "./common/smart-guides";
import {
createChannelLutFilter,
createExposureFilter,
createGrainFilter,
createMotionBlurFilter,
@@ -110,10 +118,18 @@ function SourceImage({
url,
adjustments,
filters,
levels,
curves,
canvasWidth,
canvasHeight,
}: {
url: string;
adjustments: AdjustmentValues;
filters: FilterConfig[];
levels: LevelsState;
curves: CurvesState;
canvasWidth: number;
canvasHeight: number;
}) {
const [image] = useImage(url);
const imageRef = useRef<Konva.Image>(null);
@@ -121,12 +137,17 @@ function SourceImage({
// Issue #12: Apply adjustments/filters to the source image node
const hasActiveAdjustments = Object.values(adjustments).some((v) => v !== 0);
const hasActiveFilters = filters.some((f) => f.enabled);
const hasToneAdjustments = hasLevelsAdjustments(levels) || hasCurvesAdjustments(curves);
// canvasWidth/canvasHeight are intentional deps: when the canvas is resized the node's
// cached filter output must be recomputed to match the new draw size, even though the
// effect body reaches those values through JSX rather than referencing them directly.
// biome-ignore lint/correctness/useExhaustiveDependencies: see note above
useEffect(() => {
const node = imageRef.current;
if (!node || !image) return;
if (hasActiveAdjustments || hasActiveFilters) {
if (hasActiveAdjustments || hasActiveFilters || hasToneAdjustments) {
const konvaFilters: Array<((this: Konva.Node, imageData: ImageData) => void) | string> = [];
// Built-in Konva adjustment filters
@@ -183,10 +204,15 @@ function SourceImage({
node.embossWhiteLevel(0.5);
node.embossBlend(true);
break;
case "posterize":
case "posterize": {
konvaFilters.push(KonvaFilters.Filters.Posterize);
node.levels(f.params.levels ?? 8);
// Konva's Posterize expects `levels` as a 0..1 fraction and derives the
// actual count via round(levels * 254) + 1. The panel exposes a raw count
// (2..30), so map it back to the fraction that yields that many levels.
const posterizeCount = f.params.levels ?? 8;
node.levels(Math.max(0, (posterizeCount - 1) / 254));
break;
}
case "noise":
konvaFilters.push(KonvaFilters.Filters.Noise);
node.noise(f.params.amount ?? 0);
@@ -196,7 +222,10 @@ function SourceImage({
break;
case "threshold":
konvaFilters.push(KonvaFilters.Filters.Threshold);
node.threshold((f.params.level ?? 0.5) * 255);
// Konva's Threshold multiplies by 255 internally, so pass the 0..1 level
// directly. Passing level*255 double-scales it and clears every channel
// (including alpha), turning the whole image transparent.
node.threshold(f.params.level ?? 0.5);
break;
case "kaleidoscope":
konvaFilters.push(KonvaFilters.Filters.Kaleidoscope);
@@ -256,6 +285,11 @@ function SourceImage({
}
}
// Levels + Curves: apply as a single per-channel LUT filter.
if (hasToneAdjustments) {
konvaFilters.push(createChannelLutFilter(composeChannelLuts(levels, curves)));
}
// FIX 1: Filters must be set BEFORE caching in Konva
node.clearCache();
node.filters(konvaFilters);
@@ -266,11 +300,36 @@ function SourceImage({
node.filters([]);
node.getLayer()?.batchDraw();
}
}, [image, adjustments, filters, hasActiveAdjustments, hasActiveFilters]);
}, [
image,
adjustments,
filters,
levels,
curves,
hasActiveAdjustments,
hasActiveFilters,
hasToneAdjustments,
canvasWidth,
canvasHeight,
]);
if (!image) return null;
return <KonvaImage ref={imageRef} image={image} x={0} y={0} listening={false} />;
// Draw the source at the canvas dimensions so an Image Size resize actually
// scales the raster instead of leaving it at natural resolution (which would
// overflow or crop the canvas). Rotate/flip rebake the bitmap so its natural
// dimensions already match the canvas, keeping this a 1:1 draw for them.
return (
<KonvaImage
ref={imageRef}
image={image}
x={0}
y={0}
width={canvasWidth}
height={canvasHeight}
listening={false}
/>
);
}
// ---------------------------------------------------------------------------
@@ -793,6 +852,8 @@ export function EditorCanvas({
const setPanOffset = useEditorStore((s) => s.setPanOffset);
const adjustments = useEditorStore((s) => s.adjustments);
const filters = useEditorStore((s) => s.filters);
const levels = useEditorStore((s) => s.levels);
const curves = useEditorStore((s) => s.curves);
// Issue #10: Shortcuts moved to EditorPage, removed from here
const cursor = useEditorCursor();
@@ -946,7 +1007,15 @@ export function EditorCanvas({
<Layer ref={selectionLayerRef}>
{/* Issue #14: Render source image as background */}
{sourceImageUrl && (
<SourceImage url={sourceImageUrl} adjustments={adjustments} filters={filters} />
<SourceImage
url={sourceImageUrl}
adjustments={adjustments}
filters={filters}
levels={levels}
curves={curves}
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
/>
)}
{layers.map((layer) => {
@@ -963,18 +1032,22 @@ export function EditorCanvas({
}
listening={layer.id === activeLayerId}
>
{layerObjects.map((obj) => (
<CanvasObjectRenderer
key={obj.id}
obj={obj}
isMoveTool={isMoveTool}
onSelect={isMoveTool ? moveTool.onSelect : undefined}
onDragStart={isMoveTool ? moveTool.onDragStart : undefined}
onDragMove={isMoveTool ? moveTool.onDragMove : undefined}
onDragEnd={isMoveTool ? moveTool.onDragEnd : undefined}
onTransformEnd={isMoveTool ? moveTool.onTransformEnd : undefined}
/>
))}
{layerObjects.map((obj) => {
// A locked layer's objects can't be selected, dragged, or transformed.
const editable = isMoveTool && !layer.locked;
return (
<CanvasObjectRenderer
key={obj.id}
obj={obj}
isMoveTool={editable}
onSelect={editable ? moveTool.onSelect : undefined}
onDragStart={editable ? moveTool.onDragStart : undefined}
onDragMove={editable ? moveTool.onDragMove : undefined}
onDragEnd={editable ? moveTool.onDragEnd : undefined}
onTransformEnd={editable ? moveTool.onTransformEnd : undefined}
/>
);
})}
</Group>
);
})}
@@ -406,3 +406,23 @@ export function createSharpenFilter(params: {
}
};
}
/**
* Apply per-channel lookup tables (Levels + Curves). Each LUT is a 256-entry
* array mapping input intensity to output. Alpha is untouched.
*/
export function createChannelLutFilter(luts: {
r: number[];
g: number[];
b: number[];
}): (imageData: ImageData) => void {
return (imageData: ImageData) => {
const d = imageData.data;
const { r, g, b } = luts;
for (let i = 0; i < d.length; i += 4) {
d[i] = r[d[i]];
d[i + 1] = g[d[i + 1]];
d[i + 2] = b[d[i + 2]];
}
};
}
@@ -50,8 +50,6 @@ export function SelectionOptions() {
const setMagicWandTolerance = useEditorStore((s) => s.setMagicWandTolerance);
const magicWandContiguous = useEditorStore((s) => s.magicWandContiguous);
const setMagicWandContiguous = useEditorStore((s) => s.setMagicWandContiguous);
const selectionFeather = useEditorStore((s) => s.selectionFeather);
const setSelectionFeather = useEditorStore((s) => s.setSelectionFeather);
const selectionType: SelectionType =
activeTool === "marquee-ellipse"
@@ -172,25 +170,6 @@ export function SelectionOptions() {
</>
)}
{/* Feather radius */}
{(isMarquee || isLasso) && (
<>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0">Feather:</span>
<input
type="number"
min={0}
max={100}
value={selectionFeather}
onChange={(e) => setSelectionFeather(Number(e.target.value))}
className="w-14 px-1.5 py-0.5 text-xs rounded border border-border bg-background text-foreground tabular-nums"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
</>
)}
{/* Magic Wand tolerance + contiguous */}
{isMagicWand && (
<>
@@ -2,6 +2,7 @@
import { Wand2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { hasCurvesAdjustments, hasLevelsAdjustments } from "@/components/editor/adjustment-lut";
import { SliderRow } from "@/components/editor/common/slider-row";
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
import { HistogramPanel } from "@/components/editor/panels/histogram-panel";
@@ -202,13 +203,6 @@ const CURVE_PRESETS: Record<string, CurvePoint[]> = {
],
};
const DEFAULT_LEVELS: Record<LevelsChannel, LevelsValues> = {
rgb: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 },
red: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 },
green: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 },
blue: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 },
};
// ---------------------------------------------------------------------------
// Cubic spline interpolation for curves
// ---------------------------------------------------------------------------
@@ -407,33 +401,21 @@ function AdjustmentsSlidersSection() {
function LevelsSection() {
const [channel, setChannel] = useState<LevelsChannel>("rgb");
const [levels, setLevels] = useState<Record<LevelsChannel, LevelsValues>>(() =>
JSON.parse(JSON.stringify(DEFAULT_LEVELS)),
);
const levels = useEditorStore((s) => s.levels);
const setStoreLevels = useEditorStore((s) => s.setLevels);
const currentLevels = levels[channel];
const updateLevel = useCallback(
(key: keyof LevelsValues, value: number) => {
setLevels((prev) => ({
...prev,
[channel]: { ...prev[channel], [key]: value },
}));
setStoreLevels(channel, { [key]: value });
},
[channel],
[channel, setStoreLevels],
);
const handleAutoLevels = useCallback(() => {
setLevels((prev) => ({
...prev,
[channel]: {
...prev[channel],
blackPoint: 10,
whitePoint: 245,
gamma: 1,
},
}));
}, [channel]);
setStoreLevels(channel, { blackPoint: 10, whitePoint: 245, gamma: 1 });
}, [channel, setStoreLevels]);
const channelColors: Record<LevelsChannel, string> = {
rgb: "text-foreground",
@@ -616,12 +598,8 @@ function drawTriangle(
function CurvesSection() {
const [channel, setChannel] = useState<CurveChannel>("rgb");
const [curves, setCurves] = useState<Record<CurveChannel, CurvePoint[]>>({
rgb: [...CURVE_PRESETS.Linear],
red: [...CURVE_PRESETS.Linear],
green: [...CURVE_PRESETS.Linear],
blue: [...CURVE_PRESETS.Linear],
});
const curves = useEditorStore((s) => s.curves);
const setStoreCurves = useEditorStore((s) => s.setCurves);
const [preset, setPreset] = useState("Linear");
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
@@ -744,14 +722,14 @@ function CurvesSection() {
} else {
// Add new point
const newPoints = [...currentPoints, pos].sort((a, b) => a.x - b.x);
setCurves((prev) => ({ ...prev, [channel]: newPoints }));
setStoreCurves(channel, newPoints);
setPreset("Custom");
// Find and start dragging the new point
const newIdx = newPoints.findIndex((p) => p.x === pos.x && p.y === pos.y);
setDraggingIndex(newIdx);
}
},
[getCanvasPos, findNearPoint, currentPoints, channel],
[getCanvasPos, findNearPoint, currentPoints, channel, setStoreCurves],
);
const handleMouseMove = useCallback(
@@ -759,16 +737,14 @@ function CurvesSection() {
if (draggingIndex === null) return;
const pos = getCanvasPos(e);
setCurves((prev) => {
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 };
});
const pts = [...currentPoints];
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);
setStoreCurves(channel, pts);
},
[draggingIndex, getCanvasPos, channel],
[draggingIndex, getCanvasPos, channel, currentPoints, setStoreCurves],
);
const handleMouseUp = useCallback(() => {
@@ -782,11 +758,11 @@ function CurvesSection() {
if (idx >= 0 && currentPoints.length > 2) {
const newPoints = currentPoints.filter((_, i) => i !== idx);
setCurves((prev) => ({ ...prev, [channel]: newPoints }));
setStoreCurves(channel, newPoints);
setPreset("Custom");
}
},
[getCanvasPos, findNearPoint, currentPoints, channel],
[getCanvasPos, findNearPoint, currentPoints, channel, setStoreCurves],
);
const handlePresetChange = useCallback(
@@ -794,13 +770,13 @@ function CurvesSection() {
setPreset(name);
const presetPoints = CURVE_PRESETS[name];
if (presetPoints) {
setCurves((prev) => ({
...prev,
[channel]: presetPoints.map((p) => ({ ...p })),
}));
setStoreCurves(
channel,
presetPoints.map((p) => ({ ...p })),
);
}
},
[channel],
[channel, setStoreCurves],
);
return (
@@ -1038,10 +1014,16 @@ export function AdjustmentsPanel() {
const filters = useEditorStore((s) => s.filters);
const resetAdjustments = useEditorStore((s) => s.resetAdjustments);
const canvasSize = useEditorStore((s) => s.canvasSize);
// Recompute the histogram whenever the committed document changes (paint, delete,
// adjustments, filters, levels, curves all bump this) so it never shows stale data.
const historyVersion = useEditorStore((s) => s._historyVersion);
// Capture imageData from the Konva stage for the histogram
const [histogramData, setHistogramData] = useState<ImageData | null>(null);
// historyVersion is an intentional dep: it changes on every committed edit and forces a
// fresh capture of the rendered stage even though the effect body doesn't read it.
// biome-ignore lint/correctness/useExhaustiveDependencies: see note above
useEffect(() => {
function captureImageData() {
const stage = editorStageRefHolder.current;
@@ -1059,32 +1041,23 @@ export function AdjustmentsPanel() {
const timer = setTimeout(captureImageData, 100);
return () => clearTimeout(timer);
}, [canvasSize]);
}, [canvasSize, historyVersion]);
const levels = useEditorStore((s) => s.levels);
const curves = useEditorStore((s) => s.curves);
const hasChanges = useMemo(() => {
const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0);
const hasFilterChanges = filters.some((f) => f.enabled);
return hasAdjustmentChanges || hasFilterChanges;
}, [adjustments, filters]);
return (
hasAdjustmentChanges ||
hasFilterChanges ||
hasLevelsAdjustments(levels) ||
hasCurvesAdjustments(curves)
);
}, [adjustments, filters, levels, curves]);
const handleResetAll = useCallback(() => {
const store = useEditorStore.getState();
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,
});
useEditorStore.getState().resetAllAdjustments();
}, []);
const handleApply = useCallback(() => {
+212 -20
View File
@@ -3,6 +3,15 @@
import { ANALYTICS_EVENTS } from "@snapotter/shared";
import { temporal } from "zundo";
import { create } from "zustand";
import {
type CurvePoint,
type CurvesState,
defaultCurvesState,
defaultLevelsState,
type LevelsState,
type LevelsValues,
type ToneChannel,
} from "@/components/editor/adjustment-lut";
import { generateId } from "@/lib/utils";
import type {
AdjustmentValues,
@@ -46,12 +55,75 @@ function isCenterBased(obj: CanvasObject): boolean {
return obj.type === "ellipse" || obj.type === "polygon" || obj.type === "star";
}
type RasterTransform = "rot90" | "rot180" | "rot270" | "flipH" | "flipV";
// Rebake the source image bitmap through a transformed offscreen canvas so that
// rotate/flip actually change the pixels (canvas-size + vector objects are
// transformed separately by the caller). `width`/`height` are the pre-transform
// canvas dimensions; the source is scaled to them first so a prior Image Size
// resize is baked in too. Runs async (image decode); `apply` receives the new
// data URL, and the old blob URL is revoked to avoid leaks.
function rebakeSourceRaster(
url: string,
width: number,
height: number,
transform: RasterTransform,
apply: (newUrl: string) => void,
): void {
const img = new Image();
img.onload = () => {
const swap = transform === "rot90" || transform === "rot270";
const cw = swap ? height : width;
const ch = swap ? width : height;
const off = document.createElement("canvas");
off.width = cw;
off.height = ch;
const ctx = off.getContext("2d");
if (!ctx) return;
switch (transform) {
case "rot90":
ctx.translate(cw, 0);
ctx.rotate(Math.PI / 2);
break;
case "rot270":
ctx.translate(0, ch);
ctx.rotate(-Math.PI / 2);
break;
case "rot180":
ctx.translate(cw, ch);
ctx.rotate(Math.PI);
break;
case "flipH":
ctx.translate(cw, 0);
ctx.scale(-1, 1);
break;
case "flipV":
ctx.translate(0, ch);
ctx.scale(1, -1);
break;
}
ctx.drawImage(img, 0, 0, width, height);
// Don't revoke the previous blob URL: it's captured in undo history (partialize
// keeps sourceImageUrl), so undo/redo may restore it. Revoking would 404 the reload.
apply(off.toDataURL("image/png"));
};
img.src = url;
}
// Extended store state with additional fields/methods not yet in the shared interface
interface EditorStateExtensions {
canvasBackground: string;
commitHistory: (action: string) => void;
batchNudge: (objectIds: string[], dx: number, dy: number) => void;
updateLayerThumbnail: (layerId: string, thumbnailDataUrl: string) => void;
// Levels / Curves tone adjustments (applied to the source image as LUTs)
levels: LevelsState;
curves: CurvesState;
setLevels: (channel: ToneChannel, values: Partial<LevelsValues>) => void;
setCurves: (channel: ToneChannel, points: CurvePoint[]) => void;
resetLevels: () => void;
resetCurves: () => void;
resetAllAdjustments: () => void;
}
const DEFAULT_CANVAS_SIZE = { width: 1920, height: 1080 };
@@ -109,6 +181,18 @@ const DEFAULT_FILTERS: FilterConfig[] = [
},
];
// Filters whose initial param value is a visual no-op (radius 0, size 1, ...). When one of
// these is enabled (from the Filter menu or the panel checkbox) while still at that no-op
// value, seed a sensible visible default so the toggle actually does something. A value the
// user has already tuned is left untouched.
const FILTER_ENABLE_DEFAULTS: Record<string, { key: string; noop: number; value: number }> = {
blur: { key: "radius", noop: 0, value: 8 },
sharpen: { key: "amount", noop: 0, value: 50 },
noise: { key: "amount", noop: 0, value: 30 },
pixelate: { key: "size", noop: 1, value: 10 },
emboss: { key: "strength", noop: 0, value: 0.5 },
};
function createDefaultLayer(id: string, name: string): EditorLayer {
return {
id,
@@ -175,7 +259,6 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
selectionMode: "new" as SelectionMode,
magicWandTolerance: 32,
magicWandContiguous: true,
selectionFeather: 0,
// --- Crop ---
cropState: null,
@@ -188,6 +271,10 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
params: { ...f.params },
})),
// --- Levels / Curves (LUT-based tone adjustments) ---
levels: defaultLevelsState(),
curves: defaultCurvesState(),
// --- Text ---
editingTextId: null,
@@ -289,10 +376,8 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
setPanOffset: (offset) => set({ panOffset: offset }),
loadImage: (url, width, height) => {
const oldUrl = get().sourceImageUrl;
if (oldUrl?.startsWith("blob:")) {
URL.revokeObjectURL(oldUrl);
}
// The previous image URL stays in undo history (see partialize), so it must not be
// revoked here -- undo/redo could restore it and a revoked blob would fail to reload.
set({
sourceImageUrl: url,
sourceImageSize: { width, height },
@@ -309,6 +394,8 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
...f,
params: { ...f.params },
})),
levels: defaultLevelsState(),
curves: defaultCurvesState(),
clipboard: null,
editingTextId: null,
layers: [createDefaultLayer(DEFAULT_LAYER_ID, "Layer 1")],
@@ -411,9 +498,22 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
},
rotateCanvas: (degrees) => {
const { canvasSize, objects } = get();
const { canvasSize, objects, sourceImageUrl } = get();
const newSize =
degrees === 180 ? canvasSize : { width: canvasSize.height, height: canvasSize.width };
// Rebake the source bitmap so the pixels actually rotate. Runs async and
// updates sourceImageUrl once ready; canvas-size + objects rotate now.
if (sourceImageUrl) {
const transform: RasterTransform =
degrees === 90 ? "rot90" : degrees === 270 ? "rot270" : "rot180";
rebakeSourceRaster(
sourceImageUrl,
canvasSize.width,
canvasSize.height,
transform,
(newUrl) => set({ sourceImageUrl: newUrl }),
);
}
set({
canvasSize: newSize,
sourceImageSize: newSize,
@@ -483,7 +583,16 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
},
flipCanvasHorizontal: () => {
const { canvasSize, objects } = get();
const { canvasSize, objects, sourceImageUrl } = get();
if (sourceImageUrl) {
rebakeSourceRaster(
sourceImageUrl,
canvasSize.width,
canvasSize.height,
"flipH",
(newUrl) => set({ sourceImageUrl: newUrl }),
);
}
set({
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
@@ -511,7 +620,16 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
},
flipCanvasVertical: () => {
const { canvasSize, objects } = get();
const { canvasSize, objects, sourceImageUrl } = get();
if (sourceImageUrl) {
rebakeSourceRaster(
sourceImageUrl,
canvasSize.width,
canvasSize.height,
"flipV",
(newUrl) => set({ sourceImageUrl: newUrl }),
);
}
set({
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
@@ -663,8 +781,12 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
// Objects
addObject: (obj) => {
const { layers, activeLayerId } = get();
const targetLayerId = obj.layerId || activeLayerId;
// A locked layer rejects new content (brush strokes, shapes, text, fills, ...).
if (layers.find((l) => l.id === targetLayerId)?.locked) return;
set({
objects: [...get().objects, { ...obj, layerId: obj.layerId || get().activeLayerId }],
objects: [...get().objects, { ...obj, layerId: targetLayerId }],
isDirty: true,
lastAction: `Add ${obj.type.charAt(0).toUpperCase() + obj.type.slice(1)}`,
_historyVersion: get()._historyVersion + 1,
@@ -697,10 +819,19 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
},
removeObjects: (ids) => {
const idSet = new Set(ids);
const { objects, layers, selectedObjectIds } = get();
const lockedLayerIds = new Set(layers.filter((l) => l.locked).map((l) => l.id));
// Never delete objects that sit on a locked layer.
const idSet = new Set(
ids.filter((id) => {
const obj = objects.find((o) => o.id === id);
return obj && !lockedLayerIds.has(obj.layerId);
}),
);
if (idSet.size === 0) return;
set({
objects: get().objects.filter((obj) => !idSet.has(obj.id)),
selectedObjectIds: get().selectedObjectIds.filter((id) => !idSet.has(id)),
objects: objects.filter((obj) => !idSet.has(obj.id)),
selectedObjectIds: selectedObjectIds.filter((id) => !idSet.has(id)),
isDirty: true,
lastAction: "Delete",
_historyVersion: get()._historyVersion + 1,
@@ -954,9 +1085,68 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
_historyVersion: get()._historyVersion + 1,
}),
toggleFilter: (type) => {
setLevels: (channel, values) =>
set({
filters: get().filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)),
levels: {
...get().levels,
[channel]: { ...get().levels[channel], ...values },
},
isDirty: true,
lastAction: "Levels",
_historyVersion: get()._historyVersion + 1,
}),
setCurves: (channel, points) =>
set({
curves: { ...get().curves, [channel]: points },
isDirty: true,
lastAction: "Curves",
_historyVersion: get()._historyVersion + 1,
}),
resetLevels: () =>
set({
levels: defaultLevelsState(),
isDirty: true,
lastAction: "Reset Levels",
_historyVersion: get()._historyVersion + 1,
}),
resetCurves: () =>
set({
curves: defaultCurvesState(),
isDirty: true,
lastAction: "Reset Curves",
_historyVersion: get()._historyVersion + 1,
}),
// Reset every non-destructive image adjustment in one history step:
// slider adjustments, toggled filters, Levels, and Curves.
resetAllAdjustments: () =>
set({
adjustments: { ...DEFAULT_ADJUSTMENTS },
filters: get().filters.map((f) => ({ ...f, enabled: false })),
levels: defaultLevelsState(),
curves: defaultCurvesState(),
isDirty: true,
lastAction: "Reset All",
_historyVersion: get()._historyVersion + 1,
}),
toggleFilter: (type) => {
const seed = FILTER_ENABLE_DEFAULTS[type];
set({
filters: get().filters.map((f) => {
if (f.type !== type) return f;
const enabled = !f.enabled;
// On enable, if a no-op filter is still at its identity value, give it a
// visible default so the menu/checkbox produces an immediate effect.
const params =
enabled && seed && (f.params[seed.key] ?? seed.noop) === seed.noop
? { ...f.params, [seed.key]: seed.value }
: f.params;
return { ...f, enabled, params };
}),
isDirty: true,
lastAction: `Toggle ${type.charAt(0).toUpperCase() + type.slice(1)} Filter`,
_historyVersion: get()._historyVersion + 1,
@@ -979,7 +1169,6 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
setSelectionMode: (mode) => set({ selectionMode: mode }),
setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
setMagicWandContiguous: (v: boolean) => set({ magicWandContiguous: v }),
setSelectionFeather: (v) => set({ selectionFeather: v }),
invertSelection: () => {
const { selection, canvasSize } = get();
@@ -1049,10 +1238,8 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
if (ctx) {
ctx.drawImage(img, -cropState.x, -cropState.y);
const croppedUrl = offscreen.toDataURL("image/png");
const oldUrl = get().sourceImageUrl;
if (oldUrl?.startsWith("blob:")) {
URL.revokeObjectURL(oldUrl);
}
// Keep the pre-crop blob URL alive -- it's in undo history and revoking it
// would break undo (the restored source would fail to reload).
set({ sourceImageUrl: croppedUrl });
}
};
@@ -1241,10 +1428,15 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
// Batch nudge: move multiple objects and create a history entry
batchNudge: (objectIds, dx, dy) => {
const lockedLayerIds = new Set(
get()
.layers.filter((l) => l.locked)
.map((l) => l.id),
);
const idSet = new Set(objectIds);
set({
objects: get().objects.map((obj) => {
if (!idSet.has(obj.id)) return obj;
if (!idSet.has(obj.id) || lockedLayerIds.has(obj.layerId)) return obj;
const attrs = { ...obj.attrs };
if (hasPointsArray(obj)) {
const pts = [...(attrs as { points: number[] }).points];
-2
View File
@@ -318,7 +318,6 @@ export interface EditorState {
selectionMode: SelectionMode;
magicWandTolerance: number;
magicWandContiguous: boolean;
selectionFeather: number;
// Crop
cropState: CropState | null;
@@ -445,7 +444,6 @@ export interface EditorState {
setSelectionMode: (mode: SelectionMode) => void;
setMagicWandTolerance: (v: number) => void;
setMagicWandContiguous: (v: boolean) => void;
setSelectionFeather: (v: number) => void;
invertSelection: () => void;
// Crop