From c8ab9dd5678f31afd4727f549624a8d3b8b0366a Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 9 Jul 2026 14:47:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=D0=BF=D0=BE=D0=B1=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=81=D1=8E=D0=B6=D0=B5=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BB=D0=B8=D0=BD=D0=B8=D0=B8=20=D0=B8=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82/=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D0=BB=D0=B8=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на пульте: визуальное выделение компонент графа, запрет недопустимых связей между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий. Реализован 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 --- app/main/index.ts | 126 +++- .../project/zipStore.legacyContract.test.ts | 9 +- app/main/project/zipStore.ts | 430 ++++++++++- app/renderer/control/ControlApp.module.css | 56 ++ app/renderer/control/ControlApp.tsx | 140 +++- app/renderer/editor/EditorApp.module.css | 74 ++ app/renderer/editor/EditorApp.tsx | 343 +++++---- .../editor/StorylineTransferModals.tsx | 671 ++++++++++++++++++ .../editor/graph/SceneGraph.module.css | 28 + app/renderer/editor/graph/SceneGraph.tsx | 179 +++-- .../graph/sceneGraph.contextMenu.test.ts | 8 + app/renderer/editor/help/helpSections.ts | 1 + app/renderer/editor/i18n/editorMessages.ts | 105 ++- .../editor/state/projectState.race.test.ts | 3 +- app/renderer/editor/state/projectState.ts | 232 +++++- app/renderer/shared/styles/variables.css | 6 + app/shared/graph/sceneGraphEdgeRules.ts | 29 + app/shared/graph/sceneGraphLineage.test.ts | 170 +++++ app/shared/graph/sceneGraphLineage.ts | 172 +++++ .../graph/storylineExportImport.test.ts | 230 ++++++ app/shared/graph/storylineExportImport.ts | 519 ++++++++++++++ app/shared/ipc/contracts.ts | 85 ++- app/shared/types/domain.ts | 16 +- docs/graph-editing.md | 14 + package.json | 2 +- scripts/dev.mjs | 125 +++- 26 files changed, 3507 insertions(+), 266 deletions(-) create mode 100644 app/renderer/editor/StorylineTransferModals.tsx create mode 100644 app/shared/graph/sceneGraphLineage.test.ts create mode 100644 app/shared/graph/sceneGraphLineage.ts create mode 100644 app/shared/graph/storylineExportImport.test.ts create mode 100644 app/shared/graph/storylineExportImport.ts diff --git a/app/main/index.ts b/app/main/index.ts index 41823e7..c781114 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -517,6 +517,16 @@ async function main() { emitSessionState(); 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 }) => { const project = await projectStore.deleteScene(sceneId); emitSessionState(); @@ -537,7 +547,6 @@ async function main() { } const srcPath = filePaths[0]; 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) => { emitZipProgress({ kind: 'import', @@ -550,7 +559,94 @@ async function main() { emitSessionState(); 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 entry = list.find((p) => p.id === projectId); if (!entry) { @@ -569,17 +665,23 @@ async function main() { return { canceled: true as const }; } const dest = normalizeSaveProjectZipPath(filePath); - emitZipProgress({ kind: 'export', stage: 'copy', percent: 0, detail: 'Экспорт…' }); - await projectStore.exportProjectZipToPath(projectId, dest, (p) => { - emitZipProgress({ - kind: 'export', - stage: p.stage, - percent: p.percent, - ...(p.detail ? { detail: p.detail } : null), + try { + emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' }); + await projectStore.exportStorylinesZipToPath(projectId, storylineSelections, dest, labels, (p) => { + emitZipProgress({ + kind: 'export', + stage: p.stage, + percent: p.percent, + ...(p.detail ? { detail: p.detail } : null), + }); }); - }); - emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' }); - return { canceled: false as const }; + emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' }); + 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 }) => { await projectStore.deleteProjectById(projectId); diff --git a/app/main/project/zipStore.legacyContract.test.ts b/app/main/project/zipStore.legacyContract.test.ts index 8a16e2a..4fcbc0a 100644 --- a/app/main/project/zipStore.legacyContract.test.ts +++ b/app/main/project/zipStore.legacyContract.test.ts @@ -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', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match(src, /private packChain: Promise/); - assert.match(src, /private openChain: Promise/); + assert.match(src, /private projectSwitchChain: Promise/); assert.match(src, /enqueuePack/); 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 \{[\s\S]*enqueueProjectSwitch/); + assert.match(src, /Открытие проекта отменено/); }); void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => { diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 951d6db..a0bf6cf 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -7,6 +7,20 @@ import path from 'node:path'; import { ZipFile } from 'yazl'; 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 { isProjectZipFileName, @@ -61,8 +75,8 @@ export class ZipProjectStore { private projectWriteChain: Promise = Promise.resolve(); /** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */ private packChain: Promise = Promise.resolve(); - /** Serializes open/unzip — double-click fired two concurrent opens and corrupted reads. */ - private openChain: Promise = Promise.resolve(); + /** Serializes open/close/unzip — concurrent IPC caused ghost open projects and deadlocks. */ + private projectSwitchChain: Promise = Promise.resolve(); private saveDebounceTimer: ReturnType | null = null; private enqueuePack(cacheDir: string, zipPath: string): Promise { @@ -73,15 +87,19 @@ export class ZipProjectStore { return next; } - private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise { - const task = this.openChain.then(() => this.openProjectByIdInner(projectId, onUnzipPercent)); - this.openChain = task.then( + private enqueueProjectSwitch(fn: () => Promise): Promise { + const task = this.projectSwitchChain.then(() => fn()); + this.projectSwitchChain = task.then( () => undefined, () => undefined, ); return task; } + private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise { + return this.enqueueProjectSwitch(() => this.openProjectByIdInner(projectId, onUnzipPercent)); + } + /** Waits for debounced save, in-flight pack, and pending project.json writes before reading zip. */ private async drainSavePipeline(): Promise { if (this.saveDebounceTimer) { @@ -228,13 +246,18 @@ export class ZipProjectStore { if (this.openProject?.id === projectId) { return this.openProject.project; } + const sessionAtStart = this.projectSession; // 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. if (this.openProject) { await this.saveNow(); } await this.drainSavePipeline(); + if (sessionAtStart !== this.projectSession) { + throw new Error('Открытие проекта отменено'); + } this.projectSession += 1; + const openSession = this.projectSession; const list = await this.listProjects(); const entry = list.find((p) => p.id === projectId); if (!entry) { @@ -256,6 +279,11 @@ export class ZipProjectStore { 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 projectRaw = await fs.readFile(projectPath, 'utf8'); const parsed = JSON.parse(projectRaw) as unknown as Project; @@ -640,6 +668,8 @@ export class ZipProjectStore { x, y, isStartScene: false, + isSideStoryStart: false, + sideStoryLineTitle: '', }; await this.updateProject((p) => ({ ...p, sceneGraphNodes: [...p.sceneGraphNodes, node] })); const latest = this.getOpenProject(); @@ -655,10 +685,59 @@ export class ZipProjectStore { } await this.updateProject((p) => ({ ...p, - sceneGraphNodes: p.sceneGraphNodes.map((n) => ({ - ...n, - isStartScene: graphNodeId !== null && n.id === graphNodeId, - })), + sceneGraphNodes: p.sceneGraphNodes.map((n) => { + const isMain = 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 { + 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 { + 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(); if (!latest) throw new Error('No open project'); @@ -668,6 +747,61 @@ export class ZipProjectStore { async removeSceneGraphNode(nodeId: GraphNodeId): Promise { const open = this.openProject; 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 nextEdges = open.project.sceneGraphEdges.filter( (e) => e.sourceGraphNodeId !== nodeId && e.targetGraphNodeId !== nodeId, @@ -847,12 +981,14 @@ export class ZipProjectStore { } async closeOpenProject(): Promise { - if (!this.openProject) return; - await this.saveNow(); - await this.drainSavePipeline(); - this.saveQueued = false; - this.openProject = null; - this.projectSession += 1; + return this.enqueueProjectSwitch(async () => { + this.projectSession += 1; + if (!this.openProject) return; + await this.saveNow(); + await this.drainSavePipeline(); + this.saveQueued = false; + this.openProject = null; + }); } async renameOpenProject(name: string, fileBaseName: string): Promise { @@ -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 { + 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 { + 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 { + 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 { return crypto.randomBytes(16).toString('hex'); } @@ -1166,6 +1552,8 @@ function migrateSceneGraphFromLegacy(scenes: Record): { x: s.layout.x, y: s.layout.y, isStartScene: false, + isSideStoryStart: false, + sideStoryLineTitle: '', })); const byScene = new Map(sceneGraphNodes.map((n) => [n.sceneId, n])); const sceneGraphEdges: SceneGraphEdge[] = []; @@ -1189,10 +1577,18 @@ function migrateSceneGraphFromLegacy(scenes: Record): { /** Один флаг `isStartScene` на весь проект; лишние true сбрасываются. */ function normalizeSceneGraphNodeFlags(nodes: SceneGraphNode[]): SceneGraphNode[] { 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 { ...n, - isStartScene: raw.isStartScene === true, + isStartScene, + isSideStoryStart, + sideStoryLineTitle: isSideStoryStart ? (raw.sideStoryLineTitle ?? '').trim() : '', }; }); const starters = withDefaults.filter((n) => n.isStartScene); diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index b587dc6..7ffe88f 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -278,6 +278,62 @@ 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 { display: flex; align-items: center; diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 5dbdde5..a446c53 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -3,15 +3,18 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { ipcChannels } 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 { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { getDndApi } from '../shared/dndApi'; +import { RotatedImage } from '../shared/RotatedImage'; import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay'; import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; import { useEffectsState } from '../shared/effects/useEffectsState'; import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState'; import { Button } from '../shared/ui/controls'; import { Surface } from '../shared/ui/Surface'; +import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './ControlApp.module.css'; 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 ( + + ); +} + export function ControlApp() { const api = getDndApi(); const { t } = useEditorI18n(); @@ -55,6 +93,9 @@ export function ControlApp() { const [session, setSession] = useState(null); const historyRef = useRef([]); const [history, setHistory] = useState([]); + /** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */ + const mainStoryReturnRef = useRef(null); + const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState(null); // Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта. const sceneAudioElsRef = useRef>(new Map()); const sceneAudioMetaRef = useRef>(new Map()); @@ -116,7 +157,11 @@ export function ControlApp() { return api.on(ipcChannels.session.stateChanged, ({ state }) => { setSession(state); const cur = state.project?.currentGraphNodeId ?? null; - if (!cur) return; + if (!cur) { + mainStoryReturnRef.current = null; + setMainStoryReturnGraphNodeId(null); + return; + } const arr = historyRef.current; if (arr[arr.length - 1] !== cur) { historyRef.current = [...arr, cur]; @@ -125,6 +170,15 @@ export function ControlApp() { }); }, [api]); + useEffect(() => { + return api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => { + if (!open) { + mainStoryReturnRef.current = null; + setMainStoryReturnGraphNodeId(null); + } + }); + }, [api]); + useEffect(() => { audioUnmountRef.current = false; return () => { @@ -590,6 +644,54 @@ export function ControlApp() { .filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined); }, [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 toolRef = useRef(tool); toolRef.current = tool; @@ -1407,10 +1509,23 @@ export function ControlApp() {
{t('control.branches')}
+ {showReturnToMain ? ( +
+
+
{t('control.option', { n: '1' })}
+
+
{returnSceneTitle}
+ +
+ ) : null} {nextScenes.map((o, i) => (
-
{t('control.option', { n: String(i + 1) })}
+
+ {t('control.option', { n: String(i + 1 + branchOptionOffset) })} +
{o.scene.title || t('control.unnamed')}
))} - {nextScenes.length === 0 ? ( + {nextScenes.length === 0 && !showReturnToMain ? (
{t('control.noBranches')}
); diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index ffb162f..448dbfc 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -941,3 +941,77 @@ cursor: pointer; 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; +} diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 5c6f0c0..2ffe75d 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -25,6 +25,19 @@ import { Button, Input } from '../shared/ui/controls'; import { LayoutShell } from '../shared/ui/LayoutShell'; 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 styles from './EditorApp.module.css'; import { buildNextSceneCardById } from './graph/sceneCardById'; @@ -92,6 +105,14 @@ export function EditorApp() { const [instructionsSection, setInstructionsSection] = useState('overview'); const [renameOpen, setRenameOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); + const [importSourceOpen, setImportSourceOpen] = useState(false); + const [importPeek, setImportPeek] = useState(null); + const [importStorylinesOpen, setImportStorylinesOpen] = useState(false); + const [importConflictsOpen, setImportConflictsOpen] = useState(false); + const [importConflicts, setImportConflicts] = useState>([]); + const [pendingImportSelections, setPendingImportSelections] = useState([]); + const [importReportOpen, setImportReportOpen] = useState(false); + const [importReport, setImportReport] = useState(null); const [previewDialogSceneId, setPreviewDialogSceneId] = useState(null); const [presentationOpen, setPresentationOpen] = useState(false); const [licenseSnap, setLicenseSnap] = useState(null); @@ -119,6 +140,7 @@ export function EditorApp() { const graphUi = useMemo( () => ({ badgeStart: t('graph.badgeStart'), + badgeSideStory: t('graph.badgeSideStory'), untitled: t('graph.untitled'), videoBadge: t('graph.videoBadge'), audioBadge: t('graph.audioBadge'), @@ -133,6 +155,8 @@ export function EditorApp() { closeMenu: t('common.closeMenu'), startScene: t('graph.startScene'), unsetStartScene: t('graph.unsetStartScene'), + sideStoryStartScene: t('graph.sideStoryStartScene'), + unsetSideStoryStartScene: t('graph.unsetSideStoryStartScene'), runFromScene: t('graph.runFromScene'), delete: t('common.delete'), }), @@ -146,6 +170,19 @@ export function EditorApp() { const [projectMenuPos, setProjectMenuPos] = 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 [selectedGraphNodeId, setSelectedGraphNodeId] = useState(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(() => { const p = state.project; if (!p) return []; @@ -367,6 +404,79 @@ export function EditorApp() { }, []); 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 = licenseSnap === null ? ( @@ -424,7 +534,7 @@ export function EditorApp() { type="button" className={styles.brandButton} onClick={() => { - void actions.closeProject(); + goHome(); }} title={t('top.backToProjects')} > @@ -562,7 +672,10 @@ export function EditorApp() { void actions.selectScene(s.id)} + onSelect={() => { + setSelectedGraphNodeId(null); + void actions.selectScene(s.id); + }} onDeleteScene={(id) => void actions.deleteScene(id)} /> ))} @@ -604,8 +717,11 @@ export function EditorApp() { sceneGraphNodes={state.project.sceneGraphNodes} sceneGraphEdges={state.project.sceneGraphEdges} sceneCardById={sceneCardById} - currentSceneId={state.selectedSceneId} - onCurrentSceneChange={(id) => void actions.selectScene(id)} + selectedGraphNodeId={selectedGraphNodeId} + onGraphNodeSelect={(graphNodeId, sceneId) => { + setSelectedGraphNodeId(graphNodeId); + void actions.selectScene(sceneId); + }} onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)} onDisconnect={(edgeId) => void actions.removeSceneGraphEdge(edgeId)} onNodePositionCommit={(nodeId, x, y) => @@ -616,6 +732,9 @@ export function EditorApp() { }} onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)} onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)} + onSetGraphNodeSideStoryStart={(graphNodeId) => + void actions.setSceneGraphNodeSideStoryStart(graphNodeId) + } onRunFromGraphNode={launchFromGraphNode} onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)} /> @@ -666,10 +785,14 @@ export function EditorApp() { : previewImport !== null ? t('scene.previewOptimizing') : t('scene.previewBusy'); + const sideStoryStartNodes = proj.sceneGraphNodes.filter( + (n) => n.sceneId === sid && n.isSideStoryStart, + ); return ( void actions.importMediaToScene(sid)} + onSideStoryLineTitleChange={(graphNodeId, title) => + void actions.updateSideStoryLineTitle(graphNodeId, title) + } /> ); })() @@ -910,8 +1036,7 @@ export function EditorApp() { role="menuitem" className={styles.fileMenuItem} onClick={() => { - setProjectMenuOpen(false); - void actions.closeProject(); + goHome(); }} > {t('projectMenu.home')} @@ -921,8 +1046,7 @@ export function EditorApp() { role="menuitem" className={styles.fileMenuItem} onClick={() => { - setProjectMenuOpen(false); - void actions.importProject(); + void handleImportProject(); }} > {t('projectMenu.import')} @@ -990,9 +1114,77 @@ export function EditorApp() { open={exportModalOpen} projects={state.projects} initialProjectId={exportModalInitialProjectId} + storylineLabels={storylineLabels} + loadStorylines={loadProjectStorylines} onClose={() => setExportModalOpen(false)} - onExport={async (projectId) => { - await actions.exportProject(projectId); + onExport={async (projectId, selections) => { + await actions.exportProject(projectId, selections, storylineLabels); + }} + /> + setImportSourceOpen(false)} + onContinue={handleImportSourceContinue} + /> + { + 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); + }} + /> + { + 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); + }} + /> + { + setImportReportOpen(false); + setImportReport(null); }} /> 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; -}; - -function ExportProjectModal({ - open, - projects, - initialProjectId, - onClose, - onExport, -}: ExportProjectModalProps) { - const { t } = useEditorI18n(); - const [projectId, setProjectId] = useState(initialProjectId); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(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( - <> - -
- -
-
{t('export.project')}
- -
{t('export.hint')}
-
- - {error ?
{error}
: null} - -
- - -
- - , - document.body, - ); -} - type CheckUpdatesModalProps = { open: boolean; onClose: () => void; @@ -1787,6 +1867,7 @@ function ProjectPicker({ type SceneInspectorProps = { title: string; description: string; + sideStoryStartNodes: { id: GraphNodeId; sideStoryLineTitle: string }[]; previewAssetId: AssetId | null; previewAssetType: 'image' | 'video' | null; previewVideoAutostart: boolean; @@ -1805,6 +1886,7 @@ type SceneInspectorProps = { onClearPreview: () => void; onRotatePreview: (deg: 0 | 90 | 180 | 270) => void; onUploadMedia: () => void; + onSideStoryLineTitleChange: (graphNodeId: GraphNodeId, title: string) => void; }; type CampaignInspectorProps = { @@ -1897,6 +1979,7 @@ function CampaignInspector({ function SceneInspector({ title, description, + sideStoryStartNodes, previewAssetId, previewAssetType, previewVideoAutostart, @@ -1915,6 +1998,7 @@ function SceneInspector({ onClearPreview, onRotatePreview, onUploadMedia, + onSideStoryLineTitleChange, }: SceneInspectorProps) { const { t } = useEditorI18n(); const previewUrl = useAssetUrl(previewAssetId); @@ -1930,6 +2014,21 @@ function SceneInspector({ value={description} onChange={(e) => onDescriptionChange(e.target.value)} /> + {sideStoryStartNodes.length > 0 ? ( + <> +
+ {sideStoryStartNodes.map((gn) => ( +
+
{t('scene.sideStoryLineTitle')}
+ onSideStoryLineTitleChange(gn.id, v)} + /> +
+
+ ))} + + ) : null}
{t('scene.preview')}
{t('scene.previewHint')}
diff --git a/app/renderer/editor/StorylineTransferModals.tsx b/app/renderer/editor/StorylineTransferModals.tsx new file mode 100644 index 0000000..1c9386a --- /dev/null +++ b/app/renderer/editor/StorylineTransferModals.tsx @@ -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; + onClose: () => void; + onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise; +}; + +export function ExportProjectModal({ + open, + projects, + initialProjectId, + storylineLabels, + loadStorylines, + onClose, + onExport, +}: ExportProjectModalProps) { + const { t } = useEditorI18n(); + const [projectId, setProjectId] = useState(initialProjectId); + const [storylines, setStorylines] = useState([]); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [loadingStorylines, setLoadingStorylines] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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( + <> + +
+ +
+
{t('export.project')}
+ + +
{t('storyline.section')}
+ {loadingStorylines ? ( +
{t('storyline.loading')}
+ ) : storylines.length === 0 ? ( +
{t('storyline.empty')}
+ ) : ( +
+ {storylines.map((item) => { + const key = storylineSelectionKey(item.selection); + const checked = selectedKeys.has(key); + const disabled = item.disabled === true; + return ( + + ); + })} +
+ )} + +
{t('export.hint')}
+
+ + {error ?
{error}
: null} + +
+ + +
+
+ , + 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; +}; + +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(null); + const [pickedFile, setPickedFile] = useState<{ path: string; name: string } | null>(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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( + <> + +
+ +
+ {canImportFromProject ? ( + <> +
{t('importSource.type')}
+ + + ) : ( +
{t('importSource.fileOnlyHint')}
+ )} + + {importKind === 'project' && canImportFromProject ? ( + <> +
{t('importSource.project')}
+ + {availableProjects.length === 0 ? ( +
{t('importSource.noOtherProjects')}
+ ) : null} + + ) : ( + <> +
{t('importSource.file')}
+
+ + + {pickedFile ? pickedFile.name : t('importSource.noFileSelected')} + +
+ + )} +
+ + {error ?
{error}
: null} + +
+ + +
+ + , + 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>(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( + <> + + + +
+
{t('importStoryline.source')}
+
{sourceName}
+ +
{t('storyline.section')}
+ {storylines.length === 0 ? ( +
{t('storyline.empty')}
+ ) : ( +
+ {storylines.map((item) => { + const key = storylineSelectionKey(item.selection); + const disabled = item.disabled === true; + return ( + + ); + })} +
+ )} +
+ +
+ + +
+ + , + 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>({}); + + useEffect(() => { + if (!open) return; + const init: Record = {}; + for (const c of conflicts) { + init[c.sourceSceneId] = c.matches[0]?.sceneId ?? 'create'; + } + setChoices(init); + }, [conflicts, open]); + + if (!open) return null; + + return createPortal( + <> + + + +

{t('importStoryline.conflictsHint')}

+ +
+ {conflicts.map((c) => ( +
+
{c.sourceTitle}
+ +
+ ))} +
+ +
+ + +
+ + , + 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( + <> + + + +
    +
  • {t('importStoryline.reportLines', { count: report.storylinesImported })}
  • +
  • {t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}
  • +
  • {t('importStoryline.reportScenesReused', { count: report.scenesReused })}
  • +
  • {t('importStoryline.reportNodes', { count: report.graphNodesAdded })}
  • +
  • {t('importStoryline.reportEdges', { count: report.edgesAdded })}
  • +
  • {t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}
  • +
  • {t('importStoryline.reportAssetsReused', { count: report.assetsReused })}
  • + {report.renamedSideTitles.length > 0 ? ( +
  • + {t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })} +
  • + ) : null} +
+ +
+ +
+ + , + 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], + ); +} diff --git a/app/renderer/editor/graph/SceneGraph.module.css b/app/renderer/editor/graph/SceneGraph.module.css index be1197f..5a85654 100644 --- a/app/renderer/editor/graph/SceneGraph.module.css +++ b/app/renderer/editor/graph/SceneGraph.module.css @@ -8,6 +8,12 @@ height: 8px; } +.handleSide { + background: var(--side-story-handle); + width: 8px; + height: 8px; +} + .card { box-sizing: border-box; width: 100%; @@ -26,6 +32,13 @@ 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 { position: relative; width: 100%; @@ -65,6 +78,21 @@ 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 { position: absolute; top: 8px; diff --git a/app/renderer/editor/graph/SceneGraph.tsx b/app/renderer/editor/graph/SceneGraph.tsx index 7c11207..7de8c2a 100644 --- a/app/renderer/editor/graph/SceneGraph.tsx +++ b/app/renderer/editor/graph/SceneGraph.tsx @@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS import { createPortal } from 'react-dom'; import ReactFlow, { Background, + ConnectionMode, Handle, MarkerType, Panel, @@ -19,6 +20,11 @@ import ReactFlow, { import 'reactflow/dist/style.css'; 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 { RotatedImage } from '../../shared/RotatedImage'; import { useAssetUrl } from '../../shared/useAssetImageUrl'; @@ -53,6 +59,7 @@ const SCENE_CARD_H = 248; /** UI strings for the scene graph (passed from editor i18n). */ export type SceneGraphUiStrings = { badgeStart: string; + badgeSideStory: string; untitled: string; videoBadge: string; audioBadge: string; @@ -67,12 +74,15 @@ export type SceneGraphUiStrings = { closeMenu: string; startScene: string; unsetStartScene: string; + sideStoryStartScene: string; + unsetSideStoryStartScene: string; runFromScene: string; delete: string; }; const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = { badgeStart: 'НАЧАЛО', + badgeSideStory: 'ПОБОЧНАЯ', untitled: 'Без названия', videoBadge: 'Видео', audioBadge: 'Аудио', @@ -87,6 +97,8 @@ const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = { closeMenu: 'Закрыть меню', startScene: 'Начальная сцена', unsetStartScene: 'Снять метку «Начальная сцена»', + sideStoryStartScene: 'Начальная сцена побочной линии', + unsetSideStoryStartScene: 'Снять метку «Начальная сцена побочной линии»', runFromScene: 'Запустить с этой сцены', delete: 'Удалить', }; @@ -97,15 +109,17 @@ export type SceneGraphProps = { sceneGraphNodes: SceneGraphNode[]; sceneGraphEdges: SceneGraphEdge[]; sceneCardById: Record; - currentSceneId: SceneId | null; + /** Выделенная карточка на графе (одна нода, не все копии сцены). */ + selectedGraphNodeId: GraphNodeId | null; graphUi?: SceneGraphUiStrings; - onCurrentSceneChange: (id: SceneId) => void; + onGraphNodeSelect: (graphNodeId: GraphNodeId, sceneId: SceneId) => void; onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void; onDisconnect: (edgeId: string) => void; onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void; onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void; onRemoveGraphNode: (graphNodeId: GraphNodeId) => void; onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void; + onSetGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => void; onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void; onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void; }; @@ -120,6 +134,8 @@ type SceneCardData = { previewVideoAutostart: boolean; previewRotationDeg: 0 | 90 | 180 | 270; isStartScene: boolean; + isSideStoryStart: boolean; + isSideStoryNode: boolean; hasSceneAudio: boolean; previewIsVideo: boolean; hasAnyAudioLoop: boolean; @@ -179,15 +195,24 @@ function SceneCardNode({ data }: NodeProps) { const ui = useContext(GraphUiContext); const thumbUrl = useAssetUrl(data.previewThumbAssetId); 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 showCornerAudio = data.hasSceneAudio; return (
- +
{data.isStartScene ?
{ui.badgeStart}
: null} + {data.isSideStoryStart ? ( +
{ui.badgeSideStory}
+ ) : null} {thumbUrl ? (
{data.previewRotationDeg === 0 ? ( @@ -303,7 +328,7 @@ function SceneCardNode({ data }: NodeProps) { ) : null}
- +
); } @@ -353,15 +378,16 @@ function SceneGraphCanvas({ sceneGraphNodes, sceneGraphEdges, sceneCardById, - currentSceneId, + selectedGraphNodeId, graphUi, - onCurrentSceneChange, + onGraphNodeSelect, onConnect, onDisconnect, onNodePositionCommit, onRemoveGraphNodes, onRemoveGraphNode, onSetGraphNodeStart, + onSetGraphNodeSideStoryStart, onRunFromGraphNode, onDropSceneFromList, }: SceneGraphProps) { @@ -387,10 +413,31 @@ function SceneGraphCanvas({ return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene); }, [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[]>(() => { return sceneGraphNodes.map((gn) => { 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 ?? []; return { id: gn.id, @@ -406,6 +453,8 @@ function SceneGraphCanvas({ previewVideoAutostart: c?.previewVideoAutostart ?? false, previewRotationDeg: c?.previewRotationDeg ?? 0, isStartScene: gn.isStartScene, + isSideStoryStart: gn.isSideStoryStart, + isSideStoryNode, hasSceneAudio: audios.length >= 1, previewIsVideo: c?.previewAssetType === 'video', hasAnyAudioLoop: audios.some((a) => a.loop), @@ -416,40 +465,46 @@ function SceneGraphCanvas({ style: { padding: 0, background: 'transparent', border: 'none' }, }; }); - }, [currentSceneId, sceneCardById, sceneGraphNodes]); + }, [sceneCardById, sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]); const desiredEdges = useMemo(() => { - const selectedGraphNodeIds = new Set(); - if (currentSceneId) { - for (const gn of sceneGraphNodes) { - if (gn.sceneId === currentSceneId) selectedGraphNodeIds.add(gn.id); - } - } - const hasSelection = selectedGraphNodeIds.size > 0; - return sceneGraphEdges.map((e) => ({ - ...(hasSelection - ? { - style: - selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId) - ? { stroke: 'rgba(167,139,250,0.95)', strokeWidth: 3 } - : { stroke: 'rgba(255,255,255,0.10)', strokeWidth: 2 }, - markerEnd: - selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId) - ? { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.95)', strokeWidth: 2 } - : { type: MarkerType.ArrowClosed, color: 'rgba(255,255,255,0.18)', 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 }, - }), - id: e.id, - source: e.sourceGraphNodeId, - target: e.targetGraphNodeId, - type: 'smoothstep', - animated: false, - selectable: false, - })); - }, [currentSceneId, sceneGraphEdges, sceneGraphNodes]); + const hasSelection = selectedGraphNodeId != null; + return sceneGraphEdges.map((e) => { + const isSide = isSideStoryEdge(sceneGraphNodes, sceneGraphEdges, e); + const strokeActive = isSide ? sideStoryEdgeStroke : mainEdgeStroke; + const strokeIdle = isSide ? sideStoryEdgeStrokeDim : mainEdgeStrokeDim; + const strokeDim = 'rgba(255,255,255,0.10)'; + const markerDim = 'rgba(255,255,255,0.18)'; + const touchesSelection = + selectedGraphNodeId != null && + (e.sourceGraphNodeId === selectedGraphNodeId || e.targetGraphNodeId === selectedGraphNodeId); + return { + ...(hasSelection + ? { + style: touchesSelection + ? { stroke: strokeActive, strokeWidth: 3 } + : { stroke: strokeDim, strokeWidth: 2 }, + markerEnd: touchesSelection + ? { type: MarkerType.ArrowClosed, color: strokeActive, strokeWidth: 2 } + : { type: MarkerType.ArrowClosed, color: markerDim, strokeWidth: 2 }, + } + : { + style: { stroke: strokeIdle, strokeWidth: 2 }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: isSide ? 'rgba(0,120,212,0.85)' : 'rgba(167,139,250,0.85)', + strokeWidth: 2, + }, + }), + id: e.id, + source: e.sourceGraphNodeId, + target: e.targetGraphNodeId, + type: 'smoothstep', + animated: false, + selectable: false, + }; + }); + }, [sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -494,7 +549,7 @@ function SceneGraphCanvas({ if (!menu) return null; const pad = 8; const mw = 220; - const mh = 168; + const mh = 210; 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)); return { x, y }; @@ -523,6 +578,7 @@ function SceneGraphCanvas({ }} onEdgesChange={onEdgesChange} isValidConnection={isValidConnection} + connectionMode={ConnectionMode.Loose} onConnect={onConnectInternal} onEdgeContextMenu={(e, edge) => { e.preventDefault(); @@ -536,7 +592,7 @@ function SceneGraphCanvas({ setMenu(null); setEdgeMenu(null); const d = node.data as SceneCardData; - onCurrentSceneChange(d.sceneId); + onGraphNodeSelect(node.id as GraphNodeId, d.sceneId); }} onNodeContextMenu={(e, node) => { e.preventDefault(); @@ -599,18 +655,33 @@ function SceneGraphCanvas({ > {menuNodeIsStart ? ui.unsetStartScene : ui.startScene} - + {menuNodeIsSideStoryStart || menuCanSetSideStoryStart ? ( + + ) : null} + {!menuNodeIsSideBranch ? ( + + ) : null}