2026-05-10 03:06:54 +07:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { create } from "zustand";
|
|
|
|
|
import {
|
|
|
|
|
addLayer,
|
|
|
|
|
createLayer,
|
|
|
|
|
createProject,
|
|
|
|
|
parseProjectFile,
|
|
|
|
|
removeLayer,
|
|
|
|
|
reorderLayer,
|
|
|
|
|
serializeProjectFile,
|
|
|
|
|
setCanvasSize as applyCanvasSize,
|
2026-05-21 00:02:12 +07:00
|
|
|
setLayerEffect,
|
|
|
|
|
removeLayerEffect,
|
|
|
|
|
setLayerVisible,
|
|
|
|
|
setEffectEnabled,
|
2026-05-10 03:06:54 +07:00
|
|
|
updateLayerTransform,
|
|
|
|
|
normalizeProject,
|
2026-05-21 00:02:12 +07:00
|
|
|
getAllTools,
|
|
|
|
|
type EditorToolId,
|
2026-05-10 03:06:54 +07:00
|
|
|
} from "@pien-studio/editor-core";
|
2026-05-31 03:03:49 +07:00
|
|
|
import {
|
|
|
|
|
getAssetRefId,
|
|
|
|
|
getLayerAssetRef,
|
|
|
|
|
getLayerRuntimeSource,
|
|
|
|
|
type Layer,
|
|
|
|
|
type LayerEffect,
|
|
|
|
|
type LayerType,
|
|
|
|
|
type Project,
|
|
|
|
|
} from "@pien-studio/types";
|
|
|
|
|
import { localProjectRepository } from "../lib/project-repository";
|
2026-05-10 03:06:54 +07:00
|
|
|
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;
|
|
|
|
|
|
2026-05-21 00:02:12 +07:00
|
|
|
export { EditorToolId };
|
2026-05-10 03:06:54 +07:00
|
|
|
|
2026-05-21 00:02:12 +07:00
|
|
|
const allTools = getAllTools();
|
2026-05-31 03:03:49 +07:00
|
|
|
export const EDITOR_TOOLS = Object.fromEntries(
|
|
|
|
|
allTools.map((t) => [t.id, t]),
|
|
|
|
|
) as Record<string, (typeof allTools)[number]>;
|
2026-05-10 03:06:54 +07:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
startTransaction: () => void;
|
|
|
|
|
commitTransaction: () => void;
|
|
|
|
|
cancelTransaction: () => void;
|
|
|
|
|
applyProjectDraft: (project: Project) => void;
|
|
|
|
|
setTool: (tool: EditorToolId) => void;
|
2026-05-31 03:03:49 +07:00
|
|
|
addLayerByType: (type: LayerType) => void;
|
2026-05-10 03:06:54 +07:00
|
|
|
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;
|
2026-05-21 00:02:12 +07:00
|
|
|
pasteLayer: (e?: ClipboardEvent) => void;
|
2026-05-10 03:06:54 +07:00
|
|
|
resetProject: () => void;
|
|
|
|
|
saveCurrentProject: () => Promise<void>;
|
|
|
|
|
loadProjectById: (projectId: string) => Promise<boolean>;
|
|
|
|
|
setProject: (project: Project) => void;
|
|
|
|
|
importProjectFromJson: (raw: string) => { ok: boolean; error?: string };
|
2026-05-21 00:02:12 +07:00
|
|
|
addCanvasSizedLayer: (sourceUri: string, name?: string) => void;
|
2026-05-10 03:06:54 +07:00
|
|
|
importImageFromFile: (file: File) => Promise<void>;
|
|
|
|
|
updateImageLayerSource: (layerId: string, sourceUri: string) => void;
|
2026-05-21 00:02:12 +07:00
|
|
|
setLayerEffect: (layerId: string, effect: LayerEffect) => void;
|
|
|
|
|
removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void;
|
|
|
|
|
setLayerVisible: (layerId: string, visible: boolean) => void;
|
2026-05-31 03:03:49 +07:00
|
|
|
setEffectEnabled: (
|
|
|
|
|
layerId: string,
|
|
|
|
|
kind: LayerEffect["kind"],
|
|
|
|
|
enabled: boolean,
|
|
|
|
|
) => void;
|
2026-05-10 03:06:54 +07:00
|
|
|
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");
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
function withCommittedProject(
|
|
|
|
|
state: EditorState,
|
|
|
|
|
nextProject: Project,
|
|
|
|
|
extras?: Partial<EditorState>,
|
|
|
|
|
) {
|
2026-05-10 03:06:54 +07:00
|
|
|
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>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
function makeStableProjectState(
|
|
|
|
|
previousSelectedLayerId: string | null,
|
|
|
|
|
project: Project,
|
|
|
|
|
) {
|
2026-05-10 03:06:54 +07:00
|
|
|
const history = makeHistory(project);
|
|
|
|
|
return {
|
|
|
|
|
project,
|
|
|
|
|
selectedLayerId: resolveSelectedLayerId(project, previousSelectedLayerId),
|
|
|
|
|
history,
|
|
|
|
|
...computeHistoryFlags(history),
|
|
|
|
|
clipboardLayer: null,
|
|
|
|
|
transaction: null,
|
|
|
|
|
isDirty: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getProjectAssetIds(project: Project): string[] {
|
2026-05-31 03:03:49 +07:00
|
|
|
return project.layers
|
|
|
|
|
.map((layer) => getAssetRefId(getLayerAssetRef(layer)))
|
|
|
|
|
.filter((assetId): assetId is string => Boolean(assetId));
|
2026-05-10 03:06:54 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-31 03:03:49 +07:00
|
|
|
if (
|
|
|
|
|
!hasProjectChanged(state.transaction.baselineProject, state.project)
|
|
|
|
|
) {
|
2026-05-10 03:06:54 +07:00
|
|
|
return { transaction: null };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
const past = capHistory([
|
|
|
|
|
...state.history.past,
|
|
|
|
|
cloneProject(state.transaction.baselineProject),
|
|
|
|
|
]);
|
|
|
|
|
const history = {
|
|
|
|
|
past,
|
|
|
|
|
present: cloneProject(state.project),
|
|
|
|
|
future: [],
|
|
|
|
|
};
|
2026-05-10 03:06:54 +07:00
|
|
|
return {
|
|
|
|
|
history,
|
|
|
|
|
transaction: null,
|
|
|
|
|
...computeHistoryFlags(history),
|
|
|
|
|
isDirty: true,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
cancelTransaction: () =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (!state.transaction) return state;
|
|
|
|
|
return {
|
|
|
|
|
project: cloneProject(state.transaction.baselineProject),
|
2026-05-31 03:03:49 +07:00
|
|
|
selectedLayerId: resolveSelectedLayerId(
|
|
|
|
|
state.transaction.baselineProject,
|
|
|
|
|
state.transaction.baselineSelectedLayerId,
|
|
|
|
|
),
|
2026-05-10 03:06:54 +07:00
|
|
|
transaction: null,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
applyProjectDraft: (project) => set(() => ({ project })),
|
|
|
|
|
|
|
|
|
|
setTool: (tool) => {
|
2026-05-21 00:02:12 +07:00
|
|
|
if (!EDITOR_TOOLS[tool]) return;
|
2026-05-10 03:06:54 +07:00
|
|
|
set({ tool });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
addLayerByType: (type) =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
const layer = createLayer(type);
|
|
|
|
|
const nextProject = addLayer(state.project, layer);
|
2026-05-31 03:03:49 +07:00
|
|
|
return withCommittedProject(state, nextProject, {
|
|
|
|
|
selectedLayerId: layer.id,
|
|
|
|
|
});
|
2026-05-10 03:06:54 +07:00
|
|
|
}),
|
|
|
|
|
|
2026-05-21 00:02:12 +07:00
|
|
|
addCanvasSizedLayer: (sourceUri, name) =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
const { width, height } = state.project.canvas;
|
2026-05-31 03:03:49 +07:00
|
|
|
const layer = createLayer("raster", {
|
|
|
|
|
name: name ?? "Layer",
|
|
|
|
|
asset: { kind: "inline", uri: sourceUri },
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
});
|
2026-05-21 00:02:12 +07:00
|
|
|
const nextProject = addLayer(state.project, layer);
|
2026-05-31 03:03:49 +07:00
|
|
|
return withCommittedProject(state, nextProject, {
|
|
|
|
|
selectedLayerId: layer.id,
|
|
|
|
|
});
|
2026-05-21 00:02:12 +07:00
|
|
|
}),
|
|
|
|
|
|
2026-05-10 03:06:54 +07:00
|
|
|
setSelectedLayerPosition: (x, y) =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (!state.selectedLayerId) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
const committed = state.history.present.layers.find(
|
|
|
|
|
(layer) => layer.id === state.selectedLayerId,
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
if (committed && committed.x === x && committed.y === y) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
const nextProject = updateLayerTransform(
|
|
|
|
|
state.project,
|
|
|
|
|
state.selectedLayerId,
|
|
|
|
|
{ x, y },
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
return withCommittedProject(state, nextProject);
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
setSelectedLayerPositionDraft: (x, y) =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (!state.selectedLayerId) return state;
|
|
|
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
const current = state.project.layers.find(
|
|
|
|
|
(layer) => layer.id === state.selectedLayerId,
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
if (current && current.x === x && current.y === y) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
return {
|
|
|
|
|
project: updateLayerTransform(state.project, state.selectedLayerId, {
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
}),
|
|
|
|
|
};
|
2026-05-10 03:06:54 +07:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
setSelectedLayerSize: (width, height) =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (!state.selectedLayerId) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
setSelectedLayerSizeDraft: (width, height) =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (!state.selectedLayerId) return state;
|
|
|
|
|
if (!Number.isFinite(width) || !Number.isFinite(height)) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
const current = state.project.layers.find(
|
|
|
|
|
(layer) => layer.id === state.selectedLayerId,
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
const nextWidth = Math.max(MIN_LAYER_SIZE, width);
|
|
|
|
|
const nextHeight = Math.max(MIN_LAYER_SIZE, height);
|
|
|
|
|
|
|
|
|
|
if (current) {
|
2026-05-31 03:03:49 +07:00
|
|
|
const currentWidth = current.width;
|
|
|
|
|
const currentHeight = current.height;
|
2026-05-10 03:06:54 +07:00
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
typeof currentWidth === "number" &&
|
|
|
|
|
typeof currentHeight === "number" &&
|
|
|
|
|
Math.abs(currentWidth - nextWidth) < DRAFT_TRANSFORM_EPSILON &&
|
|
|
|
|
Math.abs(currentHeight - nextHeight) < DRAFT_TRANSFORM_EPSILON
|
|
|
|
|
) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-31 03:03:49 +07:00
|
|
|
return {
|
|
|
|
|
project: updateLayerTransform(state.project, state.selectedLayerId, {
|
|
|
|
|
width: nextWidth,
|
|
|
|
|
height: nextHeight,
|
|
|
|
|
}),
|
|
|
|
|
};
|
2026-05-10 03:06:54 +07:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-31 03:03:49 +07:00
|
|
|
const idx = state.project.layers.findIndex(
|
|
|
|
|
(layer) => layer.id === state.selectedLayerId,
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
if (idx < 0) return state;
|
|
|
|
|
const nextIndex = direction === "up" ? idx + 1 : idx - 1;
|
2026-05-31 03:03:49 +07:00
|
|
|
return withCommittedProject(
|
|
|
|
|
state,
|
|
|
|
|
reorderLayer(state.project, state.selectedLayerId, nextIndex),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
}),
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
selectLayer: (layerId) =>
|
|
|
|
|
set((state) => ({
|
|
|
|
|
selectedLayerId: resolveSelectedLayerId(state.project, layerId),
|
|
|
|
|
})),
|
2026-05-10 03:06:54 +07:00
|
|
|
|
|
|
|
|
setSelectedLayerRotation: (rotation) =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (!state.selectedLayerId) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
const committed = state.history.present.layers.find(
|
|
|
|
|
(layer) => layer.id === state.selectedLayerId,
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
if (committed && committed.rotation === rotation) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
return withCommittedProject(
|
|
|
|
|
state,
|
|
|
|
|
updateLayerTransform(state.project, state.selectedLayerId, {
|
|
|
|
|
rotation,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
setSelectedLayerRotationDraft: (rotation) =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (!state.selectedLayerId) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
const current = state.project.layers.find(
|
|
|
|
|
(layer) => layer.id === state.selectedLayerId,
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
if (current && current.rotation === rotation) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
return {
|
|
|
|
|
project: updateLayerTransform(state.project, state.selectedLayerId, {
|
|
|
|
|
rotation,
|
|
|
|
|
}),
|
|
|
|
|
};
|
2026-05-10 03:06:54 +07:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
copySelectedLayer: () =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (!state.selectedLayerId) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
const layer = state.project.layers.find(
|
|
|
|
|
(item) => item.id === state.selectedLayerId,
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
if (!layer) return state;
|
|
|
|
|
return { clipboardLayer: cloneLayer(layer) };
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
cutSelectedLayer: () =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (!state.selectedLayerId) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
const layer = state.project.layers.find(
|
|
|
|
|
(item) => item.id === state.selectedLayerId,
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
if (!layer) return state;
|
|
|
|
|
const nextProject = removeLayer(state.project, state.selectedLayerId);
|
2026-05-31 03:03:49 +07:00
|
|
|
return withCommittedProject(state, nextProject, {
|
|
|
|
|
clipboardLayer: cloneLayer(layer),
|
|
|
|
|
});
|
2026-05-10 03:06:54 +07:00
|
|
|
}),
|
|
|
|
|
|
2026-05-21 00:02:12 +07:00
|
|
|
pasteLayer: (e?: ClipboardEvent) => {
|
2026-05-31 03:03:49 +07:00
|
|
|
const state = get();
|
2026-05-21 00:02:12 +07:00
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-21 00:02:12 +07:00
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
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) => {
|
2026-05-21 00:02:12 +07:00
|
|
|
const image = new Image();
|
2026-05-31 03:03:49 +07:00
|
|
|
image.onload = () =>
|
|
|
|
|
resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
|
|
|
|
image.onerror = () =>
|
|
|
|
|
resolve({
|
|
|
|
|
width: DEFAULT_IMAGE_IMPORT.fallbackWidth,
|
|
|
|
|
height: DEFAULT_IMAGE_IMPORT.fallbackHeight,
|
|
|
|
|
});
|
2026-05-21 00:02:12 +07:00
|
|
|
image.src = dataUrl;
|
2026-05-31 03:03:49 +07:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-21 00:02:12 +07:00
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
// 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;
|
2026-05-21 00:02:12 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-31 03:03:49 +07:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-21 00:02:12 +07:00
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
// Async Clipboard API is a fallback because browser support and permissions vary.
|
|
|
|
|
navigator.clipboard
|
|
|
|
|
.read()
|
|
|
|
|
.then(async (clipboardItems) => {
|
2026-05-21 00:02:12 +07:00
|
|
|
for (const item of clipboardItems) {
|
|
|
|
|
for (const type of item.types) {
|
|
|
|
|
if (type.startsWith("image/")) {
|
|
|
|
|
const blob = await item.getType(type);
|
|
|
|
|
await pasteImageBlob(blob);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-31 03:03:49 +07:00
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
},
|
2026-05-10 03:06:54 +07:00
|
|
|
|
|
|
|
|
resetProject: () => {
|
2026-05-31 03:03:49 +07:00
|
|
|
localProjectRepository.releaseObjectUrls(get().project);
|
2026-05-10 03:06:54 +07:00
|
|
|
const project = createProject("Untitled Project");
|
|
|
|
|
const history = makeHistory(project);
|
|
|
|
|
set({
|
|
|
|
|
project,
|
|
|
|
|
selectedLayerId: null,
|
|
|
|
|
history,
|
|
|
|
|
...computeHistoryFlags(history),
|
|
|
|
|
clipboardLayer: null,
|
|
|
|
|
transaction: null,
|
|
|
|
|
isDirty: false,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
saveCurrentProject: async () => {
|
2026-05-31 03:03:49 +07:00
|
|
|
await localProjectRepository.upsertProject(normalizeProject(get().project));
|
2026-05-10 03:06:54 +07:00
|
|
|
set({ isDirty: false });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
loadProjectById: async (projectId) => {
|
2026-05-31 03:03:49 +07:00
|
|
|
const project = await localProjectRepository.getProject(projectId);
|
2026-05-10 03:06:54 +07:00
|
|
|
if (!project) return false;
|
|
|
|
|
const normalized = normalizeProject(project);
|
2026-05-31 03:03:49 +07:00
|
|
|
localProjectRepository.releaseObjectUrls(
|
|
|
|
|
get().project,
|
|
|
|
|
getProjectAssetIds(normalized),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
set(makeStableProjectState(get().selectedLayerId, normalized));
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setProject: (project) => {
|
|
|
|
|
const normalized = normalizeProject(project);
|
2026-05-31 03:03:49 +07:00
|
|
|
localProjectRepository.releaseObjectUrls(
|
|
|
|
|
get().project,
|
|
|
|
|
getProjectAssetIds(normalized),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
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);
|
2026-05-31 03:03:49 +07:00
|
|
|
localProjectRepository.releaseObjectUrls(
|
|
|
|
|
get().project,
|
|
|
|
|
getProjectAssetIds(normalized),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
set(makeStableProjectState(get().selectedLayerId, normalized));
|
|
|
|
|
return { ok: true };
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
exportProjectToJson: () => {
|
|
|
|
|
const project = normalizeProject(get().project);
|
|
|
|
|
const history = get().history;
|
2026-05-31 03:03:49 +07:00
|
|
|
return serializeProjectFile(project, {
|
|
|
|
|
checkpointCount: history.past.length + history.future.length,
|
|
|
|
|
});
|
2026-05-10 03:06:54 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
importImageFromFile: async (file) => {
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
2026-05-31 03:03:49 +07:00
|
|
|
reader.onload = () =>
|
|
|
|
|
resolve(typeof reader.result === "string" ? reader.result : "");
|
2026-05-10 03:06:54 +07:00
|
|
|
reader.onerror = reject;
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
});
|
|
|
|
|
const name = file.name.replace(/\.[^/.]+$/, "") || "Image";
|
2026-05-31 03:03:49 +07:00
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-21 00:02:12 +07:00
|
|
|
const layer = createLayer("raster", {
|
2026-05-10 03:06:54 +07:00
|
|
|
name,
|
2026-05-31 03:03:49 +07:00
|
|
|
asset: { kind: "inline", uri: dataUrl },
|
2026-05-10 03:06:54 +07:00
|
|
|
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)),
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
set((state) =>
|
|
|
|
|
withCommittedProject(state, addLayer(state.project, layer), {
|
|
|
|
|
selectedLayerId: layer.id,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateImageLayerSource: (layerId, sourceUri) =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (!sourceUri) return state;
|
|
|
|
|
const layer = state.project.layers.find((item) => item.id === layerId);
|
2026-05-21 00:02:12 +07:00
|
|
|
if (!layer || layer.type !== "raster") return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
const currentSource = getLayerRuntimeSource(layer);
|
|
|
|
|
if (currentSource === sourceUri) return state;
|
|
|
|
|
const nextProject = updateLayerTransform(state.project, layerId, {
|
|
|
|
|
asset: { kind: "inline", uri: sourceUri },
|
|
|
|
|
runtimeSourceUri: undefined,
|
|
|
|
|
});
|
2026-05-10 03:06:54 +07:00
|
|
|
return withCommittedProject(state, nextProject);
|
|
|
|
|
}),
|
|
|
|
|
|
2026-05-21 00:02:12 +07:00
|
|
|
setLayerEffect: (layerId, effect) =>
|
2026-05-10 03:06:54 +07:00
|
|
|
set((state) => {
|
|
|
|
|
const layer = state.project.layers.find((item) => item.id === layerId);
|
2026-05-21 00:02:12 +07:00
|
|
|
if (!layer) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
return withCommittedProject(
|
|
|
|
|
state,
|
|
|
|
|
setLayerEffect(state.project, layerId, effect),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
}),
|
|
|
|
|
|
2026-05-21 00:02:12 +07:00
|
|
|
removeLayerEffect: (layerId, kind) =>
|
|
|
|
|
set((state) => {
|
|
|
|
|
const layer = state.project.layers.find((item) => item.id === layerId);
|
|
|
|
|
if (!layer) return state;
|
2026-05-31 03:03:49 +07:00
|
|
|
return withCommittedProject(
|
|
|
|
|
state,
|
|
|
|
|
removeLayerEffect(state.project, layerId, kind),
|
|
|
|
|
);
|
2026-05-21 00:02:12 +07:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
setLayerVisible: (layerId, visible) =>
|
2026-05-31 03:03:49 +07:00
|
|
|
set((state) =>
|
|
|
|
|
withCommittedProject(
|
|
|
|
|
state,
|
|
|
|
|
setLayerVisible(state.project, layerId, visible),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-05-21 00:02:12 +07:00
|
|
|
|
|
|
|
|
setEffectEnabled: (layerId, kind, enabled) =>
|
2026-05-31 03:03:49 +07:00
|
|
|
set((state) =>
|
|
|
|
|
withCommittedProject(
|
|
|
|
|
state,
|
|
|
|
|
setEffectEnabled(state.project, layerId, kind, enabled),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-05-21 00:02:12 +07:00
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
setCanvasSize: (width, height) =>
|
|
|
|
|
set((state) =>
|
|
|
|
|
withCommittedProject(
|
|
|
|
|
state,
|
|
|
|
|
applyCanvasSize(state.project, width, height),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-05-10 03:06:54 +07:00
|
|
|
|
|
|
|
|
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,
|
2026-05-31 03:03:49 +07:00
|
|
|
selectedLayerId: resolveSelectedLayerId(
|
|
|
|
|
previous,
|
|
|
|
|
state.selectedLayerId,
|
|
|
|
|
),
|
2026-05-10 03:06:54 +07:00
|
|
|
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) => {
|
2026-05-31 03:03:49 +07:00
|
|
|
const targetPastLength = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
Math.min(state.history.past.length, idx - 1),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
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);
|
2026-05-31 03:03:49 +07:00
|
|
|
const future = [
|
|
|
|
|
state.history.present,
|
|
|
|
|
...moved.slice(1),
|
|
|
|
|
...state.history.future,
|
|
|
|
|
];
|
2026-05-10 03:06:54 +07:00
|
|
|
const history = { past, present: cloneProject(previous), future };
|
|
|
|
|
return {
|
|
|
|
|
project: cloneProject(previous),
|
|
|
|
|
history,
|
2026-05-31 03:03:49 +07:00
|
|
|
selectedLayerId: resolveSelectedLayerId(
|
|
|
|
|
previous,
|
|
|
|
|
state.selectedLayerId,
|
|
|
|
|
),
|
2026-05-10 03:06:54 +07:00
|
|
|
transaction: null,
|
|
|
|
|
...computeHistoryFlags(history),
|
|
|
|
|
isDirty: true,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
jumpToFuture: (idx) =>
|
|
|
|
|
set((state) => {
|
2026-05-31 03:03:49 +07:00
|
|
|
const targetFutureLength = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
Math.min(state.history.future.length, idx),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
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);
|
2026-05-31 03:03:49 +07:00
|
|
|
const past = capHistory([
|
|
|
|
|
...state.history.past,
|
|
|
|
|
state.history.present,
|
|
|
|
|
...consumedFuture,
|
|
|
|
|
]);
|
2026-05-10 03:06:54 +07:00
|
|
|
const history = { past, present: cloneProject(next), future };
|
|
|
|
|
return {
|
|
|
|
|
project: cloneProject(next),
|
|
|
|
|
history,
|
|
|
|
|
selectedLayerId: resolveSelectedLayerId(next, state.selectedLayerId),
|
|
|
|
|
transaction: null,
|
|
|
|
|
...computeHistoryFlags(history),
|
|
|
|
|
isDirty: true,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
}));
|