Files
DndGamePlayer/app/renderer/editor/state/projectState.ts
T
Ivan Fontosh 10bb7013e6 feat(editor): show scene preview immediately and optimize in background
Return control after copying the original asset, then run image optimization
and thumbnail generation in the background with IPC progress updates.
Block duplicate preview uploads and scene creation while requests are in flight.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 22:38:51 +08:00

521 lines
19 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi';
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
type State = {
projects: ProjectSummary[];
project: Project | null;
selectedSceneId: SceneId | null;
openingProjectId: ProjectId | null;
creatingScene: boolean;
zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null;
scenePreviewImports: Record<
SceneId,
{ assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string }
>;
};
type Actions = {
refreshProjects: () => Promise<void>;
createProject: (name: string) => Promise<void>;
openProject: (id: ProjectId) => Promise<void>;
closeProject: () => Promise<void>;
createScene: () => Promise<void>;
selectScene: (id: SceneId) => Promise<void>;
importCampaignAudio: () => Promise<void>;
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
updateScene: (
sceneId: SceneId,
patch: {
title?: string;
description?: string;
previewAssetId?: AssetId | null;
previewThumbAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean;
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: { x: number; y: number };
},
) => Promise<void>;
updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>;
importMediaToScene: (sceneId: SceneId) => Promise<void>;
importScenePreview: (sceneId: SceneId) => Promise<{ assetId: AssetId | null; background: boolean }>;
clearScenePreview: (sceneId: SceneId) => Promise<void>;
updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>;
addSceneGraphNode: (sceneId: SceneId, x: number, y: number) => Promise<void>;
removeSceneGraphNode: (nodeId: GraphNodeId) => Promise<void>;
addSceneGraphEdge: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => Promise<void>;
removeSceneGraphEdge: (edgeId: string) => Promise<void>;
setSceneGraphNodeStart: (graphNodeId: GraphNodeId | null) => Promise<void>;
deleteScene: (sceneId: SceneId) => Promise<void>;
renameProject: (name: string, fileBaseName: string) => Promise<void>;
importProject: () => Promise<void>;
exportProject: (projectId: ProjectId) => Promise<void>;
deleteProject: (projectId: ProjectId) => Promise<void>;
};
function randomId(prefix: string): string {
return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
}
export type ProjectNoticeCode = 'campaign_audio_empty';
export type ProjectStateOpts = {
onNotice?: (code: ProjectNoticeCode) => void;
};
export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts): readonly [State, Actions] {
const api = getDndApi();
const onNoticeRef = useRef(opts?.onNotice);
useEffect(() => {
onNoticeRef.current = opts?.onNotice;
}, [opts?.onNotice]);
const [state, setState] = useState<State>({
projects: [],
project: null,
selectedSceneId: null,
openingProjectId: null,
creatingScene: false,
zipProgress: null,
scenePreviewImports: {} as Record<
SceneId,
{ assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string }
>,
});
const projectRef = useRef<Project | null>(null);
const openInFlightRef = useRef<Promise<void> | null>(null);
const createSceneInFlightRef = useRef<Promise<void> | null>(null);
/** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */
const projectDataEpochRef = useRef(0);
useEffect(() => {
projectRef.current = state.project;
}, [state.project]);
useEffect(() => {
const offImport = api.on(ipcChannels.project.importZipProgress, (evt) => {
const e = evt as unknown as { percent: number; stage: string; detail?: string };
setState((s) => ({
...s,
zipProgress: {
kind: 'import',
percent: e.percent,
stage: e.stage,
...(e.detail ? { detail: e.detail } : null),
},
}));
if (e.stage === 'done' || e.percent >= 100) {
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
}
});
const offExport = api.on(ipcChannels.project.exportZipProgress, (evt) => {
const e = evt as unknown as { percent: number; stage: string; detail?: string };
setState((s) => ({
...s,
zipProgress: {
kind: 'export',
percent: e.percent,
stage: e.stage,
...(e.detail ? { detail: e.detail } : null),
},
}));
if (e.stage === 'done' || e.percent >= 100) {
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
}
});
const offPreview = api.on(ipcChannels.project.scenePreviewImportProgress, (evt) => {
setState((s) => {
const nextImports = { ...s.scenePreviewImports };
nextImports[evt.sceneId] = {
assetId: evt.assetId,
phase: evt.phase,
...(evt.message ? { message: evt.message } : null),
};
return {
...s,
...(evt.project ? { project: evt.project } : null),
scenePreviewImports: nextImports,
};
});
if (evt.phase === 'done' || evt.phase === 'error') {
setTimeout(
() => {
setState((s) => {
const cur = s.scenePreviewImports[evt.sceneId];
if (cur?.assetId !== evt.assetId || cur.phase !== evt.phase) return s;
const nextImports = Object.fromEntries(
Object.entries(s.scenePreviewImports).filter(([sceneId]) => sceneId !== evt.sceneId),
) as State['scenePreviewImports'];
return { ...s, scenePreviewImports: nextImports };
});
},
evt.phase === 'done' ? 1000 : 4000,
);
}
});
return () => {
offImport();
offExport();
offPreview();
};
}, [api]);
const actions = useMemo<Actions>(() => {
const refreshProjects = async () => {
projectDataEpochRef.current += 1;
const res = await api.invoke(ipcChannels.project.list, {});
setState((s) => ({ ...s, projects: res.projects }));
};
const createProject = async (name: string) => {
const res = await api.invoke(ipcChannels.project.create, { name });
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId }));
await refreshProjects();
};
const openProject = async (id: ProjectId) => {
if (openInFlightRef.current) return openInFlightRef.current;
const job = (async () => {
setState((s) => ({ ...s, openingProjectId: id }));
try {
projectDataEpochRef.current += 1;
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
openingProjectId: null,
}));
} catch {
setState((s) => ({ ...s, openingProjectId: null }));
}
})();
openInFlightRef.current = job.finally(() => {
if (openInFlightRef.current === job) openInFlightRef.current = null;
});
return openInFlightRef.current;
};
const closeProject = async () => {
await api.invoke(ipcChannels.project.close, {});
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
await refreshProjects();
};
const createScene = async () => {
if (createSceneInFlightRef.current) return createSceneInFlightRef.current;
const p = projectRef.current;
if (!p) return;
const job = (async () => {
setState((s) => ({ ...s, creatingScene: true }));
const sceneId = randomId('scene') as SceneId;
const scene: Scene = {
id: sceneId,
title: `Новая сцена`,
description: '',
previewAssetId: null,
previewThumbAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
connections: [],
layout: { x: 0, y: 0 },
};
await api.invoke(ipcChannels.project.updateScene, {
sceneId,
patch: {
title: scene.title,
description: scene.description,
media: scene.media,
settings: scene.settings,
layout: scene.layout,
previewAssetId: scene.previewAssetId,
previewAssetType: scene.previewAssetType,
previewVideoAutostart: scene.previewVideoAutostart,
},
});
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId });
const res = await api.invoke(ipcChannels.project.get, {});
setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId }));
})();
createSceneInFlightRef.current = job.finally(() => {
if (createSceneInFlightRef.current === job) {
createSceneInFlightRef.current = null;
setState((s) => ({ ...s, creatingScene: false }));
}
});
return createSceneInFlightRef.current;
};
const selectScene = async (id: SceneId) => {
setState((s) => ({ ...s, selectedSceneId: id }));
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: id });
};
const importCampaignAudio = async () => {
const res = await api.invoke(ipcChannels.project.importCampaignAudio, {});
if (res.canceled) return;
if (res.imported.length === 0) {
onNoticeRef.current?.('campaign_audio_empty');
}
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const updateCampaignAudios = async (next: Project['campaignAudios']) => {
const res = await api.invoke(ipcChannels.project.updateCampaignAudios, { audios: next });
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const updateScene = async (
sceneId: SceneId,
patch: {
title?: string;
description?: string;
previewAssetId?: AssetId | null;
previewThumbAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean;
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: { x: number; y: number };
},
) => {
setState((s) => {
const p = s.project;
if (!p) return s;
const scene = p.scenes[sceneId];
if (!scene) return s;
const next: Scene = {
...scene,
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
...(patch.previewThumbAssetId !== undefined
? { previewThumbAssetId: patch.previewThumbAssetId }
: null),
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
...(patch.previewVideoAutostart !== undefined
? { previewVideoAutostart: patch.previewVideoAutostart }
: null),
...(patch.previewRotationDeg !== undefined
? { previewRotationDeg: patch.previewRotationDeg }
: null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
};
const scenes = { ...p.scenes, [sceneId]: next };
const project: Project = { ...p, scenes };
return { ...s, project };
});
await api.invoke(ipcChannels.project.updateScene, { sceneId, patch });
};
const updateConnections = async (sceneId: SceneId, connections: SceneId[]) => {
setState((s) => {
const p = s.project;
if (!p) return s;
const scene = p.scenes[sceneId];
if (!scene) return s;
const next: Scene = { ...scene, connections };
const scenes = { ...p.scenes, [sceneId]: next };
const project: Project = { ...p, scenes };
return { ...s, project };
});
await api.invoke(ipcChannels.project.updateConnections, { sceneId, connections });
};
const importMediaToScene = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.importMedia, { sceneId });
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const importScenePreview = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId });
setState((s) => ({ ...s, project: res.project }));
if (res.assetId !== null && res.background) {
setState((s) => ({
...s,
scenePreviewImports: {
...s.scenePreviewImports,
[sceneId]: { assetId: res.assetId, phase: 'queued' },
},
}));
}
await refreshProjects();
return { assetId: res.assetId, background: res.background };
};
const clearScenePreview = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.clearScenePreview, { sceneId });
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const updateSceneGraphNodePosition = async (nodeId: GraphNodeId, x: number, y: number) => {
setState((s) => {
const p = s.project;
if (!p) return s;
return {
...s,
project: {
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) => (n.id === nodeId ? { ...n, x, y } : n)),
},
};
});
const res = await api.invoke(ipcChannels.project.updateSceneGraphNodePosition, { nodeId, x, y });
setState((s) => ({ ...s, project: res.project }));
};
const addSceneGraphNode = async (sceneId: SceneId, x: number, y: number) => {
const res = await api.invoke(ipcChannels.project.addSceneGraphNode, { sceneId, x, y });
setState((s) => ({ ...s, project: res.project }));
};
const removeSceneGraphNode = async (nodeId: GraphNodeId) => {
const res = await api.invoke(ipcChannels.project.removeSceneGraphNode, { nodeId });
setState((s) => ({ ...s, project: res.project }));
};
const addSceneGraphEdge = async (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => {
const res = await api.invoke(ipcChannels.project.addSceneGraphEdge, {
sourceGraphNodeId,
targetGraphNodeId,
});
setState((s) => ({ ...s, project: res.project }));
};
const removeSceneGraphEdge = async (edgeId: string) => {
const res = await api.invoke(ipcChannels.project.removeSceneGraphEdge, { edgeId });
setState((s) => ({ ...s, project: res.project }));
};
const setSceneGraphNodeStart = async (graphNodeId: GraphNodeId | null) => {
const res = await api.invoke(ipcChannels.project.setSceneGraphNodeStart, { graphNodeId });
setState((s) => ({ ...s, project: res.project }));
};
const deleteScene = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.deleteScene, { sceneId });
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId ?? null,
}));
await refreshProjects();
};
const renameProject = async (name: string, fileBaseName: string) => {
const res = await api.invoke(ipcChannels.project.rename, { name, fileBaseName });
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const importProject = async () => {
const res = await api.invoke(ipcChannels.project.importZip, {});
if (res.canceled) return;
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
}));
await refreshProjects();
};
const exportProject = async (projectId: ProjectId) => {
const res = await api.invoke(ipcChannels.project.exportZip, { projectId });
if (res.canceled) return;
};
const deleteProject = async (projectId: ProjectId) => {
projectDataEpochRef.current += 1;
await api.invoke(ipcChannels.project.deleteProject, { projectId });
const listRes = await api.invoke(ipcChannels.project.list, {});
const res = await api.invoke(ipcChannels.project.get, {});
setState((s) => ({
...s,
projects: listRes.projects,
project: res.project,
selectedSceneId: res.project?.currentSceneId ?? null,
}));
};
return {
refreshProjects,
createProject,
openProject,
closeProject,
createScene,
selectScene,
importCampaignAudio,
updateCampaignAudios,
updateScene,
updateConnections,
importMediaToScene,
importScenePreview,
clearScenePreview,
updateSceneGraphNodePosition,
addSceneGraphNode,
removeSceneGraphNode,
addSceneGraphEdge,
removeSceneGraphEdge,
setSceneGraphNodeStart,
deleteScene,
renameProject,
importProject,
exportProject,
deleteProject,
};
}, [api]);
useEffect(() => {
const epoch = ++projectDataEpochRef.current;
void (async () => {
try {
const listRes = await api.invoke(ipcChannels.project.list, {});
if (projectDataEpochRef.current !== epoch) return;
if (!licenseActive) {
setState((s) => ({
...s,
projects: listRes.projects,
project: null,
selectedSceneId: null,
}));
return;
}
setState((s) => ({ ...s, projects: listRes.projects }));
const res = await api.invoke(ipcChannels.project.get, {});
if (projectDataEpochRef.current !== epoch) return;
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project?.currentSceneId ?? null,
}));
} catch {
if (projectDataEpochRef.current !== epoch) return;
if (!licenseActive) {
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
}
}
})();
}, [licenseActive, api]);
return [state, actions] as const;
}