Files
DndGamePlayer/app/renderer/editor/state/projectState.ts
T
Ivan Fontosh d9fbecf5a7 perf: cut ControlApp re-renders and lazy-load VFX packs
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:58:16 +08:00

968 lines
34 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
import type {
NpcImportResolution,
SceneImportResolution,
StorylineImportMergeReport,
StorylineLabels,
StorylineListItem,
StorylineSelection,
} from '../../../shared/graph/storylineExportImport';
import type {
AssetId,
GraphNodeId,
MaterialId,
Project,
ProjectId,
Scene,
SceneId,
} from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi';
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
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;
sceneBatchImport: { current: number; total: number; fileName: string } | null;
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>;
createScenesFromMediaPaths: (
items: { filePath: string; title: string }[],
) => Promise<{ created: number; lastSceneId: SceneId | null }>;
selectScene: (id: SceneId) => Promise<void>;
importCampaignAudio: () => Promise<void>;
importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
upsertMaterial: (input: {
materialId?: MaterialId;
name: string;
filePath?: string;
}) => Promise<void>;
deleteMaterial: (materialId: MaterialId) => Promise<void>;
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
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>;
importMediaToSceneFromPaths: (sceneId: SceneId, filePaths: string[]) => Promise<void>;
importScenePreview: (sceneId: SceneId) => Promise<{ assetId: AssetId | null; background: boolean }>;
importScenePreviewFromPath: (
sceneId: SceneId,
filePath: string,
) => 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>;
setSceneGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => Promise<void>;
updateSideStoryLineTitle: (graphNodeId: GraphNodeId, title: string) => Promise<void>;
deleteScene: (sceneId: SceneId) => Promise<void>;
setSceneListOrder: (sceneListOrder: SceneId[]) => Promise<void>;
renameProject: (name: string, fileBaseName: string) => Promise<void>;
importProject: () => Promise<void>;
pickFoundrySource: (
mode: 'folder' | 'archive',
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
importFoundryProject: (sourcePath: string) => Promise<void>;
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
| { canceled: true }
| {
canceled: false;
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}
>;
pickImportZipFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
peekImportZipPath: (
filePath: string,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => Promise<{
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}>;
peekImportFromProject: (
sourceProjectId: ProjectId,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => Promise<{
sourceProjectId: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}>;
mergeImportZip: (
filePath: string,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
mergeImportFromProject: (
sourceProjectId: ProjectId,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
importProjectFromPath: (filePath: string) => Promise<void>;
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
exportProject: (
projectId: ProjectId,
storylineSelections: StorylineSelection[],
labels: StorylineLabels,
) => 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,
sceneBatchImport: null,
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.stage === 'error' || e.percent >= 100) {
const delay = e.stage === 'error' ? 0 : 450;
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
}
});
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.stage === 'error' || e.percent >= 100) {
const delay = e.stage === 'error' ? 0 : 450;
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
}
});
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) => {
projectDataEpochRef.current += 1;
const epoch = projectDataEpochRef.current;
openInFlightRef.current = null;
// URL ассетов зависят от открытого проекта — сбрасываем renderer-кэш.
invalidateAssetUrlCache();
const job = (async () => {
setState((s) => ({ ...s, openingProjectId: id }));
try {
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
if (projectDataEpochRef.current !== epoch) {
setState((s) => ({ ...s, openingProjectId: null }));
return;
}
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
openingProjectId: null,
}));
} catch {
if (projectDataEpochRef.current === epoch) {
setState((s) => ({ ...s, openingProjectId: null }));
}
}
})();
openInFlightRef.current = job;
void job.finally(() => {
if (openInFlightRef.current === job) openInFlightRef.current = null;
});
return job;
};
const closeProject = async () => {
projectDataEpochRef.current += 1;
openInFlightRef.current = null;
invalidateAssetUrlCache();
try {
await api.invoke(ipcChannels.project.close, {});
} finally {
setState((s) => ({
...s,
project: null,
selectedSceneId: null,
openingProjectId: null,
zipProgress: 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 }));
try {
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 }));
} finally {
setState((s) => ({ ...s, creatingScene: false }));
}
})();
createSceneInFlightRef.current = job;
void job.finally(() => {
if (createSceneInFlightRef.current === job) createSceneInFlightRef.current = null;
});
return job;
};
const createScenesFromMediaPaths = async (
items: { filePath: string; title: string }[],
): Promise<{ created: number; lastSceneId: SceneId | null }> => {
if (items.length === 0) return { created: 0, lastSceneId: null };
if (createSceneInFlightRef.current) {
await createSceneInFlightRef.current;
}
const p = projectRef.current;
if (!p) return { created: 0, lastSceneId: null };
let created = 0;
let lastSceneId: SceneId | null = null;
const job = (async () => {
setState((s) => ({
...s,
creatingScene: true,
sceneBatchImport: { current: 0, total: items.length, fileName: items[0]?.title ?? '' },
}));
try {
for (let i = 0; i < items.length; i += 1) {
const item = items[i]!;
setState((s) => ({
...s,
sceneBatchImport: {
current: i + 1,
total: items.length,
fileName: item.title,
},
}));
const sceneId = randomId('scene') as SceneId;
await api.invoke(ipcChannels.project.updateScene, {
sceneId,
patch: {
title: item.title,
description: '',
media: { videos: [], audios: [] },
settings: {
autoplayVideo: false,
autoplayAudio: true,
loopVideo: true,
loopAudio: true,
},
layout: { x: 0, y: 0 },
previewAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
},
});
const previewRes = await api.invoke(ipcChannels.project.importScenePreview, {
sceneId,
filePath: item.filePath,
});
setState((s) => {
const nextImports = { ...s.scenePreviewImports };
if (previewRes.assetId !== null && previewRes.background) {
nextImports[sceneId] = { assetId: previewRes.assetId, phase: 'queued' };
}
return {
...s,
project: previewRes.project,
scenePreviewImports: nextImports,
};
});
created += 1;
lastSceneId = sceneId;
}
if (lastSceneId) {
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: lastSceneId });
const res = await api.invoke(ipcChannels.project.get, {});
setState((s) => ({
...s,
project: res.project,
selectedSceneId: lastSceneId,
}));
}
await refreshProjects();
} finally {
setState((s) => ({
...s,
creatingScene: false,
sceneBatchImport: null,
}));
}
})();
createSceneInFlightRef.current = job;
void job.finally(() => {
if (createSceneInFlightRef.current === job) createSceneInFlightRef.current = null;
});
await job;
return { created, lastSceneId };
};
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 importCampaignAudioFromPaths = async (filePaths: string[]) => {
if (filePaths.length === 0) return;
const res = await api.invoke(ipcChannels.project.importCampaignAudio, { filePaths });
if (res.imported.length === 0) return;
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 upsertMaterial = async (input: {
materialId?: MaterialId;
name: string;
filePath?: string;
}) => {
const res = await api.invoke(ipcChannels.project.upsertMaterial, input);
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const deleteMaterial = async (materialId: MaterialId) => {
const res = await api.invoke(ipcChannels.project.deleteMaterial, { materialId });
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const setMaterialsOrder = async (materialIds: MaterialId[]) => {
const res = await api.invoke(ipcChannels.project.setMaterialsOrder, { materialIds });
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const setMaterialRotation = async (
materialId: MaterialId,
rotationDeg: 0 | 90 | 180 | 270,
) => {
const res = await api.invoke(ipcChannels.project.setMaterialRotation, {
materialId,
rotationDeg,
});
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
};
const pickMaterialImage = async () => {
const res = await api.invoke(ipcChannels.project.pickMaterialImage, {});
if (res.canceled) return null;
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
};
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 importMediaToSceneFromPaths = async (sceneId: SceneId, filePaths: string[]) => {
if (filePaths.length === 0) return;
const res = await api.invoke(ipcChannels.project.importMedia, { sceneId, filePaths });
if (res.imported.length === 0) return;
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 importScenePreviewFromPath = async (sceneId: SceneId, filePath: string) => {
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId, filePath });
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 setSceneGraphNodeSideStoryStart = async (graphNodeId: GraphNodeId) => {
const res = await api.invoke(ipcChannels.project.setSceneGraphNodeSideStoryStart, { graphNodeId });
setState((s) => ({ ...s, project: res.project }));
};
const updateSideStoryLineTitle = async (graphNodeId: GraphNodeId, title: string) => {
const res = await api.invoke(ipcChannels.project.updateSideStoryLineTitle, { graphNodeId, title });
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 setSceneListOrder = async (sceneListOrder: SceneId[]) => {
setState((s) => {
const p = s.project;
if (!p) return s;
return { ...s, project: { ...p, sceneListOrder } };
});
const res = await api.invoke(ipcChannels.project.setSceneListOrder, { sceneListOrder });
setState((s) => ({ ...s, project: res.project }));
};
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 () => {
try {
const res = await api.invoke(ipcChannels.project.importZip, {});
if (res.canceled) return;
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
}));
await refreshProjects();
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
};
const importProjectFromPath = async (filePath: string) => {
try {
const res = await api.invoke(ipcChannels.project.importZipFromPath, { filePath });
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
}));
await refreshProjects();
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
};
const pickFoundrySource = async (mode: 'folder' | 'archive') => {
return api.invoke(ipcChannels.project.pickFoundrySource, { mode });
};
const importFoundryProject = async (sourcePath: string) => {
try {
const res = await api.invoke(ipcChannels.project.importFoundry, { sourcePath });
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
}));
await refreshProjects();
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
};
const peekImportZip = async (labels: StorylineLabels, targetHasMainStart: boolean) => {
return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart });
};
const pickImportZipFile = async () => {
return api.invoke(ipcChannels.project.pickImportZipFile, {});
};
const peekImportZipPath = async (
filePath: string,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => {
return api.invoke(ipcChannels.project.peekImportZipPath, { filePath, labels, targetHasMainStart });
};
const peekImportFromProject = async (
sourceProjectId: ProjectId,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => {
return api.invoke(ipcChannels.project.peekImportFromProject, {
sourceProjectId,
labels,
targetHasMainStart,
});
};
const mergeImportZip = async (
filePath: string,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => {
const res = await api.invoke(ipcChannels.project.mergeImportZip, {
filePath,
storylineSelections,
sceneResolutions,
...(npcResolutions ? { npcResolutions } : {}),
});
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
}));
return res;
};
const mergeImportFromProject = async (
sourceProjectId: ProjectId,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => {
const res = await api.invoke(ipcChannels.project.mergeImportFromProject, {
sourceProjectId,
storylineSelections,
sceneResolutions,
...(npcResolutions ? { npcResolutions } : {}),
});
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
}));
return res;
};
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
return res.storylines;
};
const exportProject = async (
projectId: ProjectId,
storylineSelections: StorylineSelection[],
labels: StorylineLabels,
) => {
try {
const res = await api.invoke(ipcChannels.project.exportZip, {
projectId,
storylineSelections,
labels,
});
if (res.canceled) return;
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
};
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,
createScenesFromMediaPaths,
selectScene,
importCampaignAudio,
importCampaignAudioFromPaths,
updateCampaignAudios,
upsertMaterial,
deleteMaterial,
setMaterialsOrder,
setMaterialRotation,
pickMaterialImage,
updateScene,
updateConnections,
importMediaToScene,
importMediaToSceneFromPaths,
importScenePreview,
importScenePreviewFromPath,
clearScenePreview,
updateSceneGraphNodePosition,
addSceneGraphNode,
removeSceneGraphNode,
addSceneGraphEdge,
removeSceneGraphEdge,
setSceneGraphNodeStart,
setSceneGraphNodeSideStoryStart,
updateSideStoryLineTitle,
deleteScene,
setSceneListOrder,
renameProject,
importProject,
importProjectFromPath,
pickFoundrySource,
importFoundryProject,
peekImportZip,
pickImportZipFile,
peekImportZipPath,
peekImportFromProject,
mergeImportZip,
mergeImportFromProject,
getProjectStorylines,
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;
}