diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index 9e804b7..e07896a 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -221,12 +221,28 @@ flex: 0 0 auto; } +.storyAccordions { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.storyAccordionExpanded { + flex: 1; + min-height: 0; +} + +.storyAccordionCollapsed { + flex: 0 0 auto; +} + .storyWrapCollapsed .historyAccordion { flex: 0 0 auto; } .historyAccordion { - flex: 1; min-height: 0; display: flex; flex-direction: column; diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 8ba4834..ea3aa97 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -11,6 +11,7 @@ import { isNodeInSideStoryline, listSideStoryStarts, } from '../../shared/graph/sceneGraphLineage'; +import { listActiveStorylineScenes } from '../../shared/graph/activeStorylineScenes'; import type { GraphNodeId, MaterialId, @@ -192,6 +193,7 @@ export function ControlApp() { const historyRef = useRef([]); const [history, setHistory] = useState([]); const [historyOpen, setHistoryOpen] = useState(true); + const [scenesOpen, setScenesOpen] = useState(false); const [darknessBrushShape, setDarknessBrushShape] = useState('circle'); const darknessBrushShapeRef = useRef(darknessBrushShape); darknessBrushShapeRef.current = darknessBrushShape; @@ -413,6 +415,11 @@ export function ControlApp() { const project = session?.project ?? null; const currentGraphNodeId = project?.currentGraphNodeId ?? null; const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1; + const historyGraphNodeSet = useMemo(() => new Set(history), [history]); + const storylineScenes = useMemo( + () => (project ? listActiveStorylineScenes(project) : []), + [project], + ); const currentScene = project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined; const visibleNpcTokens = useMemo( @@ -2165,8 +2172,17 @@ export function ControlApp() {
-
-
+
+
+
+
+ + {scenesOpen ? ( +
+ {storylineScenes.map(({ sceneId, graphNodeId }) => { + const s = project?.scenes[sceneId]; + const isCurrent = graphNodeId === currentGraphNodeId; + const visited = historyGraphNodeSet.has(graphNodeId); + return ( + + ); + })} + {storylineScenes.length === 0 ? ( +
{t('control.noActiveScene')}
+ ) : null} +
+ ) : null} +
+
diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index 1f2bf45..7c90c57 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -151,6 +151,18 @@ void test('ControlApp: история — колонка сверху вниз assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/); }); +void test('ControlApp: блок «Сцены» — аккордеон под историей, свёрнут по умолчанию', () => { + const src = readControlApp(); + assert.ok(src.includes('data-testid="control-scenes-accordion"')); + assert.ok(src.includes("t('control.scenesAccordion')")); + assert.ok(src.includes('const [scenesOpen, setScenesOpen] = useState(false)')); + assert.ok(src.includes('listActiveStorylineScenes')); + assert.ok(src.includes('storylineScenes.map')); + const historyIdx = src.indexOf('data-testid="control-history-accordion"'); + const scenesIdx = src.indexOf('data-testid="control-scenes-accordion"'); + assert.ok(historyIdx !== -1 && scenesIdx !== -1 && historyIdx < scenesIdx); +}); + void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => { const src = readControlApp(); assert.ok(!src.includes('suppressNextHistoryPushRef')); diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index fe2ed79..f2b4dc5 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -646,6 +646,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.brushShapeSquare': 'Квадратная', 'control.effectsSound': 'Звук эффектов', 'control.storyLine': 'ИСТОРИЯ', + 'control.scenesAccordion': 'СЦЕНЫ', 'control.gotoScene': 'Перейти к этой сцене', 'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА', 'control.passed': 'Пройдено', @@ -1297,6 +1298,7 @@ export const EDITOR_MESSAGES: Record> = { 'control.brushShapeSquare': 'Square', 'control.effectsSound': 'Effects sound', 'control.storyLine': 'HISTORY', + 'control.scenesAccordion': 'SCENES', 'control.gotoScene': 'Go to this scene', 'control.currentSceneBadge': 'CURRENT SCENE', 'control.passed': 'Visited', diff --git a/app/shared/graph/activeStorylineScenes.test.ts b/app/shared/graph/activeStorylineScenes.test.ts new file mode 100644 index 0000000..6e5c77e --- /dev/null +++ b/app/shared/graph/activeStorylineScenes.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { Project, SceneGraphEdge, SceneGraphNode } from '../types'; +import { asGraphNodeId, asSceneId } from '../types/ids'; + +import { getActiveStorylineGraphNodeIds, listActiveStorylineScenes } from './activeStorylineScenes'; + +function node(id: string, sceneId: string, extra: Partial = {}): SceneGraphNode { + return { + id: asGraphNodeId(id), + sceneId: asSceneId(sceneId), + x: 0, + y: 0, + isStartScene: false, + isSideStoryStart: false, + sideStoryLineTitle: '', + ...extra, + }; +} + +function edge(source: string, target: string): SceneGraphEdge { + return { sourceGraphNodeId: asGraphNodeId(source), targetGraphNodeId: asGraphNodeId(target) }; +} + +function minimalProject(partial: Partial & Pick): Project { + return { + id: partial.id ?? ('p1' as Project['id']), + title: partial.title ?? 'Test', + scenes: partial.scenes ?? {}, + sceneListOrder: partial.sceneListOrder ?? [], + sceneGraphNodes: partial.sceneGraphNodes, + sceneGraphEdges: partial.sceneGraphEdges, + currentGraphNodeId: partial.currentGraphNodeId ?? null, + currentSceneId: partial.currentSceneId ?? null, + materials: partial.materials ?? [], + campaignAudio: partial.campaignAudio ?? [], + npcs: partial.npcs ?? [], + npcGroups: partial.npcGroups ?? [], + npcRelations: partial.npcRelations ?? [], + }; +} + +void test('listActiveStorylineScenes: main line ordered by sceneListOrder, deduped', () => { + const s1 = asSceneId('s1'); + const s2 = asSceneId('s2'); + const s3 = asSceneId('s3'); + const nodes = [ + node('main', 's1', { isStartScene: true }), + node('a', 's2'), + node('b', 's3'), + node('dup', 's2'), + ]; + const edges = [edge('main', 'a'), edge('a', 'b'), edge('a', 'dup')]; + const project = minimalProject({ + scenes: { + [s1]: { id: s1, title: 'Start' }, + [s2]: { id: s2, title: 'Mid' }, + [s3]: { id: s3, title: 'End' }, + }, + sceneListOrder: [s3, s1, s2], + sceneGraphNodes: nodes, + sceneGraphEdges: edges, + currentGraphNodeId: asGraphNodeId('a'), + }); + + const list = listActiveStorylineScenes(project); + assert.deepEqual( + list.map((e) => e.sceneId), + [s3, s1, s2], + ); + assert.equal(list[2]?.graphNodeId, asGraphNodeId('a')); +}); + +void test('listActiveStorylineScenes: side line only', () => { + const s1 = asSceneId('s1'); + const s2 = asSceneId('s2'); + const sSide = asSceneId('sSide'); + const nodes = [ + node('main', 's1', { isStartScene: true }), + node('side', 'sSide', { isSideStoryStart: true }), + node('x', 's2'), + ]; + const edges = [edge('side', 'x')]; + const project = minimalProject({ + scenes: { + [s1]: { id: s1, title: 'Main' }, + [s2]: { id: s2, title: 'Side step' }, + [sSide]: { id: sSide, title: 'Side start' }, + }, + sceneListOrder: [s1, s2, sSide], + sceneGraphNodes: nodes, + sceneGraphEdges: edges, + currentGraphNodeId: asGraphNodeId('x'), + }); + + const ids = getActiveStorylineGraphNodeIds(nodes, edges, asGraphNodeId('x')); + assert.deepEqual([...ids].sort(), [asGraphNodeId('side'), asGraphNodeId('x')].sort()); + + const list = listActiveStorylineScenes(project); + assert.deepEqual( + list.map((e) => e.sceneId), + [s2, sSide], + ); +}); + +void test('listActiveStorylineScenes: current node preferred for duplicate sceneId', () => { + const s2 = asSceneId('s2'); + const nodes = [ + node('main', 's1', { isStartScene: true }), + node('a', 's2'), + node('dup', 's2'), + ]; + const edges = [edge('main', 'a'), edge('main', 'dup')]; + const project = minimalProject({ + scenes: { [asSceneId('s1')]: { id: asSceneId('s1'), title: 'S1' }, [s2]: { id: s2, title: 'S2' } }, + sceneListOrder: [asSceneId('s1'), s2], + sceneGraphNodes: nodes, + sceneGraphEdges: edges, + currentGraphNodeId: asGraphNodeId('dup'), + }); + + const list = listActiveStorylineScenes(project); + assert.equal(list.find((e) => e.sceneId === s2)?.graphNodeId, asGraphNodeId('dup')); +}); diff --git a/app/shared/graph/activeStorylineScenes.ts b/app/shared/graph/activeStorylineScenes.ts new file mode 100644 index 0000000..96a848b --- /dev/null +++ b/app/shared/graph/activeStorylineScenes.ts @@ -0,0 +1,77 @@ +import type { GraphNodeId, Project, SceneGraphEdge, SceneGraphNode, SceneId } from '../types'; + +import { reconcileSceneListOrder } from './sceneListOrder'; +import { + buildStoryRootMap, + getNodeStoryRoot, + getSideStoryComponentNodeIds, + isSideStoryRoot, +} from './sceneGraphLineage'; + +export type ActiveStorylineSceneEntry = { + sceneId: SceneId; + graphNodeId: GraphNodeId; +}; + +/** Узлы графа, принадлежащие активной сюжетной линии текущего положения. */ +export function getActiveStorylineGraphNodeIds( + nodes: SceneGraphNode[], + edges: SceneGraphEdge[], + currentGraphNodeId: GraphNodeId, +): Set { + const root = getNodeStoryRoot(nodes, edges, currentGraphNodeId); + if (root === 'main') { + const rootMap = buildStoryRootMap(nodes, edges); + const ids = new Set(); + for (const [id, r] of rootMap) { + if (r === 'main') ids.add(id); + } + return ids; + } + if (isSideStoryRoot(root)) { + return getSideStoryComponentNodeIds(nodes, edges, root); + } + return new Set(); +} + +function pickGraphNodeForScene( + sceneId: SceneId, + storylineNodeIds: Set, + nodes: SceneGraphNode[], + currentGraphNodeId: GraphNodeId | null, +): GraphNodeId | null { + const matching = nodes.filter((n) => storylineNodeIds.has(n.id) && n.sceneId === sceneId); + if (matching.length === 0) return null; + if (currentGraphNodeId && matching.some((n) => n.id === currentGraphNodeId)) { + return currentGraphNodeId; + } + return matching[0]?.id ?? null; +} + +/** + * Сцены активной линии в порядке бокового списка редактора; каждая sceneId — один раз. + */ +export function listActiveStorylineScenes(project: Project): ActiveStorylineSceneEntry[] { + const currentGraphNodeId = project.currentGraphNodeId; + if (!currentGraphNodeId) return []; + + const storylineNodeIds = getActiveStorylineGraphNodeIds( + project.sceneGraphNodes, + project.sceneGraphEdges, + currentGraphNodeId, + ); + if (storylineNodeIds.size === 0) return []; + + const order = reconcileSceneListOrder(project.scenes, project.sceneListOrder); + const out: ActiveStorylineSceneEntry[] = []; + for (const sceneId of order) { + const graphNodeId = pickGraphNodeForScene( + sceneId, + storylineNodeIds, + project.sceneGraphNodes, + currentGraphNodeId, + ); + if (graphNodeId) out.push({ sceneId, graphNodeId }); + } + return out; +} diff --git a/package.json b/package.json index 18ea31a..72fd3fa 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.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/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.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 app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.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/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.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 app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package",