feat(editor): DnD сцен в список, порядок списка и правки импорта
Можно перетащить изображения/видео в колонку сцен: создаются новые сцены с превью и названием из файла, с полноэкранным прогрессом и отчётом о пропущенных файлах. Добавлен ручной порядок сцен в списке (sceneListOrder) с сохранением в проект; перетаскивание карточек отделено от импорта файлов. Во время сортировки снова работает скролл колесом. Исправлен залипающий disabled у «+ Новая сцена» после создания сцены. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user