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
+77
View File
@@ -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']);
});
+66
View File
@@ -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,
+7 -1
View File
@@ -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,