feat: окно инструкций, свёрнутая история и обновление спонсоров
Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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\[\]/);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user