feat: окно инструкций, свёрнутая история и обновление спонсоров

Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-17 06:21:42 +08:00
parent 712086ddca
commit a29c72b307
24 changed files with 506 additions and 290 deletions
+9
View File
@@ -83,6 +83,8 @@ import {
openTokenPathEditorWindow,
openNpcsWindow,
openSceneDescriptionWindow,
openInstructionsWindow,
getInstructionsSection,
closeMaterialsWindow,
closePlaylistsWindow,
closeTimerWindow,
@@ -783,6 +785,13 @@ async function main() {
openSceneDescriptionWindow(html);
return { ok: true };
});
registerHandler(ipcChannels.windows.openInstructions, ({ section }) => {
openInstructionsWindow(section);
return { ok: true };
});
registerHandler(ipcChannels.windows.getInstructionsSection, () => {
return { section: getInstructionsSection() };
});
registerHandler(ipcChannels.windows.closeSceneDescription, () => {
closeSceneDescriptionWindow();
return { ok: true };
@@ -52,6 +52,12 @@ void test('createWindows: окно материалов закрывается
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
});
void test('createWindows: окно плейлистов — minWidth 640', () => {
const src = readCreateWindows();
assert.ok(src.includes('PLAYLISTS_WINDOW_MIN_WIDTH = 640'));
assert.match(src, /kind === 'playlists'[\s\S]*?minWidth:\s*PLAYLISTS_WINDOW_MIN_WIDTH/);
});
void test('createWindows: окно таймера закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openTimerWindow'));
@@ -76,6 +82,16 @@ void test('createWindows: окно кубиков закрывается с mult
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeDiceWindow/);
});
void test('createWindows: окно инструкции из редактора, не закрывается с сессией', () => {
const src = readCreateWindows();
assert.ok(src.includes('openInstructionsWindow'));
assert.ok(src.includes("createWindow('instructions'"));
assert.ok(src.includes("return 'instructions.html'"));
const aux = /function closePlaySessionAuxiliaryWindows\(\): void \{([\s\S]*?)\n\}/.exec(src);
assert.ok(aux, 'closePlaySessionAuxiliaryWindows');
assert.doesNotMatch(aux[1] ?? '', /instructions/i);
});
void test('createWindows: окно НПС закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openNpcsWindow'));
+77 -2
View File
@@ -27,7 +27,8 @@ export type WindowKind =
| 'npcsEditor'
| 'sceneEditor'
| 'tokenPathEditor'
| 'npcs';
| 'npcs'
| 'instructions';
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
@@ -53,6 +54,7 @@ let appQuitting = false;
/** Защита от каскада close(control) ↔ close(presentation). */
let closingPlaySession = false;
let pendingSceneDescriptionHtml = '';
let pendingInstructionsSection = 'overview';
/** Окно материалов — только колонка списка. */
const MATERIALS_WINDOW_WIDTH = 300;
@@ -60,6 +62,7 @@ const MATERIALS_WINDOW_HEIGHT = 720;
/** Окно плейлистов на пульте. */
const PLAYLISTS_WINDOW_WIDTH = 720;
const PLAYLISTS_WINDOW_MIN_WIDTH = 640;
const PLAYLISTS_WINDOW_HEIGHT = 800;
/** Компактное окно таймера сессии. */
@@ -81,6 +84,10 @@ const INITIATIVE_WINDOW_HEIGHT = 420;
const DICE_WINDOW_WIDTH = 380;
const DICE_WINDOW_HEIGHT = 460;
/** Окно инструкции из редактора (не модалка). */
const INSTRUCTIONS_WINDOW_WIDTH = 960;
const INSTRUCTIONS_WINDOW_HEIGHT = 720;
/** Колбэк при закрытии окна кубиков (Esc / крестик / programmatic). */
let diceWindowClosedListener: (() => void) | null = null;
@@ -147,6 +154,15 @@ function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
}
}
function sendInstructionsSection(win: BrowserWindow, section: string): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
try {
win.webContents.send(ipcChannels.windows.instructionsSection, { section });
} catch {
/* ignore */
}
}
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
export function markAppQuitting(): void {
appQuitting = true;
@@ -265,6 +281,8 @@ function pageNameForKind(kind: WindowKind): string {
return 'tokenPathEditor.html';
case 'npcs':
return 'npcs.html';
case 'instructions':
return 'instructions.html';
}
}
@@ -340,6 +358,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
if (kind === 'instructions') return { width: INSTRUCTIONS_WINDOW_WIDTH, height: INSTRUCTIONS_WINDOW_HEIGHT };
return { width: 1280, height: 800 };
}
@@ -366,6 +385,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
minHeight: 480,
}
: {}),
...(kind === 'playlists'
? {
width: PLAYLISTS_WINDOW_WIDTH,
height: PLAYLISTS_WINDOW_HEIGHT,
minWidth: PLAYLISTS_WINDOW_MIN_WIDTH,
minHeight: 480,
}
: {}),
...(kind === 'npcsEditor'
? {
width: NPCS_EDITOR_WINDOW_WIDTH,
@@ -399,6 +426,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
minHeight: 560,
}
: {}),
...(kind === 'instructions'
? {
width: INSTRUCTIONS_WINDOW_WIDTH,
height: INSTRUCTIONS_WINDOW_HEIGHT,
minWidth: 640,
minHeight: 480,
}
: {}),
show: false,
backgroundColor: '#09090B',
...(icon ? { icon } : {}),
@@ -458,7 +493,8 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
kind === 'npcsEditor' ||
kind === 'sceneEditor' ||
kind === 'tokenPathEditor' ||
kind === 'npcs'
kind === 'npcs' ||
kind === 'instructions'
) {
win.setMenuBarVisibility(false);
}
@@ -780,6 +816,44 @@ export function getSceneDescriptionContent(): string {
return pendingSceneDescriptionHtml;
}
export function getInstructionsSection(): string {
return pendingInstructionsSection;
}
/** Окно инструкции из редактора: переиспользовать существующее или создать новое. */
export function openInstructionsWindow(section?: string): void {
pendingInstructionsSection = String(section ?? '').trim() || 'overview';
const existing = windows.get('instructions');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
sendInstructionsSection(existing, pendingInstructionsSection);
return;
}
const parent = windows.get('editor');
const win = createWindow('instructions', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
win.webContents.once('did-finish-load', () => {
sendInstructionsSection(win, pendingInstructionsSection);
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
/** Одно окно описания: переиспользовать существующее или создать новое. */
export function openSceneDescriptionWindow(html: string): void {
pendingSceneDescriptionHtml = html;
@@ -856,6 +930,7 @@ export function openMaterialsWindow(): void {
export function openPlaylistsWindow(): void {
const existing = windows.get('playlists');
if (existing && !existing.isDestroyed()) {
existing.setMinimumSize(PLAYLISTS_WINDOW_MIN_WIDTH, 480);
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
+34 -2
View File
@@ -339,19 +339,39 @@
.previewHeader {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
}
.previewTitle {
font-size: var(--text-md);
font-weight: 900;
flex: 0 1 auto;
display: flex;
align-items: center;
min-height: 34px;
line-height: 1.2;
}
.previewActions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
flex: 1 1 240px;
}
.previewTokenTools {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
align-items: center;
gap: 8px 12px;
min-width: 0;
flex: 1 1 200px;
}
.npcTokenScale {
@@ -359,6 +379,7 @@
align-items: center;
gap: 8px;
min-width: 0;
min-height: 34px;
}
.snapToGrid {
@@ -366,26 +387,37 @@
align-items: center;
gap: 6px;
min-width: 0;
min-height: 34px;
cursor: pointer;
user-select: none;
}
.snapToGrid input {
margin: 0;
flex-shrink: 0;
}
.snapToGridLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
white-space: nowrap;
line-height: 1.25;
}
.npcTokenScaleLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
line-height: 1.25;
white-space: nowrap;
}
.npcTokenScaleRange {
width: min(140px, 22vw);
flex: 1 1 auto;
min-width: 72px;
width: min(140px, 100%);
height: 34px;
margin: 0;
accent-color: var(--color-accent, #c9a227);
}
+6 -6
View File
@@ -210,7 +210,7 @@ export function ControlApp() {
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
const [historyOpen, setHistoryOpen] = useState(true);
const [historyOpen, setHistoryOpen] = useState(false);
const [scenesOpen, setScenesOpen] = useState(false);
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
const darknessBrushShapeRef = useRef(darknessBrushShape);
@@ -2385,6 +2385,7 @@ export function ControlApp() {
<div className={styles.previewHeader}>
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
<div className={styles.previewActions}>
<div className={styles.previewTokenTools}>
<label
className={styles.snapToGrid}
title={
@@ -2408,7 +2409,6 @@ export function ControlApp() {
<span className={styles.snapToGridLabel}>{t('control.snapTokensToGrid')}</span>
</label>
{USERS_BRANCH_FEATURES_ENABLED ? (
<>
<label className={styles.npcTokenScale}>
<span className={styles.npcTokenScaleLabel}>{t('control.npcTokenScale')}</span>
<input
@@ -2427,7 +2427,9 @@ export function ControlApp() {
}}
/>
</label>
{scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
) : null}
</div>
{USERS_BRANCH_FEATURES_ENABLED && scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
<Button
data-testid="toggle-session-players"
onClick={() => {
@@ -2473,8 +2475,6 @@ export function ControlApp() {
? t('control.hidePlayers')
: t('control.showPlayers')}
</Button>
) : null}
</>
) : null}
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
{t('control.stopPresentation')}
@@ -3054,7 +3054,7 @@ export function ControlApp() {
style={{ zIndex: 48 }}
/>
) : null}
<SessionTimerLayer viewport={previewContentRect} editable />
<SessionTimerLayer viewport={markerViewport} editable />
</div>
</Surface>
@@ -135,16 +135,20 @@ void test('ControlApp: чекбокс привязки токенов к сет
assert.ok(src.includes('snapAllTokensToGrid'));
assert.ok(src.includes('snapNorm={snapNormActive}'));
assert.ok(css.includes('.snapToGrid'));
assert.ok(css.includes('.previewTokenTools'));
assert.match(css, /\.previewTokenTools[\s\S]*?repeat\(auto-fit/);
assert.match(css, /\.previewHeader[\s\S]*?flex-wrap:\s*wrap/);
assert.match(css, /\.previewHeader[\s\S]*?align-items:\s*center/);
});
void test('ControlApp: история — колонка сверху вниз и фон как у карточек ветвления', () => {
void test('ControlApp: история — свёрнута по умолчанию, колонка сверху вниз и фон как у карточек ветвления', () => {
const src = readControlApp();
const css = readControlAppCss();
const story = src.indexOf("t('control.storyLine')");
assert.ok(story !== -1);
assert.ok(src.includes('data-testid="control-history-accordion"'));
assert.ok(src.includes('aria-expanded={historyOpen}'));
assert.ok(src.includes('const [historyOpen, setHistoryOpen] = useState(true)'));
assert.ok(src.includes('const [historyOpen, setHistoryOpen] = useState(false)'));
assert.ok(src.includes('className={styles.storyScroll}'));
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
-107
View File
@@ -550,113 +550,6 @@
line-height: 1.45;
}
.instructionsDialog {
position: fixed;
z-index: var(--z-modal);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(920px, calc(100vw - 32px));
max-height: min(86vh, 720px);
border-radius: var(--radius-lg);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated);
box-shadow: var(--shadow-xl);
padding: 16px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 12px;
}
.instructionsLayout {
display: grid;
grid-template-columns: 1fr 220px;
gap: 14px;
min-height: 0;
max-height: min(62vh, 560px);
}
.instructionsContent {
min-height: 0;
overflow: auto;
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--bg0);
}
.instructionsContentTitle {
font-weight: 800;
font-size: var(--text-md);
margin-bottom: 12px;
}
.instructionsParagraph {
margin: 0 0 12px;
color: var(--text1);
font-size: var(--text-sm);
line-height: 1.55;
white-space: pre-wrap;
}
.instructionsParagraph:last-child {
margin-bottom: 0;
}
.instructionsInlineLink {
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
color: var(--accent, #a78bfa);
font: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.instructionsInlineLink:hover {
color: var(--text0);
}
.instructionsNav {
min-height: 0;
overflow: auto;
display: grid;
gap: 4px;
align-content: start;
padding: 6px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--panel2);
}
.instructionsNavItem {
text-align: left;
padding: 9px 10px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
font: inherit;
font-size: var(--text-xs);
line-height: 1.35;
}
.instructionsNavItem:hover {
background: var(--scene-list-hover-bg);
color: var(--text0);
}
.instructionsNavItemActive {
background: rgba(167, 139, 250, 0.18);
color: var(--text0);
font-weight: 700;
}
.aboutBody {
display: grid;
gap: 10px;
+11 -12
View File
@@ -37,7 +37,7 @@ import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import { AppAboutModal } from './about/EditorAboutModals';
import styles from './EditorApp.module.css';
import { EditorAudioTrackList } from './EditorAudioTrackList';
import {
@@ -132,8 +132,6 @@ export function EditorApp() {
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
const [appAboutOpen, setAppAboutOpen] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false);
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [importSourceOpen, setImportSourceOpen] = useState(false);
@@ -384,6 +382,14 @@ export function EditorApp() {
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
const openInstructions = useCallback((section: HelpSectionId = 'overview') => {
void getDndApi()
.invoke(ipcChannels.windows.openInstructions, { section })
.catch((err) => {
console.error('[editor] openInstructions failed', err);
});
}, []);
const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return;
@@ -990,8 +996,7 @@ export function EditorApp() {
title={runHelpTooltip}
tooltipPlacement="bottom-left"
onClick={() => {
setInstructionsSection(runHelpSection);
setInstructionsOpen(true);
openInstructions(runHelpSection);
}}
>
?
@@ -1472,11 +1477,6 @@ export function EditorApp() {
snapshot={licenseSnap}
/>
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
<InstructionsModal
open={instructionsOpen}
initialSection={instructionsSection}
onClose={() => setInstructionsOpen(false)}
/>
{aboutMenuOpen && aboutMenuPos
? createPortal(
<div
@@ -1502,8 +1502,7 @@ export function EditorApp() {
className={styles.fileMenuItem}
onClick={() => {
setAboutMenuOpen(false);
setInstructionsSection('overview');
setInstructionsOpen(true);
openInstructions('overview');
}}
>
{t('menu.instructions')}
+8 -133
View File
@@ -1,22 +1,19 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
import { Button } from '../../shared/ui/controls';
import styles from '../EditorApp.module.css';
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../help/helpLinkify';
import {
HELP_SECTION_IDS,
helpSectionBodyKey,
helpSectionTitleKey,
type HelpSectionId,
} from '../help/helpSections';
import { useEditorI18n } from '../i18n/EditorI18nContext';
const ABOUT_SPONSORS = [
{ kind: 'text', name: 'Исаков Артур' },
{ kind: 'text', name: 'Подчуфарова Евгения' },
{ kind: 'text', name: 'Никитин Виктор' },
{ kind: 'text', name: 'Тифлинг бард 😈❤️' },
{
kind: 'link',
name: 'Клуб настольных и бизнес-игр "КвестИя" - Подчуфарова Евгения Александровна',
url: 'https://vk.ru/club210361604',
},
{ kind: 'link', name: 'Старые кости', url: 'https://boosty.to/tabletoppodcasts' },
{ kind: 'text', name: 'Майер С.В. (Клуб «Серый Май»)' },
{ kind: 'link', name: 'true_rus(КАВ)', url: 'https://truecorp.su/' },
{ kind: 'text', name: 'Макс LonelyFlame Чурсин' },
@@ -119,125 +116,3 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
document.body,
);
}
type InstructionsModalProps = {
open: boolean;
onClose: () => void;
initialSection?: HelpSectionId;
};
function InstructionsModalBody({
initialSection,
onClose,
}: {
initialSection: HelpSectionId;
onClose: () => void;
}) {
const { t } = useEditorI18n();
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
const contentRef = useRef<HTMLDivElement | null>(null);
const navRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => {
contentRef.current?.scrollTo({ top: 0 });
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
activeNav?.scrollIntoView({ block: 'nearest' });
}, [activeId]);
const linkCatalog = useMemo(
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
[t],
);
const body = t(helpSectionBodyKey(activeId));
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
const goToSection = (id: HelpSectionId) => {
setActiveId(id);
};
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.instructionsDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('help.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.instructionsLayout}>
<div ref={contentRef} className={styles.instructionsContent}>
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
{paragraphs.map((p, i) => (
<p key={i} className={styles.instructionsParagraph}>
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
part.type === 'text' ? (
<React.Fragment key={j}>{part.value}</React.Fragment>
) : (
<button
key={j}
type="button"
className={styles.instructionsInlineLink}
onClick={() => goToSection(part.id)}
>
{part.value}
</button>
),
)}
</p>
))}
</div>
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}>
{HELP_SECTION_IDS.map((id) => (
<button
key={id}
type="button"
className={[
styles.instructionsNavItem,
id === activeId ? styles.instructionsNavItemActive : '',
]
.filter(Boolean)
.join(' ')}
aria-current={id === activeId ? 'true' : undefined}
onClick={() => goToSection(id)}
>
{t(helpSectionTitleKey(id))}
</button>
))}
</nav>
</div>
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) {
if (!open) return null;
const section = initialSection ?? 'overview';
return <InstructionsModalBody key={section} initialSection={section} onClose={onClose} />;
}
+7 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { HelpSectionId } from './helpSections';
import { parseHelpSectionId, type HelpSectionId } from './helpSections';
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
const RU_TITLES: Record<HelpSectionId, string> = {
@@ -80,3 +80,9 @@ void test('findHelpLinkRanges: полное «Пульт управления»
assert.equal(ranges[0]?.id, 'controlPanel');
assert.equal(ranges[0]?.text, '«Пульт управления»');
});
void test('parseHelpSectionId: unknown falls back to overview', () => {
assert.equal(parseHelpSectionId('grid'), 'grid');
assert.equal(parseHelpSectionId(''), 'overview');
assert.equal(parseHelpSectionId('not-a-section'), 'overview');
});
+6
View File
@@ -40,3 +40,9 @@ export function helpSectionTitleKey(id: HelpSectionId): string {
export function helpSectionBodyKey(id: HelpSectionId): string {
return `help.section.${id}.body`;
}
export function parseHelpSectionId(raw: unknown): HelpSectionId {
const s = String(raw ?? '').trim();
if ((HELP_SECTION_IDS as readonly string[]).includes(s)) return s as HelpSectionId;
return 'overview';
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/instructions/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,98 @@
.page {
height: 100vh;
padding: 16px;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(160px, 220px);
gap: 14px;
background: var(--color-surface-elevated);
min-height: 0;
}
.content {
min-height: 0;
overflow: auto;
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--bg0);
}
.contentTitle {
font-weight: 800;
font-size: var(--text-md);
margin-bottom: 12px;
}
.paragraph {
margin: 0 0 12px;
color: var(--text1);
font-size: var(--text-sm);
line-height: 1.55;
white-space: pre-wrap;
}
.paragraph:last-child {
margin-bottom: 0;
}
.inlineLink {
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
color: var(--accent, #a78bfa);
font: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.inlineLink:hover {
color: var(--text0);
}
.nav {
min-height: 0;
overflow: auto;
display: grid;
gap: 4px;
align-content: start;
padding: 6px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--panel2);
}
.navItem {
text-align: left;
padding: 9px 10px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
font: inherit;
font-size: var(--text-xs);
line-height: 1.35;
}
.navItem:hover {
background: var(--scene-list-hover-bg);
color: var(--text0);
}
.navItemActive {
background: rgba(167, 139, 250, 0.18);
color: var(--text0);
font-weight: 700;
}
@media (max-width: 720px) {
.page {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) minmax(120px, 32vh);
}
}
@@ -0,0 +1,105 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../editor/help/helpLinkify';
import {
HELP_SECTION_IDS,
helpSectionBodyKey,
helpSectionTitleKey,
parseHelpSectionId,
type HelpSectionId,
} from '../editor/help/helpSections';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import styles from './InstructionsApp.module.css';
function InstructionsPanel({ initialSection }: { initialSection: HelpSectionId }) {
const { t } = useEditorI18n();
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
const contentRef = useRef<HTMLDivElement | null>(null);
const navRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setActiveId(initialSection);
}, [initialSection]);
useEffect(() => {
contentRef.current?.scrollTo({ top: 0 });
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
activeNav?.scrollIntoView({ block: 'nearest' });
}, [activeId]);
const linkCatalog = useMemo(
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
[t],
);
const body = t(helpSectionBodyKey(activeId));
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
return (
<div className={styles.page} data-testid="instructions-app">
<div ref={contentRef} className={styles.content}>
<div className={styles.contentTitle}>{t(helpSectionTitleKey(activeId))}</div>
{paragraphs.map((p, i) => (
<p key={i} className={styles.paragraph}>
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
part.type === 'text' ? (
<React.Fragment key={j}>{part.value}</React.Fragment>
) : (
<button
key={j}
type="button"
className={styles.inlineLink}
onClick={() => setActiveId(part.id)}
>
{part.value}
</button>
),
)}
</p>
))}
</div>
<nav ref={navRef} className={styles.nav} aria-label={t('help.navAria')}>
{HELP_SECTION_IDS.map((id) => (
<button
key={id}
type="button"
className={[styles.navItem, id === activeId ? styles.navItemActive : '']
.filter(Boolean)
.join(' ')}
aria-current={id === activeId ? 'true' : undefined}
onClick={() => setActiveId(id)}
>
{t(helpSectionTitleKey(id))}
</button>
))}
</nav>
</div>
);
}
export function InstructionsApp() {
const [section, setSection] = useState<HelpSectionId>('overview');
useEffect(() => {
const api = getDndApi();
void api.invoke(ipcChannels.windows.getInstructionsSection, {}).then(({ section: next }) => {
setSection(parseHelpSectionId(next));
});
return api.on(ipcChannels.windows.instructionsSection, ({ section: next }) => {
setSection(parseHelpSectionId(next));
});
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') window.close();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return <InstructionsPanel initialSection={section} />;
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { InstructionsApp } from './InstructionsApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Instructions">
<EditorI18nProvider>
<InstructionsApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
+1 -1
View File
@@ -338,7 +338,7 @@ export function PresentationView({
))}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
</SceneOverlayHost>
<SessionTimerLayer viewport={contentRect} editable={false} />
<SessionTimerLayer viewport={markerViewport} editable={false} />
<DiceRollLayer />
{showTitle ? (
<div className={styles.titleWrap}>
@@ -81,29 +81,27 @@ export function useScenePlayerTokensSession(): [
visible: false,
byPlayerId: {},
});
const localRevRef = useRef(0);
/** Только revision с main. Оптимистичные move/setSize не должны глушить смену сцены. */
const serverRevRef = useRef(0);
useEffect(() => {
const applyServer = (next: ScenePlayerTokensSessionState) => {
if (next.revision < serverRevRef.current) return;
serverRevRef.current = next.revision;
setState(next);
};
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
if (res.state.revision < localRevRef.current) return;
localRevRef.current = res.state.revision;
setState(res.state);
applyServer(res.state);
});
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
if (next.revision < localRevRef.current) return;
localRevRef.current = next.revision;
setState(next);
applyServer(next);
});
}, [api]);
const dispatch = useCallback(
(event: ScenePlayerTokensSessionEvent) => {
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
setState((prev) => {
const next = applyEvent(prev, event);
localRevRef.current = Math.max(localRevRef.current, next.revision);
return next;
});
setState((prev) => applyEvent(prev, event));
}
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
},
@@ -10,6 +10,7 @@
.sidebar,
.main {
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
@@ -74,6 +75,8 @@
color: var(--text);
text-align: left;
cursor: pointer;
min-width: 0;
overflow: hidden;
}
.playlistRow:hover,
@@ -103,10 +106,10 @@
.playlistName,
.trackTitle,
.playlistNameReadonly,
.trackTitleReadonly {
flex: 1;
flex: 1 1 0;
min-width: 0;
width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -114,6 +117,7 @@
}
.trackTitle {
display: block;
background: transparent;
border: 0;
color: inherit;
@@ -122,18 +126,24 @@
padding: 0;
}
.playlistNameReadonly {
font-weight: 700;
font-size: var(--text-md, 1rem);
.trackTitleReadonly {
display: block;
color: inherit;
}
.trackTitleReadonly {
color: inherit;
.playlistNameReadonly {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
font-size: var(--text-md, 1rem);
}
.playlistCount {
font-size: var(--text-xs);
color: var(--text2);
flex-shrink: 0;
}
.mainHeader {
@@ -153,6 +163,7 @@
color: var(--text2);
cursor: pointer;
padding: 0 4px;
flex-shrink: 0;
}
.empty {
@@ -309,7 +309,7 @@ export function PlaylistsPanel({
}
onClick={() => setSelectedId(pl.id)}
>
<span className={styles.playlistName}>{pl.name || labels.untitled}</span>
<span className={styles.playlistName} title={pl.name || labels.untitled}>{pl.name || labels.untitled}</span>
{playerUi[pl.id]?.playing ? <span className={styles.playlistPlayingMark}>▶</span> : null}
<span className={styles.playlistCount}>{pl.tracks.length}</span>
</button>
@@ -337,7 +337,9 @@ export function PlaylistsPanel({
<>
<div className={styles.mainHeader}>
{readOnly ? (
<div className={styles.playlistNameReadonly}>{selected.name || labels.untitled}</div>
<div className={styles.playlistNameReadonly} title={selected.name || labels.untitled}>
{selected.name || labels.untitled}
</div>
) : (
<Input value={editingName} onChange={setEditingName} onBlur={() => void saveRename()} />
)}
@@ -436,7 +438,9 @@ export function PlaylistsPanel({
▶
</button>
{readOnly ? (
<span className={styles.trackTitleReadonly}>{title}</span>
<span className={styles.trackTitleReadonly} title={title}>
{title}
</span>
) : editingAudioId === tr.audioId ? (
<Input
value={editingAudioTitle}
@@ -453,11 +457,11 @@ export function PlaylistsPanel({
<button
type="button"
className={styles.trackTitle}
title={title}
onClick={() => {
setEditingAudioId(tr.audioId);
setEditingAudioTitle(title);
}}
title={labels.renameAudio}
>
{title}
</button>
@@ -18,6 +18,7 @@ const SECONDARY_WINDOW_MAINS = [
'dice/main.tsx',
'sceneEditor/main.tsx',
'sceneDescription/main.tsx',
'instructions/main.tsx',
'control/main.tsx',
'presentation/main.tsx',
] as const;
@@ -119,6 +120,24 @@ void test('Control and Presentation use SceneNpcTokensOverlay', () => {
assert.ok(control.includes('ScenePlayerTokensOverlay'));
assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
assert.ok(control.includes('toggle-session-players'));
assert.match(presentation, /<SessionTimerLayer viewport=\{markerViewport\} editable=\{false\} \/>/);
});
void test('useScenePlayerTokensSession: scene-switch broadcast is not gated by optimistic local revision', () => {
const src = fs.readFileSync(
path.join(rendererRoot, 'shared/playerToken/useScenePlayerTokensSession.ts'),
'utf8',
);
assert.ok(src.includes('serverRevRef'));
assert.doesNotMatch(src, /localRevRef/);
assert.doesNotMatch(src, /localRevRef\.current = Math\.max/);
});
void test('EditorApp: Instructions opens electron window, not modal', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
assert.ok(src.includes('windows.openInstructions'));
assert.doesNotMatch(src, /InstructionsModal/);
assert.ok(fs.existsSync(path.join(rendererRoot, 'instructions/InstructionsApp.tsx')));
});
void test('EditorApp: split Run button and launch-with-players modal', () => {
@@ -143,11 +162,24 @@ void test('ControlApp: Playlists tool opens window', () => {
assert.ok(src.includes('windows.openPlaylists'));
});
void test('PlaylistsPanel: long audio titles ellipsis via CSS', () => {
const css = fs.readFileSync(
path.join(rendererRoot, 'shared/playlists/PlaylistsPanel.module.css'),
'utf8',
);
const panel = fs.readFileSync(path.join(rendererRoot, 'shared/playlists/PlaylistsPanel.tsx'), 'utf8');
assert.match(css, /\.trackTitleReadonly[\s\S]*?text-overflow:\s*ellipsis/);
assert.match(css, /\.trackTitle[\s\S]*?text-overflow:\s*ellipsis/);
assert.ok(panel.includes('trackTitleReadonly'));
assert.ok(panel.includes('title={title}'));
});
void test('ControlApp: Timer tool opens window', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
assert.ok(src.includes('data-testid="control-timer-tool"'));
assert.ok(src.includes('windows.openTimer'));
assert.ok(src.includes('SessionTimerLayer'));
assert.match(src, /<SessionTimerLayer viewport=\{markerViewport\} editable \/>/);
});
void test('ControlApp: Initiative tool opens window', () => {
+3 -1
View File
@@ -29,7 +29,8 @@ export type AppWindowKind =
| 'npcsEditor'
| 'sceneEditor'
| 'tokenPathEditor'
| 'npcs';
| 'npcs'
| 'instructions';
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
editor: { ru: 'Редактор компании', en: 'Campaign editor' },
@@ -46,6 +47,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
npcs: { ru: 'НПС', en: 'NPCs' },
instructions: { ru: 'Инструкция', en: 'Instructions' },
};
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
+2
View File
@@ -15,6 +15,8 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
assert.match(src, /sceneNpcTokensSession:\s*\{/);
assert.match(src, /npcMapSpawn:\s*\{/);
assert.match(src, /scenePlayerTokensSession:\s*\{/);
assert.match(src, /openInstructions:\s*'windows\.openInstructions'/);
assert.match(src, /instructionsSection:\s*'windows\.instructionsSection'/);
assert.match(src, /tokenGridSnap:\s*\{/);
assert.match(src, /tokenGridSnap\.setEnabled/);
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
+12
View File
@@ -176,6 +176,9 @@ export const ipcChannels = {
closeSceneDescription: 'windows.closeSceneDescription',
getSceneDescriptionContent: 'windows.getSceneDescriptionContent',
sceneDescriptionContent: 'windows.sceneDescriptionContent',
openInstructions: 'windows.openInstructions',
getInstructionsSection: 'windows.getInstructionsSection',
instructionsSection: 'windows.instructionsSection',
openMaterials: 'windows.openMaterials',
closeMaterials: 'windows.closeMaterials',
openPlaylists: 'windows.openPlaylists',
@@ -429,6 +432,7 @@ export type IpcEventMap = {
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
[ipcChannels.windows.presentationContentSizeChanged]: { width: number; height: number };
[ipcChannels.windows.sceneDescriptionContent]: { html: string };
[ipcChannels.windows.instructionsSection]: { section: string };
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
@@ -815,6 +819,14 @@ export type IpcInvokeMap = {
req: { html: string };
res: { ok: true };
};
[ipcChannels.windows.openInstructions]: {
req: { section?: string };
res: { ok: true };
};
[ipcChannels.windows.getInstructionsSection]: {
req: Record<string, never>;
res: { section: string };
};
[ipcChannels.windows.closeSceneDescription]: {
req: Record<string, never>;
res: { ok: true };
+1
View File
@@ -63,6 +63,7 @@ export default defineConfig(({ mode }) => {
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
instructions: path.resolve(__dirname, 'app/renderer/instructions.html'),
},
},
},