fix(control): improve storyline history and graph launch

Append history on revisit instead of truncating, highlight the latest duplicate entry, add Start from this scene to the graph context menu, and keep storyline state isolated from project graph edits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-02 19:14:01 +08:00
parent 657e3c862e
commit ca0cf9c0ce
6 changed files with 84 additions and 24 deletions
+4 -14
View File
@@ -51,8 +51,8 @@ export function ControlApp() {
const [fxState, fx] = useEffectsState(); const [fxState, fx] = useEffectsState();
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]); const historyRef = useRef<GraphNodeId[]>([]);
const suppressNextHistoryPushRef = useRef(false);
const [history, setHistory] = useState<GraphNodeId[]>([]); const [history, setHistory] = useState<GraphNodeId[]>([]);
// Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта.
const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map()); const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map()); const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0); const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0);
@@ -104,16 +104,6 @@ export function ControlApp() {
const cur = state.project?.currentGraphNodeId ?? null; const cur = state.project?.currentGraphNodeId ?? null;
if (!cur) return; if (!cur) return;
const arr = historyRef.current; const arr = historyRef.current;
if (suppressNextHistoryPushRef.current) {
suppressNextHistoryPushRef.current = false;
setHistory(arr);
return;
}
// Если мы перемотались на уже существующий шаг, не дублируем его в истории.
if (arr.includes(cur)) {
setHistory(arr);
return;
}
if (arr[arr.length - 1] !== cur) { if (arr[arr.length - 1] !== cur) {
historyRef.current = [...arr, cur]; historyRef.current = [...arr, cur];
setHistory(historyRef.current); setHistory(historyRef.current);
@@ -150,6 +140,8 @@ export function ControlApp() {
const project = session?.project ?? null; const project = session?.project ?? null;
const currentGraphNodeId = project?.currentGraphNodeId ?? null; const currentGraphNodeId = project?.currentGraphNodeId ?? null;
const currentHistoryIdx =
currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
const currentScene = const currentScene =
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined; project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
const isVideoPreviewScene = currentScene?.previewAssetType === 'video'; const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
@@ -1174,7 +1166,7 @@ export function ControlApp() {
{history.map((gnId, idx) => { {history.map((gnId, idx) => {
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId); const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
const s = gn ? project?.scenes[gn.sceneId] : undefined; const s = gn ? project?.scenes[gn.sceneId] : undefined;
const isCurrent = gnId === project?.currentGraphNodeId; const isCurrent = idx === currentHistoryIdx;
return ( return (
<button <button
type="button" type="button"
@@ -1187,8 +1179,6 @@ export function ControlApp() {
onClick={() => { onClick={() => {
if (!project) return; if (!project) return;
if (isCurrent) return; if (isCurrent) return;
// Перемотка: переходим на выбранный шаг без добавления нового пункта в историю.
suppressNextHistoryPushRef.current = true;
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId }); void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId });
}} }}
> >
@@ -77,6 +77,32 @@ void test('ControlApp: сюжетная линия — колонка сверх
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/); assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
}); });
void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => {
const src = readControlApp();
assert.ok(!src.includes('suppressNextHistoryPushRef'));
assert.ok(!src.includes('arr.includes(cur)'));
assert.ok(src.includes('historyRef.current = [...arr, cur]'));
});
void test('ControlApp: текущая сцена в истории — последнее вхождение', () => {
const src = readControlApp();
assert.ok(src.includes('history.lastIndexOf(currentGraphNodeId)'));
assert.ok(src.includes('idx === currentHistoryIdx'));
});
void test('ControlApp: сюжетная линия не меняет граф и сцены проекта', () => {
const src = readControlApp();
const story = src.indexOf("t('control.storyLine')");
assert.ok(story !== -1);
const tail = src.slice(story);
assert.ok(!tail.includes('addSceneGraphNode'));
assert.ok(!tail.includes('removeSceneGraphNode'));
assert.ok(!tail.includes('addSceneGraphEdge'));
assert.ok(!tail.includes('removeSceneGraphEdge'));
assert.ok(!tail.includes('updateProject'));
assert.ok(tail.includes('ipcChannels.project.setCurrentGraphNode'));
});
void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => { void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => {
const src = readControlApp(); const src = readControlApp();
const css = readControlAppCss(); const css = readControlAppCss();
+15 -7
View File
@@ -9,7 +9,7 @@ import {
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension'; import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
import type { AssetId, MediaAsset, Project, ProjectId, SceneAudioRef, SceneId } from '../../shared/types'; import type { AssetId, GraphNodeId, MediaAsset, Project, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo'; import { AppLogo } from '../shared/branding/AppLogo';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage'; import { RotatedImage } from '../shared/RotatedImage';
@@ -122,6 +122,7 @@ export function EditorApp() {
closeMenu: t('common.closeMenu'), closeMenu: t('common.closeMenu'),
startScene: t('graph.startScene'), startScene: t('graph.startScene'),
unsetStartScene: t('graph.unsetStartScene'), unsetStartScene: t('graph.unsetStartScene'),
runFromScene: t('graph.runFromScene'),
delete: t('common.delete'), delete: t('common.delete'),
}), }),
[t], [t],
@@ -204,6 +205,17 @@ export function EditorApp() {
return gn?.id ?? null; return gn?.id ?? null;
}, [state.project]); }, [state.project]);
const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId) => {
if (!licenseActive) return;
void (async () => {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
})();
},
[licenseActive],
);
const currentProjectName = state.project?.meta.name ?? ''; const currentProjectName = state.project?.meta.name ?? '';
const currentFileBaseName = state.project?.meta.fileBaseName ?? ''; const currentFileBaseName = state.project?.meta.fileBaseName ?? '';
const existingProjectNames = useMemo(() => state.projects.map((p) => p.name), [state.projects]); const existingProjectNames = useMemo(() => state.projects.map((p) => p.name), [state.projects]);
@@ -501,12 +513,7 @@ export function EditorApp() {
} }
onClick={() => { onClick={() => {
if (!licenseActive || !graphStartGraphNodeId) return; if (!licenseActive || !graphStartGraphNodeId) return;
void (async () => { launchFromGraphNode(graphStartGraphNodeId);
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
graphNodeId: graphStartGraphNodeId,
});
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
})();
}} }}
> >
{t('top.run')} {t('top.run')}
@@ -570,6 +577,7 @@ export function EditorApp() {
}} }}
onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)} onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)}
onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)} onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)}
onRunFromGraphNode={launchFromGraphNode}
onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)} onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)}
/> />
) : ( ) : (
+17 -1
View File
@@ -67,6 +67,7 @@ export type SceneGraphUiStrings = {
closeMenu: string; closeMenu: string;
startScene: string; startScene: string;
unsetStartScene: string; unsetStartScene: string;
runFromScene: string;
delete: string; delete: string;
}; };
@@ -86,6 +87,7 @@ const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
closeMenu: 'Закрыть меню', closeMenu: 'Закрыть меню',
startScene: 'Начальная сцена', startScene: 'Начальная сцена',
unsetStartScene: 'Снять метку «Начальная сцена»', unsetStartScene: 'Снять метку «Начальная сцена»',
runFromScene: 'Запустить с этой сцены',
delete: 'Удалить', delete: 'Удалить',
}; };
@@ -104,6 +106,7 @@ export type SceneGraphProps = {
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void; onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void; onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void; onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void;
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void; onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
}; };
@@ -359,6 +362,7 @@ function SceneGraphCanvas({
onRemoveGraphNodes, onRemoveGraphNodes,
onRemoveGraphNode, onRemoveGraphNode,
onSetGraphNodeStart, onSetGraphNodeStart,
onRunFromGraphNode,
onDropSceneFromList, onDropSceneFromList,
}: SceneGraphProps) { }: SceneGraphProps) {
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI; const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
@@ -490,7 +494,7 @@ function SceneGraphCanvas({
if (!menu) return null; if (!menu) return null;
const pad = 8; const pad = 8;
const mw = 220; const mw = 220;
const mh = 120; const mh = 168;
const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad)); const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad));
const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad)); const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad));
return { x, y }; return { x, y };
@@ -595,6 +599,18 @@ function SceneGraphCanvas({
> >
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene} {menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
</button> </button>
<button
type="button"
role="menuitem"
className={styles.ctxItem}
disabled={!onRunFromGraphNode}
onClick={() => {
onRunFromGraphNode?.(menu.graphNodeId);
setMenu(null);
}}
>
{ui.runFromScene}
</button>
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
function readSceneGraph(): string {
return fs.readFileSync(path.join(here, 'SceneGraph.tsx'), 'utf8');
}
void test('SceneGraph: контекстное меню узла — «Запустить с этой сцены»', () => {
const src = readSceneGraph();
assert.ok(src.includes('runFromScene'));
assert.ok(src.includes('onRunFromGraphNode'));
assert.ok(src.includes('onRunFromGraphNode?.(menu.graphNodeId)'));
});
+4 -2
View File
@@ -175,7 +175,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.controlPanel.title': 'Пульт управления', 'help.section.controlPanel.title': 'Пульт управления',
'help.section.controlPanel.body': 'help.section.controlPanel.body':
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева — эффекты и ход сюжета, справа — мини-копия экрана игроков, варианты переходов и музыка.\n\n«Предпросмотр экрана» показывает то же, что видят игроки. На сценах с картинкой здесь же рисуют эффекты — они сразу появляются на большом экране.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу откатывает партию к тому месту на карте без новой записи в истории.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.', 'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева — эффекты и ход сюжета, справа — мини-копия экрана игроков, варианты переходов и музыка.\n\n«Предпросмотр экрана» показывает то же, что видят игроки. На сценах с картинкой здесь же рисуют эффекты — они сразу появляются на большом экране.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
'help.section.transitions.title': 'Переходы между сценами', 'help.section.transitions.title': 'Переходы между сценами',
'help.section.transitions.body': 'help.section.transitions.body':
@@ -305,6 +305,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'graph.fitAll': 'Показать всё', 'graph.fitAll': 'Показать всё',
'graph.startScene': 'Начальная сцена', 'graph.startScene': 'Начальная сцена',
'graph.unsetStartScene': 'Снять метку «Начальная сцена»', 'graph.unsetStartScene': 'Снять метку «Начальная сцена»',
'graph.runFromScene': 'Запустить с этой сцены',
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ', 'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
'control.effects': 'ЭФФЕКТЫ', 'control.effects': 'ЭФФЕКТЫ',
@@ -493,7 +494,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.controlPanel.title': 'Control panel', 'help.section.controlPanel.title': 'Control panel',
'help.section.controlPanel.body': 'help.section.controlPanel.body':
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nOn the left: effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to rewind the party to that spot without adding a new history entry.\n\nStop presentation ends the show and unlocks the editor.', 'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nOn the left: effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
'help.section.transitions.title': 'Scene transitions', 'help.section.transitions.title': 'Scene transitions',
'help.section.transitions.body': 'help.section.transitions.body':
@@ -624,6 +625,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'graph.fitAll': 'Fit view', 'graph.fitAll': 'Fit view',
'graph.startScene': 'Start scene', 'graph.startScene': 'Start scene',
'graph.unsetStartScene': 'Clear start scene mark', 'graph.unsetStartScene': 'Clear start scene mark',
'graph.runFromScene': 'Start from this scene',
'control.remoteTitle': 'CONTROL PANEL', 'control.remoteTitle': 'CONTROL PANEL',
'control.effects': 'EFFECTS', 'control.effects': 'EFFECTS',