f270812219
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label. Co-authored-by: Cursor <cursoragent@cursor.com>
991 lines
35 KiB
TypeScript
991 lines
35 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>;
|
|
setMaterialLegend: (
|
|
materialId: MaterialId,
|
|
legend: import('../../../shared/types').MaterialLegend | null,
|
|
) => 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, selectedSceneId: null }));
|
|
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: null,
|
|
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,
|
|
traps: [],
|
|
tokens: [],
|
|
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
|
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 setMaterialLegend = async (
|
|
materialId: MaterialId,
|
|
legend: import('../../../shared/types').MaterialLegend | null,
|
|
) => {
|
|
const res = await api.invoke(ipcChannels.project.setMaterialLegend, { materialId, legend });
|
|
// Список проектов не меняется — не дергаем refreshProjects на каждое обновление легенды.
|
|
setState((s) => ({ ...s, project: res.project }));
|
|
};
|
|
|
|
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;
|
|
traps?: import('../../../shared/types').SceneTrap[];
|
|
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.traps !== undefined ? { traps: patch.traps } : null),
|
|
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
|
|
...(patch.grid !== undefined ? { grid: patch.grid } : 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,
|
|
setMaterialLegend,
|
|
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: null,
|
|
}));
|
|
} catch {
|
|
if (projectDataEpochRef.current !== epoch) return;
|
|
if (!licenseActive) {
|
|
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
|
|
}
|
|
}
|
|
})();
|
|
}, [licenseActive, api]);
|
|
|
|
return [state, actions] as const;
|
|
}
|