♻️ 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
+302 -106
View File
@@ -19,8 +19,16 @@ import {
getAllTools,
type EditorToolId,
} from "@pien-studio/editor-core";
import type { Layer, LayerEffect, Project } from "@pien-studio/types";
import { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage";
import {
getAssetRefId,
getLayerAssetRef,
getLayerRuntimeSource,
type Layer,
type LayerEffect,
type LayerType,
type Project,
} from "@pien-studio/types";
import { localProjectRepository } from "../lib/project-repository";
import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants";
import { hasProjectChanged } from "../lib/project-equality";
import {
@@ -38,7 +46,9 @@ const DRAFT_TRANSFORM_EPSILON = 0.01;
export { EditorToolId };
const allTools = getAllTools();
export const EDITOR_TOOLS = Object.fromEntries(allTools.map((t) => [t.id, t])) as Record<string, (typeof allTools)[number]>;
export const EDITOR_TOOLS = Object.fromEntries(
allTools.map((t) => [t.id, t]),
) as Record<string, (typeof allTools)[number]>;
type TransactionState = {
baselineProject: Project;
@@ -60,7 +70,7 @@ type EditorState = {
cancelTransaction: () => void;
applyProjectDraft: (project: Project) => void;
setTool: (tool: EditorToolId) => void;
addLayerByType: (type: Layer["type"]) => void;
addLayerByType: (type: LayerType) => void;
setSelectedLayerPosition: (x: number, y: number) => void;
setSelectedLayerPositionDraft: (x: number, y: number) => void;
setSelectedLayerSize: (width: number, height: number) => void;
@@ -84,7 +94,11 @@ type EditorState = {
setLayerEffect: (layerId: string, effect: LayerEffect) => void;
removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void;
setLayerVisible: (layerId: string, visible: boolean) => void;
setEffectEnabled: (layerId: string, kind: LayerEffect["kind"], enabled: boolean) => void;
setEffectEnabled: (
layerId: string,
kind: LayerEffect["kind"],
enabled: boolean,
) => void;
setCanvasSize: (width: number, height: number) => void;
exportProjectToJson: () => string;
undo: () => void;
@@ -95,7 +109,11 @@ type EditorState = {
const initialProject = createProject("Untitled Project");
function withCommittedProject(state: EditorState, nextProject: Project, extras?: Partial<EditorState>) {
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 {
@@ -109,7 +127,10 @@ function withCommittedProject(state: EditorState, nextProject: Project, extras?:
} satisfies Partial<EditorState>;
}
function makeStableProjectState(previousSelectedLayerId: string | null, project: Project) {
function makeStableProjectState(
previousSelectedLayerId: string | null,
project: Project,
) {
const history = makeHistory(project);
return {
project,
@@ -123,7 +144,9 @@ function makeStableProjectState(previousSelectedLayerId: string | null, project:
}
function getProjectAssetIds(project: Project): string[] {
return project.layers.map((layer) => layer.assetId).filter((assetId): assetId is string => Boolean(assetId));
return project.layers
.map((layer) => getAssetRefId(getLayerAssetRef(layer)))
.filter((assetId): assetId is string => Boolean(assetId));
}
export const useEditorStore = create<EditorState>((set, get) => ({
@@ -150,12 +173,21 @@ export const useEditorStore = create<EditorState>((set, get) => ({
commitTransaction: () =>
set((state) => {
if (!state.transaction) return state;
if (!hasProjectChanged(state.transaction.baselineProject, state.project)) {
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: [] };
const past = capHistory([
...state.history.past,
cloneProject(state.transaction.baselineProject),
]);
const history = {
past,
present: cloneProject(state.project),
future: [],
};
return {
history,
transaction: null,
@@ -169,7 +201,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
if (!state.transaction) return state;
return {
project: cloneProject(state.transaction.baselineProject),
selectedLayerId: resolveSelectedLayerId(state.transaction.baselineProject, state.transaction.baselineSelectedLayerId),
selectedLayerId: resolveSelectedLayerId(
state.transaction.baselineProject,
state.transaction.baselineSelectedLayerId,
),
transaction: null,
};
}),
@@ -185,23 +220,40 @@ export const useEditorStore = create<EditorState>((set, get) => ({
set((state) => {
const layer = createLayer(type);
const nextProject = addLayer(state.project, layer);
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
return withCommittedProject(state, nextProject, {
selectedLayerId: layer.id,
});
}),
addCanvasSizedLayer: (sourceUri, name) =>
set((state) => {
const { width, height } = state.project.canvas;
const layer = createLayer("raster", { name: name ?? "Layer", sourceUri, x: 0, y: 0, width, height });
const layer = createLayer("raster", {
name: name ?? "Layer",
asset: { kind: "inline", uri: sourceUri },
x: 0,
y: 0,
width,
height,
});
const nextProject = addLayer(state.project, layer);
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id });
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);
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 });
const nextProject = updateLayerTransform(
state.project,
state.selectedLayerId,
{ x, y },
);
return withCommittedProject(state, nextProject);
}),
@@ -209,30 +261,48 @@ export const useEditorStore = create<EditorState>((set, get) => ({
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);
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 }) };
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 }));
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 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 === "raster" ? Math.round(200 * current.scale) : undefined);
const currentHeight = current.height ?? (current.type === "raster" ? Math.round(150 * current.scale) : undefined);
const currentWidth = current.width;
const currentHeight = current.height;
if (
typeof currentWidth === "number" &&
@@ -243,7 +313,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
return state;
}
}
return { project: updateLayerTransform(state.project, state.selectedLayerId, { width: nextWidth, height: nextHeight }) };
return {
project: updateLayerTransform(state.project, state.selectedLayerId, {
width: nextWidth,
height: nextHeight,
}),
};
}),
removeSelectedLayer: () =>
@@ -256,34 +331,57 @@ export const useEditorStore = create<EditorState>((set, get) => ({
moveSelectedLayerOrder: (direction) =>
set((state) => {
if (!state.selectedLayerId) return state;
const idx = state.project.layers.findIndex((layer) => layer.id === state.selectedLayerId);
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));
return withCommittedProject(
state,
reorderLayer(state.project, state.selectedLayerId, nextIndex),
);
}),
selectLayer: (layerId) => set((state) => ({ selectedLayerId: resolveSelectedLayerId(state.project, layerId) })),
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);
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 }));
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);
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 }) };
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);
const layer = state.project.layers.find(
(item) => item.id === state.selectedLayerId,
);
if (!layer) return state;
return { clipboardLayer: cloneLayer(layer) };
}),
@@ -291,60 +389,89 @@ export const useEditorStore = create<EditorState>((set, get) => ({
cutSelectedLayer: () =>
set((state) => {
if (!state.selectedLayerId) return state;
const layer = state.project.layers.find((item) => item.id === state.selectedLayerId);
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) });
return withCommittedProject(state, nextProject, {
clipboardLayer: cloneLayer(layer),
});
}),
pasteLayer: (e?: ClipboardEvent) => {
const state = get();
const state = get();
// Internal layer clipboard takes priority
if (state.clipboardLayer) {
const base = state.clipboardLayer;
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
set((s) => withCommittedProject(s, addLayer(s.project, pasted), { selectedLayerId: pasted.id }));
return;
}
if (state.clipboardLayer) {
const base = state.clipboardLayer;
const pasted: Layer = {
...base,
id: crypto.randomUUID(),
x: base.x + 20,
y: base.y + 20,
};
set((s) =>
withCommittedProject(s, addLayer(s.project, pasted), {
selectedLayerId: pasted.id,
}),
);
return;
}
async function pasteImageBlob(blob: Blob) {
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(blob);
});
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
async function pasteImageBlob(blob: Blob) {
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(blob);
});
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.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("raster", {
name: "Image",
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((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
}
},
);
const layer = createLayer("raster", {
name: "Image",
asset: { kind: "inline", uri: 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((s) =>
withCommittedProject(s, addLayer(s.project, layer), {
selectedLayerId: layer.id,
}),
);
}
// Read from native ClipboardEvent.clipboardData (works on all browsers without permission prompt)
if (e?.clipboardData) {
for (const item of Array.from(e.clipboardData.items)) {
if (item.type.startsWith("image/")) {
const blob = item.getAsFile();
if (blob) { void pasteImageBlob(blob); return; }
// Prefer event clipboard data to avoid permission prompts.
if (e?.clipboardData) {
for (const item of Array.from(e.clipboardData.items)) {
if (item.type.startsWith("image/")) {
const blob = item.getAsFile();
if (blob) {
void pasteImageBlob(blob);
return;
}
}
return;
}
return;
}
// Fallback: async Clipboard API (requires permission, may not work on Mac Safari)
navigator.clipboard.read().then(async (clipboardItems) => {
// Async Clipboard API is a fallback because browser support and permissions vary.
navigator.clipboard
.read()
.then(async (clipboardItems) => {
for (const item of clipboardItems) {
for (const type of item.types) {
if (type.startsWith("image/")) {
@@ -354,11 +481,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
}
}
}
}).catch(() => {});
},
})
.catch(() => {});
},
resetProject: () => {
releaseProjectObjectUrls(get().project);
localProjectRepository.releaseObjectUrls(get().project);
const project = createProject("Untitled Project");
const history = makeHistory(project);
set({
@@ -373,22 +501,28 @@ export const useEditorStore = create<EditorState>((set, get) => ({
},
saveCurrentProject: async () => {
await upsertProject(normalizeProject(get().project));
await localProjectRepository.upsertProject(normalizeProject(get().project));
set({ isDirty: false });
},
loadProjectById: async (projectId) => {
const project = await getProjectById(projectId);
const project = await localProjectRepository.getProject(projectId);
if (!project) return false;
const normalized = normalizeProject(project);
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
localProjectRepository.releaseObjectUrls(
get().project,
getProjectAssetIds(normalized),
);
set(makeStableProjectState(get().selectedLayerId, normalized));
return true;
},
setProject: (project) => {
const normalized = normalizeProject(project);
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
localProjectRepository.releaseObjectUrls(
get().project,
getProjectAssetIds(normalized),
);
set(makeStableProjectState(get().selectedLayerId, normalized));
},
@@ -396,7 +530,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
const parsed = parseProjectFile(raw);
if (!parsed.ok) return { ok: false, error: parsed.error };
const normalized = normalizeProject(parsed.project);
releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized));
localProjectRepository.releaseObjectUrls(
get().project,
getProjectAssetIds(normalized),
);
set(makeStableProjectState(get().selectedLayerId, normalized));
return { ok: true };
},
@@ -404,34 +541,47 @@ export const useEditorStore = create<EditorState>((set, get) => ({
exportProjectToJson: () => {
const project = normalizeProject(get().project);
const history = get().history;
return serializeProjectFile(project, { checkpointCount: history.past.length + history.future.length });
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.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 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("raster", {
name,
sourceUri: dataUrl,
asset: { kind: "inline", uri: 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 }));
set((state) =>
withCommittedProject(state, addLayer(state.project, layer), {
selectedLayerId: layer.id,
}),
);
},
updateImageLayerSource: (layerId, sourceUri) =>
@@ -439,8 +589,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
if (!sourceUri) return state;
const layer = state.project.layers.find((item) => item.id === layerId);
if (!layer || layer.type !== "raster") return state;
if (layer.sourceUri === sourceUri) return state;
const nextProject = updateLayerTransform(state.project, layerId, { sourceUri });
const currentSource = getLayerRuntimeSource(layer);
if (currentSource === sourceUri) return state;
const nextProject = updateLayerTransform(state.project, layerId, {
asset: { kind: "inline", uri: sourceUri },
runtimeSourceUri: undefined,
});
return withCommittedProject(state, nextProject);
}),
@@ -448,23 +602,45 @@ export const useEditorStore = create<EditorState>((set, get) => ({
set((state) => {
const layer = state.project.layers.find((item) => item.id === layerId);
if (!layer) return state;
return withCommittedProject(state, setLayerEffect(state.project, layerId, effect));
return withCommittedProject(
state,
setLayerEffect(state.project, layerId, effect),
);
}),
removeLayerEffect: (layerId, kind) =>
set((state) => {
const layer = state.project.layers.find((item) => item.id === layerId);
if (!layer) return state;
return withCommittedProject(state, removeLayerEffect(state.project, layerId, kind));
return withCommittedProject(
state,
removeLayerEffect(state.project, layerId, kind),
);
}),
setLayerVisible: (layerId, visible) =>
set((state) => withCommittedProject(state, setLayerVisible(state.project, layerId, visible))),
set((state) =>
withCommittedProject(
state,
setLayerVisible(state.project, layerId, visible),
),
),
setEffectEnabled: (layerId, kind, enabled) =>
set((state) => withCommittedProject(state, setEffectEnabled(state.project, layerId, kind, enabled))),
set((state) =>
withCommittedProject(
state,
setEffectEnabled(state.project, layerId, kind, enabled),
),
),
setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))),
setCanvasSize: (width, height) =>
set((state) =>
withCommittedProject(
state,
applyCanvasSize(state.project, width, height),
),
),
undo: () =>
set((state) => {
@@ -477,7 +653,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
return {
project: cloneProject(previous),
history,
selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId),
selectedLayerId: resolveSelectedLayerId(
previous,
state.selectedLayerId,
),
transaction: null,
...computeHistoryFlags(history),
isDirty: true,
@@ -504,19 +683,29 @@ export const useEditorStore = create<EditorState>((set, get) => ({
jumpToPast: (idx) =>
set((state) => {
const targetPastLength = Math.max(0, Math.min(state.history.past.length, idx - 1));
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 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),
selectedLayerId: resolveSelectedLayerId(
previous,
state.selectedLayerId,
),
transaction: null,
...computeHistoryFlags(history),
isDirty: true,
@@ -525,14 +714,21 @@ export const useEditorStore = create<EditorState>((set, get) => ({
jumpToFuture: (idx) =>
set((state) => {
const targetFutureLength = Math.max(0, Math.min(state.history.future.length, idx));
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 past = capHistory([
...state.history.past,
state.history.present,
...consumedFuture,
]);
const history = { past, present: cloneProject(next), future };
return {
project: cloneProject(next),