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