feat(control): аккордеон «Сцены» на пульте управления
Список сцен активной линии в порядке бокового списка редактора; переход с записью в историю. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -646,6 +646,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.brushShapeSquare': 'Квадратная',
|
||||
'control.effectsSound': 'Звук эффектов',
|
||||
'control.storyLine': 'ИСТОРИЯ',
|
||||
'control.scenesAccordion': 'СЦЕНЫ',
|
||||
'control.gotoScene': 'Перейти к этой сцене',
|
||||
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
|
||||
'control.passed': 'Пройдено',
|
||||
@@ -1297,6 +1298,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'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',
|
||||
|
||||
Reference in New Issue
Block a user