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,
|
ImageAttrs,
|
||||||
ObjectEffects,
|
ObjectEffects,
|
||||||
} from "@/types/editor";
|
} from "@/types/editor";
|
||||||
|
import {
|
||||||
|
type CurvesState,
|
||||||
|
composeChannelLuts,
|
||||||
|
hasCurvesAdjustments,
|
||||||
|
hasLevelsAdjustments,
|
||||||
|
type LevelsState,
|
||||||
|
} from "./adjustment-lut";
|
||||||
import { ContextMenu, useContextMenu } from "./common/context-menu";
|
import { ContextMenu, useContextMenu } from "./common/context-menu";
|
||||||
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
|
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
|
||||||
import { LoadingOverlay } from "./common/loading-overlay";
|
import { LoadingOverlay } from "./common/loading-overlay";
|
||||||
import { SmartGuidesOverlay } from "./common/smart-guides";
|
import { SmartGuidesOverlay } from "./common/smart-guides";
|
||||||
import {
|
import {
|
||||||
|
createChannelLutFilter,
|
||||||
createExposureFilter,
|
createExposureFilter,
|
||||||
createGrainFilter,
|
createGrainFilter,
|
||||||
createMotionBlurFilter,
|
createMotionBlurFilter,
|
||||||
@@ -110,10 +118,18 @@ function SourceImage({
|
|||||||
url,
|
url,
|
||||||
adjustments,
|
adjustments,
|
||||||
filters,
|
filters,
|
||||||
|
levels,
|
||||||
|
curves,
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
adjustments: AdjustmentValues;
|
adjustments: AdjustmentValues;
|
||||||
filters: FilterConfig[];
|
filters: FilterConfig[];
|
||||||
|
levels: LevelsState;
|
||||||
|
curves: CurvesState;
|
||||||
|
canvasWidth: number;
|
||||||
|
canvasHeight: number;
|
||||||
}) {
|
}) {
|
||||||
const [image] = useImage(url);
|
const [image] = useImage(url);
|
||||||
const imageRef = useRef<Konva.Image>(null);
|
const imageRef = useRef<Konva.Image>(null);
|
||||||
@@ -121,12 +137,17 @@ function SourceImage({
|
|||||||
// Issue #12: Apply adjustments/filters to the source image node
|
// Issue #12: Apply adjustments/filters to the source image node
|
||||||
const hasActiveAdjustments = Object.values(adjustments).some((v) => v !== 0);
|
const hasActiveAdjustments = Object.values(adjustments).some((v) => v !== 0);
|
||||||
const hasActiveFilters = filters.some((f) => f.enabled);
|
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(() => {
|
useEffect(() => {
|
||||||
const node = imageRef.current;
|
const node = imageRef.current;
|
||||||
if (!node || !image) return;
|
if (!node || !image) return;
|
||||||
|
|
||||||
if (hasActiveAdjustments || hasActiveFilters) {
|
if (hasActiveAdjustments || hasActiveFilters || hasToneAdjustments) {
|
||||||
const konvaFilters: Array<((this: Konva.Node, imageData: ImageData) => void) | string> = [];
|
const konvaFilters: Array<((this: Konva.Node, imageData: ImageData) => void) | string> = [];
|
||||||
|
|
||||||
// Built-in Konva adjustment filters
|
// Built-in Konva adjustment filters
|
||||||
@@ -183,10 +204,15 @@ function SourceImage({
|
|||||||
node.embossWhiteLevel(0.5);
|
node.embossWhiteLevel(0.5);
|
||||||
node.embossBlend(true);
|
node.embossBlend(true);
|
||||||
break;
|
break;
|
||||||
case "posterize":
|
case "posterize": {
|
||||||
konvaFilters.push(KonvaFilters.Filters.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;
|
break;
|
||||||
|
}
|
||||||
case "noise":
|
case "noise":
|
||||||
konvaFilters.push(KonvaFilters.Filters.Noise);
|
konvaFilters.push(KonvaFilters.Filters.Noise);
|
||||||
node.noise(f.params.amount ?? 0);
|
node.noise(f.params.amount ?? 0);
|
||||||
@@ -196,7 +222,10 @@ function SourceImage({
|
|||||||
break;
|
break;
|
||||||
case "threshold":
|
case "threshold":
|
||||||
konvaFilters.push(KonvaFilters.Filters.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;
|
break;
|
||||||
case "kaleidoscope":
|
case "kaleidoscope":
|
||||||
konvaFilters.push(KonvaFilters.Filters.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
|
// FIX 1: Filters must be set BEFORE caching in Konva
|
||||||
node.clearCache();
|
node.clearCache();
|
||||||
node.filters(konvaFilters);
|
node.filters(konvaFilters);
|
||||||
@@ -266,11 +300,36 @@ function SourceImage({
|
|||||||
node.filters([]);
|
node.filters([]);
|
||||||
node.getLayer()?.batchDraw();
|
node.getLayer()?.batchDraw();
|
||||||
}
|
}
|
||||||
}, [image, adjustments, filters, hasActiveAdjustments, hasActiveFilters]);
|
}, [
|
||||||
|
image,
|
||||||
|
adjustments,
|
||||||
|
filters,
|
||||||
|
levels,
|
||||||
|
curves,
|
||||||
|
hasActiveAdjustments,
|
||||||
|
hasActiveFilters,
|
||||||
|
hasToneAdjustments,
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!image) return null;
|
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 setPanOffset = useEditorStore((s) => s.setPanOffset);
|
||||||
const adjustments = useEditorStore((s) => s.adjustments);
|
const adjustments = useEditorStore((s) => s.adjustments);
|
||||||
const filters = useEditorStore((s) => s.filters);
|
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
|
// Issue #10: Shortcuts moved to EditorPage, removed from here
|
||||||
const cursor = useEditorCursor();
|
const cursor = useEditorCursor();
|
||||||
@@ -946,7 +1007,15 @@ export function EditorCanvas({
|
|||||||
<Layer ref={selectionLayerRef}>
|
<Layer ref={selectionLayerRef}>
|
||||||
{/* Issue #14: Render source image as background */}
|
{/* Issue #14: Render source image as background */}
|
||||||
{sourceImageUrl && (
|
{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) => {
|
{layers.map((layer) => {
|
||||||
@@ -963,18 +1032,22 @@ export function EditorCanvas({
|
|||||||
}
|
}
|
||||||
listening={layer.id === activeLayerId}
|
listening={layer.id === activeLayerId}
|
||||||
>
|
>
|
||||||
{layerObjects.map((obj) => (
|
{layerObjects.map((obj) => {
|
||||||
<CanvasObjectRenderer
|
// A locked layer's objects can't be selected, dragged, or transformed.
|
||||||
key={obj.id}
|
const editable = isMoveTool && !layer.locked;
|
||||||
obj={obj}
|
return (
|
||||||
isMoveTool={isMoveTool}
|
<CanvasObjectRenderer
|
||||||
onSelect={isMoveTool ? moveTool.onSelect : undefined}
|
key={obj.id}
|
||||||
onDragStart={isMoveTool ? moveTool.onDragStart : undefined}
|
obj={obj}
|
||||||
onDragMove={isMoveTool ? moveTool.onDragMove : undefined}
|
isMoveTool={editable}
|
||||||
onDragEnd={isMoveTool ? moveTool.onDragEnd : undefined}
|
onSelect={editable ? moveTool.onSelect : undefined}
|
||||||
onTransformEnd={isMoveTool ? moveTool.onTransformEnd : undefined}
|
onDragStart={editable ? moveTool.onDragStart : undefined}
|
||||||
/>
|
onDragMove={editable ? moveTool.onDragMove : undefined}
|
||||||
))}
|
onDragEnd={editable ? moveTool.onDragEnd : undefined}
|
||||||
|
onTransformEnd={editable ? moveTool.onTransformEnd : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Group>
|
</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 setMagicWandTolerance = useEditorStore((s) => s.setMagicWandTolerance);
|
||||||
const magicWandContiguous = useEditorStore((s) => s.magicWandContiguous);
|
const magicWandContiguous = useEditorStore((s) => s.magicWandContiguous);
|
||||||
const setMagicWandContiguous = useEditorStore((s) => s.setMagicWandContiguous);
|
const setMagicWandContiguous = useEditorStore((s) => s.setMagicWandContiguous);
|
||||||
const selectionFeather = useEditorStore((s) => s.selectionFeather);
|
|
||||||
const setSelectionFeather = useEditorStore((s) => s.setSelectionFeather);
|
|
||||||
|
|
||||||
const selectionType: SelectionType =
|
const selectionType: SelectionType =
|
||||||
activeTool === "marquee-ellipse"
|
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 */}
|
{/* Magic Wand tolerance + contiguous */}
|
||||||
{isMagicWand && (
|
{isMagicWand && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Wand2 } from "lucide-react";
|
import { Wand2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "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 { SliderRow } from "@/components/editor/common/slider-row";
|
||||||
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
|
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
|
||||||
import { HistogramPanel } from "@/components/editor/panels/histogram-panel";
|
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
|
// Cubic spline interpolation for curves
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -407,33 +401,21 @@ function AdjustmentsSlidersSection() {
|
|||||||
|
|
||||||
function LevelsSection() {
|
function LevelsSection() {
|
||||||
const [channel, setChannel] = useState<LevelsChannel>("rgb");
|
const [channel, setChannel] = useState<LevelsChannel>("rgb");
|
||||||
const [levels, setLevels] = useState<Record<LevelsChannel, LevelsValues>>(() =>
|
const levels = useEditorStore((s) => s.levels);
|
||||||
JSON.parse(JSON.stringify(DEFAULT_LEVELS)),
|
const setStoreLevels = useEditorStore((s) => s.setLevels);
|
||||||
);
|
|
||||||
|
|
||||||
const currentLevels = levels[channel];
|
const currentLevels = levels[channel];
|
||||||
|
|
||||||
const updateLevel = useCallback(
|
const updateLevel = useCallback(
|
||||||
(key: keyof LevelsValues, value: number) => {
|
(key: keyof LevelsValues, value: number) => {
|
||||||
setLevels((prev) => ({
|
setStoreLevels(channel, { [key]: value });
|
||||||
...prev,
|
|
||||||
[channel]: { ...prev[channel], [key]: value },
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
[channel],
|
[channel, setStoreLevels],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAutoLevels = useCallback(() => {
|
const handleAutoLevels = useCallback(() => {
|
||||||
setLevels((prev) => ({
|
setStoreLevels(channel, { blackPoint: 10, whitePoint: 245, gamma: 1 });
|
||||||
...prev,
|
}, [channel, setStoreLevels]);
|
||||||
[channel]: {
|
|
||||||
...prev[channel],
|
|
||||||
blackPoint: 10,
|
|
||||||
whitePoint: 245,
|
|
||||||
gamma: 1,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}, [channel]);
|
|
||||||
|
|
||||||
const channelColors: Record<LevelsChannel, string> = {
|
const channelColors: Record<LevelsChannel, string> = {
|
||||||
rgb: "text-foreground",
|
rgb: "text-foreground",
|
||||||
@@ -616,12 +598,8 @@ function drawTriangle(
|
|||||||
|
|
||||||
function CurvesSection() {
|
function CurvesSection() {
|
||||||
const [channel, setChannel] = useState<CurveChannel>("rgb");
|
const [channel, setChannel] = useState<CurveChannel>("rgb");
|
||||||
const [curves, setCurves] = useState<Record<CurveChannel, CurvePoint[]>>({
|
const curves = useEditorStore((s) => s.curves);
|
||||||
rgb: [...CURVE_PRESETS.Linear],
|
const setStoreCurves = useEditorStore((s) => s.setCurves);
|
||||||
red: [...CURVE_PRESETS.Linear],
|
|
||||||
green: [...CURVE_PRESETS.Linear],
|
|
||||||
blue: [...CURVE_PRESETS.Linear],
|
|
||||||
});
|
|
||||||
const [preset, setPreset] = useState("Linear");
|
const [preset, setPreset] = useState("Linear");
|
||||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -744,14 +722,14 @@ function CurvesSection() {
|
|||||||
} else {
|
} else {
|
||||||
// Add new point
|
// Add new point
|
||||||
const newPoints = [...currentPoints, pos].sort((a, b) => a.x - b.x);
|
const newPoints = [...currentPoints, pos].sort((a, b) => a.x - b.x);
|
||||||
setCurves((prev) => ({ ...prev, [channel]: newPoints }));
|
setStoreCurves(channel, newPoints);
|
||||||
setPreset("Custom");
|
setPreset("Custom");
|
||||||
// Find and start dragging the new point
|
// Find and start dragging the new point
|
||||||
const newIdx = newPoints.findIndex((p) => p.x === pos.x && p.y === pos.y);
|
const newIdx = newPoints.findIndex((p) => p.x === pos.x && p.y === pos.y);
|
||||||
setDraggingIndex(newIdx);
|
setDraggingIndex(newIdx);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getCanvasPos, findNearPoint, currentPoints, channel],
|
[getCanvasPos, findNearPoint, currentPoints, channel, setStoreCurves],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
@@ -759,16 +737,14 @@ function CurvesSection() {
|
|||||||
if (draggingIndex === null) return;
|
if (draggingIndex === null) return;
|
||||||
const pos = getCanvasPos(e);
|
const pos = getCanvasPos(e);
|
||||||
|
|
||||||
setCurves((prev) => {
|
const pts = [...currentPoints];
|
||||||
const pts = [...prev[channel]];
|
pts[draggingIndex] = pos;
|
||||||
pts[draggingIndex] = pos;
|
pts.sort((a, b) => a.x - b.x);
|
||||||
pts.sort((a, b) => a.x - b.x);
|
const newIndex = pts.findIndex((p) => p.x === pos.x && p.y === pos.y);
|
||||||
const newIndex = pts.findIndex((p) => p.x === pos.x && p.y === pos.y);
|
if (newIndex !== -1) setDraggingIndex(newIndex);
|
||||||
if (newIndex !== -1) setDraggingIndex(newIndex);
|
setStoreCurves(channel, pts);
|
||||||
return { ...prev, [channel]: pts };
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[draggingIndex, getCanvasPos, channel],
|
[draggingIndex, getCanvasPos, channel, currentPoints, setStoreCurves],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
@@ -782,11 +758,11 @@ function CurvesSection() {
|
|||||||
|
|
||||||
if (idx >= 0 && currentPoints.length > 2) {
|
if (idx >= 0 && currentPoints.length > 2) {
|
||||||
const newPoints = currentPoints.filter((_, i) => i !== idx);
|
const newPoints = currentPoints.filter((_, i) => i !== idx);
|
||||||
setCurves((prev) => ({ ...prev, [channel]: newPoints }));
|
setStoreCurves(channel, newPoints);
|
||||||
setPreset("Custom");
|
setPreset("Custom");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getCanvasPos, findNearPoint, currentPoints, channel],
|
[getCanvasPos, findNearPoint, currentPoints, channel, setStoreCurves],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePresetChange = useCallback(
|
const handlePresetChange = useCallback(
|
||||||
@@ -794,13 +770,13 @@ function CurvesSection() {
|
|||||||
setPreset(name);
|
setPreset(name);
|
||||||
const presetPoints = CURVE_PRESETS[name];
|
const presetPoints = CURVE_PRESETS[name];
|
||||||
if (presetPoints) {
|
if (presetPoints) {
|
||||||
setCurves((prev) => ({
|
setStoreCurves(
|
||||||
...prev,
|
channel,
|
||||||
[channel]: presetPoints.map((p) => ({ ...p })),
|
presetPoints.map((p) => ({ ...p })),
|
||||||
}));
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[channel],
|
[channel, setStoreCurves],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1038,10 +1014,16 @@ export function AdjustmentsPanel() {
|
|||||||
const filters = useEditorStore((s) => s.filters);
|
const filters = useEditorStore((s) => s.filters);
|
||||||
const resetAdjustments = useEditorStore((s) => s.resetAdjustments);
|
const resetAdjustments = useEditorStore((s) => s.resetAdjustments);
|
||||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
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
|
// Capture imageData from the Konva stage for the histogram
|
||||||
const [histogramData, setHistogramData] = useState<ImageData | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
function captureImageData() {
|
function captureImageData() {
|
||||||
const stage = editorStageRefHolder.current;
|
const stage = editorStageRefHolder.current;
|
||||||
@@ -1059,32 +1041,23 @@ export function AdjustmentsPanel() {
|
|||||||
|
|
||||||
const timer = setTimeout(captureImageData, 100);
|
const timer = setTimeout(captureImageData, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [canvasSize]);
|
}, [canvasSize, historyVersion]);
|
||||||
|
|
||||||
|
const levels = useEditorStore((s) => s.levels);
|
||||||
|
const curves = useEditorStore((s) => s.curves);
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0);
|
const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0);
|
||||||
const hasFilterChanges = filters.some((f) => f.enabled);
|
const hasFilterChanges = filters.some((f) => f.enabled);
|
||||||
return hasAdjustmentChanges || hasFilterChanges;
|
return (
|
||||||
}, [adjustments, filters]);
|
hasAdjustmentChanges ||
|
||||||
|
hasFilterChanges ||
|
||||||
|
hasLevelsAdjustments(levels) ||
|
||||||
|
hasCurvesAdjustments(curves)
|
||||||
|
);
|
||||||
|
}, [adjustments, filters, levels, curves]);
|
||||||
|
|
||||||
const handleResetAll = useCallback(() => {
|
const handleResetAll = useCallback(() => {
|
||||||
const store = useEditorStore.getState();
|
useEditorStore.getState().resetAllAdjustments();
|
||||||
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(() => {
|
const handleApply = useCallback(() => {
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
import { ANALYTICS_EVENTS } from "@snapotter/shared";
|
import { ANALYTICS_EVENTS } from "@snapotter/shared";
|
||||||
import { temporal } from "zundo";
|
import { temporal } from "zundo";
|
||||||
import { create } from "zustand";
|
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 { generateId } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
AdjustmentValues,
|
AdjustmentValues,
|
||||||
@@ -46,12 +55,75 @@ function isCenterBased(obj: CanvasObject): boolean {
|
|||||||
return obj.type === "ellipse" || obj.type === "polygon" || obj.type === "star";
|
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
|
// Extended store state with additional fields/methods not yet in the shared interface
|
||||||
interface EditorStateExtensions {
|
interface EditorStateExtensions {
|
||||||
canvasBackground: string;
|
canvasBackground: string;
|
||||||
commitHistory: (action: string) => void;
|
commitHistory: (action: string) => void;
|
||||||
batchNudge: (objectIds: string[], dx: number, dy: number) => void;
|
batchNudge: (objectIds: string[], dx: number, dy: number) => void;
|
||||||
updateLayerThumbnail: (layerId: string, thumbnailDataUrl: string) => 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 };
|
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 {
|
function createDefaultLayer(id: string, name: string): EditorLayer {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -175,7 +259,6 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
selectionMode: "new" as SelectionMode,
|
selectionMode: "new" as SelectionMode,
|
||||||
magicWandTolerance: 32,
|
magicWandTolerance: 32,
|
||||||
magicWandContiguous: true,
|
magicWandContiguous: true,
|
||||||
selectionFeather: 0,
|
|
||||||
|
|
||||||
// --- Crop ---
|
// --- Crop ---
|
||||||
cropState: null,
|
cropState: null,
|
||||||
@@ -188,6 +271,10 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
params: { ...f.params },
|
params: { ...f.params },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
// --- Levels / Curves (LUT-based tone adjustments) ---
|
||||||
|
levels: defaultLevelsState(),
|
||||||
|
curves: defaultCurvesState(),
|
||||||
|
|
||||||
// --- Text ---
|
// --- Text ---
|
||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
|
|
||||||
@@ -289,10 +376,8 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
setPanOffset: (offset) => set({ panOffset: offset }),
|
setPanOffset: (offset) => set({ panOffset: offset }),
|
||||||
|
|
||||||
loadImage: (url, width, height) => {
|
loadImage: (url, width, height) => {
|
||||||
const oldUrl = get().sourceImageUrl;
|
// The previous image URL stays in undo history (see partialize), so it must not be
|
||||||
if (oldUrl?.startsWith("blob:")) {
|
// revoked here -- undo/redo could restore it and a revoked blob would fail to reload.
|
||||||
URL.revokeObjectURL(oldUrl);
|
|
||||||
}
|
|
||||||
set({
|
set({
|
||||||
sourceImageUrl: url,
|
sourceImageUrl: url,
|
||||||
sourceImageSize: { width, height },
|
sourceImageSize: { width, height },
|
||||||
@@ -309,6 +394,8 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
...f,
|
...f,
|
||||||
params: { ...f.params },
|
params: { ...f.params },
|
||||||
})),
|
})),
|
||||||
|
levels: defaultLevelsState(),
|
||||||
|
curves: defaultCurvesState(),
|
||||||
clipboard: null,
|
clipboard: null,
|
||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
layers: [createDefaultLayer(DEFAULT_LAYER_ID, "Layer 1")],
|
layers: [createDefaultLayer(DEFAULT_LAYER_ID, "Layer 1")],
|
||||||
@@ -411,9 +498,22 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
rotateCanvas: (degrees) => {
|
rotateCanvas: (degrees) => {
|
||||||
const { canvasSize, objects } = get();
|
const { canvasSize, objects, sourceImageUrl } = get();
|
||||||
const newSize =
|
const newSize =
|
||||||
degrees === 180 ? canvasSize : { width: canvasSize.height, height: canvasSize.width };
|
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({
|
set({
|
||||||
canvasSize: newSize,
|
canvasSize: newSize,
|
||||||
sourceImageSize: newSize,
|
sourceImageSize: newSize,
|
||||||
@@ -483,7 +583,16 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
flipCanvasHorizontal: () => {
|
flipCanvasHorizontal: () => {
|
||||||
const { canvasSize, objects } = get();
|
const { canvasSize, objects, sourceImageUrl } = get();
|
||||||
|
if (sourceImageUrl) {
|
||||||
|
rebakeSourceRaster(
|
||||||
|
sourceImageUrl,
|
||||||
|
canvasSize.width,
|
||||||
|
canvasSize.height,
|
||||||
|
"flipH",
|
||||||
|
(newUrl) => set({ sourceImageUrl: newUrl }),
|
||||||
|
);
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
objects: objects.map((obj) => {
|
objects: objects.map((obj) => {
|
||||||
const attrs = { ...obj.attrs };
|
const attrs = { ...obj.attrs };
|
||||||
@@ -511,7 +620,16 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
flipCanvasVertical: () => {
|
flipCanvasVertical: () => {
|
||||||
const { canvasSize, objects } = get();
|
const { canvasSize, objects, sourceImageUrl } = get();
|
||||||
|
if (sourceImageUrl) {
|
||||||
|
rebakeSourceRaster(
|
||||||
|
sourceImageUrl,
|
||||||
|
canvasSize.width,
|
||||||
|
canvasSize.height,
|
||||||
|
"flipV",
|
||||||
|
(newUrl) => set({ sourceImageUrl: newUrl }),
|
||||||
|
);
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
objects: objects.map((obj) => {
|
objects: objects.map((obj) => {
|
||||||
const attrs = { ...obj.attrs };
|
const attrs = { ...obj.attrs };
|
||||||
@@ -663,8 +781,12 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
|
|
||||||
// Objects
|
// Objects
|
||||||
addObject: (obj) => {
|
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({
|
set({
|
||||||
objects: [...get().objects, { ...obj, layerId: obj.layerId || get().activeLayerId }],
|
objects: [...get().objects, { ...obj, layerId: targetLayerId }],
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
lastAction: `Add ${obj.type.charAt(0).toUpperCase() + obj.type.slice(1)}`,
|
lastAction: `Add ${obj.type.charAt(0).toUpperCase() + obj.type.slice(1)}`,
|
||||||
_historyVersion: get()._historyVersion + 1,
|
_historyVersion: get()._historyVersion + 1,
|
||||||
@@ -697,10 +819,19 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeObjects: (ids) => {
|
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({
|
set({
|
||||||
objects: get().objects.filter((obj) => !idSet.has(obj.id)),
|
objects: objects.filter((obj) => !idSet.has(obj.id)),
|
||||||
selectedObjectIds: get().selectedObjectIds.filter((id) => !idSet.has(id)),
|
selectedObjectIds: selectedObjectIds.filter((id) => !idSet.has(id)),
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
lastAction: "Delete",
|
lastAction: "Delete",
|
||||||
_historyVersion: get()._historyVersion + 1,
|
_historyVersion: get()._historyVersion + 1,
|
||||||
@@ -954,9 +1085,68 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
_historyVersion: get()._historyVersion + 1,
|
_historyVersion: get()._historyVersion + 1,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
toggleFilter: (type) => {
|
setLevels: (channel, values) =>
|
||||||
set({
|
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,
|
isDirty: true,
|
||||||
lastAction: `Toggle ${type.charAt(0).toUpperCase() + type.slice(1)} Filter`,
|
lastAction: `Toggle ${type.charAt(0).toUpperCase() + type.slice(1)} Filter`,
|
||||||
_historyVersion: get()._historyVersion + 1,
|
_historyVersion: get()._historyVersion + 1,
|
||||||
@@ -979,7 +1169,6 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
setSelectionMode: (mode) => set({ selectionMode: mode }),
|
setSelectionMode: (mode) => set({ selectionMode: mode }),
|
||||||
setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
|
setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
|
||||||
setMagicWandContiguous: (v: boolean) => set({ magicWandContiguous: v }),
|
setMagicWandContiguous: (v: boolean) => set({ magicWandContiguous: v }),
|
||||||
setSelectionFeather: (v) => set({ selectionFeather: v }),
|
|
||||||
|
|
||||||
invertSelection: () => {
|
invertSelection: () => {
|
||||||
const { selection, canvasSize } = get();
|
const { selection, canvasSize } = get();
|
||||||
@@ -1049,10 +1238,8 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.drawImage(img, -cropState.x, -cropState.y);
|
ctx.drawImage(img, -cropState.x, -cropState.y);
|
||||||
const croppedUrl = offscreen.toDataURL("image/png");
|
const croppedUrl = offscreen.toDataURL("image/png");
|
||||||
const oldUrl = get().sourceImageUrl;
|
// Keep the pre-crop blob URL alive -- it's in undo history and revoking it
|
||||||
if (oldUrl?.startsWith("blob:")) {
|
// would break undo (the restored source would fail to reload).
|
||||||
URL.revokeObjectURL(oldUrl);
|
|
||||||
}
|
|
||||||
set({ sourceImageUrl: croppedUrl });
|
set({ sourceImageUrl: croppedUrl });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1241,10 +1428,15 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
|
|
||||||
// Batch nudge: move multiple objects and create a history entry
|
// Batch nudge: move multiple objects and create a history entry
|
||||||
batchNudge: (objectIds, dx, dy) => {
|
batchNudge: (objectIds, dx, dy) => {
|
||||||
|
const lockedLayerIds = new Set(
|
||||||
|
get()
|
||||||
|
.layers.filter((l) => l.locked)
|
||||||
|
.map((l) => l.id),
|
||||||
|
);
|
||||||
const idSet = new Set(objectIds);
|
const idSet = new Set(objectIds);
|
||||||
set({
|
set({
|
||||||
objects: get().objects.map((obj) => {
|
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 };
|
const attrs = { ...obj.attrs };
|
||||||
if (hasPointsArray(obj)) {
|
if (hasPointsArray(obj)) {
|
||||||
const pts = [...(attrs as { points: number[] }).points];
|
const pts = [...(attrs as { points: number[] }).points];
|
||||||
|
|||||||
@@ -318,7 +318,6 @@ export interface EditorState {
|
|||||||
selectionMode: SelectionMode;
|
selectionMode: SelectionMode;
|
||||||
magicWandTolerance: number;
|
magicWandTolerance: number;
|
||||||
magicWandContiguous: boolean;
|
magicWandContiguous: boolean;
|
||||||
selectionFeather: number;
|
|
||||||
|
|
||||||
// Crop
|
// Crop
|
||||||
cropState: CropState | null;
|
cropState: CropState | null;
|
||||||
@@ -445,7 +444,6 @@ export interface EditorState {
|
|||||||
setSelectionMode: (mode: SelectionMode) => void;
|
setSelectionMode: (mode: SelectionMode) => void;
|
||||||
setMagicWandTolerance: (v: number) => void;
|
setMagicWandTolerance: (v: number) => void;
|
||||||
setMagicWandContiguous: (v: boolean) => void;
|
setMagicWandContiguous: (v: boolean) => void;
|
||||||
setSelectionFeather: (v: number) => void;
|
|
||||||
invertSelection: () => void;
|
invertSelection: () => void;
|
||||||
|
|
||||||
// Crop
|
// Crop
|
||||||
|
|||||||
Reference in New Issue
Block a user