Files
DndGamePlayer/app/renderer/editor/state/projectState.ts
T
Ivan Fontosh d94a11d466 Редактор: превью с поворотом, проекты, безопасное сохранение zip, dev-меню
RotatedImage: размер контейнера через clientWidth/Height (не getBoundingClientRect), чтобы cover при 90°/270° работал под zoom React Flow; убраны отладочные логи.

Главное меню в dev: пункт «Вид» с DevTools (Ctrl+Shift+I без пустого application menu).

Список проектов: project.list без лицензии; список подгружается при неактивной лицензии; ProjectPicker с подсказками; listProjects пропускает битые zip.

Сохранение проектов: atomicReplace — замена zip без rm до commit; восстановление *.dnd.zip.tmp при старте; тесты.

EditorApp: блокировка UI при открытых окнах презентации и пульта; стили оверлея.
Made-with: Cursor
2026-04-24 07:04:42 +08:00

424 lines
15 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } 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;
zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null;
};
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;
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<void>;
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 function useProjectState(licenseActive: boolean): readonly [State, Actions] {
const api = getDndApi();
const [state, setState] = useState<State>({
projects: [],
project: null,
selectedSceneId: null,
zipProgress: null,
});
const projectRef = useRef<Project | 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);
}
});
return () => {
offImport();
offExport();
};
}, [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) => {
projectDataEpochRef.current += 1;
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId }));
};
const closeProject = async () => {
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
await refreshProjects();
};
const createScene = async () => {
const p = projectRef.current;
if (!p) return;
const sceneId = randomId('scene') as SceneId;
const scene: Scene = {
id: sceneId,
title: `Новая сцена`,
description: '',
previewAssetId: null,
previewThumbAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
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 }));
};
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) {
window.alert('Аудио не добавлено. Проверьте формат файла.');
}
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;
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.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 }));
await refreshProjects();
};
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;
}