Files
Pien-Studio/apps/web/store/editor-store.ts
T

743 lines
22 KiB
TypeScript
Raw Normal View History

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,
};
}),
}));