♻️ refactor!: massive refactor

This commit is contained in:
2026-05-31 03:04:30 +07:00
parent c1f12c7201
commit df3c944547
86 changed files with 3691 additions and 1129 deletions
@@ -10,8 +10,12 @@ function normalizeFaceBlur(effect: FaceBlurEffect): FaceBlurEffect {
y: Number.isFinite(region.y) ? region.y : 0,
width: Math.max(1, Number.isFinite(region.width) ? region.width : 1),
height: Math.max(1, Number.isFinite(region.height) ? region.height : 1),
sourceWidth: Number.isFinite(region.sourceWidth ?? NaN) ? region.sourceWidth : undefined,
sourceHeight: Number.isFinite(region.sourceHeight ?? NaN) ? region.sourceHeight : undefined,
sourceWidth: Number.isFinite(region.sourceWidth ?? NaN)
? region.sourceWidth
: undefined,
sourceHeight: Number.isFinite(region.sourceHeight ?? NaN)
? region.sourceHeight
: undefined,
censorColor: region.censorColor,
})),
};
+3 -1
View File
@@ -12,7 +12,9 @@ const effectRegistry = new Map<string, EffectDefinition>(
definitions.map((def) => [def.kind, def]),
);
export function getEffectDefinition(kind: string): EffectDefinition | undefined {
export function getEffectDefinition(
kind: string,
): EffectDefinition | undefined {
return effectRegistry.get(kind);
}
+49
View File
@@ -0,0 +1,49 @@
import type { Layer, Project } from "@pien-studio/types";
export const HISTORY_LIMIT = 120;
export type HistoryState = {
past: Project[];
present: Project;
future: Project[];
};
export function deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === "function") {
return globalThis.structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
export function cloneProject(project: Project): Project {
return deepClone(project);
}
export function cloneLayer(layer: Layer): Layer {
return deepClone(layer);
}
export function makeHistory(project: Project): HistoryState {
return { past: [], present: cloneProject(project), future: [] };
}
export function computeHistoryFlags(history: HistoryState) {
return {
canUndo: history.past.length > 0,
canRedo: history.future.length > 0,
};
}
export function capHistory(items: Project[]): Project[] {
if (items.length <= HISTORY_LIMIT) return items;
return items.slice(items.length - HISTORY_LIMIT);
}
export function resolveSelectedLayerId(
project: Project,
preferred: string | null,
): string | null {
if (preferred && project.layers.some((layer) => layer.id === preferred))
return preferred;
return project.layers[0]?.id ?? null;
}
+67 -30
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
addLayer,
createLayer,
createProject,
moveLayer,
removeLayer,
@@ -14,20 +15,10 @@ import {
setCanvasSize,
updateLayerTransform,
} from "./index";
import type { Layer } from "@pien-studio/types";
import type { Layer, LayerType } from "@pien-studio/types";
function makeLayer(id: string, type: Layer["type"] = "raster"): Layer {
return {
id,
type,
x: 0,
y: 0,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
};
function makeLayer(id: string, type: LayerType = "raster"): Layer {
return createLayer(type, { id, x: 0, y: 0 });
}
describe("editor-core", () => {
@@ -48,7 +39,11 @@ describe("editor-core", () => {
});
it("moves a layer by delta", () => {
const project = addLayer(createProject("move"), { ...makeLayer("l1", "sticker"), x: 10, y: 20 });
const project = addLayer(createProject("move"), {
...makeLayer("l1", "sticker"),
x: 10,
y: 20,
});
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
expect(moved.layers[0]?.x).toBe(25);
@@ -56,9 +51,15 @@ describe("editor-core", () => {
});
it("updates transform fields", () => {
const project = addLayer(createProject("transform"), makeLayer("l1", "text"));
const project = addLayer(
createProject("transform"),
makeLayer("l1", "text"),
);
const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 });
const updated = updateLayerTransform(project, "l1", {
scale: 1.35,
rotation: 22,
});
expect(updated.layers[0]?.scale).toBe(1.35);
expect(updated.layers[0]?.rotation).toBe(22);
});
@@ -96,18 +97,32 @@ describe("editor-core", () => {
enabled: true,
method: "gaussian",
amount: 40,
regions: [{ x: 0, y: 3, width: 1, height: 1, sourceWidth: undefined, sourceHeight: undefined, censorColor: undefined }],
regions: [
{
x: 0,
y: 3,
width: 1,
height: 1,
sourceWidth: undefined,
sourceHeight: undefined,
censorColor: undefined,
},
],
});
});
it("does not create changes for unchanged layer visibility or effect enabled state", () => {
const project = setLayerEffect(addLayer(createProject("visibility"), makeLayer("l1")), "l1", {
kind: "face-blur",
enabled: true,
method: "pixelate",
amount: 12,
regions: [],
});
const project = setLayerEffect(
addLayer(createProject("visibility"), makeLayer("l1")),
"l1",
{
kind: "face-blur",
enabled: true,
method: "pixelate",
amount: 12,
regions: [],
},
);
expect(setLayerVisible(project, "l1", true)).toBe(project);
expect(setEffectEnabled(project, "l1", "face-blur", true)).toBe(project);
@@ -122,26 +137,48 @@ describe("editor-core", () => {
rotation: Number.NaN,
opacity: 4,
});
const normalized = normalizeProject({ ...project, canvas: { width: 0.2, height: 20.6, unit: "px" } });
const normalized = normalizeProject({
...project,
canvas: { width: 0.2, height: 20.6, unit: "px" },
});
expect(normalized.canvas).toEqual({ width: 1, height: 21, unit: "px" });
expect(normalized.layers[0]).toMatchObject({ width: 10, height: 1, scale: 1, rotation: 0, opacity: 1 });
expect(normalized.layers[0]).toMatchObject({
width: 10,
height: 1,
scale: 1,
rotation: 0,
opacity: 1,
});
});
it("serializes and parses project files with normalized projects", () => {
const project = addLayer(createProject("file"), { ...makeLayer("l1"), width: 3.8 });
const project = addLayer(createProject("file"), {
...makeLayer("l1"),
width: 3.8,
});
const raw = serializeProjectFile(project, { checkpointCount: 2 });
const parsed = parseProjectFile(raw);
expect(JSON.parse(raw).history.checkpointCount).toBe(2);
expect(parsed.ok).toBe(true);
expect(parsed.ok ? parsed.project.layers[0]?.width : undefined).toBe(4);
expect(parseProjectFile("not json")).toEqual({ ok: false, error: "Invalid JSON" });
expect(parseProjectFile(JSON.stringify({ format: "wrong" }))).toEqual({ ok: false, error: "Invalid project format" });
expect(parseProjectFile("not json")).toEqual({
ok: false,
error: "Invalid JSON",
});
expect(parseProjectFile(JSON.stringify({ format: "wrong" }))).toEqual({
ok: false,
error: "Invalid project format",
});
});
it("sets canvas size with clamped rounded dimensions", () => {
const project = createProject("canvas");
expect(setCanvasSize(project, 0, 12.6, "cm").canvas).toEqual({ width: 1, height: 13, unit: "cm" });
expect(setCanvasSize(project, 0, 12.6, "cm").canvas).toEqual({
width: 1,
height: 13,
unit: "cm",
});
});
});
+72 -14
View File
@@ -1,5 +1,16 @@
export { createProject, setCanvasSize } from "./project";
export { createLayer, addLayer, removeLayer, moveLayer, updateLayerTransform, reorderLayer, setLayerEffect, removeLayerEffect, setLayerVisible, setEffectEnabled } from "./layers";
export {
createLayer,
addLayer,
removeLayer,
moveLayer,
updateLayerTransform,
reorderLayer,
setLayerEffect,
removeLayerEffect,
setLayerVisible,
setEffectEnabled,
} from "./layers";
export type { LayerFactoryOptions, TransformPatch } from "./layers";
export { normalizeProject } from "./normalize";
export { serializeProjectFile, parseProjectFile } from "./serialization";
@@ -7,30 +18,77 @@ export { getEffectDefinition, normalizeEffect } from "./effects/registry";
export type { EffectDefinition } from "./effects/registry";
export { faceBlurDefinition } from "./effects/face-blur";
export { getToolDefinition, getAllTools } from "./tools/registry";
export type { ToolDefinition, ToolInteractionMode, EditorToolId } from "./tools/registry";
export type {
ToolDefinition,
ToolInteractionMode,
EditorToolId,
} from "./tools/registry";
export {
HISTORY_LIMIT,
capHistory,
cloneLayer,
cloneProject,
computeHistoryFlags,
deepClone,
makeHistory,
resolveSelectedLayerId,
} from "./history";
export type { HistoryState } from "./history";
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
import { addLayer, moveLayer, removeLayer, reorderLayer, setLayerEffect, updateLayerTransform } from "./layers";
import {
addLayer,
moveLayer,
removeLayer,
reorderLayer,
setLayerEffect,
updateLayerTransform,
} from "./layers";
import { setCanvasSize } from "./project";
export type EditorOperation =
| { type: "addLayer"; layer: Layer }
| { type: "removeLayer"; layerId: string }
| { type: "moveLayer"; layerId: string; delta: { dx: number; dy: number } }
| { type: "updateLayerTransform"; layerId: string; patch: import("./layers").TransformPatch }
| {
type: "updateLayerTransform";
layerId: string;
patch: import("./layers").TransformPatch;
}
| { type: "reorderLayer"; layerId: string; toIndex: number }
| { type: "setLayerEffect"; layerId: string; effect: LayerEffect }
| { type: "setCanvasSize"; width: number; height: number; unit?: "px" | "in" | "cm" };
| {
type: "setCanvasSize";
width: number;
height: number;
unit?: "px" | "in" | "cm";
};
export function applyOperation(project: Project, operation: EditorOperation): Project {
export function applyOperation(
project: Project,
operation: EditorOperation,
): Project {
switch (operation.type) {
case "addLayer": return addLayer(project, operation.layer);
case "removeLayer": return removeLayer(project, operation.layerId);
case "moveLayer": return moveLayer(project, operation.layerId, operation.delta);
case "updateLayerTransform": return updateLayerTransform(project, operation.layerId, operation.patch);
case "reorderLayer": return reorderLayer(project, operation.layerId, operation.toIndex);
case "setLayerEffect": return setLayerEffect(project, operation.layerId, operation.effect);
case "setCanvasSize": return setCanvasSize(project, operation.width, operation.height, operation.unit);
default: return project;
case "addLayer":
return addLayer(project, operation.layer);
case "removeLayer":
return removeLayer(project, operation.layerId);
case "moveLayer":
return moveLayer(project, operation.layerId, operation.delta);
case "updateLayerTransform":
return updateLayerTransform(project, operation.layerId, operation.patch);
case "reorderLayer":
return reorderLayer(project, operation.layerId, operation.toIndex);
case "setLayerEffect":
return setLayerEffect(project, operation.layerId, operation.effect);
case "setCanvasSize":
return setCanvasSize(
project,
operation.width,
operation.height,
operation.unit,
);
default:
return project;
}
}
+134 -24
View File
@@ -1,14 +1,29 @@
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
import type {
AssetRef,
Layer,
LayerEffect,
LayerType,
Project,
RasterLayer,
StickerLayer,
TextLayer,
} from "@pien-studio/types";
import { normalizeEffect } from "./effects/registry";
export type LayerFactoryOptions = {
id?: string;
name?: string;
sourceUri?: string;
asset?: AssetRef | null;
runtimeSourceUri?: string;
x?: number;
y?: number;
width?: number;
height?: number;
text?: string;
fontFamily?: string;
fontSize?: number;
color?: string;
stickerId?: string;
};
export type TransformPatch = {
@@ -19,7 +34,8 @@ export type TransformPatch = {
scale?: number;
rotation?: number;
opacity?: number;
sourceUri?: string;
asset?: AssetRef | null;
runtimeSourceUri?: string;
effects?: LayerEffect[];
};
@@ -27,7 +43,11 @@ function withUpdatedAt(project: Project, layers: Layer[]): Project {
return { ...project, layers, updatedAt: new Date().toISOString() };
}
function updateLayer(project: Project, layerId: string, updater: (layer: Layer) => Layer): Project {
function updateLayer(
project: Project,
layerId: string,
updater: (layer: Layer) => Layer,
): Project {
let changed = false;
const layers = project.layers.map((layer) => {
if (layer.id !== layerId) return layer;
@@ -40,25 +60,62 @@ function updateLayer(project: Project, layerId: string, updater: (layer: Layer)
}
function hasTransformPatchChange(layer: Layer, patch: TransformPatch): boolean {
return (Object.keys(patch) as (keyof TransformPatch)[]).some((key) => layer[key] !== patch[key]);
return (Object.keys(patch) as (keyof TransformPatch)[]).some((key) => {
if (
(key === "asset" || key === "runtimeSourceUri") &&
layer.type !== "raster" &&
layer.type !== "sticker"
)
return false;
return (layer as Layer & TransformPatch)[key] !== patch[key];
});
}
export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer {
return {
export function createLayer(
type: LayerType,
options: LayerFactoryOptions = {},
): Layer {
const base = {
id: options.id ?? crypto.randomUUID(),
type,
name: options.name,
sourceUri: options.sourceUri,
effects: [],
visible: true,
x: options.x ?? 110,
y: options.y ?? 90,
width: options.width,
height: options.height,
width: options.width ?? 200,
height: options.height ?? 150,
scale: 1,
rotation: 0,
opacity: 1,
};
if (type === "text") {
return {
...base,
type,
text: options.text ?? "Text",
fontFamily: options.fontFamily ?? "system-ui",
fontSize: options.fontSize ?? 48,
color: options.color ?? "#111827",
} satisfies TextLayer;
}
if (type === "sticker") {
return {
...base,
type,
asset: options.asset ?? null,
runtimeSourceUri: options.runtimeSourceUri,
stickerId: options.stickerId,
} satisfies StickerLayer;
}
return {
...base,
type,
asset: options.asset ?? null,
runtimeSourceUri: options.runtimeSourceUri,
} satisfies RasterLayer;
}
export function addLayer(project: Project, layer: Layer): Project {
@@ -67,19 +124,47 @@ export function addLayer(project: Project, layer: Layer): Project {
export function removeLayer(project: Project, layerId: string): Project {
if (!project.layers.some((l) => l.id === layerId)) return project;
return withUpdatedAt(project, project.layers.filter((l) => l.id !== layerId));
return withUpdatedAt(
project,
project.layers.filter((l) => l.id !== layerId),
);
}
export function moveLayer(project: Project, layerId: string, delta: { dx: number; dy: number }): Project {
export function moveLayer(
project: Project,
layerId: string,
delta: { dx: number; dy: number },
): Project {
if (delta.dx === 0 && delta.dy === 0) return project;
return updateLayer(project, layerId, (l) => ({ ...l, x: l.x + delta.dx, y: l.y + delta.dy }));
return updateLayer(project, layerId, (l) => ({
...l,
x: l.x + delta.dx,
y: l.y + delta.dy,
}));
}
export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project {
return updateLayer(project, layerId, (l) => (hasTransformPatchChange(l, patch) ? { ...l, ...patch } : l));
export function updateLayerTransform(
project: Project,
layerId: string,
patch: TransformPatch,
): Project {
return updateLayer(project, layerId, (l) => {
if (!hasTransformPatchChange(l, patch)) return l;
if (l.type === "text") {
const { asset, runtimeSourceUri, ...textPatch } = patch;
void asset;
void runtimeSourceUri;
return { ...l, ...textPatch };
}
return { ...l, ...patch };
});
}
export function reorderLayer(project: Project, layerId: string, toIndex: number): Project {
export function reorderLayer(
project: Project,
layerId: string,
toIndex: number,
): Project {
const fromIndex = project.layers.findIndex((l) => l.id === layerId);
if (fromIndex < 0) return project;
const layers = [...project.layers];
@@ -91,31 +176,56 @@ export function reorderLayer(project: Project, layerId: string, toIndex: number)
return withUpdatedAt(project, layers);
}
export function setLayerEffect(project: Project, layerId: string, effect: LayerEffect): Project {
export function setLayerEffect(
project: Project,
layerId: string,
effect: LayerEffect,
): Project {
const normalizedEffect = normalizeEffect(effect);
return updateLayer(project, layerId, (l) => {
const idx = l.effects.findIndex((e) => e.kind === normalizedEffect.kind);
const effects = idx >= 0 ? l.effects.map((e, i) => (i === idx ? normalizedEffect : e)) : [...l.effects, normalizedEffect];
const effects =
idx >= 0
? l.effects.map((e, i) => (i === idx ? normalizedEffect : e))
: [...l.effects, normalizedEffect];
if (JSON.stringify(effects) === JSON.stringify(l.effects)) return l;
return { ...l, effects };
});
}
export function removeLayerEffect(project: Project, layerId: string, kind: LayerEffect["kind"]): Project {
export function removeLayerEffect(
project: Project,
layerId: string,
kind: LayerEffect["kind"],
): Project {
return updateLayer(project, layerId, (l) => {
if (!l.effects.some((e) => e.kind === kind)) return l;
return { ...l, effects: l.effects.filter((e) => e.kind !== kind) };
});
}
export function setLayerVisible(project: Project, layerId: string, visible: boolean): Project {
return updateLayer(project, layerId, (l) => (l.visible === visible ? l : { ...l, visible }));
export function setLayerVisible(
project: Project,
layerId: string,
visible: boolean,
): Project {
return updateLayer(project, layerId, (l) =>
l.visible === visible ? l : { ...l, visible },
);
}
export function setEffectEnabled(project: Project, layerId: string, kind: LayerEffect["kind"], enabled: boolean): Project {
export function setEffectEnabled(
project: Project,
layerId: string,
kind: LayerEffect["kind"],
enabled: boolean,
): Project {
return updateLayer(project, layerId, (l) => {
const effect = l.effects.find((e) => e.kind === kind);
if (!effect || effect.enabled === enabled) return l;
return { ...l, effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)) };
return {
...l,
effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)),
};
});
}
+1 -22
View File
@@ -1,22 +1 @@
import type { Project } from "@pien-studio/types";
import { normalizeEffect } from "./effects/registry";
export function normalizeProject(project: Project): Project {
return {
...project,
canvas: {
width: Math.max(1, Math.round(project.canvas.width)),
height: Math.max(1, Math.round(project.canvas.height)),
unit: project.canvas.unit,
},
layers: project.layers.map((layer) => ({
...layer,
width: layer.width !== undefined ? Math.max(1, Math.round(layer.width)) : undefined,
height: layer.height !== undefined ? Math.max(1, Math.round(layer.height)) : undefined,
scale: Number.isFinite(layer.scale) ? layer.scale : 1,
rotation: Number.isFinite(layer.rotation) ? layer.rotation : 0,
opacity: Number.isFinite(layer.opacity) ? Math.max(0, Math.min(1, layer.opacity)) : 1,
effects: Array.isArray(layer.effects) ? layer.effects.map(normalizeEffect) : [],
})),
};
}
export { normalizeProject } from "@pien-studio/types";
+12 -3
View File
@@ -1,13 +1,22 @@
import { PRESET_CANVAS_SIZES, type AspectRatio, type Project } from "@pien-studio/types";
import {
PRESET_CANVAS_SIZES,
type AspectRatio,
type Project,
} from "@pien-studio/types";
const DEFAULT_ASPECT: AspectRatio = "4:5";
function defaultCanvas(aspect: AspectRatio) {
const preset = PRESET_CANVAS_SIZES.find((p) => p.value === aspect) ?? PRESET_CANVAS_SIZES[1];
const preset =
PRESET_CANVAS_SIZES.find((p) => p.value === aspect) ??
PRESET_CANVAS_SIZES[1];
return { width: preset.width, height: preset.height, unit: "px" as const };
}
export function createProject(title: string, aspect: AspectRatio = DEFAULT_ASPECT): Project {
export function createProject(
title: string,
aspect: AspectRatio = DEFAULT_ASPECT,
): Project {
const now = new Date().toISOString();
return {
id: crypto.randomUUID(),
+46 -7
View File
@@ -1,24 +1,63 @@
import { ProjectFileSchema, type Project, type ProjectFile } from "@pien-studio/types";
import {
getLayerAssetRef,
ProjectFileSchema,
type Project,
type ProjectFile,
} from "@pien-studio/types";
import { normalizeProject } from "./normalize";
export function serializeProjectFile(project: Project, options?: { checkpointCount?: number }): string {
export function serializeProjectFile(
project: Project,
options?: { checkpointCount?: number },
): string {
const normalized = normalizeProject(project);
const document: ProjectFile = {
format: "pien.project",
version: 1,
version: 2,
exportedAt: new Date().toISOString(),
app: { name: "pien.studio", platform: "web" },
project,
assets: [],
project: normalized,
assets: normalized.layers.flatMap((layer) => {
const asset = getLayerAssetRef(layer);
if (!asset) return [];
if (asset.kind === "stored") {
return [
{
id: asset.id,
kind:
layer.type === "sticker"
? ("sticker" as const)
: ("image" as const),
name: layer.name ?? layer.id,
uri: `asset:${asset.id}`,
},
];
}
return [
{
id: layer.id,
kind:
layer.type === "sticker"
? ("sticker" as const)
: ("image" as const),
name: layer.name ?? layer.id,
uri: asset.uri,
},
];
}),
history: { checkpointCount: options?.checkpointCount ?? 0 },
};
return JSON.stringify(document, null, 2);
}
export function parseProjectFile(raw: string): { ok: true; project: Project } | { ok: false; error: string } {
export function parseProjectFile(
raw: string,
): { ok: true; project: Project } | { ok: false; error: string } {
try {
const data = JSON.parse(raw) as unknown;
const result = ProjectFileSchema.safeParse(data);
if (result.success) return { ok: true, project: normalizeProject(result.data.project) };
if (result.success)
return { ok: true, project: normalizeProject(result.data.project) };
return { ok: false, error: "Invalid project format" };
} catch {
return { ok: false, error: "Invalid JSON" };
+1 -5
View File
@@ -1,8 +1,4 @@
export type ToolInteractionMode =
| "select" // can select and drag layers
| "pan" // pans the viewport, no layer interaction
| "paint" // pixel-level tool, fires onLayerClick with canvas coords
| "annotate"; // select-only, no drag (eg. face-blur region picking)
export type ToolInteractionMode = "select" | "pan" | "paint" | "annotate";
export type ToolDefinition = {
id: string;