mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: more tests, refactored
This commit is contained in:
@@ -14,7 +14,7 @@ import { ToolRail } from "../../../components/editor/tool-rail";
|
||||
import { exportProjectAsPng } from "../../../lib/export-png";
|
||||
import { floodFillDataUrl } from "../../../lib/flood-fill";
|
||||
import { commitStroke, type BrushStroke } from "../../../lib/brush-painter";
|
||||
import { createEditorToolControllers } from "../../../lib/editor-tool-controller";
|
||||
import type { EditorToolController } from "../../../lib/editor-tool-controller";
|
||||
import { useFaceDetection } from "../../../hooks/use-face-detection";
|
||||
import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow";
|
||||
import { useEditorAutosave } from "../../../hooks/use-editor-autosave";
|
||||
@@ -111,7 +111,7 @@ export default function EditorPage() {
|
||||
width: selectedLayer.width,
|
||||
height: selectedLayer.height,
|
||||
};
|
||||
}, [selectedLayer?.id, selectedLayer?.type, selectedLayer?.sourceUri, selectedLayer?.width, selectedLayer?.height]);
|
||||
}, [selectedLayer]);
|
||||
|
||||
const isLayerStillSelected = React.useCallback((layerId: string) => {
|
||||
const state = useEditorStore.getState();
|
||||
@@ -120,7 +120,6 @@ export default function EditorPage() {
|
||||
|
||||
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({
|
||||
tool,
|
||||
selectedLayerId,
|
||||
selectedImageLayer,
|
||||
activeLayerStillSelected: isLayerStillSelected,
|
||||
});
|
||||
@@ -173,7 +172,7 @@ export default function EditorPage() {
|
||||
|
||||
const handleExportPng = React.useCallback(async () => {
|
||||
await exportProjectAsPng(project, { isDark });
|
||||
}, [isDark, project, exportProjectAsPng]);
|
||||
}, [isDark, project]);
|
||||
|
||||
const handleExportProjectFile = React.useCallback(() => {
|
||||
const json = exportProjectToJson();
|
||||
@@ -219,6 +218,7 @@ export default function EditorPage() {
|
||||
}
|
||||
|
||||
function handleCanvasApply(width: number, height: number, aspect: AspectRatio) {
|
||||
void aspect;
|
||||
setCanvasSize(width, height);
|
||||
}
|
||||
|
||||
@@ -271,23 +271,19 @@ export default function EditorPage() {
|
||||
addCanvasSizedLayer(canvas.toDataURL("image/png"), "Layer");
|
||||
}, [cw, ch, addCanvasSizedLayer]);
|
||||
|
||||
const toolControllers = React.useMemo(
|
||||
() =>
|
||||
createEditorToolControllers({
|
||||
onAddTextLayer: () => addLayerByType("text"),
|
||||
onImportImage: () => imageInputRef.current?.click(),
|
||||
labels: {
|
||||
pointer: t("editor.toolPointer"),
|
||||
pan: t("editor.toolPan"),
|
||||
face: t("editor.toolFace"),
|
||||
fill: t("editor.toolFill"),
|
||||
brush: t("editor.toolBrush"),
|
||||
text: t("editor.toolText"),
|
||||
image: t("editor.toolImage"),
|
||||
},
|
||||
}),
|
||||
[addLayerByType, t],
|
||||
);
|
||||
const handleImportImageClick = React.useCallback(() => {
|
||||
imageInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const toolControllers = React.useMemo<EditorToolController[]>(() => [
|
||||
{ kind: "mode", id: "pointer", label: t("editor.toolPointer") },
|
||||
{ kind: "mode", id: "hand", label: t("editor.toolPan") },
|
||||
{ kind: "mode", id: "face", label: t("editor.toolFace") },
|
||||
{ kind: "mode", id: "fill", label: t("editor.toolFill") },
|
||||
{ kind: "mode", id: "brush", label: t("editor.toolBrush") },
|
||||
{ kind: "action", id: "add-text", label: t("editor.toolText"), run: () => addLayerByType("text") },
|
||||
{ kind: "action", id: "import-image", label: t("editor.toolImage"), run: handleImportImageClick },
|
||||
], [addLayerByType, handleImportImageClick, t]);
|
||||
|
||||
const canvasBindings = {
|
||||
onMoveLayer: (_id: string, x: number, y: number) => setSelectedLayerPositionDraft(x, y),
|
||||
|
||||
@@ -30,9 +30,15 @@ export default function HomePage() {
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
hydrate();
|
||||
void refreshProjects();
|
||||
}, [hydrate, refreshProjects]);
|
||||
loadProjects().then((loadedProjects) => {
|
||||
if (!canceled) setProjects(loadedProjects);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [hydrate]);
|
||||
|
||||
function openProject(project: Project) {
|
||||
setProject(project);
|
||||
|
||||
@@ -5,7 +5,8 @@ import { CanvasRenderer } from "./canvas-renderer";
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({ alt, src, unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => React.createElement("img", { alt, src, ...props }),
|
||||
default: ({ alt, src, unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) =>
|
||||
React.createElement("img", { alt, src, "data-unoptimized": unoptimized ? "true" : undefined, ...props }),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-translations", () => ({
|
||||
|
||||
@@ -231,9 +231,7 @@ export function CanvasRenderer({
|
||||
faceOverlayLayerId,
|
||||
layers,
|
||||
tool,
|
||||
viewport.scale,
|
||||
viewport.x,
|
||||
viewport.y,
|
||||
viewport,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -142,6 +142,15 @@ export function EditorSidebar({
|
||||
className="w-full accent-[var(--color-accent-strong)]"
|
||||
/>
|
||||
</div>
|
||||
{onCreateFillLayer ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateFillLayer}
|
||||
className={`mt-3 w-full rounded border px-3 py-2 text-xs font-semibold ${isDark ? "border-white/10 bg-[#353942] text-[#f0f3f8] hover:bg-[#3d424d]" : "border-black/10 bg-[#f4f6f9] text-[#1f2430] hover:bg-white"}`}
|
||||
>
|
||||
Create fill layer
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTypescript from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = [
|
||||
...nextVitals,
|
||||
...nextTypescript,
|
||||
{
|
||||
ignores: [".next/**", "coverage/**", "next-env.d.ts"],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -7,6 +7,11 @@ type FaceBlurPreview = {
|
||||
effects: LayerEffect[];
|
||||
};
|
||||
|
||||
type FaceSelectionState = {
|
||||
key: string;
|
||||
indices: number[];
|
||||
};
|
||||
|
||||
type UseFaceBlurWorkflowOptions = {
|
||||
selectedLayer: Layer | null;
|
||||
faceDetectionsLayerId: string | null;
|
||||
@@ -20,8 +25,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
|
||||
const [blurAmount, setBlurAmount] = React.useState(14);
|
||||
const [censorColor, setCensorColor] = React.useState("#111111");
|
||||
const [selectedFaceIndices, setSelectedFaceIndices] = React.useState<number[]>([]);
|
||||
const [faceBlurPreview, setFaceBlurPreview] = React.useState<FaceBlurPreview | null>(null);
|
||||
const [faceSelection, setFaceSelection] = React.useState<FaceSelectionState | null>(null);
|
||||
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "raster" && faceDetectionsLayerId === selectedLayer.id);
|
||||
|
||||
const faceBlurEffect = React.useMemo(
|
||||
@@ -29,6 +33,15 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
[selectedLayer?.effects],
|
||||
);
|
||||
|
||||
const selectionKey = `${selectedLayer?.id ?? "none"}:${faceDetectionsLayerId ?? "none"}:${faceDetections.length}:${faceBlurEffect ? "effect" : "empty"}`;
|
||||
|
||||
const defaultSelectedFaceIndices = React.useMemo(() => {
|
||||
if (!hasDetectableSelection || faceBlurEffect) return [];
|
||||
return faceDetections.map((_, index) => index);
|
||||
}, [faceDetections, faceBlurEffect, hasDetectableSelection]);
|
||||
|
||||
const selectedFaceIndices = faceSelection?.key === selectionKey ? faceSelection.indices : defaultSelectedFaceIndices;
|
||||
|
||||
const buildBlurRegions = React.useCallback(
|
||||
(indices: number[]) => {
|
||||
if (!selectedLayer || selectedLayer.type !== "raster") return [];
|
||||
@@ -56,14 +69,18 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
);
|
||||
|
||||
const toggleFaceIndex = React.useCallback((index: number) => {
|
||||
setSelectedFaceIndices((prev) => (prev.includes(index) ? prev.filter((item) => item !== index) : [...prev, index]));
|
||||
}, []);
|
||||
setFaceSelection((prev) => {
|
||||
const current = prev?.key === selectionKey ? prev.indices : defaultSelectedFaceIndices;
|
||||
const indices = current.includes(index) ? current.filter((item) => item !== index) : [...current, index];
|
||||
return { key: selectionKey, indices };
|
||||
});
|
||||
}, [defaultSelectedFaceIndices, selectionKey]);
|
||||
|
||||
const clearBlur = React.useCallback(() => {
|
||||
if (!selectedLayer) return;
|
||||
removeLayerEffect(selectedLayer.id, "face-blur");
|
||||
setFaceBlurPreview(null);
|
||||
}, [selectedLayer, removeLayerEffect]);
|
||||
setFaceSelection({ key: selectionKey, indices: [] });
|
||||
}, [removeLayerEffect, selectedLayer, selectionKey]);
|
||||
|
||||
const blurFaces = React.useCallback(
|
||||
(indices: number[]) => {
|
||||
@@ -78,41 +95,21 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
regions,
|
||||
censorColor,
|
||||
});
|
||||
setSelectedFaceIndices([]);
|
||||
setFaceBlurPreview(null);
|
||||
setFaceSelection({ key: selectionKey, indices: [] });
|
||||
},
|
||||
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setLayerEffect],
|
||||
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, selectionKey, setLayerEffect],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasDetectableSelection) {
|
||||
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
}
|
||||
if (faceBlurEffect) {
|
||||
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
|
||||
return;
|
||||
}
|
||||
setSelectedFaceIndices((prev) => {
|
||||
const next = faceDetections.map((_, i) => i);
|
||||
if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev;
|
||||
return next;
|
||||
});
|
||||
}, [faceDetections, hasDetectableSelection, faceBlurEffect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const faceBlurPreview = React.useMemo<FaceBlurPreview | null>(() => {
|
||||
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedFaceIndices.length === 0) {
|
||||
setFaceBlurPreview(null);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
setFaceBlurPreview({
|
||||
return {
|
||||
layerId: selectedLayer.id,
|
||||
effects: [{
|
||||
kind: "face-blur",
|
||||
@@ -122,7 +119,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
|
||||
regions: buildBlurRegions(selectedFaceIndices),
|
||||
censorColor,
|
||||
}],
|
||||
});
|
||||
};
|
||||
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -32,7 +32,6 @@ type SelectedImageLayer = {
|
||||
|
||||
type UseFaceDetectionOptions = {
|
||||
tool: EditorToolId;
|
||||
selectedLayerId: string | null;
|
||||
selectedImageLayer: SelectedImageLayer | null;
|
||||
activeLayerStillSelected: (layerId: string) => boolean;
|
||||
};
|
||||
@@ -40,7 +39,6 @@ type UseFaceDetectionOptions = {
|
||||
export function useFaceDetection(options: UseFaceDetectionOptions) {
|
||||
const {
|
||||
tool,
|
||||
selectedLayerId,
|
||||
selectedImageLayer,
|
||||
activeLayerStillSelected,
|
||||
} = options;
|
||||
@@ -63,6 +61,7 @@ export function useFaceDetection(options: UseFaceDetectionOptions) {
|
||||
(status: "idle" | "detecting" | "unsupported" = "idle") => {
|
||||
setFaceDetections((prev) => (prev.length === 0 ? prev : []));
|
||||
setFaceDetectionsLayerId((prev) => (prev === null ? prev : null));
|
||||
setFacePreviews((prev) => (prev.length === 0 ? prev : []));
|
||||
setFaceStatus((prev) => (prev === status ? prev : status));
|
||||
},
|
||||
[],
|
||||
@@ -115,18 +114,6 @@ export function useFaceDetection(options: UseFaceDetectionOptions) {
|
||||
tool,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tool !== "face") {
|
||||
setFaceDetections([]);
|
||||
setFaceDetectionsLayerId(null);
|
||||
setFacePreviews([]);
|
||||
return;
|
||||
}
|
||||
setFaceDetections([]);
|
||||
setFaceDetectionsLayerId(null);
|
||||
setFacePreviews([]);
|
||||
}, [tool, selectedLayerId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBrushDabs, clampBrushOptions, hexToRgb } from "./brush-painter";
|
||||
|
||||
describe("brush painter pure helpers", () => {
|
||||
it("parses valid hex colors and falls back to black", () => {
|
||||
expect(hexToRgb("#ff8040")).toEqual({ r: 255, g: 128, b: 64 });
|
||||
expect(hexToRgb("00aaee")).toEqual({ r: 0, g: 170, b: 238 });
|
||||
expect(hexToRgb("nope")).toEqual({ r: 0, g: 0, b: 0 });
|
||||
});
|
||||
|
||||
it("clamps invalid brush options", () => {
|
||||
expect(clampBrushOptions({ color: "#fff", size: 0, opacity: 2, hardness: -1 })).toEqual({
|
||||
color: "#fff",
|
||||
size: 1,
|
||||
opacity: 1,
|
||||
hardness: 0,
|
||||
});
|
||||
expect(clampBrushOptions({ color: "#000", size: Number.NaN, opacity: Number.NaN, hardness: Number.NaN })).toEqual({
|
||||
color: "#000",
|
||||
size: 1,
|
||||
opacity: 1,
|
||||
hardness: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("interpolates brush dabs including endpoints", () => {
|
||||
expect(buildBrushDabs(0, 0, 4, 0, 4)).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
{ x: 4, y: 0 },
|
||||
]);
|
||||
expect(buildBrushDabs(2, 3, 2, 3, 10)).toEqual([{ x: 2, y: 3 }, { x: 2, y: 3 }]);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,14 @@ export type BrushStroke = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const clean = hex.replace("#", "");
|
||||
export type BrushDab = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const clean = hex.replace("#", "").trim();
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return { r: 0, g: 0, b: 0 };
|
||||
return {
|
||||
r: parseInt(clean.slice(0, 2), 16),
|
||||
g: parseInt(clean.slice(2, 4), 16),
|
||||
@@ -21,20 +27,45 @@ function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
};
|
||||
}
|
||||
|
||||
export function clampBrushOptions(options: BrushOptions): BrushOptions {
|
||||
return {
|
||||
color: options.color,
|
||||
size: Math.max(1, Number.isFinite(options.size) ? options.size : 1),
|
||||
opacity: Math.max(0, Math.min(1, Number.isFinite(options.opacity) ? options.opacity : 1)),
|
||||
hardness: Math.max(0, Math.min(1, Number.isFinite(options.hardness) ? options.hardness : 1)),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBrushDabs(x0: number, y0: number, x1: number, y1: number, size: number): BrushDab[] {
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const step = Math.max(1, size * 0.25);
|
||||
const steps = Math.max(1, Math.ceil(dist / step));
|
||||
const dabs: BrushDab[] = [];
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
dabs.push({ x: x0 + dx * t, y: y0 + dy * t });
|
||||
}
|
||||
|
||||
return dabs;
|
||||
}
|
||||
|
||||
function drawDab(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
options: BrushOptions,
|
||||
) {
|
||||
const r = options.size / 2;
|
||||
const { r: cr, g: cg, b: cb } = hexToRgb(options.color);
|
||||
const normalized = clampBrushOptions(options);
|
||||
const r = normalized.size / 2;
|
||||
const { r: cr, g: cg, b: cb } = hexToRgb(normalized.color);
|
||||
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
const innerStop = Math.max(0, Math.min(1, options.hardness));
|
||||
|
||||
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${options.opacity})`);
|
||||
gradient.addColorStop(innerStop, `rgba(${cr},${cg},${cb},${options.opacity})`);
|
||||
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
|
||||
gradient.addColorStop(normalized.hardness, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
|
||||
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
@@ -62,15 +93,9 @@ export function paintSegment(
|
||||
y1: number,
|
||||
options: BrushOptions,
|
||||
) {
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const step = Math.max(1, options.size * 0.25);
|
||||
const steps = Math.max(1, Math.ceil(dist / step));
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = steps === 0 ? 0 : i / steps;
|
||||
drawDab(stroke.ctx, x0 + dx * t, y0 + dy * t, options);
|
||||
const normalized = clampBrushOptions(options);
|
||||
for (const dab of buildBrushDabs(x0, y0, x1, y1, normalized.size)) {
|
||||
drawDab(stroke.ctx, dab.x, dab.y, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,29 +14,3 @@ export type ToolActionController = {
|
||||
};
|
||||
|
||||
export type EditorToolController = ToolModeController | ToolActionController;
|
||||
|
||||
type CreateEditorToolControllersOptions = {
|
||||
onAddTextLayer: () => void;
|
||||
onImportImage: () => void;
|
||||
labels: {
|
||||
pointer: string;
|
||||
pan: string;
|
||||
face: string;
|
||||
fill: string;
|
||||
brush: string;
|
||||
text: string;
|
||||
image: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function createEditorToolControllers(options: CreateEditorToolControllersOptions): EditorToolController[] {
|
||||
return [
|
||||
{ kind: "mode", id: "pointer", label: options.labels.pointer },
|
||||
{ kind: "mode", id: "hand", label: options.labels.pan },
|
||||
{ kind: "mode", id: "face", label: options.labels.face },
|
||||
{ kind: "mode", id: "fill", label: options.labels.fill },
|
||||
{ kind: "mode", id: "brush", label: options.labels.brush },
|
||||
{ kind: "action", id: "add-text", label: options.labels.text, run: options.onAddTextLayer },
|
||||
{ kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
|
||||
ctx.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, 0, targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
export const faceBlurRenderer: EffectRenderer<FaceBlurEffect> = {
|
||||
export const faceBlurRenderer: EffectRenderer = {
|
||||
kind: "face-blur",
|
||||
render: ({ ctx, image, targetWidth, targetHeight }, effect) => {
|
||||
renderRegions(ctx, image, effect, targetWidth, targetHeight);
|
||||
|
||||
@@ -35,12 +35,12 @@ export function renderLayerWithEffects(
|
||||
// The first effect owns the full layer render (draws base image + applies itself)
|
||||
const first = activeEffects[0];
|
||||
const firstRenderer = effectRendererRegistry.get(first.kind);
|
||||
firstRenderer?.renderLayer(context, first as never);
|
||||
firstRenderer?.renderLayer(context, first);
|
||||
|
||||
// Subsequent effects render on top (overlay only, no re-draw of base)
|
||||
for (let i = 1; i < activeEffects.length; i++) {
|
||||
const effect = activeEffects[i];
|
||||
const renderer = effectRendererRegistry.get(effect.kind);
|
||||
renderer?.render(context, effect as never);
|
||||
renderer?.render(context, effect);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ export type EffectRenderContext = {
|
||||
targetHeight: number;
|
||||
};
|
||||
|
||||
export type EffectRenderer<T extends LayerEffect = LayerEffect> = {
|
||||
kind: T["kind"];
|
||||
export type EffectRenderer = {
|
||||
kind: LayerEffect["kind"];
|
||||
/** Renders the effect onto the canvas. Called after the base image is drawn. */
|
||||
render: (context: EffectRenderContext, effect: T) => void;
|
||||
render: (context: EffectRenderContext, effect: LayerEffect) => void;
|
||||
/** Renders the full layer (image + effect). Called instead of a plain drawImage. */
|
||||
renderLayer: (context: EffectRenderContext, effect: T) => void;
|
||||
renderLayer: (context: EffectRenderContext, effect: LayerEffect) => void;
|
||||
};
|
||||
|
||||
@@ -47,6 +47,13 @@ describe("hasProjectChanged", () => {
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects visibility changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
b.layers[0].visible = false;
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats missing optional fields and undefined as equal", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
|
||||
@@ -27,7 +27,8 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
|
||||
a.height !== b.height ||
|
||||
a.scale !== b.scale ||
|
||||
a.rotation !== b.rotation ||
|
||||
a.opacity !== b.opacity
|
||||
a.opacity !== b.opacity ||
|
||||
a.visible !== b.visible
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "next dev --webpack -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useEditorStore } from "./editor-store";
|
||||
import { createProject } from "@pien-studio/editor-core";
|
||||
|
||||
describe("editor store", () => {
|
||||
it("saves and loads project from indexeddb", async () => {
|
||||
@@ -74,4 +75,79 @@ describe("editor store", () => {
|
||||
expect(after[0].id).toBe(second.id);
|
||||
expect(after[1].id).toBe(first.id);
|
||||
});
|
||||
|
||||
it("applies effects, toggles visibility, and tracks undo", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("raster");
|
||||
const layerId = useEditorStore.getState().project.layers[0]?.id;
|
||||
expect(layerId).toBeDefined();
|
||||
|
||||
useEditorStore.getState().setLayerEffect(layerId!, {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 99,
|
||||
regions: [{ x: Number.NaN, y: 1, width: 0, height: 2 }],
|
||||
});
|
||||
useEditorStore.getState().setEffectEnabled(layerId!, "face-blur", false);
|
||||
useEditorStore.getState().setLayerVisible(layerId!, false);
|
||||
|
||||
const layer = useEditorStore.getState().project.layers[0];
|
||||
expect(layer?.visible).toBe(false);
|
||||
expect(layer?.effects[0]).toMatchObject({ amount: 40, enabled: false, regions: [{ x: 0, y: 1, width: 1, height: 2 }] });
|
||||
expect(useEditorStore.getState().canUndo).toBe(true);
|
||||
|
||||
useEditorStore.getState().undo();
|
||||
expect(useEditorStore.getState().project.layers[0]?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("commits and cancels draft transactions", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
const initial = useEditorStore.getState().project.layers[0];
|
||||
expect(initial).toBeDefined();
|
||||
|
||||
useEditorStore.getState().startTransaction();
|
||||
useEditorStore.getState().setSelectedLayerPositionDraft(300, 301);
|
||||
useEditorStore.getState().cancelTransaction();
|
||||
expect(useEditorStore.getState().project.layers[0]?.x).toBe(initial?.x);
|
||||
|
||||
useEditorStore.getState().startTransaction();
|
||||
useEditorStore.getState().setSelectedLayerPositionDraft(310, 311);
|
||||
useEditorStore.getState().commitTransaction();
|
||||
expect(useEditorStore.getState().project.layers[0]?.x).toBe(310);
|
||||
expect(useEditorStore.getState().canUndo).toBe(true);
|
||||
});
|
||||
|
||||
it("sets canvas size and supports redo and timeline jumps", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
useEditorStore.getState().setCanvasSize(222.4, 333.6);
|
||||
useEditorStore.getState().addLayerByType("text");
|
||||
expect(useEditorStore.getState().project.canvas).toMatchObject({ width: 222, height: 334 });
|
||||
|
||||
useEditorStore.getState().undo();
|
||||
expect(useEditorStore.getState().project.layers).toHaveLength(0);
|
||||
expect(useEditorStore.getState().canRedo).toBe(true);
|
||||
|
||||
useEditorStore.getState().redo();
|
||||
expect(useEditorStore.getState().project.layers).toHaveLength(1);
|
||||
|
||||
useEditorStore.getState().setSelectedLayerRotation(45);
|
||||
useEditorStore.getState().jumpToPast(1);
|
||||
expect(useEditorStore.getState().project.layers).toHaveLength(0);
|
||||
useEditorStore.getState().jumpToFuture(2);
|
||||
expect(useEditorStore.getState().project.layers[0]?.rotation).toBe(45);
|
||||
});
|
||||
|
||||
it("rejects invalid project json and replaces projects", () => {
|
||||
useEditorStore.getState().resetProject();
|
||||
const originalId = useEditorStore.getState().project.id;
|
||||
expect(useEditorStore.getState().importProjectFromJson("nope")).toEqual({ ok: false, error: "Invalid JSON" });
|
||||
expect(useEditorStore.getState().project.id).toBe(originalId);
|
||||
|
||||
const project = createProject("replacement");
|
||||
useEditorStore.getState().setProject(project);
|
||||
expect(useEditorStore.getState().project.title).toBe("replacement");
|
||||
expect(useEditorStore.getState().isDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user