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
+5
View File
@@ -544,6 +544,11 @@ async function main() {
emitSessionState(); emitSessionState();
return { project }; return { project };
}); });
registerHandler(ipcChannels.project.setSceneListOrder, async ({ sceneListOrder }) => {
const project = await projectStore.setSceneListOrder(sceneListOrder);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.rename, async ({ name, fileBaseName }) => { registerHandler(ipcChannels.project.rename, async ({ name, fileBaseName }) => {
const project = await projectStore.renameOpenProject(name, fileBaseName); const project = await projectStore.renameOpenProject(name, fileBaseName);
emitSessionState(); emitSessionState();
+34 -1
View File
@@ -8,6 +8,11 @@ import { ZipFile } from 'yazl';
import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules'; import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules';
import { canSetSideStoryStart, getSideStoryComponentNodeIds } from '../../shared/graph/sceneGraphLineage'; import { canSetSideStoryStart, getSideStoryComponentNodeIds } from '../../shared/graph/sceneGraphLineage';
import {
prependSceneListOrder,
reconcileSceneListOrder,
removeFromSceneListOrder,
} from '../../shared/graph/sceneListOrder';
import { import {
buildPartialExportProject, buildPartialExportProject,
computeGraphImportOffsetX, computeGraphImportOffsetX,
@@ -217,6 +222,7 @@ export class ZipProjectStore {
schemaVersion: PROJECT_SCHEMA_VERSION, schemaVersion: PROJECT_SCHEMA_VERSION,
}, },
scenes: {}, scenes: {},
sceneListOrder: [],
assets: {}, assets: {},
campaignAudios: [], campaignAudios: [],
currentSceneId: null, currentSceneId: null,
@@ -591,7 +597,14 @@ export class ZipProjectStore {
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
}; };
await this.updateProject((p) => ({ ...p, scenes: { ...p.scenes, [sceneId]: next } })); await this.updateProject((p) => {
const scenes = { ...p.scenes, [sceneId]: next };
const sceneListOrder =
existing != null
? reconcileSceneListOrder(scenes, p.sceneListOrder)
: prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId);
return { ...p, scenes, sceneListOrder };
});
return next; return next;
} }
@@ -638,6 +651,10 @@ export class ZipProjectStore {
return { return {
...withGraph, ...withGraph,
scenes: nextScenes, scenes: nextScenes,
sceneListOrder: removeFromSceneListOrder(
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
sceneId,
),
currentSceneId, currentSceneId,
}; };
}); });
@@ -646,6 +663,18 @@ export class ZipProjectStore {
return latest; return latest;
} }
async setSceneListOrder(sceneListOrder: SceneId[]): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
sceneListOrder: reconcileSceneListOrder(p.scenes, sceneListOrder),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async updateSceneGraphNodePosition(nodeId: GraphNodeId, x: number, y: number): Promise<Project> { async updateSceneGraphNodePosition(nodeId: GraphNodeId, x: number, y: number): Promise<Project> {
const open = this.openProject; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
@@ -1711,6 +1740,10 @@ function normalizeProject(p: Project): Project {
sceneGraphNodes, sceneGraphNodes,
sceneGraphEdges, sceneGraphEdges,
currentGraphNodeId, currentGraphNodeId,
sceneListOrder: reconcileSceneListOrder(
scenes,
(p as { sceneListOrder?: SceneId[] }).sceneListOrder,
),
}; };
} }
+53
View File
@@ -68,6 +68,17 @@
border-right: 1px solid var(--stroke); border-right: 1px solid var(--stroke);
background: var(--editor-column-bg); background: var(--editor-column-bg);
overflow: hidden; overflow: hidden;
position: relative;
transition:
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
}
.editorSidebarDragOver {
border-right-color: var(--accent);
background: var(--accent-fill-soft);
box-shadow: inset 0 0 0 2px var(--accent);
} }
.editorGraphHost { .editorGraphHost {
@@ -219,6 +230,11 @@
color: var(--text2); color: var(--text2);
} }
.noticeMessage {
white-space: pre-wrap;
line-height: 1.45;
}
.fileMenu { .fileMenu {
position: fixed; position: fixed;
min-width: 220px; min-width: 220px;
@@ -872,6 +888,36 @@
background: var(--scene-list-selected-bg); background: var(--scene-list-selected-bg);
} }
.sceneCardDragging {
opacity: 0.45;
}
.sceneCardDropBefore,
.sceneCardDropAfter {
position: relative;
}
.sceneCardDropBefore::before,
.sceneCardDropAfter::after {
content: '';
position: absolute;
left: 8px;
right: 8px;
height: 3px;
border-radius: 999px;
background: var(--accent);
z-index: 2;
pointer-events: none;
}
.sceneCardDropBefore::before {
top: 0;
}
.sceneCardDropAfter::after {
bottom: 0;
}
.sceneThumb { .sceneThumb {
height: 92px; height: 92px;
position: relative; position: relative;
@@ -886,6 +932,13 @@
overflow: hidden; overflow: hidden;
} }
.sceneThumbInner img,
.sceneThumbInner video,
.sceneThumbVideo {
-webkit-user-drag: none;
user-select: none;
}
.sceneThumbVideo { .sceneThumbVideo {
display: block; display: block;
width: 100%; width: 100%;
+240 -16
View File
@@ -27,10 +27,13 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
import { import {
filterAudioFilePaths, filterAudioFilePaths,
partitionSceneMediaDrops,
pickFirstPreviewFilePath, pickFirstPreviewFilePath,
sceneTitleFromMediaPath,
useFileDropZone, useFileDropZone,
} from './fileDrop'; } from './fileDrop';
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport'; import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport';
import { import {
buildSceneResolutionsForImport, buildSceneResolutionsForImport,
@@ -142,6 +145,57 @@ export function EditorApp() {
); );
const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice }); const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice });
const renameFromPickerRef = useRef(false); const renameFromPickerRef = useRef(false);
/** Синхронно на dragStart, чтобы файловый drop колонки не успел сработать. */
const draggingListSceneIdRef = useRef<SceneId | null>(null);
const showSceneDropSkippedNotice = useCallback(
(rejected: { name: string; reason: 'unsupported' | 'no_path' }[]) => {
if (rejected.length === 0) return;
const reasonText = (reason: 'unsupported' | 'no_path') =>
reason === 'no_path' ? t('scenes.dropSkippedNoPath') : t('scenes.dropSkippedUnsupported');
const lines = rejected.map((r) => `${r.name}${reasonText(r.reason)}`);
setAppNotice({
title: t('scenes.dropSkippedTitle'),
message: `${t('scenes.dropSkippedIntro')}\n${lines.join('\n')}`,
});
},
[t],
);
const onScenesColumnDrop = useCallback(
(entries: { path: string; name: string }[]) => {
if (!state.project || state.creatingScene || state.sceneBatchImport) return;
const { accepted, rejected } = partitionSceneMediaDrops(entries);
if (accepted.length === 0) {
showSceneDropSkippedNotice(rejected);
return;
}
void (async () => {
try {
await actions.createScenesFromMediaPaths(
accepted.map((entry) => ({
filePath: entry.path,
title: sceneTitleFromMediaPath(entry.path),
})),
);
showSceneDropSkippedNotice(rejected);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
})();
},
[actions, showSceneDropSkippedNotice, state.creatingScene, state.project, state.sceneBatchImport, t],
);
const scenesColumnDrop = useFileDropZone({
disabled: !state.project || state.creatingScene || state.sceneBatchImport !== null,
isBlocked: () => draggingListSceneIdRef.current !== null,
onDropEntries: onScenesColumnDrop,
});
const sceneCardById = useStableSceneCardById(state.project); const sceneCardById = useStableSceneCardById(state.project);
const graphUi = useMemo<SceneGraphUiStrings>( const graphUi = useMemo<SceneGraphUiStrings>(
() => ({ () => ({
@@ -177,6 +231,25 @@ export function EditorApp() {
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null); const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null); const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null);
const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null); const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
const [sceneListDrop, setSceneListDrop] = useState<{
targetId: SceneId;
place: 'before' | 'after';
} | null>(null);
const [draggingListSceneId, setDraggingListSceneId] = useState<SceneId | null>(null);
const sceneListScrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!draggingListSceneId) return;
const onWheel = (e: WheelEvent) => {
const el = sceneListScrollRef.current;
if (!el) return;
// Во время HTML5-drag браузер глотает обычный scroll — крутим вручную.
e.preventDefault();
el.scrollTop += e.deltaY;
};
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
return () => window.removeEventListener('wheel', onWheel, { capture: true });
}, [draggingListSceneId]);
useEffect(() => { useEffect(() => {
setSelectedGraphNodeId(null); setSelectedGraphNodeId(null);
@@ -192,13 +265,10 @@ export function EditorApp() {
const scenes = useMemo<SceneCard[]>(() => { const scenes = useMemo<SceneCard[]>(() => {
const p = state.project; const p = state.project;
if (!p) return []; if (!p) return [];
const createdAtSortKey = (sceneId: string): number => { const order = reconcileSceneListOrder(p.scenes, p.sceneListOrder);
// sceneId создаётся как `${prefix}_${rand}_${Date.now().toString(16)}` return order
const last = sceneId.split('_').at(-1) ?? ''; .map((id) => p.scenes[id])
const n = Number.parseInt(last, 16); .filter((s): s is NonNullable<typeof s> => Boolean(s))
return Number.isFinite(n) ? n : 0;
};
return Object.values(p.scenes)
.map((s) => ({ .map((s) => ({
id: s.id, id: s.id,
title: s.title, title: s.title,
@@ -208,10 +278,11 @@ export function EditorApp() {
previewAssetType: s.previewAssetType, previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart, previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg, previewRotationDeg: s.previewRotationDeg,
})) }));
.sort((a, b) => createdAtSortKey(b.id) - createdAtSortKey(a.id));
}, [state.project, state.selectedSceneId]); }, [state.project, state.selectedSceneId]);
const sceneListReorderEnabled = query.trim().length === 0;
const filtered = useMemo( const filtered = useMemo(
() => scenes.filter((s) => s.title.toLowerCase().includes(query.trim().toLowerCase())), () => scenes.filter((s) => s.title.toLowerCase().includes(query.trim().toLowerCase())),
[query, scenes], [query, scenes],
@@ -532,6 +603,51 @@ export function EditorApp() {
document.body, document.body,
) )
: null} : null}
{state.sceneBatchImport
? createPortal(
<div className={styles.editorLockOverlay} role="dialog" aria-label={t('scenes.batchTitle')}>
<div className={styles.progressModal}>
<div className={styles.progressTitle}>{t('scenes.batchTitle')}</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{
width: `${String(
Math.max(
0,
Math.min(
100,
Math.round(
(state.sceneBatchImport.current / Math.max(1, state.sceneBatchImport.total)) *
100,
),
),
),
)}%`,
}}
/>
</div>
<div className={styles.progressMeta}>
<div>
{t('scenes.batchProgress')
.replace('{current}', String(state.sceneBatchImport.current))
.replace('{total}', String(state.sceneBatchImport.total))}
{state.sceneBatchImport.fileName
? `: ${state.sceneBatchImport.fileName}`
: ''}
</div>
<div>
{Math.round(
(state.sceneBatchImport.current / Math.max(1, state.sceneBatchImport.total)) * 100,
)}
%
</div>
</div>
</div>
</div>,
document.body,
)
: null}
<LayoutShell <LayoutShell
bodyOverlay={bodyOverlay} bodyOverlay={bodyOverlay}
topBar={ topBar={
@@ -658,31 +774,87 @@ export function EditorApp() {
</div> </div>
} }
left={ left={
<div className={styles.editorSidebar}> <div
className={[
styles.editorSidebar,
state.project && scenesColumnDrop.dragOver ? styles.editorSidebarDragOver : '',
].join(' ')}
onDragEnter={state.project ? scenesColumnDrop.onDragEnter : undefined}
onDragLeave={state.project ? scenesColumnDrop.onDragLeave : undefined}
onDragOver={state.project ? scenesColumnDrop.onDragOver : undefined}
onDrop={state.project ? scenesColumnDrop.onDrop : undefined}
>
{state.project && scenesColumnDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('scenes.dropHint')}</div>
) : null}
{state.project ? ( {state.project ? (
<> <>
<div className={styles.gridTools}> <div className={styles.gridTools}>
<Input value={query} onChange={setQuery} placeholder={t('scenes.search')} /> <Input value={query} onChange={setQuery} placeholder={t('scenes.search')} />
<Button <Button
variant="primary" variant="primary"
disabled={state.creatingScene} disabled={state.creatingScene || state.sceneBatchImport !== null}
onClick={() => void actions.createScene()} onClick={() => void actions.createScene()}
> >
{t('scenes.new')} {t('scenes.new')}
</Button> </Button>
</div> </div>
<div className={styles.spacer14} /> <div className={styles.spacer14} />
<div className={styles.sidebarScroll}> <div className={styles.sidebarScroll} ref={sceneListScrollRef}>
<div className={styles.sceneListGrid}> <div className={styles.sceneListGrid}>
{filtered.map((s) => ( {filtered.map((s) => (
<SceneListCard <SceneListCard
key={s.id} key={s.id}
scene={s} scene={s}
reorderEnabled={sceneListReorderEnabled}
listDragActive={draggingListSceneId !== null}
dropPlace={
sceneListDrop?.targetId === s.id ? sceneListDrop.place : null
}
isDragging={draggingListSceneId === s.id}
onSelect={() => { onSelect={() => {
setSelectedGraphNodeId(null); setSelectedGraphNodeId(null);
void actions.selectScene(s.id); void actions.selectScene(s.id);
}} }}
onDeleteScene={(id) => void actions.deleteScene(id)} onDeleteScene={(id) => void actions.deleteScene(id)}
onDragListStart={(id) => {
draggingListSceneIdRef.current = id;
setDraggingListSceneId(id);
}}
onDragListEnd={() => {
draggingListSceneIdRef.current = null;
setDraggingListSceneId(null);
setSceneListDrop(null);
}}
onDragListOver={(targetId, place) => {
if (!sceneListReorderEnabled || !draggingListSceneIdRef.current) return;
if (draggingListSceneIdRef.current === targetId) {
setSceneListDrop(null);
return;
}
setSceneListDrop((cur) =>
cur?.targetId === targetId && cur.place === place
? cur
: { targetId, place },
);
}}
onDropListReorder={(draggedId, targetId, place) => {
if (!sceneListReorderEnabled || !state.project) {
draggingListSceneIdRef.current = null;
setSceneListDrop(null);
setDraggingListSceneId(null);
return;
}
const order = reconcileSceneListOrder(
state.project.scenes,
state.project.sceneListOrder,
);
const next = moveSceneInListOrder(order, draggedId, targetId, place);
draggingListSceneIdRef.current = null;
setSceneListDrop(null);
setDraggingListSceneId(null);
void actions.setSceneListOrder(next);
}}
/> />
))} ))}
</div> </div>
@@ -1466,7 +1638,7 @@ function SimpleMessageModal({ open, title, message, onClose }: SimpleMessageModa
</button> </button>
</div> </div>
<div className={styles.fieldGrid}> <div className={styles.fieldGrid}>
<div className={styles.muted}>{message}</div> <div className={[styles.muted, styles.noticeMessage].join(' ')}>{message}</div>
</div> </div>
<div className={styles.modalFooter}> <div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.close')}</Button> <Button onClick={onClose}>{t('common.close')}</Button>
@@ -2275,11 +2447,31 @@ function SceneInspector({
type SceneListCardProps = { type SceneListCardProps = {
scene: SceneCard; scene: SceneCard;
reorderEnabled: boolean;
listDragActive: boolean;
dropPlace: 'before' | 'after' | null;
isDragging: boolean;
onSelect: () => void; onSelect: () => void;
onDeleteScene: (sceneId: SceneId) => void; onDeleteScene: (sceneId: SceneId) => void;
onDragListStart: (sceneId: SceneId) => void;
onDragListEnd: () => void;
onDragListOver: (targetId: SceneId, place: 'before' | 'after') => void;
onDropListReorder: (draggedId: SceneId, targetId: SceneId, place: 'before' | 'after') => void;
}; };
function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) { function SceneListCard({
scene,
reorderEnabled,
listDragActive,
dropPlace,
isDragging,
onSelect,
onDeleteScene,
onDragListStart,
onDragListEnd,
onDragListOver,
onDropListReorder,
}: SceneListCardProps) {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const thumbUrl = useAssetUrl(scene.previewThumbAssetId); const thumbUrl = useAssetUrl(scene.previewThumbAssetId);
const previewUrl = useAssetUrl(scene.previewAssetId); const previewUrl = useAssetUrl(scene.previewAssetId);
@@ -2305,14 +2497,45 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
}; };
}, [menu]); }, [menu]);
const cardClass = [styles.sceneCard, scene.active ? styles.sceneCardActive : ''].filter(Boolean).join(' '); const cardClass = [
styles.sceneCard,
scene.active ? styles.sceneCardActive : '',
isDragging ? styles.sceneCardDragging : '',
dropPlace === 'before' ? styles.sceneCardDropBefore : '',
dropPlace === 'after' ? styles.sceneCardDropAfter : '',
]
.filter(Boolean)
.join(' ');
return ( return (
<div <div
draggable draggable
className={cardClass} className={cardClass}
onDragStart={(e) => { onDragStart={(e) => {
// Сразу блокируем файловый drop колонки (до React re-render).
onDragListStart(scene.id);
e.dataTransfer.setData(DND_SCENE_ID_MIME, scene.id); e.dataTransfer.setData(DND_SCENE_ID_MIME, scene.id);
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = reorderEnabled ? 'copyMove' : 'copy';
}}
onDragEnd={() => onDragListEnd()}
onDragOver={(e) => {
if (!reorderEnabled || !listDragActive) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
onDragListOver(scene.id, place);
}}
onDrop={(e) => {
if (!reorderEnabled || !listDragActive) return;
const draggedId = e.dataTransfer.getData(DND_SCENE_ID_MIME);
if (!draggedId) return;
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
onDropListReorder(draggedId as SceneId, scene.id, place);
}} }}
onClick={onSelect} onClick={onSelect}
role="button" role="button"
@@ -2349,6 +2572,7 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
muted muted
playsInline playsInline
preload="metadata" preload="metadata"
draggable={false}
className={styles.sceneThumbVideo} className={styles.sceneThumbVideo}
onLoadedData={(e) => { onLoadedData={(e) => {
const v = e.currentTarget; const v = e.currentTarget;
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isPreviewMediaPath,
partitionSceneMediaDrops,
sceneTitleFromMediaPath,
} from './fileDrop';
void test('sceneTitleFromMediaPath strips extension and path', () => {
assert.equal(sceneTitleFromMediaPath('C:\\media\\Forest Gate.png'), 'Forest Gate');
assert.equal(sceneTitleFromMediaPath('/tmp/battle.mp4'), 'battle');
assert.equal(sceneTitleFromMediaPath('onlyname'), 'onlyname');
});
void test('isPreviewMediaPath accepts images and videos', () => {
assert.equal(isPreviewMediaPath('a.png'), true);
assert.equal(isPreviewMediaPath('a.JPG'), true);
assert.equal(isPreviewMediaPath('a.webm'), true);
assert.equal(isPreviewMediaPath('a.mp3'), false);
assert.equal(isPreviewMediaPath('a.pdf'), false);
});
void test('partitionSceneMediaDrops keeps valid and rejects others', () => {
const { accepted, rejected } = partitionSceneMediaDrops([
{ path: 'D:\\a\\one.jpg', name: 'one.jpg' },
{ path: 'D:\\a\\two.mp3', name: 'two.mp3' },
{ path: '', name: 'ghost.png' },
{ path: 'D:\\a\\three.mov', name: 'three.mov' },
]);
assert.deepEqual(
accepted.map((x) => x.name),
['one.jpg', 'three.mov'],
);
assert.deepEqual(rejected, [
{ name: 'two.mp3', reason: 'unsupported' },
{ name: 'ghost.png', reason: 'no_path' },
]);
});
+81 -15
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState, type DragEvent } from 'react'; import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
@@ -20,13 +20,26 @@ function fileExtension(filePath: string): string {
return dot >= 0 ? filePath.slice(dot).toLowerCase() : ''; return dot >= 0 ? filePath.slice(dot).toLowerCase() : '';
} }
export type DroppedFileEntry = { path: string; name: string };
export type SceneMediaDropRejectReason = 'unsupported' | 'no_path';
export type SceneMediaDropRejected = { name: string; reason: SceneMediaDropRejectReason };
export function getDroppedFilePaths(e: DragEvent): string[] { export function getDroppedFilePaths(e: DragEvent): string[] {
return getDroppedFileEntries(e)
.map((entry) => entry.path)
.filter((path) => path.length > 0);
}
export function getDroppedFileEntries(e: DragEvent): DroppedFileEntry[] {
const files = e.dataTransfer?.files; const files = e.dataTransfer?.files;
if (!files?.length) return []; if (!files?.length) return [];
const getPathForFile = getDndApi().getPathForFile; const getPathForFile = getDndApi().getPathForFile;
return Array.from(files) return Array.from(files).map((file) => ({
.map((file) => getPathForFile(file)) path: getPathForFile(file),
.filter((path) => path.length > 0); name: file.name || 'file',
}));
} }
export function filterAudioFilePaths(paths: string[]): string[] { export function filterAudioFilePaths(paths: string[]): string[] {
@@ -40,69 +53,122 @@ export function pickFirstPreviewFilePath(paths: string[]): string | null {
return null; return null;
} }
export function isPreviewMediaPath(filePath: string): boolean {
return PREVIEW_EXTENSIONS.has(fileExtension(filePath));
}
export function sceneTitleFromMediaPath(filePath: string): string {
const base = filePath.split(/[/\\]/).pop() ?? filePath;
const dot = base.lastIndexOf('.');
const title = (dot > 0 ? base.slice(0, dot) : base).trim();
return title.length > 0 ? title : 'Новая сцена';
}
export function partitionSceneMediaDrops(entries: DroppedFileEntry[]): {
accepted: DroppedFileEntry[];
rejected: SceneMediaDropRejected[];
} {
const accepted: DroppedFileEntry[] = [];
const rejected: SceneMediaDropRejected[] = [];
for (const entry of entries) {
if (!entry.path) {
rejected.push({ name: entry.name, reason: 'no_path' });
continue;
}
if (!isPreviewMediaPath(entry.path)) {
rejected.push({ name: entry.name, reason: 'unsupported' });
continue;
}
accepted.push(entry);
}
return { accepted, rejected };
}
function dragHasFiles(e: DragEvent): boolean { function dragHasFiles(e: DragEvent): boolean {
return Array.from(e.dataTransfer?.types ?? []).includes('Files'); const types = Array.from(e.dataTransfer?.types ?? []);
// Внутренний drag карточки сцены (в т.ч. с превью-картинкой) не должен считаться файловым.
if (types.some((t) => t === 'application/x-dnd-scene-id')) return false;
return types.includes('Files');
} }
type UseFileDropZoneOptions = { type UseFileDropZoneOptions = {
disabled?: boolean; disabled?: boolean;
onDropPaths: (paths: string[]) => void; /** Синхронная блокировка (например, идёт reorder списка сцен). */
isBlocked?: () => boolean;
onDropPaths?: (paths: string[]) => void;
onDropEntries?: (entries: DroppedFileEntry[]) => void;
filterPaths?: (paths: string[]) => string[]; filterPaths?: (paths: string[]) => string[];
}; };
export function useFileDropZone({ export function useFileDropZone({
disabled = false, disabled = false,
isBlocked,
onDropPaths, onDropPaths,
onDropEntries,
filterPaths, filterPaths,
}: UseFileDropZoneOptions) { }: UseFileDropZoneOptions) {
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const depthRef = useRef(0); const depthRef = useRef(0);
const blocked = useCallback(() => disabled || Boolean(isBlocked?.()), [disabled, isBlocked]);
useEffect(() => {
if (!blocked()) return;
depthRef.current = 0;
setDragOver(false);
}, [blocked]);
const onDragEnter = useCallback( const onDragEnter = useCallback(
(e: DragEvent) => { (e: DragEvent) => {
if (disabled || !dragHasFiles(e)) return; if (blocked() || !dragHasFiles(e)) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
depthRef.current += 1; depthRef.current += 1;
setDragOver(true); setDragOver(true);
}, },
[disabled], [blocked],
); );
const onDragLeave = useCallback( const onDragLeave = useCallback(
(e: DragEvent) => { (e: DragEvent) => {
if (disabled) return; if (blocked()) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
depthRef.current = Math.max(0, depthRef.current - 1); depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) setDragOver(false); if (depthRef.current === 0) setDragOver(false);
}, },
[disabled], [blocked],
); );
const onDragOver = useCallback( const onDragOver = useCallback(
(e: DragEvent) => { (e: DragEvent) => {
if (disabled || !dragHasFiles(e)) return; if (blocked() || !dragHasFiles(e)) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
}, },
[disabled], [blocked],
); );
const onDrop = useCallback( const onDrop = useCallback(
(e: DragEvent) => { (e: DragEvent) => {
if (disabled || !dragHasFiles(e)) return; if (blocked() || !dragHasFiles(e)) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
depthRef.current = 0; depthRef.current = 0;
setDragOver(false); setDragOver(false);
if (onDropEntries) {
const entries = getDroppedFileEntries(e);
if (entries.length === 0) return;
onDropEntries(entries);
return;
}
const raw = getDroppedFilePaths(e); const raw = getDroppedFilePaths(e);
const paths = filterPaths ? filterPaths(raw) : raw; const paths = filterPaths ? filterPaths(raw) : raw;
if (paths.length === 0) return; if (paths.length === 0) return;
onDropPaths(paths); onDropPaths?.(paths);
}, },
[disabled, filterPaths, onDropPaths], [blocked, filterPaths, onDropEntries, onDropPaths],
); );
return { dragOver, onDragEnter, onDragLeave, onDragOver, onDrop }; return { dragOver, onDragEnter, onDragLeave, onDragOver, onDrop };
@@ -19,6 +19,7 @@ function minimalProject(overrides: Partial<Project>): Project {
schemaVersion: 1 as unknown as Project['meta']['schemaVersion'], schemaVersion: 1 as unknown as Project['meta']['schemaVersion'],
}, },
scenes: {}, scenes: {},
sceneListOrder: [],
assets: {}, assets: {},
campaignAudios: [], campaignAudios: [],
currentSceneId: null, currentSceneId: null,
@@ -233,6 +233,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scenes.search': 'Поиск сцен…', 'scenes.search': 'Поиск сцен…',
'scenes.new': '+ Новая сцена', 'scenes.new': '+ Новая сцена',
'scenes.dropHint': 'Перетащите изображения или видео',
'scenes.batchTitle': 'Создание сцен',
'scenes.batchProgress': 'Сцена {current} из {total}',
'scenes.dropSkippedTitle': 'Часть файлов не добавлена',
'scenes.dropSkippedIntro': 'Эти файлы пропущены:',
'scenes.dropSkippedUnsupported': 'неподдерживаемый формат',
'scenes.dropSkippedNoPath': 'не удалось получить путь к файлу',
'scenes.inspectorGame': 'Свойства игры', 'scenes.inspectorGame': 'Свойства игры',
'scenes.inspectorScene': 'Свойства сцены', 'scenes.inspectorScene': 'Свойства сцены',
'scenes.selectHint': 'Выберите сцену слева, чтобы редактировать её свойства.', 'scenes.selectHint': 'Выберите сцену слева, чтобы редактировать её свойства.',
@@ -610,6 +617,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scenes.search': 'Search scenes…', 'scenes.search': 'Search scenes…',
'scenes.new': '+ New scene', 'scenes.new': '+ New scene',
'scenes.dropHint': 'Drop images or videos',
'scenes.batchTitle': 'Creating scenes',
'scenes.batchProgress': 'Scene {current} of {total}',
'scenes.dropSkippedTitle': 'Some files were not added',
'scenes.dropSkippedIntro': 'These files were skipped:',
'scenes.dropSkippedUnsupported': 'unsupported format',
'scenes.dropSkippedNoPath': 'could not resolve file path',
'scenes.inspectorGame': 'Game properties', 'scenes.inspectorGame': 'Game properties',
'scenes.inspectorScene': 'Scene properties', 'scenes.inspectorScene': 'Scene properties',
'scenes.selectHint': 'Select a scene on the left to edit its properties.', 'scenes.selectHint': 'Select a scene on the left to edit its properties.',
@@ -23,6 +23,13 @@ void test('projectState: list/get after delete invalidates in-flight initial loa
assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/); assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/);
}); });
void test('projectState: createScene clears creatingScene and in-flight ref', () => {
const src = fs.readFileSync(path.join(here, 'projectState.ts'), 'utf8');
assert.match(src, /createSceneInFlightRef\.current = job;/);
assert.match(src, /creatingScene: false/);
assert.doesNotMatch(src, /createSceneInFlightRef\.current = job\.finally/);
});
void test('ipc router: project.list does not require license', () => { void test('ipc router: project.list does not require license', () => {
const routerSrc = fs.readFileSync(path.join(here, '..', '..', '..', 'main', 'ipc', 'router.ts'), 'utf8'); const routerSrc = fs.readFileSync(path.join(here, '..', '..', '..', 'main', 'ipc', 'router.ts'), 'utf8');
assert.match(routerSrc, /if \(channel === ipcChannels\.project\.list\) return false/); assert.match(routerSrc, /if \(channel === ipcChannels\.project\.list\) return false/);
+152 -39
View File
@@ -19,6 +19,7 @@ type State = {
selectedSceneId: SceneId | null; selectedSceneId: SceneId | null;
openingProjectId: ProjectId | null; openingProjectId: ProjectId | null;
creatingScene: boolean; creatingScene: boolean;
sceneBatchImport: { current: number; total: number; fileName: string } | null;
zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null; zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null;
scenePreviewImports: Record< scenePreviewImports: Record<
SceneId, SceneId,
@@ -32,6 +33,9 @@ type Actions = {
openProject: (id: ProjectId) => Promise<void>; openProject: (id: ProjectId) => Promise<void>;
closeProject: () => Promise<void>; closeProject: () => Promise<void>;
createScene: () => Promise<void>; createScene: () => Promise<void>;
createScenesFromMediaPaths: (
items: { filePath: string; title: string }[],
) => Promise<{ created: number; lastSceneId: SceneId | null }>;
selectScene: (id: SceneId) => Promise<void>; selectScene: (id: SceneId) => Promise<void>;
importCampaignAudio: () => Promise<void>; importCampaignAudio: () => Promise<void>;
importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>; importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
@@ -70,6 +74,7 @@ type Actions = {
setSceneGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => Promise<void>; setSceneGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => Promise<void>;
updateSideStoryLineTitle: (graphNodeId: GraphNodeId, title: string) => Promise<void>; updateSideStoryLineTitle: (graphNodeId: GraphNodeId, title: string) => Promise<void>;
deleteScene: (sceneId: SceneId) => Promise<void>; deleteScene: (sceneId: SceneId) => Promise<void>;
setSceneListOrder: (sceneListOrder: SceneId[]) => Promise<void>;
renameProject: (name: string, fileBaseName: string) => Promise<void>; renameProject: (name: string, fileBaseName: string) => Promise<void>;
importProject: () => Promise<void>; importProject: () => Promise<void>;
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise< peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
@@ -145,6 +150,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
selectedSceneId: null, selectedSceneId: null,
openingProjectId: null, openingProjectId: null,
creatingScene: false, creatingScene: false,
sceneBatchImport: null,
zipProgress: null, zipProgress: null,
scenePreviewImports: {} as Record< scenePreviewImports: {} as Record<
SceneId, 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; if (openInFlightRef.current === job) openInFlightRef.current = null;
}); });
return openInFlightRef.current; return job;
}; };
const closeProject = async () => { const closeProject = async () => {
@@ -297,46 +304,140 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
if (!p) return; if (!p) return;
const job = (async () => { const job = (async () => {
setState((s) => ({ ...s, creatingScene: true })); setState((s) => ({ ...s, creatingScene: true }));
const sceneId = randomId('scene') as SceneId; try {
const scene: Scene = { const sceneId = randomId('scene') as SceneId;
id: sceneId, const scene: Scene = {
title: `Новая сцена`, id: sceneId,
description: '', title: `Новая сцена`,
previewAssetId: null, description: '',
previewThumbAssetId: null, previewAssetId: null,
previewAssetType: null, previewThumbAssetId: null,
previewVideoAutostart: false, previewAssetType: null,
previewRotationDeg: 0, previewVideoAutostart: false,
darkenScene: false, previewRotationDeg: 0,
media: { videos: [], audios: [] }, darkenScene: false,
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, media: { videos: [], audios: [] },
connections: [], settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
layout: { x: 0, y: 0 }, connections: [],
}; layout: { x: 0, y: 0 },
await api.invoke(ipcChannels.project.updateScene, { };
sceneId, await api.invoke(ipcChannels.project.updateScene, {
patch: { sceneId,
title: scene.title, patch: {
description: scene.description, title: scene.title,
media: scene.media, description: scene.description,
settings: scene.settings, media: scene.media,
layout: scene.layout, settings: scene.settings,
previewAssetId: scene.previewAssetId, layout: scene.layout,
previewAssetType: scene.previewAssetType, previewAssetId: scene.previewAssetId,
previewVideoAutostart: scene.previewVideoAutostart, previewAssetType: scene.previewAssetType,
}, previewVideoAutostart: scene.previewVideoAutostart,
}); },
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId }); });
const res = await api.invoke(ipcChannels.project.get, {}); await api.invoke(ipcChannels.project.setCurrentScene, { sceneId });
setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId })); const res = await api.invoke(ipcChannels.project.get, {});
})(); setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId }));
createSceneInFlightRef.current = job.finally(() => { } finally {
if (createSceneInFlightRef.current === job) {
createSceneInFlightRef.current = null;
setState((s) => ({ ...s, creatingScene: false })); 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) => { const selectScene = async (id: SceneId) => {
@@ -546,6 +647,16 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
await refreshProjects(); 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 renameProject = async (name: string, fileBaseName: string) => {
const res = await api.invoke(ipcChannels.project.rename, { name, fileBaseName }); const res = await api.invoke(ipcChannels.project.rename, { name, fileBaseName });
setState((s) => ({ ...s, project: res.project })); setState((s) => ({ ...s, project: res.project }));
@@ -686,6 +797,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
openProject, openProject,
closeProject, closeProject,
createScene, createScene,
createScenesFromMediaPaths,
selectScene, selectScene,
importCampaignAudio, importCampaignAudio,
importCampaignAudioFromPaths, importCampaignAudioFromPaths,
@@ -706,6 +818,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
setSceneGraphNodeSideStoryStart, setSceneGraphNodeSideStoryStart,
updateSideStoryLineTitle, updateSideStoryLineTitle,
deleteScene, deleteScene,
setSceneListOrder,
renameProject, renameProject,
importProject, importProject,
importProjectFromPath, importProjectFromPath,
+1
View File
@@ -101,6 +101,7 @@ export function RotatedImage({
src={url} src={url}
loading={loading} loading={loading}
decoding={decoding} decoding={decoding}
draggable={false}
className={styles.img} className={styles.img}
onLoad={(e) => { onLoad={(e) => {
const el = e.currentTarget; const el = e.currentTarget;
+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, schemaVersion: 5,
}, },
scenes: {}, scenes: {},
sceneListOrder: [],
assets: {}, assets: {},
campaignAudios: [], campaignAudios: [],
currentSceneId: null, currentSceneId: null,
+7 -1
View File
@@ -3,6 +3,7 @@ import {
getSideStoryComponentNodeIds, getSideStoryComponentNodeIds,
listSideStoryStarts, listSideStoryStarts,
} from './sceneGraphLineage'; } from './sceneGraphLineage';
import { reconcileSceneListOrder } from './sceneListOrder';
import type { import type {
ExportedStorylineRef, ExportedStorylineRef,
GraphNodeId, GraphNodeId,
@@ -248,6 +249,7 @@ export function buildPartialExportProject(
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}, },
scenes, scenes,
sceneListOrder: reconcileSceneListOrder(scenes, source.sceneListOrder),
sceneGraphNodes, sceneGraphNodes,
sceneGraphEdges, sceneGraphEdges,
currentSceneId: null, currentSceneId: null,
@@ -491,7 +493,11 @@ export function mergeStorylinesIntoProject(
}; };
const outgoing = recomputeOutgoing(merged.sceneGraphNodes, merged.sceneGraphEdges); 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 { return {
project: merged, project: merged,
+5
View File
@@ -58,6 +58,7 @@ export const ipcChannels = {
setSceneGraphNodeSideStoryStart: 'project.setSceneGraphNodeSideStoryStart', setSceneGraphNodeSideStoryStart: 'project.setSceneGraphNodeSideStoryStart',
updateSideStoryLineTitle: 'project.updateSideStoryLineTitle', updateSideStoryLineTitle: 'project.updateSideStoryLineTitle',
deleteScene: 'project.deleteScene', deleteScene: 'project.deleteScene',
setSceneListOrder: 'project.setSceneListOrder',
rename: 'project.rename', rename: 'project.rename',
importZip: 'project.importZip', importZip: 'project.importZip',
exportZip: 'project.exportZip', exportZip: 'project.exportZip',
@@ -278,6 +279,10 @@ export type IpcInvokeMap = {
req: { sceneId: SceneId }; req: { sceneId: SceneId };
res: { project: Project }; res: { project: Project };
}; };
[ipcChannels.project.setSceneListOrder]: {
req: { sceneListOrder: SceneId[] };
res: { project: Project };
};
[ipcChannels.project.rename]: { [ipcChannels.project.rename]: {
req: { name: string; fileBaseName: string }; req: { name: string; fileBaseName: string };
res: { project: Project }; res: { project: Project };
+5
View File
@@ -127,6 +127,11 @@ export type Project = {
id: ProjectId; id: ProjectId;
meta: ProjectMeta; meta: ProjectMeta;
scenes: Record<SceneId, Scene>; scenes: Record<SceneId, Scene>;
/**
* Порядок сцен в левом списке редактора (сверху вниз).
* Если отсутствует или устарел — нормализуется при загрузке.
*/
sceneListOrder: SceneId[];
assets: Record<AssetId, MediaAsset>; assets: Record<AssetId, MediaAsset>;
/** Аудио кампании: играет в пульте на протяжении всей презентации, если в сцене нет своей музыки. */ /** Аудио кампании: играет в пульте на протяжении всей презентации, если в сцене нет своей музыки. */
campaignAudios: SceneAudioRef[]; campaignAudios: SceneAudioRef[];
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate", "build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit", "typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs", "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
"format": "prettier . --check", "format": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",