feat(editor): побочные сюжетные линии и импорт/экспорт линий

Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на
пульте: визуальное выделение компонент графа, запрет недопустимых связей
между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий.

Реализован partial export/import сюжетных линий:
- экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json;
- импорт в открытый проект с выбором линий, разрешением конфликтов названий
  сцен (одна модалка на операцию) и отчётом о результате;
- новое окно источника импорта: «Из проекта» (dropdown, без текущего) или
  «Из файла»; на главном экране — только полный импорт из файла.

Исправлены гонки при открытии/закрытии проекта (сериализация open/close в
main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и
старт Electron в dev после готовности Vite.

Добавлены unit-тесты для lineage, export/import и контрактов zipStore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-09 14:47:28 +08:00
parent de9190959c
commit c8ab9dd567
26 changed files with 3507 additions and 266 deletions
+114 -12
View File
@@ -517,6 +517,16 @@ async function main() {
emitSessionState(); emitSessionState();
return { project }; return { project };
}); });
registerHandler(ipcChannels.project.setSceneGraphNodeSideStoryStart, async ({ graphNodeId }) => {
const project = await projectStore.setSceneGraphNodeSideStoryStart(graphNodeId);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.updateSideStoryLineTitle, async ({ graphNodeId, title }) => {
const project = await projectStore.updateSideStoryLineTitle(graphNodeId, title);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteScene, async ({ sceneId }) => { registerHandler(ipcChannels.project.deleteScene, async ({ sceneId }) => {
const project = await projectStore.deleteScene(sceneId); const project = await projectStore.deleteScene(sceneId);
emitSessionState(); emitSessionState();
@@ -537,7 +547,6 @@ async function main() {
} }
const srcPath = filePaths[0]; const srcPath = filePaths[0];
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' }); emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
// Let store import; progress for unzip is emitted from unzipToDir wrapper in store.
const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => { const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
emitZipProgress({ emitZipProgress({
kind: 'import', kind: 'import',
@@ -550,7 +559,94 @@ async function main() {
emitSessionState(); emitSessionState();
return { canceled: false as const, project }; return { canceled: false as const, project };
}); });
registerHandler(ipcChannels.project.exportZip, async ({ projectId }) => { registerHandler(ipcChannels.project.getProjectStorylines, async ({ projectId, labels }) => {
const storylines = await projectStore.getProjectStorylines(projectId, labels);
return { storylines };
});
registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
const filePath = filePaths[0];
const peek = await projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
return { canceled: false as const, ...peek };
});
registerHandler(ipcChannels.project.pickImportZipFile, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
}
return { canceled: false as const, filePath: filePaths[0] };
});
registerHandler(ipcChannels.project.peekImportZipPath, async ({ filePath, labels, targetHasMainStart }) => {
return projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
});
registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => {
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
});
registerHandler(ipcChannels.project.mergeImportZip, async ({ filePath, storylineSelections, sceneResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromExternalZip(
filePath,
storylineSelections,
sceneResolutions,
(p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project, report };
});
registerHandler(
ipcChannels.project.mergeImportFromProject,
async ({ sourceProjectId, storylineSelections, sceneResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromProjectId(
sourceProjectId,
storylineSelections,
sceneResolutions,
(p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project, report };
},
);
registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
const project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => {
const list = await projectStore.listProjects(); const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId); const entry = list.find((p) => p.id === projectId);
if (!entry) { if (!entry) {
@@ -569,17 +665,23 @@ async function main() {
return { canceled: true as const }; return { canceled: true as const };
} }
const dest = normalizeSaveProjectZipPath(filePath); const dest = normalizeSaveProjectZipPath(filePath);
emitZipProgress({ kind: 'export', stage: 'copy', percent: 0, detail: 'Экспорт…' }); try {
await projectStore.exportProjectZipToPath(projectId, dest, (p) => { emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
emitZipProgress({ await projectStore.exportStorylinesZipToPath(projectId, storylineSelections, dest, labels, (p) => {
kind: 'export', emitZipProgress({
stage: p.stage, kind: 'export',
percent: p.percent, stage: p.stage,
...(p.detail ? { detail: p.detail } : null), percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
}); });
}); emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' }); return { canceled: false as const };
return { canceled: false as const }; } catch (err) {
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
throw err;
}
}); });
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => { registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
await projectStore.deleteProjectById(projectId); await projectStore.deleteProjectById(projectId);
@@ -44,9 +44,16 @@ void test('zipStore: openProjectById skips re-unzip when project is already open
void test('zipStore: pack and open operations are serialized', () => { void test('zipStore: pack and open operations are serialized', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /private packChain: Promise<void>/); assert.match(src, /private packChain: Promise<void>/);
assert.match(src, /private openChain: Promise<void>/); assert.match(src, /private projectSwitchChain: Promise<void>/);
assert.match(src, /enqueuePack/); assert.match(src, /enqueuePack/);
assert.match(src, /enqueueOpenProject/); assert.match(src, /enqueueOpenProject/);
assert.match(src, /enqueueProjectSwitch/);
});
void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/);
assert.match(src, /Открытие проекта отменено/);
}); });
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => { void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
+413 -17
View File
@@ -7,6 +7,20 @@ import path from 'node:path';
import { ZipFile } from 'yazl'; import { ZipFile } from 'yazl';
import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules'; import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules';
import { canSetSideStoryStart, getSideStoryComponentNodeIds } from '../../shared/graph/sceneGraphLineage';
import {
buildPartialExportProject,
computeGraphImportOffsetX,
listExportableStorylines,
listImportableStorylines,
mergeStorylinesIntoProject,
newExportBundleProjectId,
type SceneImportResolution,
type StorylineImportMergeReport,
type StorylineLabels,
type StorylineListItem,
type StorylineSelection,
} from '../../shared/graph/storylineExportImport';
import type { ScenePatch } from '../../shared/ipc/contracts'; import type { ScenePatch } from '../../shared/ipc/contracts';
import { import {
isProjectZipFileName, isProjectZipFileName,
@@ -61,8 +75,8 @@ export class ZipProjectStore {
private projectWriteChain: Promise<void> = Promise.resolve(); private projectWriteChain: Promise<void> = Promise.resolve();
/** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */ /** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */
private packChain: Promise<void> = Promise.resolve(); private packChain: Promise<void> = Promise.resolve();
/** Serializes open/unzip — double-click fired two concurrent opens and corrupted reads. */ /** Serializes open/close/unzip — concurrent IPC caused ghost open projects and deadlocks. */
private openChain: Promise<void> = Promise.resolve(); private projectSwitchChain: Promise<void> = Promise.resolve();
private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null; private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private enqueuePack(cacheDir: string, zipPath: string): Promise<void> { private enqueuePack(cacheDir: string, zipPath: string): Promise<void> {
@@ -73,15 +87,19 @@ export class ZipProjectStore {
return next; return next;
} }
private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise<Project> { private enqueueProjectSwitch<T>(fn: () => Promise<T>): Promise<T> {
const task = this.openChain.then(() => this.openProjectByIdInner(projectId, onUnzipPercent)); const task = this.projectSwitchChain.then(() => fn());
this.openChain = task.then( this.projectSwitchChain = task.then(
() => undefined, () => undefined,
() => undefined, () => undefined,
); );
return task; return task;
} }
private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise<Project> {
return this.enqueueProjectSwitch(() => this.openProjectByIdInner(projectId, onUnzipPercent));
}
/** Waits for debounced save, in-flight pack, and pending project.json writes before reading zip. */ /** Waits for debounced save, in-flight pack, and pending project.json writes before reading zip. */
private async drainSavePipeline(): Promise<void> { private async drainSavePipeline(): Promise<void> {
if (this.saveDebounceTimer) { if (this.saveDebounceTimer) {
@@ -228,13 +246,18 @@ export class ZipProjectStore {
if (this.openProject?.id === projectId) { if (this.openProject?.id === projectId) {
return this.openProject.project; return this.openProject.project;
} }
const sessionAtStart = this.projectSession;
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave). // Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first. // When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) { if (this.openProject) {
await this.saveNow(); await this.saveNow();
} }
await this.drainSavePipeline(); await this.drainSavePipeline();
if (sessionAtStart !== this.projectSession) {
throw new Error('Открытие проекта отменено');
}
this.projectSession += 1; this.projectSession += 1;
const openSession = this.projectSession;
const list = await this.listProjects(); const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId); const entry = list.find((p) => p.id === projectId);
if (!entry) { if (!entry) {
@@ -256,6 +279,11 @@ export class ZipProjectStore {
throw new Error(`Не удалось открыть проект: архив повреждён или занят (${detail})`); throw new Error(`Не удалось открыть проект: архив повреждён или занят (${detail})`);
} }
if (openSession !== this.projectSession) {
await fs.rm(cacheDir, { recursive: true, force: true }).catch(() => undefined);
throw new Error('Открытие проекта отменено');
}
const projectPath = path.join(cacheDir, 'project.json'); const projectPath = path.join(cacheDir, 'project.json');
const projectRaw = await fs.readFile(projectPath, 'utf8'); const projectRaw = await fs.readFile(projectPath, 'utf8');
const parsed = JSON.parse(projectRaw) as unknown as Project; const parsed = JSON.parse(projectRaw) as unknown as Project;
@@ -640,6 +668,8 @@ export class ZipProjectStore {
x, x,
y, y,
isStartScene: false, isStartScene: false,
isSideStoryStart: false,
sideStoryLineTitle: '',
}; };
await this.updateProject((p) => ({ ...p, sceneGraphNodes: [...p.sceneGraphNodes, node] })); await this.updateProject((p) => ({ ...p, sceneGraphNodes: [...p.sceneGraphNodes, node] }));
const latest = this.getOpenProject(); const latest = this.getOpenProject();
@@ -655,10 +685,59 @@ export class ZipProjectStore {
} }
await this.updateProject((p) => ({ await this.updateProject((p) => ({
...p, ...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) => ({ sceneGraphNodes: p.sceneGraphNodes.map((n) => {
...n, const isMain = graphNodeId !== null && n.id === graphNodeId;
isStartScene: graphNodeId !== null && n.id === graphNodeId, if (isMain) {
})), return { ...n, isStartScene: true, isSideStoryStart: false, sideStoryLineTitle: '' };
}
return { ...n, isStartScene: false };
}),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setSceneGraphNodeSideStoryStart(graphNodeId: GraphNodeId | null): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
if (graphNodeId === null) {
throw new Error('Graph node id required');
}
if (!open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
throw new Error('Graph node not found');
}
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
if (!node) throw new Error('Graph node not found');
const enabling = !node.isSideStoryStart;
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) {
return open.project;
}
await this.updateProject((p) => ({
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
if (n.id !== graphNodeId) return n;
if (enabling) {
return { ...n, isSideStoryStart: true, isStartScene: false };
}
return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' };
}),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async updateSideStoryLineTitle(graphNodeId: GraphNodeId, title: string): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
if (!node?.isSideStoryStart) throw new Error('Not a side story start node');
await this.updateProject((p) => ({
...p,
sceneGraphNodes: p.sceneGraphNodes.map((n) =>
n.id === graphNodeId ? { ...n, sideStoryLineTitle: title } : n,
),
})); }));
const latest = this.getOpenProject(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project'); if (!latest) throw new Error('No open project');
@@ -668,6 +747,61 @@ export class ZipProjectStore {
async removeSceneGraphNode(nodeId: GraphNodeId): Promise<Project> { async removeSceneGraphNode(nodeId: GraphNodeId): 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');
const node = open.project.sceneGraphNodes.find((n) => n.id === nodeId);
if (!node) throw new Error('Graph node not found');
if (node.isSideStoryStart) {
const outgoing = open.project.sceneGraphEdges
.filter((e) => e.sourceGraphNodeId === nodeId)
.sort((a, b) => a.id.localeCompare(b.id));
if (outgoing.length > 0) {
const newStartId = outgoing[0]?.targetGraphNodeId;
if (!newStartId) throw new Error('Invalid graph edge');
const nextNodes = open.project.sceneGraphNodes
.filter((gn) => gn.id !== nodeId)
.map((gn) =>
gn.id === newStartId
? {
...gn,
isSideStoryStart: true,
isStartScene: false,
sideStoryLineTitle: node.sideStoryLineTitle,
}
: gn,
);
const nextEdges = open.project.sceneGraphEdges.filter(
(e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId,
);
await this.updateProject((p) => {
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
const removeIds = getSideStoryComponentNodeIds(
open.project.sceneGraphNodes,
open.project.sceneGraphEdges,
nodeId,
);
const nextNodes = open.project.sceneGraphNodes.filter((gn) => !removeIds.has(gn.id));
const nextEdges = open.project.sceneGraphEdges.filter(
(e) => !removeIds.has(e.sourceGraphNodeId) && !removeIds.has(e.targetGraphNodeId),
);
await this.updateProject((p) => {
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
const nextNodes = open.project.sceneGraphNodes.filter((gn) => gn.id !== nodeId); const nextNodes = open.project.sceneGraphNodes.filter((gn) => gn.id !== nodeId);
const nextEdges = open.project.sceneGraphEdges.filter( const nextEdges = open.project.sceneGraphEdges.filter(
(e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId, (e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId,
@@ -847,12 +981,14 @@ export class ZipProjectStore {
} }
async closeOpenProject(): Promise<void> { async closeOpenProject(): Promise<void> {
if (!this.openProject) return; return this.enqueueProjectSwitch(async () => {
await this.saveNow(); this.projectSession += 1;
await this.drainSavePipeline(); if (!this.openProject) return;
this.saveQueued = false; await this.saveNow();
this.openProject = null; await this.drainSavePipeline();
this.projectSession += 1; this.saveQueued = false;
this.openProject = null;
});
} }
async renameOpenProject(name: string, fileBaseName: string): Promise<Project> { async renameOpenProject(name: string, fileBaseName: string): Promise<Project> {
@@ -1116,6 +1252,256 @@ export class ZipProjectStore {
} }
} }
/** Снимок проекта для экспорта/списка линий (распаковывает во временный кэш при необходимости). */
private async loadProjectSnapshot(
projectId: ProjectId,
): Promise<{ project: Project; cacheDir: string; ownsCache: boolean }> {
await this.drainSavePipeline();
if (this.openProject?.id === projectId) {
await this.saveNow();
return {
project: structuredClone(this.openProject.project),
cacheDir: this.openProject.cacheDir,
ownsCache: false,
};
}
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) throw new Error('Проект не найден');
const zipPath = path.join(getProjectsRootDir(), entry.fileName);
const project = normalizeProject(await readProjectJsonFromZip(zipPath));
const cacheDir = path.join(tmpdir(), `dnd_snap_${crypto.randomBytes(8).toString('hex')}`);
await unzipToDir(zipPath, cacheDir);
return { project, cacheDir, ownsCache: true };
}
async getProjectStorylines(
projectId: ProjectId,
labels: StorylineLabels,
): Promise<StorylineListItem[]> {
const snap = await this.loadProjectSnapshot(projectId);
try {
return listExportableStorylines(snap.project, labels);
} finally {
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async exportStorylinesZipToPath(
projectId: ProjectId,
selections: StorylineSelection[],
destinationPath: string,
labels: StorylineLabels,
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
): Promise<void> {
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
await this.ensureRoots();
const snap = await this.loadProjectSnapshot(projectId);
const exportCache = path.join(tmpdir(), `dnd_pexport_${crypto.randomBytes(8).toString('hex')}`);
try {
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
const partial = buildPartialExportProject(snap.project, selections, {
newProjectId: newExportBundleProjectId(),
exportTitle: entry?.name ?? snap.project.meta.name,
labels,
});
await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true });
const assetIds = Object.keys(partial.assets) as AssetId[];
for (let i = 0; i < assetIds.length; i += 1) {
const id = assetIds[i]!;
const a = partial.assets[id];
if (!a) continue;
const srcAbs = path.join(snap.cacheDir, a.relPath);
const destAbs = path.join(exportCache, a.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.copyFile(srcAbs, destAbs);
if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 80);
onProgress({ stage: 'zip', percent: pct, detail: 'Сборка архива…' });
}
}
const now = new Date().toISOString();
const toWrite: Project = {
...partial,
meta: { ...partial.meta, updatedAt: now, appVersion: getAppSemanticVersion() },
};
await atomicWriteFile(path.join(exportCache, 'project.json'), JSON.stringify(toWrite, null, 2));
const tmpZip = `${path.resolve(destinationPath)}.tmp`;
if (onProgress) onProgress({ stage: 'zip', percent: 90, detail: 'Сборка архива…' });
await zipDir(exportCache, tmpZip);
await replaceFileAtomic(tmpZip, path.resolve(destinationPath));
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
} finally {
await fs.rm(exportCache, { recursive: true, force: true }).catch(() => undefined);
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async readExternalProjectForImport(sourcePath: string): Promise<Project> {
const resolved = path.resolve(sourcePath);
const st = await fs.stat(resolved).catch(() => null);
if (!st?.isFile()) throw new Error('Файл проекта не найден');
if (!isProjectZipFileName(resolved)) {
throw new Error('Ожидается файл проекта с расширением .ttrpg.zip или .dnd.zip');
}
return normalizeProject(await readProjectJsonFromZip(resolved));
}
async peekImportFromProjectId(
sourceProjectId: ProjectId,
labels: StorylineLabels,
targetHasMainStart: boolean,
): Promise<{
sourceProjectId: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}> {
const snap = await this.loadProjectSnapshot(sourceProjectId);
try {
return {
sourceProjectId,
projectName: snap.project.meta.name,
storylines: listExportableStorylines(snap.project, labels).map((item) => {
if (item.selection.kind === 'main' && targetHasMainStart) {
return { ...item, disabled: true, disabledReason: 'main_exists' };
}
return item;
}),
sourceProject: structuredClone(snap.project),
};
} finally {
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async peekImportFromZipPath(
sourcePath: string,
labels: StorylineLabels,
targetHasMainStart: boolean,
): Promise<{
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}> {
const project = await this.readExternalProjectForImport(sourcePath);
return {
filePath: path.resolve(sourcePath),
projectName: project.meta.name,
storylines: listImportableStorylines(project, labels, targetHasMainStart),
sourceProject: project,
};
}
private assertStorylineMergeAllowed(selections: StorylineSelection[]): void {
if (!this.openProject) throw new Error('Нет открытого проекта');
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
if (
selections.some((s) => s.kind === 'main') &&
this.openProject.project.sceneGraphNodes.some((n) => n.isStartScene)
) {
throw new Error('В проекте уже есть основная сюжетная линия');
}
}
private async mergeStorylinesFromSourceCache(
source: Project,
sourceCache: string,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
if (!this.openProject) throw new Error('Нет открытого проекта');
const offsetX = computeGraphImportOffsetX(this.openProject.project);
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
this.openProject.project,
source,
selections,
sceneResolutions,
{ graphOffsetX: offsetX },
);
const targetCache = this.openProject.cacheDir;
await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true });
for (let i = 0; i < assetCopies.length; i += 1) {
const { fromId, toId } = assetCopies[i]!;
const srcAsset = source.assets[fromId];
const tgtMeta = merged.assets[toId];
if (!srcAsset || !tgtMeta) continue;
const srcAbs = path.join(sourceCache, srcAsset.relPath);
const destAbs = path.join(targetCache, tgtMeta.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.copyFile(srcAbs, destAbs);
if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
onProgress({ stage: 'copy', percent: pct, detail: 'Копирование материалов…' });
}
}
const normalized = normalizeProject(merged);
const reconciled = await reconcileAssetFiles(this.openProject.project, normalized, targetCache);
await this.updateProject(() => reconciled);
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
if (onProgress) onProgress({ stage: 'done', percent: 100, detail: 'Готово' });
return { project: latest, report };
}
async mergeStorylinesFromProjectId(
sourceProjectId: ProjectId,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections);
const snap = await this.loadProjectSnapshot(sourceProjectId);
try {
return await this.mergeStorylinesFromSourceCache(
snap.project,
snap.cacheDir,
selections,
sceneResolutions,
onProgress,
);
} finally {
if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async mergeStorylinesFromExternalZip(
sourcePath: string,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections);
const source = await this.readExternalProjectForImport(sourcePath);
const sourceCache = path.join(tmpdir(), `dnd_mimport_${crypto.randomBytes(8).toString('hex')}`);
await unzipToDir(path.resolve(sourcePath), sourceCache);
try {
return await this.mergeStorylinesFromSourceCache(
source,
sourceCache,
selections,
sceneResolutions,
onProgress,
);
} finally {
await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined);
}
}
private randomId(): string { private randomId(): string {
return crypto.randomBytes(16).toString('hex'); return crypto.randomBytes(16).toString('hex');
} }
@@ -1166,6 +1552,8 @@ function migrateSceneGraphFromLegacy(scenes: Record<SceneId, Scene>): {
x: s.layout.x, x: s.layout.x,
y: s.layout.y, y: s.layout.y,
isStartScene: false, isStartScene: false,
isSideStoryStart: false,
sideStoryLineTitle: '',
})); }));
const byScene = new Map(sceneGraphNodes.map((n) => [n.sceneId, n])); const byScene = new Map(sceneGraphNodes.map((n) => [n.sceneId, n]));
const sceneGraphEdges: SceneGraphEdge[] = []; const sceneGraphEdges: SceneGraphEdge[] = [];
@@ -1189,10 +1577,18 @@ function migrateSceneGraphFromLegacy(scenes: Record<SceneId, Scene>): {
/** Один флаг `isStartScene` на весь проект; лишние true сбрасываются. */ /** Один флаг `isStartScene` на весь проект; лишние true сбрасываются. */
function normalizeSceneGraphNodeFlags(nodes: SceneGraphNode[]): SceneGraphNode[] { function normalizeSceneGraphNodeFlags(nodes: SceneGraphNode[]): SceneGraphNode[] {
const withDefaults = nodes.map((n) => { const withDefaults = nodes.map((n) => {
const raw = n as unknown as { isStartScene?: boolean }; const raw = n as unknown as {
isStartScene?: boolean;
isSideStoryStart?: boolean;
sideStoryLineTitle?: string;
};
const isStartScene = raw.isStartScene === true;
const isSideStoryStart = raw.isSideStoryStart === true && !isStartScene;
return { return {
...n, ...n,
isStartScene: raw.isStartScene === true, isStartScene,
isSideStoryStart,
sideStoryLineTitle: isSideStoryStart ? (raw.sideStoryLineTitle ?? '').trim() : '',
}; };
}); });
const starters = withDefaults.filter((n) => n.isStartScene); const starters = withDefaults.filter((n) => n.isStartScene);
@@ -278,6 +278,62 @@
font-weight: 900; font-weight: 900;
} }
.branchCardReturn {
border-color: rgba(0, 120, 212, 0.45);
}
.sideStoryGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.sideStoryTile {
display: grid;
gap: 8px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
}
.sideStoryTile:hover .sideStoryPreview {
border-color: rgba(0, 120, 212, 0.55);
}
.sideStoryPreview {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--scene-tile-radius);
overflow: hidden;
border: 2px solid var(--stroke);
background: #0c0c0e;
position: relative;
}
.sideStoryVideo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.sideStoryPlaceholder {
width: 100%;
height: 100%;
background: #0c0c0e;
}
.sideStoryTitle {
font-size: var(--text-xs);
font-weight: 800;
line-height: 1.25;
color: var(--text1);
}
.musicHeader { .musicHeader {
display: flex; display: flex;
align-items: center; align-items: center;
+137 -3
View File
@@ -3,15 +3,18 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're
import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts'; import type { SessionState } from '../../shared/ipc/contracts';
import { isNodeInMainStoryline, isNodeInSideStoryline, listSideStoryStarts } from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, Scene, SceneId } from '../../shared/types'; import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay'; import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState'; import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState'; import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { Button } from '../shared/ui/controls'; import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface'; import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './ControlApp.module.css'; import styles from './ControlApp.module.css';
import { ControlScenePreview } from './ControlScenePreview'; import { ControlScenePreview } from './ControlScenePreview';
@@ -45,6 +48,41 @@ function playLightningEffectSound(): void {
} }
} }
function SideStoryTile({
scene,
title,
onClick,
}: {
scene: Scene;
title: string;
onClick: () => void;
}) {
const thumbUrl = useAssetUrl(scene.previewThumbAssetId ?? scene.previewAssetId);
const previewUrl = useAssetUrl(scene.previewAssetId);
const imageUrl = thumbUrl ?? (scene.previewAssetType === 'image' ? previewUrl : null);
return (
<button type="button" className={styles.sideStoryTile} onClick={onClick}>
<div className={styles.sideStoryPreview}>
{imageUrl ? (
<RotatedImage
url={imageUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%' }}
/>
) : previewUrl && scene.previewAssetType === 'video' ? (
<video src={previewUrl} muted playsInline preload="metadata" className={styles.sideStoryVideo} />
) : (
<div className={styles.sideStoryPlaceholder} aria-hidden />
)}
</div>
<div className={styles.sideStoryTitle}>{title}</div>
</button>
);
}
export function ControlApp() { export function ControlApp() {
const api = getDndApi(); const api = getDndApi();
const { t } = useEditorI18n(); const { t } = useEditorI18n();
@@ -55,6 +93,9 @@ export function ControlApp() {
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]); const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]); const [history, setHistory] = useState<GraphNodeId[]>([]);
/** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */
const mainStoryReturnRef = useRef<GraphNodeId | null>(null);
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
// Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта. // Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта.
const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map()); const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map()); const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
@@ -116,7 +157,11 @@ export function ControlApp() {
return api.on(ipcChannels.session.stateChanged, ({ state }) => { return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state); setSession(state);
const cur = state.project?.currentGraphNodeId ?? null; const cur = state.project?.currentGraphNodeId ?? null;
if (!cur) return; if (!cur) {
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
return;
}
const arr = historyRef.current; const arr = historyRef.current;
if (arr[arr.length - 1] !== cur) { if (arr[arr.length - 1] !== cur) {
historyRef.current = [...arr, cur]; historyRef.current = [...arr, cur];
@@ -125,6 +170,15 @@ export function ControlApp() {
}); });
}, [api]); }, [api]);
useEffect(() => {
return api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
if (!open) {
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
}
});
}, [api]);
useEffect(() => { useEffect(() => {
audioUnmountRef.current = false; audioUnmountRef.current = false;
return () => { return () => {
@@ -590,6 +644,54 @@ export function ControlApp() {
.filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined); .filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined);
}, [currentGraphNodeId, project]); }, [currentGraphNodeId, project]);
const isInSideStoryline = useMemo(() => {
if (!project || !currentGraphNodeId) return false;
return isNodeInSideStoryline(project.sceneGraphNodes, project.sceneGraphEdges, currentGraphNodeId);
}, [currentGraphNodeId, project]);
const sideStoryLines = useMemo(() => {
if (!project) return [];
return listSideStoryStarts(project.sceneGraphNodes).map((gn) => {
const scene = project.scenes[gn.sceneId];
return {
graphNodeId: gn.id,
title: gn.sideStoryLineTitle.trim() || scene?.title || t('control.unnamed'),
scene,
};
});
}, [project, t]);
const returnSceneTitle = useMemo(() => {
if (!project || !mainStoryReturnGraphNodeId) return '';
const gn = project.sceneGraphNodes.find((n) => n.id === mainStoryReturnGraphNodeId);
if (!gn) return '';
return project.scenes[gn.sceneId]?.title || t('control.unnamed');
}, [mainStoryReturnGraphNodeId, project, t]);
const enterSideStoryline = (startGraphNodeId: GraphNodeId) => {
if (!project) return;
if (
currentGraphNodeId &&
mainStoryReturnRef.current === null &&
isNodeInMainStoryline(project.sceneGraphNodes, project.sceneGraphEdges, currentGraphNodeId)
) {
mainStoryReturnRef.current = currentGraphNodeId;
setMainStoryReturnGraphNodeId(currentGraphNodeId);
}
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: startGraphNodeId });
};
const returnToMainStoryline = () => {
const ret = mainStoryReturnRef.current;
if (!ret) return;
mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null);
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: ret });
};
const showReturnToMain = isInSideStoryline && mainStoryReturnGraphNodeId !== null;
const branchOptionOffset = showReturnToMain ? 1 : 0;
const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 }; const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
const toolRef = useRef(tool); const toolRef = useRef(tool);
toolRef.current = tool; toolRef.current = tool;
@@ -1407,10 +1509,23 @@ export function ControlApp() {
<Surface className={styles.surfacePad}> <Surface className={styles.surfacePad}>
<div className={styles.branchTitle}>{t('control.branches')}</div> <div className={styles.branchTitle}>{t('control.branches')}</div>
<div className={styles.branchGrid}> <div className={styles.branchGrid}>
{showReturnToMain ? (
<div className={[styles.branchCard, styles.branchCardReturn].join(' ')}>
<div className={styles.branchCardHeader}>
<div className={styles.branchOption}>{t('control.option', { n: '1' })}</div>
</div>
<div className={styles.branchName}>{returnSceneTitle}</div>
<Button variant="primary" onClick={returnToMainStoryline}>
{t('control.returnToMainStory')}
</Button>
</div>
) : null}
{nextScenes.map((o, i) => ( {nextScenes.map((o, i) => (
<div key={o.graphNodeId} className={styles.branchCard}> <div key={o.graphNodeId} className={styles.branchCard}>
<div className={styles.branchCardHeader}> <div className={styles.branchCardHeader}>
<div className={styles.branchOption}>{t('control.option', { n: String(i + 1) })}</div> <div className={styles.branchOption}>
{t('control.option', { n: String(i + 1 + branchOptionOffset) })}
</div>
</div> </div>
<div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div> <div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div>
<Button <Button
@@ -1423,7 +1538,7 @@ export function ControlApp() {
</Button> </Button>
</div> </div>
))} ))}
{nextScenes.length === 0 ? ( {nextScenes.length === 0 && !showReturnToMain ? (
<div className={styles.branchEmpty}> <div className={styles.branchEmpty}>
<div>{t('control.noBranches')}</div> <div>{t('control.noBranches')}</div>
<Button <Button
@@ -1666,6 +1781,25 @@ export function ControlApp() {
</div> </div>
)} )}
</Surface> </Surface>
{sideStoryLines.length > 0 ? (
<Surface className={styles.surfacePad}>
<div className={styles.previewTitle}>{t('control.sideStoryLines')}</div>
<div className={styles.spacer10} />
<div className={styles.sideStoryGrid}>
{sideStoryLines.map((line) =>
line.scene ? (
<SideStoryTile
key={line.graphNodeId}
scene={line.scene}
title={line.title}
onClick={() => enterSideStoryline(line.graphNodeId)}
/>
) : null,
)}
</div>
</Surface>
) : null}
</div> </div>
</div> </div>
); );
+74
View File
@@ -941,3 +941,77 @@
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
} }
.modalDialogWide {
width: 640px;
}
.storylineChecklist {
display: grid;
gap: 8px;
max-height: 280px;
overflow-y: auto;
padding: 4px 0;
}
.storylineCheck {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-surface);
cursor: pointer;
font-size: 13px;
}
.storylineCheck input {
margin-top: 2px;
}
.storylineCheckDisabled {
opacity: 0.55;
cursor: not-allowed;
}
.conflictList {
display: grid;
gap: 12px;
max-height: 360px;
overflow-y: auto;
}
.conflictRow {
display: grid;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-surface);
}
.conflictRowTitle {
font-weight: 700;
font-size: 13px;
}
.conflictRowOptions {
display: grid;
gap: 6px;
}
.importFileRow {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.reportList {
display: grid;
gap: 6px;
font-size: 13px;
margin: 0;
padding-left: 18px;
}
+221 -122
View File
@@ -25,6 +25,19 @@ import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell'; import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport';
import {
buildSceneResolutionsForImport,
computeImportConflicts,
ExportProjectModal,
ImportReportModal,
ImportSourceModal,
ImportStorylinesModal,
SceneConflictModal,
useStorylineLabels,
type ImportPeekResult,
type ImportSourceSelection,
} from './StorylineTransferModals';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals'; import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import styles from './EditorApp.module.css'; import styles from './EditorApp.module.css';
import { buildNextSceneCardById } from './graph/sceneCardById'; import { buildNextSceneCardById } from './graph/sceneCardById';
@@ -92,6 +105,14 @@ export function EditorApp() {
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview'); const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
const [renameOpen, setRenameOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [importSourceOpen, setImportSourceOpen] = useState(false);
const [importPeek, setImportPeek] = useState<ImportPeekResult | null>(null);
const [importStorylinesOpen, setImportStorylinesOpen] = useState(false);
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false);
const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null);
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null); const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
const [presentationOpen, setPresentationOpen] = useState(false); const [presentationOpen, setPresentationOpen] = useState(false);
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null); const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
@@ -119,6 +140,7 @@ export function EditorApp() {
const graphUi = useMemo<SceneGraphUiStrings>( const graphUi = useMemo<SceneGraphUiStrings>(
() => ({ () => ({
badgeStart: t('graph.badgeStart'), badgeStart: t('graph.badgeStart'),
badgeSideStory: t('graph.badgeSideStory'),
untitled: t('graph.untitled'), untitled: t('graph.untitled'),
videoBadge: t('graph.videoBadge'), videoBadge: t('graph.videoBadge'),
audioBadge: t('graph.audioBadge'), audioBadge: t('graph.audioBadge'),
@@ -133,6 +155,8 @@ export function EditorApp() {
closeMenu: t('common.closeMenu'), closeMenu: t('common.closeMenu'),
startScene: t('graph.startScene'), startScene: t('graph.startScene'),
unsetStartScene: t('graph.unsetStartScene'), unsetStartScene: t('graph.unsetStartScene'),
sideStoryStartScene: t('graph.sideStoryStartScene'),
unsetSideStoryStartScene: t('graph.unsetSideStoryStartScene'),
runFromScene: t('graph.runFromScene'), runFromScene: t('graph.runFromScene'),
delete: t('common.delete'), delete: t('common.delete'),
}), }),
@@ -146,6 +170,19 @@ export function EditorApp() {
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null); const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
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);
useEffect(() => {
setSelectedGraphNodeId(null);
}, [state.project?.id]);
useEffect(() => {
if (!state.project || !selectedGraphNodeId) return;
if (!state.project.sceneGraphNodes.some((n) => n.id === selectedGraphNodeId)) {
setSelectedGraphNodeId(null);
}
}, [selectedGraphNodeId, state.project?.sceneGraphNodes]);
const scenes = useMemo<SceneCard[]>(() => { const scenes = useMemo<SceneCard[]>(() => {
const p = state.project; const p = state.project;
if (!p) return []; if (!p) return [];
@@ -367,6 +404,79 @@ export function EditorApp() {
}, []); }, []);
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null; const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
const storylineLabels = useStorylineLabels();
const targetHasMainStart = state.project?.sceneGraphNodes.some((n) => n.isStartScene) ?? false;
const loadProjectStorylines = useCallback(
(projectId: ProjectId) => actions.getProjectStorylines(projectId, storylineLabels),
[actions, storylineLabels],
);
const runStorylineMerge = useCallback(
async (selections: StorylineSelection[], resolutions: SceneImportResolution[]) => {
if (!importPeek) return;
const { report } =
importPeek.kind === 'project' && importPeek.sourceProjectId
? await actions.mergeImportFromProject(importPeek.sourceProjectId, selections, resolutions)
: await actions.mergeImportZip(importPeek.filePath!, selections, resolutions);
setImportReport(report);
setImportReportOpen(true);
setImportPeek(null);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setPendingImportSelections([]);
setImportConflicts([]);
},
[actions, importPeek],
);
const handleImportSourceContinue = useCallback(
async (selection: ImportSourceSelection) => {
if (!state.project) {
if (selection.kind === 'file') {
await actions.importProjectFromPath(selection.filePath);
setImportSourceOpen(false);
}
return;
}
const peek =
selection.kind === 'project'
? await actions.peekImportFromProject(
selection.sourceProjectId,
storylineLabels,
targetHasMainStart,
)
: await actions.peekImportZipPath(selection.filePath, storylineLabels, targetHasMainStart);
setImportPeek({
kind: selection.kind,
...(selection.kind === 'file'
? { filePath: selection.filePath }
: { sourceProjectId: selection.sourceProjectId }),
projectName: peek.projectName,
storylines: peek.storylines,
sourceProject: peek.sourceProject,
});
setImportSourceOpen(false);
setImportStorylinesOpen(true);
},
[actions, state.project, storylineLabels, targetHasMainStart],
);
const handleImportProject = useCallback(() => {
setProjectMenuOpen(false);
setImportSourceOpen(true);
}, []);
const goHome = useCallback(() => {
setProjectMenuOpen(false);
setExportModalOpen(false);
setImportSourceOpen(false);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setImportReportOpen(false);
setImportPeek(null);
void actions.closeProject();
}, [actions]);
const bodyOverlay = const bodyOverlay =
licenseSnap === null ? ( licenseSnap === null ? (
@@ -424,7 +534,7 @@ export function EditorApp() {
type="button" type="button"
className={styles.brandButton} className={styles.brandButton}
onClick={() => { onClick={() => {
void actions.closeProject(); goHome();
}} }}
title={t('top.backToProjects')} title={t('top.backToProjects')}
> >
@@ -562,7 +672,10 @@ export function EditorApp() {
<SceneListCard <SceneListCard
key={s.id} key={s.id}
scene={s} scene={s}
onSelect={() => void actions.selectScene(s.id)} onSelect={() => {
setSelectedGraphNodeId(null);
void actions.selectScene(s.id);
}}
onDeleteScene={(id) => void actions.deleteScene(id)} onDeleteScene={(id) => void actions.deleteScene(id)}
/> />
))} ))}
@@ -604,8 +717,11 @@ export function EditorApp() {
sceneGraphNodes={state.project.sceneGraphNodes} sceneGraphNodes={state.project.sceneGraphNodes}
sceneGraphEdges={state.project.sceneGraphEdges} sceneGraphEdges={state.project.sceneGraphEdges}
sceneCardById={sceneCardById} sceneCardById={sceneCardById}
currentSceneId={state.selectedSceneId} selectedGraphNodeId={selectedGraphNodeId}
onCurrentSceneChange={(id) => void actions.selectScene(id)} onGraphNodeSelect={(graphNodeId, sceneId) => {
setSelectedGraphNodeId(graphNodeId);
void actions.selectScene(sceneId);
}}
onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)} onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)}
onDisconnect={(edgeId) => void actions.removeSceneGraphEdge(edgeId)} onDisconnect={(edgeId) => void actions.removeSceneGraphEdge(edgeId)}
onNodePositionCommit={(nodeId, x, y) => onNodePositionCommit={(nodeId, x, y) =>
@@ -616,6 +732,9 @@ export function EditorApp() {
}} }}
onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)} onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)}
onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)} onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)}
onSetGraphNodeSideStoryStart={(graphNodeId) =>
void actions.setSceneGraphNodeSideStoryStart(graphNodeId)
}
onRunFromGraphNode={launchFromGraphNode} onRunFromGraphNode={launchFromGraphNode}
onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)} onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)}
/> />
@@ -666,10 +785,14 @@ export function EditorApp() {
: previewImport !== null : previewImport !== null
? t('scene.previewOptimizing') ? t('scene.previewOptimizing')
: t('scene.previewBusy'); : t('scene.previewBusy');
const sideStoryStartNodes = proj.sceneGraphNodes.filter(
(n) => n.sceneId === sid && n.isSideStoryStart,
);
return ( return (
<SceneInspector <SceneInspector
title={sc?.title ?? ''} title={sc?.title ?? ''}
description={sc?.description ?? ''} description={sc?.description ?? ''}
sideStoryStartNodes={sideStoryStartNodes}
previewAssetId={sc?.previewAssetId ?? null} previewAssetId={sc?.previewAssetId ?? null}
previewAssetType={sc?.previewAssetType ?? null} previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false} previewVideoAutostart={sc?.previewVideoAutostart ?? false}
@@ -711,6 +834,9 @@ export function EditorApp() {
void actions.updateScene(sid, { previewRotationDeg }) void actions.updateScene(sid, { previewRotationDeg })
} }
onUploadMedia={() => void actions.importMediaToScene(sid)} onUploadMedia={() => void actions.importMediaToScene(sid)}
onSideStoryLineTitleChange={(graphNodeId, title) =>
void actions.updateSideStoryLineTitle(graphNodeId, title)
}
/> />
); );
})() })()
@@ -910,8 +1036,7 @@ export function EditorApp() {
role="menuitem" role="menuitem"
className={styles.fileMenuItem} className={styles.fileMenuItem}
onClick={() => { onClick={() => {
setProjectMenuOpen(false); goHome();
void actions.closeProject();
}} }}
> >
{t('projectMenu.home')} {t('projectMenu.home')}
@@ -921,8 +1046,7 @@ export function EditorApp() {
role="menuitem" role="menuitem"
className={styles.fileMenuItem} className={styles.fileMenuItem}
onClick={() => { onClick={() => {
setProjectMenuOpen(false); void handleImportProject();
void actions.importProject();
}} }}
> >
{t('projectMenu.import')} {t('projectMenu.import')}
@@ -990,9 +1114,77 @@ export function EditorApp() {
open={exportModalOpen} open={exportModalOpen}
projects={state.projects} projects={state.projects}
initialProjectId={exportModalInitialProjectId} initialProjectId={exportModalInitialProjectId}
storylineLabels={storylineLabels}
loadStorylines={loadProjectStorylines}
onClose={() => setExportModalOpen(false)} onClose={() => setExportModalOpen(false)}
onExport={async (projectId) => { onExport={async (projectId, selections) => {
await actions.exportProject(projectId); await actions.exportProject(projectId, selections, storylineLabels);
}}
/>
<ImportSourceModal
open={importSourceOpen}
canImportFromProject={state.project !== null}
projects={state.projects}
currentProjectId={state.project?.id ?? null}
pickFile={actions.pickImportZipFile}
onClose={() => setImportSourceOpen(false)}
onContinue={handleImportSourceContinue}
/>
<ImportStorylinesModal
open={importStorylinesOpen}
sourceName={importPeek?.projectName ?? ''}
storylines={importPeek?.storylines ?? []}
onClose={() => {
setImportStorylinesOpen(false);
setImportPeek(null);
}}
onContinue={(selections) => {
if (!importPeek || !state.project) return;
const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections);
setPendingImportSelections(selections);
if (conflicts.length > 0) {
setImportConflicts(conflicts);
setImportConflictsOpen(true);
setImportStorylinesOpen(false);
return;
}
const resolutions = buildSceneResolutionsForImport(
state.project,
importPeek.sourceProject,
selections,
[],
[],
);
void runStorylineMerge(selections, resolutions);
}}
/>
<SceneConflictModal
open={importConflictsOpen}
conflicts={importConflicts}
onClose={() => {
setImportConflictsOpen(false);
setImportPeek(null);
setPendingImportSelections([]);
setImportConflicts([]);
}}
onConfirm={(userResolutions) => {
if (!importPeek || !state.project) return;
const resolutions = buildSceneResolutionsForImport(
state.project,
importPeek.sourceProject,
pendingImportSelections,
importConflicts,
userResolutions,
);
void runStorylineMerge(pendingImportSelections, resolutions);
}}
/>
<ImportReportModal
open={importReportOpen}
report={importReport}
onClose={() => {
setImportReportOpen(false);
setImportReport(null);
}} }}
/> />
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} /> <CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
@@ -1006,118 +1198,6 @@ export function EditorApp() {
); );
} }
type ExportProjectModalProps = {
open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null;
onClose: () => void;
onExport: (projectId: ProjectId) => Promise<void>;
};
function ExportProjectModal({
open,
projects,
initialProjectId,
onClose,
onExport,
}: ExportProjectModalProps) {
const { t } = useEditorI18n();
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setProjectId(initialProjectId);
setSaving(false);
setError(null);
}, [initialProjectId, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const canExport = projectId !== null && projects.some((p) => p.id === projectId);
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('export.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('export.project')}</div>
<select
className={styles.selectInput}
value={projectId ?? ''}
onChange={(e) => setProjectId((e.target.value as ProjectId) || null)}
disabled={projects.length === 0}
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.fileName})
</option>
))}
</select>
<div className={styles.muted}>{t('export.hint')}</div>
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canExport || saving}
onClick={() => {
if (!projectId || !canExport) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onExport(projectId);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('export.saveAs')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type CheckUpdatesModalProps = { type CheckUpdatesModalProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -1787,6 +1867,7 @@ function ProjectPicker({
type SceneInspectorProps = { type SceneInspectorProps = {
title: string; title: string;
description: string; description: string;
sideStoryStartNodes: { id: GraphNodeId; sideStoryLineTitle: string }[];
previewAssetId: AssetId | null; previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null; previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean; previewVideoAutostart: boolean;
@@ -1805,6 +1886,7 @@ type SceneInspectorProps = {
onClearPreview: () => void; onClearPreview: () => void;
onRotatePreview: (deg: 0 | 90 | 180 | 270) => void; onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
onUploadMedia: () => void; onUploadMedia: () => void;
onSideStoryLineTitleChange: (graphNodeId: GraphNodeId, title: string) => void;
}; };
type CampaignInspectorProps = { type CampaignInspectorProps = {
@@ -1897,6 +1979,7 @@ function CampaignInspector({
function SceneInspector({ function SceneInspector({
title, title,
description, description,
sideStoryStartNodes,
previewAssetId, previewAssetId,
previewAssetType, previewAssetType,
previewVideoAutostart, previewVideoAutostart,
@@ -1915,6 +1998,7 @@ function SceneInspector({
onClearPreview, onClearPreview,
onRotatePreview, onRotatePreview,
onUploadMedia, onUploadMedia,
onSideStoryLineTitleChange,
}: SceneInspectorProps) { }: SceneInspectorProps) {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const previewUrl = useAssetUrl(previewAssetId); const previewUrl = useAssetUrl(previewAssetId);
@@ -1930,6 +2014,21 @@ function SceneInspector({
value={description} value={description}
onChange={(e) => onDescriptionChange(e.target.value)} onChange={(e) => onDescriptionChange(e.target.value)}
/> />
{sideStoryStartNodes.length > 0 ? (
<>
<div className={styles.spacer8} />
{sideStoryStartNodes.map((gn) => (
<div key={gn.id}>
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
<Input
value={gn.sideStoryLineTitle}
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
/>
<div className={styles.spacer8} />
</div>
))}
</>
) : null}
<div className={styles.spacer6} /> <div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.preview')}</div> <div className={styles.labelSm}>{t('scene.preview')}</div>
<div className={styles.hint}>{t('scene.previewHint')}</div> <div className={styles.hint}>{t('scene.previewHint')}</div>
@@ -0,0 +1,671 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
collectSceneIdsForSelections,
findSceneTitleConflicts,
storylineSelectionKey,
type SceneImportResolution,
type SceneTitleConflict,
type StorylineImportMergeReport,
type StorylineLabels,
type StorylineListItem,
type StorylineSelection,
} from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button } from '../shared/ui/controls';
import { useEditorI18n } from './i18n/EditorI18nContext';
import styles from './EditorApp.module.css';
type ExportProjectModalProps = {
open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null;
storylineLabels: StorylineLabels;
loadStorylines: (projectId: ProjectId) => Promise<StorylineListItem[]>;
onClose: () => void;
onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise<void>;
};
export function ExportProjectModal({
open,
projects,
initialProjectId,
storylineLabels,
loadStorylines,
onClose,
onExport,
}: ExportProjectModalProps) {
const { t } = useEditorI18n();
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [loadingStorylines, setLoadingStorylines] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setProjectId(initialProjectId);
setSaving(false);
setError(null);
setSelectedKeys(new Set());
}, [initialProjectId, open]);
useEffect(() => {
if (!open || !projectId) {
setStorylines([]);
return;
}
let cancelled = false;
setLoadingStorylines(true);
void (async () => {
try {
const list = await loadStorylines(projectId);
if (cancelled) return;
setStorylines(list);
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
} finally {
if (!cancelled) setLoadingStorylines(false);
}
})();
return () => {
cancelled = true;
};
}, [loadStorylines, open, projectId]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const canExport =
projectId !== null &&
projects.some((p) => p.id === projectId) &&
selectedKeys.size > 0 &&
!loadingStorylines;
const toggleKey = (key: string, disabled?: boolean) => {
if (disabled) return;
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectedSelections = storylines
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
.map((item) => item.selection);
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('export.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('export.project')}</div>
<select
className={styles.selectInput}
value={projectId ?? ''}
onChange={(e) => setProjectId((e.target.value as ProjectId) || null)}
disabled={projects.length === 0}
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.fileName})
</option>
))}
</select>
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
{loadingStorylines ? (
<div className={styles.muted}>{t('storyline.loading')}</div>
) : storylines.length === 0 ? (
<div className={styles.muted}>{t('storyline.empty')}</div>
) : (
<div className={styles.storylineChecklist}>
{storylines.map((item) => {
const key = storylineSelectionKey(item.selection);
const checked = selectedKeys.has(key);
const disabled = item.disabled === true;
return (
<label key={key} className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => toggleKey(key, disabled)}
/>
<span>{item.label}</span>
{disabled && item.disabledReason === 'main_exists' ? (
<span className={styles.muted}> {t('storyline.mainExistsHint')}</span>
) : null}
</label>
);
})}
</div>
)}
<div className={styles.muted}>{t('export.hint')}</div>
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canExport || saving}
onClick={() => {
if (!projectId || !canExport) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onExport(projectId, selectedSelections);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('export.saveAs')}
</Button>
</div>
</div>
</>,
document.body,
);
}
export type ImportPeekResult = {
kind: 'file' | 'project';
filePath?: string;
sourceProjectId?: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
};
export type ImportSourceSelection =
| { kind: 'file'; filePath: string; fileName: string }
| { kind: 'project'; sourceProjectId: ProjectId };
type ImportSourceModalProps = {
open: boolean;
/** false — только импорт из файла (полный импорт проекта). */
canImportFromProject: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
currentProjectId: ProjectId | null;
pickFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
onClose: () => void;
onContinue: (selection: ImportSourceSelection) => Promise<void>;
};
export function ImportSourceModal({
open,
canImportFromProject,
projects,
currentProjectId,
pickFile,
onClose,
onContinue,
}: ImportSourceModalProps) {
const { t } = useEditorI18n();
const [importKind, setImportKind] = useState<'project' | 'file'>('file');
const [sourceProjectId, setSourceProjectId] = useState<ProjectId | null>(null);
const [pickedFile, setPickedFile] = useState<{ path: string; name: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const availableProjects = useMemo(
() => projects.filter((p) => p.id !== currentProjectId),
[currentProjectId, projects],
);
useEffect(() => {
if (!open) return;
setImportKind(canImportFromProject && availableProjects.length > 0 ? 'project' : 'file');
setSourceProjectId(availableProjects[0]?.id ?? null);
setPickedFile(null);
setSubmitting(false);
setError(null);
}, [availableProjects, canImportFromProject, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const canContinue =
!submitting &&
(importKind === 'project'
? canImportFromProject && sourceProjectId !== null
: pickedFile !== null);
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importSource.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<div className={styles.fieldGrid}>
{canImportFromProject ? (
<>
<div className={styles.fieldLabel}>{t('importSource.type')}</div>
<select
className={styles.selectInput}
value={importKind}
onChange={(e) => setImportKind(e.target.value as 'project' | 'file')}
disabled={submitting}
>
<option value="project">{t('importSource.fromProject')}</option>
<option value="file">{t('importSource.fromFile')}</option>
</select>
</>
) : (
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
)}
{importKind === 'project' && canImportFromProject ? (
<>
<div className={styles.fieldLabel}>{t('importSource.project')}</div>
<select
className={styles.selectInput}
value={sourceProjectId ?? ''}
onChange={(e) => setSourceProjectId((e.target.value as ProjectId) || null)}
disabled={availableProjects.length === 0 || submitting}
>
{availableProjects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.fileName})
</option>
))}
</select>
{availableProjects.length === 0 ? (
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
) : null}
</>
) : (
<>
<div className={styles.fieldLabel}>{t('importSource.file')}</div>
<div className={styles.importFileRow}>
<Button
disabled={submitting}
onClick={() => {
void (async () => {
setError(null);
const res = await pickFile();
if (res.canceled) return;
const name = res.filePath.split(/[/\\]/).pop() ?? res.filePath;
setPickedFile({ path: res.filePath, name });
})();
}}
>
{t('importSource.chooseFile')}
</Button>
<span className={styles.muted}>
{pickedFile ? pickedFile.name : t('importSource.noFileSelected')}
</span>
</div>
</>
)}
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canContinue}
onClick={() => {
void (async () => {
setSubmitting(true);
setError(null);
try {
if (importKind === 'project' && canImportFromProject && sourceProjectId) {
await onContinue({ kind: 'project', sourceProjectId });
} else if (pickedFile) {
await onContinue({ kind: 'file', filePath: pickedFile.path, fileName: pickedFile.name });
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
})();
}}
>
{t('importStoryline.continue')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ImportStorylinesModalProps = {
open: boolean;
sourceName: string;
storylines: StorylineListItem[];
onClose: () => void;
onContinue: (selections: StorylineSelection[]) => void;
};
export function ImportStorylinesModal({
open,
sourceName,
storylines,
onClose,
onContinue,
}: ImportStorylinesModalProps) {
const { t } = useEditorI18n();
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
useEffect(() => {
if (!open) return;
setSelectedKeys(new Set());
}, [open, sourceName]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const toggleKey = (key: string, disabled?: boolean) => {
if (disabled) return;
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectedSelections = storylines
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
.map((item) => item.selection);
const canContinue = selectedKeys.size > 0;
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('importStoryline.source')}</div>
<div>{sourceName}</div>
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
{storylines.length === 0 ? (
<div className={styles.muted}>{t('storyline.empty')}</div>
) : (
<div className={styles.storylineChecklist}>
{storylines.map((item) => {
const key = storylineSelectionKey(item.selection);
const disabled = item.disabled === true;
return (
<label key={key} className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}>
<input
type="checkbox"
checked={selectedKeys.has(key)}
disabled={disabled}
onChange={() => toggleKey(key, disabled)}
/>
<span>{item.label}</span>
{disabled && item.disabledReason === 'main_exists' ? (
<span className={styles.muted}> {t('storyline.mainExistsHint')}</span>
) : null}
</label>
);
})}
</div>
)}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="primary"
disabled={!canContinue}
onClick={() => onContinue(selectedSelections)}
>
{t('importStoryline.continue')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type SceneConflictModalProps = {
open: boolean;
conflicts: SceneTitleConflict[];
onClose: () => void;
onConfirm: (resolutions: SceneImportResolution[]) => void;
};
export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: SceneConflictModalProps) {
const { t } = useEditorI18n();
const [choices, setChoices] = useState<Record<string, 'create' | SceneId>>({});
useEffect(() => {
if (!open) return;
const init: Record<string, 'create' | SceneId> = {};
for (const c of conflicts) {
init[c.sourceSceneId] = c.matches[0]?.sceneId ?? 'create';
}
setChoices(init);
}, [conflicts, open]);
if (!open) return null;
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<div role="dialog" aria-modal="true" className={`${styles.modalDialog} ${styles.modalDialogWide}`}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.conflictsTitle')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<p className={styles.muted}>{t('importStoryline.conflictsHint')}</p>
<div className={styles.conflictList}>
{conflicts.map((c) => (
<div key={c.sourceSceneId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceTitle}</div>
<select
className={styles.selectInput}
value={choices[c.sourceSceneId] ?? 'create'}
onChange={(e) => {
const v = e.target.value;
setChoices((prev) => ({
...prev,
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
}));
}}
>
<option value="create">{t('importStoryline.createNewScene')}</option>
{c.matches.map((m) => (
<option key={m.sceneId} value={m.sceneId}>
{t('importStoryline.useExistingScene', { title: m.title })}
</option>
))}
</select>
</div>
))}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const resolutions: SceneImportResolution[] = conflicts.map((c) => {
const choice = choices[c.sourceSceneId] ?? 'create';
if (choice === 'create') return { sourceSceneId: c.sourceSceneId, mode: 'create' };
return { sourceSceneId: c.sourceSceneId, mode: 'use', targetSceneId: choice };
});
onConfirm(resolutions);
}}
>
{t('importStoryline.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ImportReportModalProps = {
open: boolean;
report: StorylineImportMergeReport | null;
onClose: () => void;
};
export function ImportReportModal({ open, report, onClose }: ImportReportModalProps) {
const { t } = useEditorI18n();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open || !report) return null;
return createPortal(
<>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.reportTitle')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<ul className={styles.reportList}>
<li>{t('importStoryline.reportLines', { count: report.storylinesImported })}</li>
<li>{t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}</li>
<li>{t('importStoryline.reportScenesReused', { count: report.scenesReused })}</li>
<li>{t('importStoryline.reportNodes', { count: report.graphNodesAdded })}</li>
<li>{t('importStoryline.reportEdges', { count: report.edgesAdded })}</li>
<li>{t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}</li>
<li>{t('importStoryline.reportAssetsReused', { count: report.assetsReused })}</li>
{report.renamedSideTitles.length > 0 ? (
<li>
{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}
</li>
) : null}
</ul>
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
export function buildSceneResolutionsForImport(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
conflicts: SceneTitleConflict[],
userResolutions: SceneImportResolution[],
): SceneImportResolution[] {
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
const conflictIds = new Set(conflicts.map((c) => c.sourceSceneId));
const bySource = new Map(userResolutions.map((r) => [r.sourceSceneId, r]));
const out: SceneImportResolution[] = [];
for (const sid of sceneIds) {
if (conflictIds.has(sid)) {
const r = bySource.get(sid);
if (r) out.push(r);
continue;
}
out.push({ sourceSceneId: sid, mode: 'create' });
}
return out;
}
export function computeImportConflicts(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
): SceneTitleConflict[] {
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
return findSceneTitleConflicts(targetProject, sourceProject, sceneIds);
}
export function useStorylineLabels(): StorylineLabels {
const { t } = useEditorI18n();
return useMemo(
() => ({
main: t('storyline.main'),
untitled: t('graph.untitled'),
}),
[t],
);
}
@@ -8,6 +8,12 @@
height: 8px; height: 8px;
} }
.handleSide {
background: var(--side-story-handle);
width: 8px;
height: 8px;
}
.card { .card {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@@ -26,6 +32,13 @@
0 25px 50px -12px rgba(167, 139, 250, 0.12); 0 25px 50px -12px rgba(167, 139, 250, 0.12);
} }
.cardActiveSide {
border-color: rgba(0, 120, 212, 0.95);
box-shadow:
0 0 0 2px rgba(0, 120, 212, 0.35),
0 25px 50px -12px rgba(0, 120, 212, 0.12);
}
.previewShell { .previewShell {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -65,6 +78,21 @@
box-shadow: var(--shadow-start-badge); box-shadow: var(--shadow-start-badge);
} }
.badgeSideStory {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
font-size: 8.5px;
font-weight: 800;
letter-spacing: 0.4px;
padding: 4px 8px;
border-radius: 8px;
background: var(--side-story-fill-solid);
color: var(--text-on-accent);
box-shadow: var(--shadow-side-story-badge);
}
.cornerBadges { .cornerBadges {
position: absolute; position: absolute;
top: 8px; top: 8px;
+125 -54
View File
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import ReactFlow, { import ReactFlow, {
Background, Background,
ConnectionMode,
Handle, Handle,
MarkerType, MarkerType,
Panel, Panel,
@@ -19,6 +20,11 @@ import ReactFlow, {
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules'; import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
import {
canSetSideStoryStart,
isNodeInSideStoryline,
isSideStoryEdge,
} from '../../../shared/graph/sceneGraphLineage';
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types'; import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
import { RotatedImage } from '../../shared/RotatedImage'; import { RotatedImage } from '../../shared/RotatedImage';
import { useAssetUrl } from '../../shared/useAssetImageUrl'; import { useAssetUrl } from '../../shared/useAssetImageUrl';
@@ -53,6 +59,7 @@ const SCENE_CARD_H = 248;
/** UI strings for the scene graph (passed from editor i18n). */ /** UI strings for the scene graph (passed from editor i18n). */
export type SceneGraphUiStrings = { export type SceneGraphUiStrings = {
badgeStart: string; badgeStart: string;
badgeSideStory: string;
untitled: string; untitled: string;
videoBadge: string; videoBadge: string;
audioBadge: string; audioBadge: string;
@@ -67,12 +74,15 @@ export type SceneGraphUiStrings = {
closeMenu: string; closeMenu: string;
startScene: string; startScene: string;
unsetStartScene: string; unsetStartScene: string;
sideStoryStartScene: string;
unsetSideStoryStartScene: string;
runFromScene: string; runFromScene: string;
delete: string; delete: string;
}; };
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = { const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
badgeStart: 'НАЧАЛО', badgeStart: 'НАЧАЛО',
badgeSideStory: 'ПОБОЧНАЯ',
untitled: 'Без названия', untitled: 'Без названия',
videoBadge: 'Видео', videoBadge: 'Видео',
audioBadge: 'Аудио', audioBadge: 'Аудио',
@@ -87,6 +97,8 @@ const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
closeMenu: 'Закрыть меню', closeMenu: 'Закрыть меню',
startScene: 'Начальная сцена', startScene: 'Начальная сцена',
unsetStartScene: 'Снять метку «Начальная сцена»', unsetStartScene: 'Снять метку «Начальная сцена»',
sideStoryStartScene: 'Начальная сцена побочной линии',
unsetSideStoryStartScene: 'Снять метку «Начальная сцена побочной линии»',
runFromScene: 'Запустить с этой сцены', runFromScene: 'Запустить с этой сцены',
delete: 'Удалить', delete: 'Удалить',
}; };
@@ -97,15 +109,17 @@ export type SceneGraphProps = {
sceneGraphNodes: SceneGraphNode[]; sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[]; sceneGraphEdges: SceneGraphEdge[];
sceneCardById: Record<SceneId, SceneGraphSceneCard>; sceneCardById: Record<SceneId, SceneGraphSceneCard>;
currentSceneId: SceneId | null; /** Выделенная карточка на графе (одна нода, не все копии сцены). */
selectedGraphNodeId: GraphNodeId | null;
graphUi?: SceneGraphUiStrings; graphUi?: SceneGraphUiStrings;
onCurrentSceneChange: (id: SceneId) => void; onGraphNodeSelect: (graphNodeId: GraphNodeId, sceneId: SceneId) => void;
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void; onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
onDisconnect: (edgeId: string) => void; onDisconnect: (edgeId: string) => void;
onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void; onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void;
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void; onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void; onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void; onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
onSetGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => void;
onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void; onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void;
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void; onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
}; };
@@ -120,6 +134,8 @@ type SceneCardData = {
previewVideoAutostart: boolean; previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270; previewRotationDeg: 0 | 90 | 180 | 270;
isStartScene: boolean; isStartScene: boolean;
isSideStoryStart: boolean;
isSideStoryNode: boolean;
hasSceneAudio: boolean; hasSceneAudio: boolean;
previewIsVideo: boolean; previewIsVideo: boolean;
hasAnyAudioLoop: boolean; hasAnyAudioLoop: boolean;
@@ -179,15 +195,24 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
const ui = useContext(GraphUiContext); const ui = useContext(GraphUiContext);
const thumbUrl = useAssetUrl(data.previewThumbAssetId); const thumbUrl = useAssetUrl(data.previewThumbAssetId);
const previewUrl = useAssetUrl(data.previewAssetId); const previewUrl = useAssetUrl(data.previewAssetId);
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' '); const cardClass = [
styles.card,
data.active ? (data.isSideStoryNode ? styles.cardActiveSide : styles.cardActive) : '',
]
.filter(Boolean)
.join(' ');
const handleClass = data.isSideStoryNode ? styles.handleSide : styles.handle;
const showCornerVideo = data.previewIsVideo; const showCornerVideo = data.previewIsVideo;
const showCornerAudio = data.hasSceneAudio; const showCornerAudio = data.hasSceneAudio;
return ( return (
<div className={styles.nodeWrap}> <div className={styles.nodeWrap}>
<Handle type="target" position={Position.Top} className={styles.handle} /> <Handle type="target" position={Position.Top} className={handleClass} />
<div className={cardClass}> <div className={cardClass}>
<div className={styles.previewShell}> <div className={styles.previewShell}>
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null} {data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
{data.isSideStoryStart ? (
<div className={styles.badgeSideStory}>{ui.badgeSideStory}</div>
) : null}
{thumbUrl ? ( {thumbUrl ? (
<div className={styles.previewFill}> <div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? ( {data.previewRotationDeg === 0 ? (
@@ -303,7 +328,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
) : null} ) : null}
</div> </div>
</div> </div>
<Handle type="source" position={Position.Bottom} className={styles.handle} /> <Handle type="source" position={Position.Bottom} className={handleClass} />
</div> </div>
); );
} }
@@ -353,15 +378,16 @@ function SceneGraphCanvas({
sceneGraphNodes, sceneGraphNodes,
sceneGraphEdges, sceneGraphEdges,
sceneCardById, sceneCardById,
currentSceneId, selectedGraphNodeId,
graphUi, graphUi,
onCurrentSceneChange, onGraphNodeSelect,
onConnect, onConnect,
onDisconnect, onDisconnect,
onNodePositionCommit, onNodePositionCommit,
onRemoveGraphNodes, onRemoveGraphNodes,
onRemoveGraphNode, onRemoveGraphNode,
onSetGraphNodeStart, onSetGraphNodeStart,
onSetGraphNodeSideStoryStart,
onRunFromGraphNode, onRunFromGraphNode,
onDropSceneFromList, onDropSceneFromList,
}: SceneGraphProps) { }: SceneGraphProps) {
@@ -387,10 +413,31 @@ function SceneGraphCanvas({
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene); return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene);
}, [menu, sceneGraphNodes]); }, [menu, sceneGraphNodes]);
const menuNodeIsSideStoryStart = useMemo(() => {
if (!menu) return false;
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isSideStoryStart);
}, [menu, sceneGraphNodes]);
const menuCanSetSideStoryStart = useMemo(() => {
if (!menu) return false;
return canSetSideStoryStart(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
}, [menu, sceneGraphEdges, sceneGraphNodes]);
const menuNodeIsSideBranch = useMemo(() => {
if (!menu) return false;
return isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
}, [menu, sceneGraphEdges, sceneGraphNodes]);
const sideStoryEdgeStroke = 'rgba(0,120,212,0.95)';
const sideStoryEdgeStrokeDim = 'rgba(0,120,212,0.55)';
const mainEdgeStroke = 'rgba(167,139,250,0.95)';
const mainEdgeStrokeDim = 'rgba(167,139,250,0.55)';
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => { const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
return sceneGraphNodes.map((gn) => { return sceneGraphNodes.map((gn) => {
const c = sceneCardById[gn.sceneId]; const c = sceneCardById[gn.sceneId];
const active = gn.sceneId === currentSceneId; const active = selectedGraphNodeId === gn.id;
const isSideStoryNode = isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, gn.id);
const audios = c?.audios ?? []; const audios = c?.audios ?? [];
return { return {
id: gn.id, id: gn.id,
@@ -406,6 +453,8 @@ function SceneGraphCanvas({
previewVideoAutostart: c?.previewVideoAutostart ?? false, previewVideoAutostart: c?.previewVideoAutostart ?? false,
previewRotationDeg: c?.previewRotationDeg ?? 0, previewRotationDeg: c?.previewRotationDeg ?? 0,
isStartScene: gn.isStartScene, isStartScene: gn.isStartScene,
isSideStoryStart: gn.isSideStoryStart,
isSideStoryNode,
hasSceneAudio: audios.length >= 1, hasSceneAudio: audios.length >= 1,
previewIsVideo: c?.previewAssetType === 'video', previewIsVideo: c?.previewAssetType === 'video',
hasAnyAudioLoop: audios.some((a) => a.loop), hasAnyAudioLoop: audios.some((a) => a.loop),
@@ -416,40 +465,46 @@ function SceneGraphCanvas({
style: { padding: 0, background: 'transparent', border: 'none' }, style: { padding: 0, background: 'transparent', border: 'none' },
}; };
}); });
}, [currentSceneId, sceneCardById, sceneGraphNodes]); }, [sceneCardById, sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
const desiredEdges = useMemo<Edge[]>(() => { const desiredEdges = useMemo<Edge[]>(() => {
const selectedGraphNodeIds = new Set<GraphNodeId>(); const hasSelection = selectedGraphNodeId != null;
if (currentSceneId) { return sceneGraphEdges.map((e) => {
for (const gn of sceneGraphNodes) { const isSide = isSideStoryEdge(sceneGraphNodes, sceneGraphEdges, e);
if (gn.sceneId === currentSceneId) selectedGraphNodeIds.add(gn.id); const strokeActive = isSide ? sideStoryEdgeStroke : mainEdgeStroke;
} const strokeIdle = isSide ? sideStoryEdgeStrokeDim : mainEdgeStrokeDim;
} const strokeDim = 'rgba(255,255,255,0.10)';
const hasSelection = selectedGraphNodeIds.size > 0; const markerDim = 'rgba(255,255,255,0.18)';
return sceneGraphEdges.map((e) => ({ const touchesSelection =
...(hasSelection selectedGraphNodeId != null &&
? { (e.sourceGraphNodeId === selectedGraphNodeId || e.targetGraphNodeId === selectedGraphNodeId);
style: return {
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId) ...(hasSelection
? { stroke: 'rgba(167,139,250,0.95)', strokeWidth: 3 } ? {
: { stroke: 'rgba(255,255,255,0.10)', strokeWidth: 2 }, style: touchesSelection
markerEnd: ? { stroke: strokeActive, strokeWidth: 3 }
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId) : { stroke: strokeDim, strokeWidth: 2 },
? { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.95)', strokeWidth: 2 } markerEnd: touchesSelection
: { type: MarkerType.ArrowClosed, color: 'rgba(255,255,255,0.18)', strokeWidth: 2 }, ? { type: MarkerType.ArrowClosed, color: strokeActive, strokeWidth: 2 }
} : { type: MarkerType.ArrowClosed, color: markerDim, strokeWidth: 2 },
: { }
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 }, : {
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 }, style: { stroke: strokeIdle, strokeWidth: 2 },
}), markerEnd: {
id: e.id, type: MarkerType.ArrowClosed,
source: e.sourceGraphNodeId, color: isSide ? 'rgba(0,120,212,0.85)' : 'rgba(167,139,250,0.85)',
target: e.targetGraphNodeId, strokeWidth: 2,
type: 'smoothstep', },
animated: false, }),
selectable: false, id: e.id,
})); source: e.sourceGraphNodeId,
}, [currentSceneId, sceneGraphEdges, sceneGraphNodes]); target: e.targetGraphNodeId,
type: 'smoothstep',
animated: false,
selectable: false,
};
});
}, [sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]); const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
@@ -494,7 +549,7 @@ function SceneGraphCanvas({
if (!menu) return null; if (!menu) return null;
const pad = 8; const pad = 8;
const mw = 220; const mw = 220;
const mh = 168; const mh = 210;
const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad)); const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad));
const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad)); const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad));
return { x, y }; return { x, y };
@@ -523,6 +578,7 @@ function SceneGraphCanvas({
}} }}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
isValidConnection={isValidConnection} isValidConnection={isValidConnection}
connectionMode={ConnectionMode.Loose}
onConnect={onConnectInternal} onConnect={onConnectInternal}
onEdgeContextMenu={(e, edge) => { onEdgeContextMenu={(e, edge) => {
e.preventDefault(); e.preventDefault();
@@ -536,7 +592,7 @@ function SceneGraphCanvas({
setMenu(null); setMenu(null);
setEdgeMenu(null); setEdgeMenu(null);
const d = node.data as SceneCardData; const d = node.data as SceneCardData;
onCurrentSceneChange(d.sceneId); onGraphNodeSelect(node.id as GraphNodeId, d.sceneId);
}} }}
onNodeContextMenu={(e, node) => { onNodeContextMenu={(e, node) => {
e.preventDefault(); e.preventDefault();
@@ -599,18 +655,33 @@ function SceneGraphCanvas({
> >
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene} {menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
</button> </button>
<button {menuNodeIsSideStoryStart || menuCanSetSideStoryStart ? (
type="button" <button
role="menuitem" type="button"
className={styles.ctxItem} role="menuitem"
disabled={!onRunFromGraphNode} className={styles.ctxItem}
onClick={() => { onClick={() => {
onRunFromGraphNode?.(menu.graphNodeId); onSetGraphNodeSideStoryStart(menu.graphNodeId);
setMenu(null); setMenu(null);
}} }}
> >
{ui.runFromScene} {menuNodeIsSideStoryStart ? ui.unsetSideStoryStartScene : ui.sideStoryStartScene}
</button> </button>
) : null}
{!menuNodeIsSideBranch ? (
<button
type="button"
role="menuitem"
className={styles.ctxItem}
disabled={!onRunFromGraphNode}
onClick={() => {
onRunFromGraphNode?.(menu.graphNodeId);
setMenu(null);
}}
>
{ui.runFromScene}
</button>
) : null}
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
@@ -16,3 +16,11 @@ void test('SceneGraph: контекстное меню узла — «Запус
assert.ok(src.includes('onRunFromGraphNode')); assert.ok(src.includes('onRunFromGraphNode'));
assert.ok(src.includes('onRunFromGraphNode?.(menu.graphNodeId)')); assert.ok(src.includes('onRunFromGraphNode?.(menu.graphNodeId)'));
}); });
void test('SceneGraph: побочная линия — меню старта и скрытие Run', () => {
const src = readSceneGraph();
assert.ok(src.includes('sideStoryStartScene'));
assert.ok(src.includes('onSetGraphNodeSideStoryStart'));
assert.ok(src.includes('menuNodeIsSideBranch'));
assert.ok(src.includes('badgeSideStory'));
});
+1
View File
@@ -5,6 +5,7 @@ export const HELP_SECTION_IDS = [
'projects', 'projects',
'scenes', 'scenes',
'graph', 'graph',
'sideStorylines',
'sceneProps', 'sceneProps',
'campaignAudio', 'campaignAudio',
'session', 'session',
+99 -6
View File
@@ -160,11 +160,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.graph.title': 'Граф сцен', 'help.section.graph.title': 'Граф сцен',
'help.section.graph.body': 'help.section.graph.body':
'Карта в центре показывает, как эпизоды связаны. Каждый прямоугольник — место на схеме; одна сцена может встретиться несколько раз (например, игроки снова возвращаются в таверну).\n\nДобавить сцену на карту:\n\n1) Возьмите сцену в левом списке.\n\n2) Перетащите на свободное место на карте.\n\nСделать переход между сценами:\n\n1) Наведите на нижнюю точку первой карточки.\n\n2) Потяните линию к верхней точке второй и отпустите — появится стрелка.\n\nИз одной сцены может выходить несколько стрелок — так вы делаете ветвление сюжета. Вторую стрелку к той же паре сцен провести нельзя.\n\nУдалить стрелку: правый клик по линии → «Удалить». Обычный клик по линии ничего не делает.\n\nС чего начинается игра: правый клик по карточке → «Начальная сцена» (появится метка «НАЧАЛО»), затем «Запустить» в шапке. Можно начать с любой карточки: ПКМ → «Запустить с этой сцены» — откроются презентация и пульт с выбранного места (метку «НАЧАЛО» ставить не обязательно).\n\nУбрать карточку с карты, не удаляя сцену из списка: ПКМ → «Удалить».\n\nМасштаб — кнопки внизу или колёсико мыши. «Показать всё» вместит всю схему на экран.', 'Карта в центре показывает, как эпизоды связаны. Каждый прямоугольник — место на схеме; одна сцена может встретиться несколько раз (например, игроки снова возвращаются в таверну).\n\nДобавить сцену на карту:\n\n1) Возьмите сцену в левом списке.\n\n2) Перетащите на свободное место на карте.\n\nСделать переход между сценами:\n\n1) Наведите на нижнюю точку первой карточки.\n\n2) Потяните линию к верхней точке второй и отпустите — появится стрелка.\n\nИз одной сцены может выходить несколько стрелок — так вы делаете ветвление сюжета. Вторую стрелку к той же паре сцен провести нельзя.\n\nУдалить стрелку: правый клик по линии → «Удалить». Обычный клик по линии ничего не делает.\n\nС чего начинается игра: правый клик по карточке → «Начальная сцена» (появится метка «НАЧАЛО»), затем «Запустить» в шапке. Можно начать с любой карточки основного сюжета: ПКМ → «Запустить с этой сцены» — откроются презентация и пульт с выбранного места (метку «НАЧАЛО» ставить не обязательно). Для карточек побочных линий этот пункт недоступен.\n\nУбрать карточку с карты, не удаляя сцену из списка: ПКМ → «Удалить».\n\nМасштаб — кнопки внизу или колёсико мыши. «Показать всё» вместит всю схему на экран.',
'help.section.sideStorylines.title': 'Побочные сюжетные линии',
'help.section.sideStorylines.body':
'Побочная линия — отдельная ветка сюжета, не связанная с основным сюжетом. Она нужна для ответвлений, флешбэков, побочных квестов и сцен «вне основного пути».\n\nСоздать побочную линию в редакторе:\n\n1) Добавьте сцены на карту и соедините их стрелками в отдельной группе — она не должна касаться основного сюжета (фиолетовые связи) и других побочных линий.\n\n2) Правый клик по стартовой карточке побочной ветки → «Начальная сцена побочной линии». Появится синяя метка «ПОБОЧНАЯ».\n\n3) В свойствах сцены задайте «Название побочной линии» — оно будет видно на пульте.\n\nПункт «Начальная сцена побочной линии» скрыт, если карточка уже связана с основным сюжетом (где есть фиолетовое «НАЧАЛО») или с другой побочной линией (где есть синее «ПОБОЧНАЯ»).\n\nСвязи внутри побочной линии и выделение её карточек — синего цвета (#0078d4). Между основным и побочным сюжетом, а также между разными побочными линиями, стрелки провести нельзя.\n\nСнять метку: ПКМ → «Снять метку «Начальная сцена побочной линии»». Название очистится, плитка исчезнет с пульта.\n\nУдалить стартовую карточку: если есть следующая сцена по стрелке — метка переносится на неё; если нет — вся побочная линия удаляется с карты.\n\nВо время игры на пульте под блоком «Музыка» появляется «Побочные сюжетные линии» — плитки с превью и названием. Клик переносит партию на первую сцену линии. Программа запоминает, с какой сцены основного сюжета вы ушли.\n\nПока идёт побочная линия, в «Варианты ветвления» первой опцией всегда «Вернуться в основной сюжет» — возврат на запомненную сцену. История «Сюжетная линия» продолжает записывать все шаги, включая побочную ветку.\n\nЗапустить побочную линию из редактора нельзя — только с пульта во время сессии.',
'help.section.sceneProps.title': 'Свойства сцены', 'help.section.sceneProps.title': 'Свойства сцены',
'help.section.sceneProps.body': 'help.section.sceneProps.body':
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» и «Описание» — для мастера. Описание видно на пульте в блоке сюжетной линии.\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки можно включить «Затемнить сцену»: при показе игроки сначала увидят карту в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков. На видео-сценах эффекты кистью недоступны.\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».', 'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» и «Описание» — для мастера. Описание видно на пульте в блоке сюжетной линии.\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки можно включить «Затемнить сцену»: при показе игроки сначала увидят карту в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков. На видео-сценах эффекты кистью недоступны.\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
'help.section.campaignAudio.title': 'Аудио игры', 'help.section.campaignAudio.title': 'Аудио игры',
'help.section.campaignAudio.body': 'help.section.campaignAudio.body':
@@ -247,10 +251,46 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.title': 'Экспорт проекта', 'export.title': 'Экспорт проекта',
'export.project': 'ПРОЕКТ', 'export.project': 'ПРОЕКТ',
'export.hint': 'export.hint':
'Далее откроется окно сохранения: укажите имя и папку для файла .ttrpg.zip — будет создана копия архива проекта.', 'Выберите сюжетные линии для экспорта. В архив попадут только выбранные линии, их сцены и материалы. Далее откроется окно сохранения .ttrpg.zip.',
'export.exporting': 'Экспорт…', 'export.exporting': 'Экспорт…',
'export.saveAs': 'Сохранить как…', 'export.saveAs': 'Сохранить как…',
'storyline.section': 'СЮЖЕТНАЯ ЛИНИЯ',
'storyline.main': 'Основная линия',
'storyline.loading': 'Загрузка линий…',
'storyline.empty': 'В проекте нет сюжетных линий с метками «НАЧАЛО» или «ПОБОЧНАЯ».',
'storyline.mainExistsHint': 'в проекте уже есть основная линия',
'importSource.title': 'Импорт',
'importSource.type': 'ТИП ИМПОРТА',
'importSource.fromProject': 'Из проекта',
'importSource.fromFile': 'Из файла',
'importSource.project': 'ПРОЕКТ',
'importSource.file': 'ФАЙЛ',
'importSource.chooseFile': 'Выбрать файл',
'importSource.noFileSelected': 'Файл не выбран',
'importSource.noOtherProjects': 'Нет других проектов для импорта.',
'importSource.fileOnlyHint': 'Выберите файл проекта (.ttrpg.zip) для полного импорта.',
'importStoryline.title': 'Импорт сюжетных линий',
'importStoryline.source': 'ИСТОЧНИК',
'importStoryline.continue': 'Далее',
'importStoryline.import': 'Импортировать',
'importStoryline.conflictsTitle': 'Совпадение названий сцен',
'importStoryline.conflictsHint':
'В импортируемых линиях есть сцены с такими же названиями, как в текущем проекте. Выберите действие для каждой.',
'importStoryline.createNewScene': 'Создать новую сцену',
'importStoryline.useExistingScene': 'Использовать «{title}»',
'importStoryline.reportTitle': 'Импорт завершён',
'importStoryline.reportLines': 'Импортировано линий: {count}',
'importStoryline.reportScenesCreated': 'Создано новых сцен: {count}',
'importStoryline.reportScenesReused': 'Использовано существующих сцен: {count}',
'importStoryline.reportNodes': 'Добавлено карточек на граф: {count}',
'importStoryline.reportEdges': 'Добавлено связей: {count}',
'importStoryline.reportAssetsCopied': 'Скопировано файлов материалов: {count}',
'importStoryline.reportAssetsReused': 'Повторно использовано материалов: {count}',
'importStoryline.reportRenamedSides': 'Переименованы побочные линии: {names}',
'confirmDelete.title': 'Удаление проекта', 'confirmDelete.title': 'Удаление проекта',
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.', 'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
'confirmDelete.failedTitle': 'Не удалось удалить', 'confirmDelete.failedTitle': 'Не удалось удалить',
@@ -299,6 +339,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'sceneCard.menu': 'Меню сцены', 'sceneCard.menu': 'Меню сцены',
'graph.badgeStart': 'НАЧАЛО', 'graph.badgeStart': 'НАЧАЛО',
'graph.badgeSideStory': 'ПОБОЧНАЯ',
'graph.untitled': 'Без названия', 'graph.untitled': 'Без названия',
'graph.videoBadge': 'Видео', 'graph.videoBadge': 'Видео',
'graph.audioBadge': 'Аудио', 'graph.audioBadge': 'Аудио',
@@ -312,8 +353,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'graph.fitAll': 'Показать всё', 'graph.fitAll': 'Показать всё',
'graph.startScene': 'Начальная сцена', 'graph.startScene': 'Начальная сцена',
'graph.unsetStartScene': 'Снять метку «Начальная сцена»', 'graph.unsetStartScene': 'Снять метку «Начальная сцена»',
'graph.sideStoryStartScene': 'Начальная сцена побочной линии',
'graph.unsetSideStoryStartScene': 'Снять метку «Начальная сцена побочной линии»',
'graph.runFromScene': 'Запустить с этой сцены', 'graph.runFromScene': 'Запустить с этой сцены',
'scene.sideStoryLineTitle': 'Название побочной линии',
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ', 'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
'control.effects': 'ЭФФЕКТЫ', 'control.effects': 'ЭФФЕКТЫ',
'control.tools': 'Инструменты', 'control.tools': 'Инструменты',
@@ -343,6 +388,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.videoBrushHint': 'control.videoBrushHint':
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).', 'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
'control.branches': 'Варианты ветвления', 'control.branches': 'Варианты ветвления',
'control.returnToMainStory': 'Вернуться в основной сюжет',
'control.sideStoryLines': 'Побочные сюжетные линии',
'control.option': 'ОПЦИЯ {n}', 'control.option': 'ОПЦИЯ {n}',
'control.unnamed': 'Без названия', 'control.unnamed': 'Без названия',
'control.switchScene': 'Переключить', 'control.switchScene': 'Переключить',
@@ -488,11 +535,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.graph.title': 'Scene graph', 'help.section.graph.title': 'Scene graph',
'help.section.graph.body': 'help.section.graph.body':
'The map in the center shows how episodes connect. Each box is a spot on your story; the same scene can appear more than once (for example, players return to the tavern).\n\nPlace a scene on the map:\n\n1) Grab a scene in the left list.\n\n2) Drag it onto empty space on the map.\n\nConnect two scenes:\n\n1) Point at the bottom dot on the first card.\n\n2) Drag a line to the top dot on the second and release — an arrow appears.\n\nOne scene can have several outgoing arrows — that is how you branch the story. You cannot draw a second arrow between the same pair.\n\nRemove an arrow: right-click the line → Delete. A normal click on the line does nothing.\n\nSet where the game starts: right-click a card → Start scene (a START badge appears), then click Run in the header. You can also start from any card: right-click → Start from this scene — presentation and the control panel open at that spot (no START badge required).\n\nRemove a card from the map without deleting the scene from the list: right-click → Delete.\n\nZoom with the buttons at the bottom or the mouse wheel. Fit view shows the whole map.', 'The map in the center shows how episodes connect. Each box is a spot on your story; the same scene can appear more than once (for example, players return to the tavern).\n\nPlace a scene on the map:\n\n1) Grab a scene in the left list.\n\n2) Drag it onto empty space on the map.\n\nConnect two scenes:\n\n1) Point at the bottom dot on the first card.\n\n2) Drag a line to the top dot on the second and release — an arrow appears.\n\nOne scene can have several outgoing arrows — that is how you branch the story. You cannot draw a second arrow between the same pair.\n\nRemove an arrow: right-click the line → Delete. A normal click on the line does nothing.\n\nSet where the game starts: right-click a card → Start scene (a START badge appears), then click Run in the header. You can also start from any main-story card: right-click → Start from this scene — presentation and the control panel open at that spot (no START badge required). Side-story cards do not offer this menu item.\n\nRemove a card from the map without deleting the scene from the list: right-click → Delete.\n\nZoom with the buttons at the bottom or the mouse wheel. Fit view shows the whole map.',
'help.section.sideStorylines.title': 'Side storylines',
'help.section.sideStorylines.body':
'A side storyline is a separate branch not connected to the main plot — for detours, flashbacks, side quests, and scenes off the main path.\n\nCreate one in the editor:\n\n1) Place scenes on the map and link them in an isolated group — it must not touch the main story (purple links) or other side storylines.\n\n2) Right-click the starting card → Side storyline start scene. A blue SIDE badge appears.\n\n3) In scene properties, set Side storyline title — it appears on the control panel.\n\nThe menu item is hidden if the card is already linked to the main story (purple START anywhere in the group) or another side storyline (blue SIDE in the group).\n\nLinks inside a side storyline and card selection use blue (#0078d4). You cannot link main to side, or one side storyline to another.\n\nClear the mark: right-click → Clear side storyline start mark. The title is cleared and the tile disappears from the control panel.\n\nDeleting the start card: if there is a next scene along an arrow, the mark moves to it; otherwise the whole side storyline is removed from the map.\n\nDuring play, Side storylines appears under Music on the control panel — tiles with preview and title. Clicking jumps to the first scene. The app remembers which main-story scene you left from.\n\nWhile in a side storyline, Branch options always lists Return to main story first — back to the remembered scene. Storyline history keeps recording all steps, including inside side branches.\n\nYou cannot launch a side storyline from the editor — only from the control panel during a session.',
'help.section.sceneProps.title': 'Scene properties', 'help.section.sceneProps.title': 'Scene properties',
'help.section.sceneProps.body': 'help.section.sceneProps.body':
'Select a scene in the left list — its properties open on the right.\n\nScene title and Description are for the GM. The description appears in the storyline on the control panel.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects are not available on video scenes.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.', 'Select a scene in the left list — its properties open on the right.\n\nScene title and Description are for the GM. The description appears in the storyline on the control panel.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects are not available on video scenes.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
'help.section.campaignAudio.title': 'Game audio', 'help.section.campaignAudio.title': 'Game audio',
'help.section.campaignAudio.body': 'help.section.campaignAudio.body':
@@ -575,10 +626,46 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.title': 'Export project', 'export.title': 'Export project',
'export.project': 'PROJECT', 'export.project': 'PROJECT',
'export.hint': 'export.hint':
'A save dialog will open: choose a name and folder for the .ttrpg.zip file — a copy of the project archive will be created.', 'Select storylines to export. The archive will include only the chosen lines, their scenes, and assets. Then choose where to save the .ttrpg.zip file.',
'export.exporting': 'Exporting…', 'export.exporting': 'Exporting…',
'export.saveAs': 'Save as…', 'export.saveAs': 'Save as…',
'storyline.section': 'STORYLINE',
'storyline.main': 'Main storyline',
'storyline.loading': 'Loading storylines…',
'storyline.empty': 'This project has no storylines marked with START or SIDE badges.',
'storyline.mainExistsHint': 'main storyline already exists in this project',
'importSource.title': 'Import',
'importSource.type': 'IMPORT TYPE',
'importSource.fromProject': 'From project',
'importSource.fromFile': 'From file',
'importSource.project': 'PROJECT',
'importSource.file': 'FILE',
'importSource.chooseFile': 'Choose file',
'importSource.noFileSelected': 'No file selected',
'importSource.noOtherProjects': 'No other projects available to import from.',
'importSource.fileOnlyHint': 'Choose a project file (.ttrpg.zip) for a full import.',
'importStoryline.title': 'Import storylines',
'importStoryline.source': 'SOURCE',
'importStoryline.continue': 'Continue',
'importStoryline.import': 'Import',
'importStoryline.conflictsTitle': 'Duplicate scene titles',
'importStoryline.conflictsHint':
'Imported storylines contain scenes with the same titles as in the current project. Choose what to do for each.',
'importStoryline.createNewScene': 'Create new scene',
'importStoryline.useExistingScene': 'Use existing «{title}»',
'importStoryline.reportTitle': 'Import complete',
'importStoryline.reportLines': 'Storylines imported: {count}',
'importStoryline.reportScenesCreated': 'New scenes created: {count}',
'importStoryline.reportScenesReused': 'Existing scenes reused: {count}',
'importStoryline.reportNodes': 'Graph cards added: {count}',
'importStoryline.reportEdges': 'Connections added: {count}',
'importStoryline.reportAssetsCopied': 'Asset files copied: {count}',
'importStoryline.reportAssetsReused': 'Assets reused: {count}',
'importStoryline.reportRenamedSides': 'Renamed side storylines: {names}',
'confirmDelete.title': 'Delete project', 'confirmDelete.title': 'Delete project',
'confirmDelete.body': 'confirmDelete.body':
'Permanently delete project “{name}”? The file and cache will be removed from disk.', 'Permanently delete project “{name}”? The file and cache will be removed from disk.',
@@ -619,6 +706,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scene.darkenScene': 'Darken scene', 'scene.darkenScene': 'Darken scene',
'scene.rotate': 'Rotate', 'scene.rotate': 'Rotate',
'scene.audio': 'SCENE AUDIO', 'scene.audio': 'SCENE AUDIO',
'scene.sideStoryLineTitle': 'Side storyline title',
'scene.removeTitle': 'Remove from scene', 'scene.removeTitle': 'Remove from scene',
'scene.branching': 'BRANCHING', 'scene.branching': 'BRANCHING',
'scene.branchingHint': 'scene.branchingHint':
@@ -628,6 +716,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'sceneCard.menu': 'Scene menu', 'sceneCard.menu': 'Scene menu',
'graph.badgeStart': 'START', 'graph.badgeStart': 'START',
'graph.badgeSideStory': 'SIDE',
'graph.untitled': 'Untitled', 'graph.untitled': 'Untitled',
'graph.videoBadge': 'Video', 'graph.videoBadge': 'Video',
'graph.audioBadge': 'Audio', 'graph.audioBadge': 'Audio',
@@ -641,6 +730,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'graph.fitAll': 'Fit view', 'graph.fitAll': 'Fit view',
'graph.startScene': 'Start scene', 'graph.startScene': 'Start scene',
'graph.unsetStartScene': 'Clear start scene mark', 'graph.unsetStartScene': 'Clear start scene mark',
'graph.sideStoryStartScene': 'Side storyline start scene',
'graph.unsetSideStoryStartScene': 'Clear side storyline start mark',
'graph.runFromScene': 'Start from this scene', 'graph.runFromScene': 'Start from this scene',
'control.remoteTitle': 'CONTROL PANEL', 'control.remoteTitle': 'CONTROL PANEL',
@@ -672,6 +763,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'control.videoBrushHint': 'control.videoBrushHint':
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).', 'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
'control.branches': 'Branch options', 'control.branches': 'Branch options',
'control.returnToMainStory': 'Return to main story',
'control.sideStoryLines': 'Side storylines',
'control.option': 'OPTION {n}', 'control.option': 'OPTION {n}',
'control.unnamed': 'Untitled', 'control.unnamed': 'Untitled',
'control.switchScene': 'Switch', 'control.switchScene': 'Switch',
@@ -17,8 +17,9 @@ void test('projectState: list/get after delete invalidates in-flight initial loa
); );
assert.match( assert.match(
src, src,
/const openProject = async[\s\S]+?openInFlightRef\.current[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?await api\.invoke/, /const openProject = async[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?const epoch = projectDataEpochRef\.current[\s\S]+?openInFlightRef\.current = null[\s\S]+?if \(projectDataEpochRef\.current !== epoch\)/,
); );
assert.match(src, /openInFlightRef\.current = null[\s\S]+?openingProjectId: null/);
assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/); assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/);
}); });
+214 -18
View File
@@ -1,6 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts'; import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
import type {
SceneImportResolution,
StorylineImportMergeReport,
StorylineLabels,
StorylineListItem,
StorylineSelection,
} from '../../../shared/graph/storylineExportImport';
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types'; import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi'; import { getDndApi } from '../../shared/dndApi';
@@ -54,10 +61,59 @@ type Actions = {
addSceneGraphEdge: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => Promise<void>; addSceneGraphEdge: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => Promise<void>;
removeSceneGraphEdge: (edgeId: string) => Promise<void>; removeSceneGraphEdge: (edgeId: string) => Promise<void>;
setSceneGraphNodeStart: (graphNodeId: GraphNodeId | null) => Promise<void>; setSceneGraphNodeStart: (graphNodeId: GraphNodeId | null) => Promise<void>;
setSceneGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => Promise<void>;
updateSideStoryLineTitle: (graphNodeId: GraphNodeId, title: string) => Promise<void>;
deleteScene: (sceneId: SceneId) => Promise<void>; deleteScene: (sceneId: SceneId) => Promise<void>;
renameProject: (name: string, fileBaseName: string) => Promise<void>; renameProject: (name: string, fileBaseName: string) => Promise<void>;
importProject: () => Promise<void>; importProject: () => Promise<void>;
exportProject: (projectId: ProjectId) => Promise<void>; peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
| { canceled: true }
| {
canceled: false;
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}
>;
pickImportZipFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
peekImportZipPath: (
filePath: string,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => Promise<{
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}>;
peekImportFromProject: (
sourceProjectId: ProjectId,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => Promise<{
sourceProjectId: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
}>;
mergeImportZip: (
filePath: string,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
mergeImportFromProject: (
sourceProjectId: ProjectId,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
importProjectFromPath: (filePath: string) => Promise<void>;
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
exportProject: (
projectId: ProjectId,
storylineSelections: StorylineSelection[],
labels: StorylineLabels,
) => Promise<void>;
deleteProject: (projectId: ProjectId) => Promise<void>; deleteProject: (projectId: ProjectId) => Promise<void>;
}; };
@@ -110,8 +166,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
...(e.detail ? { detail: e.detail } : null), ...(e.detail ? { detail: e.detail } : null),
}, },
})); }));
if (e.stage === 'done' || e.percent >= 100) { if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) {
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450); const delay = e.stage === 'error' ? 0 : 450;
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
} }
}); });
const offExport = api.on(ipcChannels.project.exportZipProgress, (evt) => { const offExport = api.on(ipcChannels.project.exportZipProgress, (evt) => {
@@ -125,8 +182,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
...(e.detail ? { detail: e.detail } : null), ...(e.detail ? { detail: e.detail } : null),
}, },
})); }));
if (e.stage === 'done' || e.percent >= 100) { if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) {
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450); const delay = e.stage === 'error' ? 0 : 450;
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
} }
}); });
const offPreview = api.on(ipcChannels.project.scenePreviewImportProgress, (evt) => { const offPreview = api.on(ipcChannels.project.scenePreviewImportProgress, (evt) => {
@@ -180,12 +238,18 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
}; };
const openProject = async (id: ProjectId) => { const openProject = async (id: ProjectId) => {
if (openInFlightRef.current) return openInFlightRef.current; projectDataEpochRef.current += 1;
const epoch = projectDataEpochRef.current;
openInFlightRef.current = null;
const job = (async () => { const job = (async () => {
setState((s) => ({ ...s, openingProjectId: id })); setState((s) => ({ ...s, openingProjectId: id }));
try { try {
projectDataEpochRef.current += 1;
const res = await api.invoke(ipcChannels.project.open, { projectId: id }); const res = await api.invoke(ipcChannels.project.open, { projectId: id });
if (projectDataEpochRef.current !== epoch) {
setState((s) => ({ ...s, openingProjectId: null }));
return;
}
setState((s) => ({ setState((s) => ({
...s, ...s,
project: res.project, project: res.project,
@@ -193,7 +257,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
openingProjectId: null, openingProjectId: null,
})); }));
} catch { } catch {
setState((s) => ({ ...s, openingProjectId: null })); if (projectDataEpochRef.current === epoch) {
setState((s) => ({ ...s, openingProjectId: null }));
}
} }
})(); })();
openInFlightRef.current = job.finally(() => { openInFlightRef.current = job.finally(() => {
@@ -203,9 +269,20 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
}; };
const closeProject = async () => { const closeProject = async () => {
await api.invoke(ipcChannels.project.close, {}); projectDataEpochRef.current += 1;
setState((s) => ({ ...s, project: null, selectedSceneId: null })); openInFlightRef.current = null;
await refreshProjects(); try {
await api.invoke(ipcChannels.project.close, {});
} finally {
setState((s) => ({
...s,
project: null,
selectedSceneId: null,
openingProjectId: null,
zipProgress: null,
}));
await refreshProjects();
}
}; };
const createScene = async () => { const createScene = async () => {
@@ -411,6 +488,16 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
setState((s) => ({ ...s, project: res.project })); setState((s) => ({ ...s, project: res.project }));
}; };
const setSceneGraphNodeSideStoryStart = async (graphNodeId: GraphNodeId) => {
const res = await api.invoke(ipcChannels.project.setSceneGraphNodeSideStoryStart, { graphNodeId });
setState((s) => ({ ...s, project: res.project }));
};
const updateSideStoryLineTitle = async (graphNodeId: GraphNodeId, title: string) => {
const res = await api.invoke(ipcChannels.project.updateSideStoryLineTitle, { graphNodeId, title });
setState((s) => ({ ...s, project: res.project }));
};
const deleteScene = async (sceneId: SceneId) => { const deleteScene = async (sceneId: SceneId) => {
const res = await api.invoke(ipcChannels.project.deleteScene, { sceneId }); const res = await api.invoke(ipcChannels.project.deleteScene, { sceneId });
setState((s) => ({ setState((s) => ({
@@ -428,19 +515,118 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
}; };
const importProject = async () => { const importProject = async () => {
const res = await api.invoke(ipcChannels.project.importZip, {}); try {
if (res.canceled) return; const res = await api.invoke(ipcChannels.project.importZip, {});
if (res.canceled) return;
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
}));
await refreshProjects();
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
};
const importProjectFromPath = async (filePath: string) => {
try {
const res = await api.invoke(ipcChannels.project.importZipFromPath, { filePath });
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId,
}));
await refreshProjects();
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
};
const peekImportZip = async (labels: StorylineLabels, targetHasMainStart: boolean) => {
return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart });
};
const pickImportZipFile = async () => {
return api.invoke(ipcChannels.project.pickImportZipFile, {});
};
const peekImportZipPath = async (
filePath: string,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => {
return api.invoke(ipcChannels.project.peekImportZipPath, { filePath, labels, targetHasMainStart });
};
const peekImportFromProject = async (
sourceProjectId: ProjectId,
labels: StorylineLabels,
targetHasMainStart: boolean,
) => {
return api.invoke(ipcChannels.project.peekImportFromProject, {
sourceProjectId,
labels,
targetHasMainStart,
});
};
const mergeImportZip = async (
filePath: string,
storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
) => {
const res = await api.invoke(ipcChannels.project.mergeImportZip, {
filePath,
storylineSelections,
sceneResolutions,
});
setState((s) => ({ setState((s) => ({
...s, ...s,
project: res.project, project: res.project,
selectedSceneId: res.project.currentSceneId, selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
})); }));
await refreshProjects(); return res;
}; };
const exportProject = async (projectId: ProjectId) => { const mergeImportFromProject = async (
const res = await api.invoke(ipcChannels.project.exportZip, { projectId }); sourceProjectId: ProjectId,
if (res.canceled) return; storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
) => {
const res = await api.invoke(ipcChannels.project.mergeImportFromProject, {
sourceProjectId,
storylineSelections,
sceneResolutions,
});
setState((s) => ({
...s,
project: res.project,
selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
}));
return res;
};
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
return res.storylines;
};
const exportProject = async (
projectId: ProjectId,
storylineSelections: StorylineSelection[],
labels: StorylineLabels,
) => {
try {
const res = await api.invoke(ipcChannels.project.exportZip, {
projectId,
storylineSelections,
labels,
});
if (res.canceled) return;
} finally {
setState((s) => ({ ...s, zipProgress: null }));
}
}; };
const deleteProject = async (projectId: ProjectId) => { const deleteProject = async (projectId: ProjectId) => {
@@ -476,9 +662,19 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
addSceneGraphEdge, addSceneGraphEdge,
removeSceneGraphEdge, removeSceneGraphEdge,
setSceneGraphNodeStart, setSceneGraphNodeStart,
setSceneGraphNodeSideStoryStart,
updateSideStoryLineTitle,
deleteScene, deleteScene,
renameProject, renameProject,
importProject, importProject,
importProjectFromPath,
peekImportZip,
pickImportZipFile,
peekImportZipPath,
peekImportFromProject,
mergeImportZip,
mergeImportFromProject,
getProjectStorylines,
exportProject, exportProject,
deleteProject, deleteProject,
}; };
+6
View File
@@ -63,6 +63,12 @@
--accent-glow: rgba(124, 58, 237, 0.35); --accent-glow: rgba(124, 58, 237, 0.35);
--selection-bg: rgba(124, 58, 237, 0.35); --selection-bg: rgba(124, 58, 237, 0.35);
/* --- Побочные сюжетные линии --- */
--side-story-accent: #0078d4;
--side-story-fill-solid: rgba(0, 120, 212, 0.92);
--side-story-handle: rgba(0, 120, 212, 0.9);
--shadow-side-story-badge: 0 4px 12px rgba(0, 0, 0, 0.35);
/* --- Цвета: опасность / ошибка --- */ /* --- Цвета: опасность / ошибка --- */
--color-danger: rgba(248, 113, 113, 0.95); --color-danger: rgba(248, 113, 113, 0.95);
--color-danger-icon: #e5484d; --color-danger-icon: #e5484d;
+29
View File
@@ -1,9 +1,34 @@
import type { GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../types'; import type { GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../types';
import { getConnectedComponent, getStorylineBucket } from './sceneGraphLineage';
function isCrossStorylineEdgeRejected(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
sourceGraphNodeId: GraphNodeId,
targetGraphNodeId: GraphNodeId,
): boolean {
const srcBucket = getStorylineBucket(nodes, edges, sourceGraphNodeId);
const tgtBucket = getStorylineBucket(nodes, edges, targetGraphNodeId);
// Запрет любых связей между основной (НАЧАЛО) и побочной (ПОБОЧНАЯ) линиями.
if (srcBucket === 'main' && tgtBucket === 'side') return true;
if (srcBucket === 'side' && tgtBucket === 'main') return true;
// Разные побочные линии (разные компоненты).
if (srcBucket === 'side' && tgtBucket === 'side') {
const srcComp = getConnectedComponent(nodes, edges, sourceGraphNodeId);
return !srcComp.has(targetGraphNodeId);
}
return false;
}
/** /**
* true — связь добавлять нельзя: нет узлов, петля по одной сцене, то же ребро уже есть, * true — связь добавлять нельзя: нет узлов, петля по одной сцене, то же ребро уже есть,
* или с этого узла уже ведёт связь к другой карточке той же целевой сцены. * или с этого узла уже ведёт связь к другой карточке той же целевой сцены.
* Разрешены несколько исходящих «вариантов» с одной ноды только на разные сцены. * Разрешены несколько исходящих «вариантов» с одной ноды только на разные сцены.
* Запрещены связи между основной и побочной линиями, а также между разными побочными линиями.
*/ */
export function isSceneGraphEdgeRejected( export function isSceneGraphEdgeRejected(
sceneGraphNodes: SceneGraphNode[], sceneGraphNodes: SceneGraphNode[],
@@ -17,6 +42,10 @@ export function isSceneGraphEdgeRejected(
if (srcScene === undefined || tgtScene === undefined) return true; if (srcScene === undefined || tgtScene === undefined) return true;
if (srcScene === tgtScene) return true; if (srcScene === tgtScene) return true;
if (isCrossStorylineEdgeRejected(sceneGraphNodes, sceneGraphEdges, sourceGraphNodeId, targetGraphNodeId)) {
return true;
}
for (const e of sceneGraphEdges) { for (const e of sceneGraphEdges) {
if (e.sourceGraphNodeId !== sourceGraphNodeId) continue; if (e.sourceGraphNodeId !== sourceGraphNodeId) continue;
if (e.targetGraphNodeId === targetGraphNodeId) return true; if (e.targetGraphNodeId === targetGraphNodeId) return true;
+170
View File
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isSceneGraphEdgeRejected } from './sceneGraphEdgeRules';
import {
canSetSideStoryStart,
getNodeStoryRoot,
isNodeInSideStoryline,
isSideStoryEdge,
listSideStoryStarts,
} from './sceneGraphLineage';
import type { SceneGraphEdge, SceneGraphNode } from '../types';
import { asGraphNodeId, asSceneId } from '../types/ids';
function node(id: string, scene: string, opts?: Partial<SceneGraphNode>): SceneGraphNode {
return {
id: asGraphNodeId(id),
sceneId: asSceneId(scene),
x: 0,
y: 0,
isStartScene: false,
isSideStoryStart: false,
sideStoryLineTitle: '',
...opts,
};
}
function edge(id: string, src: string, tgt: string): SceneGraphEdge {
return { id, sourceGraphNodeId: asGraphNodeId(src), targetGraphNodeId: asGraphNodeId(tgt) };
}
void test('canSetSideStoryStart: false when main or side marker in component', () => {
const nodes = [
node('main', 's1', { isStartScene: true }),
node('a', 's2'),
node('side', 's3', { isSideStoryStart: true }),
node('b', 's4'),
node('orphan', 's5'),
];
const edges = [edge('e1', 'main', 'a'), edge('e2', 'side', 'b')];
assert.equal(canSetSideStoryStart(nodes, edges, asGraphNodeId('a')), false);
assert.equal(canSetSideStoryStart(nodes, edges, asGraphNodeId('b')), false);
assert.equal(canSetSideStoryStart(nodes, edges, asGraphNodeId('orphan')), true);
});
void test('getNodeStoryRoot classifies main, side, orphan', () => {
const nodes = [
node('main', 's1', { isStartScene: true }),
node('a', 's2'),
node('side', 's3', { isSideStoryStart: true }),
node('b', 's4'),
node('orphan', 's5'),
];
const edges = [edge('e1', 'main', 'a'), edge('e2', 'side', 'b')];
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('main')), 'main');
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('a')), 'main');
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('b')), asGraphNodeId('side'));
assert.equal(getNodeStoryRoot(nodes, edges, asGraphNodeId('orphan')), null);
assert.equal(isNodeInSideStoryline(nodes, edges, asGraphNodeId('main')), false);
assert.equal(isNodeInSideStoryline(nodes, edges, asGraphNodeId('a')), false);
assert.equal(isNodeInSideStoryline(nodes, edges, asGraphNodeId('b')), true);
});
void test('isSceneGraphEdgeRejected: blocks main↔side and side↔side', () => {
const nodes = [
node('main', 's1', { isStartScene: true }),
node('side1', 's2', { isSideStoryStart: true }),
node('side2', 's3', { isSideStoryStart: true }),
node('a', 's4'),
node('b', 's5'),
];
const edges = [edge('e1', 'side1', 'a'), edge('e2', 'side2', 'b')];
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('side1')), true);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('side1'), asGraphNodeId('main')), true);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('a')), true);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('main')), true);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('b')), true);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('side1')), false);
});
void test('isSceneGraphEdgeRejected: blocks main to any node in side component without badge', () => {
const nodes = [
node('main', 's1', { isStartScene: true }),
node('side1', 's2', { isSideStoryStart: true }),
node('a', 's3'),
node('b', 's4'),
];
const edges = [edge('e1', 'side1', 'a'), edge('e2', 'a', 'b')];
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('a')), true);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('b')), true);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('main')), true);
});
void test('isSideStoryEdge: side chain edges are blue even without badge on endpoint', () => {
const nodes = [
node('side', 's1', { isSideStoryStart: true }),
node('a', 's2'),
node('b', 's3'),
];
const edges = [edge('e1', 'side', 'a'), edge('e2', 'a', 'b')];
assert.equal(isSideStoryEdge(nodes, edges, edges[0]!), true);
assert.equal(isSideStoryEdge(nodes, edges, edges[1]!), true);
});
void test('isNodeInSideStoryline: follows current component after reconnect', () => {
const nodes = [
node('main', 's1', { isStartScene: true }),
node('side', 's2', { isSideStoryStart: true }),
node('x', 's3'),
];
const mainEdge = [edge('e1', 'main', 'x')];
const sideEdge = [edge('e2', 'side', 'x')];
assert.equal(isNodeInSideStoryline(nodes, mainEdge, asGraphNodeId('x')), false);
assert.equal(isNodeInSideStoryline(nodes, sideEdge, asGraphNodeId('x')), true);
});
void test('isSideStoryEdge: main edges are not side edges', () => {
const nodes = [
node('main', 's1', { isStartScene: true }),
node('a', 's2'),
node('side', 's3', { isSideStoryStart: true }),
node('b', 's4'),
];
const edges = [edge('e1', 'main', 'a'), edge('e2', 'side', 'b')];
assert.equal(isSideStoryEdge(nodes, edges, edges[0]!), false);
assert.equal(isSideStoryEdge(nodes, edges, edges[1]!), true);
});
void test('isSceneGraphEdgeRejected: allows main-to-main edges', () => {
const nodes = [node('main', 's1', { isStartScene: true }), node('a', 's2')];
const edges: SceneGraphEdge[] = [];
assert.equal(
isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('a')),
false,
);
});
void test('isSceneGraphEdgeRejected: allows main to free connected chain without markers', () => {
const nodes = [
node('main', 's1', { isStartScene: true }),
node('a', 's2'),
node('b', 's3'),
node('c', 's4'),
];
const edges = [edge('e1', 'main', 'a'), edge('e2', 'b', 'c')];
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('b')), false);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('b')), false);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('c')), false);
});
void test('isSceneGraphEdgeRejected: allows connections within main component without start badge on target', () => {
const nodes = [
node('main', 's1', { isStartScene: true }),
node('a', 's2'),
node('b', 's3'),
node('c', 's4'),
];
const edges = [edge('e1', 'main', 'a'), edge('e2', 'a', 'b')];
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('main'), asGraphNodeId('b')), false);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('a'), asGraphNodeId('c')), false);
assert.equal(isSceneGraphEdgeRejected(nodes, edges, asGraphNodeId('b'), asGraphNodeId('c')), false);
});
void test('listSideStoryStarts returns all side starters', () => {
const nodes = [
node('side1', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Quest A' }),
node('side2', 's2', { isSideStoryStart: true }),
];
assert.equal(listSideStoryStarts(nodes).length, 2);
});
+172
View File
@@ -0,0 +1,172 @@
import type { GraphNodeId, SceneGraphEdge, SceneGraphNode } from '../types';
/** Корень сюжета: основная линия, старт побочной (id узла) или изолированный узел. */
export type StoryRoot = 'main' | GraphNodeId | null;
/** true — корень относится к побочной линии (id стартового узла), не к основной. */
export function isSideStoryRoot(root: StoryRoot): root is GraphNodeId {
return root !== null && root !== 'main';
}
function buildAdjacency(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
): Map<GraphNodeId, Set<GraphNodeId>> {
const adj = new Map<GraphNodeId, Set<GraphNodeId>>();
for (const n of nodes) {
adj.set(n.id, new Set());
}
for (const e of edges) {
adj.get(e.sourceGraphNodeId)?.add(e.targetGraphNodeId);
adj.get(e.targetGraphNodeId)?.add(e.sourceGraphNodeId);
}
return adj;
}
function bfsComponent(adj: Map<GraphNodeId, Set<GraphNodeId>>, startId: GraphNodeId): Set<GraphNodeId> {
const seen = new Set<GraphNodeId>();
const queue: GraphNodeId[] = [startId];
seen.add(startId);
while (queue.length > 0) {
const cur = queue.shift();
if (!cur) continue;
for (const nb of adj.get(cur) ?? []) {
if (seen.has(nb)) continue;
seen.add(nb);
queue.push(nb);
}
}
return seen;
}
/** Неориентированная компонента связности узла. */
export function getConnectedComponent(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
graphNodeId: GraphNodeId,
): Set<GraphNodeId> {
const adj = buildAdjacency(nodes, edges);
if (!adj.has(graphNodeId)) return new Set();
return bfsComponent(adj, graphNodeId);
}
export function componentHasMainStart(nodes: SceneGraphNode[], component: Set<GraphNodeId>): boolean {
return nodes.some((n) => component.has(n.id) && n.isStartScene);
}
export function componentHasSideStart(nodes: SceneGraphNode[], component: Set<GraphNodeId>): boolean {
return nodes.some((n) => component.has(n.id) && n.isSideStoryStart);
}
/** Классификация связной компоненты графа для правил связей и стилей. */
export type StorylineBucket = 'main' | 'side' | 'free';
/**
* main — в компоненте есть «НАЧАЛО» (основной сюжет);
* side — есть «ПОБОЧНАЯ», но нет «НАЧАЛО» в той же компоненте;
* free — нет ни одной стартовой метки в компоненте.
*/
export function getStorylineBucket(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
graphNodeId: GraphNodeId,
): StorylineBucket {
const component = getConnectedComponent(nodes, edges, graphNodeId);
if (componentHasMainStart(nodes, component)) return 'main';
if (componentHasSideStart(nodes, component)) return 'side';
return 'free';
}
/** Можно ли поставить синюю метку «ПОБОЧНАЯ» на узел. */
export function canSetSideStoryStart(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
graphNodeId: GraphNodeId,
): boolean {
const component = getConnectedComponent(nodes, edges, graphNodeId);
if (componentHasMainStart(nodes, component)) return false;
if (componentHasSideStart(nodes, component)) return false;
return true;
}
/** Карта узел → корень сюжета (основной приоритетнее побочного). */
export function buildStoryRootMap(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
): Map<GraphNodeId, StoryRoot> {
const adj = buildAdjacency(nodes, edges);
const roots = new Map<GraphNodeId, StoryRoot>();
const mainStart = nodes.find((n) => n.isStartScene);
if (mainStart) {
for (const id of bfsComponent(adj, mainStart.id)) {
roots.set(id, 'main');
}
}
for (const sideStart of nodes.filter((n) => n.isSideStoryStart)) {
for (const id of bfsComponent(adj, sideStart.id)) {
if (!roots.has(id)) {
roots.set(id, sideStart.id);
}
}
}
for (const n of nodes) {
if (!roots.has(n.id)) {
roots.set(n.id, null);
}
}
return roots;
}
export function getNodeStoryRoot(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
graphNodeId: GraphNodeId,
): StoryRoot {
return buildStoryRootMap(nodes, edges).get(graphNodeId) ?? null;
}
export function isNodeInSideStoryline(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
graphNodeId: GraphNodeId,
): boolean {
return getStorylineBucket(nodes, edges, graphNodeId) === 'side';
}
export function isNodeInMainStoryline(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
graphNodeId: GraphNodeId,
): boolean {
return getStorylineBucket(nodes, edges, graphNodeId) === 'main';
}
/** Все узлы одной побочной линии (по её стартовому узлу). */
export function getSideStoryComponentNodeIds(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
sideStartGraphNodeId: GraphNodeId,
): Set<GraphNodeId> {
return getConnectedComponent(nodes, edges, sideStartGraphNodeId);
}
export function listSideStoryStarts(nodes: SceneGraphNode[]): SceneGraphNode[] {
return nodes.filter((n) => n.isSideStoryStart);
}
/** true, если ребро целиком внутри одной побочной линии (по компоненте связности). */
export function isSideStoryEdge(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
edge: SceneGraphEdge,
): boolean {
const srcBucket = getStorylineBucket(nodes, edges, edge.sourceGraphNodeId);
const tgtBucket = getStorylineBucket(nodes, edges, edge.targetGraphNodeId);
if (srcBucket !== 'side' || tgtBucket !== 'side') return false;
const srcComp = getConnectedComponent(nodes, edges, edge.sourceGraphNodeId);
return srcComp.has(edge.targetGraphNodeId);
}
@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Project, Scene, SceneGraphEdge, SceneGraphNode } from '../types';
import { asAssetId, asGraphNodeId, asProjectId, asSceneId } from '../types/ids';
import {
buildPartialExportProject,
collectStorylineGraphNodeIds,
computeGraphImportOffsetX,
findSceneTitleConflicts,
listImportableStorylines,
mergeStorylinesIntoProject,
newExportBundleProjectId,
} from './storylineExportImport';
const LABELS = { main: 'Основная линия', untitled: 'Без названия' };
function scene(id: string, title: string): Scene {
return {
id: asSceneId(id),
title,
description: '',
previewAssetId: null,
previewAssetType: null,
previewThumbAssetId: null,
previewVideoAutostart: false,
previewRotationDeg: 0,
darkenScene: false,
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
connections: [],
layout: { x: 0, y: 0 },
};
}
function node(id: string, sceneId: string, opts?: Partial<SceneGraphNode>): SceneGraphNode {
return {
id: asGraphNodeId(id),
sceneId: asSceneId(sceneId),
x: 0,
y: 0,
isStartScene: false,
isSideStoryStart: false,
sideStoryLineTitle: '',
...opts,
};
}
function edge(id: string, src: string, tgt: string): SceneGraphEdge {
return { id, sourceGraphNodeId: asGraphNodeId(src), targetGraphNodeId: asGraphNodeId(tgt) };
}
function minimalProject(overrides: Partial<Project> = {}): Project {
return {
id: asProjectId('p1'),
meta: {
name: 'Test',
fileBaseName: 'test',
createdAt: '2020-01-01T00:00:00.000Z',
updatedAt: '2020-01-01T00:00:00.000Z',
createdWithAppVersion: '1',
appVersion: '1',
schemaVersion: 5,
},
scenes: {},
assets: {},
campaignAudios: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
sceneGraphEdges: [],
...overrides,
};
}
void test('collectStorylineGraphNodeIds: main line excludes orphan and side nodes', () => {
const project = minimalProject({
scenes: {
[asSceneId('s1')]: scene('s1', 'Start'),
[asSceneId('s2')]: scene('s2', 'A'),
[asSceneId('s3')]: scene('s3', 'Side'),
[asSceneId('s4')]: scene('s4', 'Orphan'),
},
sceneGraphNodes: [
node('main', 's1', { isStartScene: true, x: 0 }),
node('a', 's2', { x: 100 }),
node('side', 's3', { isSideStoryStart: true, x: 200 }),
node('orphan', 's4', { x: 300 }),
],
sceneGraphEdges: [edge('e1', 'main', 'a'), edge('e2', 'side', 'orphan')],
});
const mainIds = collectStorylineGraphNodeIds(project, { kind: 'main' });
assert.deepEqual([...mainIds].sort(), ['a', 'main'].sort());
});
void test('buildPartialExportProject: subset, manifest, no free nodes', () => {
const source = minimalProject({
scenes: {
[asSceneId('s1')]: scene('s1', 'Start'),
[asSceneId('s2')]: scene('s2', 'A'),
[asSceneId('s3')]: scene('s3', 'Side'),
[asSceneId('s4')]: scene('s4', 'Orphan'),
},
sceneGraphNodes: [
node('main', 's1', { isStartScene: true }),
node('a', 's2'),
node('side', 's3', { isSideStoryStart: true, sideStoryLineTitle: 'Квест' }),
node('orphan', 's4'),
],
sceneGraphEdges: [edge('e1', 'main', 'a'), edge('e2', 'side', 'orphan')],
});
const partial = buildPartialExportProject(
source,
[{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }],
{ newProjectId: newExportBundleProjectId(), exportTitle: 'Export', labels: LABELS },
);
assert.equal(partial.sceneGraphNodes.length, 2);
assert.ok(partial.sceneGraphNodes.some((n) => n.id === asGraphNodeId('side')));
assert.ok(partial.sceneGraphNodes.some((n) => n.id === asGraphNodeId('orphan')));
assert.equal(Object.keys(partial.scenes).length, 2);
assert.ok(partial.scenes[asSceneId('s3')]);
assert.ok(!partial.scenes[asSceneId('s1')]);
assert.ok(!partial.scenes[asSceneId('s2')]);
assert.equal(partial.exportedStorylines?.length, 1);
assert.equal(partial.exportedStorylines![0]!.label, 'Квест');
assert.equal(partial.exportedStorylines![0]!.kind, 'side');
});
void test('listImportableStorylines: disables main when target already has start', () => {
const source = minimalProject({
exportedStorylines: [
{ kind: 'main', startGraphNodeId: null, label: 'Основная линия' },
{ kind: 'side', startGraphNodeId: asGraphNodeId('side'), label: 'Побочная' },
],
});
const items = listImportableStorylines(source, LABELS, true);
assert.equal(items[0]!.disabled, true);
assert.equal(items[0]!.disabledReason, 'main_exists');
assert.ok(!items[1]!.disabled);
});
void test('findSceneTitleConflicts: case-insensitive trimmed match', () => {
const target = minimalProject({
scenes: { [asSceneId('t1')]: scene('t1', ' Tavern ') },
});
const source = minimalProject({
scenes: { [asSceneId('s1')]: scene('s1', 'tavern') },
});
const conflicts = findSceneTitleConflicts(target, source, [asSceneId('s1')]);
assert.equal(conflicts.length, 1);
assert.equal(conflicts[0]!.sourceTitle, 'tavern');
assert.equal(conflicts[0]!.matches[0]!.sceneId, asSceneId('t1'));
});
void test('mergeStorylinesIntoProject: offset X, create scene, rename side title', () => {
const target = minimalProject({
scenes: { [asSceneId('t1')]: scene('t1', 'Existing') },
sceneGraphNodes: [node('tgn', 't1', { x: 400 })],
});
const source = minimalProject({
scenes: {
[asSceneId('s1')]: scene('s1', 'New scene'),
[asSceneId('s2')]: scene('s2', 'Side scene'),
},
sceneGraphNodes: [
node('side', 's2', { isSideStoryStart: true, sideStoryLineTitle: 'Квест', x: 50, y: 10 }),
node('a', 's1', { x: 150, y: 10 }),
],
sceneGraphEdges: [edge('e1', 'side', 'a')],
});
target.sceneGraphNodes.push(
node('existingSide', 't1', { isSideStoryStart: true, sideStoryLineTitle: 'Квест' }),
);
const offsetX = computeGraphImportOffsetX(target);
const { project: merged, report } = mergeStorylinesIntoProject(
target,
source,
[{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }],
[{ sourceSceneId: asSceneId('s1'), mode: 'create' }, { sourceSceneId: asSceneId('s2'), mode: 'create' }],
{ graphOffsetX: offsetX },
);
const imported = merged.sceneGraphNodes.filter((n) => n.id !== asGraphNodeId('tgn') && n.id !== asGraphNodeId('existingSide'));
assert.equal(imported.length, 2);
assert.equal(imported[0]!.x, offsetX);
assert.equal(imported[1]!.x, offsetX + 100);
const sideStart = imported.find((n) => n.isSideStoryStart);
assert.equal(sideStart?.sideStoryLineTitle, 'Квест (2)');
assert.equal(report.renamedSideTitles.length, 1);
assert.equal(report.scenesCreated, 2);
assert.equal(report.graphNodesAdded, 2);
assert.equal(report.edgesAdded, 1);
});
void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () => {
const targetSceneId = asSceneId('t1');
const target = minimalProject({
scenes: { [targetSceneId]: scene('t1', 'Tavern') },
sceneGraphNodes: [],
});
const source = minimalProject({
scenes: { [asSceneId('s1')]: scene('s1', 'Tavern') },
sceneGraphNodes: [node('gn', 's1', { isStartScene: true, x: 0 })],
sceneGraphEdges: [],
});
const { project: merged, report } = mergeStorylinesIntoProject(
target,
source,
[{ kind: 'main' }],
[{ sourceSceneId: asSceneId('s1'), mode: 'use', targetSceneId }],
{ graphOffsetX: 100 },
);
assert.equal(report.scenesReused, 1);
assert.equal(report.scenesCreated, 0);
assert.equal(merged.sceneGraphNodes.length, 1);
assert.equal(merged.sceneGraphNodes[0]!.sceneId, targetSceneId);
});
+519
View File
@@ -0,0 +1,519 @@
import {
getConnectedComponent,
getSideStoryComponentNodeIds,
listSideStoryStarts,
} from './sceneGraphLineage';
import type {
ExportedStorylineRef,
GraphNodeId,
Project,
Scene,
SceneGraphEdge,
SceneGraphNode,
SceneId,
} from '../types';
import type { AssetId, ProjectId } from '../types/ids';
import { asAssetId, asGraphNodeId, asProjectId, asSceneId } from '../types/ids';
export type StorylineKind = 'main' | 'side';
/** Выбор сюжетной линии для экспорта/импорта. */
export type StorylineSelection =
| { kind: 'main' }
| { kind: 'side'; startGraphNodeId: GraphNodeId };
export type StorylineListItem = {
selection: StorylineSelection;
label: string;
disabled?: boolean;
disabledReason?: string;
};
export type SceneImportResolution =
| { sourceSceneId: SceneId; mode: 'create' }
| { sourceSceneId: SceneId; mode: 'use'; targetSceneId: SceneId };
export type SceneTitleConflict = {
sourceSceneId: SceneId;
sourceTitle: string;
matches: { sceneId: SceneId; title: string }[];
};
export type StorylineImportMergeReport = {
storylinesImported: number;
scenesCreated: number;
scenesReused: number;
graphNodesAdded: number;
edgesAdded: number;
assetsCopied: number;
assetsReused: number;
renamedSideTitles: string[];
};
export type StorylineLabels = {
main: string;
untitled: string;
};
function generateId(): string {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}
export function storylineSelectionKey(sel: StorylineSelection): string {
return sel.kind === 'main' ? 'main' : `side:${sel.startGraphNodeId}`;
}
export function storylineSelectionsEqual(a: StorylineSelection, b: StorylineSelection): boolean {
return storylineSelectionKey(a) === storylineSelectionKey(b);
}
export function sideStoryDisplayLabel(
node: SceneGraphNode,
scenes: Record<SceneId, Scene>,
untitledLabel: string,
): string {
const custom = node.sideStoryLineTitle.trim();
if (custom) return custom;
const scene = scenes[node.sceneId];
const sceneTitle = scene?.title.trim();
if (sceneTitle) return sceneTitle;
return untitledLabel;
}
function findMainStartNode(nodes: SceneGraphNode[]): SceneGraphNode | null {
return nodes.find((n) => n.isStartScene) ?? null;
}
export function collectStorylineGraphNodeIds(
project: Project,
selection: StorylineSelection,
): Set<GraphNodeId> {
const { sceneGraphNodes, sceneGraphEdges } = project;
if (selection.kind === 'main') {
const mainStart = findMainStartNode(sceneGraphNodes);
if (!mainStart) return new Set();
return getConnectedComponent(sceneGraphNodes, sceneGraphEdges, mainStart.id);
}
return getSideStoryComponentNodeIds(sceneGraphNodes, sceneGraphEdges, selection.startGraphNodeId);
}
export function collectSelectionsGraphNodeIds(
project: Project,
selections: StorylineSelection[],
): Set<GraphNodeId> {
const out = new Set<GraphNodeId>();
for (const sel of selections) {
for (const id of collectStorylineGraphNodeIds(project, sel)) {
out.add(id);
}
}
return out;
}
export function listExportableStorylines(project: Project, labels: StorylineLabels): StorylineListItem[] {
const items: StorylineListItem[] = [];
if (findMainStartNode(project.sceneGraphNodes)) {
items.push({ selection: { kind: 'main' }, label: labels.main });
}
for (const gn of listSideStoryStarts(project.sceneGraphNodes)) {
items.push({
selection: { kind: 'side', startGraphNodeId: gn.id },
label: sideStoryDisplayLabel(gn, project.scenes, labels.untitled),
});
}
return items;
}
export function listImportableStorylines(
source: Project,
labels: StorylineLabels,
targetHasMainStart: boolean,
): StorylineListItem[] {
const fromManifest = source.exportedStorylines;
if (fromManifest && fromManifest.length > 0) {
return fromManifest.map((entry) => {
const selection: StorylineSelection =
entry.kind === 'main'
? { kind: 'main' }
: { kind: 'side', startGraphNodeId: entry.startGraphNodeId! };
const item: StorylineListItem = { selection, label: entry.label };
if (entry.kind === 'main' && targetHasMainStart) {
item.disabled = true;
item.disabledReason = 'main_exists';
}
return item;
});
}
return listExportableStorylines(source, labels).map((item) => {
if (item.selection.kind === 'main' && targetHasMainStart) {
return { ...item, disabled: true, disabledReason: 'main_exists' };
}
return item;
});
}
function recomputeOutgoing(
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
): Map<SceneId, Set<SceneId>> {
const gnMap = new Map(nodes.map((n) => [n.id, n]));
const outgoing = new Map<SceneId, Set<SceneId>>();
for (const e of edges) {
const a = gnMap.get(e.sourceGraphNodeId);
const b = gnMap.get(e.targetGraphNodeId);
if (!a || !b || a.sceneId === b.sceneId) continue;
let set = outgoing.get(a.sceneId);
if (!set) {
set = new Set();
outgoing.set(a.sceneId, set);
}
set.add(b.sceneId);
}
return outgoing;
}
function applyConnectionSets(scenes: Record<SceneId, Scene>, outgoing: Map<SceneId, Set<SceneId>>): Record<SceneId, Scene> {
const next: Record<SceneId, Scene> = { ...scenes };
for (const sid of Object.keys(next) as SceneId[]) {
const prev = next[sid];
if (!prev) continue;
const c = outgoing.get(sid);
next[sid] = { ...prev, connections: c ? [...c] : [] };
}
return next;
}
function selectionToExportedRef(
project: Project,
selection: StorylineSelection,
labels: StorylineLabels,
): ExportedStorylineRef {
if (selection.kind === 'main') {
return { kind: 'main', startGraphNodeId: null, label: labels.main };
}
const start = project.sceneGraphNodes.find((n) => n.id === selection.startGraphNodeId);
return {
kind: 'side',
startGraphNodeId: selection.startGraphNodeId,
label: start
? sideStoryDisplayLabel(start, project.scenes, labels.untitled)
: labels.untitled,
};
}
export function collectSceneIdsForSelections(
project: Project,
selections: StorylineSelection[],
): SceneId[] {
const nodeIds = collectSelectionsGraphNodeIds(project, selections);
const sceneIds = new Set<SceneId>();
for (const gn of project.sceneGraphNodes) {
if (nodeIds.has(gn.id)) sceneIds.add(gn.sceneId);
}
return [...sceneIds];
}
export function buildPartialExportProject(
source: Project,
selections: StorylineSelection[],
opts: {
newProjectId: ProjectId;
exportTitle: string;
labels: StorylineLabels;
},
): Project {
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
const sceneIds = new Set(collectSceneIdsForSelections(source, selections));
const sceneGraphNodes = source.sceneGraphNodes
.filter((n) => nodeIds.has(n.id))
.map((n) => ({ ...n }));
const sceneGraphEdges = source.sceneGraphEdges.filter(
(e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId),
);
const scenes: Record<SceneId, Scene> = {};
for (const sid of sceneIds) {
const sc = source.scenes[sid];
if (sc) scenes[sid] = { ...sc };
}
let draft: Project = {
...source,
id: opts.newProjectId,
meta: {
...source.meta,
name: opts.exportTitle,
updatedAt: new Date().toISOString(),
},
scenes,
sceneGraphNodes,
sceneGraphEdges,
currentSceneId: null,
currentGraphNodeId: null,
exportedStorylines: selections.map((s) => selectionToExportedRef(source, s, opts.labels)),
};
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) };
const assetIds = collectReferencedAssetIdsForProject(draft);
const assets: Project['assets'] = {} as Project['assets'];
for (const id of assetIds) {
const a = source.assets[id];
if (a) assets[id] = { ...a };
}
const mainStart = findMainStartNode(draft.sceneGraphNodes);
const firstSide = listSideStoryStarts(draft.sceneGraphNodes)[0];
return {
...draft,
assets,
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
};
}
function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> {
const refs = new Set<AssetId>();
for (const sc of Object.values(p.scenes)) {
if (sc.previewAssetId) refs.add(sc.previewAssetId);
if (sc.previewThumbAssetId) refs.add(sc.previewThumbAssetId);
for (const vid of sc.media.videos) refs.add(vid);
for (const au of sc.media.audios) refs.add(au.assetId);
}
for (const au of p.campaignAudios) refs.add(au.assetId);
return refs;
}
export function findSceneTitleConflicts(
target: Project,
source: Project,
sourceSceneIds: SceneId[],
): SceneTitleConflict[] {
const targetByTitle = new Map<string, { sceneId: SceneId; title: string }[]>();
for (const sc of Object.values(target.scenes)) {
const key = sc.title.trim().toLowerCase();
if (!key) continue;
const list = targetByTitle.get(key) ?? [];
list.push({ sceneId: sc.id, title: sc.title });
targetByTitle.set(key, list);
}
const conflicts: SceneTitleConflict[] = [];
const seen = new Set<SceneId>();
for (const sid of sourceSceneIds) {
if (seen.has(sid)) continue;
seen.add(sid);
const src = source.scenes[sid];
if (!src) continue;
const key = src.title.trim().toLowerCase();
if (!key) continue;
const matches = targetByTitle.get(key);
if (matches && matches.length > 0) {
conflicts.push({ sourceSceneId: sid, sourceTitle: src.title, matches });
}
}
return conflicts;
}
function remapSceneAssetRefs(scene: Scene, assetMap: Map<AssetId, AssetId>): Scene {
const mapId = (id: AssetId | null): AssetId | null => (id ? (assetMap.get(id) ?? id) : null);
return {
...scene,
previewAssetId: mapId(scene.previewAssetId),
previewThumbAssetId: mapId(scene.previewThumbAssetId),
media: {
videos: scene.media.videos.map((id) => assetMap.get(id) ?? id),
audios: scene.media.audios.map((a) => ({
...a,
assetId: assetMap.get(a.assetId) ?? a.assetId,
})),
},
};
}
function uniqueSideTitle(existing: Set<string>, desired: string): string {
const base = desired.trim() || 'Побочная';
if (!existing.has(base.toLowerCase())) {
existing.add(base.toLowerCase());
return base;
}
let n = 2;
while (existing.has(`${base} (${String(n)})`.toLowerCase())) n += 1;
const next = `${base} (${String(n)})`;
existing.add(next.toLowerCase());
return next;
}
export function mergeStorylinesIntoProject(
target: Project,
source: Project,
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
opts: { graphOffsetX: number },
): { project: Project; report: StorylineImportMergeReport; assetCopies: { fromId: AssetId; toId: AssetId }[] } {
const resolutionBySource = new Map(sceneResolutions.map((r) => [r.sourceSceneId, r]));
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
const sourceSceneIds = collectSceneIdsForSelections(source, selections);
const neededAssetIds = new Set<AssetId>();
for (const sid of sourceSceneIds) {
const sc = source.scenes[sid];
if (!sc) continue;
const res = resolutionBySource.get(sid);
if (res?.mode === 'use') continue;
if (sc.previewAssetId) neededAssetIds.add(sc.previewAssetId);
if (sc.previewThumbAssetId) neededAssetIds.add(sc.previewThumbAssetId);
for (const vid of sc.media.videos) neededAssetIds.add(vid);
for (const au of sc.media.audios) neededAssetIds.add(au.assetId);
}
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
const assetMap = new Map<AssetId, AssetId>();
const targetSha = new Map<string, AssetId>();
for (const a of Object.values(target.assets)) {
targetSha.set(a.sha256, a.id);
}
let assetsCopied = 0;
let assetsReused = 0;
const assetCopies: { fromId: AssetId; toId: AssetId }[] = [];
for (const id of neededAssetIds) {
const srcAsset = source.assets[id];
if (!srcAsset) continue;
const existing = targetSha.get(srcAsset.sha256);
if (existing) {
assetMap.set(id, existing);
assetsReused += 1;
} else {
const newId = asAssetId(`a_${generateId()}`);
assetMap.set(id, newId);
targetSha.set(srcAsset.sha256, newId);
assetCopies.push({ fromId: id, toId: newId });
assetsCopied += 1;
}
}
const sceneIdMap = new Map<SceneId, SceneId>();
let scenesCreated = 0;
let scenesReused = 0;
const scenes: Record<SceneId, Scene> = { ...target.scenes };
for (const sid of sourceSceneIds) {
const res = resolutionBySource.get(sid);
const srcScene = source.scenes[sid];
if (!srcScene) continue;
if (res?.mode === 'use') {
sceneIdMap.set(sid, res.targetSceneId);
scenesReused += 1;
} else {
const newId = asSceneId(`s_${generateId()}`);
sceneIdMap.set(sid, newId);
scenes[newId] = remapSceneAssetRefs({ ...srcScene, id: newId }, assetMap);
scenesCreated += 1;
}
}
const importedNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id));
const minX = importedNodes.length > 0 ? Math.min(...importedNodes.map((n) => n.x)) : 0;
const xShift = opts.graphOffsetX - minX;
const graphNodeIdMap = new Map<GraphNodeId, GraphNodeId>();
for (const gn of importedNodes) {
graphNodeIdMap.set(gn.id, asGraphNodeId(`gn_${generateId()}`));
}
const existingSideTitles = new Set(
target.sceneGraphNodes
.filter((n) => n.isSideStoryStart)
.map((n) => (n.sideStoryLineTitle.trim() || 'Побочная').toLowerCase()),
);
const renamedSideTitles: string[] = [];
const importMain = selections.some((s) => s.kind === 'main');
const targetHasMain = !!findMainStartNode(target.sceneGraphNodes);
const newGraphNodes: SceneGraphNode[] = importedNodes.map((gn) => {
const newId = graphNodeIdMap.get(gn.id)!;
let sideStoryLineTitle = gn.sideStoryLineTitle;
if (gn.isSideStoryStart) {
const desired = sideStoryDisplayLabel(gn, source.scenes, 'Без названия');
const resolved = uniqueSideTitle(existingSideTitles, desired);
if (resolved !== desired) renamedSideTitles.push(resolved);
sideStoryLineTitle = resolved;
}
return {
...gn,
id: newId,
sceneId: sceneIdMap.get(gn.sceneId) ?? gn.sceneId,
x: gn.x + xShift,
y: gn.y,
isStartScene: importMain && !targetHasMain ? gn.isStartScene : false,
sideStoryLineTitle,
};
});
const newEdges: SceneGraphEdge[] = source.sceneGraphEdges
.filter((e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId))
.map((e) => ({
id: `e_${generateId()}`,
sourceGraphNodeId: graphNodeIdMap.get(e.sourceGraphNodeId)!,
targetGraphNodeId: graphNodeIdMap.get(e.targetGraphNodeId)!,
}));
const assets: Project['assets'] = { ...target.assets };
for (const [srcId, tgtId] of assetMap) {
if (assets[tgtId]) continue;
const srcAsset = source.assets[srcId];
if (srcAsset) {
assets[tgtId] = { ...srcAsset, id: tgtId };
}
}
const campaignAudios = [...target.campaignAudios];
const campaignAssetIds = new Set(campaignAudios.map((a) => a.assetId));
for (const au of source.campaignAudios) {
const mapped = assetMap.get(au.assetId) ?? au.assetId;
if (!campaignAssetIds.has(mapped)) {
campaignAudios.push({ ...au, assetId: mapped });
campaignAssetIds.add(mapped);
}
}
let merged: Project = {
...target,
scenes,
assets,
campaignAudios,
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
};
const outgoing = recomputeOutgoing(merged.sceneGraphNodes, merged.sceneGraphEdges);
merged = { ...merged, scenes: applyConnectionSets(merged.scenes, outgoing) };
return {
project: merged,
report: {
storylinesImported: selections.length,
scenesCreated,
scenesReused,
graphNodesAdded: newGraphNodes.length,
edgesAdded: newEdges.length,
assetsCopied,
assetsReused,
renamedSideTitles,
},
assetCopies,
};
}
export function computeGraphImportOffsetX(target: Project, padding = 120): number {
const maxX = target.sceneGraphNodes.reduce((m, n) => Math.max(m, n.x), 0);
return maxX + padding;
}
export function newExportBundleProjectId(): ProjectId {
return asProjectId(`p_${generateId()}`);
}
+84 -1
View File
@@ -14,6 +14,13 @@ import type {
VideoPlaybackEvent, VideoPlaybackEvent,
VideoPlaybackState, VideoPlaybackState,
} from '../types'; } from '../types';
import type {
SceneImportResolution,
StorylineImportMergeReport,
StorylineLabels,
StorylineListItem,
StorylineSelection,
} from '../graph/storylineExportImport';
export const ipcChannels = { export const ipcChannels = {
app: { app: {
@@ -48,10 +55,20 @@ export const ipcChannels = {
addSceneGraphEdge: 'project.addSceneGraphEdge', addSceneGraphEdge: 'project.addSceneGraphEdge',
removeSceneGraphEdge: 'project.removeSceneGraphEdge', removeSceneGraphEdge: 'project.removeSceneGraphEdge',
setSceneGraphNodeStart: 'project.setSceneGraphNodeStart', setSceneGraphNodeStart: 'project.setSceneGraphNodeStart',
setSceneGraphNodeSideStoryStart: 'project.setSceneGraphNodeSideStoryStart',
updateSideStoryLineTitle: 'project.updateSideStoryLineTitle',
deleteScene: 'project.deleteScene', deleteScene: 'project.deleteScene',
rename: 'project.rename', rename: 'project.rename',
importZip: 'project.importZip', importZip: 'project.importZip',
exportZip: 'project.exportZip', exportZip: 'project.exportZip',
getProjectStorylines: 'project.getProjectStorylines',
peekImportZip: 'project.peekImportZip',
pickImportZipFile: 'project.pickImportZipFile',
peekImportZipPath: 'project.peekImportZipPath',
peekImportFromProject: 'project.peekImportFromProject',
mergeImportZip: 'project.mergeImportZip',
mergeImportFromProject: 'project.mergeImportFromProject',
importZipFromPath: 'project.importZipFromPath',
deleteProject: 'project.deleteProject', deleteProject: 'project.deleteProject',
importZipProgress: 'project.importZipProgress', importZipProgress: 'project.importZipProgress',
exportZipProgress: 'project.exportZipProgress', exportZipProgress: 'project.exportZipProgress',
@@ -249,6 +266,14 @@ export type IpcInvokeMap = {
req: { graphNodeId: GraphNodeId | null }; req: { graphNodeId: GraphNodeId | null };
res: { project: Project }; res: { project: Project };
}; };
[ipcChannels.project.setSceneGraphNodeSideStoryStart]: {
req: { graphNodeId: GraphNodeId };
res: { project: Project };
};
[ipcChannels.project.updateSideStoryLineTitle]: {
req: { graphNodeId: GraphNodeId; title: string };
res: { project: Project };
};
[ipcChannels.project.deleteScene]: { [ipcChannels.project.deleteScene]: {
req: { sceneId: SceneId }; req: { sceneId: SceneId };
res: { project: Project }; res: { project: Project };
@@ -261,8 +286,66 @@ export type IpcInvokeMap = {
req: Record<string, never>; req: Record<string, never>;
res: { canceled: true } | { canceled: false; project: Project }; res: { canceled: true } | { canceled: false; project: Project };
}; };
[ipcChannels.project.getProjectStorylines]: {
req: { projectId: ProjectId; labels: StorylineLabels };
res: { storylines: StorylineListItem[] };
};
[ipcChannels.project.peekImportZip]: {
req: { labels: StorylineLabels; targetHasMainStart: boolean };
res:
| { canceled: true }
| {
canceled: false;
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
};
};
[ipcChannels.project.pickImportZipFile]: {
req: Record<string, never>;
res: { canceled: true } | { canceled: false; filePath: string };
};
[ipcChannels.project.peekImportZipPath]: {
req: { filePath: string; labels: StorylineLabels; targetHasMainStart: boolean };
res: {
filePath: string;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
};
};
[ipcChannels.project.peekImportFromProject]: {
req: { sourceProjectId: ProjectId; labels: StorylineLabels; targetHasMainStart: boolean };
res: {
sourceProjectId: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
};
};
[ipcChannels.project.mergeImportZip]: {
req: {
filePath: string;
storylineSelections: StorylineSelection[];
sceneResolutions: SceneImportResolution[];
};
res: { project: Project; report: StorylineImportMergeReport };
};
[ipcChannels.project.mergeImportFromProject]: {
req: {
sourceProjectId: ProjectId;
storylineSelections: StorylineSelection[];
sceneResolutions: SceneImportResolution[];
};
res: { project: Project; report: StorylineImportMergeReport };
};
[ipcChannels.project.importZipFromPath]: {
req: { filePath: string };
res: { project: Project };
};
[ipcChannels.project.exportZip]: { [ipcChannels.project.exportZip]: {
req: { projectId: ProjectId }; req: { projectId: ProjectId; storylineSelections: StorylineSelection[]; labels: StorylineLabels };
res: { canceled: true } | { canceled: false }; res: { canceled: true } | { canceled: false };
}; };
[ipcChannels.project.deleteProject]: { [ipcChannels.project.deleteProject]: {
+15 -1
View File
@@ -1,6 +1,6 @@
import type { AssetId, GraphNodeId, ProjectId, SceneId } from './ids'; import type { AssetId, GraphNodeId, ProjectId, SceneId } from './ids';
export const PROJECT_SCHEMA_VERSION = 4 as const; export const PROJECT_SCHEMA_VERSION = 5 as const;
export type IsoDateTimeString = string; export type IsoDateTimeString = string;
@@ -69,6 +69,10 @@ export type SceneGraphNode = {
y: number; y: number;
/** Ровно один узел в проекте может быть начальной сценой для входа в граф. */ /** Ровно один узел в проекте может быть начальной сценой для входа в граф. */
isStartScene: boolean; isStartScene: boolean;
/** Стартовый узел побочной сюжетной линии (синяя метка «ПОБОЧНАЯ»). */
isSideStoryStart: boolean;
/** Название побочной линии; только на узле с `isSideStoryStart`. */
sideStoryLineTitle: string;
}; };
export type SceneGraphEdge = { export type SceneGraphEdge = {
@@ -98,6 +102,14 @@ export type Scene = {
layout: SceneLayout; layout: SceneLayout;
}; };
/** Запись о сюжетной линии в partial-экспорте (manifest в project.json). */
export type ExportedStorylineRef = {
kind: 'main' | 'side';
/** Для побочной линии — id стартового узла графа в этом архиве. */
startGraphNodeId: GraphNodeId | null;
label: string;
};
export type ProjectMeta = { export type ProjectMeta = {
name: string; name: string;
/** Имя файла проекта без суффикса `.ttrpg.zip` (то, что пользователь редактирует). */ /** Имя файла проекта без суффикса `.ttrpg.zip` (то, что пользователь редактирует). */
@@ -124,4 +136,6 @@ export type Project = {
/** Позиции карточек на графе; логические связи сцен по-прежнему в `Scene.connections`. */ /** Позиции карточек на графе; логические связи сцен по-прежнему в `Scene.connections`. */
sceneGraphNodes: SceneGraphNode[]; sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[]; sceneGraphEdges: SceneGraphEdge[];
/** При partial-экспорте: какие сюжетные линии включены в архив. */
exportedStorylines?: ExportedStorylineRef[];
}; };
+14
View File
@@ -7,3 +7,17 @@
- Delete a link only through its context menu: right-click the line and choose **Delete**. - Delete a link only through its context menu: right-click the line and choose **Delete**.
- Delete a node through the node context menu: right-click the node and choose **Delete**. Removing a node does not delete the scene from the scene list. - Delete a node through the node context menu: right-click the node and choose **Delete**. Removing a node does not delete the scene from the scene list.
## Main story start
- Right-click a node → **Start scene** to mark the main storyline entry (purple **START** badge).
- Only one main start per project. **Run** in the editor header uses this node.
## Side storylines
- Side storylines are isolated subgraphs: no links to the main story or to other side storylines.
- Right-click a node in a free component → **Side storyline start scene** (blue **SIDE** badge).
- The menu item is hidden if the connected component already has a main **START** or another **SIDE** marker.
- Side storyline links and selected cards use blue (`#0078d4`).
- **Start from this scene** is not available on side-story nodes.
- Scene properties show **Side storyline title** when the scene has a side-start card.
- During play, side storylines appear as tiles on the control panel (under Music). Clicking a tile jumps to its first scene and remembers the main-story return point.
+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/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/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",
+96 -29
View File
@@ -2,11 +2,19 @@ import { context } from 'esbuild';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import http from 'node:http';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const electronEnv = {
...process.env,
NODE_ENV: 'development',
DND_SKIP_LICENSE: process.env.DND_SKIP_LICENSE ?? '1',
VITE_DEV_SERVER_URL: 'http://localhost:5173/',
};
function spawnShell(command, opts = {}) { function spawnShell(command, opts = {}) {
const child = spawn(command, { const child = spawn(command, {
cwd: root, cwd: root,
@@ -41,7 +49,83 @@ function killTree(child) {
} }
} }
function waitForVite(url = 'http://127.0.0.1:5173/editor.html', timeoutMs = 60000) {
const started = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
const req = http.get(url, (res) => {
res.resume();
resolve();
});
req.on('error', () => {
if (Date.now() - started > timeoutMs) {
reject(new Error(`Timed out waiting for Vite at ${url}`));
return;
}
setTimeout(tick, 300);
});
req.setTimeout(2000, () => {
req.destroy();
});
};
tick();
});
}
let shuttingDown = false;
let electron = null;
let vite = null;
let dispose = async () => {};
let restartTimer = null;
let electronStarted = false;
let restartingElectron = false;
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
if (restartTimer) clearTimeout(restartTimer);
killTree(vite);
killTree(electron);
await dispose();
process.exit(0);
}
function startElectron() {
if (shuttingDown) return;
restartingElectron = false;
if (electron) killTree(electron);
electron = spawnShell('npx electron .', { env: electronEnv });
electron.once('exit', () => {
if (restartingElectron || shuttingDown) return;
void shutdown();
});
electronStarted = true;
}
function scheduleElectronRestart() {
if (shuttingDown || !electronStarted) return;
if (restartTimer) clearTimeout(restartTimer);
restartTimer = setTimeout(() => {
restartTimer = null;
console.log('[dev] main/preload rebuilt — restarting Electron…');
restartingElectron = true;
startElectron();
}, 300);
}
function createRestartPlugin() {
return {
name: 'restart-electron-on-rebuild',
setup(build) {
build.onEnd((result) => {
if (result.errors.length === 0) scheduleElectronRestart();
});
},
};
}
async function watchMainAndPreload() { async function watchMainAndPreload() {
const restartPlugin = createRestartPlugin();
const main = await context({ const main = await context({
entryPoints: [path.join(root, 'app/main/index.ts')], entryPoints: [path.join(root, 'app/main/index.ts')],
outfile: path.join(root, 'dist/main/index.cjs'), outfile: path.join(root, 'dist/main/index.cjs'),
@@ -52,6 +136,7 @@ async function watchMainAndPreload() {
sourcemap: true, sourcemap: true,
external: ['electron'], external: ['electron'],
define: { 'process.env.NODE_ENV': JSON.stringify('development') }, define: { 'process.env.NODE_ENV': JSON.stringify('development') },
plugins: [restartPlugin],
}); });
await main.rebuild(); await main.rebuild();
await main.watch(); await main.watch();
@@ -66,6 +151,7 @@ async function watchMainAndPreload() {
sourcemap: true, sourcemap: true,
external: ['electron'], external: ['electron'],
define: { 'process.env.NODE_ENV': JSON.stringify('development') }, define: { 'process.env.NODE_ENV': JSON.stringify('development') },
plugins: [restartPlugin],
}); });
await preload.rebuild(); await preload.rebuild();
await preload.watch(); await preload.watch();
@@ -75,42 +161,23 @@ async function watchMainAndPreload() {
}; };
} }
const dispose = await watchMainAndPreload(); dispose = await watchMainAndPreload();
const vite = spawnShell('npx vite dev --strictPort', {
env: { vite = spawnShell('npx vite dev --strictPort', {
...process.env, env: electronEnv,
NODE_ENV: 'development',
DND_SKIP_LICENSE: process.env.DND_SKIP_LICENSE ?? '1',
VITE_DEV_SERVER_URL: 'http://localhost:5173/',
},
});
const electron = spawnShell('npx electron .', {
env: {
...process.env,
NODE_ENV: 'development',
DND_SKIP_LICENSE: process.env.DND_SKIP_LICENSE ?? '1',
VITE_DEV_SERVER_URL: 'http://localhost:5173/',
},
}); });
let shuttingDown = false; try {
await waitForVite();
} catch (err) {
console.error('[dev] Vite did not start in time:', err);
}
const shutdown = async () => { startElectron();
if (shuttingDown) return;
shuttingDown = true;
killTree(vite);
killTree(electron);
await dispose();
process.exit(0);
};
process.on('SIGINT', () => void shutdown()); process.on('SIGINT', () => void shutdown());
process.on('SIGTERM', () => void shutdown()); process.on('SIGTERM', () => void shutdown());
electron.once('exit', () => {
void shutdown();
});
vite.once('exit', (code) => { vite.once('exit', (code) => {
if (code !== 0 && code !== null && !shuttingDown) { if (code !== 0 && code !== null && !shuttingDown) {
void shutdown(); void shutdown();