DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useMemo, 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(): readonly [State, Actions] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<State>({ projects: [], project: null, selectedSceneId: null });
|
||||
|
||||
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 }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const createScene = async () => {
|
||||
const p = state.project;
|
||||
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, state.project]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await actions.refreshProjects();
|
||||
const res = await api.invoke(ipcChannels.project.get, {});
|
||||
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null }));
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [state, actions] as const;
|
||||
}
|
||||
Reference in New Issue
Block a user