feat(editor): DnD сцен в список, порядок списка и правки импорта

Можно перетащить изображения/видео в колонку сцен: создаются новые сцены
с превью и названием из файла, с полноэкранным прогрессом и отчётом о
пропущенных файлах.

Добавлен ручной порядок сцен в списке (sceneListOrder) с сохранением в
проект; перетаскивание карточек отделено от импорта файлов. Во время
сортировки снова работает скролл колесом.

Исправлен залипающий disabled у «+ Новая сцена» после создания сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-12 10:32:50 +08:00
parent b7e6ff6915
commit 35a6e979eb
18 changed files with 789 additions and 73 deletions
+152 -39
View File
@@ -19,6 +19,7 @@ type State = {
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,
@@ -32,6 +33,9 @@ type Actions = {
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>;
@@ -70,6 +74,7 @@ type Actions = {
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>;
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
@@ -145,6 +150,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
selectedSceneId: null,
openingProjectId: null,
creatingScene: false,
sceneBatchImport: null,
zipProgress: null,
scenePreviewImports: {} as Record<
SceneId,
@@ -268,10 +274,11 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
}
}
})();
openInFlightRef.current = job.finally(() => {
openInFlightRef.current = job;
void job.finally(() => {
if (openInFlightRef.current === job) openInFlightRef.current = null;
});
return openInFlightRef.current;
return job;
};
const closeProject = async () => {
@@ -297,46 +304,140 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
if (!p) return;
const job = (async () => {
setState((s) => ({ ...s, creatingScene: true }));
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 }));
})();
createSceneInFlightRef.current = job.finally(() => {
if (createSceneInFlightRef.current === job) {
createSceneInFlightRef.current = null;
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 createSceneInFlightRef.current;
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) => {
@@ -546,6 +647,16 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
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 }));
@@ -686,6 +797,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
openProject,
closeProject,
createScene,
createScenesFromMediaPaths,
selectScene,
importCampaignAudio,
importCampaignAudioFromPaths,
@@ -706,6 +818,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
setSceneGraphNodeSideStoryStart,
updateSideStoryLineTitle,
deleteScene,
setSceneListOrder,
renameProject,
importProject,
importProjectFromPath,