feat: окно инструкций, свёрнутая история и обновление спонсоров
Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -83,6 +83,8 @@ import {
|
|||||||
openTokenPathEditorWindow,
|
openTokenPathEditorWindow,
|
||||||
openNpcsWindow,
|
openNpcsWindow,
|
||||||
openSceneDescriptionWindow,
|
openSceneDescriptionWindow,
|
||||||
|
openInstructionsWindow,
|
||||||
|
getInstructionsSection,
|
||||||
closeMaterialsWindow,
|
closeMaterialsWindow,
|
||||||
closePlaylistsWindow,
|
closePlaylistsWindow,
|
||||||
closeTimerWindow,
|
closeTimerWindow,
|
||||||
@@ -783,6 +785,13 @@ async function main() {
|
|||||||
openSceneDescriptionWindow(html);
|
openSceneDescriptionWindow(html);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
registerHandler(ipcChannels.windows.openInstructions, ({ section }) => {
|
||||||
|
openInstructionsWindow(section);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
registerHandler(ipcChannels.windows.getInstructionsSection, () => {
|
||||||
|
return { section: getInstructionsSection() };
|
||||||
|
});
|
||||||
registerHandler(ipcChannels.windows.closeSceneDescription, () => {
|
registerHandler(ipcChannels.windows.closeSceneDescription, () => {
|
||||||
closeSceneDescriptionWindow();
|
closeSceneDescriptionWindow();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ void test('createWindows: окно материалов закрывается
|
|||||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
|
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', () => {
|
void test('createWindows: окно таймера закрывается с multi-window', () => {
|
||||||
const src = readCreateWindows();
|
const src = readCreateWindows();
|
||||||
assert.ok(src.includes('openTimerWindow'));
|
assert.ok(src.includes('openTimerWindow'));
|
||||||
@@ -76,6 +82,16 @@ void test('createWindows: окно кубиков закрывается с mult
|
|||||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeDiceWindow/);
|
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', () => {
|
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
||||||
const src = readCreateWindows();
|
const src = readCreateWindows();
|
||||||
assert.ok(src.includes('openNpcsWindow'));
|
assert.ok(src.includes('openNpcsWindow'));
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export type WindowKind =
|
|||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
| 'sceneEditor'
|
| 'sceneEditor'
|
||||||
| 'tokenPathEditor'
|
| 'tokenPathEditor'
|
||||||
| 'npcs';
|
| 'npcs'
|
||||||
|
| 'instructions';
|
||||||
|
|
||||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||||
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||||||
@@ -53,6 +54,7 @@ let appQuitting = false;
|
|||||||
/** Защита от каскада close(control) ↔ close(presentation). */
|
/** Защита от каскада close(control) ↔ close(presentation). */
|
||||||
let closingPlaySession = false;
|
let closingPlaySession = false;
|
||||||
let pendingSceneDescriptionHtml = '';
|
let pendingSceneDescriptionHtml = '';
|
||||||
|
let pendingInstructionsSection = 'overview';
|
||||||
|
|
||||||
/** Окно материалов — только колонка списка. */
|
/** Окно материалов — только колонка списка. */
|
||||||
const MATERIALS_WINDOW_WIDTH = 300;
|
const MATERIALS_WINDOW_WIDTH = 300;
|
||||||
@@ -60,6 +62,7 @@ const MATERIALS_WINDOW_HEIGHT = 720;
|
|||||||
|
|
||||||
/** Окно плейлистов на пульте. */
|
/** Окно плейлистов на пульте. */
|
||||||
const PLAYLISTS_WINDOW_WIDTH = 720;
|
const PLAYLISTS_WINDOW_WIDTH = 720;
|
||||||
|
const PLAYLISTS_WINDOW_MIN_WIDTH = 640;
|
||||||
const PLAYLISTS_WINDOW_HEIGHT = 800;
|
const PLAYLISTS_WINDOW_HEIGHT = 800;
|
||||||
|
|
||||||
/** Компактное окно таймера сессии. */
|
/** Компактное окно таймера сессии. */
|
||||||
@@ -81,6 +84,10 @@ const INITIATIVE_WINDOW_HEIGHT = 420;
|
|||||||
const DICE_WINDOW_WIDTH = 380;
|
const DICE_WINDOW_WIDTH = 380;
|
||||||
const DICE_WINDOW_HEIGHT = 460;
|
const DICE_WINDOW_HEIGHT = 460;
|
||||||
|
|
||||||
|
/** Окно инструкции из редактора (не модалка). */
|
||||||
|
const INSTRUCTIONS_WINDOW_WIDTH = 960;
|
||||||
|
const INSTRUCTIONS_WINDOW_HEIGHT = 720;
|
||||||
|
|
||||||
/** Колбэк при закрытии окна кубиков (Esc / крестик / programmatic). */
|
/** Колбэк при закрытии окна кубиков (Esc / крестик / programmatic). */
|
||||||
let diceWindowClosedListener: (() => void) | null = null;
|
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 {
|
export function markAppQuitting(): void {
|
||||||
appQuitting = true;
|
appQuitting = true;
|
||||||
@@ -265,6 +281,8 @@ function pageNameForKind(kind: WindowKind): string {
|
|||||||
return 'tokenPathEditor.html';
|
return 'tokenPathEditor.html';
|
||||||
case 'npcs':
|
case 'npcs':
|
||||||
return 'npcs.html';
|
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 === 'sceneEditor') return { width: 1280, height: 800 };
|
||||||
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
||||||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
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 };
|
return { width: 1280, height: 800 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +385,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
minHeight: 480,
|
minHeight: 480,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(kind === 'playlists'
|
||||||
|
? {
|
||||||
|
width: PLAYLISTS_WINDOW_WIDTH,
|
||||||
|
height: PLAYLISTS_WINDOW_HEIGHT,
|
||||||
|
minWidth: PLAYLISTS_WINDOW_MIN_WIDTH,
|
||||||
|
minHeight: 480,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(kind === 'npcsEditor'
|
...(kind === 'npcsEditor'
|
||||||
? {
|
? {
|
||||||
width: NPCS_EDITOR_WINDOW_WIDTH,
|
width: NPCS_EDITOR_WINDOW_WIDTH,
|
||||||
@@ -399,6 +426,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
minHeight: 560,
|
minHeight: 560,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(kind === 'instructions'
|
||||||
|
? {
|
||||||
|
width: INSTRUCTIONS_WINDOW_WIDTH,
|
||||||
|
height: INSTRUCTIONS_WINDOW_HEIGHT,
|
||||||
|
minWidth: 640,
|
||||||
|
minHeight: 480,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: '#09090B',
|
backgroundColor: '#09090B',
|
||||||
...(icon ? { icon } : {}),
|
...(icon ? { icon } : {}),
|
||||||
@@ -458,7 +493,8 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
kind === 'npcsEditor' ||
|
kind === 'npcsEditor' ||
|
||||||
kind === 'sceneEditor' ||
|
kind === 'sceneEditor' ||
|
||||||
kind === 'tokenPathEditor' ||
|
kind === 'tokenPathEditor' ||
|
||||||
kind === 'npcs'
|
kind === 'npcs' ||
|
||||||
|
kind === 'instructions'
|
||||||
) {
|
) {
|
||||||
win.setMenuBarVisibility(false);
|
win.setMenuBarVisibility(false);
|
||||||
}
|
}
|
||||||
@@ -780,6 +816,44 @@ export function getSceneDescriptionContent(): string {
|
|||||||
return pendingSceneDescriptionHtml;
|
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 {
|
export function openSceneDescriptionWindow(html: string): void {
|
||||||
pendingSceneDescriptionHtml = html;
|
pendingSceneDescriptionHtml = html;
|
||||||
@@ -856,6 +930,7 @@ export function openMaterialsWindow(): void {
|
|||||||
export function openPlaylistsWindow(): void {
|
export function openPlaylistsWindow(): void {
|
||||||
const existing = windows.get('playlists');
|
const existing = windows.get('playlists');
|
||||||
if (existing && !existing.isDestroyed()) {
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
existing.setMinimumSize(PLAYLISTS_WINDOW_MIN_WIDTH, 480);
|
||||||
if (existing.isMinimized()) existing.restore();
|
if (existing.isMinimized()) existing.restore();
|
||||||
existing.show();
|
existing.show();
|
||||||
existing.focus();
|
existing.focus();
|
||||||
|
|||||||
@@ -339,19 +339,39 @@
|
|||||||
|
|
||||||
.previewHeader {
|
.previewHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewTitle {
|
.previewTitle {
|
||||||
font-size: var(--text-md);
|
font-size: var(--text-md);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewActions {
|
.previewActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
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 {
|
.npcTokenScale {
|
||||||
@@ -359,6 +379,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snapToGrid {
|
.snapToGrid {
|
||||||
@@ -366,26 +387,37 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 34px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.snapToGrid input {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.snapToGridLabel {
|
.snapToGridLabel {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.npcTokenScaleLabel {
|
.npcTokenScaleLabel {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.npcTokenScaleRange {
|
.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);
|
accent-color: var(--color-accent, #c9a227);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function ControlApp() {
|
|||||||
const [session, setSession] = useState<SessionState | null>(null);
|
const [session, setSession] = useState<SessionState | null>(null);
|
||||||
const historyRef = useRef<GraphNodeId[]>([]);
|
const historyRef = useRef<GraphNodeId[]>([]);
|
||||||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||||||
const [historyOpen, setHistoryOpen] = useState(true);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [scenesOpen, setScenesOpen] = useState(false);
|
const [scenesOpen, setScenesOpen] = useState(false);
|
||||||
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
|
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
|
||||||
const darknessBrushShapeRef = useRef(darknessBrushShape);
|
const darknessBrushShapeRef = useRef(darknessBrushShape);
|
||||||
@@ -2385,6 +2385,7 @@ export function ControlApp() {
|
|||||||
<div className={styles.previewHeader}>
|
<div className={styles.previewHeader}>
|
||||||
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
|
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
|
||||||
<div className={styles.previewActions}>
|
<div className={styles.previewActions}>
|
||||||
|
<div className={styles.previewTokenTools}>
|
||||||
<label
|
<label
|
||||||
className={styles.snapToGrid}
|
className={styles.snapToGrid}
|
||||||
title={
|
title={
|
||||||
@@ -2408,7 +2409,6 @@ export function ControlApp() {
|
|||||||
<span className={styles.snapToGridLabel}>{t('control.snapTokensToGrid')}</span>
|
<span className={styles.snapToGridLabel}>{t('control.snapTokensToGrid')}</span>
|
||||||
</label>
|
</label>
|
||||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||||
<>
|
|
||||||
<label className={styles.npcTokenScale}>
|
<label className={styles.npcTokenScale}>
|
||||||
<span className={styles.npcTokenScaleLabel}>{t('control.npcTokenScale')}</span>
|
<span className={styles.npcTokenScaleLabel}>{t('control.npcTokenScale')}</span>
|
||||||
<input
|
<input
|
||||||
@@ -2427,7 +2427,9 @@ export function ControlApp() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
|
) : null}
|
||||||
|
</div>
|
||||||
|
{USERS_BRANCH_FEATURES_ENABLED && scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
|
||||||
<Button
|
<Button
|
||||||
data-testid="toggle-session-players"
|
data-testid="toggle-session-players"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -2473,8 +2475,6 @@ export function ControlApp() {
|
|||||||
? t('control.hidePlayers')
|
? t('control.hidePlayers')
|
||||||
: t('control.showPlayers')}
|
: t('control.showPlayers')}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
|
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
|
||||||
{t('control.stopPresentation')}
|
{t('control.stopPresentation')}
|
||||||
@@ -3054,7 +3054,7 @@ export function ControlApp() {
|
|||||||
style={{ zIndex: 48 }}
|
style={{ zIndex: 48 }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<SessionTimerLayer viewport={previewContentRect} editable />
|
<SessionTimerLayer viewport={markerViewport} editable />
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
|
|||||||
@@ -135,16 +135,20 @@ void test('ControlApp: чекбокс привязки токенов к сет
|
|||||||
assert.ok(src.includes('snapAllTokensToGrid'));
|
assert.ok(src.includes('snapAllTokensToGrid'));
|
||||||
assert.ok(src.includes('snapNorm={snapNormActive}'));
|
assert.ok(src.includes('snapNorm={snapNormActive}'));
|
||||||
assert.ok(css.includes('.snapToGrid'));
|
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 src = readControlApp();
|
||||||
const css = readControlAppCss();
|
const css = readControlAppCss();
|
||||||
const story = src.indexOf("t('control.storyLine')");
|
const story = src.indexOf("t('control.storyLine')");
|
||||||
assert.ok(story !== -1);
|
assert.ok(story !== -1);
|
||||||
assert.ok(src.includes('data-testid="control-history-accordion"'));
|
assert.ok(src.includes('data-testid="control-history-accordion"'));
|
||||||
assert.ok(src.includes('aria-expanded={historyOpen}'));
|
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.ok(src.includes('className={styles.storyScroll}'));
|
||||||
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
||||||
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||||
|
|||||||
@@ -550,113 +550,6 @@
|
|||||||
line-height: 1.45;
|
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 {
|
.aboutBody {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { Button, Input } from '../shared/ui/controls';
|
|||||||
import { LayoutShell } from '../shared/ui/LayoutShell';
|
import { LayoutShell } from '../shared/ui/LayoutShell';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
|
import { AppAboutModal } from './about/EditorAboutModals';
|
||||||
import styles from './EditorApp.module.css';
|
import styles from './EditorApp.module.css';
|
||||||
import { EditorAudioTrackList } from './EditorAudioTrackList';
|
import { EditorAudioTrackList } from './EditorAudioTrackList';
|
||||||
import {
|
import {
|
||||||
@@ -132,8 +132,6 @@ export function EditorApp() {
|
|||||||
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
|
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
|
||||||
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
|
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
|
||||||
const [appAboutOpen, setAppAboutOpen] = useState(false);
|
const [appAboutOpen, setAppAboutOpen] = useState(false);
|
||||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
|
||||||
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
|
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
const [importSourceOpen, setImportSourceOpen] = useState(false);
|
const [importSourceOpen, setImportSourceOpen] = useState(false);
|
||||||
@@ -384,6 +382,14 @@ export function EditorApp() {
|
|||||||
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
|
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
|
||||||
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
|
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(
|
const launchFromGraphNode = useCallback(
|
||||||
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
||||||
if (!licenseActive || launching) return;
|
if (!licenseActive || launching) return;
|
||||||
@@ -990,8 +996,7 @@ export function EditorApp() {
|
|||||||
title={runHelpTooltip}
|
title={runHelpTooltip}
|
||||||
tooltipPlacement="bottom-left"
|
tooltipPlacement="bottom-left"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setInstructionsSection(runHelpSection);
|
openInstructions(runHelpSection);
|
||||||
setInstructionsOpen(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
@@ -1472,11 +1477,6 @@ export function EditorApp() {
|
|||||||
snapshot={licenseSnap}
|
snapshot={licenseSnap}
|
||||||
/>
|
/>
|
||||||
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
|
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
|
||||||
<InstructionsModal
|
|
||||||
open={instructionsOpen}
|
|
||||||
initialSection={instructionsSection}
|
|
||||||
onClose={() => setInstructionsOpen(false)}
|
|
||||||
/>
|
|
||||||
{aboutMenuOpen && aboutMenuPos
|
{aboutMenuOpen && aboutMenuPos
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -1502,8 +1502,7 @@ export function EditorApp() {
|
|||||||
className={styles.fileMenuItem}
|
className={styles.fileMenuItem}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAboutMenuOpen(false);
|
setAboutMenuOpen(false);
|
||||||
setInstructionsSection('overview');
|
openInstructions('overview');
|
||||||
setInstructionsOpen(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('menu.instructions')}
|
{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 { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
|
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
|
||||||
import { Button } from '../../shared/ui/controls';
|
import { Button } from '../../shared/ui/controls';
|
||||||
import styles from '../EditorApp.module.css';
|
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';
|
import { useEditorI18n } from '../i18n/EditorI18nContext';
|
||||||
|
|
||||||
const ABOUT_SPONSORS = [
|
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: 'text', name: 'Майер С.В. (Клуб «Серый Май»)' },
|
||||||
{ kind: 'link', name: 'true_rus(КАВ)', url: 'https://truecorp.su/' },
|
{ kind: 'link', name: 'true_rus(КАВ)', url: 'https://truecorp.su/' },
|
||||||
{ kind: 'text', name: 'Макс LonelyFlame Чурсин' },
|
{ kind: 'text', name: 'Макс LonelyFlame Чурсин' },
|
||||||
@@ -119,125 +116,3 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
|
|||||||
document.body,
|
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 assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import type { HelpSectionId } from './helpSections';
|
import { parseHelpSectionId, type HelpSectionId } from './helpSections';
|
||||||
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
|
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
|
||||||
|
|
||||||
const RU_TITLES: Record<HelpSectionId, string> = {
|
const RU_TITLES: Record<HelpSectionId, string> = {
|
||||||
@@ -80,3 +80,9 @@ void test('findHelpLinkRanges: полное «Пульт управления»
|
|||||||
assert.equal(ranges[0]?.id, 'controlPanel');
|
assert.equal(ranges[0]?.id, 'controlPanel');
|
||||||
assert.equal(ranges[0]?.text, '«Пульт управления»');
|
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 {
|
export function helpSectionBodyKey(id: HelpSectionId): string {
|
||||||
return `help.section.${id}.body`;
|
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}
|
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
||||||
</SceneOverlayHost>
|
</SceneOverlayHost>
|
||||||
<SessionTimerLayer viewport={contentRect} editable={false} />
|
<SessionTimerLayer viewport={markerViewport} editable={false} />
|
||||||
<DiceRollLayer />
|
<DiceRollLayer />
|
||||||
{showTitle ? (
|
{showTitle ? (
|
||||||
<div className={styles.titleWrap}>
|
<div className={styles.titleWrap}>
|
||||||
|
|||||||
@@ -81,29 +81,27 @@ export function useScenePlayerTokensSession(): [
|
|||||||
visible: false,
|
visible: false,
|
||||||
byPlayerId: {},
|
byPlayerId: {},
|
||||||
});
|
});
|
||||||
const localRevRef = useRef(0);
|
/** Только revision с main. Оптимистичные move/setSize не должны глушить смену сцены. */
|
||||||
|
const serverRevRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
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) => {
|
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
|
||||||
if (res.state.revision < localRevRef.current) return;
|
applyServer(res.state);
|
||||||
localRevRef.current = res.state.revision;
|
|
||||||
setState(res.state);
|
|
||||||
});
|
});
|
||||||
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
|
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
|
||||||
if (next.revision < localRevRef.current) return;
|
applyServer(next);
|
||||||
localRevRef.current = next.revision;
|
|
||||||
setState(next);
|
|
||||||
});
|
});
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
const dispatch = useCallback(
|
const dispatch = useCallback(
|
||||||
(event: ScenePlayerTokensSessionEvent) => {
|
(event: ScenePlayerTokensSessionEvent) => {
|
||||||
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
|
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
|
||||||
setState((prev) => {
|
setState((prev) => applyEvent(prev, event));
|
||||||
const next = applyEvent(prev, event);
|
|
||||||
localRevRef.current = Math.max(localRevRef.current, next.revision);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
|
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
.sidebar,
|
.sidebar,
|
||||||
.main {
|
.main {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -74,6 +75,8 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlistRow:hover,
|
.playlistRow:hover,
|
||||||
@@ -103,10 +106,10 @@
|
|||||||
|
|
||||||
.playlistName,
|
.playlistName,
|
||||||
.trackTitle,
|
.trackTitle,
|
||||||
.playlistNameReadonly,
|
|
||||||
.trackTitleReadonly {
|
.trackTitleReadonly {
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -114,6 +117,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trackTitle {
|
.trackTitle {
|
||||||
|
display: block;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -122,18 +126,24 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlistNameReadonly {
|
.trackTitleReadonly {
|
||||||
font-weight: 700;
|
display: block;
|
||||||
font-size: var(--text-md, 1rem);
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trackTitleReadonly {
|
.playlistNameReadonly {
|
||||||
color: inherit;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-md, 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlistCount {
|
.playlistCount {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mainHeader {
|
.mainHeader {
|
||||||
@@ -153,6 +163,7 @@
|
|||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ export function PlaylistsPanel({
|
|||||||
}
|
}
|
||||||
onClick={() => setSelectedId(pl.id)}
|
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}
|
{playerUi[pl.id]?.playing ? <span className={styles.playlistPlayingMark}>▶</span> : null}
|
||||||
<span className={styles.playlistCount}>{pl.tracks.length}</span>
|
<span className={styles.playlistCount}>{pl.tracks.length}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -337,7 +337,9 @@ export function PlaylistsPanel({
|
|||||||
<>
|
<>
|
||||||
<div className={styles.mainHeader}>
|
<div className={styles.mainHeader}>
|
||||||
{readOnly ? (
|
{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()} />
|
<Input value={editingName} onChange={setEditingName} onBlur={() => void saveRename()} />
|
||||||
)}
|
)}
|
||||||
@@ -436,7 +438,9 @@ export function PlaylistsPanel({
|
|||||||
▶
|
▶
|
||||||
</button>
|
</button>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<span className={styles.trackTitleReadonly}>{title}</span>
|
<span className={styles.trackTitleReadonly} title={title}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
) : editingAudioId === tr.audioId ? (
|
) : editingAudioId === tr.audioId ? (
|
||||||
<Input
|
<Input
|
||||||
value={editingAudioTitle}
|
value={editingAudioTitle}
|
||||||
@@ -453,11 +457,11 @@ export function PlaylistsPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.trackTitle}
|
className={styles.trackTitle}
|
||||||
|
title={title}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingAudioId(tr.audioId);
|
setEditingAudioId(tr.audioId);
|
||||||
setEditingAudioTitle(title);
|
setEditingAudioTitle(title);
|
||||||
}}
|
}}
|
||||||
title={labels.renameAudio}
|
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const SECONDARY_WINDOW_MAINS = [
|
|||||||
'dice/main.tsx',
|
'dice/main.tsx',
|
||||||
'sceneEditor/main.tsx',
|
'sceneEditor/main.tsx',
|
||||||
'sceneDescription/main.tsx',
|
'sceneDescription/main.tsx',
|
||||||
|
'instructions/main.tsx',
|
||||||
'control/main.tsx',
|
'control/main.tsx',
|
||||||
'presentation/main.tsx',
|
'presentation/main.tsx',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -119,6 +120,24 @@ void test('Control and Presentation use SceneNpcTokensOverlay', () => {
|
|||||||
assert.ok(control.includes('ScenePlayerTokensOverlay'));
|
assert.ok(control.includes('ScenePlayerTokensOverlay'));
|
||||||
assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
|
assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
|
||||||
assert.ok(control.includes('toggle-session-players'));
|
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', () => {
|
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'));
|
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', () => {
|
void test('ControlApp: Timer tool opens window', () => {
|
||||||
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
|
const src = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
|
||||||
assert.ok(src.includes('data-testid="control-timer-tool"'));
|
assert.ok(src.includes('data-testid="control-timer-tool"'));
|
||||||
assert.ok(src.includes('windows.openTimer'));
|
assert.ok(src.includes('windows.openTimer'));
|
||||||
assert.ok(src.includes('SessionTimerLayer'));
|
assert.ok(src.includes('SessionTimerLayer'));
|
||||||
|
assert.match(src, /<SessionTimerLayer viewport=\{markerViewport\} editable \/>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('ControlApp: Initiative tool opens window', () => {
|
void test('ControlApp: Initiative tool opens window', () => {
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export type AppWindowKind =
|
|||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
| 'sceneEditor'
|
| 'sceneEditor'
|
||||||
| 'tokenPathEditor'
|
| 'tokenPathEditor'
|
||||||
| 'npcs';
|
| 'npcs'
|
||||||
|
| 'instructions';
|
||||||
|
|
||||||
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||||
editor: { ru: 'Редактор компании', en: 'Campaign editor' },
|
editor: { ru: 'Редактор компании', en: 'Campaign editor' },
|
||||||
@@ -46,6 +47,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
|||||||
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
sceneEditor: { ru: 'Редактор сцены', en: 'Scene editor' },
|
||||||
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
tokenPathEditor: { ru: 'Движение токена', en: 'Token path' },
|
||||||
npcs: { ru: 'НПС', en: 'NPCs' },
|
npcs: { ru: 'НПС', en: 'NPCs' },
|
||||||
|
instructions: { ru: 'Инструкция', en: 'Instructions' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
|
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, /sceneNpcTokensSession:\s*\{/);
|
||||||
assert.match(src, /npcMapSpawn:\s*\{/);
|
assert.match(src, /npcMapSpawn:\s*\{/);
|
||||||
assert.match(src, /scenePlayerTokensSession:\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:\s*\{/);
|
||||||
assert.match(src, /tokenGridSnap\.setEnabled/);
|
assert.match(src, /tokenGridSnap\.setEnabled/);
|
||||||
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
|
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ export const ipcChannels = {
|
|||||||
closeSceneDescription: 'windows.closeSceneDescription',
|
closeSceneDescription: 'windows.closeSceneDescription',
|
||||||
getSceneDescriptionContent: 'windows.getSceneDescriptionContent',
|
getSceneDescriptionContent: 'windows.getSceneDescriptionContent',
|
||||||
sceneDescriptionContent: 'windows.sceneDescriptionContent',
|
sceneDescriptionContent: 'windows.sceneDescriptionContent',
|
||||||
|
openInstructions: 'windows.openInstructions',
|
||||||
|
getInstructionsSection: 'windows.getInstructionsSection',
|
||||||
|
instructionsSection: 'windows.instructionsSection',
|
||||||
openMaterials: 'windows.openMaterials',
|
openMaterials: 'windows.openMaterials',
|
||||||
closeMaterials: 'windows.closeMaterials',
|
closeMaterials: 'windows.closeMaterials',
|
||||||
openPlaylists: 'windows.openPlaylists',
|
openPlaylists: 'windows.openPlaylists',
|
||||||
@@ -429,6 +432,7 @@ export type IpcEventMap = {
|
|||||||
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
||||||
[ipcChannels.windows.presentationContentSizeChanged]: { width: number; height: number };
|
[ipcChannels.windows.presentationContentSizeChanged]: { width: number; height: number };
|
||||||
[ipcChannels.windows.sceneDescriptionContent]: { html: string };
|
[ipcChannels.windows.sceneDescriptionContent]: { html: string };
|
||||||
|
[ipcChannels.windows.instructionsSection]: { section: string };
|
||||||
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
|
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
|
||||||
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
|
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
|
||||||
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
|
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
|
||||||
@@ -815,6 +819,14 @@ export type IpcInvokeMap = {
|
|||||||
req: { html: string };
|
req: { html: string };
|
||||||
res: { ok: true };
|
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]: {
|
[ipcChannels.windows.closeSceneDescription]: {
|
||||||
req: Record<string, never>;
|
req: Record<string, never>;
|
||||||
res: { ok: true };
|
res: { ok: true };
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
|
tokenPathEditor: path.resolve(__dirname, 'app/renderer/tokenPathEditor.html'),
|
||||||
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
npcsEditor: path.resolve(__dirname, 'app/renderer/npcsEditor.html'),
|
||||||
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
|
npcs: path.resolve(__dirname, 'app/renderer/npcs.html'),
|
||||||
|
instructions: path.resolve(__dirname, 'app/renderer/instructions.html'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user