feat(editor): DnD сцен в список, порядок списка и правки импорта
Можно перетащить изображения/видео в колонку сцен: создаются новые сцены с превью и названием из файла, с полноэкранным прогрессом и отчётом о пропущенных файлах. Добавлен ручной порядок сцен в списке (sceneListOrder) с сохранением в проект; перетаскивание карточек отделено от импорта файлов. Во время сортировки снова работает скролл колесом. Исправлен залипающий disabled у «+ Новая сцена» после создания сцены. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { Scene, SceneId } from '../types';
|
||||
|
||||
import {
|
||||
defaultSceneListOrder,
|
||||
moveSceneInListOrder,
|
||||
prependSceneListOrder,
|
||||
reconcileSceneListOrder,
|
||||
removeFromSceneListOrder,
|
||||
} from './sceneListOrder';
|
||||
|
||||
function scene(id: string): Scene {
|
||||
return {
|
||||
id: id as SceneId,
|
||||
title: id,
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
void test('defaultSceneListOrder sorts by id timestamp newest first', () => {
|
||||
const scenes = {
|
||||
scene_a_100: scene('scene_a_100'),
|
||||
scene_b_200: scene('scene_b_200'),
|
||||
scene_c_150: scene('scene_c_150'),
|
||||
} as Record<SceneId, Scene>;
|
||||
assert.deepEqual(defaultSceneListOrder(scenes), ['scene_b_200', 'scene_c_150', 'scene_a_100']);
|
||||
});
|
||||
|
||||
void test('reconcileSceneListOrder drops missing and appends new', () => {
|
||||
const scenes = {
|
||||
a: scene('a'),
|
||||
b: scene('b'),
|
||||
c: scene('c'),
|
||||
} as Record<SceneId, Scene>;
|
||||
assert.deepEqual(reconcileSceneListOrder(scenes, ['b', 'gone', 'a'] as SceneId[]), ['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
void test('moveSceneInListOrder before/after', () => {
|
||||
const order = ['a', 'b', 'c', 'd'] as SceneId[];
|
||||
assert.deepEqual(moveSceneInListOrder(order, 'd' as SceneId, 'b' as SceneId, 'before'), [
|
||||
'a',
|
||||
'd',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
assert.deepEqual(moveSceneInListOrder(order, 'a' as SceneId, 'c' as SceneId, 'after'), [
|
||||
'b',
|
||||
'c',
|
||||
'a',
|
||||
'd',
|
||||
]);
|
||||
assert.deepEqual(moveSceneInListOrder(order, 'b' as SceneId, 'b' as SceneId, 'before'), [
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
]);
|
||||
});
|
||||
|
||||
void test('prepend and remove helpers', () => {
|
||||
const order = ['a', 'b'] as SceneId[];
|
||||
assert.deepEqual(prependSceneListOrder(order, 'c' as SceneId), ['c', 'a', 'b']);
|
||||
assert.deepEqual(prependSceneListOrder(order, 'a' as SceneId), ['a', 'b']);
|
||||
assert.deepEqual(removeFromSceneListOrder(order, 'a' as SceneId), ['b']);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Scene, SceneId } from '../types';
|
||||
|
||||
function sceneCreatedAtSortKey(sceneId: string): number {
|
||||
const last = sceneId.split('_').at(-1) ?? '';
|
||||
const n = Number.parseInt(last, 16);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** Порядок по умолчанию: новее выше (как раньше по timestamp в id). */
|
||||
export function defaultSceneListOrder(scenes: Record<SceneId, Scene>): SceneId[] {
|
||||
return (Object.keys(scenes) as SceneId[]).sort(
|
||||
(a, b) => sceneCreatedAtSortKey(b) - sceneCreatedAtSortKey(a),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует порядок: убирает отсутствующие id, добавляет новые в конец
|
||||
* (новые без записи в order — по умолчанию в «хвост» относительно сохранённого порядка;
|
||||
* при создании сцены мы сами prepend'им).
|
||||
*/
|
||||
export function reconcileSceneListOrder(
|
||||
scenes: Record<SceneId, Scene>,
|
||||
order: readonly SceneId[] | null | undefined,
|
||||
): SceneId[] {
|
||||
const ids = new Set(Object.keys(scenes) as SceneId[]);
|
||||
if (ids.size === 0) return [];
|
||||
if (!order || order.length === 0) return defaultSceneListOrder(scenes);
|
||||
|
||||
const seen = new Set<SceneId>();
|
||||
const next: SceneId[] = [];
|
||||
for (const id of order) {
|
||||
if (!ids.has(id) || seen.has(id)) continue;
|
||||
next.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
for (const id of defaultSceneListOrder(scenes)) {
|
||||
if (seen.has(id)) continue;
|
||||
next.push(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function prependSceneListOrder(order: readonly SceneId[], sceneId: SceneId): SceneId[] {
|
||||
return [sceneId, ...order.filter((id) => id !== sceneId)];
|
||||
}
|
||||
|
||||
export function removeFromSceneListOrder(order: readonly SceneId[], sceneId: SceneId): SceneId[] {
|
||||
return order.filter((id) => id !== sceneId);
|
||||
}
|
||||
|
||||
export function moveSceneInListOrder(
|
||||
order: readonly SceneId[],
|
||||
draggedId: SceneId,
|
||||
targetId: SceneId,
|
||||
place: 'before' | 'after',
|
||||
): SceneId[] {
|
||||
if (draggedId === targetId) return [...order];
|
||||
if (!order.includes(draggedId) || !order.includes(targetId)) return [...order];
|
||||
const without = order.filter((id) => id !== draggedId);
|
||||
const targetIndex = without.indexOf(targetId);
|
||||
if (targetIndex < 0) return [...order];
|
||||
const insertAt = place === 'before' ? targetIndex : targetIndex + 1;
|
||||
const next = [...without];
|
||||
next.splice(insertAt, 0, draggedId);
|
||||
return next;
|
||||
}
|
||||
@@ -64,6 +64,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
|
||||
schemaVersion: 5,
|
||||
},
|
||||
scenes: {},
|
||||
sceneListOrder: [],
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
currentSceneId: null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getSideStoryComponentNodeIds,
|
||||
listSideStoryStarts,
|
||||
} from './sceneGraphLineage';
|
||||
import { reconcileSceneListOrder } from './sceneListOrder';
|
||||
import type {
|
||||
ExportedStorylineRef,
|
||||
GraphNodeId,
|
||||
@@ -248,6 +249,7 @@ export function buildPartialExportProject(
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
scenes,
|
||||
sceneListOrder: reconcileSceneListOrder(scenes, source.sceneListOrder),
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
currentSceneId: null,
|
||||
@@ -491,7 +493,11 @@ export function mergeStorylinesIntoProject(
|
||||
};
|
||||
|
||||
const outgoing = recomputeOutgoing(merged.sceneGraphNodes, merged.sceneGraphEdges);
|
||||
merged = { ...merged, scenes: applyConnectionSets(merged.scenes, outgoing) };
|
||||
merged = {
|
||||
...merged,
|
||||
scenes: applyConnectionSets(merged.scenes, outgoing),
|
||||
sceneListOrder: reconcileSceneListOrder(scenes, target.sceneListOrder),
|
||||
};
|
||||
|
||||
return {
|
||||
project: merged,
|
||||
|
||||
@@ -58,6 +58,7 @@ export const ipcChannels = {
|
||||
setSceneGraphNodeSideStoryStart: 'project.setSceneGraphNodeSideStoryStart',
|
||||
updateSideStoryLineTitle: 'project.updateSideStoryLineTitle',
|
||||
deleteScene: 'project.deleteScene',
|
||||
setSceneListOrder: 'project.setSceneListOrder',
|
||||
rename: 'project.rename',
|
||||
importZip: 'project.importZip',
|
||||
exportZip: 'project.exportZip',
|
||||
@@ -278,6 +279,10 @@ export type IpcInvokeMap = {
|
||||
req: { sceneId: SceneId };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.setSceneListOrder]: {
|
||||
req: { sceneListOrder: SceneId[] };
|
||||
res: { project: Project };
|
||||
};
|
||||
[ipcChannels.project.rename]: {
|
||||
req: { name: string; fileBaseName: string };
|
||||
res: { project: Project };
|
||||
|
||||
@@ -127,6 +127,11 @@ export type Project = {
|
||||
id: ProjectId;
|
||||
meta: ProjectMeta;
|
||||
scenes: Record<SceneId, Scene>;
|
||||
/**
|
||||
* Порядок сцен в левом списке редактора (сверху вниз).
|
||||
* Если отсутствует или устарел — нормализуется при загрузке.
|
||||
*/
|
||||
sceneListOrder: SceneId[];
|
||||
assets: Record<AssetId, MediaAsset>;
|
||||
/** Аудио кампании: играет в пульте на протяжении всей презентации, если в сцене нет своей музыки. */
|
||||
campaignAudios: SceneAudioRef[];
|
||||
|
||||
Reference in New Issue
Block a user