mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: resolve 41 bugs and wire 21 unimplemented features in image editor
Canvas rendering: - Fix Konva filter application order (filters before cache) - Implement 6 missing filters (motionBlur, radialBlur, surfaceBlur, vignette, grain, sharpen) - Implement exposure, vibrance, warmth adjustments as custom Konva filters - Apply layer blend modes via globalCompositeOperation - Apply object effects (drop shadow, outer glow, stroke) to all shapes - Mount SmartGuidesOverlay during move tool drag - Clip pixel grid to visible viewport (200-line cap for performance) Store logic: - resizeImage now scales all objects proportionally (points, radii, fontSize) - rotate/flip/trim handle line/arrow points arrays and center-based objects - applyCrop creates cropped source image via offscreen canvas - invertSelection creates mask from bounds when no mask exists - cutObjects uses single atomic set() to prevent race conditions - sendToBack respects layer ordering in multi-layer documents - Add batchNudge() and commitHistory() for undoable nudge operations - Add updateLayerThumbnail() method Tool hooks: - Fix clone stamp/dodge/burn perf (toDataURL only on mouseUp, not every move) - Fix magic wand zoom/pixelRatio with explicit stage.toCanvas() viewport - Fix eyedropper sampling with unzoomed canvas export - Fix selection tool stale closure via isDrawingRef - Implement polygonal lasso (click-to-place vertices, double-click to close) - Implement selection subtract mode (geometric and mask-based) - Implement gradient live preview during drag - Fix transform/move tool to persist changes and handle ellipse/polygon/star UI wiring: - Mount rulers and guidelines in editor page - Wire histogram with live canvas imageData - Wire autosave recovery with blob-to-dataURL conversion - Wire fill dialog to Shift+Backspace shortcut - Wire eyedropper and transform options to options bar - Fix history panel undo/redo button reactive state via useSyncExternalStore - Fix layer row name click to select layer (timer-based click/dblclick) - Fix zoom animation coordinate drift with progressive store sync - Fix copy merged to use Konva stage composite export Tests: - 49 new unit tests (store fixes + konva filters) - 8 new E2E test files with 39 test cases
This commit is contained in:
@@ -542,9 +542,33 @@ interface AutosaveData {
|
||||
state: AutosaveState;
|
||||
}
|
||||
|
||||
export function saveEditorState(): void {
|
||||
/**
|
||||
* Convert a blob: URL to a data: URL. Returns the original string
|
||||
* if it is not a blob URL or if the fetch fails.
|
||||
*/
|
||||
async function blobUrlToDataUrl(url: string): Promise<string> {
|
||||
if (!url.startsWith("blob:")) return url;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
return await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = () => resolve(url);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveEditorState(): Promise<void> {
|
||||
try {
|
||||
const s = useEditorStore.getState();
|
||||
|
||||
// Convert blob URLs to data URLs so they survive localStorage round-trip
|
||||
const sourceImageUrl = s.sourceImageUrl ? await blobUrlToDataUrl(s.sourceImageUrl) : null;
|
||||
|
||||
const data: AutosaveData = {
|
||||
version: 1,
|
||||
timestamp: Date.now(),
|
||||
@@ -555,16 +579,21 @@ export function saveEditorState(): void {
|
||||
adjustments: s.adjustments,
|
||||
filters: s.filters,
|
||||
guides: s.guides,
|
||||
sourceImageUrl: s.sourceImageUrl,
|
||||
sourceImageUrl,
|
||||
sourceImageSize: s.sourceImageSize,
|
||||
foregroundColor: s.foregroundColor,
|
||||
backgroundColor: s.backgroundColor,
|
||||
},
|
||||
};
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
|
||||
useEditorStore.setState({ lastAutoSave: Date.now() });
|
||||
|
||||
try {
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
|
||||
useEditorStore.setState({ lastAutoSave: Date.now() });
|
||||
} catch (storageErr) {
|
||||
console.warn("[SnapOtter] Autosave failed (localStorage quota may be exceeded):", storageErr);
|
||||
}
|
||||
} catch {
|
||||
// localStorage might be full or unavailable
|
||||
// Serialization or fetch error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,28 @@ import {
|
||||
import useImage from "use-image";
|
||||
import { useCanvasZoom } from "@/hooks/use-canvas-zoom";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { AdjustmentValues, CanvasObject, FilterConfig, ImageAttrs } from "@/types/editor";
|
||||
import type {
|
||||
AdjustmentValues,
|
||||
CanvasObject,
|
||||
FilterConfig,
|
||||
ImageAttrs,
|
||||
ObjectEffects,
|
||||
} from "@/types/editor";
|
||||
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 {
|
||||
createExposureFilter,
|
||||
createGrainFilter,
|
||||
createMotionBlurFilter,
|
||||
createRadialBlurFilter,
|
||||
createSharpenFilter,
|
||||
createSurfaceBlurFilter,
|
||||
createVibranceFilter,
|
||||
createVignetteFilter,
|
||||
createWarmthFilter,
|
||||
} from "./konva-filters";
|
||||
import { useBrushTool } from "./tools/brush-tool";
|
||||
import { useCloneStampTool } from "./tools/clone-stamp-tool";
|
||||
import { CropOverlay } from "./tools/crop-tool";
|
||||
@@ -40,6 +58,36 @@ import { useShapeTool } from "./tools/shape-tool";
|
||||
import { useTextTool } from "./tools/text-tool";
|
||||
import { TransformToolTransformer, useTransformTool } from "./tools/transform-tool";
|
||||
|
||||
// Konva's globalCompositeOperationType is not exported, so we define a compatible alias
|
||||
type GlobalCompositeOperation =
|
||||
| ""
|
||||
| "source-over"
|
||||
| "source-in"
|
||||
| "source-out"
|
||||
| "source-atop"
|
||||
| "destination-over"
|
||||
| "destination-in"
|
||||
| "destination-out"
|
||||
| "destination-atop"
|
||||
| "lighter"
|
||||
| "copy"
|
||||
| "xor"
|
||||
| "multiply"
|
||||
| "screen"
|
||||
| "overlay"
|
||||
| "darken"
|
||||
| "lighten"
|
||||
| "color-dodge"
|
||||
| "color-burn"
|
||||
| "hard-light"
|
||||
| "soft-light"
|
||||
| "difference"
|
||||
| "exclusion"
|
||||
| "hue"
|
||||
| "saturation"
|
||||
| "color"
|
||||
| "luminosity";
|
||||
|
||||
// Module-level stage ref for export dialog access (Issue #6)
|
||||
export const editorStageRefHolder: { current: Konva.Stage | null } = {
|
||||
current: null,
|
||||
@@ -80,6 +128,7 @@ function SourceImage({
|
||||
if (hasActiveAdjustments || hasActiveFilters) {
|
||||
const konvaFilters: Array<((this: Konva.Node, imageData: ImageData) => void) | string> = [];
|
||||
|
||||
// Built-in Konva adjustment filters
|
||||
if (adjustments.brightness !== 0) {
|
||||
konvaFilters.push(KonvaFilters.Filters.Brighten);
|
||||
node.brightness(adjustments.brightness / 100);
|
||||
@@ -95,6 +144,17 @@ function SourceImage({
|
||||
node.luminance(adjustments.luminance / 100);
|
||||
}
|
||||
|
||||
// FIX 3: Custom adjustment filters for exposure, vibrance, warmth
|
||||
if (adjustments.exposure !== 0) {
|
||||
konvaFilters.push(createExposureFilter(adjustments.exposure / 100));
|
||||
}
|
||||
if (adjustments.vibrance !== 0) {
|
||||
konvaFilters.push(createVibranceFilter(adjustments.vibrance));
|
||||
}
|
||||
if (adjustments.warmth !== 0) {
|
||||
konvaFilters.push(createWarmthFilter(adjustments.warmth));
|
||||
}
|
||||
|
||||
// Apply enabled filters
|
||||
for (const f of filters) {
|
||||
if (!f.enabled) continue;
|
||||
@@ -142,12 +202,63 @@ function SourceImage({
|
||||
node.kaleidoscopePower(f.params.power ?? 2);
|
||||
node.kaleidoscopeAngle(f.params.angle ?? 0);
|
||||
break;
|
||||
// FIX 2: Custom filter types
|
||||
case "motionBlur":
|
||||
konvaFilters.push(
|
||||
createMotionBlurFilter({
|
||||
angle: f.params.angle ?? 0,
|
||||
distance: f.params.distance ?? 10,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "radialBlur":
|
||||
konvaFilters.push(
|
||||
createRadialBlurFilter({
|
||||
amount: f.params.amount ?? 10,
|
||||
centerX: f.params.centerX ?? 0.5,
|
||||
centerY: f.params.centerY ?? 0.5,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "surfaceBlur":
|
||||
konvaFilters.push(
|
||||
createSurfaceBlurFilter({
|
||||
radius: f.params.radius ?? 5,
|
||||
threshold: f.params.threshold ?? 25,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "vignette":
|
||||
konvaFilters.push(
|
||||
createVignetteFilter({
|
||||
amount: f.params.amount ?? 50,
|
||||
midpoint: f.params.midpoint ?? 50,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "grain":
|
||||
konvaFilters.push(
|
||||
createGrainFilter({
|
||||
amount: f.params.amount ?? 25,
|
||||
size: f.params.size ?? 25,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case "sharpen":
|
||||
konvaFilters.push(
|
||||
createSharpenFilter({
|
||||
amount: f.params.amount ?? 0,
|
||||
radius: f.params.radius ?? 1,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// FIX 1: Filters must be set BEFORE caching in Konva
|
||||
node.clearCache();
|
||||
node.cache();
|
||||
node.filters(konvaFilters);
|
||||
node.cache();
|
||||
node.getLayer()?.batchDraw();
|
||||
} else {
|
||||
node.clearCache();
|
||||
@@ -206,6 +317,58 @@ function ImageObject({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object Effects helpers (FIX 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute Konva-compatible props from an object's effects configuration.
|
||||
* Returns props for drop shadow, outer glow, and stroke that can be spread
|
||||
* onto any Konva shape node.
|
||||
*/
|
||||
function computeEffectProps(effects?: ObjectEffects): Record<string, unknown> {
|
||||
if (!effects) return {};
|
||||
const props: Record<string, unknown> = {};
|
||||
|
||||
// Drop shadow: uses Konva's built-in shadow support
|
||||
if (effects.dropShadow?.enabled) {
|
||||
const ds = effects.dropShadow;
|
||||
const rad = (ds.angle * Math.PI) / 180;
|
||||
props.shadowColor = ds.color;
|
||||
props.shadowBlur = ds.blur;
|
||||
props.shadowOffsetX = Math.cos(rad) * ds.distance;
|
||||
props.shadowOffsetY = Math.sin(rad) * ds.distance;
|
||||
props.shadowOpacity = ds.opacity;
|
||||
props.shadowEnabled = true;
|
||||
}
|
||||
|
||||
// Outer glow: like drop shadow but with zero offset
|
||||
// Only apply if drop shadow is not already active (Konva has one shadow per node)
|
||||
if (effects.outerGlow?.enabled && !effects.dropShadow?.enabled) {
|
||||
const og = effects.outerGlow;
|
||||
props.shadowColor = og.color;
|
||||
props.shadowBlur = og.blur + og.spread;
|
||||
props.shadowOffsetX = 0;
|
||||
props.shadowOffsetY = 0;
|
||||
props.shadowOpacity = og.opacity;
|
||||
props.shadowEnabled = true;
|
||||
}
|
||||
|
||||
// Stroke effect (outer or center position -- Konva draws strokes centered by default)
|
||||
if (effects.stroke?.enabled) {
|
||||
const s = effects.stroke;
|
||||
props.stroke = s.color;
|
||||
props.strokeWidth = s.position === "inside" ? s.width * 2 : s.width;
|
||||
props.strokeEnabled = true;
|
||||
// For "inside" strokes, we double the width and clip via strokeScaleEnabled
|
||||
if (s.position === "inside") {
|
||||
props.strokeScaleEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas Object Renderer (Issue #3: wire move tool handlers)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -228,6 +391,8 @@ function CanvasObjectRenderer({
|
||||
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
|
||||
}) {
|
||||
const draggable = isMoveTool;
|
||||
// FIX 5: Compute effect props (drop shadow, outer glow, stroke) for all shapes
|
||||
const fx = computeEffectProps(obj.effects);
|
||||
|
||||
switch (obj.type) {
|
||||
case "line": {
|
||||
@@ -249,6 +414,7 @@ function CanvasObjectRenderer({
|
||||
shadowColor={a.shadowColor}
|
||||
shadowOffsetX={a.shadowOffsetX}
|
||||
shadowOffsetY={a.shadowOffsetY}
|
||||
{...fx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -273,6 +439,7 @@ function CanvasObjectRenderer({
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onTransformEnd={onTransformEnd}
|
||||
{...fx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -296,6 +463,7 @@ function CanvasObjectRenderer({
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onTransformEnd={onTransformEnd}
|
||||
{...fx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -325,6 +493,7 @@ function CanvasObjectRenderer({
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onTransformEnd={onTransformEnd}
|
||||
{...fx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -347,6 +516,7 @@ function CanvasObjectRenderer({
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onTransformEnd={onTransformEnd}
|
||||
{...fx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -370,6 +540,7 @@ function CanvasObjectRenderer({
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onTransformEnd={onTransformEnd}
|
||||
{...fx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -394,6 +565,7 @@ function CanvasObjectRenderer({
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onTransformEnd={onTransformEnd}
|
||||
{...fx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -761,7 +933,16 @@ export function EditorCanvas({
|
||||
const layerObjects = objectsByLayer.get(layer.id) ?? [];
|
||||
if (!layer.visible) return null;
|
||||
return (
|
||||
<Group key={layer.id} opacity={layer.opacity} listening={layer.id === activeLayerId}>
|
||||
<Group
|
||||
key={layer.id}
|
||||
opacity={layer.opacity}
|
||||
globalCompositeOperation={
|
||||
layer.blendMode !== "normal"
|
||||
? (layer.blendMode as GlobalCompositeOperation)
|
||||
: undefined
|
||||
}
|
||||
listening={layer.id === activeLayerId}
|
||||
>
|
||||
{layerObjects.map((obj) => (
|
||||
<CanvasObjectRenderer
|
||||
key={obj.id}
|
||||
@@ -783,6 +964,9 @@ export function EditorCanvas({
|
||||
<MoveToolTransformer transformerRef={moveTool.transformerRef} />
|
||||
)}
|
||||
|
||||
{/* FIX 6: Smart guides overlay (shown during drag with move tool) */}
|
||||
{activeTool === "move" && <SmartGuidesOverlay guides={moveTool.smartGuides} />}
|
||||
|
||||
{/* Transform tool transformer */}
|
||||
{activeTool === "transform" && (
|
||||
<TransformToolTransformer transformerRef={transformTool.transformerRef} />
|
||||
@@ -814,6 +998,9 @@ export function EditorCanvas({
|
||||
canvasWidth={canvasSize.width}
|
||||
canvasHeight={canvasSize.height}
|
||||
zoom={zoom}
|
||||
panOffset={panOffset}
|
||||
stageWidth={stageWidth}
|
||||
stageHeight={stageHeight}
|
||||
showGrid={gridVisible}
|
||||
showPixelGrid={zoom >= 8}
|
||||
/>
|
||||
@@ -839,12 +1026,18 @@ function GridOverlay({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
zoom,
|
||||
panOffset,
|
||||
stageWidth,
|
||||
stageHeight,
|
||||
showGrid,
|
||||
showPixelGrid,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
zoom: number;
|
||||
panOffset: { x: number; y: number };
|
||||
stageWidth: number;
|
||||
stageHeight: number;
|
||||
showGrid: boolean;
|
||||
showPixelGrid: boolean;
|
||||
}) {
|
||||
@@ -868,17 +1061,38 @@ function GridOverlay({
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// FIX 7: Clip pixel grid to the visible viewport to avoid drawing
|
||||
// thousands of lines for large images. Cap at 200 lines per axis.
|
||||
if (showPixelGrid) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = "rgba(128, 128, 128, 0.1)";
|
||||
ctx.lineWidth = 1 / zoom;
|
||||
for (let x = 1; x < canvasWidth; x++) {
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, canvasHeight);
|
||||
|
||||
// Calculate visible region in canvas coordinates from pan/zoom
|
||||
const visMinX = Math.max(0, Math.floor(-panOffset.x / zoom));
|
||||
const visMinY = Math.max(0, Math.floor(-panOffset.y / zoom));
|
||||
const visMaxX = Math.min(canvasWidth, Math.ceil((stageWidth - panOffset.x) / zoom));
|
||||
const visMaxY = Math.min(canvasHeight, Math.ceil((stageHeight - panOffset.y) / zoom));
|
||||
|
||||
const MAX_LINES = 200;
|
||||
|
||||
// Determine step: if visible range exceeds max lines, skip pixels
|
||||
const xRange = visMaxX - visMinX;
|
||||
const yRange = visMaxY - visMinY;
|
||||
const xStep = xRange > MAX_LINES ? Math.ceil(xRange / MAX_LINES) : 1;
|
||||
const yStep = yRange > MAX_LINES ? Math.ceil(yRange / MAX_LINES) : 1;
|
||||
|
||||
// Align start to step boundary
|
||||
const startX = Math.max(1, visMinX - (visMinX % xStep) + xStep);
|
||||
const startY = Math.max(1, visMinY - (visMinY % yStep) + yStep);
|
||||
|
||||
for (let x = startX; x < visMaxX; x += xStep) {
|
||||
ctx.moveTo(x, visMinY);
|
||||
ctx.lineTo(x, visMaxY);
|
||||
}
|
||||
for (let y = 1; y < canvasHeight; y++) {
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(canvasWidth, y);
|
||||
for (let y = startY; y < visMaxY; y += yStep) {
|
||||
ctx.moveTo(visMinX, y);
|
||||
ctx.lineTo(visMaxX, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// apps/web/src/components/editor/editor-options-bar.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { ToolType } from "@/types/editor";
|
||||
import { BrushOptions } from "./options/brush-options";
|
||||
import { CloneStampOptions } from "./options/clone-stamp-options";
|
||||
import { CropOptions } from "./options/crop-options";
|
||||
import { DodgeBurnOptions } from "./options/dodge-burn-options";
|
||||
import { EyedropperOptions, type SampleSize } from "./options/eyedropper-options";
|
||||
import { FillOptions } from "./options/fill-options";
|
||||
import { GradientOptions } from "./options/gradient-options";
|
||||
import { MoveOptions } from "./options/move-options";
|
||||
@@ -13,6 +15,8 @@ import { PixelBrushOptions } from "./options/pixel-brush-options";
|
||||
import { SelectionOptions } from "./options/selection-options";
|
||||
import { ShapeOptions } from "./options/shape-options";
|
||||
import { TextOptions } from "./options/text-options";
|
||||
import { TransformOptions } from "./options/transform-options";
|
||||
import { useTransformTool } from "./tools/transform-tool";
|
||||
|
||||
function getOptionsComponent(tool: ToolType): React.ComponentType | null {
|
||||
switch (tool) {
|
||||
@@ -53,6 +57,7 @@ function getOptionsComponent(tool: ToolType): React.ComponentType | null {
|
||||
return ShapeOptions;
|
||||
case "text":
|
||||
return TextOptions;
|
||||
// transform and eyedropper are handled separately in EditorOptionsBar
|
||||
case "transform":
|
||||
case "eyedropper":
|
||||
case "hand":
|
||||
@@ -68,6 +73,16 @@ export function EditorOptionsBar() {
|
||||
|
||||
const OptionsComponent = getOptionsComponent(activeTool);
|
||||
|
||||
// Eyedropper state (managed here since EyedropperOptions requires props)
|
||||
const [eyedropperSampleSize, setEyedropperSampleSize] = useState<SampleSize>(1);
|
||||
const [sampledColor, setSampledColor] = useState<string | null>(null);
|
||||
|
||||
// Transform tool API (managed here since TransformOptions requires props)
|
||||
const transformApi = useTransformTool();
|
||||
|
||||
// Pick sampled color from store foreground when eyedropper is active
|
||||
const foregroundColor = useEditorStore((s) => s.foregroundColor);
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-10 px-3 bg-card border-b border-border gap-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
@@ -79,6 +94,14 @@ export function EditorOptionsBar() {
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{OptionsComponent && <OptionsComponent />}
|
||||
{activeTool === "eyedropper" && (
|
||||
<EyedropperOptions
|
||||
sampleSize={eyedropperSampleSize}
|
||||
onSampleSizeChange={setEyedropperSampleSize}
|
||||
sampledColor={sampledColor ?? foregroundColor}
|
||||
/>
|
||||
)}
|
||||
{activeTool === "transform" && <TransformOptions api={transformApi} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// apps/web/src/components/editor/konva-filters.ts
|
||||
// Custom Konva filter factories for the image editor.
|
||||
// Each factory returns a function `(imageData: ImageData) => void` that mutates pixel data in place.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adjustment Filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Exposure adjustment via gamma curve.
|
||||
* exposure > 0 brightens, exposure < 0 darkens.
|
||||
* Formula: pixel = 255 * pow(pixel/255, 1/(1 + exposure))
|
||||
*/
|
||||
export function createExposureFilter(exposure: number): (imageData: ImageData) => void {
|
||||
return (imageData: ImageData) => {
|
||||
const d = imageData.data;
|
||||
const gamma = 1 / (1 + exposure);
|
||||
// Build a lookup table for performance
|
||||
const lut = new Uint8Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
lut[i] = Math.min(255, Math.max(0, Math.round(255 * (i / 255) ** gamma)));
|
||||
}
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = lut[d[i]];
|
||||
d[i + 1] = lut[d[i + 1]];
|
||||
d[i + 2] = lut[d[i + 2]];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibrance: selective saturation boost that increases saturation more
|
||||
* for less-saturated pixels, preserving already-vivid colors.
|
||||
* amount is in [-100, 100] range.
|
||||
*/
|
||||
export function createVibranceFilter(amount: number): (imageData: ImageData) => void {
|
||||
return (imageData: ImageData) => {
|
||||
const d = imageData.data;
|
||||
const amt = amount / 100;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const r = d[i];
|
||||
const g = d[i + 1];
|
||||
const b = d[i + 2];
|
||||
const maxC = Math.max(r, g, b);
|
||||
const minC = Math.min(r, g, b);
|
||||
// Saturation approximation (0..1)
|
||||
const sat = maxC === 0 ? 0 : (maxC - minC) / maxC;
|
||||
// Boost less-saturated pixels more
|
||||
const boost = amt * (1 - sat);
|
||||
const avg = (r + g + b) / 3;
|
||||
d[i] = Math.min(255, Math.max(0, Math.round(r + (r - avg) * boost)));
|
||||
d[i + 1] = Math.min(255, Math.max(0, Math.round(g + (g - avg) * boost)));
|
||||
d[i + 2] = Math.min(255, Math.max(0, Math.round(b + (b - avg) * boost)));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Warmth / color temperature shift.
|
||||
* Positive warmth: boost red, reduce blue (warmer).
|
||||
* Negative warmth: boost blue, reduce red (cooler).
|
||||
* amount is in [-100, 100] range.
|
||||
*/
|
||||
export function createWarmthFilter(amount: number): (imageData: ImageData) => void {
|
||||
return (imageData: ImageData) => {
|
||||
const d = imageData.data;
|
||||
// Scale to a reasonable pixel shift range
|
||||
const shift = (amount / 100) * 30;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = Math.min(255, Math.max(0, Math.round(d[i] + shift)));
|
||||
d[i + 2] = Math.min(255, Math.max(0, Math.round(d[i + 2] - shift)));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Creative Filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Motion blur: directional box blur along a given angle.
|
||||
* angle in degrees, distance is the blur length in pixels.
|
||||
*/
|
||||
export function createMotionBlurFilter(params: {
|
||||
angle: number;
|
||||
distance: number;
|
||||
}): (imageData: ImageData) => void {
|
||||
return (imageData: ImageData) => {
|
||||
const { angle, distance } = params;
|
||||
if (distance <= 0) return;
|
||||
|
||||
const w = imageData.width;
|
||||
const h = imageData.height;
|
||||
const d = imageData.data;
|
||||
const copy = new Uint8ClampedArray(d);
|
||||
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const dx = Math.cos(rad);
|
||||
const dy = Math.sin(rad);
|
||||
const steps = Math.max(1, Math.round(distance));
|
||||
const half = steps / 2;
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
let rSum = 0;
|
||||
let gSum = 0;
|
||||
let bSum = 0;
|
||||
let aSum = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let s = -half; s <= half; s++) {
|
||||
const sx = Math.round(x + dx * s);
|
||||
const sy = Math.round(y + dy * s);
|
||||
if (sx >= 0 && sx < w && sy >= 0 && sy < h) {
|
||||
const idx = (sy * w + sx) * 4;
|
||||
rSum += copy[idx];
|
||||
gSum += copy[idx + 1];
|
||||
bSum += copy[idx + 2];
|
||||
aSum += copy[idx + 3];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
const idx = (y * w + x) * 4;
|
||||
d[idx] = Math.round(rSum / count);
|
||||
d[idx + 1] = Math.round(gSum / count);
|
||||
d[idx + 2] = Math.round(bSum / count);
|
||||
d[idx + 3] = Math.round(aSum / count);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Radial blur: concentric blur emanating from a center point.
|
||||
* amount controls blur strength, centerX/centerY are 0..1 normalized.
|
||||
*/
|
||||
export function createRadialBlurFilter(params: {
|
||||
amount: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
}): (imageData: ImageData) => void {
|
||||
return (imageData: ImageData) => {
|
||||
const { amount, centerX, centerY } = params;
|
||||
if (amount <= 0) return;
|
||||
|
||||
const w = imageData.width;
|
||||
const h = imageData.height;
|
||||
const d = imageData.data;
|
||||
const copy = new Uint8ClampedArray(d);
|
||||
|
||||
const cx = centerX * w;
|
||||
const cy = centerY * h;
|
||||
const maxDist = Math.sqrt(w * w + h * h) / 2;
|
||||
const samples = Math.max(2, Math.min(32, Math.round(amount / 3)));
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
// Blur strength scales with distance from center
|
||||
const strength = (dist / maxDist) * (amount / 100);
|
||||
|
||||
let rSum = 0;
|
||||
let gSum = 0;
|
||||
let bSum = 0;
|
||||
let aSum = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let s = 0; s < samples; s++) {
|
||||
const t = (s / (samples - 1)) * 2 - 1; // -1..1
|
||||
const sx = Math.round(x + dx * t * strength);
|
||||
const sy = Math.round(y + dy * t * strength);
|
||||
if (sx >= 0 && sx < w && sy >= 0 && sy < h) {
|
||||
const idx = (sy * w + sx) * 4;
|
||||
rSum += copy[idx];
|
||||
gSum += copy[idx + 1];
|
||||
bSum += copy[idx + 2];
|
||||
aSum += copy[idx + 3];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
const idx = (y * w + x) * 4;
|
||||
d[idx] = Math.round(rSum / count);
|
||||
d[idx + 1] = Math.round(gSum / count);
|
||||
d[idx + 2] = Math.round(bSum / count);
|
||||
d[idx + 3] = Math.round(aSum / count);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface blur: bilateral-like filter that blurs while preserving edges.
|
||||
* radius controls the spatial extent, threshold controls the edge sensitivity.
|
||||
*/
|
||||
export function createSurfaceBlurFilter(params: {
|
||||
radius: number;
|
||||
threshold: number;
|
||||
}): (imageData: ImageData) => void {
|
||||
return (imageData: ImageData) => {
|
||||
const { radius, threshold } = params;
|
||||
if (radius <= 0) return;
|
||||
|
||||
const w = imageData.width;
|
||||
const h = imageData.height;
|
||||
const d = imageData.data;
|
||||
const copy = new Uint8ClampedArray(d);
|
||||
|
||||
const r = Math.min(radius, 10); // Cap radius for performance
|
||||
const threshSq = threshold * threshold;
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const idx = (y * w + x) * 4;
|
||||
const cR = copy[idx];
|
||||
const cG = copy[idx + 1];
|
||||
const cB = copy[idx + 2];
|
||||
|
||||
let rSum = 0;
|
||||
let gSum = 0;
|
||||
let bSum = 0;
|
||||
let wSum = 0;
|
||||
|
||||
for (let ky = -r; ky <= r; ky++) {
|
||||
const sy = y + ky;
|
||||
if (sy < 0 || sy >= h) continue;
|
||||
for (let kx = -r; kx <= r; kx++) {
|
||||
const sx = x + kx;
|
||||
if (sx < 0 || sx >= w) continue;
|
||||
|
||||
const sIdx = (sy * w + sx) * 4;
|
||||
const dR = copy[sIdx] - cR;
|
||||
const dG = copy[sIdx + 1] - cG;
|
||||
const dB = copy[sIdx + 2] - cB;
|
||||
const colorDist = dR * dR + dG * dG + dB * dB;
|
||||
|
||||
// Weight falls off as color difference increases
|
||||
const weight = colorDist < threshSq ? 1 - colorDist / threshSq : 0;
|
||||
|
||||
if (weight > 0) {
|
||||
rSum += copy[sIdx] * weight;
|
||||
gSum += copy[sIdx + 1] * weight;
|
||||
bSum += copy[sIdx + 2] * weight;
|
||||
wSum += weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wSum > 0) {
|
||||
d[idx] = Math.round(rSum / wSum);
|
||||
d[idx + 1] = Math.round(gSum / wSum);
|
||||
d[idx + 2] = Math.round(bSum / wSum);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vignette: darken edges in a radial gradient pattern.
|
||||
* amount controls darkness (0..100), midpoint controls where falloff begins (0..100).
|
||||
*/
|
||||
export function createVignetteFilter(params: {
|
||||
amount: number;
|
||||
midpoint: number;
|
||||
}): (imageData: ImageData) => void {
|
||||
return (imageData: ImageData) => {
|
||||
const { amount, midpoint } = params;
|
||||
if (amount <= 0) return;
|
||||
|
||||
const w = imageData.width;
|
||||
const h = imageData.height;
|
||||
const d = imageData.data;
|
||||
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const strength = amount / 100;
|
||||
const mid = midpoint / 100;
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const dx = (x - cx) / cx;
|
||||
const dy = (y - cy) / cy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
// Smooth falloff: no darkening below midpoint, ramps up after
|
||||
const factor = Math.max(0, (dist - mid) / (Math.SQRT2 - mid));
|
||||
const darken = 1 - factor * factor * strength;
|
||||
|
||||
const idx = (y * w + x) * 4;
|
||||
d[idx] = Math.round(d[idx] * darken);
|
||||
d[idx + 1] = Math.round(d[idx + 1] * darken);
|
||||
d[idx + 2] = Math.round(d[idx + 2] * darken);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grain: add random noise overlay to simulate film grain.
|
||||
* amount controls intensity (0..100), size controls grain size (1..100).
|
||||
*/
|
||||
export function createGrainFilter(params: {
|
||||
amount: number;
|
||||
size: number;
|
||||
}): (imageData: ImageData) => void {
|
||||
return (imageData: ImageData) => {
|
||||
const { amount, size } = params;
|
||||
if (amount <= 0) return;
|
||||
|
||||
const w = imageData.width;
|
||||
const h = imageData.height;
|
||||
const d = imageData.data;
|
||||
|
||||
const intensity = (amount / 100) * 80; // Max noise amplitude in pixel values
|
||||
const grainSize = Math.max(1, Math.round((size / 100) * 4)); // 1..4 pixel blocks
|
||||
|
||||
// Pre-generate noise grid at reduced resolution for grain size > 1
|
||||
const nw = Math.ceil(w / grainSize);
|
||||
const nh = Math.ceil(h / grainSize);
|
||||
const noise = new Float32Array(nw * nh);
|
||||
for (let i = 0; i < noise.length; i++) {
|
||||
noise[i] = (Math.random() - 0.5) * 2 * intensity;
|
||||
}
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
const ny = Math.floor(y / grainSize);
|
||||
for (let x = 0; x < w; x++) {
|
||||
const nx = Math.floor(x / grainSize);
|
||||
const n = noise[ny * nw + nx];
|
||||
const idx = (y * w + x) * 4;
|
||||
d[idx] = Math.min(255, Math.max(0, Math.round(d[idx] + n)));
|
||||
d[idx + 1] = Math.min(255, Math.max(0, Math.round(d[idx + 1] + n)));
|
||||
d[idx + 2] = Math.min(255, Math.max(0, Math.round(d[idx + 2] + n)));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sharpen: unsharp mask convolution.
|
||||
* amount controls sharpening strength (0..100), radius controls kernel size.
|
||||
*/
|
||||
export function createSharpenFilter(params: {
|
||||
amount: number;
|
||||
radius: number;
|
||||
}): (imageData: ImageData) => void {
|
||||
return (imageData: ImageData) => {
|
||||
const { amount, radius } = params;
|
||||
if (amount <= 0) return;
|
||||
|
||||
const w = imageData.width;
|
||||
const h = imageData.height;
|
||||
const d = imageData.data;
|
||||
const copy = new Uint8ClampedArray(d);
|
||||
|
||||
const r = Math.max(1, Math.min(Math.round(radius), 5));
|
||||
const strength = amount / 100;
|
||||
|
||||
// Simple box blur for the "unsharp" step
|
||||
const blurred = new Float32Array(w * h * 4);
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
let rSum = 0;
|
||||
let gSum = 0;
|
||||
let bSum = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let ky = -r; ky <= r; ky++) {
|
||||
const sy = Math.min(h - 1, Math.max(0, y + ky));
|
||||
for (let kx = -r; kx <= r; kx++) {
|
||||
const sx = Math.min(w - 1, Math.max(0, x + kx));
|
||||
const sIdx = (sy * w + sx) * 4;
|
||||
rSum += copy[sIdx];
|
||||
gSum += copy[sIdx + 1];
|
||||
bSum += copy[sIdx + 2];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
const idx = (y * w + x) * 4;
|
||||
blurred[idx] = rSum / count;
|
||||
blurred[idx + 1] = gSum / count;
|
||||
blurred[idx + 2] = bSum / count;
|
||||
}
|
||||
}
|
||||
|
||||
// Unsharp mask: original + strength * (original - blurred)
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = Math.min(255, Math.max(0, Math.round(copy[i] + strength * (copy[i] - blurred[i]))));
|
||||
d[i + 1] = Math.min(
|
||||
255,
|
||||
Math.max(0, Math.round(copy[i + 1] + strength * (copy[i + 1] - blurred[i + 1]))),
|
||||
);
|
||||
d[i + 2] = Math.min(
|
||||
255,
|
||||
Math.max(0, Math.round(copy[i + 2] + strength * (copy[i + 2] - blurred[i + 2]))),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { SliderRow } from "@/components/editor/common/slider-row";
|
||||
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
|
||||
import { HistogramPanel } from "@/components/editor/panels/histogram-panel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
@@ -1035,6 +1036,36 @@ export function AdjustmentsPanel() {
|
||||
const adjustments = useEditorStore((s) => s.adjustments);
|
||||
const filters = useEditorStore((s) => s.filters);
|
||||
const resetAdjustments = useEditorStore((s) => s.resetAdjustments);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
|
||||
// Capture imageData from the Konva stage for the histogram
|
||||
const [histogramData, setHistogramData] = useState<ImageData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function captureImageData() {
|
||||
const stage = editorStageRefHolder.current;
|
||||
if (!stage) return;
|
||||
try {
|
||||
const canvas = stage.toCanvas({
|
||||
pixelRatio: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
});
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
setHistogramData(data);
|
||||
} catch {
|
||||
// Stage may not be ready yet
|
||||
}
|
||||
}
|
||||
|
||||
// Capture on mount and when adjustments/filters change
|
||||
const timer = setTimeout(captureImageData, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [adjustments, filters, canvasSize]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0);
|
||||
@@ -1079,7 +1110,7 @@ export function AdjustmentsPanel() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
{/* Histogram */}
|
||||
<HistogramPanel />
|
||||
<HistogramPanel imageData={histogramData} />
|
||||
|
||||
{/* Auto Adjustments */}
|
||||
<SectionHeader title="Auto" />
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
Type,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
@@ -85,6 +85,18 @@ export function HistoryPanel() {
|
||||
// Force re-render when history changes by subscribing to history version
|
||||
useEditorStore((s) => s._historyVersion);
|
||||
|
||||
// Subscribe reactively to temporal state for undo/redo button disabled states
|
||||
const pastLength = useSyncExternalStore(
|
||||
(cb) => useEditorStore.temporal.subscribe(cb),
|
||||
() => useEditorStore.temporal.getState().pastStates.length,
|
||||
);
|
||||
const futureLength = useSyncExternalStore(
|
||||
(cb) => useEditorStore.temporal.subscribe(cb),
|
||||
() => useEditorStore.temporal.getState().futureStates.length,
|
||||
);
|
||||
const canUndo = pastLength > 0;
|
||||
const canRedo = futureLength > 0;
|
||||
|
||||
const undo = useCallback(() => {
|
||||
useEditorStore.temporal.getState().undo();
|
||||
}, []);
|
||||
@@ -151,10 +163,10 @@ export function HistoryPanel() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={undo}
|
||||
disabled={useEditorStore.temporal.getState().pastStates.length === 0}
|
||||
disabled={!canUndo}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
useEditorStore.temporal.getState().pastStates.length > 0
|
||||
canUndo
|
||||
? "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
: "text-muted-foreground/30 cursor-not-allowed",
|
||||
)}
|
||||
@@ -166,10 +178,10 @@ export function HistoryPanel() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={redo}
|
||||
disabled={useEditorStore.temporal.getState().futureStates.length === 0}
|
||||
disabled={!canRedo}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
useEditorStore.temporal.getState().futureStates.length > 0
|
||||
canRedo
|
||||
? "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
: "text-muted-foreground/30 cursor-not-allowed",
|
||||
)}
|
||||
@@ -178,9 +190,7 @@ export function HistoryPanel() {
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
</button>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
{useEditorStore.temporal.getState().pastStates.length} / 50
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">{pastLength} / 50</span>
|
||||
</div>
|
||||
|
||||
{/* History list */}
|
||||
|
||||
@@ -369,9 +369,31 @@ function LayerRow({
|
||||
setEditing(false);
|
||||
}, [editName, layer.name, onRename]);
|
||||
|
||||
// Timer-based single-click vs double-click differentiation for the name button
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleNameClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (clickTimerRef.current) {
|
||||
// Second click within threshold: enter rename mode
|
||||
clearTimeout(clickTimerRef.current);
|
||||
clickTimerRef.current = null;
|
||||
setEditing(true);
|
||||
} else {
|
||||
// First click: start timer; if no second click, select the layer
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
clickTimerRef.current = null;
|
||||
onSelect();
|
||||
}, 250);
|
||||
}
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditing(true);
|
||||
// Handled by the timer-based approach in handleNameClick
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -526,6 +548,7 @@ function LayerRow({
|
||||
"block text-xs truncate text-left bg-transparent border-0 p-0 w-full cursor-pointer",
|
||||
isActive ? "text-foreground font-medium" : "text-muted-foreground",
|
||||
)}
|
||||
onClick={handleNameClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`layer-name-${layer.id}`}
|
||||
|
||||
@@ -145,11 +145,25 @@ export function useCloneStampTool(stageRef: React.RefObject<Konva.Stage | null>)
|
||||
|
||||
paintDab(ctx, sourceSnapshot, x, y, offsetX, offsetY, brushSize, brushOpacity, canvasSize);
|
||||
|
||||
const dataUrl = canvas.toDataURL();
|
||||
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
|
||||
// Use canvas element directly as image source during the stroke instead of
|
||||
// converting to a data URL on every mouse move (major perf fix).
|
||||
// The "image" property is a runtime-only hint for the renderer; cast to bypass strict attrs type.
|
||||
useEditorStore
|
||||
.getState()
|
||||
.updateObject(objectId, { image: canvas } as unknown as Record<string, unknown>);
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (stampRef.current) {
|
||||
const { canvas, objectId } = stampRef.current;
|
||||
const dataUrl = canvas.toDataURL();
|
||||
useEditorStore
|
||||
.getState()
|
||||
.updateObject(objectId, { src: dataUrl, image: undefined } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
}
|
||||
stampRef.current = null;
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -179,11 +179,24 @@ export function useDodgeBurnTool(stageRef: React.RefObject<Konva.Stage | null>)
|
||||
|
||||
applyBrushDab(ctx, sourceSnapshot, x, y, canvasSize);
|
||||
|
||||
const dataUrl = canvas.toDataURL();
|
||||
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
|
||||
// Use canvas element directly as image source during the stroke instead of
|
||||
// converting to a data URL on every mouse move (major perf fix).
|
||||
useEditorStore
|
||||
.getState()
|
||||
.updateObject(objectId, { image: canvas } as unknown as Record<string, unknown>);
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (strokeRef.current) {
|
||||
const { canvas, objectId } = strokeRef.current;
|
||||
const dataUrl = canvas.toDataURL();
|
||||
useEditorStore
|
||||
.getState()
|
||||
.updateObject(objectId, { src: dataUrl, image: undefined } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
}
|
||||
strokeRef.current = null;
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -49,29 +49,41 @@ interface UseEyedropperToolOptions {
|
||||
sampleSize: SampleSize;
|
||||
}
|
||||
|
||||
export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOptions) {
|
||||
export function useEyedropperTool({
|
||||
stageRef,
|
||||
sampleSize: _sampleSizeProp,
|
||||
}: UseEyedropperToolOptions) {
|
||||
const setForegroundColor = useEditorStore((s) => s.setForegroundColor);
|
||||
const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor);
|
||||
const zoom = useEditorStore((s) => s.zoom);
|
||||
const panOffset = useEditorStore((s) => s.panOffset);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
// Use the sampleSize prop directly (caller wires it from options state)
|
||||
const sampleSize: SampleSize = _sampleSizeProp;
|
||||
const [sampledColor, setSampledColor] = useState<string | null>(null);
|
||||
const canvasCache = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
/**
|
||||
* Export the visible stage to a flat canvas for pixel sampling.
|
||||
* Uses explicit viewport options to get a consistent unzoomed canvas,
|
||||
* excluding zoom/pan transforms and device pixel ratio.
|
||||
* Cached so repeated clicks during one drag don't re-export.
|
||||
*/
|
||||
const getStageCanvas = useCallback((): HTMLCanvasElement | null => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return null;
|
||||
|
||||
// Use toCanvas to get a composited view of all visible layers
|
||||
// Specify explicit viewport to exclude zoom/pan transforms
|
||||
const canvas = stage.toCanvas({
|
||||
pixelRatio: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
});
|
||||
canvasCache.current = canvas;
|
||||
return canvas;
|
||||
}, [stageRef]);
|
||||
}, [stageRef, canvasSize]);
|
||||
|
||||
/**
|
||||
* Invalidate the cache (call on mousedown so we get fresh data).
|
||||
@@ -102,8 +114,8 @@ export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOpt
|
||||
const x = Math.round((pointer.x - panOffset.x) / zoom);
|
||||
const y = Math.round((pointer.y - panOffset.y) / zoom);
|
||||
|
||||
// Bounds check
|
||||
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) {
|
||||
// Bounds check against the actual canvas dimensions (unzoomed)
|
||||
if (x < 0 || y < 0 || x >= canvasSize.width || y >= canvasSize.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -111,7 +123,7 @@ export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOpt
|
||||
setSampledColor(color);
|
||||
return color;
|
||||
},
|
||||
[getStageCanvas, sampleSize, zoom, panOffset],
|
||||
[getStageCanvas, sampleSize, zoom, panOffset, canvasSize],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// apps/web/src/components/editor/tools/gradient-tool.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject } from "@/types/editor";
|
||||
@@ -11,8 +11,17 @@ interface DragState {
|
||||
startY: number;
|
||||
}
|
||||
|
||||
export interface GradientPreview {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
gradientType: "linear" | "radial";
|
||||
}
|
||||
|
||||
export function useGradientTool() {
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const [preview, setPreview] = useState<GradientPreview | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const { activeTool, zoom, panOffset } = useEditorStore.getState();
|
||||
@@ -29,15 +38,34 @@ export function useGradientTool() {
|
||||
const y = (pointer.y - panOffset.y) / zoom;
|
||||
|
||||
dragRef.current = { startX: x, startY: y };
|
||||
|
||||
const { gradientType } = useEditorStore.getState();
|
||||
setPreview({ startX: x, startY: y, endX: x, endY: y, gradientType });
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((_e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
// Could show a preview line/circle here; keeping simple for now
|
||||
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (!dragRef.current) return;
|
||||
|
||||
const { zoom, panOffset, gradientType } = useEditorStore.getState();
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const endX = (pointer.x - panOffset.x) / zoom;
|
||||
const endY = (pointer.y - panOffset.y) / zoom;
|
||||
const { startX, startY } = dragRef.current;
|
||||
|
||||
setPreview({ startX, startY, endX, endY, gradientType });
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (!dragRef.current) return;
|
||||
|
||||
setPreview(null);
|
||||
|
||||
const {
|
||||
foregroundColor,
|
||||
backgroundColor,
|
||||
@@ -115,5 +143,5 @@ export function useGradientTool() {
|
||||
dragRef.current = null;
|
||||
}, []);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp };
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp, preview };
|
||||
}
|
||||
|
||||
@@ -383,22 +383,44 @@ export function useMoveTool(): MoveToolApi {
|
||||
const onTransformEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<Event>) => {
|
||||
const node = e.target;
|
||||
const id = node.id();
|
||||
const scaleX = node.scaleX();
|
||||
const scaleY = node.scaleY();
|
||||
|
||||
// Normalize scale into width/height
|
||||
// Normalize scale into absolute dimensions
|
||||
const newWidth = Math.max(1, node.width() * scaleX);
|
||||
const newHeight = Math.max(1, node.height() * scaleY);
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
|
||||
updateObject(node.id(), {
|
||||
// Look up the object type to write the correct attributes
|
||||
const obj = useEditorStore.getState().objects.find((o) => o.id === id);
|
||||
const newAttrs: Record<string, unknown> = {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
rotation: node.rotation(),
|
||||
});
|
||||
};
|
||||
|
||||
if (obj?.type === "ellipse") {
|
||||
// Convert width/height to radiusX/radiusY for ellipses
|
||||
newAttrs.radiusX = newWidth / 2;
|
||||
newAttrs.radiusY = newHeight / 2;
|
||||
} else if (obj?.type === "polygon") {
|
||||
// Convert to radius for polygons (average of width/height / 2)
|
||||
newAttrs.radius = (newWidth + newHeight) / 4;
|
||||
} else if (obj?.type === "star") {
|
||||
// Scale outerRadius and innerRadius proportionally for stars
|
||||
const starAttrs = obj.attrs as { outerRadius: number; innerRadius: number };
|
||||
const avgScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2;
|
||||
newAttrs.outerRadius = starAttrs.outerRadius * avgScale;
|
||||
newAttrs.innerRadius = starAttrs.innerRadius * avgScale;
|
||||
} else {
|
||||
// Rectangles, images, text: use width/height directly
|
||||
newAttrs.width = newWidth;
|
||||
newAttrs.height = newHeight;
|
||||
}
|
||||
|
||||
updateObject(id, newAttrs);
|
||||
},
|
||||
[updateObject],
|
||||
);
|
||||
|
||||
@@ -113,11 +113,24 @@ export function usePixelBrushTool(stageRef: React.RefObject<Konva.Stage | null>)
|
||||
strokeRef.current.lastX = x;
|
||||
strokeRef.current.lastY = y;
|
||||
|
||||
const dataUrl = canvas.toDataURL();
|
||||
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
|
||||
// Use canvas element directly as image source during the stroke instead of
|
||||
// converting to a data URL on every mouse move (major perf fix).
|
||||
useEditorStore
|
||||
.getState()
|
||||
.updateObject(objectId, { image: canvas } as unknown as Record<string, unknown>);
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (strokeRef.current) {
|
||||
const { canvas, objectId } = strokeRef.current;
|
||||
const dataUrl = canvas.toDataURL();
|
||||
useEditorStore
|
||||
.getState()
|
||||
.updateObject(objectId, { src: dataUrl, image: undefined } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
}
|
||||
strokeRef.current = null;
|
||||
}, []);
|
||||
|
||||
@@ -203,12 +216,7 @@ function applyBoxBlur(
|
||||
for (let kx = -kernelSize; kx <= kernelSize; kx++) {
|
||||
const sx = startX + px + kx;
|
||||
const sy = startY + py + ky;
|
||||
if (
|
||||
sx < 0 ||
|
||||
sx >= sourceWidth ||
|
||||
sy < 0 ||
|
||||
sy >= source.height / (source.width / sourceWidth)
|
||||
) {
|
||||
if (sx < 0 || sx >= sourceWidth || sy < 0 || sy >= source.height) {
|
||||
continue;
|
||||
}
|
||||
const si = (sy * sourceWidth + sx) * 4;
|
||||
|
||||
@@ -238,6 +238,9 @@ export function useSelectionTool(): SelectionToolApi {
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [currentPoints, setCurrentPoints] = useState<number[]>([]);
|
||||
const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const isDrawingRef = useRef(false);
|
||||
// Polygon vertices for lasso-poly mode
|
||||
const polyVerticesRef = useRef<number[]>([]);
|
||||
|
||||
const selectionMode = useEditorStore((s) => s.selectionMode);
|
||||
|
||||
@@ -256,71 +259,227 @@ export function useSelectionTool(): SelectionToolApi {
|
||||
const nb = newSel.bounds;
|
||||
|
||||
if (mode === "add") {
|
||||
// Union of the two bounding boxes
|
||||
const x = Math.min(eb.x, nb.x);
|
||||
const y = Math.min(eb.y, nb.y);
|
||||
setSelection({
|
||||
...newSel,
|
||||
bounds: {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(eb.x + eb.width, nb.x + nb.width) - x,
|
||||
height: Math.max(eb.y + eb.height, nb.y + nb.height) - y,
|
||||
},
|
||||
});
|
||||
const right = Math.max(eb.x + eb.width, nb.x + nb.width);
|
||||
const bottom = Math.max(eb.y + eb.height, nb.y + nb.height);
|
||||
|
||||
// For mask-based selections (wand, lasso), merge masks
|
||||
if (existingSelection.mask && newSel.mask) {
|
||||
const unionW = right - x;
|
||||
const unionH = bottom - y;
|
||||
const merged = new Uint8Array(unionW * unionH);
|
||||
// Copy existing mask into merged
|
||||
for (let row = 0; row < eb.height; row++) {
|
||||
for (let col = 0; col < eb.width; col++) {
|
||||
const si = row * eb.width + col;
|
||||
const di = (row + eb.y - y) * unionW + (col + eb.x - x);
|
||||
if (existingSelection.mask[si]) merged[di] = 1;
|
||||
}
|
||||
}
|
||||
// OR new mask into merged
|
||||
for (let row = 0; row < nb.height; row++) {
|
||||
for (let col = 0; col < nb.width; col++) {
|
||||
const si = row * nb.width + col;
|
||||
const di = (row + nb.y - y) * unionW + (col + nb.x - x);
|
||||
if (newSel.mask?.[si]) merged[di] = 1;
|
||||
}
|
||||
}
|
||||
setSelection({
|
||||
type: newSel.type,
|
||||
points: [],
|
||||
bounds: { x, y, width: unionW, height: unionH },
|
||||
mask: merged,
|
||||
});
|
||||
} else {
|
||||
setSelection({
|
||||
...newSel,
|
||||
bounds: { x, y, width: right - x, height: bottom - y },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// subtract: use the new bounds minus overlap (simplified)
|
||||
setSelection(newSel);
|
||||
// Subtract mode: remove the intersection of the new selection from the existing one
|
||||
if (existingSelection.mask && newSel.mask) {
|
||||
// Mask-based subtract: AND inverse of new mask with old mask
|
||||
const result = new Uint8Array(eb.width * eb.height);
|
||||
for (let row = 0; row < eb.height; row++) {
|
||||
for (let col = 0; col < eb.width; col++) {
|
||||
const absX = eb.x + col;
|
||||
const absY = eb.y + row;
|
||||
const ei = row * eb.width + col;
|
||||
// Check if this pixel is in the new selection's mask
|
||||
const relX = absX - nb.x;
|
||||
const relY = absY - nb.y;
|
||||
let inNew = false;
|
||||
if (relX >= 0 && relX < nb.width && relY >= 0 && relY < nb.height) {
|
||||
inNew = newSel.mask?.[relY * nb.width + relX] === 1;
|
||||
}
|
||||
result[ei] = existingSelection.mask[ei] && !inNew ? 1 : 0;
|
||||
}
|
||||
}
|
||||
setSelection({
|
||||
type: existingSelection.type,
|
||||
points: existingSelection.points,
|
||||
bounds: eb,
|
||||
mask: result,
|
||||
});
|
||||
} else {
|
||||
// Geometric subtract for rectangular selections
|
||||
// If the new selection fully contains the old one, deselect
|
||||
if (
|
||||
nb.x <= eb.x &&
|
||||
nb.y <= eb.y &&
|
||||
nb.x + nb.width >= eb.x + eb.width &&
|
||||
nb.y + nb.height >= eb.y + eb.height
|
||||
) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
// Otherwise keep the existing selection with its bounds reduced where they overlap
|
||||
// This is a simplified approach: we keep the existing bounds but trim from the side
|
||||
// with the largest overlap
|
||||
const overlapX1 = Math.max(eb.x, nb.x);
|
||||
const overlapY1 = Math.max(eb.y, nb.y);
|
||||
const overlapX2 = Math.min(eb.x + eb.width, nb.x + nb.width);
|
||||
const overlapY2 = Math.min(eb.y + eb.height, nb.y + nb.height);
|
||||
|
||||
// No overlap means nothing to subtract
|
||||
if (overlapX2 <= overlapX1 || overlapY2 <= overlapY1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which side the overlap is on and trim accordingly
|
||||
const trimLeft = overlapX1 === eb.x ? overlapX2 - eb.x : 0;
|
||||
const trimRight = overlapX2 === eb.x + eb.width ? eb.x + eb.width - overlapX1 : 0;
|
||||
const trimTop = overlapY1 === eb.y ? overlapY2 - eb.y : 0;
|
||||
const trimBottom = overlapY2 === eb.y + eb.height ? eb.y + eb.height - overlapY1 : 0;
|
||||
|
||||
const maxTrim = Math.max(trimLeft, trimRight, trimTop, trimBottom);
|
||||
let newBounds = { ...eb };
|
||||
if (maxTrim === trimLeft && trimLeft > 0) {
|
||||
newBounds = {
|
||||
x: eb.x + trimLeft,
|
||||
y: eb.y,
|
||||
width: eb.width - trimLeft,
|
||||
height: eb.height,
|
||||
};
|
||||
} else if (maxTrim === trimRight && trimRight > 0) {
|
||||
newBounds = { x: eb.x, y: eb.y, width: eb.width - trimRight, height: eb.height };
|
||||
} else if (maxTrim === trimTop && trimTop > 0) {
|
||||
newBounds = {
|
||||
x: eb.x,
|
||||
y: eb.y + trimTop,
|
||||
width: eb.width,
|
||||
height: eb.height - trimTop,
|
||||
};
|
||||
} else if (maxTrim === trimBottom && trimBottom > 0) {
|
||||
newBounds = { x: eb.x, y: eb.y, width: eb.width, height: eb.height - trimBottom };
|
||||
}
|
||||
|
||||
if (newBounds.width <= 0 || newBounds.height <= 0) {
|
||||
setSelection(null);
|
||||
} else {
|
||||
setSelection({
|
||||
type: existingSelection.type,
|
||||
points: existingSelection.points,
|
||||
bounds: newBounds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[existingSelection, setSelection],
|
||||
);
|
||||
|
||||
const isPolyLasso = useCallback(() => {
|
||||
return useEditorStore.getState().activeTool === "lasso-poly";
|
||||
}, []);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(pos: { x: number; y: number }, _stage?: Konva.Stage) => {
|
||||
setIsDrawing(true);
|
||||
startRef.current = pos;
|
||||
if (selectionType === "lasso") {
|
||||
setCurrentPoints([pos.x, pos.y]);
|
||||
if (selectionType === "lasso" && isPolyLasso()) {
|
||||
// Polygonal lasso: each click adds a vertex
|
||||
if (!isDrawingRef.current) {
|
||||
// Start a new polygon
|
||||
setIsDrawing(true);
|
||||
isDrawingRef.current = true;
|
||||
polyVerticesRef.current = [pos.x, pos.y];
|
||||
setCurrentPoints([pos.x, pos.y]);
|
||||
} else {
|
||||
// Add another vertex
|
||||
polyVerticesRef.current = [...polyVerticesRef.current, pos.x, pos.y];
|
||||
setCurrentPoints([...polyVerticesRef.current]);
|
||||
}
|
||||
} else {
|
||||
setCurrentPoints([]);
|
||||
// Freehand lasso, rect, or ellipse
|
||||
setIsDrawing(true);
|
||||
isDrawingRef.current = true;
|
||||
startRef.current = pos;
|
||||
if (selectionType === "lasso") {
|
||||
setCurrentPoints([pos.x, pos.y]);
|
||||
} else {
|
||||
setCurrentPoints([]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectionType],
|
||||
[selectionType, isPolyLasso],
|
||||
);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(pos: { x: number; y: number }) => {
|
||||
if (!isDrawing) return;
|
||||
if (!isDrawingRef.current) return;
|
||||
|
||||
if (selectionType === "lasso") {
|
||||
if (selectionType === "lasso" && isPolyLasso()) {
|
||||
// Polygonal lasso: show rubber band line from last vertex to cursor
|
||||
const verts = polyVerticesRef.current;
|
||||
setCurrentPoints([...verts, pos.x, pos.y]);
|
||||
} else if (selectionType === "lasso") {
|
||||
// Freehand lasso
|
||||
setCurrentPoints((prev) => [...prev, pos.x, pos.y]);
|
||||
} else {
|
||||
const s = startRef.current;
|
||||
setCurrentPoints([s.x, s.y, pos.x, pos.y]);
|
||||
}
|
||||
},
|
||||
[isDrawing, selectionType],
|
||||
[selectionType, isPolyLasso],
|
||||
);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
if (!isDrawing) return;
|
||||
setIsDrawing(false);
|
||||
|
||||
if (selectionType === "lasso") {
|
||||
if (currentPoints.length < 6) {
|
||||
const finalizeLasso = useCallback(
|
||||
(points: number[]) => {
|
||||
if (points.length < 6) {
|
||||
setSelection(null);
|
||||
setCurrentPoints([]);
|
||||
return;
|
||||
}
|
||||
const xs = currentPoints.filter((_, i) => i % 2 === 0);
|
||||
const ys = currentPoints.filter((_, i) => i % 2 === 1);
|
||||
const xs = points.filter((_, i) => i % 2 === 0);
|
||||
const ys = points.filter((_, i) => i % 2 === 1);
|
||||
const bounds = {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
};
|
||||
mergeSelection({ type: "lasso", points: currentPoints, bounds }, selectionMode);
|
||||
mergeSelection({ type: "lasso", points, bounds }, selectionMode);
|
||||
setCurrentPoints([]);
|
||||
},
|
||||
[selectionMode, mergeSelection, setSelection],
|
||||
);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
if (!isDrawingRef.current) return;
|
||||
|
||||
if (selectionType === "lasso" && isPolyLasso()) {
|
||||
// Polygonal lasso: mouseUp does NOT close the polygon, only dblclick does.
|
||||
// The vertex was already added in onMouseDown, so nothing to do here.
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDrawing(false);
|
||||
isDrawingRef.current = false;
|
||||
|
||||
if (selectionType === "lasso") {
|
||||
finalizeLasso(currentPoints);
|
||||
} else {
|
||||
if (currentPoints.length < 4) {
|
||||
setCurrentPoints([]);
|
||||
@@ -346,30 +505,26 @@ export function useSelectionTool(): SelectionToolApi {
|
||||
);
|
||||
}
|
||||
setCurrentPoints([]);
|
||||
}, [isDrawing, currentPoints, selectionType, selectionMode, mergeSelection, setSelection]);
|
||||
}, [
|
||||
currentPoints,
|
||||
selectionType,
|
||||
selectionMode,
|
||||
mergeSelection,
|
||||
setSelection,
|
||||
finalizeLasso,
|
||||
isPolyLasso,
|
||||
]);
|
||||
|
||||
const onDoubleClick = useCallback(() => {
|
||||
// Close polygonal lasso
|
||||
if (selectionType === "lasso" && currentPoints.length >= 6) {
|
||||
if (selectionType === "lasso" && isDrawingRef.current) {
|
||||
setIsDrawing(false);
|
||||
const xs = currentPoints.filter((_, i) => i % 2 === 0);
|
||||
const ys = currentPoints.filter((_, i) => i % 2 === 1);
|
||||
mergeSelection(
|
||||
{
|
||||
type: "lasso",
|
||||
points: currentPoints,
|
||||
bounds: {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
},
|
||||
},
|
||||
selectionMode,
|
||||
);
|
||||
setCurrentPoints([]);
|
||||
isDrawingRef.current = false;
|
||||
const verts = polyVerticesRef.current;
|
||||
finalizeLasso(verts);
|
||||
polyVerticesRef.current = [];
|
||||
}
|
||||
}, [selectionType, currentPoints, selectionMode, mergeSelection]);
|
||||
}, [selectionType, finalizeLasso]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelection({
|
||||
@@ -385,16 +540,33 @@ export function useSelectionTool(): SelectionToolApi {
|
||||
|
||||
const magicWandSelect = useCallback(
|
||||
(stage: Konva.Stage, x: number, y: number, tolerance: number, contiguous: boolean) => {
|
||||
const canvas = stage.toCanvas();
|
||||
// Use explicit viewport options to get a consistent unzoomed canvas,
|
||||
// ignoring zoom/pan transforms and device pixel ratio
|
||||
const canvas = stage.toCanvas({
|
||||
pixelRatio: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
});
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const imageData = ctx.getImageData(0, 0, canvasSize.width, canvasSize.height);
|
||||
const mask = floodFillMask(imageData, x, y, tolerance, contiguous);
|
||||
const bounds = maskToBounds(mask);
|
||||
if (!bounds) return;
|
||||
mergeSelection({ type: "rect", points: [], bounds }, selectionMode);
|
||||
|
||||
// Convert the boolean[][] mask to a flat Uint8Array within the bounds region
|
||||
const flatMask = new Uint8Array(bounds.width * bounds.height);
|
||||
for (let row = 0; row < bounds.height; row++) {
|
||||
for (let col = 0; col < bounds.width; col++) {
|
||||
flatMask[row * bounds.width + col] = mask[bounds.y + row][bounds.x + col] ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
mergeSelection({ type: "wand", points: [], bounds, mask: flatMask }, selectionMode);
|
||||
},
|
||||
[selectionMode, mergeSelection],
|
||||
[selectionMode, mergeSelection, canvasSize],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -204,14 +204,49 @@ export function TransformToolTransformer({
|
||||
const handleTransformEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<Event>) => {
|
||||
const node = e.target;
|
||||
const id = node.id();
|
||||
const scaleX = node.scaleX();
|
||||
const scaleY = node.scaleY();
|
||||
const newW = Math.max(1, node.width() * scaleX);
|
||||
const newH = Math.max(1, node.height() * scaleY);
|
||||
|
||||
// Reset scale on the Konva node
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
node.width(newW);
|
||||
node.height(newH);
|
||||
|
||||
// Directly update the store with the new attributes
|
||||
if (id) {
|
||||
const obj = useEditorStore.getState().objects.find((o) => o.id === id);
|
||||
const newAttrs: Record<string, unknown> = {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
rotation: node.rotation(),
|
||||
};
|
||||
|
||||
if (obj?.type === "ellipse") {
|
||||
// Convert width/height to radiusX/radiusY for ellipses
|
||||
newAttrs.radiusX = newW / 2;
|
||||
newAttrs.radiusY = newH / 2;
|
||||
} else if (obj?.type === "polygon") {
|
||||
// Convert width/height to radius for polygons
|
||||
newAttrs.radius = (newW + newH) / 4;
|
||||
} else if (obj?.type === "star") {
|
||||
// Scale outerRadius and innerRadius proportionally for stars
|
||||
const starAttrs = obj.attrs as { outerRadius: number; innerRadius: number };
|
||||
const avgScale = (Math.abs(scaleX) + Math.abs(scaleY)) / 2;
|
||||
newAttrs.outerRadius = starAttrs.outerRadius * avgScale;
|
||||
newAttrs.innerRadius = starAttrs.innerRadius * avgScale;
|
||||
} else {
|
||||
// Rectangles, images, text: use width/height directly
|
||||
newAttrs.width = newW;
|
||||
newAttrs.height = newH;
|
||||
}
|
||||
|
||||
useEditorStore.getState().updateObject(id, newAttrs);
|
||||
}
|
||||
|
||||
onTransformEnd?.(e);
|
||||
},
|
||||
[onTransformEnd],
|
||||
|
||||
@@ -26,10 +26,6 @@ export function useCanvasZoom() {
|
||||
if (tweenRef.current) {
|
||||
tweenRef.current.destroy();
|
||||
}
|
||||
// Update store immediately so all tools get correct coordinates
|
||||
setZoom(targetZoom);
|
||||
setPanOffset(targetPos);
|
||||
|
||||
tweenRef.current = new Konva.Tween({
|
||||
node: stage,
|
||||
scaleX: targetZoom,
|
||||
@@ -38,7 +34,18 @@ export function useCanvasZoom() {
|
||||
y: targetPos.y,
|
||||
duration: ZOOM_ANIMATION_DURATION,
|
||||
easing: Konva.Easings.EaseOut,
|
||||
onUpdate: () => {
|
||||
// Progressively sync store with the stage's current animated values
|
||||
// so tools always have accurate coordinates during the tween.
|
||||
if (stage) {
|
||||
setZoom(stage.scaleX());
|
||||
setPanOffset({ x: stage.x(), y: stage.y() });
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
// Ensure final values are exact (no floating-point drift)
|
||||
setZoom(targetZoom);
|
||||
setPanOffset(targetPos);
|
||||
tweenRef.current?.destroy();
|
||||
tweenRef.current = null;
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { ToolType } from "@/types/editor";
|
||||
|
||||
@@ -55,7 +56,11 @@ function cycleSubtool(current: ToolType, cycle: ToolType[]): ToolType {
|
||||
*
|
||||
* @param callbacks Optional callbacks for save/export dialogs
|
||||
*/
|
||||
export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?: () => void }) {
|
||||
export function useEditorShortcuts(callbacks?: {
|
||||
onSave?: () => void;
|
||||
onExport?: () => void;
|
||||
onFillDialog?: () => void;
|
||||
}) {
|
||||
const previousToolRef = useRef<ToolType | null>(null);
|
||||
const isSpaceHeldRef = useRef(false);
|
||||
|
||||
@@ -435,24 +440,34 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
|
||||
{ preventDefault: false },
|
||||
);
|
||||
|
||||
// Ctrl+Shift+C / Cmd+Shift+C - Copy merged
|
||||
// Ctrl+Shift+C / Cmd+Shift+C - Copy merged (use Konva stage ref for proper composite)
|
||||
useHotkeys(
|
||||
"mod+shift+c",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
// Export visible layers to clipboard as PNG
|
||||
const stageCanvas = document.querySelector(
|
||||
"[data-testid='editor-canvas'] canvas",
|
||||
) as HTMLCanvasElement | null;
|
||||
if (!stageCanvas) return;
|
||||
stageCanvas.toBlob(async (blob) => {
|
||||
if (!blob) return;
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
}, "image/png");
|
||||
const stage = editorStageRefHolder.current;
|
||||
if (!stage) return;
|
||||
const { canvasSize } = useEditorStore.getState();
|
||||
const dataUrl = stage.toDataURL({
|
||||
pixelRatio: 1,
|
||||
mimeType: "image/png",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
});
|
||||
fetch(dataUrl)
|
||||
.then((res) => res.blob())
|
||||
.then(async (blob) => {
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Export failed silently
|
||||
});
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
@@ -806,15 +821,18 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Shift+Backspace - Fill dialog (trigger callback or use fill tool)
|
||||
// Shift+Backspace - Open fill dialog
|
||||
useHotkeys(
|
||||
"shift+backspace",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
// Fill dialog would be handled by Agent 1's fill-dialog component
|
||||
// For now, switch to fill tool as a fallback
|
||||
useEditorStore.getState().setTool("fill");
|
||||
if (callbacks?.onFillDialog) {
|
||||
callbacks.onFillDialog();
|
||||
} else {
|
||||
// Fallback: dispatch custom event for FillDialog listener
|
||||
window.dispatchEvent(new CustomEvent("snapotter:open-fill-dialog"));
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
@@ -863,6 +881,7 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
|
||||
/** Nudge all selected objects by (dx, dy) pixels. */
|
||||
function nudgeSelected(dx: number, dy: number): void {
|
||||
const state = useEditorStore.getState();
|
||||
if (state.selectedObjectIds.length === 0) return;
|
||||
for (const id of state.selectedObjectIds) {
|
||||
const obj = state.objects.find((o) => o.id === id);
|
||||
if (!obj) continue;
|
||||
@@ -874,4 +893,9 @@ function nudgeSelected(dx: number, dy: number): void {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Create a history entry so the nudge is undoable
|
||||
useEditorStore.setState((s) => ({
|
||||
_historyVersion: s._historyVersion + 1,
|
||||
lastAction: "Nudge",
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
import { Monitor } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CanvasResizeDialog } from "@/components/editor/common/canvas-resize-dialog";
|
||||
import { ExportDialog, saveEditorState } from "@/components/editor/common/export-dialog";
|
||||
import {
|
||||
AutosaveRecoveryBanner,
|
||||
ExportDialog,
|
||||
saveEditorState,
|
||||
useAutosave,
|
||||
} from "@/components/editor/common/export-dialog";
|
||||
import { FillDialog } from "@/components/editor/common/fill-dialog";
|
||||
import { ImageResizeDialog } from "@/components/editor/common/image-resize-dialog";
|
||||
import { HorizontalRuler, VerticalRuler } from "@/components/editor/common/rulers";
|
||||
import { WelcomeScreen } from "@/components/editor/common/welcome-screen";
|
||||
import { EditorCanvas } from "@/components/editor/editor-canvas";
|
||||
import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
|
||||
@@ -19,16 +26,29 @@ export function EditorPage() {
|
||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||
const isDirty = useEditorStore((s) => s.isDirty);
|
||||
const loadImage = useEditorStore((s) => s.loadImage);
|
||||
const rulersVisible = useEditorStore((s) => s.rulersVisible);
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const [showCanvasResize, setShowCanvasResize] = useState(false);
|
||||
const [showImageResize, setShowImageResize] = useState(false);
|
||||
const [fillDialogOpen, setFillDialogOpen] = useState(false);
|
||||
|
||||
// Autosave recovery
|
||||
const { recoveryData, dismissRecovery, restoreRecovery } = useAutosave();
|
||||
|
||||
// Issue #10: Shortcuts belong at page level, not canvas level
|
||||
useEditorShortcuts({
|
||||
onSave: () => saveEditorState(),
|
||||
onExport: () => setShowExport(true),
|
||||
onFillDialog: () => setFillDialogOpen(true),
|
||||
});
|
||||
|
||||
// Listen for fill-dialog custom event (dispatched from Shift+Backspace shortcut)
|
||||
useEffect(() => {
|
||||
const handler = () => setFillDialogOpen(true);
|
||||
window.addEventListener("snapotter:open-fill-dialog", handler);
|
||||
return () => window.removeEventListener("snapotter:open-fill-dialog", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty) {
|
||||
@@ -90,9 +110,21 @@ export function EditorPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Autosave recovery banner */}
|
||||
{recoveryData && (
|
||||
<AutosaveRecoveryBanner
|
||||
data={recoveryData}
|
||||
onRestore={restoreRecovery}
|
||||
onDiscard={dismissRecovery}
|
||||
/>
|
||||
)}
|
||||
<EditorOptionsBar />
|
||||
{/* Horizontal ruler along the top edge */}
|
||||
{rulersVisible && <HorizontalRuler />}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<EditorToolbar />
|
||||
{/* Vertical ruler along the left edge */}
|
||||
{rulersVisible && <VerticalRuler />}
|
||||
<div className="relative flex-1 overflow-hidden bg-muted/30">
|
||||
<EditorCanvas
|
||||
onCanvasResize={() => setShowCanvasResize(true)}
|
||||
@@ -106,6 +138,7 @@ export function EditorPage() {
|
||||
{showExport && <ExportDialog onClose={() => setShowExport(false)} />}
|
||||
<CanvasResizeDialog open={showCanvasResize} onClose={() => setShowCanvasResize(false)} />
|
||||
<ImageResizeDialog open={showImageResize} onClose={() => setShowImageResize(false)} />
|
||||
<FillDialog open={fillDialogOpen} onClose={() => setFillDialogOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,23 @@ import type {
|
||||
ToolType,
|
||||
} from "@/types/editor";
|
||||
|
||||
// Helpers for objects that use points arrays (line, arrow) vs positioned objects
|
||||
function hasPointsArray(obj: CanvasObject): obj is CanvasObject & { attrs: { points: number[] } } {
|
||||
return "points" in obj.attrs && Array.isArray((obj.attrs as { points: number[] }).points);
|
||||
}
|
||||
|
||||
function isCenterBased(obj: CanvasObject): boolean {
|
||||
return obj.type === "ellipse" || obj.type === "polygon" || obj.type === "star";
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const DEFAULT_CANVAS_SIZE = { width: 1920, height: 1080 };
|
||||
const DEFAULT_LAYER_ID = "layer-1";
|
||||
const MAX_RECENT_COLORS = 12;
|
||||
@@ -89,7 +106,7 @@ function nextLayerNumber(layers: EditorLayer[]): number {
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>()(
|
||||
export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
||||
temporal(
|
||||
(set, get) => ({
|
||||
// --- Canvas ---
|
||||
@@ -98,6 +115,9 @@ export const useEditorStore = create<EditorState>()(
|
||||
panOffset: { x: 0, y: 0 },
|
||||
cursorPosition: { x: 0, y: 0 },
|
||||
|
||||
// --- Canvas background (used when canvas is resized larger) ---
|
||||
canvasBackground: "#ffffff",
|
||||
|
||||
// --- Image ---
|
||||
sourceImageUrl: null,
|
||||
sourceImageSize: null,
|
||||
@@ -260,7 +280,7 @@ export const useEditorStore = create<EditorState>()(
|
||||
});
|
||||
},
|
||||
|
||||
resizeCanvas: (width, height, anchor, _fill) => {
|
||||
resizeCanvas: (width, height, anchor, fill) => {
|
||||
const { canvasSize, objects } = get();
|
||||
const dw = width - canvasSize.width;
|
||||
const dh = height - canvasSize.height;
|
||||
@@ -283,15 +303,25 @@ export const useEditorStore = create<EditorState>()(
|
||||
}
|
||||
set({
|
||||
canvasSize: { width, height },
|
||||
...(fill ? { canvasBackground: fill } : {}),
|
||||
objects:
|
||||
offsetX !== 0 || offsetY !== 0
|
||||
? objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
if ("x" in attrs) {
|
||||
(attrs as { x: number }).x += offsetX;
|
||||
}
|
||||
if ("y" in attrs) {
|
||||
(attrs as { y: number }).y += offsetY;
|
||||
if (hasPointsArray(obj)) {
|
||||
const pts = [...(attrs as { points: number[] }).points];
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
pts[i] += offsetX;
|
||||
pts[i + 1] += offsetY;
|
||||
}
|
||||
(attrs as { points: number[] }).points = pts;
|
||||
} else {
|
||||
if ("x" in attrs) {
|
||||
(attrs as { x: number }).x += offsetX;
|
||||
}
|
||||
if ("y" in attrs) {
|
||||
(attrs as { y: number }).y += offsetY;
|
||||
}
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
})
|
||||
@@ -302,10 +332,40 @@ export const useEditorStore = create<EditorState>()(
|
||||
});
|
||||
},
|
||||
|
||||
resizeImage: (width, height, _resample) => {
|
||||
resizeImage: (width, height, resample) => {
|
||||
void resample; // accepted for future server-side resize; client-side scales objects only
|
||||
const { canvasSize, objects } = get();
|
||||
const scaleX = width / canvasSize.width;
|
||||
const scaleY = height / canvasSize.height;
|
||||
set({
|
||||
canvasSize: { width, height },
|
||||
sourceImageSize: { width, height },
|
||||
objects: objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
const a = attrs as unknown as Record<string, number>;
|
||||
if (hasPointsArray(obj)) {
|
||||
const pts = [...(attrs as { points: number[] }).points];
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
pts[i] *= scaleX;
|
||||
pts[i + 1] *= scaleY;
|
||||
}
|
||||
(attrs as { points: number[] }).points = pts;
|
||||
if ("strokeWidth" in attrs) a.strokeWidth *= Math.min(scaleX, scaleY);
|
||||
} else {
|
||||
if ("x" in attrs) a.x *= scaleX;
|
||||
if ("y" in attrs) a.y *= scaleY;
|
||||
if ("width" in attrs) a.width *= scaleX;
|
||||
if ("height" in attrs) a.height *= scaleY;
|
||||
if ("radius" in attrs) a.radius *= Math.min(scaleX, scaleY);
|
||||
if ("radiusX" in attrs) a.radiusX *= scaleX;
|
||||
if ("radiusY" in attrs) a.radiusY *= scaleY;
|
||||
if ("innerRadius" in attrs) a.innerRadius *= Math.min(scaleX, scaleY);
|
||||
if ("outerRadius" in attrs) a.outerRadius *= Math.min(scaleX, scaleY);
|
||||
if ("fontSize" in attrs) a.fontSize *= Math.min(scaleX, scaleY);
|
||||
if ("strokeWidth" in attrs) a.strokeWidth *= Math.min(scaleX, scaleY);
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
}),
|
||||
isDirty: true,
|
||||
lastAction: "Resize Image",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
@@ -321,34 +381,57 @@ export const useEditorStore = create<EditorState>()(
|
||||
sourceImageSize: newSize,
|
||||
objects: objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
const hasPos = "x" in attrs && "y" in attrs;
|
||||
const hasSize = "width" in attrs && "height" in attrs;
|
||||
const a = attrs as unknown as Record<string, number>;
|
||||
if (hasPos) {
|
||||
if (degrees === 90) {
|
||||
const newX = canvasSize.height - a.y - (hasSize ? a.height : 0);
|
||||
const newY = a.x;
|
||||
a.x = newX;
|
||||
a.y = newY;
|
||||
} else if (degrees === 270) {
|
||||
const newX = a.y;
|
||||
const newY = canvasSize.width - a.x - (hasSize ? a.width : 0);
|
||||
a.x = newX;
|
||||
a.y = newY;
|
||||
} else {
|
||||
a.x = canvasSize.width - a.x - (hasSize ? a.width : 0);
|
||||
a.y = canvasSize.height - a.y - (hasSize ? a.height : 0);
|
||||
|
||||
// Handle points-based objects (line, arrow)
|
||||
if (hasPointsArray(obj)) {
|
||||
const pts = [...(attrs as { points: number[] }).points];
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
const px = pts[i];
|
||||
const py = pts[i + 1];
|
||||
if (degrees === 90) {
|
||||
pts[i] = canvasSize.height - py;
|
||||
pts[i + 1] = px;
|
||||
} else if (degrees === 270) {
|
||||
pts[i] = py;
|
||||
pts[i + 1] = canvasSize.width - px;
|
||||
} else {
|
||||
pts[i] = canvasSize.width - px;
|
||||
pts[i + 1] = canvasSize.height - py;
|
||||
}
|
||||
}
|
||||
(attrs as { points: number[] }).points = pts;
|
||||
} else {
|
||||
const centerBased = isCenterBased(obj);
|
||||
const hasPos = "x" in attrs && "y" in attrs;
|
||||
const hasSize = "width" in attrs && "height" in attrs;
|
||||
|
||||
if (hasPos) {
|
||||
if (degrees === 90) {
|
||||
const newX = canvasSize.height - a.y - (centerBased ? 0 : hasSize ? a.height : 0);
|
||||
const newY = a.x;
|
||||
a.x = newX;
|
||||
a.y = newY;
|
||||
} else if (degrees === 270) {
|
||||
const newX = a.y;
|
||||
const newY = canvasSize.width - a.x - (centerBased ? 0 : hasSize ? a.width : 0);
|
||||
a.x = newX;
|
||||
a.y = newY;
|
||||
} else {
|
||||
a.x = canvasSize.width - a.x - (centerBased ? 0 : hasSize ? a.width : 0);
|
||||
a.y = canvasSize.height - a.y - (centerBased ? 0 : hasSize ? a.height : 0);
|
||||
}
|
||||
}
|
||||
if (hasSize && degrees !== 180) {
|
||||
const oldW = a.width;
|
||||
a.width = a.height;
|
||||
a.height = oldW;
|
||||
}
|
||||
if ("radiusX" in attrs && "radiusY" in attrs && degrees !== 180) {
|
||||
const oldRx = a.radiusX;
|
||||
a.radiusX = a.radiusY;
|
||||
a.radiusY = oldRx;
|
||||
}
|
||||
}
|
||||
if (hasSize && degrees !== 180) {
|
||||
const oldW = a.width;
|
||||
a.width = a.height;
|
||||
a.height = oldW;
|
||||
}
|
||||
if ("radiusX" in attrs && "radiusY" in attrs && degrees !== 180) {
|
||||
const oldRx = a.radiusX;
|
||||
a.radiusX = a.radiusY;
|
||||
a.radiusY = oldRx;
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
}),
|
||||
@@ -363,9 +446,16 @@ export const useEditorStore = create<EditorState>()(
|
||||
set({
|
||||
objects: objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
if ("x" in attrs) {
|
||||
const a = attrs as unknown as Record<string, number>;
|
||||
const w = "width" in attrs ? a.width : 0;
|
||||
const a = attrs as unknown as Record<string, number>;
|
||||
if (hasPointsArray(obj)) {
|
||||
const pts = [...(attrs as { points: number[] }).points];
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
pts[i] = canvasSize.width - pts[i];
|
||||
}
|
||||
(attrs as { points: number[] }).points = pts;
|
||||
} else if ("x" in attrs) {
|
||||
const centerBased = isCenterBased(obj);
|
||||
const w = centerBased ? 0 : "width" in attrs ? a.width : 0;
|
||||
a.x = canvasSize.width - a.x - w;
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
@@ -381,9 +471,16 @@ export const useEditorStore = create<EditorState>()(
|
||||
set({
|
||||
objects: objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
if ("y" in attrs) {
|
||||
const a = attrs as unknown as Record<string, number>;
|
||||
const h = "height" in attrs ? a.height : 0;
|
||||
const a = attrs as unknown as Record<string, number>;
|
||||
if (hasPointsArray(obj)) {
|
||||
const pts = [...(attrs as { points: number[] }).points];
|
||||
for (let i = 1; i < pts.length; i += 2) {
|
||||
pts[i] = canvasSize.height - pts[i];
|
||||
}
|
||||
(attrs as { points: number[] }).points = pts;
|
||||
} else if ("y" in attrs) {
|
||||
const centerBased = isCenterBased(obj);
|
||||
const h = centerBased ? 0 : "height" in attrs ? a.height : 0;
|
||||
a.y = canvasSize.height - a.y - h;
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
@@ -403,14 +500,48 @@ export const useEditorStore = create<EditorState>()(
|
||||
let maxY = 0;
|
||||
for (const obj of objects) {
|
||||
const a = obj.attrs as unknown as Record<string, number>;
|
||||
const x = "x" in obj.attrs ? a.x : 0;
|
||||
const y = "y" in obj.attrs ? a.y : 0;
|
||||
const w = "width" in obj.attrs ? a.width : "radiusX" in obj.attrs ? a.radiusX * 2 : 0;
|
||||
const h = "height" in obj.attrs ? a.height : "radiusY" in obj.attrs ? a.radiusY * 2 : 0;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + w);
|
||||
maxY = Math.max(maxY, y + h);
|
||||
const sw = "strokeWidth" in obj.attrs ? a.strokeWidth / 2 : 0;
|
||||
if (hasPointsArray(obj)) {
|
||||
const pts = (obj.attrs as { points: number[] }).points;
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
minX = Math.min(minX, pts[i] - sw);
|
||||
minY = Math.min(minY, pts[i + 1] - sw);
|
||||
maxX = Math.max(maxX, pts[i] + sw);
|
||||
maxY = Math.max(maxY, pts[i + 1] + sw);
|
||||
}
|
||||
} else if (isCenterBased(obj)) {
|
||||
const cx = "x" in obj.attrs ? a.x : 0;
|
||||
const cy = "y" in obj.attrs ? a.y : 0;
|
||||
const rx =
|
||||
"radiusX" in obj.attrs
|
||||
? a.radiusX
|
||||
: "radius" in obj.attrs
|
||||
? a.radius
|
||||
: "outerRadius" in obj.attrs
|
||||
? a.outerRadius
|
||||
: 0;
|
||||
const ry =
|
||||
"radiusY" in obj.attrs
|
||||
? a.radiusY
|
||||
: "radius" in obj.attrs
|
||||
? a.radius
|
||||
: "outerRadius" in obj.attrs
|
||||
? a.outerRadius
|
||||
: 0;
|
||||
minX = Math.min(minX, cx - rx - sw);
|
||||
minY = Math.min(minY, cy - ry - sw);
|
||||
maxX = Math.max(maxX, cx + rx + sw);
|
||||
maxY = Math.max(maxY, cy + ry + sw);
|
||||
} else {
|
||||
const x = "x" in obj.attrs ? a.x : 0;
|
||||
const y = "y" in obj.attrs ? a.y : 0;
|
||||
const w = "width" in obj.attrs ? a.width : 0;
|
||||
const h = "height" in obj.attrs ? a.height : 0;
|
||||
minX = Math.min(minX, x - sw);
|
||||
minY = Math.min(minY, y - sw);
|
||||
maxX = Math.max(maxX, x + w + sw);
|
||||
maxY = Math.max(maxY, y + h + sw);
|
||||
}
|
||||
}
|
||||
minX = Math.max(0, Math.floor(minX));
|
||||
minY = Math.max(0, Math.floor(minY));
|
||||
@@ -431,11 +562,20 @@ export const useEditorStore = create<EditorState>()(
|
||||
sourceImageSize: { width: newWidth, height: newHeight },
|
||||
objects: objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
if ("x" in attrs) {
|
||||
(attrs as unknown as Record<string, number>).x -= minX;
|
||||
}
|
||||
if ("y" in attrs) {
|
||||
(attrs as unknown as Record<string, number>).y -= minY;
|
||||
if (hasPointsArray(obj)) {
|
||||
const pts = [...(attrs as { points: number[] }).points];
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
pts[i] -= minX;
|
||||
pts[i + 1] -= minY;
|
||||
}
|
||||
(attrs as { points: number[] }).points = pts;
|
||||
} else {
|
||||
if ("x" in attrs) {
|
||||
(attrs as unknown as Record<string, number>).x -= minX;
|
||||
}
|
||||
if ("y" in attrs) {
|
||||
(attrs as unknown as Record<string, number>).y -= minY;
|
||||
}
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
}),
|
||||
@@ -574,17 +714,30 @@ export const useEditorStore = create<EditorState>()(
|
||||
},
|
||||
|
||||
sendToBack: (objectId) => {
|
||||
const { objects } = get();
|
||||
const { objects, layers } = get();
|
||||
const obj = objects.find((o) => o.id === objectId);
|
||||
if (!obj) return;
|
||||
const newObjects = objects.filter((o) => o.id !== objectId);
|
||||
let insertIdx = 0;
|
||||
// Find the first object on the same layer
|
||||
let insertIdx = -1;
|
||||
for (let i = 0; i < newObjects.length; i++) {
|
||||
if (newObjects[i].layerId === obj.layerId) {
|
||||
insertIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If no other objects on the same layer, find the correct position
|
||||
// based on layer ordering (after all objects from earlier layers)
|
||||
if (insertIdx === -1) {
|
||||
const layerIdx = layers.findIndex((l) => l.id === obj.layerId);
|
||||
insertIdx = 0;
|
||||
for (let i = 0; i < newObjects.length; i++) {
|
||||
const objLayerIdx = layers.findIndex((l) => l.id === newObjects[i].layerId);
|
||||
if (objLayerIdx < layerIdx) {
|
||||
insertIdx = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
newObjects.splice(insertIdx, 0, obj);
|
||||
set({
|
||||
objects: newObjects,
|
||||
@@ -767,33 +920,79 @@ export const useEditorStore = create<EditorState>()(
|
||||
setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
|
||||
|
||||
invertSelection: () => {
|
||||
const { selection } = get();
|
||||
const { selection, canvasSize } = get();
|
||||
if (!selection) return;
|
||||
if (selection.mask) {
|
||||
const inverted = new Uint8Array(selection.mask.length);
|
||||
for (let i = 0; i < selection.mask.length; i++) {
|
||||
inverted[i] = 255 - selection.mask[i];
|
||||
let mask = selection.mask;
|
||||
// If no mask but bounds exist, create a mask from the bounds
|
||||
if (!mask && selection.bounds) {
|
||||
const totalPixels = canvasSize.width * canvasSize.height;
|
||||
mask = new Uint8Array(totalPixels);
|
||||
const { x, y, width, height } = selection.bounds;
|
||||
const x0 = Math.max(0, Math.floor(x));
|
||||
const y0 = Math.max(0, Math.floor(y));
|
||||
const x1 = Math.min(canvasSize.width, Math.ceil(x + width));
|
||||
const y1 = Math.min(canvasSize.height, Math.ceil(y + height));
|
||||
for (let row = y0; row < y1; row++) {
|
||||
for (let col = x0; col < x1; col++) {
|
||||
mask[row * canvasSize.width + col] = 255;
|
||||
}
|
||||
}
|
||||
set({ selection: { ...selection, mask: inverted } });
|
||||
}
|
||||
if (!mask) return;
|
||||
const inverted = new Uint8Array(mask.length);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
inverted[i] = 255 - mask[i];
|
||||
}
|
||||
set({ selection: { ...selection, mask: inverted } });
|
||||
},
|
||||
|
||||
// Crop
|
||||
setCropState: (state) => set({ cropState: state, isCropping: state !== null }),
|
||||
|
||||
applyCrop: () => {
|
||||
const { cropState, objects } = get();
|
||||
const { cropState, objects, sourceImageUrl } = get();
|
||||
if (!cropState) return;
|
||||
|
||||
// Crop the source image via an offscreen canvas
|
||||
if (sourceImageUrl) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const offscreen = document.createElement("canvas");
|
||||
offscreen.width = cropState.width;
|
||||
offscreen.height = cropState.height;
|
||||
const ctx = offscreen.getContext("2d");
|
||||
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);
|
||||
}
|
||||
set({ sourceImageUrl: croppedUrl });
|
||||
}
|
||||
};
|
||||
img.src = sourceImageUrl;
|
||||
}
|
||||
|
||||
set({
|
||||
canvasSize: { width: cropState.width, height: cropState.height },
|
||||
sourceImageSize: { width: cropState.width, height: cropState.height },
|
||||
objects: objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
if ("x" in attrs) {
|
||||
(attrs as { x: number }).x -= cropState.x;
|
||||
}
|
||||
if ("y" in attrs) {
|
||||
(attrs as { y: number }).y -= cropState.y;
|
||||
if (hasPointsArray(obj)) {
|
||||
const pts = [...(attrs as { points: number[] }).points];
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
pts[i] -= cropState.x;
|
||||
pts[i + 1] -= cropState.y;
|
||||
}
|
||||
(attrs as { points: number[] }).points = pts;
|
||||
} else {
|
||||
if ("x" in attrs) {
|
||||
(attrs as { x: number }).x -= cropState.x;
|
||||
}
|
||||
if ("y" in attrs) {
|
||||
(attrs as { y: number }).y -= cropState.y;
|
||||
}
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
}),
|
||||
@@ -818,10 +1017,18 @@ export const useEditorStore = create<EditorState>()(
|
||||
},
|
||||
|
||||
cutObjects: () => {
|
||||
const { objects, selectedObjectIds } = get();
|
||||
const selected = objects.filter((o) => selectedObjectIds.includes(o.id));
|
||||
set({ clipboard: selected });
|
||||
get().removeObjects(selectedObjectIds);
|
||||
const state = get();
|
||||
const selected = state.objects.filter((o) => state.selectedObjectIds.includes(o.id));
|
||||
if (!selected.length) return;
|
||||
const idSet = new Set(state.selectedObjectIds);
|
||||
set({
|
||||
clipboard: selected,
|
||||
objects: state.objects.filter((o) => !idSet.has(o.id)),
|
||||
selectedObjectIds: [],
|
||||
isDirty: true,
|
||||
lastAction: "Cut",
|
||||
_historyVersion: state._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
pasteObjects: () => {
|
||||
@@ -931,6 +1138,54 @@ export const useEditorStore = create<EditorState>()(
|
||||
setPixelBrushStrength: (strength) =>
|
||||
set({ pixelBrushStrength: Math.max(1, Math.min(100, strength)) }),
|
||||
|
||||
// History commit (for operations like nudge that use updateObject
|
||||
// but still need an undo point)
|
||||
commitHistory: (action) => {
|
||||
set({
|
||||
lastAction: action,
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
// Batch nudge: move multiple objects and create a history entry
|
||||
batchNudge: (objectIds, dx, dy) => {
|
||||
const idSet = new Set(objectIds);
|
||||
set({
|
||||
objects: get().objects.map((obj) => {
|
||||
if (!idSet.has(obj.id)) return obj;
|
||||
const attrs = { ...obj.attrs };
|
||||
if (hasPointsArray(obj)) {
|
||||
const pts = [...(attrs as { points: number[] }).points];
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
pts[i] += dx;
|
||||
pts[i + 1] += dy;
|
||||
}
|
||||
(attrs as { points: number[] }).points = pts;
|
||||
} else {
|
||||
if ("x" in attrs) {
|
||||
(attrs as unknown as Record<string, number>).x += dx;
|
||||
}
|
||||
if ("y" in attrs) {
|
||||
(attrs as unknown as Record<string, number>).y += dy;
|
||||
}
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
}),
|
||||
isDirty: true,
|
||||
lastAction: "Nudge",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
// Layer thumbnail update (actual generation happens in the canvas component)
|
||||
updateLayerThumbnail: (layerId, thumbnailDataUrl) => {
|
||||
set({
|
||||
layers: get().layers.map((l) =>
|
||||
l.id === layerId ? { ...l, thumbnail: thumbnailDataUrl } : l,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
// Right panel
|
||||
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
|
||||
toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }),
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
retries: 0,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: "http://localhost:1351",
|
||||
baseURL: `http://localhost:${process.env.EDITOR_TEST_PORT ?? "1349"}`,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createNewDocument, drawOnCanvas, expect, selectTool, test } from "./helpers";
|
||||
|
||||
test.describe("Editor Autosave", () => {
|
||||
test("editor saves state periodically (check localStorage has autosave data)", async ({
|
||||
editorPage: page,
|
||||
}) => {
|
||||
test.slow();
|
||||
|
||||
await createNewDocument(page);
|
||||
|
||||
// Draw something to mark the canvas dirty
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Manually trigger autosave by calling saveEditorState from the page context.
|
||||
// The autosave interval is 60s which is too long for E2E, so invoke directly.
|
||||
await page.evaluate(async () => {
|
||||
// The saveEditorState function writes to localStorage under this key
|
||||
const { saveEditorState } = await import("/src/components/editor/common/export-dialog.tsx");
|
||||
await saveEditorState();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify localStorage contains the autosave key
|
||||
const autosaveData = await page.evaluate(() => {
|
||||
return localStorage.getItem("snapotter-editor-autosave");
|
||||
});
|
||||
|
||||
expect(autosaveData).not.toBeNull();
|
||||
|
||||
// Parse and verify the structure
|
||||
const parsed = JSON.parse(autosaveData!);
|
||||
expect(parsed.version).toBe(1);
|
||||
expect(parsed.timestamp).toBeGreaterThan(0);
|
||||
expect(parsed.state).toBeDefined();
|
||||
expect(parsed.state.canvasSize).toBeDefined();
|
||||
expect(parsed.state.canvasSize.width).toBeGreaterThan(0);
|
||||
expect(parsed.state.canvasSize.height).toBeGreaterThan(0);
|
||||
expect(parsed.state.layers).toBeDefined();
|
||||
expect(Array.isArray(parsed.state.layers)).toBe(true);
|
||||
});
|
||||
|
||||
test("modified content persists across page reload", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
await createNewDocument(page);
|
||||
|
||||
// Draw something to create content
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Trigger autosave manually
|
||||
await page.evaluate(async () => {
|
||||
const { saveEditorState } = await import("/src/components/editor/common/export-dialog.tsx");
|
||||
await saveEditorState();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify autosave data exists before reload
|
||||
const dataBefore = await page.evaluate(() => {
|
||||
return localStorage.getItem("snapotter-editor-autosave");
|
||||
});
|
||||
expect(dataBefore).not.toBeNull();
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// After reload, localStorage should still have the autosave data
|
||||
const dataAfter = await page.evaluate(() => {
|
||||
return localStorage.getItem("snapotter-editor-autosave");
|
||||
});
|
||||
expect(dataAfter).not.toBeNull();
|
||||
|
||||
// The data should contain the same canvas dimensions
|
||||
const parsedBefore = JSON.parse(dataBefore!);
|
||||
const parsedAfter = JSON.parse(dataAfter!);
|
||||
expect(parsedAfter.state.canvasSize.width).toBe(parsedBefore.state.canvasSize.width);
|
||||
expect(parsedAfter.state.canvasSize.height).toBe(parsedBefore.state.canvasSize.height);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createNewDocument, expect, test } from "./helpers";
|
||||
|
||||
test.describe("Editor Fill Dialog", () => {
|
||||
test.beforeEach(async ({ editorPage: page }) => {
|
||||
await createNewDocument(page);
|
||||
});
|
||||
|
||||
test("Shift+Backspace opens fill dialog", async ({ editorPage: page }) => {
|
||||
// Press Shift+Backspace to open the fill dialog
|
||||
await page.keyboard.press("Shift+Backspace");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// The fill dialog should appear
|
||||
const dialog = page.locator("div[role='dialog'][aria-label='Fill']");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// It should have the "Fill" heading
|
||||
await expect(dialog.getByText("Fill", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("fill dialog has color options", async ({ editorPage: page }) => {
|
||||
// Open the fill dialog
|
||||
await page.keyboard.press("Shift+Backspace");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const dialog = page.locator("div[role='dialog'][aria-label='Fill']");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Contents dropdown should be visible
|
||||
await expect(dialog.getByText("Contents")).toBeVisible();
|
||||
const contentsSelect = dialog.locator("select");
|
||||
await expect(contentsSelect).toBeVisible();
|
||||
|
||||
// Check the available fill options
|
||||
const options = contentsSelect.locator("option");
|
||||
const texts = await options.allTextContents();
|
||||
expect(texts).toContain("Foreground Color");
|
||||
expect(texts).toContain("Background Color");
|
||||
expect(texts).toContain("Color...");
|
||||
expect(texts).toContain("White");
|
||||
expect(texts).toContain("Black");
|
||||
expect(texts).toContain("50% Gray");
|
||||
|
||||
// Opacity slider should be visible
|
||||
await expect(dialog.getByText("Opacity")).toBeVisible();
|
||||
const opacityRange = dialog.locator("input[type='range']");
|
||||
await expect(opacityRange).toBeVisible();
|
||||
|
||||
// Preview swatch should be visible
|
||||
await expect(dialog.getByText("Preview:")).toBeVisible();
|
||||
|
||||
// OK and Cancel buttons should be present
|
||||
await expect(dialog.locator("button").filter({ hasText: "OK" })).toBeVisible();
|
||||
await expect(dialog.locator("button").filter({ hasText: "Cancel" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("fill dialog can be closed with Escape", async ({ editorPage: page }) => {
|
||||
// Open the fill dialog
|
||||
await page.keyboard.press("Shift+Backspace");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const dialog = page.locator("div[role='dialog'][aria-label='Fill']");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Press Escape to close
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Dialog should be gone
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { createNewDocument, drawOnCanvas, expect, selectTool, test } from "./helpers";
|
||||
|
||||
test.describe("Editor Filters and Adjustments", () => {
|
||||
test.beforeEach(async ({ editorPage: page }) => {
|
||||
await createNewDocument(page);
|
||||
// Switch to adjustments tab
|
||||
await page.locator("[data-testid='tab-adjustments']").click();
|
||||
await page.waitForTimeout(300);
|
||||
});
|
||||
|
||||
test("adjustments panel is visible and has sliders", async ({ editorPage: page }) => {
|
||||
// The adjustments section header should be visible
|
||||
await expect(page.getByText("Adjustments", { exact: true }).first()).toBeVisible();
|
||||
|
||||
// Check that core slider labels are present
|
||||
await expect(page.getByText("Brightness").first()).toBeVisible();
|
||||
await expect(page.getByText("Contrast").first()).toBeVisible();
|
||||
await expect(page.getByText("Saturation").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("brightness slider changes canvas visually", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Draw something on canvas so there are pixels to adjust
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
|
||||
// Re-select adjustments tab
|
||||
await page.locator("[data-testid='tab-adjustments']").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Find the Brightness slider row and change its value
|
||||
const brightnessNumber = page
|
||||
.locator(".flex.items-center.gap-2")
|
||||
.filter({ hasText: "Brightness" })
|
||||
.locator("input[type='number']");
|
||||
await brightnessNumber.fill("50");
|
||||
await brightnessNumber.press("Enter");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("exposure slider changes canvas visually", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
|
||||
await page.locator("[data-testid='tab-adjustments']").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
const exposureNumber = page
|
||||
.locator(".flex.items-center.gap-2")
|
||||
.filter({ hasText: "Exposure" })
|
||||
.locator("input[type='number']");
|
||||
await exposureNumber.fill("50");
|
||||
await exposureNumber.press("Enter");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("vibrance slider changes canvas visually", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
|
||||
await page.locator("[data-testid='tab-adjustments']").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
const vibranceNumber = page
|
||||
.locator(".flex.items-center.gap-2")
|
||||
.filter({ hasText: "Vibrance" })
|
||||
.locator("input[type='number']");
|
||||
await vibranceNumber.fill("60");
|
||||
await vibranceNumber.press("Enter");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("warmth slider changes canvas visually", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
|
||||
await page.locator("[data-testid='tab-adjustments']").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
const warmthNumber = page
|
||||
.locator(".flex.items-center.gap-2")
|
||||
.filter({ hasText: "Warmth" })
|
||||
.locator("input[type='number']");
|
||||
await warmthNumber.fill("40");
|
||||
await warmthNumber.press("Enter");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("filter toggle (blur) changes canvas", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
|
||||
await page.locator("[data-testid='tab-adjustments']").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Scroll to the Filters section and enable Blur
|
||||
const blurCheckbox = page
|
||||
.locator("label")
|
||||
.filter({ hasText: /^Blur$/ })
|
||||
.locator("input[type='checkbox']");
|
||||
await blurCheckbox.scrollIntoViewIfNeeded();
|
||||
await blurCheckbox.check();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("filter toggle (sharpen) changes canvas", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
|
||||
await page.locator("[data-testid='tab-adjustments']").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
const sharpenCheckbox = page
|
||||
.locator("label")
|
||||
.filter({ hasText: /^Sharpen$/ })
|
||||
.locator("input[type='checkbox']");
|
||||
await sharpenCheckbox.scrollIntoViewIfNeeded();
|
||||
await sharpenCheckbox.check();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("filter toggle (vignette) changes canvas", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
|
||||
await page.locator("[data-testid='tab-adjustments']").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Scroll to the Effects section and enable Vignette
|
||||
const vignetteCheckbox = page
|
||||
.locator("label")
|
||||
.filter({ hasText: /^Vignette$/ })
|
||||
.locator("input[type='checkbox']");
|
||||
await vignetteCheckbox.scrollIntoViewIfNeeded();
|
||||
await vignetteCheckbox.check();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("filter toggle (grain) changes canvas", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
|
||||
await page.locator("[data-testid='tab-adjustments']").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
const grainCheckbox = page
|
||||
.locator("label")
|
||||
.filter({ hasText: /^Grain$/ })
|
||||
.locator("input[type='checkbox']");
|
||||
await grainCheckbox.scrollIntoViewIfNeeded();
|
||||
await grainCheckbox.check();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("histogram panel shows data when image is loaded", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Draw something so the histogram has pixel data to analyze
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 50, 50, 400, 400);
|
||||
|
||||
await page.locator("[data-testid='tab-adjustments']").click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The histogram canvas element should be rendered
|
||||
const histogramCanvas = page.locator("canvas[width='256'][height='80']");
|
||||
await expect(histogramCanvas).toBeVisible();
|
||||
});
|
||||
|
||||
test("reset button resets all adjustments to zero", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Set a non-zero adjustment
|
||||
const brightnessNumber = page
|
||||
.locator(".flex.items-center.gap-2")
|
||||
.filter({ hasText: "Brightness" })
|
||||
.locator("input[type='number']");
|
||||
await brightnessNumber.fill("50");
|
||||
await brightnessNumber.press("Enter");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// The Reset All button should be enabled
|
||||
const resetBtn = page.locator("button").filter({ hasText: "Reset All" });
|
||||
await resetBtn.scrollIntoViewIfNeeded();
|
||||
await expect(resetBtn).toBeEnabled();
|
||||
|
||||
// Click Reset All
|
||||
await resetBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Brightness should be back to 0
|
||||
await expect(brightnessNumber).toHaveValue("0");
|
||||
|
||||
// Reset All button should now be disabled
|
||||
await expect(resetBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createNewDocument, drawOnCanvas, expect, selectTool, test } from "./helpers";
|
||||
|
||||
test.describe("Editor Layer Effects and Blend Modes", () => {
|
||||
test.beforeEach(async ({ editorPage: page }) => {
|
||||
await createNewDocument(page);
|
||||
// Ensure layers tab is active
|
||||
await page.locator("[data-testid='tab-layers']").click();
|
||||
await page.waitForTimeout(300);
|
||||
});
|
||||
|
||||
test("blend mode dropdown exists in layers panel", async ({ editorPage: page }) => {
|
||||
const blendSelect = page.locator("[data-testid='blend-mode-select']");
|
||||
await expect(blendSelect).toBeVisible();
|
||||
|
||||
// Should default to Normal (source-over)
|
||||
await expect(blendSelect).toHaveValue("source-over");
|
||||
});
|
||||
|
||||
test("blend mode can be changed", async ({ editorPage: page }) => {
|
||||
const blendSelect = page.locator("[data-testid='blend-mode-select']");
|
||||
await expect(blendSelect).toBeVisible();
|
||||
|
||||
// Change to Multiply
|
||||
await blendSelect.selectOption("multiply");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(blendSelect).toHaveValue("multiply");
|
||||
|
||||
// Change to Screen
|
||||
await blendSelect.selectOption("screen");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(blendSelect).toHaveValue("screen");
|
||||
|
||||
// Change to Overlay
|
||||
await blendSelect.selectOption("overlay");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(blendSelect).toHaveValue("overlay");
|
||||
});
|
||||
|
||||
test("effects section exists (drop shadow, stroke)", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// We need a selected object for the effects section to appear.
|
||||
// Draw something on the canvas to create an object.
|
||||
await selectTool(page, "shape-rect");
|
||||
await drawOnCanvas(page, 100, 100, 300, 250);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Switch to move tool and click the shape to select it
|
||||
await selectTool(page, "move");
|
||||
const canvas = page.locator("canvas").first();
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
await page.mouse.click(box.x + 200, box.y + 175);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Switch to layers tab to see effects
|
||||
await page.locator("[data-testid='tab-layers']").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// The Layer Effects section should be visible
|
||||
const effectsLabel = page.getByText("Layer Effects");
|
||||
await expect(effectsLabel).toBeVisible();
|
||||
|
||||
// Drop Shadow and Stroke labels should exist
|
||||
await expect(page.getByText("Drop Shadow").first()).toBeVisible();
|
||||
await expect(page.getByText("Stroke").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("drop shadow toggle enables shadow settings", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Create and select a shape object
|
||||
await selectTool(page, "shape-rect");
|
||||
await drawOnCanvas(page, 100, 100, 300, 250);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await selectTool(page, "move");
|
||||
const canvas = page.locator("canvas").first();
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
await page.mouse.click(box.x + 200, box.y + 175);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await page.locator("[data-testid='tab-layers']").click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Find the Drop Shadow checkbox
|
||||
const dropShadowLabel = page.locator("label").filter({ hasText: "Drop Shadow" });
|
||||
const dropShadowCheckbox = dropShadowLabel.locator("input[type='checkbox']");
|
||||
await dropShadowCheckbox.scrollIntoViewIfNeeded();
|
||||
|
||||
// Initially unchecked
|
||||
await expect(dropShadowCheckbox).not.toBeChecked();
|
||||
|
||||
// Enable drop shadow
|
||||
await dropShadowCheckbox.check();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(dropShadowCheckbox).toBeChecked();
|
||||
|
||||
// Expand the section to see controls by clicking the chevron
|
||||
const expandBtn = dropShadowLabel.locator("..").locator("button[aria-label*='Expand']");
|
||||
if (await expandBtn.isVisible()) {
|
||||
await expandBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
});
|
||||
|
||||
test("layer opacity slider works", async ({ editorPage: page }) => {
|
||||
const slider = page.locator("[data-testid='layer-opacity-slider']");
|
||||
await expect(slider).toBeVisible();
|
||||
|
||||
// Default opacity should be 100 (full)
|
||||
await expect(slider).toHaveValue("100");
|
||||
|
||||
// Change opacity to 50
|
||||
await slider.fill("50");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(slider).toHaveValue("50");
|
||||
|
||||
// Change back to 75
|
||||
await slider.fill("75");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(slider).toHaveValue("75");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createNewDocument, expect, selectTool, test } from "./helpers";
|
||||
|
||||
test.describe("Editor Options Bar", () => {
|
||||
test.beforeEach(async ({ editorPage: page }) => {
|
||||
await createNewDocument(page);
|
||||
});
|
||||
|
||||
test("eyedropper options show sample size when eyedropper selected", async ({
|
||||
editorPage: page,
|
||||
}) => {
|
||||
await selectTool(page, "eyedropper");
|
||||
|
||||
// The options bar should display the Sample label and dropdown
|
||||
await expect(page.getByText("Sample:")).toBeVisible();
|
||||
|
||||
const sampleDropdown = page.locator("[data-testid='sample-size-dropdown']");
|
||||
await expect(sampleDropdown).toBeVisible();
|
||||
|
||||
// Should default to "Point (1x1)"
|
||||
await expect(sampleDropdown).toContainText("Point (1x1)");
|
||||
|
||||
// Clicking the dropdown should reveal size options
|
||||
await sampleDropdown.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(page.getByText("3x3 Average")).toBeVisible();
|
||||
await expect(page.getByText("5x5 Average")).toBeVisible();
|
||||
});
|
||||
|
||||
test("transform options show position/size when transform selected", async ({
|
||||
editorPage: page,
|
||||
}) => {
|
||||
await selectTool(page, "transform");
|
||||
|
||||
// The options bar should show X, Y, W, H, Rotation inputs
|
||||
await expect(page.locator("#transform-x")).toBeVisible();
|
||||
await expect(page.locator("#transform-y")).toBeVisible();
|
||||
await expect(page.locator("#transform-w")).toBeVisible();
|
||||
await expect(page.locator("#transform-h")).toBeVisible();
|
||||
await expect(page.locator("#transform-rotation")).toBeVisible();
|
||||
|
||||
// Flip buttons should be visible
|
||||
await expect(page.locator("button[aria-label='Flip Horizontal']")).toBeVisible();
|
||||
await expect(page.locator("button[aria-label='Flip Vertical']")).toBeVisible();
|
||||
|
||||
// Aspect ratio lock button should be visible
|
||||
const lockBtn = page.locator("button[aria-label*='aspect ratio']");
|
||||
await expect(lockBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test("brush options show size, opacity, hardness", async ({ editorPage: page }) => {
|
||||
await selectTool(page, "brush");
|
||||
|
||||
const optionsBar = page.locator(".flex.items-center.h-10");
|
||||
|
||||
// Size, Opacity, and Hardness labels should be in the options bar
|
||||
await expect(optionsBar.getByText("Size")).toBeVisible();
|
||||
await expect(optionsBar.getByText("Opacity")).toBeVisible();
|
||||
await expect(optionsBar.getByText("Hardness")).toBeVisible();
|
||||
|
||||
// Each should have a range slider and a number input
|
||||
const sizeSlider = optionsBar
|
||||
.locator("label")
|
||||
.filter({ hasText: "Size" })
|
||||
.locator("input[type='range']");
|
||||
await expect(sizeSlider).toBeVisible();
|
||||
|
||||
const opacitySlider = optionsBar
|
||||
.locator("label")
|
||||
.filter({ hasText: "Opacity" })
|
||||
.locator("input[type='range']");
|
||||
await expect(opacitySlider).toBeVisible();
|
||||
|
||||
const hardnessSlider = optionsBar
|
||||
.locator("label")
|
||||
.filter({ hasText: "Hardness" })
|
||||
.locator("input[type='range']");
|
||||
await expect(hardnessSlider).toBeVisible();
|
||||
});
|
||||
|
||||
test("selection options show mode dropdown", async ({ editorPage: page }) => {
|
||||
await selectTool(page, "marquee-rect");
|
||||
|
||||
// The options bar should show Type and Mode sections
|
||||
await expect(page.getByText("Type:")).toBeVisible();
|
||||
await expect(page.getByText("Mode:")).toBeVisible();
|
||||
|
||||
// Type buttons: Rect, Ellipse, Lasso
|
||||
const rectBtn = page.locator("button[aria-label='Rectangular']");
|
||||
const ellipseBtn = page.locator("button[aria-label='Elliptical']");
|
||||
const lassoBtn = page.locator("button[aria-label='Lasso']");
|
||||
|
||||
await expect(rectBtn).toBeVisible();
|
||||
await expect(ellipseBtn).toBeVisible();
|
||||
await expect(lassoBtn).toBeVisible();
|
||||
|
||||
// Rect should be active (pressed) since we selected marquee-rect
|
||||
await expect(rectBtn).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// Mode buttons: New, Add, Sub
|
||||
const newBtn = page.locator("button[aria-label='New Selection']");
|
||||
const addBtn = page.locator("button[aria-label='Add to Selection']");
|
||||
const subBtn = page.locator("button[aria-label='Subtract from Selection']");
|
||||
|
||||
await expect(newBtn).toBeVisible();
|
||||
await expect(addBtn).toBeVisible();
|
||||
await expect(subBtn).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createNewDocument, expect, test } from "./helpers";
|
||||
|
||||
test.describe("Editor Rulers and Guides", () => {
|
||||
test.beforeEach(async ({ editorPage: page }) => {
|
||||
await createNewDocument(page);
|
||||
});
|
||||
|
||||
test("rulers are hidden by default", async ({ editorPage: page }) => {
|
||||
// The ruler canvases render only when rulersVisible is true.
|
||||
// By default rulers are hidden, so the ruler-specific canvases
|
||||
// (with cursor-col-resize / cursor-row-resize) should not be present.
|
||||
const horizontalRuler = page.locator("canvas.cursor-col-resize");
|
||||
const verticalRuler = page.locator("canvas.cursor-row-resize");
|
||||
|
||||
await expect(horizontalRuler).toHaveCount(0);
|
||||
await expect(verticalRuler).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Ctrl+R toggles ruler visibility", async ({ editorPage: page }) => {
|
||||
// Initially hidden
|
||||
const horizontalRuler = page.locator("canvas.cursor-col-resize");
|
||||
await expect(horizontalRuler).toHaveCount(0);
|
||||
|
||||
// Press Ctrl+R to show rulers
|
||||
await page.keyboard.press("Control+r");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Now they should appear
|
||||
await expect(page.locator("canvas.cursor-col-resize")).toBeVisible();
|
||||
await expect(page.locator("canvas.cursor-row-resize")).toBeVisible();
|
||||
|
||||
// Press Ctrl+R again to hide
|
||||
await page.keyboard.press("Control+r");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.locator("canvas.cursor-col-resize")).toHaveCount(0);
|
||||
await expect(page.locator("canvas.cursor-row-resize")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("horizontal ruler appears at top edge", async ({ editorPage: page }) => {
|
||||
// Enable rulers
|
||||
await page.keyboard.press("Control+r");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const horizontalRuler = page.locator("canvas.cursor-col-resize");
|
||||
await expect(horizontalRuler).toBeVisible();
|
||||
|
||||
// Ruler should have a fixed height of 20px (RULER_SIZE)
|
||||
const box = await horizontalRuler.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.height).toBe(20);
|
||||
|
||||
// Ruler should stretch to full width (w-full class)
|
||||
expect(box!.width).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test("vertical ruler appears at left edge", async ({ editorPage: page }) => {
|
||||
// Enable rulers
|
||||
await page.keyboard.press("Control+r");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const verticalRuler = page.locator("canvas.cursor-row-resize");
|
||||
await expect(verticalRuler).toBeVisible();
|
||||
|
||||
// Ruler should have a fixed width of 20px (RULER_SIZE)
|
||||
const box = await verticalRuler.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBe(20);
|
||||
|
||||
// Ruler should stretch to fill the available height
|
||||
expect(box!.height).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createNewDocument, drawOnCanvas, expect, selectTool, test } from "./helpers";
|
||||
|
||||
test.describe("Editor Selection Tools", () => {
|
||||
test.beforeEach(async ({ editorPage: page }) => {
|
||||
await createNewDocument(page);
|
||||
});
|
||||
|
||||
test("rectangle selection creates visible selection area", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
await selectTool(page, "marquee-rect");
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Drag to create a rectangular selection
|
||||
await drawOnCanvas(page, 100, 100, 300, 250);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
// The marching ants overlay should cause a visual difference
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("lasso tool creates selection", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Activate the lasso-free tool
|
||||
await selectTool(page, "lasso-free");
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Draw a freehand lasso path (needs enough points to form a polygon)
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
|
||||
await page.mouse.move(box.x + 100, box.y + 100);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 200, box.y + 100, { steps: 5 });
|
||||
await page.mouse.move(box.x + 200, box.y + 200, { steps: 5 });
|
||||
await page.mouse.move(box.x + 100, box.y + 200, { steps: 5 });
|
||||
await page.mouse.move(box.x + 100, box.y + 100, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("magic wand tool creates selection on click", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Draw something first so the magic wand has varied pixel data
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 100, 100, 300, 300);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Switch to magic wand
|
||||
await selectTool(page, "magic-wand");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Click on a blank area to select it
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
await page.mouse.click(box.x + 50, box.y + 50);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
// The magic wand should create a selection (marching ants visible)
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("selection mode toggle (add/subtract) exists in options bar", async ({
|
||||
editorPage: page,
|
||||
}) => {
|
||||
await selectTool(page, "marquee-rect");
|
||||
|
||||
// The options bar should show Mode label
|
||||
await expect(page.getByText("Mode:")).toBeVisible();
|
||||
|
||||
// New, Add, Sub buttons should be visible
|
||||
const newBtn = page.locator("button[aria-label='New Selection']");
|
||||
const addBtn = page.locator("button[aria-label='Add to Selection']");
|
||||
const subBtn = page.locator("button[aria-label='Subtract from Selection']");
|
||||
|
||||
await expect(newBtn).toBeVisible();
|
||||
await expect(addBtn).toBeVisible();
|
||||
await expect(subBtn).toBeVisible();
|
||||
|
||||
// "New" should be active by default
|
||||
await expect(newBtn).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
test("Ctrl+D deselects", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Create a selection first
|
||||
await selectTool(page, "marquee-rect");
|
||||
await drawOnCanvas(page, 100, 100, 300, 250);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const withSelection = await canvas.screenshot();
|
||||
|
||||
// Press Ctrl+D to deselect
|
||||
await page.keyboard.press("Control+d");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const afterDeselect = await canvas.screenshot();
|
||||
// The marching ants should disappear, making a visual difference
|
||||
expect(Buffer.compare(withSelection, afterDeselect)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("Ctrl+Shift+I inverts selection", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Create a selection first
|
||||
await selectTool(page, "marquee-rect");
|
||||
await drawOnCanvas(page, 100, 100, 200, 200);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const beforeInvert = await canvas.screenshot();
|
||||
|
||||
// Press Ctrl+Shift+I to invert selection
|
||||
await page.keyboard.press("Control+Shift+i");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const afterInvert = await canvas.screenshot();
|
||||
// The selection bounds should change, causing a visual difference
|
||||
expect(Buffer.compare(beforeInvert, afterInvert)).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createNewDocument, drawOnCanvas, expect, selectTool, test } from "./helpers";
|
||||
|
||||
test.describe("Editor Transform and Resize", () => {
|
||||
test.beforeEach(async ({ editorPage: page }) => {
|
||||
await createNewDocument(page);
|
||||
});
|
||||
|
||||
test("resize canvas dialog opens and works", async ({ editorPage: page }) => {
|
||||
// Right-click on the canvas to open the context menu
|
||||
const canvas = page.locator("canvas").first();
|
||||
await canvas.click({ button: "right" });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Click "Canvas Size..." in the context menu
|
||||
const canvasSizeBtn = page.locator("button").filter({ hasText: "Canvas Size..." });
|
||||
await expect(canvasSizeBtn).toBeVisible();
|
||||
await canvasSizeBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// The Canvas Size dialog should appear
|
||||
const dialogTitle = page.getByText("Canvas Size", { exact: true });
|
||||
await expect(dialogTitle).toBeVisible();
|
||||
|
||||
// Width and Height inputs should be visible
|
||||
const widthInput = page.locator("#canvas-w");
|
||||
const heightInput = page.locator("#canvas-h");
|
||||
await expect(widthInput).toBeVisible();
|
||||
await expect(heightInput).toBeVisible();
|
||||
|
||||
// Anchor buttons should be present (9-point grid)
|
||||
const anchorButtons = page.locator("button[aria-label^='Anchor']");
|
||||
await expect(anchorButtons).toHaveCount(9);
|
||||
|
||||
// Background color input should be visible
|
||||
const bgColorInput = page.locator("#canvas-fill");
|
||||
await expect(bgColorInput).toBeVisible();
|
||||
|
||||
// Apply and Cancel buttons should be present
|
||||
await expect(page.locator("button").filter({ hasText: "Apply" })).toBeVisible();
|
||||
await expect(page.locator("button").filter({ hasText: "Cancel" })).toBeVisible();
|
||||
|
||||
// Cancel should close the dialog
|
||||
await page.locator("button").filter({ hasText: "Cancel" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Dialog should be gone
|
||||
await expect(dialogTitle).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("resize image dialog opens and works", async ({ editorPage: page }) => {
|
||||
// Right-click on the canvas to open the context menu
|
||||
const canvas = page.locator("canvas").first();
|
||||
await canvas.click({ button: "right" });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Click "Image Size..." in the context menu
|
||||
const imageSizeBtn = page.locator("button").filter({ hasText: "Image Size..." });
|
||||
await expect(imageSizeBtn).toBeVisible();
|
||||
await imageSizeBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// The Image Size dialog should appear
|
||||
const dialogTitle = page.getByText("Image Size", { exact: true });
|
||||
await expect(dialogTitle).toBeVisible();
|
||||
|
||||
// Width and Height inputs should be visible
|
||||
const widthInput = page.locator("#img-w");
|
||||
const heightInput = page.locator("#img-h");
|
||||
await expect(widthInput).toBeVisible();
|
||||
await expect(heightInput).toBeVisible();
|
||||
|
||||
// Aspect ratio lock button should be visible
|
||||
const lockBtn = page.locator("button[aria-label*='aspect ratio']");
|
||||
await expect(lockBtn).toBeVisible();
|
||||
|
||||
// Resampling select should be present
|
||||
const resampleSelect = page.locator("#resample");
|
||||
await expect(resampleSelect).toBeVisible();
|
||||
|
||||
// It should have the expected options
|
||||
const options = resampleSelect.locator("option");
|
||||
const texts = await options.allTextContents();
|
||||
expect(texts).toContain("Nearest Neighbor (fast)");
|
||||
expect(texts).toContain("Bicubic (smooth)");
|
||||
|
||||
// Cancel should close the dialog
|
||||
await page.locator("button").filter({ hasText: "Cancel" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(dialogTitle).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("flip horizontal via transform options changes canvas", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Draw an asymmetric shape so flip is visually detectable
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 50, 50, 200, 100);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Switch to transform tool
|
||||
await selectTool(page, "transform");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Click the Flip Horizontal button in the transform options bar
|
||||
const flipHBtn = page.locator("button[aria-label='Flip Horizontal']");
|
||||
await expect(flipHBtn).toBeVisible();
|
||||
await flipHBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
|
||||
test("flip vertical via transform options changes canvas", async ({ editorPage: page }) => {
|
||||
test.slow();
|
||||
|
||||
// Draw an asymmetric shape so flip is visually detectable
|
||||
await selectTool(page, "brush");
|
||||
await drawOnCanvas(page, 50, 50, 100, 200);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Switch to transform tool
|
||||
await selectTool(page, "transform");
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const canvas = page.locator("canvas").first();
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
// Click the Flip Vertical button in the transform options bar
|
||||
const flipVBtn = page.locator("button[aria-label='Flip Vertical']");
|
||||
await expect(flipVBtn).toBeVisible();
|
||||
await flipVBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const after = await canvas.screenshot();
|
||||
expect(Buffer.compare(before, after)).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -834,7 +834,8 @@ describe("Selection", () => {
|
||||
};
|
||||
act((s) => s.setSelection(sel));
|
||||
act((s) => s.invertSelection());
|
||||
expect(state().selection?.mask).toBeUndefined();
|
||||
// After fix: invertSelection now creates a mask from bounds and inverts it
|
||||
expect(state().selection?.mask).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1198,10 +1199,13 @@ describe("Canvas Transforms", () => {
|
||||
act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 })));
|
||||
act((s) => s.trimCanvas());
|
||||
expect(state().isDirty).toBe(true);
|
||||
expect(state().canvasSize).toEqual({ width: 100, height: 50 });
|
||||
// Trim now includes strokeWidth (1px / 2 = 0.5px per side -> 1px extra per axis)
|
||||
expect(state().canvasSize).toEqual({ width: 102, height: 52 });
|
||||
const obj = state().objects[0];
|
||||
expect(obj.type === "rect" && obj.attrs.x).toBe(0);
|
||||
expect(obj.type === "rect" && obj.attrs.y).toBe(0);
|
||||
// Object at (100,200) with stroke half-width 0.5: trim origin is floor(99.5)=99
|
||||
// so x = 100 - 99 = 1, y = 200 - 199 = 1
|
||||
expect(obj.type === "rect" && obj.attrs.x).toBe(1);
|
||||
expect(obj.type === "rect" && obj.attrs.y).toBe(1);
|
||||
});
|
||||
|
||||
it("trimCanvas no-ops when no objects exist", () => {
|
||||
@@ -1245,3 +1249,548 @@ describe("Cursor Position", () => {
|
||||
expect(state().cursorPosition).toEqual({ x: 150, y: 250 });
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Helper factories for new object types
|
||||
// ===========================================================================
|
||||
|
||||
function makeEllipse(
|
||||
overrides: Partial<{ id: string; layerId: string; x: number; y: number }> = {},
|
||||
): CanvasObject {
|
||||
return {
|
||||
id: overrides.id ?? "ellipse-1",
|
||||
type: "ellipse",
|
||||
layerId: overrides.layerId ?? state().activeLayerId,
|
||||
attrs: {
|
||||
x: overrides.x ?? 200,
|
||||
y: overrides.y ?? 150,
|
||||
radiusX: 80,
|
||||
radiusY: 50,
|
||||
fill: "#00ff00",
|
||||
stroke: "#000000",
|
||||
strokeWidth: 2,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeArrow(overrides: Partial<{ id: string; layerId: string }> = {}): CanvasObject {
|
||||
return {
|
||||
id: overrides.id ?? "arrow-1",
|
||||
type: "arrow",
|
||||
layerId: overrides.layerId ?? state().activeLayerId,
|
||||
attrs: {
|
||||
points: [10, 20, 110, 120],
|
||||
fill: "#000",
|
||||
stroke: "#000",
|
||||
strokeWidth: 3,
|
||||
pointerLength: 10,
|
||||
pointerWidth: 10,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// resizeImage with object scaling
|
||||
// ===========================================================================
|
||||
|
||||
describe("resizeImage object scaling", () => {
|
||||
it("scales rect positions and dimensions proportionally", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 })));
|
||||
act((s) => s.resizeImage(400, 300));
|
||||
const obj = state().objects[0];
|
||||
expect(obj.type).toBe("rect");
|
||||
if (obj.type === "rect") {
|
||||
expect(obj.attrs.x).toBe(50); // 100 * (400/800)
|
||||
expect(obj.attrs.y).toBe(100); // 200 * (300/600)
|
||||
expect(obj.attrs.width).toBe(50); // 100 * 0.5
|
||||
expect(obj.attrs.height).toBe(25); // 50 * 0.5
|
||||
}
|
||||
});
|
||||
|
||||
it("scales line/arrow points arrays proportionally", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeLine({ id: "l1" })));
|
||||
// line points: [0, 0, 100, 100]
|
||||
act((s) => s.resizeImage(400, 300));
|
||||
const obj = state().objects[0];
|
||||
expect(obj.type).toBe("line");
|
||||
if (obj.type === "line") {
|
||||
expect(obj.attrs.points[0]).toBe(0); // 0 * 0.5
|
||||
expect(obj.attrs.points[1]).toBe(0); // 0 * 0.5
|
||||
expect(obj.attrs.points[2]).toBe(50); // 100 * 0.5
|
||||
expect(obj.attrs.points[3]).toBe(50); // 100 * 0.5
|
||||
}
|
||||
});
|
||||
|
||||
it("scales ellipse radii proportionally", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeEllipse({ id: "e1", x: 200, y: 150 })));
|
||||
act((s) => s.resizeImage(400, 300));
|
||||
const obj = state().objects[0];
|
||||
expect(obj.type).toBe("ellipse");
|
||||
if (obj.type === "ellipse") {
|
||||
expect(obj.attrs.x).toBe(100); // 200 * 0.5
|
||||
expect(obj.attrs.y).toBe(75); // 150 * 0.5
|
||||
expect(obj.attrs.radiusX).toBe(40); // 80 * 0.5
|
||||
expect(obj.attrs.radiusY).toBe(25); // 50 * 0.5
|
||||
}
|
||||
});
|
||||
|
||||
it("scales text fontSize and position", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeText({ id: "t1", x: 50, y: 60 })));
|
||||
act((s) => s.resizeImage(400, 300));
|
||||
const obj = state().objects[0];
|
||||
expect(obj.type).toBe("text");
|
||||
if (obj.type === "text") {
|
||||
expect(obj.attrs.x).toBe(25); // 50 * 0.5
|
||||
expect(obj.attrs.y).toBe(30); // 60 * 0.5
|
||||
expect(obj.attrs.fontSize).toBe(8); // 16 * 0.5
|
||||
}
|
||||
});
|
||||
|
||||
it("scales strokeWidth", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeRect({ id: "r1" })));
|
||||
act((s) => s.resizeImage(400, 300));
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "rect") {
|
||||
expect(obj.attrs.strokeWidth).toBe(0.5); // 1 * 0.5
|
||||
}
|
||||
});
|
||||
|
||||
it("handles non-uniform scaling (different scaleX/scaleY)", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 })));
|
||||
// Scale width by 2x, height by 0.5x
|
||||
act((s) => s.resizeImage(1600, 300));
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "rect") {
|
||||
expect(obj.attrs.x).toBe(200); // 100 * 2
|
||||
expect(obj.attrs.y).toBe(100); // 200 * 0.5
|
||||
expect(obj.attrs.width).toBe(200); // 100 * 2
|
||||
expect(obj.attrs.height).toBe(25); // 50 * 0.5
|
||||
// strokeWidth scales by min(scaleX, scaleY) = min(2, 0.5) = 0.5
|
||||
expect(obj.attrs.strokeWidth).toBe(0.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// rotateCanvas with points-based objects
|
||||
// ===========================================================================
|
||||
|
||||
describe("rotateCanvas with line objects", () => {
|
||||
it("rotates line points 90 degrees clockwise", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
// line with points [0, 0, 100, 100]
|
||||
act((s) => s.addObject(makeLine({ id: "l1" })));
|
||||
act((s) => s.rotateCanvas(90));
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "line") {
|
||||
// 90 deg CW: newX = canvasHeight - py, newY = px
|
||||
// canvasHeight was 600 before rotation
|
||||
expect(obj.attrs.points[0]).toBe(600); // 600 - 0
|
||||
expect(obj.attrs.points[1]).toBe(0); // 0
|
||||
expect(obj.attrs.points[2]).toBe(500); // 600 - 100
|
||||
expect(obj.attrs.points[3]).toBe(100); // 100
|
||||
}
|
||||
});
|
||||
|
||||
it("rotates line points 270 degrees clockwise", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeLine({ id: "l1" })));
|
||||
act((s) => s.rotateCanvas(270));
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "line") {
|
||||
// 270 deg CW: newX = py, newY = canvasWidth - px
|
||||
// canvasWidth was 800 before rotation
|
||||
expect(obj.attrs.points[0]).toBe(0); // 0
|
||||
expect(obj.attrs.points[1]).toBe(800); // 800 - 0
|
||||
expect(obj.attrs.points[2]).toBe(100); // 100
|
||||
expect(obj.attrs.points[3]).toBe(700); // 800 - 100
|
||||
}
|
||||
});
|
||||
|
||||
it("rotates line points 180 degrees", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeLine({ id: "l1" })));
|
||||
act((s) => s.rotateCanvas(180));
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "line") {
|
||||
// 180 deg: newX = canvasWidth - px, newY = canvasHeight - py
|
||||
expect(obj.attrs.points[0]).toBe(800); // 800 - 0
|
||||
expect(obj.attrs.points[1]).toBe(600); // 600 - 0
|
||||
expect(obj.attrs.points[2]).toBe(700); // 800 - 100
|
||||
expect(obj.attrs.points[3]).toBe(500); // 600 - 100
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// flipCanvas with points-based objects
|
||||
// ===========================================================================
|
||||
|
||||
describe("flipCanvas with line objects", () => {
|
||||
it("flipCanvasHorizontal flips line points x-coordinates", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeLine({ id: "l1" })));
|
||||
act((s) => s.flipCanvasHorizontal());
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "line") {
|
||||
// Flip horizontal: newX = canvasWidth - px, y unchanged
|
||||
expect(obj.attrs.points[0]).toBe(800); // 800 - 0
|
||||
expect(obj.attrs.points[1]).toBe(0); // unchanged
|
||||
expect(obj.attrs.points[2]).toBe(700); // 800 - 100
|
||||
expect(obj.attrs.points[3]).toBe(100); // unchanged
|
||||
}
|
||||
});
|
||||
|
||||
it("flipCanvasVertical flips line points y-coordinates", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeLine({ id: "l1" })));
|
||||
act((s) => s.flipCanvasVertical());
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "line") {
|
||||
// Flip vertical: x unchanged, newY = canvasHeight - py
|
||||
expect(obj.attrs.points[0]).toBe(0); // unchanged
|
||||
expect(obj.attrs.points[1]).toBe(600); // 600 - 0
|
||||
expect(obj.attrs.points[2]).toBe(100); // unchanged
|
||||
expect(obj.attrs.points[3]).toBe(500); // 600 - 100
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// flipCanvas/rotateCanvas with center-based objects (ellipse)
|
||||
// ===========================================================================
|
||||
|
||||
describe("transform with center-based objects", () => {
|
||||
it("flipCanvasHorizontal correctly flips ellipse center position (no width subtraction)", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeEllipse({ id: "e1", x: 200, y: 150 })));
|
||||
act((s) => s.flipCanvasHorizontal());
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "ellipse") {
|
||||
// Center-based: newX = canvasWidth - x (no width subtraction)
|
||||
expect(obj.attrs.x).toBe(600); // 800 - 200
|
||||
expect(obj.attrs.y).toBe(150); // unchanged
|
||||
}
|
||||
});
|
||||
|
||||
it("rotateCanvas 90 correctly rotates ellipse center position", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeEllipse({ id: "e1", x: 200, y: 150 })));
|
||||
act((s) => s.rotateCanvas(90));
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "ellipse") {
|
||||
// Center-based 90 deg: newX = canvasHeight - y (no height subtraction), newY = x
|
||||
expect(obj.attrs.x).toBe(450); // 600 - 150
|
||||
expect(obj.attrs.y).toBe(200); // original x
|
||||
// Radii swap for 90 deg rotation
|
||||
expect(obj.attrs.radiusX).toBe(50); // was radiusY
|
||||
expect(obj.attrs.radiusY).toBe(80); // was radiusX
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// trimCanvas with points-based objects
|
||||
// ===========================================================================
|
||||
|
||||
describe("trimCanvas with line objects", () => {
|
||||
it("computes correct bounds from line points", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
// Line with points [50, 100, 250, 300], strokeWidth = 2
|
||||
const lineObj: CanvasObject = {
|
||||
id: "l1",
|
||||
type: "line",
|
||||
layerId: state().activeLayerId,
|
||||
attrs: {
|
||||
points: [50, 100, 250, 300],
|
||||
stroke: "#000",
|
||||
strokeWidth: 2,
|
||||
tension: 0,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
opacity: 1,
|
||||
globalCompositeOperation: "source-over",
|
||||
},
|
||||
};
|
||||
act((s) => s.addObject(lineObj));
|
||||
act((s) => s.trimCanvas());
|
||||
// Bounds: minX = 50-1=49, minY = 100-1=99, maxX = 250+1=251, maxY = 300+1=301
|
||||
// Trimmed size = ceil(251) - floor(49) = 251-49 = 202, ceil(301) - floor(99) = 301-99 = 202
|
||||
expect(state().canvasSize).toEqual({ width: 202, height: 202 });
|
||||
});
|
||||
|
||||
it("offsets line points after trim", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
const lineObj: CanvasObject = {
|
||||
id: "l1",
|
||||
type: "line",
|
||||
layerId: state().activeLayerId,
|
||||
attrs: {
|
||||
points: [50, 100, 250, 300],
|
||||
stroke: "#000",
|
||||
strokeWidth: 2,
|
||||
tension: 0,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
opacity: 1,
|
||||
globalCompositeOperation: "source-over",
|
||||
},
|
||||
};
|
||||
act((s) => s.addObject(lineObj));
|
||||
act((s) => s.trimCanvas());
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "line") {
|
||||
// minX = floor(49) = 49, minY = floor(99) = 99
|
||||
expect(obj.attrs.points[0]).toBe(1); // 50 - 49
|
||||
expect(obj.attrs.points[1]).toBe(1); // 100 - 99
|
||||
expect(obj.attrs.points[2]).toBe(201); // 250 - 49
|
||||
expect(obj.attrs.points[3]).toBe(201); // 300 - 99
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// applyCrop with points-based objects
|
||||
// ===========================================================================
|
||||
|
||||
describe("applyCrop with line objects", () => {
|
||||
it("shifts line points by crop offset", () => {
|
||||
act((s) => s.loadImage("blob:test", 800, 600));
|
||||
act((s) => s.addObject(makeLine({ id: "l1" })));
|
||||
// line points: [0, 0, 100, 100]
|
||||
act((s) => s.setCropState({ x: 20, y: 30, width: 400, height: 300, aspectRatio: null }));
|
||||
act((s) => s.applyCrop());
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "line") {
|
||||
expect(obj.attrs.points[0]).toBe(-20); // 0 - 20
|
||||
expect(obj.attrs.points[1]).toBe(-30); // 0 - 30
|
||||
expect(obj.attrs.points[2]).toBe(80); // 100 - 20
|
||||
expect(obj.attrs.points[3]).toBe(70); // 100 - 30
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// invertSelection for bounds-based selections
|
||||
// ===========================================================================
|
||||
|
||||
describe("invertSelection for bounds-based selections", () => {
|
||||
it("creates mask from bounds and inverts it", () => {
|
||||
// Use a small canvas for tractable mask sizes
|
||||
useEditorStore.setState({ canvasSize: { width: 10, height: 10 } });
|
||||
const sel = {
|
||||
type: "rect" as const,
|
||||
points: [2, 2, 6, 6],
|
||||
bounds: { x: 2, y: 2, width: 4, height: 4 },
|
||||
};
|
||||
act((s) => s.setSelection(sel));
|
||||
act((s) => s.invertSelection());
|
||||
const mask = state().selection?.mask;
|
||||
expect(mask).toBeDefined();
|
||||
});
|
||||
|
||||
it("mask has correct dimensions (canvasSize width * height)", () => {
|
||||
useEditorStore.setState({ canvasSize: { width: 10, height: 10 } });
|
||||
const sel = {
|
||||
type: "rect" as const,
|
||||
points: [2, 2, 6, 6],
|
||||
bounds: { x: 2, y: 2, width: 4, height: 4 },
|
||||
};
|
||||
act((s) => s.setSelection(sel));
|
||||
act((s) => s.invertSelection());
|
||||
const mask = state().selection?.mask;
|
||||
expect(mask?.length).toBe(100); // 10 * 10
|
||||
});
|
||||
|
||||
it("area inside bounds is 0 after inversion, outside is 255", () => {
|
||||
useEditorStore.setState({ canvasSize: { width: 10, height: 10 } });
|
||||
const sel = {
|
||||
type: "rect" as const,
|
||||
points: [2, 2, 6, 6],
|
||||
bounds: { x: 2, y: 2, width: 4, height: 4 },
|
||||
};
|
||||
act((s) => s.setSelection(sel));
|
||||
act((s) => s.invertSelection());
|
||||
const mask = state().selection!.mask!;
|
||||
// Inside the bounds (rows 2-5, cols 2-5) should be 0 (was 255, now inverted)
|
||||
expect(mask[2 * 10 + 2]).toBe(0); // row 2, col 2
|
||||
expect(mask[5 * 10 + 5]).toBe(0); // row 5, col 5
|
||||
// Outside the bounds should be 255 (was 0, now inverted)
|
||||
expect(mask[0 * 10 + 0]).toBe(255); // row 0, col 0
|
||||
expect(mask[9 * 10 + 9]).toBe(255); // row 9, col 9
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// cutObjects atomic
|
||||
// ===========================================================================
|
||||
|
||||
describe("cutObjects atomic", () => {
|
||||
it("removes selected objects and stores in clipboard atomically", () => {
|
||||
act((s) => s.addObject(makeRect({ id: "r1" })));
|
||||
act((s) => s.addObject(makeRect({ id: "r2" })));
|
||||
act((s) => s.setSelectedObjects(["r1"]));
|
||||
act((s) => s.cutObjects());
|
||||
// clipboard should contain the cut object
|
||||
expect(state().clipboard).toHaveLength(1);
|
||||
expect(state().clipboard?.[0].id).toBe("r1");
|
||||
// r1 removed from objects
|
||||
expect(state().objects).toHaveLength(1);
|
||||
expect(state().objects[0].id).toBe("r2");
|
||||
// selection cleared
|
||||
expect(state().selectedObjectIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates history entry with Cut action", () => {
|
||||
act((s) => s.addObject(makeRect({ id: "r1" })));
|
||||
act((s) => s.setSelectedObjects(["r1"]));
|
||||
const versionBefore = state()._historyVersion;
|
||||
act((s) => s.cutObjects());
|
||||
expect(state().lastAction).toBe("Cut");
|
||||
expect(state()._historyVersion).toBe(versionBefore + 1);
|
||||
});
|
||||
|
||||
it("does nothing when no objects selected", () => {
|
||||
act((s) => s.addObject(makeRect({ id: "r1" })));
|
||||
act((s) => s.setSelectedObjects([]));
|
||||
const objsBefore = state().objects.length;
|
||||
const versionBefore = state()._historyVersion;
|
||||
act((s) => s.cutObjects());
|
||||
expect(state().objects.length).toBe(objsBefore);
|
||||
expect(state().clipboard).toBeNull();
|
||||
expect(state()._historyVersion).toBe(versionBefore);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// sendToBack with multiple layers
|
||||
// ===========================================================================
|
||||
|
||||
describe("sendToBack with multiple layers", () => {
|
||||
it("places object at start of its layer's section, not at index 0", () => {
|
||||
const layer1Id = state().activeLayerId;
|
||||
act((s) => s.addLayer());
|
||||
const layer2Id = state().activeLayerId;
|
||||
// Add objects to layer 1
|
||||
act((s) => s.addObject(makeRect({ id: "l1-r1", layerId: layer1Id })));
|
||||
act((s) => s.addObject(makeRect({ id: "l1-r2", layerId: layer1Id })));
|
||||
// Add objects to layer 2
|
||||
act((s) => s.addObject(makeRect({ id: "l2-r1", layerId: layer2Id })));
|
||||
act((s) => s.addObject(makeRect({ id: "l2-r2", layerId: layer2Id })));
|
||||
// Send last object of layer 2 to back within its layer
|
||||
act((s) => s.sendToBack("l2-r2"));
|
||||
// l2-r2 should be before l2-r1 but after layer 1 objects
|
||||
const ids = state().objects.map((o) => o.id);
|
||||
const l2r2Idx = ids.indexOf("l2-r2");
|
||||
const l2r1Idx = ids.indexOf("l2-r1");
|
||||
const l1r2Idx = ids.indexOf("l1-r2");
|
||||
expect(l2r2Idx).toBeLessThan(l2r1Idx);
|
||||
expect(l2r2Idx).toBeGreaterThan(l1r2Idx);
|
||||
});
|
||||
|
||||
it("handles case when no other objects on same layer", () => {
|
||||
const layer1Id = state().activeLayerId;
|
||||
act((s) => s.addLayer());
|
||||
const layer2Id = state().activeLayerId;
|
||||
// Add objects to layer 1
|
||||
act((s) => s.addObject(makeRect({ id: "l1-r1", layerId: layer1Id })));
|
||||
// Add single object to layer 2
|
||||
act((s) => s.addObject(makeRect({ id: "l2-r1", layerId: layer2Id })));
|
||||
// sendToBack should still work (no other objects on the same layer)
|
||||
act((s) => s.sendToBack("l2-r1"));
|
||||
const ids = state().objects.map((o) => o.id);
|
||||
// l2-r1 should still be after layer 1 objects
|
||||
expect(ids.indexOf("l2-r1")).toBeGreaterThan(ids.indexOf("l1-r1"));
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// batchNudge
|
||||
// ===========================================================================
|
||||
|
||||
describe("batchNudge", () => {
|
||||
it("moves multiple objects by dx, dy", () => {
|
||||
act((s) => s.addObject(makeRect({ id: "r1", x: 10, y: 20 })));
|
||||
act((s) => s.addObject(makeRect({ id: "r2", x: 50, y: 60 })));
|
||||
act((s) => s.batchNudge(["r1", "r2"], 5, -3));
|
||||
const r1 = state().objects.find((o) => o.id === "r1")!;
|
||||
const r2 = state().objects.find((o) => o.id === "r2")!;
|
||||
if (r1.type === "rect") {
|
||||
expect(r1.attrs.x).toBe(15); // 10 + 5
|
||||
expect(r1.attrs.y).toBe(17); // 20 - 3
|
||||
}
|
||||
if (r2.type === "rect") {
|
||||
expect(r2.attrs.x).toBe(55); // 50 + 5
|
||||
expect(r2.attrs.y).toBe(57); // 60 - 3
|
||||
}
|
||||
});
|
||||
|
||||
it("moves line objects (points array) by dx, dy", () => {
|
||||
act((s) => s.addObject(makeLine({ id: "l1" })));
|
||||
// line points: [0, 0, 100, 100]
|
||||
act((s) => s.batchNudge(["l1"], 10, 20));
|
||||
const obj = state().objects[0];
|
||||
if (obj.type === "line") {
|
||||
expect(obj.attrs.points[0]).toBe(10); // 0 + 10
|
||||
expect(obj.attrs.points[1]).toBe(20); // 0 + 20
|
||||
expect(obj.attrs.points[2]).toBe(110); // 100 + 10
|
||||
expect(obj.attrs.points[3]).toBe(120); // 100 + 20
|
||||
}
|
||||
});
|
||||
|
||||
it("creates history entry with Nudge action", () => {
|
||||
act((s) => s.addObject(makeRect({ id: "r1", x: 10, y: 20 })));
|
||||
const versionBefore = state()._historyVersion;
|
||||
act((s) => s.batchNudge(["r1"], 1, 1));
|
||||
expect(state().lastAction).toBe("Nudge");
|
||||
expect(state()._historyVersion).toBe(versionBefore + 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// commitHistory
|
||||
// ===========================================================================
|
||||
|
||||
describe("commitHistory", () => {
|
||||
it("increments _historyVersion", () => {
|
||||
const versionBefore = state()._historyVersion;
|
||||
act((s) => s.commitHistory("Test Action"));
|
||||
expect(state()._historyVersion).toBe(versionBefore + 1);
|
||||
});
|
||||
|
||||
it("sets lastAction to provided string", () => {
|
||||
act((s) => s.commitHistory("My Custom Action"));
|
||||
expect(state().lastAction).toBe("My Custom Action");
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// updateLayerThumbnail
|
||||
// ===========================================================================
|
||||
|
||||
describe("updateLayerThumbnail", () => {
|
||||
it("sets thumbnail on the specified layer", () => {
|
||||
const layerId = state().layers[0].id;
|
||||
act((s) => s.updateLayerThumbnail(layerId, "data:image/png;base64,abc123"));
|
||||
expect(state().layers[0].thumbnail).toBe("data:image/png;base64,abc123");
|
||||
});
|
||||
|
||||
it("does not affect other layers", () => {
|
||||
act((s) => s.addLayer());
|
||||
const firstId = state().layers[0].id;
|
||||
const secondId = state().layers[1].id;
|
||||
act((s) => s.updateLayerThumbnail(firstId, "data:image/png;base64,first"));
|
||||
expect(state().layers[0].thumbnail).toBe("data:image/png;base64,first");
|
||||
expect(state().layers[1].thumbnail).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createExposureFilter,
|
||||
createGrainFilter,
|
||||
createMotionBlurFilter,
|
||||
createSharpenFilter,
|
||||
createVibranceFilter,
|
||||
createVignetteFilter,
|
||||
createWarmthFilter,
|
||||
} from "@/components/editor/konva-filters";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Polyfill: jsdom does not provide ImageData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (typeof globalThis.ImageData === "undefined") {
|
||||
(globalThis as Record<string, unknown>).ImageData = class ImageData {
|
||||
readonly data: Uint8ClampedArray;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
constructor(data: Uint8ClampedArray, width: number, height: number) {
|
||||
if (data.length !== width * height * 4) {
|
||||
throw new Error("ImageData data length mismatch");
|
||||
}
|
||||
this.data = data;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a small ImageData from flat RGBA values.
|
||||
* Every 4 entries = one pixel: [R, G, B, A, R, G, B, A, ...].
|
||||
*/
|
||||
function makeImageData(pixels: number[], width: number, height: number): ImageData {
|
||||
return new ImageData(new Uint8ClampedArray(pixels), width, height);
|
||||
}
|
||||
|
||||
/** Create a uniform 4x4 image where every pixel has the same RGBA. */
|
||||
function uniform4x4(r: number, g: number, b: number, a = 255): ImageData {
|
||||
const pixels: number[] = [];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
pixels.push(r, g, b, a);
|
||||
}
|
||||
return makeImageData(pixels, 4, 4);
|
||||
}
|
||||
|
||||
/** Snapshot all data bytes from an ImageData. */
|
||||
function snapshot(img: ImageData): Uint8ClampedArray {
|
||||
return new Uint8ClampedArray(img.data);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// createExposureFilter
|
||||
// ===========================================================================
|
||||
|
||||
describe("createExposureFilter", () => {
|
||||
it("positive exposure brightens pixels", () => {
|
||||
const img = uniform4x4(100, 100, 100);
|
||||
const filter = createExposureFilter(0.5);
|
||||
filter(img);
|
||||
// Each channel should be brighter than the original 100
|
||||
expect(img.data[0]).toBeGreaterThan(100);
|
||||
expect(img.data[1]).toBeGreaterThan(100);
|
||||
expect(img.data[2]).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("negative exposure darkens pixels", () => {
|
||||
const img = uniform4x4(100, 100, 100);
|
||||
const filter = createExposureFilter(-0.5);
|
||||
filter(img);
|
||||
expect(img.data[0]).toBeLessThan(100);
|
||||
expect(img.data[1]).toBeLessThan(100);
|
||||
expect(img.data[2]).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("zero exposure is no-op", () => {
|
||||
const img = uniform4x4(100, 100, 100);
|
||||
const before = snapshot(img);
|
||||
const filter = createExposureFilter(0);
|
||||
filter(img);
|
||||
expect(img.data).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not modify alpha channel", () => {
|
||||
const img = uniform4x4(100, 100, 100, 200);
|
||||
const filter = createExposureFilter(0.5);
|
||||
filter(img);
|
||||
// Check alpha for each pixel
|
||||
for (let i = 3; i < img.data.length; i += 4) {
|
||||
expect(img.data[i]).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// createVibranceFilter
|
||||
// ===========================================================================
|
||||
|
||||
describe("createVibranceFilter", () => {
|
||||
it("positive vibrance increases saturation of dull pixels", () => {
|
||||
// A dull reddish pixel (low saturation)
|
||||
const img = makeImageData([130, 120, 110, 255], 1, 1);
|
||||
const before = snapshot(img);
|
||||
const filter = createVibranceFilter(80);
|
||||
filter(img);
|
||||
// The difference between max and min channel should increase
|
||||
const maxBefore = Math.max(before[0], before[1], before[2]);
|
||||
const minBefore = Math.min(before[0], before[1], before[2]);
|
||||
const maxAfter = Math.max(img.data[0], img.data[1], img.data[2]);
|
||||
const minAfter = Math.min(img.data[0], img.data[1], img.data[2]);
|
||||
expect(maxAfter - minAfter).toBeGreaterThanOrEqual(maxBefore - minBefore);
|
||||
});
|
||||
|
||||
it("does not modify alpha channel", () => {
|
||||
const img = uniform4x4(130, 120, 110, 180);
|
||||
const filter = createVibranceFilter(50);
|
||||
filter(img);
|
||||
for (let i = 3; i < img.data.length; i += 4) {
|
||||
expect(img.data[i]).toBe(180);
|
||||
}
|
||||
});
|
||||
|
||||
it("neutral gray pixels remain neutral", () => {
|
||||
// Pure gray: r=g=b, saturation is 0, so boost = amt * (1 - 0) = amt
|
||||
// but r-avg = 0 for each channel, so result = r + 0*boost = r
|
||||
const img = makeImageData([128, 128, 128, 255], 1, 1);
|
||||
const filter = createVibranceFilter(100);
|
||||
filter(img);
|
||||
expect(img.data[0]).toBe(128);
|
||||
expect(img.data[1]).toBe(128);
|
||||
expect(img.data[2]).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// createWarmthFilter
|
||||
// ===========================================================================
|
||||
|
||||
describe("createWarmthFilter", () => {
|
||||
it("positive warmth increases red, decreases blue", () => {
|
||||
const img = uniform4x4(100, 100, 100);
|
||||
const filter = createWarmthFilter(50);
|
||||
filter(img);
|
||||
expect(img.data[0]).toBeGreaterThan(100); // red increased
|
||||
expect(img.data[2]).toBeLessThan(100); // blue decreased
|
||||
});
|
||||
|
||||
it("negative warmth increases blue, decreases red", () => {
|
||||
const img = uniform4x4(100, 100, 100);
|
||||
const filter = createWarmthFilter(-50);
|
||||
filter(img);
|
||||
expect(img.data[0]).toBeLessThan(100); // red decreased
|
||||
expect(img.data[2]).toBeGreaterThan(100); // blue increased
|
||||
});
|
||||
|
||||
it("does not modify green or alpha channels", () => {
|
||||
const img = uniform4x4(100, 100, 100, 200);
|
||||
const filter = createWarmthFilter(50);
|
||||
filter(img);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
expect(img.data[i + 1]).toBe(100); // green unchanged
|
||||
expect(img.data[i + 3]).toBe(200); // alpha unchanged
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// createMotionBlurFilter
|
||||
// ===========================================================================
|
||||
|
||||
describe("createMotionBlurFilter", () => {
|
||||
it("blurs pixels in the direction of the angle", () => {
|
||||
// Create a 5x1 image with a bright pixel in the center, dark elsewhere
|
||||
// Horizontal motion blur (angle 0) should spread the bright pixel sideways
|
||||
const pixels = [0, 0, 0, 255, 0, 0, 0, 255, 200, 200, 200, 255, 0, 0, 0, 255, 0, 0, 0, 255];
|
||||
const img = makeImageData(pixels, 5, 1);
|
||||
const filter = createMotionBlurFilter({ angle: 0, distance: 5 });
|
||||
filter(img);
|
||||
// Pixels immediately adjacent to center should now be brighter (blur leaked)
|
||||
expect(img.data[1 * 4]).toBeGreaterThan(0); // pixel 1 gained brightness
|
||||
expect(img.data[3 * 4]).toBeGreaterThan(0); // pixel 3 gained brightness
|
||||
});
|
||||
|
||||
it("does not throw on edge pixels", () => {
|
||||
const img = uniform4x4(128, 128, 128);
|
||||
const filter = createMotionBlurFilter({ angle: 45, distance: 10 });
|
||||
expect(() => filter(img)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// createVignetteFilter
|
||||
// ===========================================================================
|
||||
|
||||
describe("createVignetteFilter", () => {
|
||||
it("darkens corner pixels more than center pixels", () => {
|
||||
const img = uniform4x4(200, 200, 200);
|
||||
const filter = createVignetteFilter({ amount: 80, midpoint: 20 });
|
||||
filter(img);
|
||||
// Corner pixel (0,0) index = 0
|
||||
const cornerR = img.data[0];
|
||||
// Center pixel -- for 4x4, "center" is at (2,2), index = (2*4+2)*4 = 40
|
||||
const centerR = img.data[40];
|
||||
// Corner should be darker (lower value)
|
||||
expect(cornerR).toBeLessThan(centerR);
|
||||
});
|
||||
|
||||
it("center pixel is minimally affected", () => {
|
||||
const img = uniform4x4(200, 200, 200);
|
||||
const filter = createVignetteFilter({ amount: 50, midpoint: 50 });
|
||||
filter(img);
|
||||
// Center-ish pixel (2,2)
|
||||
const centerR = img.data[40];
|
||||
// With a high midpoint, center should stay close to original
|
||||
expect(centerR).toBeGreaterThanOrEqual(180);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// createGrainFilter
|
||||
// ===========================================================================
|
||||
|
||||
describe("createGrainFilter", () => {
|
||||
it("modifies pixel values (adds noise)", () => {
|
||||
const img = uniform4x4(128, 128, 128);
|
||||
const before = snapshot(img);
|
||||
const filter = createGrainFilter({ amount: 80, size: 50 });
|
||||
filter(img);
|
||||
// At least some pixels should differ from the original due to noise
|
||||
let changed = false;
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
if (img.data[i] !== before[i] || img.data[i + 1] !== before[i + 1]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(changed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not modify alpha channel", () => {
|
||||
const img = uniform4x4(128, 128, 128, 200);
|
||||
const filter = createGrainFilter({ amount: 50, size: 25 });
|
||||
filter(img);
|
||||
for (let i = 3; i < img.data.length; i += 4) {
|
||||
expect(img.data[i]).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// createSharpenFilter
|
||||
// ===========================================================================
|
||||
|
||||
describe("createSharpenFilter", () => {
|
||||
it("sharpens high-contrast edges", () => {
|
||||
// 3x3 image: dark edges with a bright center
|
||||
const pixels = [
|
||||
0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 200, 200, 200, 255, 0, 0, 0, 255, 0,
|
||||
0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255,
|
||||
];
|
||||
const img = makeImageData(pixels, 3, 3);
|
||||
const filter = createSharpenFilter({ amount: 100, radius: 1 });
|
||||
filter(img);
|
||||
// The center pixel should remain bright or get brighter due to sharpening
|
||||
// (unsharp mask enhances the difference from the blur)
|
||||
const centerIdx = (1 * 3 + 1) * 4;
|
||||
expect(img.data[centerIdx]).toBeGreaterThanOrEqual(200);
|
||||
});
|
||||
|
||||
it("does not modify alpha channel", () => {
|
||||
const img = uniform4x4(128, 128, 128, 180);
|
||||
const filter = createSharpenFilter({ amount: 50, radius: 1 });
|
||||
filter(img);
|
||||
for (let i = 3; i < img.data.length; i += 4) {
|
||||
expect(img.data[i]).toBe(180);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user