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:
@@ -51,8 +51,8 @@ export function ControlApp() {
|
||||
const [fxState, fx] = useEffectsState();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const historyRef = useRef<GraphNodeId[]>([]);
|
||||
const suppressNextHistoryPushRef = useRef(false);
|
||||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||||
// Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта.
|
||||
const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||||
const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||||
const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0);
|
||||
@@ -104,16 +104,6 @@ export function ControlApp() {
|
||||
const cur = state.project?.currentGraphNodeId ?? null;
|
||||
if (!cur) return;
|
||||
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) {
|
||||
historyRef.current = [...arr, cur];
|
||||
setHistory(historyRef.current);
|
||||
@@ -150,6 +140,8 @@ export function ControlApp() {
|
||||
|
||||
const project = session?.project ?? null;
|
||||
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
||||
const currentHistoryIdx =
|
||||
currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
|
||||
const currentScene =
|
||||
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
||||
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
||||
@@ -1174,7 +1166,7 @@ export function ControlApp() {
|
||||
{history.map((gnId, idx) => {
|
||||
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
|
||||
const s = gn ? project?.scenes[gn.sceneId] : undefined;
|
||||
const isCurrent = gnId === project?.currentGraphNodeId;
|
||||
const isCurrent = idx === currentHistoryIdx;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1187,8 +1179,6 @@ export function ControlApp() {
|
||||
onClick={() => {
|
||||
if (!project) return;
|
||||
if (isCurrent) return;
|
||||
// Перемотка: переходим на выбранный шаг без добавления нового пункта в историю.
|
||||
suppressNextHistoryPushRef.current = true;
|
||||
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\)/);
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||
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 { getDndApi } from '../shared/dndApi';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
@@ -122,6 +122,7 @@ export function EditorApp() {
|
||||
closeMenu: t('common.closeMenu'),
|
||||
startScene: t('graph.startScene'),
|
||||
unsetStartScene: t('graph.unsetStartScene'),
|
||||
runFromScene: t('graph.runFromScene'),
|
||||
delete: t('common.delete'),
|
||||
}),
|
||||
[t],
|
||||
@@ -204,6 +205,17 @@ export function EditorApp() {
|
||||
return gn?.id ?? null;
|
||||
}, [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 currentFileBaseName = state.project?.meta.fileBaseName ?? '';
|
||||
const existingProjectNames = useMemo(() => state.projects.map((p) => p.name), [state.projects]);
|
||||
@@ -501,12 +513,7 @@ export function EditorApp() {
|
||||
}
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId) return;
|
||||
void (async () => {
|
||||
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
|
||||
graphNodeId: graphStartGraphNodeId,
|
||||
});
|
||||
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
|
||||
})();
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
}}
|
||||
>
|
||||
{t('top.run')}
|
||||
@@ -570,6 +577,7 @@ export function EditorApp() {
|
||||
}}
|
||||
onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)}
|
||||
onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)}
|
||||
onRunFromGraphNode={launchFromGraphNode}
|
||||
onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -67,6 +67,7 @@ export type SceneGraphUiStrings = {
|
||||
closeMenu: string;
|
||||
startScene: string;
|
||||
unsetStartScene: string;
|
||||
runFromScene: string;
|
||||
delete: string;
|
||||
};
|
||||
|
||||
@@ -86,6 +87,7 @@ const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
||||
closeMenu: 'Закрыть меню',
|
||||
startScene: 'Начальная сцена',
|
||||
unsetStartScene: 'Снять метку «Начальная сцена»',
|
||||
runFromScene: 'Запустить с этой сцены',
|
||||
delete: 'Удалить',
|
||||
};
|
||||
|
||||
@@ -104,6 +106,7 @@ export type SceneGraphProps = {
|
||||
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
|
||||
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
|
||||
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
|
||||
onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void;
|
||||
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
|
||||
};
|
||||
|
||||
@@ -359,6 +362,7 @@ function SceneGraphCanvas({
|
||||
onRemoveGraphNodes,
|
||||
onRemoveGraphNode,
|
||||
onSetGraphNodeStart,
|
||||
onRunFromGraphNode,
|
||||
onDropSceneFromList,
|
||||
}: SceneGraphProps) {
|
||||
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
|
||||
@@ -490,7 +494,7 @@ function SceneGraphCanvas({
|
||||
if (!menu) return null;
|
||||
const pad = 8;
|
||||
const mw = 220;
|
||||
const mh = 120;
|
||||
const mh = 168;
|
||||
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 };
|
||||
@@ -595,6 +599,18 @@ function SceneGraphCanvas({
|
||||
>
|
||||
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
disabled={!onRunFromGraphNode}
|
||||
onClick={() => {
|
||||
onRunFromGraphNode?.(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{ui.runFromScene}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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)'));
|
||||
});
|
||||
@@ -175,7 +175,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.controlPanel.title': 'Пульт управления',
|
||||
'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.body':
|
||||
@@ -305,6 +305,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'graph.fitAll': 'Показать всё',
|
||||
'graph.startScene': 'Начальная сцена',
|
||||
'graph.unsetStartScene': 'Снять метку «Начальная сцена»',
|
||||
'graph.runFromScene': 'Запустить с этой сцены',
|
||||
|
||||
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
|
||||
'control.effects': 'ЭФФЕКТЫ',
|
||||
@@ -493,7 +494,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.controlPanel.title': 'Control panel',
|
||||
'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.body':
|
||||
@@ -624,6 +625,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'graph.fitAll': 'Fit view',
|
||||
'graph.startScene': 'Start scene',
|
||||
'graph.unsetStartScene': 'Clear start scene mark',
|
||||
'graph.runFromScene': 'Start from this scene',
|
||||
|
||||
'control.remoteTitle': 'CONTROL PANEL',
|
||||
'control.effects': 'EFFECTS',
|
||||
|
||||
Reference in New Issue
Block a user