From a29c72b307329f12872f07a4b8f9f144c3041a70 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 17 Sep 2026 06:21:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=86=D0=B8=D0=B9,=20=D1=81?= =?UTF-8?q?=D0=B2=D1=91=D1=80=D0=BD=D1=83=D1=82=D0=B0=D1=8F=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D0=B8=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D1=81=D0=BE=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию. Co-authored-by: Cursor --- app/main/index.ts | 9 ++ .../windows/createWindows.editorClose.test.ts | 16 ++ app/main/windows/createWindows.ts | 79 +++++++++- app/renderer/control/ControlApp.module.css | 36 ++++- app/renderer/control/ControlApp.tsx | 12 +- .../control/controlApp.effectsPanel.test.ts | 8 +- app/renderer/editor/EditorApp.module.css | 107 ------------- app/renderer/editor/EditorApp.tsx | 23 ++- .../editor/about/EditorAboutModals.tsx | 141 +----------------- app/renderer/editor/help/helpLinkify.test.ts | 8 +- app/renderer/editor/help/helpSections.ts | 6 + app/renderer/instructions.html | 13 ++ .../instructions/InstructionsApp.module.css | 98 ++++++++++++ app/renderer/instructions/InstructionsApp.tsx | 105 +++++++++++++ app/renderer/instructions/main.tsx | 23 +++ app/renderer/shared/PresentationView.tsx | 2 +- .../useScenePlayerTokensSession.ts | 22 ++- .../playlists/PlaylistsPanel.module.css | 25 +++- .../shared/playlists/PlaylistsPanel.tsx | 12 +- .../ui/secondaryWindows.stability.test.ts | 32 ++++ app/shared/appBranding.ts | 4 +- app/shared/ipc/contracts.players.test.ts | 2 + app/shared/ipc/contracts.ts | 12 ++ vite.config.ts | 1 + 24 files changed, 506 insertions(+), 290 deletions(-) create mode 100644 app/renderer/instructions.html create mode 100644 app/renderer/instructions/InstructionsApp.module.css create mode 100644 app/renderer/instructions/InstructionsApp.tsx create mode 100644 app/renderer/instructions/main.tsx diff --git a/app/main/index.ts b/app/main/index.ts index 4e44f9c..1d7b68f 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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 }; diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index ed69d15..b81e937 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -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')); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index d6959e0..5d92efa 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -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(); diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index e07896a..dd53c4f 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -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); } diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index ec9be4b..c44a5be 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -210,7 +210,7 @@ export function ControlApp() { const [session, setSession] = useState(null); const historyRef = useRef([]); const [history, setHistory] = useState([]); - const [historyOpen, setHistoryOpen] = useState(true); + const [historyOpen, setHistoryOpen] = useState(false); const [scenesOpen, setScenesOpen] = useState(false); const [darknessBrushShape, setDarknessBrushShape] = useState('circle'); const darknessBrushShapeRef = useRef(darknessBrushShape); @@ -2385,6 +2385,7 @@ export function ControlApp() {
{t('control.screenPreview')}
+
{USERS_BRANCH_FEATURES_ENABLED ? ( - <> - {scenePlayerTokensSession.selectedPlayerIds.length > 0 ? ( + ) : null} +
+ {USERS_BRANCH_FEATURES_ENABLED && scenePlayerTokensSession.selectedPlayerIds.length > 0 ? ( - ) : null} - ) : null}
diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index 7c90c57..8fe3bdb 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -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\)/); diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index 3ced0fe..34bc02e 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -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; diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 2c5c1b0..ab6f6e5 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -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('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} /> setAppAboutOpen(false)} appVersion={appVersionText} /> - setInstructionsOpen(false)} - /> {aboutMenuOpen && aboutMenuPos ? createPortal(
{ setAboutMenuOpen(false); - setInstructionsSection('overview'); - setInstructionsOpen(true); + openInstructions('overview'); }} > {t('menu.instructions')} diff --git a/app/renderer/editor/about/EditorAboutModals.tsx b/app/renderer/editor/about/EditorAboutModals.tsx index 648d215..a112768 100644 --- a/app/renderer/editor/about/EditorAboutModals.tsx +++ b/app/renderer/editor/about/EditorAboutModals.tsx @@ -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(initialSection); - const contentRef = useRef(null); - const navRef = useRef(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('[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( - <> - -
-
-
-
{t(helpSectionTitleKey(activeId))}
- {paragraphs.map((p, i) => ( -

- {splitHelpTextWithLinks(p, linkCatalog).map((part, j) => - part.type === 'text' ? ( - {part.value} - ) : ( - - ), - )} -

- ))} -
- -
-
- -
-
- , - document.body, - ); -} - -export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) { - if (!open) return null; - const section = initialSection ?? 'overview'; - return ; -} diff --git a/app/renderer/editor/help/helpLinkify.test.ts b/app/renderer/editor/help/helpLinkify.test.ts index 749ee45..b6b8511 100644 --- a/app/renderer/editor/help/helpLinkify.test.ts +++ b/app/renderer/editor/help/helpLinkify.test.ts @@ -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 = { @@ -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'); +}); diff --git a/app/renderer/editor/help/helpSections.ts b/app/renderer/editor/help/helpSections.ts index c8135e6..41c8f6a 100644 --- a/app/renderer/editor/help/helpSections.ts +++ b/app/renderer/editor/help/helpSections.ts @@ -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'; +} diff --git a/app/renderer/instructions.html b/app/renderer/instructions.html new file mode 100644 index 0000000..381d9c5 --- /dev/null +++ b/app/renderer/instructions.html @@ -0,0 +1,13 @@ + + + + + + + TTRPG + + +
+ + + diff --git a/app/renderer/instructions/InstructionsApp.module.css b/app/renderer/instructions/InstructionsApp.module.css new file mode 100644 index 0000000..38c2281 --- /dev/null +++ b/app/renderer/instructions/InstructionsApp.module.css @@ -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); + } +} diff --git a/app/renderer/instructions/InstructionsApp.tsx b/app/renderer/instructions/InstructionsApp.tsx new file mode 100644 index 0000000..2ea9cfe --- /dev/null +++ b/app/renderer/instructions/InstructionsApp.tsx @@ -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(initialSection); + const contentRef = useRef(null); + const navRef = useRef(null); + + useEffect(() => { + setActiveId(initialSection); + }, [initialSection]); + + useEffect(() => { + contentRef.current?.scrollTo({ top: 0 }); + const activeNav = navRef.current?.querySelector('[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 ( +
+
+
{t(helpSectionTitleKey(activeId))}
+ {paragraphs.map((p, i) => ( +

+ {splitHelpTextWithLinks(p, linkCatalog).map((part, j) => + part.type === 'text' ? ( + {part.value} + ) : ( + + ), + )} +

+ ))} +
+ +
+ ); +} + +export function InstructionsApp() { + const [section, setSection] = useState('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 ; +} diff --git a/app/renderer/instructions/main.tsx b/app/renderer/instructions/main.tsx new file mode 100644 index 0000000..e55e05d --- /dev/null +++ b/app/renderer/instructions/main.tsx @@ -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( + + + + + + + , +); diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index cfb52cf..1b8fc57 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -338,7 +338,7 @@ export function PresentationView({ ))} {activeNpcItems.length > 0 ? : null} - + {showTitle ? (
diff --git a/app/renderer/shared/playerToken/useScenePlayerTokensSession.ts b/app/renderer/shared/playerToken/useScenePlayerTokensSession.ts index f2e8e7f..7b24f60 100644 --- a/app/renderer/shared/playerToken/useScenePlayerTokensSession.ts +++ b/app/renderer/shared/playerToken/useScenePlayerTokensSession.ts @@ -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 }); }, diff --git a/app/renderer/shared/playlists/PlaylistsPanel.module.css b/app/renderer/shared/playlists/PlaylistsPanel.module.css index 8836f19..a539951 100644 --- a/app/renderer/shared/playlists/PlaylistsPanel.module.css +++ b/app/renderer/shared/playlists/PlaylistsPanel.module.css @@ -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 { diff --git a/app/renderer/shared/playlists/PlaylistsPanel.tsx b/app/renderer/shared/playlists/PlaylistsPanel.tsx index 6bdfa4f..ff3bbd7 100644 --- a/app/renderer/shared/playlists/PlaylistsPanel.tsx +++ b/app/renderer/shared/playlists/PlaylistsPanel.tsx @@ -309,7 +309,7 @@ export function PlaylistsPanel({ } onClick={() => setSelectedId(pl.id)} > - {pl.name || labels.untitled} + {pl.name || labels.untitled} {playerUi[pl.id]?.playing ? ▶ : null} {pl.tracks.length} @@ -337,7 +337,9 @@ export function PlaylistsPanel({ <>
{readOnly ? ( -
{selected.name || labels.untitled}
+
+ {selected.name || labels.untitled} +
) : ( void saveRename()} /> )} @@ -436,7 +438,9 @@ export function PlaylistsPanel({ ▶ {readOnly ? ( - {title} + + {title} + ) : editingAudioId === tr.audioId ? ( { setEditingAudioId(tr.audioId); setEditingAudioTitle(title); }} - title={labels.renameAudio} > {title} diff --git a/app/renderer/shared/ui/secondaryWindows.stability.test.ts b/app/renderer/shared/ui/secondaryWindows.stability.test.ts index 5284546..077ee9e 100644 --- a/app/renderer/shared/ui/secondaryWindows.stability.test.ts +++ b/app/renderer/shared/ui/secondaryWindows.stability.test.ts @@ -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, //); +}); + +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, //); }); void test('ControlApp: Initiative tool opens window', () => { diff --git a/app/shared/appBranding.ts b/app/shared/appBranding.ts index 5ba8a85..1f0a6e1 100644 --- a/app/shared/appBranding.ts +++ b/app/shared/appBranding.ts @@ -29,7 +29,8 @@ export type AppWindowKind = | 'npcsEditor' | 'sceneEditor' | 'tokenPathEditor' - | 'npcs'; + | 'npcs' + | 'instructions'; const WINDOW_SUFFIX: Record = { editor: { ru: 'Редактор компании', en: 'Campaign editor' }, @@ -46,6 +47,7 @@ const WINDOW_SUFFIX: Record = { 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 { diff --git a/app/shared/ipc/contracts.players.test.ts b/app/shared/ipc/contracts.players.test.ts index e7c5835..496be6f 100644 --- a/app/shared/ipc/contracts.players.test.ts +++ b/app/shared/ipc/contracts.players.test.ts @@ -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\[\]/); diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 3172ca4..f396375 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -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; + res: { section: string }; + }; [ipcChannels.windows.closeSceneDescription]: { req: Record; res: { ok: true }; diff --git a/vite.config.ts b/vite.config.ts index e7d55a4..f2039e9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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'), }, }, },