feat(control): аккордеон «Сцены» на пульте управления

Список сцен активной линии в порядке бокового списка редактора; переход с записью в историю.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 13:29:12 +08:00
parent 746b862cb9
commit cfb6e62331
7 changed files with 308 additions and 4 deletions
+74 -2
View File
@@ -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<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
const [historyOpen, setHistoryOpen] = useState(true);
const [scenesOpen, setScenesOpen] = useState(false);
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('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() {
</div>
<div className={styles.spacer12} />
</>
<div className={[styles.storyWrap, historyOpen ? '' : styles.storyWrapCollapsed].filter(Boolean).join(' ')}>
<div className={styles.historyAccordion} data-testid="control-history-accordion">
<div className={[styles.storyWrap, historyOpen || scenesOpen ? '' : styles.storyWrapCollapsed].filter(Boolean).join(' ')}>
<div className={styles.storyAccordions}>
<div
className={[
styles.historyAccordion,
historyOpen ? styles.storyAccordionExpanded : styles.storyAccordionCollapsed,
]
.filter(Boolean)
.join(' ')}
data-testid="control-history-accordion"
>
<button
type="button"
className={styles.historyAccordionHead}
@@ -2214,6 +2230,62 @@ export function ControlApp() {
</div>
) : null}
</div>
<div
className={[
styles.historyAccordion,
scenesOpen ? styles.storyAccordionExpanded : styles.storyAccordionCollapsed,
]
.filter(Boolean)
.join(' ')}
data-testid="control-scenes-accordion"
>
<button
type="button"
className={styles.historyAccordionHead}
aria-expanded={scenesOpen}
onClick={() => setScenesOpen((v) => !v)}
>
{t('control.scenesAccordion')} {scenesOpen ? '▾' : '▸'}
</button>
{scenesOpen ? (
<div className={styles.storyScroll}>
{storylineScenes.map(({ sceneId, graphNodeId }) => {
const s = project?.scenes[sceneId];
const isCurrent = graphNodeId === currentGraphNodeId;
const visited = historyGraphNodeSet.has(graphNodeId);
return (
<button
type="button"
key={sceneId}
disabled={!project || isCurrent}
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
.filter(Boolean)
.join(' ')}
title={project && !isCurrent ? t('control.gotoScene') : undefined}
onClick={() => {
if (!project || isCurrent) return;
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
}}
>
{isCurrent ? (
<div className={styles.historyBadge}>{t('control.currentSceneBadge')}</div>
) : visited ? (
<div className={styles.historyMuted}>{t('control.passed')}</div>
) : null}
<EllipsisText
text={s?.title ?? String(sceneId)}
className={[styles.historyTitle, ellipsisStyles.root].join(' ')}
/>
</button>
);
})}
{storylineScenes.length === 0 ? (
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
</Surface>