feat: initial app

This commit is contained in:
2026-05-10 03:06:54 +07:00
parent 23be44dbe0
commit d5b92cc9a4
95 changed files with 8104 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
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;
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { useEditorStore } from "./editor-store";
describe("editor store", () => {
it("saves and loads project from indexeddb", async () => {
useEditorStore.getState().resetProject();
useEditorStore.getState().addLayerByType("sticker");
const savedId = useEditorStore.getState().project.id;
await useEditorStore.getState().saveCurrentProject();
useEditorStore.getState().resetProject();
expect(useEditorStore.getState().project.id).not.toBe(savedId);
await useEditorStore.getState().loadProjectById(savedId);
expect(useEditorStore.getState().project.id).toBe(savedId);
});
it("imports and exports project json", () => {
useEditorStore.getState().resetProject();
useEditorStore.getState().addLayerByType("image");
const json = useEditorStore.getState().exportProjectToJson();
useEditorStore.getState().resetProject();
const result = useEditorStore.getState().importProjectFromJson(json);
expect(result.ok).toBe(true);
expect(useEditorStore.getState().project.layers.length).toBe(1);
});
it("adds layer by type and removes selected layer", () => {
useEditorStore.getState().resetProject();
useEditorStore.getState().addLayerByType("text");
expect(useEditorStore.getState().project.layers[0]?.type).toBe("text");
useEditorStore.getState().removeSelectedLayer();
expect(useEditorStore.getState().project.layers).toHaveLength(0);
});
it("updates selected layer position directly", () => {
useEditorStore.getState().resetProject();
useEditorStore.getState().addLayerByType("text");
useEditorStore.getState().setSelectedLayerPosition(220, 140);
const layer = useEditorStore.getState().project.layers[0];
expect(layer?.x).toBe(220);
expect(layer?.y).toBe(140);
});
it("records pointer draft transforms in undo history on commit", () => {
useEditorStore.getState().resetProject();
useEditorStore.getState().addLayerByType("image");
const initial = useEditorStore.getState().project.layers[0];
expect(initial).toBeDefined();
useEditorStore.getState().setSelectedLayerPositionDraft(180, 150);
useEditorStore.getState().setSelectedLayerPosition(180, 150);
expect(useEditorStore.getState().canUndo).toBe(true);
useEditorStore.getState().undo();
const movedBack = useEditorStore.getState().project.layers[0];
expect(movedBack?.x).toBe(initial?.x);
expect(movedBack?.y).toBe(initial?.y);
});
it("reorders layers up and down", () => {
useEditorStore.getState().resetProject();
useEditorStore.getState().addLayerByType("text");
useEditorStore.getState().addLayerByType("image");
const layers = useEditorStore.getState().project.layers;
const [first, second] = layers;
expect(first.id).not.toBe(second.id);
useEditorStore.getState().selectLayer(first.id);
useEditorStore.getState().moveSelectedLayerOrder("up");
const after = useEditorStore.getState().project.layers;
expect(after[0].id).toBe(second.id);
expect(after[1].id).toBe(first.id);
});
});
+470
View File
@@ -0,0 +1,470 @@
"use client";
import { create } from "zustand";
import {
addLayer,
createLayer,
createProject,
parseProjectFile,
removeLayer,
reorderLayer,
serializeProjectFile,
setCanvasSize as applyCanvasSize,
updateLayerTransform,
normalizeProject,
} from "@pien-studio/editor-core";
import type { FaceBlurSettings, Layer, Project } from "@pien-studio/types";
import { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage";
import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants";
import { hasProjectChanged } from "../lib/project-equality";
import {
capHistory,
cloneLayer,
cloneProject,
computeHistoryFlags,
makeHistory,
resolveSelectedLayerId,
type HistoryState,
} from "./editor-store-helpers";
const DRAFT_TRANSFORM_EPSILON = 0.01;
export const EDITOR_TOOLS = {
pointer: { id: "pointer", allowsSelection: true, allowsLayerEditing: true },
hand: { id: "hand", allowsSelection: false, allowsLayerEditing: false },
face: { id: "face", allowsSelection: true, allowsLayerEditing: false },
} as const;
export type EditorToolId = keyof typeof EDITOR_TOOLS;
type TransactionState = {
baselineProject: Project;
baselineSelectedLayerId: string | null;
};
type EditorState = {
project: Project;
selectedLayerId: string | null;
tool: EditorToolId;
history: HistoryState;
canUndo: boolean;
canRedo: boolean;
clipboardLayer: Layer | null;
isDirty: boolean;
transaction: TransactionState | null;
tools: typeof EDITOR_TOOLS;
startTransaction: () => void;
commitTransaction: () => void;
cancelTransaction: () => void;
applyProjectDraft: (project: Project) => void;
setTool: (tool: EditorToolId) => void;
addLayerByType: (type: Layer["type"]) => void;
setSelectedLayerPosition: (x: number, y: number) => void;
setSelectedLayerPositionDraft: (x: number, y: number) => void;
setSelectedLayerSize: (width: number, height: number) => void;
setSelectedLayerSizeDraft: (width: number, height: number) => void;
setSelectedLayerRotation: (rotation: number) => void;
setSelectedLayerRotationDraft: (rotation: number) => void;
removeSelectedLayer: () => void;
moveSelectedLayerOrder: (direction: "up" | "down") => void;
selectLayer: (layerId: string | null) => void;
copySelectedLayer: () => void;
cutSelectedLayer: () => void;
pasteLayer: () => void;
resetProject: () => void;
saveCurrentProject: () => Promise<void>;
loadProjectById: (projectId: string) => Promise<boolean>;
setProject: (project: Project) => void;
importProjectFromJson: (raw: string) => { ok: boolean; error?: string };
importImageFromFile: (file: File) => Promise<void>;
updateImageLayerSource: (layerId: string, sourceUri: string) => void;
setImageLayerFaceBlur: (layerId: string, faceBlur: FaceBlurSettings | undefined) => void;
setCanvasSize: (width: number, height: number) => void;
exportProjectToJson: () => string;
undo: () => void;
redo: () => void;
jumpToPast: (idx: number) => void;
jumpToFuture: (idx: number) => void;
};
const initialProject = createProject("Untitled Project");
function withCommittedProject(state: EditorState, nextProject: Project, extras?: Partial<EditorState>) {
const past = capHistory([...state.history.past, state.history.present]);
const history = { past, present: cloneProject(nextProject), future: [] };
return {
project: nextProject,
history,
selectedLayerId: resolveSelectedLayerId(nextProject, state.selectedLayerId),
transaction: null,
...computeHistoryFlags(history),
isDirty: true,
...extras,
} satisfies Partial<EditorState>;
}
function makeStableProjectState(previousSelectedLayerId: string | null, project: Project) {
const history = makeHistory(project);
return {
project,
selectedLayerId: resolveSelectedLayerId(project, previousSelectedLayerId),
history,
...computeHistoryFlags(history),
clipboardLayer: null,
transaction: null,
isDirty: false,
};
}
function getProjectAssetIds(project: Project): string[] {
return project.layers.map((layer) => layer.assetId).filter((assetId): assetId is string => Boolean(assetId));
}
export const useEditorStore = create<EditorState>((set, get) => ({
project: initialProject,
selectedLayerId: null,
tool: "pointer",
history: makeHistory(initialProject),
...computeHistoryFlags(makeHistory(initialProject)),
clipboardLayer: null,
isDirty: false,
transaction: null,
tools: EDITOR_TOOLS,
startTransaction: () =>
set((state) => {
if (state.transaction) return state;
return {
transaction: {
baselineProject: cloneProject(state.project),
baselineSelectedLayerId: state.selectedLayerId,
},
};
}),
commitTransaction: () =>
set((state) => {
if (!state.transaction) return state;
if (!hasProjectChanged(state.transaction.baselineProject, state.project)) {
return { transaction: null };
}
const past = capHistory([...state.history.past, cloneProject(state.transaction.baselineProject)]);
const history = { past, present: cloneProject(state.project), future: [] };
return {
history,
transaction: null,
...computeHistoryFlags(history),
isDirty: true,
};
}),
cancelTransaction: () =>
set((state) => {
if (!state.transaction) return state;
return {
project: cloneProject(state.transaction.baselineProject),
selectedLayerId: resolveSelectedLayerId(state.transaction.baselineProject, state.transaction.baselineSelectedLayerId),
transaction: null,
};
}),
applyProjectDraft: (project) => set(() => ({ project })),
setTool: (tool) => {
if (!(tool in EDITOR_TOOLS)) return;
set({ tool });
},
addLayerByType: (type) =>
set((state) => {
const layer = createLayer(type);
const nextProject = addLayer(state.project, layer);
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
}),
setSelectedLayerPosition: (x, y) =>
set((state) => {
if (!state.selectedLayerId) return state;
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
if (committed && committed.x === x && committed.y === y) return state;
const nextProject = updateLayerTransform(state.project, state.selectedLayerId, { x, y });
return withCommittedProject(state, nextProject);
}),
setSelectedLayerPositionDraft: (x, y) =>
set((state) => {
if (!state.selectedLayerId) return state;
if (!Number.isFinite(x) || !Number.isFinite(y)) return state;
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
if (current && current.x === x && current.y === y) return state;
return { project: updateLayerTransform(state.project, state.selectedLayerId, { x, y }) };
}),
setSelectedLayerSize: (width, height) =>
set((state) => {
if (!state.selectedLayerId) return state;
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
if (committed && committed.width === width && committed.height === height) return state;
return withCommittedProject(state, updateLayerTransform(state.project, state.selectedLayerId, { width, height }));
}),
setSelectedLayerSizeDraft: (width, height) =>
set((state) => {
if (!state.selectedLayerId) return state;
if (!Number.isFinite(width) || !Number.isFinite(height)) return state;
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
const nextWidth = Math.max(MIN_LAYER_SIZE, width);
const nextHeight = Math.max(MIN_LAYER_SIZE, height);
if (current) {
const currentWidth = current.width ?? (current.type === "image" ? Math.round(200 * current.scale) : undefined);
const currentHeight = current.height ?? (current.type === "image" ? Math.round(150 * current.scale) : undefined);
if (
typeof currentWidth === "number" &&
typeof currentHeight === "number" &&
Math.abs(currentWidth - nextWidth) < DRAFT_TRANSFORM_EPSILON &&
Math.abs(currentHeight - nextHeight) < DRAFT_TRANSFORM_EPSILON
) {
return state;
}
}
return { project: updateLayerTransform(state.project, state.selectedLayerId, { width: nextWidth, height: nextHeight }) };
}),
removeSelectedLayer: () =>
set((state) => {
if (!state.selectedLayerId) return state;
const nextProject = removeLayer(state.project, state.selectedLayerId);
return withCommittedProject(state, nextProject);
}),
moveSelectedLayerOrder: (direction) =>
set((state) => {
if (!state.selectedLayerId) return state;
const idx = state.project.layers.findIndex((layer) => layer.id === state.selectedLayerId);
if (idx < 0) return state;
const nextIndex = direction === "up" ? idx + 1 : idx - 1;
return withCommittedProject(state, reorderLayer(state.project, state.selectedLayerId, nextIndex));
}),
selectLayer: (layerId) => set((state) => ({ selectedLayerId: resolveSelectedLayerId(state.project, layerId) })),
setSelectedLayerRotation: (rotation) =>
set((state) => {
if (!state.selectedLayerId) return state;
const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId);
if (committed && committed.rotation === rotation) return state;
return withCommittedProject(state, updateLayerTransform(state.project, state.selectedLayerId, { rotation }));
}),
setSelectedLayerRotationDraft: (rotation) =>
set((state) => {
if (!state.selectedLayerId) return state;
const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId);
if (current && current.rotation === rotation) return state;
return { project: updateLayerTransform(state.project, state.selectedLayerId, { rotation }) };
}),
copySelectedLayer: () =>
set((state) => {
if (!state.selectedLayerId) return state;
const layer = state.project.layers.find((item) => item.id === state.selectedLayerId);
if (!layer) return state;
return { clipboardLayer: cloneLayer(layer) };
}),
cutSelectedLayer: () =>
set((state) => {
if (!state.selectedLayerId) return state;
const layer = state.project.layers.find((item) => item.id === state.selectedLayerId);
if (!layer) return state;
const nextProject = removeLayer(state.project, state.selectedLayerId);
return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) });
}),
pasteLayer: () =>
set((state) => {
if (!state.clipboardLayer) return state;
const base = state.clipboardLayer;
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
const nextProject = addLayer(state.project, pasted);
return withCommittedProject(state, nextProject, { selectedLayerId: pasted.id });
}),
resetProject: () => {
releaseProjectObjectUrls(get().project);
const project = createProject("Untitled Project");
const history = makeHistory(project);
set({
project,
selectedLayerId: null,
history,
...computeHistoryFlags(history),
clipboardLayer: null,
transaction: null,
isDirty: false,
});
},
saveCurrentProject: async () => {
await upsertProject(normalizeProject(get().project));
set({ isDirty: false });
},
loadProjectById: async (projectId) => {
const project = await getProjectById(projectId);
if (!project) return false;
const normalized = normalizeProject(project);
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
set(makeStableProjectState(get().selectedLayerId, normalized));
return true;
},
setProject: (project) => {
const normalized = normalizeProject(project);
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
set(makeStableProjectState(get().selectedLayerId, normalized));
},
importProjectFromJson: (raw) => {
const parsed = parseProjectFile(raw);
if (!parsed.ok) return { ok: false, error: parsed.error };
const normalized = normalizeProject(parsed.project);
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
set(makeStableProjectState(get().selectedLayerId, normalized));
return { ok: true };
},
exportProjectToJson: () => {
const project = normalizeProject(get().project);
const history = get().history;
return serializeProjectFile(project, { checkpointCount: history.past.length + history.future.length });
},
importImageFromFile: async (file) => {
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
reader.onerror = reject;
reader.readAsDataURL(file);
});
const name = file.name.replace(/\.[^/.]+$/, "") || "Image";
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
image.onerror = () =>
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
image.src = dataUrl;
});
const layer = createLayer("image", {
name,
sourceUri: dataUrl,
x: DEFAULT_IMAGE_IMPORT.offsetX,
y: DEFAULT_IMAGE_IMPORT.offsetY,
width: Math.max(1, Math.round(imageSize.width)),
height: Math.max(1, Math.round(imageSize.height)),
});
set((state) => withCommittedProject(state, addLayer(state.project, layer), { selectedLayerId: layer.id }));
},
updateImageLayerSource: (layerId, sourceUri) =>
set((state) => {
if (!sourceUri) return state;
const layer = state.project.layers.find((item) => item.id === layerId);
if (!layer || layer.type !== "image") return state;
if (layer.sourceUri === sourceUri) return state;
const nextProject = updateLayerTransform(state.project, layerId, { sourceUri });
return withCommittedProject(state, nextProject);
}),
setImageLayerFaceBlur: (layerId, faceBlur) =>
set((state) => {
const layer = state.project.layers.find((item) => item.id === layerId);
if (!layer || layer.type !== "image") return state;
const nextProject = updateLayerTransform(state.project, layerId, { faceBlur });
return withCommittedProject(state, nextProject);
}),
setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))),
undo: () =>
set((state) => {
if (state.history.past.length === 0) return state;
const past = [...state.history.past];
const previous = past.pop();
if (!previous) return state;
const future = [state.history.present, ...state.history.future];
const history = { past, present: cloneProject(previous), future };
return {
project: cloneProject(previous),
history,
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
transaction: null,
...computeHistoryFlags(history),
isDirty: true,
};
}),
redo: () =>
set((state) => {
if (state.history.future.length === 0) return state;
const future = [...state.history.future];
const next = future.shift();
if (!next) return state;
const past = capHistory([...state.history.past, state.history.present]);
const history = { past, present: cloneProject(next), future };
return {
project: cloneProject(next),
history,
selectedLayerId: resolveSelectedLayerId(next, state.selectedLayerId),
transaction: null,
...computeHistoryFlags(history),
isDirty: true,
};
}),
jumpToPast: (idx) =>
set((state) => {
const targetPastLength = Math.max(0, Math.min(state.history.past.length, idx - 1));
if (state.history.past.length === targetPastLength) return state;
const moved = state.history.past.slice(targetPastLength);
if (moved.length === 0) return state;
const previous = moved[0];
if (!previous) return state;
const past = state.history.past.slice(0, targetPastLength);
const future = [state.history.present, ...moved.slice(1), ...state.history.future];
const history = { past, present: cloneProject(previous), future };
return {
project: cloneProject(previous),
history,
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
transaction: null,
...computeHistoryFlags(history),
isDirty: true,
};
}),
jumpToFuture: (idx) =>
set((state) => {
const targetFutureLength = Math.max(0, Math.min(state.history.future.length, idx));
if (state.history.future.length === targetFutureLength) return state;
const redoCount = state.history.future.length - targetFutureLength;
const next = state.history.future[redoCount - 1];
if (!next) return state;
const consumedFuture = state.history.future.slice(0, redoCount - 1);
const future = state.history.future.slice(redoCount);
const past = capHistory([...state.history.past, state.history.present, ...consumedFuture]);
const history = { past, present: cloneProject(next), future };
return {
project: cloneProject(next),
history,
selectedLayerId: resolveSelectedLayerId(next, state.selectedLayerId),
transaction: null,
...computeHistoryFlags(history),
isDirty: true,
};
}),
}));
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useUiStore } from "./ui-store";
describe("ui store", () => {
beforeEach(() => {
window.localStorage.clear();
document.documentElement.setAttribute("data-theme", "dark");
useUiStore.setState({ theme: "dark", locale: "en" });
});
it("hydrates from local storage", () => {
window.localStorage.setItem("pien.ui.theme", "light");
window.localStorage.setItem("pien.ui.locale", "ja");
useUiStore.getState().hydrate();
expect(useUiStore.getState().theme).toBe("light");
expect(useUiStore.getState().locale).toBe("ja");
});
it("updates and persists theme", () => {
useUiStore.getState().setTheme("light");
expect(useUiStore.getState().theme).toBe("light");
expect(window.localStorage.getItem("pien.ui.theme")).toBe("light");
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
it("updates and persists locale", () => {
useUiStore.getState().setLocale("th");
expect(useUiStore.getState().locale).toBe("th");
expect(window.localStorage.getItem("pien.ui.locale")).toBe("th");
});
});
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { create } from "zustand";
export type AppTheme = "light" | "dark";
export type AppLocale = "en" | "th" | "ja";
const THEME_KEY = "pien.ui.theme";
const LOCALE_KEY = "pien.ui.locale";
function readTheme(): AppTheme {
if (typeof window === "undefined") return "light";
const value = window.localStorage.getItem(THEME_KEY);
return value === "light" ? "light" : "dark";
}
function readLocale(): AppLocale {
if (typeof window === "undefined") return "en";
const value = window.localStorage.getItem(LOCALE_KEY);
if (value === "th" || value === "ja" || value === "en") return value;
return "en";
}
function applyTheme(theme: AppTheme): void {
if (typeof document === "undefined") return;
document.documentElement.setAttribute("data-theme", theme);
}
type UiState = {
theme: AppTheme;
locale: AppLocale;
hydrate: () => void;
setTheme: (theme: AppTheme) => void;
setLocale: (locale: AppLocale) => void;
};
export const useUiStore = create<UiState>((set) => ({
theme: "light",
locale: "en",
hydrate: () => {
const theme = readTheme();
const locale = readLocale();
applyTheme(theme);
set((state) => {
if (state.theme === theme && state.locale === locale) return state;
return { theme, locale };
});
},
setTheme: (theme) => {
if (typeof window !== "undefined") {
window.localStorage.setItem(THEME_KEY, theme);
}
applyTheme(theme);
set({ theme });
},
setLocale: (locale) => {
if (typeof window !== "undefined") {
window.localStorage.setItem(LOCALE_KEY, locale);
}
set({ locale });
},
}));