2fa20da94d
- Main: license service, IPC, router; закрытие окон; yauzl закрытие zip (EMFILE), zipRead тест - Editor: стабильный projectState без мигания, логотип и меню, строки UI, LayoutShell overlay - Control: ластик для всех типов эффектов, затухание/нарастание музыки при смене сцены - Сборка: vite, build/dev scripts, obfuscate-main и build-env скрипты с тестами; package.json Made-with: Cursor
333 lines
12 KiB
TypeScript
333 lines
12 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;
|
|
};
|
|
|
|
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>;
|
|
updateScene: (
|
|
sceneId: SceneId,
|
|
patch: {
|
|
title?: string;
|
|
description?: string;
|
|
previewAssetId?: 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 });
|
|
const projectRef = useRef<Project | null>(null);
|
|
useEffect(() => {
|
|
projectRef.current = state.project;
|
|
}, [state.project]);
|
|
|
|
const actions = useMemo<Actions>(() => {
|
|
const refreshProjects = async () => {
|
|
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) => {
|
|
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 }));
|
|
if (licenseActive) 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,
|
|
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 updateScene = async (
|
|
sceneId: SceneId,
|
|
patch: {
|
|
title?: string;
|
|
description?: string;
|
|
previewAssetId?: 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.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) => {
|
|
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,
|
|
updateScene,
|
|
updateConnections,
|
|
importMediaToScene,
|
|
importScenePreview,
|
|
clearScenePreview,
|
|
updateSceneGraphNodePosition,
|
|
addSceneGraphNode,
|
|
removeSceneGraphNode,
|
|
addSceneGraphEdge,
|
|
removeSceneGraphEdge,
|
|
setSceneGraphNodeStart,
|
|
deleteScene,
|
|
renameProject,
|
|
importProject,
|
|
exportProject,
|
|
deleteProject,
|
|
};
|
|
}, [api, licenseActive]);
|
|
|
|
useEffect(() => {
|
|
if (!licenseActive) {
|
|
queueMicrotask(() => {
|
|
setState({ projects: [], project: null, selectedSceneId: null });
|
|
});
|
|
return;
|
|
}
|
|
void (async () => {
|
|
const listRes = await api.invoke(ipcChannels.project.list, {});
|
|
setState((s) => ({ ...s, projects: listRes.projects }));
|
|
const res = await api.invoke(ipcChannels.project.get, {});
|
|
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null }));
|
|
})();
|
|
}, [licenseActive, api]);
|
|
|
|
return [state, actions] as const;
|
|
}
|