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