24 Commits

Author SHA1 Message Date
Ivan Fontosh 5c0c72892d fix(foundry): copy playlists before deleting the unpacked archive
Archive import removed the temp folder before playlist tracks were read, so zip imports with music failed. The dialog hint now names .zip / .fvtt instead of .world.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 21:05:55 +08:00
Ivan Fontosh 877fbaa8e7 feat: save games and edit scene darkness
Persist show state to local saves; author map darkness in scene editor with bake-to-scene and help updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 11:41:42 +08:00
Ivan Fontosh f6c301a12b feat(play): hide NPC tokens from the player screen
A token can be hidden per session from the control menu. The GM still sees it, and its path keeps moving. Scene editor grid labels no longer call a removed helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 10:15:32 +08:00
Ivan Fontosh 060bbd471a feat(ui): confirm ending a show and surface help
End-session buttons, Escape, and window close share one confirm. Help is a question-mark icon on the control panel and after the version. About opens directly. Scene editor follows the UI language.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 08:10:54 +08:00
Ivan Fontosh d228b9aab7 fix(play): ask before Escape ends session
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 08:53:48 +08:00
Ivan Fontosh 143d141aa6 chore: release v1.1.0 2026-09-17 15:31:45 +08:00
Ivan Fontosh a29c72b307 feat: окно инструкций, свёрнутая история и обновление спонсоров
Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 06:21:42 +08:00
Ivan Fontosh 712086ddca feat: кубики/таймер/кисть/спонсоры и задел игровых систем
Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 15:37:44 +08:00
Ivan Fontosh 3d0474fe97 fix(export): не падать с EPERM при сохранении в корень диска на Windows
Перед записью zip больше не вызываем mkdir для E:\ / C:\ — на Windows это даёт operation not permitted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:09:48 +08:00
     Фонтош Иван Сергеевич 48a9c933af fix(effects): снизить лаги сцены на Mac
Pixi режется по contentRect, пульт идёт в preview (12 FPS, 0.5 DPR), штампы тумана/огня/дождя прореживаются, draft темноты и маркера не чаще 20 Гц.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:13:37 +08:00
Ivan Fontosh 34dd39791b docs: обложка и текст ВК про успешный краудфандинг
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 16:16:37 +08:00
Ivan Fontosh fdccc0deb5 fix(tokens): импорт неигровых токенов из zip и плавные пути на macOS
Полный импорт/открытие проекта подхватывает app-tokens. Движение по путям пишется в DOM без ре-рендера кадра, у пульта и презентации выключен background throttling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 16:16:30 +08:00
Ivan Fontosh 586166cacd feat(audio): volume в проекте, DnD, параллельные плейлисты
Громкость треков сцены/игры сохраняется в проекте; порядок через DnD; несколько плейлистов сразу; автоимя из файла; стоп превью при запуске сессии.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 17:09:26 +08:00
Ivan Fontosh dbe8ab1fda docs: инструкции и обложка ВК по новым фичам после 1.0.29
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 15:44:25 +08:00
Ivan Fontosh 9ac01d217f feat(control): 3D-кубики в отдельном окне
Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать».

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 13:01:34 +08:00
Ivan Fontosh 86d6d157f0 feat(control): трекер инициативы в отдельном окне
Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 10:09:51 +08:00
Ivan Fontosh db1168a46c feat(control): таймер сессии поверх сцены
Отдельное окно countdown с «Показать игрокам»; на превью — move/resize/rotate, на презентации тот же layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 16:51:56 +08:00
Ivan Fontosh a4d7ab77e7 feat(grid): именованные шаблоны сетки кампании
Несколько шаблонов в проекте с ручным сохранением и применением (включает сетку); без автоподстановки.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 12:04:47 +08:00
Ivan Fontosh 278eb78637 feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 10:37:21 +08:00
Ivan Fontosh cfb6e62331 feat(control): аккордеон «Сцены» на пульте управления
Список сцен активной линии в порядке бокового списка редактора; переход с записью в историю.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 13:29:12 +08:00
Ivan Fontosh 746b862cb9 feat(editor): настройки экрана презентации при запуске сессии
Выбор монитора и полноэкранного режима в настройках; при «Запустить» окно презентации открывается на выбранном дисплее автоматически.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 13:05:50 +08:00
Ivan Fontosh 8b28a9b11a feat(control): инструмент «Маркер» на области видимости
Добавлена кисть маркера на пульте и презентации с настройками в панели эффектов; штрихи привязаны к жёлтой рамке, ластик и очистка работают с отдельным сессионным слоем.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 12:53:06 +08:00
Ivan Fontosh 153be98780 fix(materials): синхронизация легенды и маркеров на превью и презентации
Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:57:50 +08:00
Ivan Fontosh ea718a26df feat(control): история-аккордеон и форма кисти затемнения
Переименовать блок сюжетной линии в «История» и сделать его сворачиваемым. Добавить выбор круглой/квадратной формы для кистей открытия и закрытия с сохранением в штрихах и сбросом при смене инструмента или сцены без затемнения.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:00:10 +08:00
322 changed files with 15716 additions and 1083 deletions
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DiceRollSessionStore } from './diceRollSessionStore';
import {
D100_FACE_VALUES,
formatPredeterminedDiceNotation,
snapD100Value,
} from '../../shared/types/diceRoll';
void test('diceRoll: roll produces values in range and notation', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 3, sides: 6 });
const st = store.getState();
assert.equal(st.active, true);
assert.equal(st.visibleToPlayers, true);
assert.equal(st.notation, '3d6');
assert.equal(st.values.length, 3);
assert.ok(st.values.every((v) => v >= 1 && v <= 6));
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
});
void test('diceRoll: roll without visibleToPlayers keeps master result only', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
store.dispatch({ kind: 'roll', count: 2, sides: 8 });
const st = store.getState();
assert.equal(st.visibleToPlayers, false);
assert.equal(st.active, false);
assert.equal(st.notation, '2d8');
assert.equal(st.values.length, 2);
assert.ok(st.values.every((v) => v >= 1 && v <= 8));
assert.equal(st.startedAt, null);
});
void test('diceRoll: unchecking visibleToPlayers hides overlay', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
assert.equal(store.getState().active, true);
const values = [...store.getState().values];
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
assert.equal(store.getState().active, false);
assert.equal(store.getState().visibleToPlayers, false);
assert.deepEqual(store.getState().values, values);
});
void test('diceRoll: d100 values are tens faces only', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 5, sides: 100 });
const st = store.getState();
assert.equal(st.notation, '5d100');
assert.equal(st.values.length, 5);
const allowed = new Set<number>(D100_FACE_VALUES);
assert.ok(st.values.every((v) => allowed.has(v)));
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
});
void test('diceRoll: clear resets but keeps visibleToPlayers', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
store.dispatch({ kind: 'clear' });
assert.equal(store.getState().active, false);
assert.deepEqual(store.getState().values, []);
assert.equal(store.getState().visibleToPlayers, false);
});
void test('diceRoll: hideOverlay keeps values for master UI', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 2, sides: 6 });
const values = [...store.getState().values];
const total = store.getState().total;
store.dispatch({ kind: 'hideOverlay' });
assert.equal(store.getState().active, false);
assert.deepEqual(store.getState().values, values);
assert.equal(store.getState().total, total);
assert.equal(store.getState().notation, '2d6');
assert.equal(store.getState().visibleToPlayers, true);
});
void test('diceRoll: roll with zero count is no-op', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
const before = store.getState().revision;
store.dispatch({ kind: 'roll', count: 0, sides: 6 });
assert.equal(store.getState().revision, before);
assert.deepEqual(store.getState().values, []);
});
void test('formatPredeterminedDiceNotation', () => {
assert.equal(formatPredeterminedDiceNotation(2, 20, [15, 7]), '2d20@15,7');
assert.equal(formatPredeterminedDiceNotation(2, 100, [20, 100]), '2d100@20,100');
assert.equal(formatPredeterminedDiceNotation(1, 100, [27]), '1d100@30');
});
void test('snapD100Value', () => {
assert.equal(snapD100Value(20), 20);
assert.equal(snapD100Value(21), 30);
assert.equal(snapD100Value(100), 100);
assert.equal(snapD100Value(1), 10);
});
+80
View File
@@ -0,0 +1,80 @@
import {
clampDiceCount,
createInitialDiceRollState,
formatDiceNotation,
isDiceSides,
rollDiceValues,
type DiceRollEvent,
type DiceRollState,
} from '../../shared/types/diceRoll';
export class DiceRollSessionStore {
private state: DiceRollState = createInitialDiceRollState();
getState(): DiceRollState {
return this.state;
}
clear(): DiceRollState {
this.state = createInitialDiceRollState();
return this.state;
}
dispatch(event: DiceRollEvent): DiceRollState {
switch (event.kind) {
case 'clear': {
if (!this.state.active && this.state.values.length === 0) return this.state;
this.state = {
...createInitialDiceRollState(),
revision: this.state.revision + 1,
visibleToPlayers: this.state.visibleToPlayers,
};
return this.state;
}
case 'setVisibleToPlayers': {
if (this.state.visibleToPlayers === event.visible) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
visibleToPlayers: event.visible,
...(event.visible === false && this.state.active
? { active: false, startedAt: null }
: {}),
};
return this.state;
}
case 'hideOverlay': {
if (!this.state.active) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
active: false,
startedAt: null,
};
return this.state;
}
case 'roll': {
if (!isDiceSides(event.sides)) return this.state;
const count = clampDiceCount(event.count);
if (count < 1) return this.state;
const values = rollDiceValues(count, event.sides);
const total = values.reduce((a, b) => a + b, 0);
const showToPlayers = this.state.visibleToPlayers;
this.state = {
revision: this.state.revision + 1,
active: showToPlayers,
visibleToPlayers: showToPlayers,
notation: formatDiceNotation(count, event.sides),
values,
total,
startedAt: showToPlayers ? Date.now() : null,
};
return this.state;
}
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
}
+76 -1
View File
@@ -1,7 +1,13 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser'; import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types'; import {
isPersistentFieldEffect,
type EffectInstance,
type EffectsEvent,
type EffectsState,
type EffectToolState,
} from '../../shared/types';
function nowMs(): number { function nowMs(): number {
return Date.now(); return Date.now();
@@ -23,6 +29,9 @@ export class EffectsStore {
tool: defaultTool(), tool: defaultTool(),
instances: [], instances: [],
}; };
/** Постоянные эффекты по карточке (graphNodeId или sceneId) на время показа. */
private cache = new Map<string, EffectInstance[]>();
private currentKey: string | null = null;
getState(): EffectsState { getState(): EffectsState {
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров. // Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
@@ -36,13 +45,70 @@ export class EffectsStore {
serverNowMs: nowMs(), serverNowMs: nowMs(),
instances: [], instances: [],
}; };
this.commitCurrent();
return this.state; return this.state;
} }
/** Новый показ: и текущая карточка, и кэш остальных. */
resetSession(): EffectsState {
this.cache.clear();
this.currentKey = null;
this.state = {
revision: this.state.revision + 1,
serverNowMs: nowMs(),
tool: this.state.tool,
instances: [],
};
return this.getState();
}
/**
* Смена карточки: туман/огонь/дождь/вода/лёд/пятно остаются на ушедшей
* и поднимаются на новой. Разовые эффекты действий сбрасываются.
*/
switchScene(cacheKey: string | null): EffectsState {
this.commitCurrent();
this.currentKey = cacheKey;
const instances = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
this.state = {
...this.state,
revision: this.state.revision + 1,
serverNowMs: nowMs(),
instances,
};
return this.getState();
}
exportByCard(): Record<string, EffectInstance[]> {
this.commitCurrent();
const out: Record<string, EffectInstance[]> = {};
for (const [key, instances] of this.cache) {
const kept = instances.filter((i) => isPersistentFieldEffect(i.type));
if (kept.length > 0) out[key] = kept.map((i) => ({ ...i }));
}
return out;
}
importByCard(byCard: Record<string, EffectInstance[]>): void {
this.cache.clear();
this.currentKey = null;
this.state = {
...this.state,
revision: this.state.revision + 1,
serverNowMs: nowMs(),
instances: [],
};
for (const [key, instances] of Object.entries(byCard)) {
const kept = instances.filter((i) => isPersistentFieldEffect(i.type));
if (kept.length > 0) this.cache.set(key, kept.map((i) => ({ ...i })));
}
}
dispatch(event: EffectsEvent): EffectsState { dispatch(event: EffectsEvent): EffectsState {
const s = this.state; const s = this.state;
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix)); const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
this.state = next; this.state = next;
this.commitCurrent();
return next; return next;
} }
@@ -78,9 +144,18 @@ export class EffectsStore {
serverNowMs: now, serverNowMs: now,
instances: kept, instances: kept,
}; };
this.commitCurrent();
return true; return true;
} }
private commitCurrent(): void {
if (this.currentKey === null) return;
this.cache.set(
this.currentKey,
this.state.instances.filter((i) => isPersistentFieldEffect(i.type)).map((i) => ({ ...i })),
);
}
makeId(prefix: string): string { makeId(prefix: string): string {
return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`; return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`;
} }
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { EffectsStore } from './effectsStore';
import { GameSavesStore } from '../gameSave/gameSavesStore';
import { canonicalGameSave, emptyGameSaveBody, freezeTokenPathPlayback } from '../../shared/gameSave/buildSnapshot';
import type { EffectInstance } from '../../shared/types';
import type { TokenPath } from '../../shared/types/tokenPath';
import type { TokenPathPlaybackEntry } from '../../shared/types/tokenPathSession';
function fog(): EffectInstance {
return {
id: 'fog1',
type: 'fog',
seed: 1,
createdAtMs: 1,
points: [{ x: 0.2, y: 0.2, tMs: 1 }],
radiusN: 0.1,
opacity: 0.4,
lifetimeMs: null,
};
}
function bolt(): EffectInstance {
return {
id: 'lt1',
type: 'lightning',
seed: 2,
createdAtMs: 1,
start: { x: 0, y: 0 },
end: { x: 1, y: 1 },
widthN: 0.02,
intensity: 1,
lifetimeMs: 800,
};
}
void test('effects stay on the card and action effects do not', () => {
const store = new EffectsStore();
store.switchScene('card-a');
store.dispatch({ kind: 'instance.add', instance: fog() });
store.dispatch({ kind: 'instance.add', instance: bolt() });
store.switchScene('card-b');
assert.equal(store.getState().instances.length, 0);
store.switchScene('card-a');
assert.deepEqual(
store.getState().instances.map((i) => i.type),
['fog'],
);
store.dispatch({ kind: 'instances.clear' });
store.switchScene('card-b');
store.switchScene('card-a');
assert.equal(store.getState().instances.length, 0);
});
void test('freezeTokenPathPlayback parks a moving token', () => {
const pathLine: TokenPath = {
points: [
{ nx: 0, ny: 0.5 },
{ nx: 1, ny: 0.5 },
],
closed: false,
loopMode: 'once',
durationSec: 2,
startMode: 'onEnter',
delaySec: 0,
facingMode: 'tangentSmooth',
fixedRotationDeg: 0,
};
const entry: TokenPathPlaybackEntry = {
kind: 'token',
placementId: 't1',
phase: 'moving',
segmentStartedAtMs: 0,
baseDist: 0,
direction: 1,
rejoinDist: null,
durationSec: 2,
pathLength: 1,
};
const frozen = freezeTokenPathPlayback({ 'token:t1': entry }, () => pathLine, 1000);
const next = frozen['token:t1'];
assert.ok(next);
assert.equal(next.phase, 'stopped');
assert.ok(next.baseDist > 0.4 && next.baseDist < 0.6);
});
void test('game save file keeps id on overwrite and lists one campaign', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-saves-'));
const store = new GameSavesStore(root);
const body = emptyGameSaveBody('p_one');
const first = await store.write('p_one', body, { id: null, name: 'Вечер' });
const again = await store.write('p_one', body, { id: first.id, name: 'Вечер 2' });
assert.equal(again.id, first.id);
assert.equal(again.name, 'Вечер 2');
const list = await store.list('p_one');
assert.equal(list.length, 1);
assert.equal(list[0]?.name, 'Вечер 2');
const other = emptyGameSaveBody('p_two');
await store.write('p_two', other, { id: null, name: 'Другая' });
assert.equal((await store.list('p_one')).length, 1);
const loaded = await store.read('p_one', first.id);
const { id: _id, name: _name, savedAt: _savedAt, ...rest } = loaded;
assert.equal(canonicalGameSave(rest), canonicalGameSave(body));
});
@@ -67,6 +67,66 @@ void test('SceneDarknessStore: draft синхронизируется и сбр
assert.equal(store.getState().strokes.length, 1); assert.equal(store.getState().strokes.length, 1);
}); });
void test('SceneDarknessStore: cache miss берёт seedStrokes из сцены', () => {
const store = new SceneDarknessStore();
const seed = [
{
id: 'base1',
seed: 7,
createdAtMs: 50,
points: [{ x: 0.3, y: 0.4, tMs: 50 }],
radiusN: 0.06,
mode: 'reveal' as const,
},
];
store.switchScene('card_a', true, seed);
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 'base1');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'session1',
seed: 2,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
mode: 'cover',
},
});
store.switchScene('card_b', true, []);
store.switchScene('card_a', true, seed);
assert.equal(store.getState().strokes.length, 2);
assert.equal(store.getState().strokes[1]?.id, 'session1');
});
void test('SceneDarknessStore: resetSession снова берёт seed', () => {
const store = new SceneDarknessStore();
const seed = [
{
id: 'base1',
seed: 1,
createdAtMs: 10,
points: [{ x: 0.1, y: 0.1, tMs: 10 }],
radiusN: 0.05,
},
];
store.switchScene('card_a', true, seed);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 2,
createdAtMs: 20,
points: [{ x: 0.2, y: 0.2, tMs: 20 }],
radiusN: 0.05,
},
});
store.resetSession();
store.switchScene('card_a', true, seed);
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 'base1');
});
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => { void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
const store = new SceneDarknessStore(); const store = new SceneDarknessStore();
store.switchScene('scene_a', true); store.switchScene('scene_a', true);
+44 -4
View File
@@ -29,14 +29,26 @@ export class SceneDarknessStore {
/** /**
* Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены. * Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены.
* Если ключа ещё не было в сессии — берём seed (база из файла партии).
*/ */
switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState { switchScene(
cacheKey: string | null,
enabled: boolean,
seedStrokes: readonly SceneDarknessRevealStroke[] = [],
): SceneDarknessState {
if (this.currentKey !== null) { if (this.currentKey !== null) {
this.cache.set(this.currentKey, [...this.state.strokes]); this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
} }
this.currentKey = cacheKey; this.currentKey = cacheKey;
const strokes = let strokes: SceneDarknessRevealStroke[] = [];
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : []; if (cacheKey && enabled) {
if (this.cache.has(cacheKey)) {
strokes = (this.cache.get(cacheKey) ?? []).map(cloneStroke);
} else {
strokes = seedStrokes.map(cloneStroke);
this.cache.set(cacheKey, strokes.map(cloneStroke));
}
}
this.state = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
enabled, enabled,
@@ -77,4 +89,32 @@ export class SceneDarknessStore {
} }
} }
} }
/** Кэш по карточкам, включая текущую. Пустые карточки не входят. */
exportByCard(): Record<string, SceneDarknessRevealStroke[]> {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
}
const out: Record<string, SceneDarknessRevealStroke[]> = {};
for (const [key, strokes] of this.cache) {
if (strokes.length === 0) continue;
out[key] = strokes.map(cloneStroke);
}
return out;
}
/** Кладёт кэш, не переключая карточку. Дальше вызывают switchScene. */
importByCard(byCard: Record<string, SceneDarknessRevealStroke[]>): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
for (const [key, strokes] of Object.entries(byCard)) {
if (!Array.isArray(strokes) || strokes.length === 0) continue;
this.cache.set(key, strokes.map(cloneStroke));
}
}
}
function cloneStroke(stroke: SceneDarknessRevealStroke): SceneDarknessRevealStroke {
return { ...stroke, points: stroke.points.map((p) => ({ ...p })) };
} }
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneMarkerStore } from './sceneMarkerStore';
void test('SceneMarkerStore: сохраняет штрихи при переключении сцен', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
color: '#ff0000',
},
});
store.switchScene('scene_b');
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 'm1');
});
void test('SceneMarkerStore: resetSession очищает кэш', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.2, y: 0.2, tMs: 100 }],
radiusN: 0.08,
color: '#00ff00',
},
});
store.resetSession();
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 0);
});
void test('SceneMarkerStore: strokes.erase удаляет точки под ластиком', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [
{ x: 0.1, y: 0.5, tMs: 100 },
{ x: 0.5, y: 0.5, tMs: 101 },
{ x: 0.9, y: 0.5, tMs: 102 },
],
radiusN: 0.04,
color: '#ff0000',
},
});
store.dispatch({
kind: 'strokes.erase',
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
radiusN: 0.06,
});
const strokes = store.getState().strokes;
assert.ok(strokes.length >= 1);
assert.ok(strokes.every((s) => s.points.every((p) => Math.abs(p.x - 0.5) > 0.05)));
});
void test('SceneMarkerStore: strokes.clear удаляет все штрихи', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
color: '#0000ff',
},
});
store.dispatch({ kind: 'strokes.clear' });
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_b');
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 0);
});
+134
View File
@@ -0,0 +1,134 @@
import { applySceneMarkerEraserStroke } from '../../shared/sceneMarkerEraser';
import type { SceneMarkerEvent, SceneMarkerState, SceneMarkerStroke } from '../../shared/types';
function emptyState(): SceneMarkerState {
return {
revision: 1,
cacheKey: null,
strokes: [],
draft: null,
};
}
function makeStrokeId(prefix: string): string {
return `${prefix}_${String(Date.now())}_${String(Math.floor(Math.random() * 1_000_000_000))}`;
}
export class SceneMarkerStore {
private state: SceneMarkerState = emptyState();
/** Кэш штрихов маркера по ключу (graphNodeId или sceneId) на время сессии показа. */
private cache = new Map<string, SceneMarkerStroke[]>();
private currentKey: string | null = null;
getState(): SceneMarkerState {
return { ...this.state, strokes: [...this.state.strokes] };
}
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
resetSession(): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
}
/** Переключение сцены: сохраняем штрихи в кэш и загружаем новую сцену. */
switchScene(cacheKey: string | null): SceneMarkerState {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, [...this.state.strokes]);
}
this.currentKey = cacheKey;
const strokes = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
this.state = {
revision: this.state.revision + 1,
cacheKey,
strokes,
draft: null,
};
return this.getState();
}
dispatch(event: SceneMarkerEvent): SceneMarkerState {
switch (event.kind) {
case 'draft.set':
this.state = {
...this.state,
revision: this.state.revision + 1,
draft: event.draft,
};
return this.getState();
case 'stroke.add': {
const strokes = [...this.state.strokes, event.stroke];
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
case 'strokes.erase': {
const strokes = applySceneMarkerEraserStroke(
this.state.strokes,
event.points,
event.radiusN,
(prefix) => makeStrokeId(prefix),
);
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
case 'strokes.clear':
if (this.currentKey) {
this.cache.set(this.currentKey, []);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes: [],
draft: null,
};
return this.getState();
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return this.getState();
}
}
}
exportByCard(): Record<string, SceneMarkerStroke[]> {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
}
const out: Record<string, SceneMarkerStroke[]> = {};
for (const [key, strokes] of this.cache) {
if (strokes.length === 0) continue;
out[key] = strokes.map(cloneStroke);
}
return out;
}
importByCard(byCard: Record<string, SceneMarkerStroke[]>): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
for (const [key, strokes] of Object.entries(byCard)) {
if (!Array.isArray(strokes) || strokes.length === 0) continue;
this.cache.set(key, strokes.map(cloneStroke));
}
}
}
function cloneStroke(stroke: SceneMarkerStroke): SceneMarkerStroke {
return { ...stroke, points: stroke.points.map((p) => ({ ...p })) };
}
+27 -5
View File
@@ -279,10 +279,17 @@ function collectSceneAudioPaths(
return paths; return paths;
} }
export type FoundryPendingAppPlaylist = {
name: string;
tracks: { title: string; absPath: string; mime: string }[];
};
export type FoundryBuiltProject = { export type FoundryBuiltProject = {
project: Project; project: Project;
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */ /** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
previewSources: { sceneId: SceneId; absPath: string }[]; previewSources: { sceneId: SceneId; absPath: string }[];
/** Неиспользуемые на сценах Foundry-плейлисты → локальная библиотека. */
pendingAppPlaylists: FoundryPendingAppPlaylist[];
}; };
export async function buildProjectFromFoundryDocuments( export async function buildProjectFromFoundryDocuments(
@@ -372,7 +379,7 @@ export async function buildProjectFromFoundryDocuments(
const assetId = await resolveAndImport(ctx, manifest, audioPath); const assetId = await resolveAndImport(ctx, manifest, audioPath);
if (!assetId) continue; if (!assetId) continue;
if (ctx.assets[assetId]?.type !== 'audio') continue; if (ctx.assets[assetId]?.type !== 'audio') continue;
audioRefs.push({ assetId, autoplay: true, loop: true }); audioRefs.push({ assetId, autoplay: true, loop: true, volume: 1 });
} }
scenes[sceneId] = { scenes[sceneId] = {
@@ -385,6 +392,7 @@ export async function buildProjectFromFoundryDocuments(
previewVideoAutostart: previewAssetType === 'video', previewVideoAutostart: previewAssetType === 'video',
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
@@ -406,15 +414,28 @@ export async function buildProjectFromFoundryDocuments(
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'), importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
); );
const campaignAudios: Project['campaignAudios'] = []; const campaignAudios: Project['campaignAudios'] = [];
const pendingAppPlaylists: FoundryPendingAppPlaylist[] = [];
for (const pl of docs.playlists) { for (const pl of docs.playlists) {
if (usedPlaylistIds.has(pl._id)) continue; if (usedPlaylistIds.has(pl._id)) continue;
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)); const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
const tracks: FoundryPendingAppPlaylist['tracks'] = [];
for (const s of sounds) { for (const s of sounds) {
if (typeof s.path !== 'string' || !s.path.trim()) continue; if (typeof s.path !== 'string' || !s.path.trim()) continue;
const assetId = await resolveAndImport(ctx, manifest, s.path); const abs = await resolveFoundryAssetPath(manifest, s.path);
if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue; if (!abs) continue;
campaignAudios.push({ assetId, autoplay: false, loop: true }); const kind = classifyMediaPath(abs);
if (!kind || kind.type !== 'audio') continue;
const title =
(typeof s.name === 'string' && s.name.trim()) ||
path.basename(s.path).replace(/\.[^.]+$/u, '') ||
'Track';
tracks.push({ title, absPath: abs, mime: kind.mime });
} }
if (tracks.length === 0) continue;
pendingAppPlaylists.push({
name: (typeof pl.name === 'string' && pl.name.trim()) || 'Playlist',
tracks,
});
} }
const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders); const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders);
@@ -529,6 +550,7 @@ export async function buildProjectFromFoundryDocuments(
assets: ctx.assets, assets: ctx.assets,
campaignAudios, campaignAudios,
materials: [], materials: [],
gridTemplates: [],
npcs, npcs,
npcGroups, npcGroups,
npcRelations: [], npcRelations: [],
@@ -538,7 +560,7 @@ export async function buildProjectFromFoundryDocuments(
sceneGraphEdges, sceneGraphEdges,
}; };
return { project, previewSources }; return { project, previewSources, pendingAppPlaylists };
} }
/** Actor folders Foundry → дерево групп НПС. */ /** Actor folders Foundry → дерево групп НПС. */
+84
View File
@@ -0,0 +1,84 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { defaultGameSaveName } from '../../shared/gameSave/buildSnapshot';
import { GAME_SAVE_SCHEMA_VERSION, type GameSaveBody, type GameSaveFile, type GameSaveListItem } from '../../shared/types/gameSave';
function safeSegment(raw: string): string {
const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, '');
if (!cleaned || cleaned === '.' || cleaned === '..') throw new Error('GAME_SAVE_BAD_ID');
return cleaned;
}
export class GameSavesStore {
private readonly rootDir: string;
constructor(userData: string) {
this.rootDir = path.join(userData, 'game-saves');
}
async list(projectId: string): Promise<GameSaveListItem[]> {
const dir = this.projectDir(projectId);
let names: string[] = [];
try {
names = await fs.readdir(dir);
} catch {
return [];
}
const items: GameSaveListItem[] = [];
for (const name of names) {
if (!name.endsWith('.json')) continue;
try {
const file = await this.readFile(path.join(dir, name));
if (file.projectId !== projectId) continue;
items.push({ id: file.id, name: file.name, savedAt: file.savedAt });
} catch {
continue;
}
}
items.sort((a, b) => (a.savedAt < b.savedAt ? 1 : a.savedAt > b.savedAt ? -1 : 0));
return items;
}
async read(projectId: string, id: string): Promise<GameSaveFile> {
const filePath = this.filePath(projectId, id);
try {
const file = await this.readFile(filePath);
if (file.projectId !== projectId || file.id !== id) throw new Error('GAME_SAVE_NOT_FOUND');
return file;
} catch (err) {
if (err instanceof Error && err.message.startsWith('GAME_SAVE_')) throw err;
throw new Error('GAME_SAVE_NOT_FOUND');
}
}
async write(projectId: string, body: GameSaveBody, opts: { id: string | null; name: string }): Promise<GameSaveListItem> {
if (body.projectId !== projectId) throw new Error('GAME_SAVE_PROJECT_MISMATCH');
const id = opts.id ? safeSegment(opts.id) : `gs_${crypto.randomBytes(8).toString('hex')}`;
const name = opts.name.trim() || defaultGameSaveName();
const savedAt = new Date().toISOString();
const file: GameSaveFile = { ...body, id, name, savedAt, schemaVersion: GAME_SAVE_SCHEMA_VERSION };
const filePath = this.filePath(projectId, id);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(file), 'utf8');
return { id, name, savedAt };
}
private projectDir(projectId: string): string {
return path.join(this.rootDir, safeSegment(projectId));
}
private filePath(projectId: string, id: string): string {
return path.join(this.projectDir(projectId), `${safeSegment(id)}.json`);
}
private async readFile(filePath: string): Promise<GameSaveFile> {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as GameSaveFile;
if (!parsed || parsed.schemaVersion !== GAME_SAVE_SCHEMA_VERSION || typeof parsed.id !== 'string') {
throw new Error('GAME_SAVE_NOT_FOUND');
}
return parsed;
}
}
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { GameSystemsStore } from './gameSystemsStore';
import type { SystemDefinition } from '../../shared/characterSheet/types';
import { FORMAT_VERSION, SYSTEM_FORMAT } from '../../shared/characterSheet/types';
function sampleSystem(): SystemDefinition {
return {
format: SYSTEM_FORMAT,
formatVersion: FORMAT_VERSION,
id: 'shadowdark',
name: 'Shadowdark',
schemaVersion: 1,
revision: 1,
bricks: { attributes: [{ key: 'str', label: 'STR', scale: 'numeric', default: 10 }] },
creationRules: [],
sheetLayout: { pageSize: 'fluid', grid: { size: 8 }, pages: [{ id: 'main', label: 'Main', widgets: [] }] },
};
}
test('GameSystemsStore import list delete', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gs-'));
const store = new GameSystemsStore(dir);
await store.importSystem(sampleSystem());
const list = await store.list();
assert.equal(list.length, 1);
assert.equal(list[0]!.id, 'shadowdark');
const bind = await store.bindSnapshot('shadowdark');
assert.ok(bind.snapshotHash.length === 64);
const snap = await store.getSnapshot(bind.snapshotHash);
assert.equal(snap?.id, 'shadowdark');
await store.delete('shadowdark');
assert.equal((await store.list()).length, 0);
});
+129
View File
@@ -0,0 +1,129 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import type { GameSystemListItem } from '../../shared/ipc/contracts';
import type { SystemDefinition } from '../../shared/characterSheet/types';
import { validateSystemDefinition } from '../../shared/characterSheet/systemValidate';
import { snapshotHash } from '../../shared/characterSheet/snapshot';
export type { GameSystemListItem };
type Manifest = {
systems: GameSystemListItem[];
};
export class GameSystemsStore {
private readonly rootDir: string;
private readonly manifestPath: string;
private readonly snapshotsDir: string;
private manifest: Manifest = { systems: [] };
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'game-systems');
this.manifestPath = path.join(this.rootDir, 'manifest.json');
this.snapshotsDir = path.join(this.rootDir, 'snapshots');
}
async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await fs.mkdir(this.snapshotsDir, { recursive: true });
try {
const raw = await fs.readFile(this.manifestPath, 'utf8');
this.manifest = JSON.parse(raw) as Manifest;
if (!Array.isArray(this.manifest.systems)) this.manifest.systems = [];
} catch {
this.manifest = { systems: [] };
}
this.loaded = true;
}
async list(): Promise<GameSystemListItem[]> {
await this.ensureLoaded();
return [...this.manifest.systems];
}
private systemPath(id: string): string {
return path.join(this.rootDir, id, 'system.json');
}
async get(id: string): Promise<SystemDefinition | null> {
await this.ensureLoaded();
try {
const raw = await fs.readFile(this.systemPath(id), 'utf8');
return JSON.parse(raw) as SystemDefinition;
} catch {
return null;
}
}
async importSystem(system: SystemDefinition): Promise<GameSystemListItem> {
await this.ensureLoaded();
const check = validateSystemDefinition(system);
if (!check.ok) throw new Error(check.errors.map((e) => e.message).join('; '));
const dir = path.join(this.rootDir, system.id);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(this.systemPath(system.id), JSON.stringify(system, null, 2), 'utf8');
const hash = snapshotHash(system);
await fs.writeFile(path.join(this.snapshotsDir, `${hash}.json`), JSON.stringify(system), 'utf8');
const item: GameSystemListItem = {
id: system.id,
name: system.name,
edition: system.edition,
schemaVersion: system.schemaVersion,
revision: system.revision,
};
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== system.id).concat(item);
await this.saveManifest();
return item;
}
async delete(id: string): Promise<string[]> {
await this.ensureLoaded();
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== id);
await this.saveManifest();
try {
await fs.rm(path.join(this.rootDir, id), { recursive: true, force: true });
} catch {
// ignore
}
// Caller clears player bindings; return id for convenience
return [id];
}
async getSnapshot(hash: string): Promise<SystemDefinition | null> {
try {
const raw = await fs.readFile(path.join(this.snapshotsDir, `${hash}.json`), 'utf8');
return JSON.parse(raw) as SystemDefinition;
} catch {
return null;
}
}
async bindSnapshot(systemId: string): Promise<{ systemId: string; snapshotHash: string; system: SystemDefinition }> {
const system = await this.get(systemId);
if (!system) throw new Error(`System not found: ${systemId}`);
const hash = snapshotHash(system);
const snapPath = path.join(this.snapshotsDir, `${hash}.json`);
try {
await fs.access(snapPath);
} catch {
await fs.writeFile(snapPath, JSON.stringify(system), 'utf8');
}
return { systemId, snapshotHash: hash, system };
}
private async saveManifest(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
await fs.writeFile(this.manifestPath, JSON.stringify(this.manifest, null, 2), 'utf8');
}
}
/** Test helper — content hash of arbitrary buffer. */
export function sha256Hex(data: string): string {
return createHash('sha256').update(data).digest('hex');
}
+757 -27
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { InitiativeSessionStore } from './initiativeSessionStore';
import {
initiativeNpcEntryId,
initiativePlayerEntryId,
} from '../../shared/types/initiative';
void test('initiative: addManual + next cycles order', () => {
const store = new InitiativeSessionStore();
store.dispatch({ kind: 'addManual', name: 'A' });
store.dispatch({ kind: 'addManual', name: 'B' });
const a = store.getState().order[0]!;
const b = store.getState().order[1]!;
assert.equal(store.getState().activeId, a);
store.dispatch({ kind: 'next' });
assert.equal(store.getState().activeId, b);
store.dispatch({ kind: 'next' });
assert.equal(store.getState().activeId, a);
});
void test('initiative: syncPresence adds npcs and players', () => {
const store = new InitiativeSessionStore();
store.dispatch({
kind: 'syncPresence',
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: true,
});
const st = store.getState();
assert.ok(st.entries[initiativeNpcEntryId('p1')]);
assert.ok(st.entries[initiativePlayerEntryId('pl1')]);
assert.deepEqual(st.order, [initiativeNpcEntryId('p1'), initiativePlayerEntryId('pl1')]);
});
void test('initiative: hide players moves them to reserve', () => {
const store = new InitiativeSessionStore();
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: true,
});
const pid = initiativePlayerEntryId('pl1');
assert.ok(store.getState().order.includes(pid));
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: false,
});
assert.ok(!store.getState().order.includes(pid));
assert.ok(store.getState().reserve.includes(pid));
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: true,
});
assert.ok(store.getState().reserve.includes(pid));
assert.ok(!store.getState().order.includes(pid));
});
void test('initiative: conditions unique + reserve/restore', () => {
const store = new InitiativeSessionStore();
store.dispatch({ kind: 'addManual', name: 'X' });
const id = store.getState().order[0]!;
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
store.dispatch({ kind: 'moveToReserve', id });
assert.deepEqual(store.getState().order, []);
assert.deepEqual(store.getState().reserve, [id]);
store.dispatch({ kind: 'restoreFromReserve', id });
assert.deepEqual(store.getState().order, [id]);
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
});
void test('initiative: clear keeps enabled', () => {
const store = new InitiativeSessionStore();
store.dispatch({ kind: 'setEnabled', enabled: true });
store.dispatch({ kind: 'addManual', name: 'Z' });
store.clear();
assert.equal(store.getState().enabled, true);
assert.deepEqual(store.getState().order, []);
});
void test('initiative: despawn removes npc entry', () => {
const store = new InitiativeSessionStore();
store.dispatch({
kind: 'syncPresence',
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
players: [],
playersVisible: false,
});
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [],
playersVisible: false,
});
assert.deepEqual(store.getState().order, []);
assert.deepEqual(Object.keys(store.getState().entries), []);
});
@@ -0,0 +1,330 @@
import {
createInitialInitiativeState,
initiativeManualLetter,
initiativeNpcEntryId,
initiativePlayerEntryId,
isInitiativeConditionId,
newInitiativeManualId,
pickInitiativeManualGradient,
type InitiativeEntry,
type InitiativeEvent,
type InitiativePresenceNpc,
type InitiativePresencePlayer,
type InitiativeState,
} from '../../shared/types/initiative';
function withoutId(ids: readonly string[], id: string): string[] {
return ids.filter((x) => x !== id);
}
function ensureActiveValid(state: InitiativeState): InitiativeState {
if (state.activeId != null && state.order.includes(state.activeId)) return state;
const nextActive = state.order[0] ?? null;
if (state.activeId === nextActive) return state;
return { ...state, activeId: nextActive };
}
export class InitiativeSessionStore {
private state: InitiativeState = createInitialInitiativeState();
getState(): InitiativeState {
return this.state;
}
clear(): InitiativeState {
const enabled = this.state.enabled;
this.state = { ...createInitialInitiativeState(), enabled, revision: this.state.revision + 1 };
return this.state;
}
dispatch(event: InitiativeEvent): InitiativeState {
switch (event.kind) {
case 'clear': {
this.state = {
...createInitialInitiativeState(),
enabled: this.state.enabled,
revision: this.state.revision + 1,
};
return this.state;
}
case 'setEnabled': {
if (this.state.enabled === event.enabled) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
enabled: event.enabled,
};
return this.state;
}
case 'syncPresence': {
return this.syncPresence(event.npcs, event.players, event.playersVisible);
}
case 'reorder': {
const allowed = new Set(this.state.order);
const nextOrder = event.order.filter((id) => allowed.has(id));
for (const id of this.state.order) {
if (!nextOrder.includes(id)) nextOrder.push(id);
}
if (nextOrder.length === this.state.order.length && nextOrder.every((id, i) => id === this.state.order[i])) {
return this.state;
}
this.state = ensureActiveValid({
...this.state,
revision: this.state.revision + 1,
order: nextOrder,
});
return this.state;
}
case 'setActive': {
if (event.id != null && !this.state.order.includes(event.id)) return this.state;
if (this.state.activeId === event.id) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
activeId: event.id,
};
return this.state;
}
case 'next': {
if (this.state.order.length === 0) {
if (this.state.activeId == null) return this.state;
this.state = { ...this.state, revision: this.state.revision + 1, activeId: null };
return this.state;
}
const cur = this.state.activeId ? this.state.order.indexOf(this.state.activeId) : -1;
const nextIdx = cur < 0 ? 0 : (cur + 1) % this.state.order.length;
const nextId = this.state.order[nextIdx] ?? null;
if (nextId === this.state.activeId) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
activeId: nextId,
};
return this.state;
}
case 'addManual': {
const name = event.name.trim();
if (!name) return this.state;
const id = newInitiativeManualId();
const entry: InitiativeEntry = {
id,
kind: 'manual',
name,
letter: initiativeManualLetter(name),
gradient: pickInitiativeManualGradient(),
};
this.state = {
...this.state,
revision: this.state.revision + 1,
entries: { ...this.state.entries, [id]: entry },
order: [...this.state.order, id],
activeId: this.state.activeId ?? id,
};
return this.state;
}
case 'moveToReserve': {
if (!this.state.order.includes(event.id)) return this.state;
if (!this.state.entries[event.id]) return this.state;
const order = withoutId(this.state.order, event.id);
const reserve = this.state.reserve.includes(event.id)
? this.state.reserve
: [...this.state.reserve, event.id];
this.state = ensureActiveValid({
...this.state,
revision: this.state.revision + 1,
order,
reserve,
});
return this.state;
}
case 'restoreFromReserve': {
if (!this.state.reserve.includes(event.id)) return this.state;
if (!this.state.entries[event.id]) return this.state;
const reserve = withoutId(this.state.reserve, event.id);
const order = this.state.order.includes(event.id)
? this.state.order
: [...this.state.order, event.id];
this.state = {
...this.state,
revision: this.state.revision + 1,
order,
reserve,
activeId: this.state.activeId ?? event.id,
};
return this.state;
}
case 'deleteEntry': {
if (!this.state.entries[event.id]) return this.state;
const { [event.id]: _removed, ...entries } = this.state.entries;
const { [event.id]: _c, ...conditions } = this.state.conditions;
this.state = ensureActiveValid({
...this.state,
revision: this.state.revision + 1,
entries,
conditions,
order: withoutId(this.state.order, event.id),
reserve: withoutId(this.state.reserve, event.id),
});
return this.state;
}
case 'addCondition': {
if (!this.state.entries[event.id]) return this.state;
if (!isInitiativeConditionId(event.conditionId)) return this.state;
const cur = this.state.conditions[event.id] ?? [];
if (cur.includes(event.conditionId)) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
conditions: {
...this.state.conditions,
[event.id]: [...cur, event.conditionId],
},
};
return this.state;
}
case 'removeCondition': {
const cur = this.state.conditions[event.id];
if (!cur?.includes(event.conditionId)) return this.state;
const next = cur.filter((c) => c !== event.conditionId);
const conditions = { ...this.state.conditions };
if (next.length === 0) delete conditions[event.id];
else conditions[event.id] = next;
this.state = {
...this.state,
revision: this.state.revision + 1,
conditions,
};
return this.state;
}
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
private syncPresence(
npcs: readonly InitiativePresenceNpc[],
players: readonly InitiativePresencePlayer[],
playersVisible: boolean,
): InitiativeState {
const npcByPlacement = new Map(npcs.map((n) => [n.placementId, n]));
const playerById = new Map(players.map((p) => [p.playerId, p]));
let entries: Record<string, InitiativeEntry> = { ...this.state.entries };
let order = [...this.state.order];
let reserve = [...this.state.reserve];
let conditions = { ...this.state.conditions };
let changed = false;
for (const [id, entry] of Object.entries(entries)) {
if (entry.kind === 'manual') continue;
if (entry.kind === 'npc') {
const live = npcByPlacement.get(entry.placementId);
if (!live) {
delete entries[id];
delete conditions[id];
order = withoutId(order, id);
reserve = withoutId(reserve, id);
changed = true;
continue;
}
if (entry.name !== live.name || entry.avatarAssetId !== live.avatarAssetId || entry.npcId !== live.npcId) {
entries[id] = {
...entry,
npcId: live.npcId,
name: live.name,
avatarAssetId: live.avatarAssetId,
};
changed = true;
}
continue;
}
const live = playerById.get(entry.playerId);
if (!live) {
delete entries[id];
delete conditions[id];
order = withoutId(order, id);
reserve = withoutId(reserve, id);
changed = true;
continue;
}
if (entry.name !== live.name) {
entries[id] = { ...entry, name: live.name };
changed = true;
}
}
for (const npc of npcs) {
const id = initiativeNpcEntryId(npc.placementId);
if (entries[id]) continue;
entries[id] = {
id,
kind: 'npc',
placementId: npc.placementId,
npcId: npc.npcId,
name: npc.name,
avatarAssetId: npc.avatarAssetId,
};
order.push(id);
changed = true;
}
for (const player of players) {
const id = initiativePlayerEntryId(player.playerId);
if (entries[id]) continue;
entries[id] = {
id,
kind: 'player',
playerId: player.playerId,
name: player.name,
};
order.push(id);
changed = true;
}
const wasVisible = this.state.playersVisible;
if (wasVisible && !playersVisible) {
const playerIdsInOrder = order.filter((id) => entries[id]?.kind === 'player');
if (playerIdsInOrder.length > 0) {
for (const id of playerIdsInOrder) {
order = withoutId(order, id);
if (!reserve.includes(id)) reserve.push(id);
}
changed = true;
}
}
if (this.state.playersVisible !== playersVisible) changed = true;
if (!changed) {
// Still update playersVisible if somehow same structure
if (this.state.playersVisible === playersVisible) return this.state;
}
let next: InitiativeState = {
...this.state,
revision: this.state.revision + 1,
entries,
order,
reserve,
conditions,
playersVisible,
};
next = ensureActiveValid(next);
this.state = next;
return this.state;
}
load(next: Omit<InitiativeState, 'revision'>): InitiativeState {
this.state = ensureActiveValid({
...next,
revision: this.state.revision + 1,
entries: { ...next.entries },
order: [...next.order],
reserve: [...next.reserve],
conditions: { ...next.conditions },
});
return this.state;
}
}
+1
View File
@@ -18,6 +18,7 @@ function channelRequiresLicense(channel: string): boolean {
if (channel.startsWith('license.')) return false; if (channel.startsWith('license.')) return false;
if (channel.startsWith('app.')) return false; if (channel.startsWith('app.')) return false;
if (channel === ipcChannels.windows.closeMultiWindow) return false; if (channel === ipcChannels.windows.closeMultiWindow) return false;
if (channel === ipcChannels.windows.requestCloseMultiWindowConfirm) return false;
if (channel === ipcChannels.windows.closeSceneDescription) return false; if (channel === ipcChannels.windows.closeSceneDescription) return false;
if (channel === ipcChannels.windows.closeMaterials) return false; if (channel === ipcChannels.windows.closeMaterials) return false;
if (channel === ipcChannels.windows.closeNpcs) return false; if (channel === ipcChannels.windows.closeNpcs) return false;
@@ -225,4 +225,16 @@ export class MaterialsOverlayStore {
}; };
return this.state; return this.state;
} }
load(next: Omit<MaterialsOverlayState, 'revision' | 'zoomTool'>): MaterialsOverlayState {
this.state = {
revision: this.state.revision + 1,
activeMaterialIds: [...next.activeMaterialIds],
layouts: { ...next.layouts },
legendLayouts: { ...next.legendLayouts },
focusMaterialId: next.focusMaterialId,
zoomTool: null,
};
return this.state;
}
} }
+13
View File
@@ -256,4 +256,17 @@ export class NpcsOverlayStore {
}; };
return this.state; return this.state;
} }
load(next: Pick<NpcsOverlayState, 'activeNpcIds' | 'layouts' | 'focusNpcId'>): NpcsOverlayState {
this.state = {
revision: this.state.revision + 1,
activeNpcIds: [...next.activeNpcIds],
layouts: { ...next.layouts },
focusNpcId: next.focusNpcId,
zoomTool: null,
inspectNpcIds: [],
inspectFocusNpcId: null,
};
return this.state;
}
} }
+16
View File
@@ -188,6 +188,8 @@ export class PlayersStore {
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE, input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE,
), ),
}; };
if (existing?.systemId !== undefined) player.systemId = existing.systemId;
if (existing?.characterSheet !== undefined) player.characterSheet = existing.characterSheet;
if (existing) { if (existing) {
this.players = this.players.map((p) => (p.id === player.id ? player : p)); this.players = this.players.map((p) => (p.id === player.id ? player : p));
@@ -287,6 +289,20 @@ export class PlayersStore {
return this.getById(playerId); return this.getById(playerId);
} }
/** Clears systemId / characterSheet on players bound to a removed game system. */
async clearSystemBindings(systemId: string): Promise<number> {
await this.ensureLoaded();
let changed = 0;
this.players = this.players.map((p) => {
const hit = p.systemId === systemId || p.characterSheet?.systemId === systemId;
if (!hit) return p;
changed += 1;
return { ...p, systemId: null, characterSheet: null };
});
if (changed > 0) await this.persist();
return changed;
}
private async persist(): Promise<void> { private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true }); await fs.mkdir(this.rootDir, { recursive: true });
const payload: PlayersManifest = { players: this.players, teams: this.teams }; const payload: PlayersManifest = { players: this.players, teams: this.teams };
@@ -82,6 +82,22 @@ void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
assert.equal(s3.byPlacementId.a?.inactive, undefined); assert.equal(s3.byPlacementId.a?.inactive, undefined);
}); });
void test('SceneNpcTokensSessionStore hide from players keeps the token for the GM', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: true });
assert.equal(s1.byPlacementId.a?.hiddenFromPlayers, true);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.4 });
assert.equal(s2.byPlacementId.a?.hiddenFromPlayers, true);
assert.equal(s2.byPlacementId.a?.nx, 0.2);
const s3 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: false });
assert.equal(s3.byPlacementId.a?.hiddenFromPlayers, undefined);
assert.equal(s3.byPlacementId.a?.nx, 0.2);
const only = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: true });
store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: false });
assert.equal(store.getState().byPlacementId.b, undefined);
assert.ok(only.byPlacementId.b?.hiddenFromPlayers);
});
void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => { void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
const store = new SceneNpcTokensSessionStore(); const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 }); const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
+40 -1
View File
@@ -21,7 +21,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
p.ny === undefined && p.ny === undefined &&
p.sizeN === undefined && p.sizeN === undefined &&
!p.disposition && !p.disposition &&
!p.inactive !p.inactive &&
!p.hiddenFromPlayers
); );
} }
@@ -32,6 +33,7 @@ function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcToken
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}), ...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}), ...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}), ...(prev?.inactive ? { inactive: true } : {}),
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
}; };
} }
@@ -238,6 +240,7 @@ export class SceneNpcTokensSessionStore {
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}), ...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}), ...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(inactive ? { inactive: true } : {}), ...(inactive ? { inactive: true } : {}),
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
}; };
if (isEmptyPlacement(next)) { if (isEmptyPlacement(next)) {
this.state = withoutPlacement(this.state, placementId); this.state = withoutPlacement(this.state, placementId);
@@ -254,6 +257,32 @@ export class SceneNpcTokensSessionStore {
}; };
return this.state; return this.state;
} }
case 'setHiddenFromPlayers': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const hidden = Boolean(event.hidden);
const prev = this.state.byPlacementId[placementId];
if (Boolean(prev?.hiddenFromPlayers) === hidden) return this.state;
const next: SceneNpcTokensSessionPlacement = {
...restOf(prev),
...(hidden ? { hiddenFromPlayers: true } : {}),
};
if (!hidden) delete next.hiddenFromPlayers;
if (isEmptyPlacement(next)) {
this.state = withoutPlacement(this.state, placementId);
return this.state;
}
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
spawned: this.state.spawned,
};
return this.state;
}
case 'setSize': { case 'setSize': {
const placementId = String(event.placementId ?? ''); const placementId = String(event.placementId ?? '');
if (!placementId) return this.state; if (!placementId) return this.state;
@@ -281,4 +310,14 @@ export class SceneNpcTokensSessionStore {
} }
} }
} }
load(next: Omit<SceneNpcTokensSessionState, 'revision'>): SceneNpcTokensSessionState {
this.state = {
revision: this.state.revision + 1,
byPlacementId: { ...next.byPlacementId },
scale: clampNpcTokenSessionScale(next.scale),
spawned: { ...next.spawned },
};
return this.state;
}
} }
@@ -179,4 +179,14 @@ export class ScenePlayerTokensSessionStore {
} }
} }
} }
load(next: Omit<ScenePlayerTokensSessionState, 'revision'>): ScenePlayerTokensSessionState {
this.state = {
revision: this.state.revision + 1,
selectedPlayerIds: [...next.selectedPlayerIds],
visible: next.visible,
byPlayerId: { ...next.byPlayerId },
};
return this.state;
}
} }
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { filterPlaylistsByQuery } from '../../shared/types/appPlaylists';
import { asPlaylistAudioId, asPlaylistId, asPlaylistTrackId } from '../../shared/types/ids';
import { PlaylistsStore } from './playlistsStore';
void test('filterPlaylistsByQuery: matches playlist name and track title', () => {
const state = {
playlists: [
{
id: asPlaylistId('p1'),
name: 'Battle',
tracks: [{ id: asPlaylistTrackId('t1'), audioId: asPlaylistAudioId('a1') }],
},
{
id: asPlaylistId('p2'),
name: 'Town',
tracks: [{ id: asPlaylistTrackId('t2'), audioId: asPlaylistAudioId('a2') }],
},
],
audios: [
{
id: asPlaylistAudioId('a1'),
title: 'Drums',
originalName: 'drums.mp3',
relPath: 'files/a.mp3',
sha256: '1',
mime: 'audio/mpeg',
sizeBytes: 1,
},
{
id: asPlaylistAudioId('a2'),
title: 'Market',
originalName: 'market.mp3',
relPath: 'files/b.mp3',
sha256: '2',
mime: 'audio/mpeg',
sizeBytes: 1,
},
],
};
const byName = filterPlaylistsByQuery(state, 'bat');
assert.equal(byName.playlists.length, 1);
assert.equal(byName.playlists[0]?.id, asPlaylistId('p1'));
const byTrack = filterPlaylistsByQuery(state, 'market');
assert.equal(byTrack.playlists.length, 1);
assert.ok(byTrack.matchedAudioIds.has(asPlaylistAudioId('a2')));
});
void test('PlaylistsStore: create playlist and add audio by hash reuse', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-pl-'));
const store = new PlaylistsStore(dir);
await store.ensureLoaded();
const pl = await store.createPlaylist('Test');
const audioPath = path.join(dir, 'sample.mp3');
fs.writeFileSync(audioPath, Buffer.from('ID3fake-audio-bytes'));
await store.addAudioFiles(pl.id, [audioPath]);
await store.addAudioFiles(pl.id, [audioPath]);
const state = store.getState();
assert.equal(state.audios.length, 1);
assert.equal(state.playlists[0]?.tracks.length, 2);
assert.equal(state.playlists[0]?.tracks[0]?.audioId, state.playlists[0]?.tracks[1]?.audioId);
});
+424
View File
@@ -0,0 +1,424 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import type {
AppPlaylist,
PlaylistAudio,
PlaylistTrack,
PlaylistsExportBundle,
PlaylistsState,
} from '../../shared/types/appPlaylists';
import {
normalizePlaylistName,
titleFromAudioFileName,
} from '../../shared/types/appPlaylists';
import {
asPlaylistAudioId,
asPlaylistId,
asPlaylistTrackId,
type PlaylistAudioId,
type PlaylistId,
type PlaylistTrackId,
} from '../../shared/types/ids';
type PlaylistsManifest = {
playlists: AppPlaylist[];
audios: PlaylistAudio[];
};
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
function mimeFromExt(ext: string): string {
const e = ext.toLowerCase();
if (e === '.mp3') return 'audio/mpeg';
if (e === '.wav') return 'audio/wav';
if (e === '.ogg') return 'audio/ogg';
if (e === '.m4a') return 'audio/mp4';
if (e === '.aac') return 'audio/aac';
return 'application/octet-stream';
}
function safeFileBase(name: string): string {
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
return base || 'audio';
}
function randomPlaylistId(): PlaylistId {
return asPlaylistId(`pl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
function randomAudioId(): PlaylistAudioId {
return asPlaylistAudioId(`pla_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
function randomTrackId(): PlaylistTrackId {
return asPlaylistTrackId(`plt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
function uniquePlaylistName(desired: string, playlists: AppPlaylist[], exceptId?: PlaylistId): string {
const base = normalizePlaylistName(desired);
const taken = new Set(
playlists.filter((p) => p.id !== exceptId).map((p) => p.name.trim().toLowerCase()),
);
if (!taken.has(base.toLowerCase())) return base;
let i = 2;
while (taken.has(`${base} (${String(i)})`.toLowerCase())) i += 1;
return `${base} (${String(i)})`;
}
export class PlaylistsStore {
private readonly rootDir: string;
private readonly filesDir: string;
private readonly manifestPath: string;
private playlists: AppPlaylist[] = [];
private audios: PlaylistAudio[] = [];
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'playlists');
this.filesDir = path.join(this.rootDir, 'files');
this.manifestPath = path.join(this.rootDir, 'playlists.json');
}
async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await fs.mkdir(this.filesDir, { recursive: true });
try {
const raw = await fs.readFile(this.manifestPath, 'utf8');
const parsed = JSON.parse(raw) as PlaylistsManifest;
this.audios = Array.isArray(parsed.audios) ? parsed.audios : [];
const audioIds = new Set(this.audios.map((a) => a.id));
this.playlists = (Array.isArray(parsed.playlists) ? parsed.playlists : []).map((p) => ({
id: asPlaylistId(String(p.id)),
name: normalizePlaylistName(String(p.name ?? '')),
tracks: (Array.isArray(p.tracks) ? p.tracks : [])
.filter((t) => audioIds.has(t.audioId))
.map((t) => ({
id: asPlaylistTrackId(String(t.id)),
audioId: asPlaylistAudioId(String(t.audioId)),
})),
}));
} catch {
this.playlists = [];
this.audios = [];
await this.persist();
}
this.loaded = true;
}
getState(): PlaylistsState {
return {
playlists: this.playlists.map((p) => ({ ...p, tracks: [...p.tracks] })),
audios: [...this.audios],
};
}
getAudioReadInfo(id: PlaylistAudioId): { absPath: string; mime: string } | null {
const audio = this.audios.find((a) => a.id === id);
if (!audio) return null;
return {
absPath: path.join(this.rootDir, audio.relPath),
mime: audio.mime || mimeFromExt(path.extname(audio.relPath)),
};
}
getAudioUrl(id: PlaylistAudioId): string | null {
if (!this.getAudioReadInfo(id)) return null;
return `dnd://playlist-audio?id=${encodeURIComponent(id)}`;
}
private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
const manifest: PlaylistsManifest = { playlists: this.playlists, audios: this.audios };
await fs.writeFile(this.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
}
async createPlaylist(name?: string): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl: AppPlaylist = {
id: randomPlaylistId(),
name: uniquePlaylistName(name ?? 'Playlist', this.playlists),
tracks: [],
};
this.playlists = [pl, ...this.playlists];
await this.persist();
return pl;
}
async renamePlaylist(id: PlaylistId, name: string): Promise<AppPlaylist> {
await this.ensureLoaded();
const idx = this.playlists.findIndex((p) => p.id === id);
if (idx < 0) throw new Error('Playlist not found');
const next = {
...this.playlists[idx]!,
name: uniquePlaylistName(name, this.playlists, id),
};
this.playlists = this.playlists.map((p, i) => (i === idx ? next : p));
await this.persist();
return next;
}
async duplicatePlaylist(id: PlaylistId): Promise<AppPlaylist> {
await this.ensureLoaded();
const src = this.playlists.find((p) => p.id === id);
if (!src) throw new Error('Playlist not found');
const copy: AppPlaylist = {
id: randomPlaylistId(),
name: uniquePlaylistName(`${src.name} copy`, this.playlists),
tracks: src.tracks.map((t) => ({ id: randomTrackId(), audioId: t.audioId })),
};
const at = this.playlists.findIndex((p) => p.id === id);
const next = [...this.playlists];
next.splice(at + 1, 0, copy);
this.playlists = next;
await this.persist();
return copy;
}
async deletePlaylist(id: PlaylistId): Promise<void> {
await this.ensureLoaded();
this.playlists = this.playlists.filter((p) => p.id !== id);
await this.pruneOrphanAudios();
await this.persist();
}
async setPlaylistsOrder(playlistIds: PlaylistId[]): Promise<void> {
await this.ensureLoaded();
const map = new Map(this.playlists.map((p) => [p.id, p]));
const next: AppPlaylist[] = [];
for (const id of playlistIds) {
const p = map.get(id);
if (p) {
next.push(p);
map.delete(id);
}
}
for (const p of map.values()) next.push(p);
this.playlists = next;
await this.persist();
}
async setTracksOrder(playlistId: PlaylistId, trackIds: PlaylistTrackId[]): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl = this.playlists.find((p) => p.id === playlistId);
if (!pl) throw new Error('Playlist not found');
const map = new Map(pl.tracks.map((t) => [t.id, t]));
const tracks: PlaylistTrack[] = [];
for (const id of trackIds) {
const t = map.get(id);
if (t) {
tracks.push(t);
map.delete(id);
}
}
for (const t of map.values()) tracks.push(t);
const next = { ...pl, tracks };
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
await this.persist();
return next;
}
async renameAudio(audioId: PlaylistAudioId, title: string): Promise<PlaylistAudio> {
await this.ensureLoaded();
const idx = this.audios.findIndex((a) => a.id === audioId);
if (idx < 0) throw new Error('Audio not found');
const next = { ...this.audios[idx]!, title: title.trim() || this.audios[idx]!.title };
this.audios = this.audios.map((a, i) => (i === idx ? next : a));
await this.persist();
return next;
}
async removeTrack(playlistId: PlaylistId, trackId: PlaylistTrackId): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl = this.playlists.find((p) => p.id === playlistId);
if (!pl) throw new Error('Playlist not found');
const next = { ...pl, tracks: pl.tracks.filter((t) => t.id !== trackId) };
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
await this.pruneOrphanAudios();
await this.persist();
return next;
}
async addAudioFiles(playlistId: PlaylistId, filePaths: string[]): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl = this.playlists.find((p) => p.id === playlistId);
if (!pl) throw new Error('Playlist not found');
const tracks = [...pl.tracks];
for (const filePath of filePaths) {
const ext = path.extname(filePath).toLowerCase();
if (!AUDIO_EXTS.has(ext)) continue;
const audio = await this.importOrReuseFile(filePath);
tracks.push({ id: randomTrackId(), audioId: audio.id });
}
const next = { ...pl, tracks };
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
await this.persist();
return next;
}
/** Импорт файла с известным содержимым (Foundry / zip bundle). */
async importAudioBuffer(input: {
preferredId?: PlaylistAudioId;
title: string;
originalName: string;
mime: string;
buffer: Buffer;
}): Promise<PlaylistAudio> {
await this.ensureLoaded();
const sha256 = crypto.createHash('sha256').update(input.buffer).digest('hex');
const existing = this.audios.find((a) => a.sha256 === sha256);
if (existing) return existing;
const id =
input.preferredId && !this.audios.some((a) => a.id === input.preferredId)
? input.preferredId
: randomAudioId();
const ext = path.extname(input.originalName).toLowerCase() || '.mp3';
const fileName = `${id}_${safeFileBase(input.title)}${AUDIO_EXTS.has(ext) ? ext : '.mp3'}`;
const abs = path.join(this.filesDir, fileName);
await fs.writeFile(abs, input.buffer);
const audio: PlaylistAudio = {
id,
title: input.title.trim() || titleFromAudioFileName(input.originalName),
originalName: input.originalName,
relPath: path.join('files', fileName).replace(/\\/gu, '/'),
sha256,
mime: input.mime || mimeFromExt(ext),
sizeBytes: input.buffer.length,
};
this.audios = [...this.audios, audio];
await this.persist();
return audio;
}
async importBundle(
bundle: PlaylistsExportBundle,
readFile: (relPath: string) => Promise<Buffer>,
): Promise<{ importedPlaylistIds: PlaylistId[] }> {
await this.ensureLoaded();
const audioIdMap = new Map<PlaylistAudioId, PlaylistAudioId>();
for (const a of bundle.audios ?? []) {
try {
const buf = await readFile(a.relPath);
const imported = await this.importAudioBuffer({
title: a.title,
originalName: a.originalName,
mime: a.mime,
buffer: buf,
});
audioIdMap.set(a.id, imported.id);
} catch {
/* skip missing file */
}
}
const importedPlaylistIds: PlaylistId[] = [];
for (const pl of bundle.playlists ?? []) {
const tracks: PlaylistTrack[] = [];
for (const t of pl.tracks ?? []) {
const mapped = audioIdMap.get(t.audioId);
if (!mapped) continue;
tracks.push({ id: randomTrackId(), audioId: mapped });
}
const created: AppPlaylist = {
id: randomPlaylistId(),
name: uniquePlaylistName(pl.name, this.playlists),
tracks,
};
this.playlists = [...this.playlists, created];
importedPlaylistIds.push(created.id);
}
await this.persist();
return { importedPlaylistIds };
}
buildExportBundle(playlistIds: PlaylistId[]): {
bundle: PlaylistsExportBundle;
fileAbsPaths: { relPath: string; absPath: string }[];
} {
const selected = this.playlists.filter((p) => playlistIds.includes(p.id));
const usedAudioIds = new Set<PlaylistAudioId>();
for (const p of selected) {
for (const t of p.tracks) usedAudioIds.add(t.audioId);
}
const audios = this.audios.filter((a) => usedAudioIds.has(a.id));
const fileAbsPaths = audios.map((a) => ({
relPath: a.relPath,
absPath: path.join(this.rootDir, a.relPath),
}));
return {
bundle: { version: 1, playlists: selected, audios },
fileAbsPaths,
};
}
async packForExport(playlistIds: PlaylistId[], exportRoot: string): Promise<void> {
await this.ensureLoaded();
if (playlistIds.length === 0) return;
const { bundle, fileAbsPaths } = this.buildExportBundle(playlistIds);
if (bundle.playlists.length === 0) return;
const outDir = path.join(exportRoot, 'app-playlists');
await fs.mkdir(outDir, { recursive: true });
for (const f of fileAbsPaths) {
const dest = path.join(outDir, f.relPath);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(f.absPath, dest);
}
await fs.writeFile(
path.join(outDir, 'playlists.json'),
`${JSON.stringify(bundle, null, 2)}\n`,
'utf8',
);
}
async importFromExportDir(sourceCache: string): Promise<void> {
await this.ensureLoaded();
const manifestPath = path.join(sourceCache, 'app-playlists', 'playlists.json');
let raw: string;
try {
raw = await fs.readFile(manifestPath, 'utf8');
} catch {
return;
}
let bundle: PlaylistsExportBundle;
try {
bundle = JSON.parse(raw) as PlaylistsExportBundle;
} catch {
return;
}
await this.importBundle(bundle, async (relPath) => {
return fs.readFile(path.join(sourceCache, 'app-playlists', relPath));
});
}
private async importOrReuseFile(filePath: string): Promise<PlaylistAudio> {
const buf = await fs.readFile(filePath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const existing = this.audios.find((a) => a.sha256 === sha256);
if (existing) return existing;
const originalName = path.basename(filePath);
const ext = path.extname(filePath).toLowerCase();
return this.importAudioBuffer({
title: titleFromAudioFileName(originalName),
originalName,
mime: mimeFromExt(ext),
buffer: buf,
});
}
private async pruneOrphanAudios(): Promise<void> {
const used = new Set<PlaylistAudioId>();
for (const p of this.playlists) {
for (const t of p.tracks) used.add(t.audioId);
}
const orphans = this.audios.filter((a) => !used.has(a.id));
this.audios = this.audios.filter((a) => used.has(a.id));
for (const a of orphans) {
try {
await fs.unlink(path.join(this.rootDir, a.relPath));
} catch {
/* ignore */
}
}
}
}
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { readPresentationSettings, writePresentationSettings } from './appPreferences';
void test('appPreferences: presentation settings round-trip without clobbering eula', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-prefs-'));
const prefsPath = path.join(dir, 'preferences.json');
fs.writeFileSync(prefsPath, `${JSON.stringify({ eulaAcceptedVersion: 2 }, null, 2)}\n`, 'utf8');
writePresentationSettings(dir, { displayId: 42, fullScreen: false });
const loaded = readPresentationSettings(dir);
assert.equal(loaded.displayId, 42);
assert.equal(loaded.fullScreen, false);
const raw = JSON.parse(fs.readFileSync(prefsPath, 'utf8')) as {
eulaAcceptedVersion?: number;
presentation?: { displayId: number | null; fullScreen: boolean };
};
assert.equal(raw.eulaAcceptedVersion, 2);
assert.equal(raw.presentation?.displayId, 42);
assert.equal(raw.presentation?.fullScreen, false);
});
void test('appPreferences: defaults when preferences missing', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-prefs-'));
const loaded = readPresentationSettings(dir);
assert.equal(loaded.displayId, null);
assert.equal(loaded.fullScreen, true);
});
+47
View File
@@ -0,0 +1,47 @@
import fs from 'node:fs';
import type { PresentationSettings } from '../../shared/types/presentationSettings';
import { DEFAULT_PRESENTATION_SETTINGS } from '../../shared/types/presentationSettings';
import { preferencesPath } from '../license/paths';
type StoredPreferences = {
eulaAcceptedVersion?: number;
presentation?: Partial<PresentationSettings>;
};
function readAll(userData: string): StoredPreferences {
try {
const raw = fs.readFileSync(preferencesPath(userData), 'utf8');
return JSON.parse(raw) as StoredPreferences;
} catch {
return {};
}
}
function writeAll(userData: string, prefs: StoredPreferences): void {
fs.mkdirSync(userData, { recursive: true });
fs.writeFileSync(preferencesPath(userData), `${JSON.stringify(prefs, null, 2)}\n`, 'utf8');
}
export function readPresentationSettings(userData: string): PresentationSettings {
const prefs = readAll(userData);
return {
...DEFAULT_PRESENTATION_SETTINGS,
...prefs.presentation,
};
}
export function writePresentationSettings(
userData: string,
patch: Partial<PresentationSettings>,
): PresentationSettings {
const prefs = readAll(userData);
const next: PresentationSettings = {
...DEFAULT_PRESENTATION_SETTINGS,
...prefs.presentation,
...patch,
};
prefs.presentation = next;
writeAll(userData, prefs);
return next;
}
+2 -2
View File
@@ -17,11 +17,11 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
previewThumbAssetId: 'th' as AssetId, previewThumbAssetId: 'th' as AssetId,
media: { media: {
videos: ['v1' as AssetId], videos: ['v1' as AssetId],
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }], audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true, volume: 1 }],
}, },
}, },
}, },
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }], campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true, volume: 1 }],
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }], materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }], npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
} as unknown as Project; } as unknown as Project;
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { ensureParentDir } from './ensureParentDir';
void test('ensureParentDir: creates nested parent', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-mkdir-'));
try {
const file = path.join(root, 'a', 'b', 'out.ttrpg.zip');
await ensureParentDir(file);
const st = await fs.stat(path.dirname(file));
assert.equal(st.isDirectory(), true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
void test('ensureParentDir: drive root is a no-op (Windows)', async () => {
if (process.platform !== 'win32') return;
// Must not throw EPERM the way fs.mkdir('E:\\') often does.
await ensureParentDir('E:\\campaign.ttrpg.zip');
await ensureParentDir('C:\\file.ttrpg.zip');
});
+14
View File
@@ -0,0 +1,14 @@
import fs from 'node:fs/promises';
import path from 'node:path';
/**
* Создаёт родительский каталог для файла.
* На Windows `mkdir('E:\\')` даёт EPERM — корень диска пропускаем.
*/
export async function ensureParentDir(filePath: string): Promise<void> {
const dir = path.dirname(filePath);
if (!dir || dir === '.') return;
const root = path.parse(path.resolve(filePath)).root;
if (root && path.resolve(dir) === path.resolve(root)) return;
await fs.mkdir(dir, { recursive: true });
}
+1 -1
View File
@@ -69,7 +69,7 @@ void test('readProjectJsonFromZip: campaignAudios round-trips inside project.jso
}, },
scenes: {}, scenes: {},
assets: {}, assets: {},
campaignAudios: [{ assetId, autoplay: true, loop: false }], campaignAudios: [{ assetId, autoplay: true, loop: false, volume: 1 }],
currentSceneId: null, currentSceneId: null,
currentGraphNodeId: null, currentGraphNodeId: null,
sceneGraphNodes: [], sceneGraphNodes: [],
@@ -67,6 +67,15 @@ void test('zipStore: closeOpenProject is serialized with open on projectSwitchCh
assert.match(src, /Открытие проекта отменено/); assert.match(src, /Открытие проекта отменено/);
}); });
void test('zipStore: Foundry archive playlists are copied before temp unzip cleanup', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
const fn = src.slice(src.indexOf('async importProjectFromFoundry'));
const body = fn.slice(0, fn.indexOf('\n async exportProjectZipToPath'));
const copyAt = body.indexOf('await importPlaylists(pendingAppPlaylists)');
const cleanupAt = body.indexOf('await loaded.cleanup()');
assert.ok(copyAt > 0 && cleanupAt > copyAt);
});
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => { void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /async exportProjectZipToPath/); assert.match(src, /async exportProjectZipToPath/);
@@ -105,6 +114,50 @@ void test('zipStore: listProjects skips unreadable archives', () => {
); );
}); });
void test('zipStore: setSessionPointer updates pointer in memory without disk write', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
const m = src.match(/setSessionPointer\([\s\S]*?\n \}/);
assert.ok(m);
assert.doesNotMatch(m[0], /writeCacheProject|queueSave|enqueueProjectUpdate/);
});
void test('main index: setCurrentGraphNode uses setSessionPointer not updateProject', () => {
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
const m = src.match(
/registerHandler\(ipcChannels\.project\.setCurrentGraphNode[\s\S]*?emitSessionState\(\)/,
);
assert.ok(m);
assert.match(m[0], /setSessionPointer/);
assert.doesNotMatch(m[0], /updateProject/);
});
void test('main index: full zip import loads app-tokens like playlists', () => {
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
assert.match(src, /async function importAppTokensFromOpenProjectCache/);
assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/);
assert.match(src, /remapProjectSceneTokenIds/);
const importZip = src.match(
/registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/,
);
assert.ok(importZip);
assert.match(importZip[0], /importAppTokensFromOpenProjectCache/);
const importFromPath = src.match(
/registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/,
);
assert.ok(importFromPath);
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
});
void test('zipStore: zipDir uses ensureParentDir (no mkdir of drive root)', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /from '\.\/ensureParentDir'/);
const i = src.indexOf('async function zipDir(');
assert.ok(i >= 0);
const block = src.slice(i, i + 800);
assert.match(block, /await ensureParentDir\(outZipPath\)/);
assert.doesNotMatch(block, /fs\.mkdir\(path\.dirname\(outZipPath\)/);
});
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => { void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8'); const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
const i = src.indexOf('export async function replaceFileAtomic'); const i = src.indexOf('export async function replaceFileAtomic');
+158 -30
View File
@@ -45,12 +45,14 @@ import type {
ProjectNpcGroup, ProjectNpcGroup,
ProjectNpcRelation, ProjectNpcRelation,
Scene, Scene,
SceneAudioRef,
SceneGraphEdge, SceneGraphEdge,
SceneGraphNode, SceneGraphNode,
SceneId, SceneId,
SceneTrap, SceneTrap,
} from '../../shared/types'; } from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeSceneDarknessStrokes } from '../../shared/types/sceneDarkness';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens'; import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import { import {
@@ -67,7 +69,8 @@ import {
type NpcDisposition, type NpcDisposition,
} from '../../shared/types/npcDisposition'; } from '../../shared/types/npcDisposition';
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl'; import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid'; import { DEFAULT_SCENE_GRID, normalizeSceneGrid, normalizeSceneGridTemplates, sceneGridFromTemplate, sceneGridTemplateStyleFromGrid, MAX_SCENE_GRID_TEMPLATES } from '../../shared/types/sceneGrid';
import type { SceneGridTemplate } from '../../shared/types/sceneGrid';
import { import {
asPreviewRotationDeg, asPreviewRotationDeg,
previewRotationStepsCw, previewRotationStepsCw,
@@ -78,6 +81,7 @@ import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type { import type {
AssetId, AssetId,
GraphNodeId, GraphNodeId,
GridTemplateId,
MaterialId, MaterialId,
NpcGroupId, NpcGroupId,
NpcId, NpcId,
@@ -86,6 +90,7 @@ import type {
import { import {
asAssetId, asAssetId,
asGraphNodeId, asGraphNodeId,
asGridTemplateId,
asMaterialId, asMaterialId,
asNpcGroupId, asNpcGroupId,
asNpcId, asNpcId,
@@ -103,11 +108,13 @@ import {
buildProjectFromFoundryDocuments, buildProjectFromFoundryDocuments,
loadFoundryDocumentsForImport, loadFoundryDocumentsForImport,
type FoundryImportProgress, type FoundryImportProgress,
type FoundryPendingAppPlaylist,
} from '../foundry/foundryImport'; } from '../foundry/foundryImport';
import { getAppSemanticVersion } from '../versionInfo'; import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune'; import { reconcileAssetFiles } from './assetPrune';
import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace'; import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
import { ensureParentDir } from './ensureParentDir';
import { rmWithRetries } from './fsRetry'; import { rmWithRetries } from './fsRetry';
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs'; import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths'; import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
@@ -320,6 +327,7 @@ export class ZipProjectStore {
assets: {}, assets: {},
campaignAudios: [], campaignAudios: [],
materials: [], materials: [],
gridTemplates: [],
npcs: [], npcs: [],
npcGroups: [], npcGroups: [],
npcRelations: [], npcRelations: [],
@@ -411,6 +419,22 @@ export class ZipProjectStore {
return this.openProject?.project ?? null; return this.openProject?.project ?? null;
} }
getOpenCacheDir(): string | null {
return this.openProject?.cacheDir ?? null;
}
/** Только указатель сессии в памяти — без записи project.json на диск. */
setSessionPointer(currentGraphNodeId: GraphNodeId | null, currentSceneId: SceneId | null): Project {
const open = this.openProject;
if (!open) throw new Error('No open project');
open.project = {
...open.project,
currentGraphNodeId,
currentSceneId,
};
return open.project;
}
/** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */ /** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */
getAssetFileUrl(assetId: AssetId): string | null { getAssetFileUrl(assetId: AssetId): string | null {
if (!this.getAssetReadInfo(assetId)) return null; if (!this.getAssetReadInfo(assetId)) return null;
@@ -454,7 +478,7 @@ export class ZipProjectStore {
const relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
await fs.mkdir(path.dirname(abs), { recursive: true }); await ensureParentDir(abs);
await fs.writeFile(abs, buf); await fs.writeFile(abs, buf);
const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length); const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);
@@ -750,6 +774,7 @@ export class ZipProjectStore {
previewVideoAutostart: false, previewVideoAutostart: false,
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
@@ -776,6 +801,9 @@ export class ZipProjectStore {
? { previewRotationDeg: patch.previewRotationDeg } ? { previewRotationDeg: patch.previewRotationDeg }
: null), : null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.darknessStrokes !== undefined
? { darknessStrokes: normalizeSceneDarknessStrokes(patch.darknessStrokes) }
: null),
...(patch.traps !== undefined ...(patch.traps !== undefined
? { ? {
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)), traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
@@ -797,7 +825,14 @@ export class ZipProjectStore {
: null), : null),
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null), ...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...base.media, ...patch.media } } : null), ...(patch.media
? {
media: {
videos: patch.media.videos ?? base.media.videos,
audios: (patch.media.audios ?? base.media.audios).map((a) => normalizeSceneAudioRef(a)),
},
}
: null),
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
}; };
@@ -1137,7 +1172,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig); const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true }); await ensureParentDir(abs);
await fs.copyFile(filePath, abs); await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length)); staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
} }
@@ -1160,8 +1195,9 @@ export class ZipProjectStore {
else else
media.audios.push({ media.audios.push({
assetId: asset.id, assetId: asset.id,
autoplay: sc.settings.autoplayAudio, autoplay: false,
loop: sc.settings.loopAudio, loop: false,
volume: 1,
}); });
} }
return { return {
@@ -1197,7 +1233,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig); const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true }); await ensureParentDir(abs);
await fs.copyFile(filePath, abs); await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length)); staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
} }
@@ -1212,7 +1248,7 @@ export class ZipProjectStore {
for (const asset of staged) { for (const asset of staged) {
assets[asset.id] = asset; assets[asset.id] = asset;
if (asset.type !== 'audio') continue; if (asset.type !== 'audio') continue;
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false }); campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false, volume: 1 });
} }
return { ...p, assets, campaignAudios }; return { ...p, assets, campaignAudios };
}); });
@@ -1225,7 +1261,10 @@ export class ZipProjectStore {
async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> { async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
const open = this.openProject; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] })); const normalized = (Array.isArray(audios) ? audios : [])
.filter((a) => a && typeof a === 'object' && a.assetId)
.map((a) => normalizeSceneAudioRef(a));
await this.updateProject((p) => ({ ...p, campaignAudios: normalized }));
const latest = this.getOpenProject(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project'); if (!latest) throw new Error('No open project');
return latest; return latest;
@@ -1284,7 +1323,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig); const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true }); await ensureParentDir(abs);
await fs.writeFile(abs, buf); await fs.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length); stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id; nextAssetId = id;
@@ -1395,6 +1434,70 @@ export class ZipProjectStore {
return latest; return latest;
} }
async addGridTemplate(input: {
name: string;
type: SceneGridTemplate['type'];
sizeN: number;
color: string;
}): Promise<Project> {
if (!this.openProject) throw new Error('No open project');
const name = input.name.trim();
if (!name) throw new Error('Grid template name is required');
const style = sceneGridTemplateStyleFromGrid(input);
await this.updateProject((p) => {
const list = normalizeSceneGridTemplates(p.gridTemplates);
if (list.length >= MAX_SCENE_GRID_TEMPLATES) {
throw new Error('Grid template limit reached');
}
const template: SceneGridTemplate = {
id: asGridTemplateId(`gt_${this.randomId()}`),
name,
...style,
};
return { ...p, gridTemplates: [...list, template] };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async renameGridTemplate(templateId: GridTemplateId, name: string): Promise<Project> {
if (!this.openProject) throw new Error('No open project');
const nextName = name.trim();
if (!nextName) throw new Error('Grid template name is required');
await this.updateProject((p) => {
const list = normalizeSceneGridTemplates(p.gridTemplates);
const idx = list.findIndex((t) => t.id === templateId);
if (idx < 0) throw new Error('Grid template not found');
const next = [...list];
next[idx] = { ...next[idx]!, name: nextName };
return { ...p, gridTemplates: next };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteGridTemplate(templateId: GridTemplateId): Promise<Project> {
if (!this.openProject) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
gridTemplates: normalizeSceneGridTemplates(p.gridTemplates).filter((t) => t.id !== templateId),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async applyGridTemplate(sceneId: SceneId, templateId: GridTemplateId): Promise<Scene> {
if (!this.openProject) throw new Error('No open project');
const open = this.openProject;
const template = normalizeSceneGridTemplates(open.project.gridTemplates).find((t) => t.id === templateId);
if (!template) throw new Error('Grid template not found');
if (!open.project.scenes[sceneId]) throw new Error('Scene not found');
return this.updateScene(sceneId, { grid: sceneGridFromTemplate(template) });
}
/** /**
* Создаёт или обновляет НПС. * Создаёт или обновляет НПС.
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар. * При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
@@ -1455,7 +1558,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig); const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`; const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath); const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true }); await ensureParentDir(abs);
await fs.writeFile(abs, buf); await fs.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length); stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id; nextAssetId = id;
@@ -1942,7 +2045,7 @@ export class ZipProjectStore {
private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> { private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> {
const tmpPath = `${zipPath}.tmp`; const tmpPath = `${zipPath}.tmp`;
await fs.mkdir(path.dirname(zipPath), { recursive: true }); await ensureParentDir(zipPath);
await zipDir(cacheDir, tmpPath); await zipDir(cacheDir, tmpPath);
const st = await fs.stat(tmpPath).catch(() => null); const st = await fs.stat(tmpPath).catch(() => null);
if (!st?.isFile() || st.size < 22) { if (!st?.isFile() || st.size < 22) {
@@ -2027,7 +2130,8 @@ export class ZipProjectStore {
async importProjectFromFoundry( async importProjectFromFoundry(
sourcePath: string, sourcePath: string,
onProgress?: (p: FoundryImportProgress) => void, onProgress?: (p: FoundryImportProgress) => void,
): Promise<Project> { importPlaylists?: (lists: FoundryPendingAppPlaylist[]) => Promise<void>,
): Promise<{ project: Project; pendingAppPlaylists: FoundryPendingAppPlaylist[] }> {
await this.ensureRoots(); await this.ensureRoots();
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress); const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
try { try {
@@ -2037,7 +2141,7 @@ export class ZipProjectStore {
await fs.rm(cacheDir, { recursive: true, force: true }); await fs.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true }); await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
const { project } = await buildProjectFromFoundryDocuments( const { project, pendingAppPlaylists } = await buildProjectFromFoundryDocuments(
loaded.manifest, loaded.manifest,
loaded.docs, loaded.docs,
cacheDir, cacheDir,
@@ -2057,8 +2161,12 @@ export class ZipProjectStore {
await this.writeCacheProject(cacheDir, project); await this.writeCacheProject(cacheDir, project);
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' }); onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
await this.enqueuePack(cacheDir, zipPath); await this.enqueuePack(cacheDir, zipPath);
// Архив распакован во временную папку. Плейлисты читают её по absPath — копировать до cleanup.
if (importPlaylists && pendingAppPlaylists.length > 0) {
await importPlaylists(pendingAppPlaylists);
}
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' }); onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
return this.openProject.project; return { project: this.openProject.project, pendingAppPlaylists };
} finally { } finally {
await loaded.cleanup(); await loaded.cleanup();
} }
@@ -2082,7 +2190,7 @@ export class ZipProjectStore {
} }
const src = path.join(getProjectsRootDir(), entry.fileName); const src = path.join(getProjectsRootDir(), entry.fileName);
const dest = path.resolve(destinationPath); const dest = path.resolve(destinationPath);
await fs.mkdir(path.dirname(dest), { recursive: true }); await ensureParentDir(dest);
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' }); if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
await copyFileWithProgress(src, dest, (pct) => { await copyFileWithProgress(src, dest, (pct) => {
if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' }); if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
@@ -2172,6 +2280,7 @@ export class ZipProjectStore {
labels: StorylineLabels, labels: StorylineLabels,
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void, onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>, packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
packAppPlaylists?: (exportRoot: string) => Promise<void>,
): Promise<void> { ): Promise<void> {
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия'); if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
await this.ensureRoots(); await this.ensureRoots();
@@ -2194,7 +2303,7 @@ export class ZipProjectStore {
if (!a) continue; if (!a) continue;
const srcAbs = path.join(snap.cacheDir, a.relPath); const srcAbs = path.join(snap.cacheDir, a.relPath);
const destAbs = path.join(exportCache, a.relPath); const destAbs = path.join(exportCache, a.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true }); await ensureParentDir(destAbs);
await fs.copyFile(srcAbs, destAbs); await fs.copyFile(srcAbs, destAbs);
if (onProgress) { if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75); const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
@@ -2205,6 +2314,9 @@ export class ZipProjectStore {
const tokenIds = collectTokenIdsFromProject(partial); const tokenIds = collectTokenIdsFromProject(partial);
await packAppTokens(tokenIds, exportCache); await packAppTokens(tokenIds, exportCache);
} }
if (packAppPlaylists) {
await packAppPlaylists(exportCache);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const toWrite: Project = { const toWrite: Project = {
...partial, ...partial,
@@ -2328,7 +2440,7 @@ export class ZipProjectStore {
if (!srcAsset || !tgtMeta) continue; if (!srcAsset || !tgtMeta) continue;
const srcAbs = path.join(sourceCache, srcAsset.relPath); const srcAbs = path.join(sourceCache, srcAsset.relPath);
const destAbs = path.join(targetCache, tgtMeta.relPath); const destAbs = path.join(targetCache, tgtMeta.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true }); await ensureParentDir(destAbs);
await fs.copyFile(srcAbs, destAbs); await fs.copyFile(srcAbs, destAbs);
if (onProgress) { if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90); const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
@@ -2518,6 +2630,9 @@ function normalizeScene(s: Scene): Scene {
const previewThumbAssetId = const previewThumbAssetId =
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null; (s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene); const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene);
const darknessStrokes = normalizeSceneDarknessStrokes(
(s as unknown as { darknessStrokes?: unknown }).darknessStrokes,
);
const rawTraps = (s as unknown as { traps?: unknown[] }).traps; const rawTraps = (s as unknown as { traps?: unknown[] }).traps;
const traps = (Array.isArray(rawTraps) ? rawTraps : []) const traps = (Array.isArray(rawTraps) ? rawTraps : [])
.map((t) => normalizeSceneTrap(t)) .map((t) => normalizeSceneTrap(t))
@@ -2536,20 +2651,26 @@ function normalizeScene(s: Scene): Scene {
const audios = rawAudios const audios = rawAudios
.map((a) => { .map((a) => {
if (typeof a === 'string') { if (typeof a === 'string') {
return { return normalizeSceneAudioRef({
assetId: a as AssetId, assetId: a as AssetId,
autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio), autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio),
loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio), loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio),
}; volume: 1,
});
} }
if (a && typeof a === 'object') { if (a && typeof a === 'object') {
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean }; const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown };
if (!obj.assetId) return null; if (!obj.assetId) return null;
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) }; return normalizeSceneAudioRef({
assetId: obj.assetId,
autoplay: Boolean(obj.autoplay),
loop: Boolean(obj.loop),
volume: obj.volume,
});
} }
return null; return null;
}) })
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x)); .filter((x): x is SceneAudioRef => Boolean(x));
return { return {
...s, ...s,
@@ -2559,6 +2680,7 @@ function normalizeScene(s: Scene): Scene {
previewVideoAutostart, previewVideoAutostart,
previewRotationDeg, previewRotationDeg,
darkenScene, darkenScene,
darknessStrokes,
traps, traps,
tokens, tokens,
npcTokens, npcTokens,
@@ -2594,15 +2716,20 @@ function normalizeProject(p: Project): Project {
const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios; const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : []) const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
.map((a) => { .map((a) => {
if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false }; if (typeof a === 'string') return normalizeSceneAudioRef({ assetId: a as AssetId, autoplay: false, loop: false, volume: 1 });
if (a && typeof a === 'object') { if (a && typeof a === 'object') {
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean }; const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown };
if (!obj.assetId) return null; if (!obj.assetId) return null;
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) }; return normalizeSceneAudioRef({
assetId: obj.assetId,
autoplay: Boolean(obj.autoplay),
loop: Boolean(obj.loop),
volume: obj.volume,
});
} }
return null; return null;
}) })
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x)); .filter((x): x is SceneAudioRef => Boolean(x));
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials; const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
const materials = (Array.isArray(rawMaterials) ? rawMaterials : []) const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
.map((m) => { .map((m) => {
@@ -2736,6 +2863,7 @@ function normalizeProject(p: Project): Project {
scenes: scenesPruned, scenes: scenesPruned,
campaignAudios, campaignAudios,
materials, materials,
gridTemplates: normalizeSceneGridTemplates((p as unknown as { gridTemplates?: unknown }).gridTemplates),
npcs, npcs,
npcGroups, npcGroups,
npcRelations, npcRelations,
@@ -2782,7 +2910,7 @@ async function copyFileWithProgress(
onPercent(100); onPercent(100);
return; return;
} }
await fs.mkdir(path.dirname(dest), { recursive: true }); await ensureParentDir(dest);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
let done = 0; let done = 0;
const rs = fssync.createReadStream(src); const rs = fssync.createReadStream(src);
@@ -2915,7 +3043,7 @@ async function zipDir(srcDir: string, outZipPath: string): Promise<void> {
} }
zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel)); zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel));
} }
await fs.mkdir(path.dirname(outZipPath), { recursive: true }); await ensureParentDir(outZipPath);
const out = fssync.createWriteStream(outZipPath); const out = fssync.createWriteStream(outZipPath);
const done = new Promise<void>((resolve, reject) => { const done = new Promise<void>((resolve, reject) => {
out.on('close', resolve); out.on('close', resolve);
+6 -2
View File
@@ -2,8 +2,9 @@ import fs from 'node:fs/promises';
import { session } from 'electron'; import { session } from 'electron';
import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids'; import { asAssetId, asPlayerId, asPlaylistAudioId, asTokenId } from '../../shared/types/ids';
import type { PlayersStore } from '../players/playersStore'; import type { PlayersStore } from '../players/playersStore';
import type { PlaylistsStore } from '../playlists/playlistsStore';
import type { ZipProjectStore } from '../project/zipStore'; import type { ZipProjectStore } from '../project/zipStore';
import type { TokensStore } from '../tokens/tokensStore'; import type { TokensStore } from '../tokens/tokensStore';
@@ -76,12 +77,13 @@ async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
} }
/** /**
* Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`. * Обслуживает `dnd://asset?...`, `dnd://token?...`, `dnd://player?...` и `dnd://playlist-audio?...`.
*/ */
export function registerDndAssetProtocol( export function registerDndAssetProtocol(
projectStore: ZipProjectStore, projectStore: ZipProjectStore,
tokensStore: TokensStore, tokensStore: TokensStore,
playersStore: PlayersStore | null, playersStore: PlayersStore | null,
playlistsStore: PlaylistsStore | null = null,
): void { ): void {
session.defaultSession.protocol.handle('dnd', async (request) => { session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url); const url = new URL(request.url);
@@ -96,6 +98,8 @@ export function registerDndAssetProtocol(
info = tokensStore.getImageReadInfo(asTokenId(id)); info = tokensStore.getImageReadInfo(asTokenId(id));
} else if (url.hostname === 'player') { } else if (url.hostname === 'player') {
info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null; info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null;
} else if (url.hostname === 'playlist-audio') {
info = playlistsStore?.getAudioReadInfo(asPlaylistAudioId(id)) ?? null;
} }
if (!info) { if (!info) {
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
+25
View File
@@ -131,4 +131,29 @@ export class SceneTrapsStore {
} }
} }
} }
exportByCard(): Record<string, Record<string, SceneTrapRuntime>> {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, cloneById(this.state.byId));
}
const out: Record<string, Record<string, SceneTrapRuntime>> = {};
for (const [key, byId] of this.cache) {
const kept: Record<string, SceneTrapRuntime> = {};
for (const [id, runtime] of Object.entries(byId)) {
if (runtime.status === 'inactive' && !runtime.revealed) continue;
kept[id] = { ...runtime };
}
if (Object.keys(kept).length > 0) out[key] = kept;
}
return out;
}
importByCard(byCard: Record<string, Record<string, SceneTrapRuntime>>): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
for (const [key, byId] of Object.entries(byCard)) {
this.cache.set(key, cloneById(byId));
}
}
} }
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TimerSessionStore } from './timerSessionStore';
import {
clampSessionTimerLayout,
formatSessionTimerMs,
getSessionTimerRemainingMs,
parseSessionTimerDurationInput,
} from '../../shared/types/sessionTimer';
void test('getSessionTimerRemainingMs: paused uses remainingMs', () => {
const store = new TimerSessionStore();
const s = store.getState();
assert.equal(getSessionTimerRemainingMs(s, 1_000_000), s.remainingMs);
});
void test('start pause start preserves remaining', () => {
const store = new TimerSessionStore();
const t0 = 1_000_000;
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
store.dispatch({ kind: 'start' }, t0);
store.dispatch({ kind: 'pause' }, t0 + 10_000);
const afterPause = store.getState();
assert.equal(afterPause.running, false);
assert.equal(afterPause.remainingMs, 50_000);
store.dispatch({ kind: 'start' }, t0 + 20_000);
assert.equal(getSessionTimerRemainingMs(store.getState(), t0 + 25_000), 45_000);
});
void test('reset restores duration', () => {
const store = new TimerSessionStore();
store.dispatch({ kind: 'setDuration', durationMs: 90_000 });
store.dispatch({ kind: 'start' }, 0);
store.dispatch({ kind: 'pause' }, 30_000);
store.dispatch({ kind: 'reset' });
assert.equal(store.getState().remainingMs, 90_000);
assert.equal(store.getState().running, false);
});
void test('setVisibleToPlayers and layout.set', () => {
const store = new TimerSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
assert.equal(store.getState().visibleToPlayers, true);
store.dispatch({
kind: 'layout.set',
layout: { cx: 0.2, cy: 0.3, scale: 2, rotationDeg: 45 },
});
assert.equal(store.getState().layout.cx, 0.2);
assert.equal(store.getState().layout.scale, 2);
assert.equal(store.getState().layout.rotationDeg, 45);
});
void test('settleIfExpired stops at zero', () => {
const store = new TimerSessionStore();
store.dispatch({ kind: 'setDuration', durationMs: 5_000 }, 0);
store.dispatch({ kind: 'start' }, 0);
store.settleIfExpired(10_000);
assert.equal(store.getState().running, false);
assert.equal(store.getState().remainingMs, 0);
});
void test('clampSessionTimerLayout / format / parse', () => {
const clamped = clampSessionTimerLayout({ cx: 9, cy: -9, scale: 100, rotationDeg: 370 });
assert.ok(clamped.cx <= 1.2);
assert.ok(clamped.cy >= -0.2);
assert.ok(clamped.scale <= 8);
assert.equal(clamped.rotationDeg, 10);
assert.equal(formatSessionTimerMs(65_000), '01:05');
assert.equal(formatSessionTimerMs(3_661_000), '1:01:01');
assert.equal(parseSessionTimerDurationInput('5:00'), 300_000);
assert.equal(parseSessionTimerDurationInput('1:02:03'), 3723_000);
assert.equal(parseSessionTimerDurationInput('bad'), null);
});
void test('clear restores defaults', () => {
const store = new TimerSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
store.dispatch({ kind: 'start' }, 0);
store.clear();
assert.equal(store.getState().visibleToPlayers, false);
assert.equal(store.getState().running, false);
});
void test('dismiss on window close: pause + hide from players', () => {
const store = new TimerSessionStore();
const t0 = 1_000_000;
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
store.dispatch({ kind: 'start' }, t0);
store.dispatch({ kind: 'pause' }, t0 + 5_000);
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
assert.equal(store.getState().running, false);
assert.equal(store.getState().visibleToPlayers, false);
assert.equal(store.getState().remainingMs, 55_000);
});
+147
View File
@@ -0,0 +1,147 @@
import {
clampSessionTimerDurationMs,
clampSessionTimerLayout,
createInitialSessionTimerState,
getSessionTimerRemainingMs,
type SessionTimerEvent,
type SessionTimerState,
} from '../../shared/types/sessionTimer';
export class TimerSessionStore {
private state: SessionTimerState = createInitialSessionTimerState();
getState(): SessionTimerState {
return this.state;
}
clear(): SessionTimerState {
this.state = createInitialSessionTimerState();
return this.state;
}
dispatch(event: SessionTimerEvent, now = Date.now()): SessionTimerState {
switch (event.kind) {
case 'setDuration': {
const durationMs = clampSessionTimerDurationMs(event.durationMs);
if (this.state.running) {
const remaining = getSessionTimerRemainingMs(this.state, now);
this.state = {
...this.state,
revision: this.state.revision + 1,
durationMs,
remainingMs: Math.min(remaining, durationMs),
endsAt: now + Math.min(remaining, durationMs),
};
} else {
this.state = {
...this.state,
revision: this.state.revision + 1,
durationMs,
remainingMs: durationMs,
endsAt: null,
running: false,
};
}
return this.state;
}
case 'start': {
const remaining = getSessionTimerRemainingMs(this.state, now);
if (remaining <= 0) {
// Restart from full duration if already at zero.
const durationMs = this.state.durationMs;
this.state = {
...this.state,
revision: this.state.revision + 1,
remainingMs: durationMs,
running: true,
endsAt: now + durationMs,
};
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
running: true,
endsAt: now + remaining,
remainingMs: remaining,
};
return this.state;
}
case 'pause': {
if (!this.state.running) return this.state;
const remaining = getSessionTimerRemainingMs(this.state, now);
this.state = {
...this.state,
revision: this.state.revision + 1,
running: false,
endsAt: null,
remainingMs: remaining,
};
return this.state;
}
case 'reset': {
this.state = {
...this.state,
revision: this.state.revision + 1,
running: false,
endsAt: null,
remainingMs: this.state.durationMs,
};
return this.state;
}
case 'setVisibleToPlayers': {
if (this.state.visibleToPlayers === event.visible) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
visibleToPlayers: event.visible,
};
return this.state;
}
case 'layout.set': {
const layout = clampSessionTimerLayout(event.layout);
this.state = {
...this.state,
revision: this.state.revision + 1,
layout,
};
return this.state;
}
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
/** Когда running и время вышло — зафиксировать 00:00 (вызывается из UI/main по желанию). */
settleIfExpired(now = Date.now()): SessionTimerState {
if (!this.state.running) return this.state;
const remaining = getSessionTimerRemainingMs(this.state, now);
if (remaining > 0) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
running: false,
endsAt: null,
remainingMs: 0,
};
return this.state;
}
/** Слепок: таймер на паузе, без абсолютного момента конца. */
loadPaused(next: Pick<SessionTimerState, 'durationMs' | 'remainingMs' | 'visibleToPlayers' | 'layout'>): SessionTimerState {
const durationMs = clampSessionTimerDurationMs(next.durationMs);
const remainingMs = Math.max(0, Math.min(durationMs, Math.round(next.remainingMs)));
this.state = {
revision: this.state.revision + 1,
durationMs,
remainingMs,
running: false,
endsAt: null,
visibleToPlayers: Boolean(next.visibleToPlayers),
layout: clampSessionTimerLayout(next.layout),
};
return this.state;
}
}
@@ -112,4 +112,12 @@ export class SceneTokensSessionStore {
} }
} }
} }
load(byPlacementId: SceneTokensSessionState['byPlacementId']): SceneTokensSessionState {
this.state = {
revision: this.state.revision + 1,
byPlacementId: { ...byPlacementId },
};
return this.state;
}
} }
+13
View File
@@ -166,6 +166,19 @@ export class TokenPathSessionStore {
} }
} }
} }
load(
playback: TokenPathSessionState['playback'],
presentationVisible: TokenPathSessionState['presentationVisible'],
): TokenPathSessionState {
this.state = {
revision: this.state.revision + 1,
playback: { ...playback },
presentationVisible: { ...presentationVisible },
serverNowMs: Date.now(),
};
return this.state;
}
} }
export type { TokenPathTargetRef }; export type { TokenPathTargetRef };
@@ -52,6 +52,53 @@ void test('createWindows: окно материалов закрывается
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/); assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
}); });
void test('createWindows: Escape запрашивает confirm на пульте', () => {
const src = readCreateWindows();
assert.ok(src.includes('export function requestCloseMultiWindowConfirm'));
assert.ok(src.includes('closeMultiWindowConfirmRequested'));
assert.match(src, /requestCloseMultiWindowConfirm[\s\S]*ctrl\.focus\(\)/);
});
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'));
assert.ok(src.includes('closeTimerWindow'));
assert.ok(src.includes("createWindow('timer'"));
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeTimerWindow/);
});
void test('createWindows: окно инициативы закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openInitiativeWindow'));
assert.ok(src.includes('closeInitiativeWindow'));
assert.ok(src.includes("createWindow('initiative'"));
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeInitiativeWindow/);
});
void test('createWindows: окно кубиков закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openDiceWindow'));
assert.ok(src.includes('closeDiceWindow'));
assert.ok(src.includes("createWindow('dice'"));
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'));
@@ -65,10 +112,11 @@ void test('createWindows: окно НПС закрывается с multi-window
assert.ok(src.includes('will-attach-webview')); assert.ok(src.includes('will-attach-webview'));
}); });
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => { void test('createWindows: закрытие пульта и презентации спрашивает confirm', () => {
const src = readCreateWindows(); const src = readCreateWindows();
assert.match(src, /kind === 'control'/); assert.match(src, /kind === 'control'[\s\S]*?e\.preventDefault\(\)[\s\S]*?requestCloseMultiWindowConfirm/);
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/); assert.match(src, /kind === 'presentation'[\s\S]*?e\.preventDefault\(\)[\s\S]*?requestCloseMultiWindowConfirm/);
assert.match(src, /export function closeMultiWindow[\s\S]*closePlaySessionAuxiliaryWindows/);
}); });
void test('createWindows: production — loadFile для HTML (не только file://)', () => { void test('createWindows: production — loadFile для HTML (не только file://)', () => {
@@ -89,3 +137,10 @@ void test('createWindows: логи окон не валят main через EPIP
assert.ok(src.includes('errorCode === -3')); assert.ok(src.includes('errorCode === -3'));
assert.ok(src.includes('isMainFrame')); assert.ok(src.includes('isMainFrame'));
}); });
void test('createWindows: presentation/control не троттлят RAF в фоне (macOS paths)', () => {
const src = readCreateWindows();
assert.match(src, /kind === 'presentation' \|\| kind === 'control'/);
assert.match(src, /backgroundThrottling:\s*false/);
assert.match(src, /setBackgroundThrottling\(false\)/);
});
+355 -30
View File
@@ -1,9 +1,13 @@
import path from 'node:path'; import path from 'node:path';
import { app, BrowserWindow, screen } from 'electron'; import { app, BrowserWindow, screen, shell } from 'electron';
import type { PresentationSettings } from '../../shared/types/presentationSettings';
import { readPresentationSettings } from '../preferences/appPreferences';
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding'; import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
import { resolvePresentationDisplay } from './presentationDisplay';
import { safeConsoleError } from '../safeConsole'; import { safeConsoleError } from '../safeConsole';
@@ -16,10 +20,15 @@ export type WindowKind =
| 'control' | 'control'
| 'sceneDescription' | 'sceneDescription'
| 'materials' | 'materials'
| 'playlists'
| 'timer'
| 'initiative'
| 'dice'
| '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[] = [
@@ -45,11 +54,47 @@ 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;
const MATERIALS_WINDOW_HEIGHT = 720; const MATERIALS_WINDOW_HEIGHT = 720;
/** Окно плейлистов на пульте. */
const PLAYLISTS_WINDOW_WIDTH = 720;
const PLAYLISTS_WINDOW_MIN_WIDTH = 640;
const PLAYLISTS_WINDOW_HEIGHT = 800;
/** Компактное окно таймера сессии. */
const TIMER_WINDOW_WIDTH = 360;
const TIMER_WINDOW_HEIGHT = 420;
/** Колбэк при закрытии окна таймера (Esc / крестик / programmatic). */
let timerWindowClosedListener: (() => void) | null = null;
export function setTimerWindowClosedListener(listener: (() => void) | null): void {
timerWindowClosedListener = listener;
}
/** Окно трекера инициативы. */
const INITIATIVE_WINDOW_WIDTH = 920;
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;
export function setDiceWindowClosedListener(listener: (() => void) | null): void {
diceWindowClosedListener = listener;
}
/** Редактор НПС — как основной редактор, шире инспектор. */ /** Редактор НПС — как основной редактор, шире инспектор. */
const NPCS_EDITOR_WINDOW_WIDTH = 1400; const NPCS_EDITOR_WINDOW_WIDTH = 1400;
const NPCS_EDITOR_WINDOW_HEIGHT = 860; const NPCS_EDITOR_WINDOW_HEIGHT = 860;
@@ -109,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;
@@ -159,23 +213,13 @@ function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
function closePlaySessionAuxiliaryWindows(): void { function closePlaySessionAuxiliaryWindows(): void {
closeSceneDescriptionWindow(); closeSceneDescriptionWindow();
closeMaterialsWindow(); closeMaterialsWindow();
closePlaylistsWindow();
closeTimerWindow();
closeInitiativeWindow();
closeDiceWindow();
closeNpcsWindow(); closeNpcsWindow();
} }
function closePresentationWindow(): void {
const pres = windows.get('presentation');
if (pres && !pres.isDestroyed()) {
pres.close();
}
}
function closeControlWindow(): void {
const ctrl = windows.get('control');
if (ctrl && !ctrl.isDestroyed()) {
ctrl.close();
}
}
function quitAppFromEditorClose(): void { function quitAppFromEditorClose(): void {
markAppQuitting(); markAppQuitting();
app.quit(); app.quit();
@@ -207,6 +251,14 @@ function pageNameForKind(kind: WindowKind): string {
return 'sceneDescription.html'; return 'sceneDescription.html';
case 'materials': case 'materials':
return 'materials.html'; return 'materials.html';
case 'playlists':
return 'playlists.html';
case 'timer':
return 'timer.html';
case 'initiative':
return 'initiative.html';
case 'dice':
return 'dice.html';
case 'npcsEditor': case 'npcsEditor':
return 'npcsEditor.html'; return 'npcsEditor.html';
case 'sceneEditor': case 'sceneEditor':
@@ -215,6 +267,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';
} }
} }
@@ -282,10 +336,15 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
if (kind === 'control') return { width: 1200, height: 800 }; if (kind === 'control') return { width: 1200, height: 800 };
if (kind === 'sceneDescription') return { width: 720, height: 640 }; if (kind === 'sceneDescription') return { width: 720, height: 640 };
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT }; if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
if (kind === 'playlists') return { width: PLAYLISTS_WINDOW_WIDTH, height: PLAYLISTS_WINDOW_HEIGHT };
if (kind === 'timer') return { width: TIMER_WINDOW_WIDTH, height: TIMER_WINDOW_HEIGHT };
if (kind === 'initiative') return { width: INITIATIVE_WINDOW_WIDTH, height: INITIATIVE_WINDOW_HEIGHT };
if (kind === 'dice') return { width: DICE_WINDOW_WIDTH, height: DICE_WINDOW_HEIGHT };
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT }; if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
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 };
} }
@@ -312,6 +371,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,
@@ -345,6 +412,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 } : {}),
@@ -358,6 +433,10 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
autoplayPolicy: 'no-user-gesture-required', autoplayPolicy: 'no-user-gesture-required',
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим. // file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL), webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
// macOS throttles RAF in unfocused windows — token paths and video stutter on presentation.
...(kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor'
? { backgroundThrottling: false }
: {}),
...(kind === 'npcs' ...(kind === 'npcs'
? { ? {
webviewTag: true, webviewTag: true,
@@ -376,6 +455,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
} }
bindWindowChromeTitle(win, kind); bindWindowChromeTitle(win, kind);
if (kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor') {
win.webContents.setBackgroundThrottling(false);
}
if (kind === 'npcs') { if (kind === 'npcs') {
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => { win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
delete guestPrefs.preload; delete guestPrefs.preload;
@@ -392,10 +474,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
if ( if (
kind === 'sceneDescription' || kind === 'sceneDescription' ||
kind === 'materials' || kind === 'materials' ||
kind === 'playlists' ||
kind === 'timer' ||
kind === 'npcsEditor' || kind === 'npcsEditor' ||
kind === 'sceneEditor' || kind === 'sceneEditor' ||
kind === 'tokenPathEditor' || kind === 'tokenPathEditor' ||
kind === 'npcs' kind === 'npcs' ||
kind === 'instructions'
) { ) {
win.setMenuBarVisibility(false); win.setMenuBarVisibility(false);
} }
@@ -403,6 +488,17 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
win.webContents.on('preload-error', (_event, preloadPath, error) => { win.webContents.on('preload-error', (_event, preloadPath, error) => {
safeConsoleError(`[preload-error] ${preloadPath}:`, error); safeConsoleError(`[preload-error] ${preloadPath}:`, error);
}); });
win.webContents.setWindowOpenHandler(({ url }) => {
try {
const parsed = new URL(url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
void shell.openExternal(parsed.toString());
}
} catch {
/* ignore invalid urls */
}
return { action: 'deny' };
});
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим. // -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
if (errorCode === -3) return; if (errorCode === -3) return;
@@ -425,23 +521,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}); });
} }
if (kind === 'control') { if (kind === 'control') {
win.on('close', () => { win.on('close', (e) => {
if (appQuitting || closingPlaySession) return; if (appQuitting || closingPlaySession) return;
closingPlaySession = true; e.preventDefault();
closePlaySessionAuxiliaryWindows(); requestCloseMultiWindowConfirm();
closePresentationWindow();
}); });
} }
if (kind === 'presentation') { if (kind === 'presentation') {
bindPresentationContentSizeTracking(win); bindPresentationContentSizeTracking(win);
win.on('close', () => { win.on('close', (e) => {
if (appQuitting || closingPlaySession) return; if (appQuitting || closingPlaySession) return;
closingPlaySession = true; e.preventDefault();
closePlaySessionAuxiliaryWindows(); requestCloseMultiWindowConfirm();
closeControlWindow();
}); });
} }
win.on('closed', () => windows.delete(kind)); win.on('closed', () => windows.delete(kind));
if (kind === 'timer') {
win.on('closed', () => {
timerWindowClosedListener?.();
});
}
if (kind === 'dice') {
win.on('closed', () => {
diceWindowClosedListener?.();
});
}
if (kind === 'sceneEditor') { if (kind === 'sceneEditor') {
win.on('closed', () => { win.on('closed', () => {
closeTokenPathEditorWindow(); closeTokenPathEditorWindow();
@@ -517,20 +621,36 @@ export function focusEditorWindow(): void {
} }
} }
export function openMultiWindow() { export function openMultiWindow(presentationSettings?: PresentationSettings) {
const settings = presentationSettings ?? readPresentationSettings(app.getPath('userData'));
let presentation = windows.get('presentation'); let presentation = windows.get('presentation');
if (!presentation) { if (!presentation) {
const display = screen.getPrimaryDisplay(); const display = resolvePresentationDisplay(settings.displayId);
const { x, y, width, height } = display.bounds; const { x, y, width, height } = display.bounds;
presentation = createWindow('presentation'); presentation = createWindow('presentation');
presentation.setBounds({ x, y, width, height }); presentation.setBounds({ x, y, width, height });
presentation.setMenuBarVisibility(false); presentation.setMenuBarVisibility(false);
if (settings.fullScreen) {
presentation.setFullScreen(true);
} else {
presentation.maximize(); presentation.maximize();
} }
}
if (!windows.has('control')) { if (!windows.has('control')) {
// macOS: parent-child window binding moves child with the parent (unlike Windows behavior we want). const editor = windows.get('editor');
// Keep control window independent on darwin. const editorDisplay = editor
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation }); ? screen.getDisplayMatching(editor.getBounds())
: screen.getPrimaryDisplay();
const control = createWindow('control');
const area = editorDisplay.workArea;
const cw = Math.min(1200, Math.max(640, area.width - 80));
const ch = Math.min(800, Math.max(480, area.height - 80));
control.setBounds({
x: area.x + Math.max(0, Math.round((area.width - cw) / 2)),
y: area.y + Math.max(0, Math.round((area.height - ch) / 2)),
width: cw,
height: ch,
});
} }
broadcastMultiWindowStateChanged(true); broadcastMultiWindowStateChanged(true);
broadcastPresentationContentSize(); broadcastPresentationContentSize();
@@ -548,6 +668,19 @@ export function closeMultiWindow(): void {
} }
} }
/** Фокус пульта + событие: выход из показа спрашивает подтверждение на пульте. */
export function requestCloseMultiWindowConfirm(): void {
const ctrl = windows.get('control');
if (!ctrl || ctrl.isDestroyed()) {
closeMultiWindow();
return;
}
if (ctrl.isMinimized()) ctrl.restore();
ctrl.show();
ctrl.focus();
ctrl.webContents.send(ipcChannels.windows.closeMultiWindowConfirmRequested, {});
}
export function isMultiWindowOpen(): boolean { export function isMultiWindowOpen(): boolean {
return windows.has('presentation') || windows.has('control'); return windows.has('presentation') || windows.has('control');
} }
@@ -566,6 +699,34 @@ export function closeMaterialsWindow(): void {
} }
} }
export function closePlaylistsWindow(): void {
const win = windows.get('playlists');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeTimerWindow(): void {
const win = windows.get('timer');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeInitiativeWindow(): void {
const win = windows.get('initiative');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeDiceWindow(): void {
const win = windows.get('dice');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeNpcsEditorWindow(): void { export function closeNpcsEditorWindow(): void {
const win = windows.get('npcsEditor'); const win = windows.get('npcsEditor');
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
@@ -652,6 +813,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;
@@ -725,6 +924,132 @@ 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();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('playlists', 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', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function openTimerWindow(): void {
const existing = windows.get('timer');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('timer', 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', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function openInitiativeWindow(opts?: { onClosed?: () => void }): void {
const existing = windows.get('initiative');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('initiative', parent ? { parent } : undefined);
if (opts?.onClosed) {
win.once('closed', () => {
opts.onClosed?.();
});
}
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', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function openDiceWindow(): void {
const existing = windows.get('dice');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('dice', 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', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
function positionNpcsEditorWindow(win: BrowserWindow): void { function positionNpcsEditorWindow(win: BrowserWindow): void {
const parent = windows.get('editor'); const parent = windows.get('editor');
const { width, height } = win.getBounds(); const { width, height } = win.getBounds();
+27
View File
@@ -0,0 +1,27 @@
import { screen, type Display } from 'electron';
import type { DisplayInfo } from '../../shared/types/presentationSettings';
export function listDisplayInfos(): DisplayInfo[] {
const primaryId = screen.getPrimaryDisplay().id;
return screen.getAllDisplays().map((d) => displayToInfo(d, d.id === primaryId));
}
function displayToInfo(display: Display, isPrimary: boolean): DisplayInfo {
const label = display.label?.trim() || `Display ${String(display.id)}`;
return {
id: display.id,
label,
width: display.bounds.width,
height: display.bounds.height,
isPrimary,
};
}
export function resolvePresentationDisplay(displayId: number | null): Display {
if (displayId != null) {
const found = screen.getAllDisplays().find((d) => d.id === displayId);
if (found) return found;
}
return screen.getPrimaryDisplay();
}
+183 -5
View File
@@ -160,6 +160,50 @@
margin-top: 4px; margin-top: 4px;
} }
.modalFooterWrap {
flex-wrap: wrap;
}
.saveList {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow: auto;
}
.saveRow {
text-align: left;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
}
.saveRowSelected {
border-color: var(--scene-list-selected-border);
background: var(--scene-list-selected-bg);
}
.saveNameInput {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-surface);
color: inherit;
font: inherit;
}
.saveError {
color: var(--color-danger);
font-size: 13px;
}
.descriptionViewBody { .descriptionViewBody {
min-height: 360px; min-height: 360px;
max-height: min(560px, calc(100vh - 200px)); max-height: min(560px, calc(100vh - 200px));
@@ -217,11 +261,58 @@
flex-direction: column; flex-direction: column;
} }
.storyScroll { .storyWrapCollapsed {
flex: 0 0 70%; flex: 0 0 auto;
}
.storyAccordions {
flex: 1;
min-height: 0; min-height: 0;
border-radius: var(--radius-lg); display: flex;
flex-direction: column;
gap: 8px;
}
.storyAccordionExpanded {
flex: 1;
min-height: 0;
}
.storyAccordionCollapsed {
flex: 0 0 auto;
}
.storyWrapCollapsed .historyAccordion {
flex: 0 0 auto;
}
.historyAccordion {
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
border-radius: var(--radius-lg);
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.historyAccordionHead {
width: 100%;
text-align: left;
padding: 10px 12px;
font-size: var(--text-xs);
font-weight: 800;
color: var(--text2);
background: transparent;
border: 0;
cursor: pointer;
flex-shrink: 0;
}
.storyScroll {
flex: 1;
min-height: 0;
border-top: 1px solid var(--stroke);
background: var(--color-overlay-dark-2); background: var(--color-overlay-dark-2);
overflow: auto; overflow: auto;
padding: 10px; padding: 10px;
@@ -292,19 +383,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 {
@@ -312,6 +423,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
min-height: 34px;
} }
.snapToGrid { .snapToGrid {
@@ -319,26 +431,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);
} }
@@ -394,6 +517,61 @@
pointer-events: none; pointer-events: none;
} }
.brushCursorSquare {
border-radius: 2px;
}
.brushShapeRow {
display: grid;
grid-template-columns: 120px 1fr;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.brushShapeButtons {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.brushShapeIcon {
display: flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
color: var(--text1);
}
.markerColorInput {
flex: 0 0 40px;
width: 40px;
height: 34px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
overflow: hidden;
}
.markerColorInput::-webkit-color-swatch-wrapper {
padding: 0;
border-radius: inherit;
}
.markerColorInput::-webkit-color-swatch {
border: none;
border-radius: inherit;
}
.markerColorInput::-moz-color-swatch {
border: none;
border-radius: inherit;
}
.brushLayer { .brushLayer {
position: absolute; position: absolute;
inset: 0; inset: 0;
File diff suppressed because it is too large Load Diff
@@ -32,4 +32,18 @@ void test('ControlApp: draft кисти без корневого draftFxTick',
assert.ok(pixi.includes('setDraft')); assert.ok(pixi.includes('setDraft'));
assert.ok(pixi.includes('mergeDraftState')); assert.ok(pixi.includes('mergeDraftState'));
assert.ok(pixi.includes('forwardRef')); assert.ok(pixi.includes('forwardRef'));
assert.ok(app.includes('perfProfile="preview"'));
assert.ok(app.includes('OVERLAY_DRAFT_IPC_MS'));
});
void test('ControlApp: path playback не гоняет setState корня', () => {
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
const hook = fs.readFileSync(
path.join(here, '../shared/tokens/useTokenPathLivePoses.ts'),
'utf8',
);
assert.doesNotMatch(app, /\bpathLivePoses\b/);
assert.doesNotMatch(hook, /\bsetPoses\b/);
assert.ok(hook.includes('applyLivePathPose'));
assert.ok(hook.includes('requestAnimationFrame'));
}); });
@@ -78,6 +78,15 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
const src = readControlApp(); const src = readControlApp();
assert.ok(src.includes("t('control.instruments')")); assert.ok(src.includes("t('control.instruments')"));
assert.ok(src.includes("t('control.descriptionTool')")); assert.ok(src.includes("t('control.descriptionTool')"));
assert.ok(src.includes("t('control.markerTool')"));
assert.ok(src.includes("selectEffectTool('marker')"));
assert.ok(src.includes('SceneMarkerOverlay'));
assert.ok(src.includes('useSceneMarkerState'));
assert.ok(src.includes("t('control.markerColor')"));
assert.ok(src.includes('dispatchMarkerEraserPoints'));
assert.ok(src.includes('markerViewport'));
assert.ok(src.includes('toMarkerNPoint'));
assert.ok(src.includes("kind: 'strokes.clear'"));
assert.ok(src.includes("t('control.descriptionMissing')")); assert.ok(src.includes("t('control.descriptionMissing')"));
assert.ok(src.includes('openSceneDescription')); assert.ok(src.includes('openSceneDescription'));
assert.ok(!src.includes('SceneDescriptionViewModal')); assert.ok(!src.includes('SceneDescriptionViewModal'));
@@ -88,6 +97,8 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
assert.ok(src.includes("t('control.darknessControl')")); assert.ok(src.includes("t('control.darknessControl')"));
assert.ok(src.includes("t('control.explorerBrush')")); assert.ok(src.includes("t('control.explorerBrush')"));
assert.ok(src.includes("t('control.closerBrush')")); assert.ok(src.includes("t('control.closerBrush')"));
assert.ok(src.includes("t('control.bakeDarknessToScene')"));
assert.ok(src.includes('bake-darkness-to-scene'));
assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')")); assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'")); assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
assert.ok(src.includes('SceneDarknessOverlay')); assert.ok(src.includes('SceneDarknessOverlay'));
@@ -113,7 +124,7 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
instruments !== -1 && fx !== -1 && instruments < fx, instruments !== -1 && fx !== -1 && instruments < fx,
'Блок инструментов должен быть выше эффектов', 'Блок инструментов должен быть выше эффектов',
); );
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии'); assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше истории');
}); });
void test('ControlApp: чекбокс привязки токенов к сетке', () => { void test('ControlApp: чекбокс привязки токенов к сетке', () => {
@@ -126,19 +137,38 @@ 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('aria-expanded={historyOpen}'));
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\)/);
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/); assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
}); });
void test('ControlApp: блок «Сцены» — аккордеон под историей, свёрнут по умолчанию', () => {
const src = readControlApp();
assert.ok(src.includes('data-testid="control-scenes-accordion"'));
assert.ok(src.includes("t('control.scenesAccordion')"));
assert.ok(src.includes('const [scenesOpen, setScenesOpen] = useState(false)'));
assert.ok(src.includes('listActiveStorylineScenes'));
assert.ok(src.includes('storylineScenes.map'));
const historyIdx = src.indexOf('data-testid="control-history-accordion"');
const scenesIdx = src.indexOf('data-testid="control-scenes-accordion"');
assert.ok(historyIdx !== -1 && scenesIdx !== -1 && historyIdx < scenesIdx);
});
void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => { void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => {
const src = readControlApp(); const src = readControlApp();
assert.ok(!src.includes('suppressNextHistoryPushRef')); assert.ok(!src.includes('suppressNextHistoryPushRef'));
@@ -173,6 +203,27 @@ void test('ControlApp: слой кисти не использует курсо
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/); assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
}); });
void test('ControlApp: форма кисти затемнения — UI, shape в штрихах и сброс', () => {
const src = readControlApp();
const css = readControlAppCss();
assert.ok(src.includes("t('control.brushShape')"));
assert.ok(src.includes("t('control.brushShapeCircle')"));
assert.ok(src.includes("t('control.brushShapeSquare')"));
assert.ok(src.includes('darknessBrushShape'));
assert.ok(src.includes('darknessBrushDraft'));
assert.ok(src.includes("shape: darknessBrushShapeRef.current"));
assert.ok(src.includes('setDarknessBrushShape'));
assert.ok(src.includes('styles.brushShapeRow'));
assert.ok(src.includes("tool.tool === 'exploreBrush' || tool.tool === 'closeBrush'"));
assert.ok(css.includes('.brushCursorSquare'));
assert.ok(css.includes('.brushShapeRow'));
const radius = src.indexOf("t('control.brushRadius')");
const shape = src.indexOf("t('control.brushShape')");
const sound = src.indexOf("t('control.effectsSound')");
assert.ok(radius !== -1 && shape !== -1 && sound !== -1);
assert.ok(radius < shape && shape < sound, 'Форма кисти между радиусом и звуком эффектов');
});
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => { void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
const src = readControlApp(); const src = readControlApp();
const previewLabel = src.indexOf("t('control.screenPreview')"); const previewLabel = src.indexOf("t('control.screenPreview')");
@@ -267,3 +318,20 @@ void test('ControlApp: загрузка камп. аудио — useEffect за
assert.ok(!/\bproject\b/.test(depList)); assert.ok(!/\bproject\b/.test(depList));
assert.ok(!/\bcampaignAudioRefs\b/.test(depList)); assert.ok(!/\bcampaignAudioRefs\b/.test(depList));
}); });
void test('ControlApp: Escape → своя модалка подтверждения завершения показа', () => {
const src = readControlApp();
assert.ok(src.includes('closeMultiWindowConfirmRequested'));
assert.ok(src.includes('stopConfirmOpen'));
assert.ok(src.includes('control.stopConfirm.title'));
assert.ok(src.includes('data-testid="stop-confirm-modal"'));
assert.ok(src.includes('data-testid="control-help-tool"'));
assert.match(src, /data-testid="control-help-tool"[\s\S]{0,180}iconOnly|iconOnly[\s\S]{0,180}data-testid="control-help-tool"/);
assert.ok(src.includes("section: 'controlPanel'"));
assert.equal((src.match(/windows\.closeMultiWindow,/g) ?? []).length, 3);
assert.ok(src.includes('data-testid="control-save-game"'));
assert.ok(src.includes('data-testid="stop-confirm-save-and-end"'));
assert.ok(src.includes('data-testid="stop-confirm-discard"'));
assert.ok(!src.includes('showMessageBox'));
assert.ok(!src.includes('window.confirm'));
});
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/dice/main.tsx"></script>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
.page {
height: 100vh;
box-sizing: border-box;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg);
color: var(--text);
}
.sectionLabel {
font-size: var(--text-xs);
font-weight: 800;
letter-spacing: 0.06em;
color: var(--text2);
text-transform: uppercase;
}
.sidesGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.sidesGrid > * {
width: 100%;
max-width: none;
}
.sidesGrid button {
width: 100%;
justify-content: center;
}
.countRow {
display: grid;
grid-template-columns: 48px 1fr 48px;
gap: 8px;
align-items: center;
}
.countValue {
text-align: center;
font-size: 28px;
font-weight: 800;
font-variant-numeric: tabular-nums;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
padding: 8px;
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
color: inherit;
width: 100%;
box-sizing: border-box;
outline: none;
}
.countValue:focus {
border-color: rgba(147, 197, 253, 0.75);
}
.actions {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 8px;
margin-top: 4px;
}
.checkRow {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--text-sm);
font-weight: 600;
user-select: none;
}
.actions > * {
width: 100%;
max-width: none;
}
.actions button {
width: 100%;
justify-content: center;
}
.lastResult {
margin-top: auto;
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
display: flex;
flex-direction: column;
gap: 8px;
}
.lastLabel {
font-size: 11px;
font-weight: 700;
color: var(--text2);
}
.lastNotation {
font-size: 13px;
font-weight: 700;
color: #93c5fd;
}
.valueChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.valueChip {
min-width: 36px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(245, 197, 66, 0.55);
background: rgba(245, 197, 66, 0.12);
color: #fef3c7;
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
text-align: center;
}
.lastTotal {
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.rollingHint {
font-size: 12px;
color: var(--text2);
}
.emptyResult {
margin-top: auto;
padding: 12px;
color: var(--text2);
font-size: var(--text-sm);
}
+190
View File
@@ -0,0 +1,190 @@
import React, { useEffect, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import {
DICE_COUNT_MAX,
DICE_COUNT_MIN,
DICE_SIDES,
clampDiceCount,
type DiceSides,
} from '../../shared/types/diceRoll';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { useDiceRollState } from '../shared/dice/useDiceRollState';
import { Button } from '../shared/ui/controls';
import styles from './DiceApp.module.css';
export function DiceApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [state, diceApi] = useDiceRollState();
const [sides, setSides] = useState<DiceSides>(20);
const [count, setCount] = useState(1);
const [countDraft, setCountDraft] = useState('1');
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeDice, {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
const commitCountDraft = () => {
const trimmed = countDraft.trim();
if (trimmed === '') {
setCount(DICE_COUNT_MIN);
setCountDraft(String(DICE_COUNT_MIN));
return;
}
const parsed = Number(trimmed);
if (!Number.isFinite(parsed) || parsed < DICE_COUNT_MIN) {
setCount(DICE_COUNT_MIN);
setCountDraft(String(DICE_COUNT_MIN));
return;
}
const next = clampDiceCount(parsed);
setCount(next);
setCountDraft(String(next));
};
const setCountClamped = (n: number) => {
const next = clampDiceCount(n);
setCount(next);
setCountDraft(String(next));
};
const showToPlayers = state?.visibleToPlayers ?? true;
return (
<div className={styles.page} data-testid="dice-app">
<div className={styles.sectionLabel}>{t('dice.sides')}</div>
<div className={styles.sidesGrid}>
{DICE_SIDES.map((s) => (
<Button
key={s}
variant={sides === s ? 'primary' : 'ghost'}
onClick={() => setSides(s)}
data-testid={`dice-side-${s}`}
>
d{s}
</Button>
))}
</div>
<div className={styles.sectionLabel}>{t('dice.count')}</div>
<div className={styles.countRow}>
<Button
variant="ghost"
onClick={() => setCountClamped(count - 1)}
ariaLabel={t('dice.dec')}
disabled={count <= DICE_COUNT_MIN}
>
−
</Button>
<input
className={styles.countValue}
type="text"
inputMode="numeric"
value={countDraft}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '');
if (digits === '') {
setCountDraft('');
return;
}
const n = Number(digits);
if (!Number.isFinite(n)) {
setCountDraft(String(DICE_COUNT_MIN));
setCount(DICE_COUNT_MIN);
return;
}
if (n > DICE_COUNT_MAX) {
setCountDraft(String(DICE_COUNT_MAX));
setCount(DICE_COUNT_MAX);
return;
}
setCountDraft(digits);
}}
onBlur={() => commitCountDraft()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitCountDraft();
(e.currentTarget as HTMLInputElement).blur();
}
}}
aria-label={t('dice.count')}
data-testid="dice-count"
/>
<Button
variant="ghost"
onClick={() => setCountClamped(count + 1)}
ariaLabel={t('dice.inc')}
disabled={count >= DICE_COUNT_MAX}
>
+
</Button>
</div>
<label className={styles.checkRow}>
<input
type="checkbox"
checked={showToPlayers}
onChange={(e) =>
void diceApi.dispatch({ kind: 'setVisibleToPlayers', visible: e.target.checked })
}
data-testid="dice-show-to-players"
/>
<span>{t('dice.showAnimationToPlayers')}</span>
</label>
<div className={styles.actions}>
<Button
variant="primary"
disabled={clampDiceCount(Number(countDraft.trim()) || count) < 1}
onClick={() => {
const next = clampDiceCount(Number(countDraft.trim()));
setCount(next);
setCountDraft(String(next));
if (next < 1) return;
void diceApi.dispatch({ kind: 'roll', count: next, sides });
}}
data-testid="dice-roll"
>
{t('dice.roll')}
</Button>
<Button
variant="ghost"
disabled={!state?.values.length && !state?.active}
onClick={() => void diceApi.dispatch({ kind: 'clear' })}
>
{t('dice.clear')}
</Button>
</div>
{state && state.values.length > 0 ? (
<div className={styles.lastResult} data-testid="dice-last-result">
<div className={styles.lastLabel}>{t('dice.result')}</div>
<div className={styles.lastNotation}>{state.notation}</div>
<div className={styles.valueChips} aria-label={t('dice.result')}>
{state.values.map((v, i) => (
<span key={`${state.revision}-${i}`} className={styles.valueChip}>
{v}
</span>
))}
</div>
<div className={styles.lastTotal}>
{t('dice.total')}: <strong>{state.total}</strong>
</div>
{state.active ? <div className={styles.rollingHint}>{t('dice.rolling')}</div> : null}
</div>
) : (
<div className={styles.emptyResult}>{t('dice.noResult')}</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { DiceApp } from './DiceApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Dice">
<EditorI18nProvider>
<DiceApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
+96 -112
View File
@@ -53,6 +53,12 @@
user-select: none; user-select: none;
} }
.helpMark {
font-size: 18px;
font-weight: 400;
line-height: 1;
}
.headerActions { .headerActions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -379,6 +385,11 @@
font: inherit; font: inherit;
} }
.fileMenuItem:disabled {
opacity: 0.45;
cursor: default;
}
.fileMenuItemDanger { .fileMenuItemDanger {
text-align: left; text-align: left;
padding: 10px; padding: 10px;
@@ -519,6 +530,30 @@
box-sizing: border-box; box-sizing: border-box;
} }
.saveList {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow: auto;
}
.saveRow {
text-align: left;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
}
.saveRowSelected {
border-color: var(--scene-list-selected-border);
background: var(--scene-list-selected-bg);
}
.licenseBlockTitle { .licenseBlockTitle {
font-weight: 900; font-weight: 900;
font-size: var(--text-lg); font-size: var(--text-lg);
@@ -550,113 +585,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;
@@ -688,6 +616,13 @@
margin-top: 4px; margin-top: 4px;
} }
.aboutSponsorsList {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.45;
}
.aboutLink { .aboutLink {
color: var(--color-accent, #a78bfa); color: var(--color-accent, #a78bfa);
text-decoration: none; text-decoration: none;
@@ -1015,6 +950,11 @@
width: 100%; width: 100%;
} }
.actionsRowHalf > button {
min-width: 0;
width: 100%;
}
.actionsRowHalf > span { .actionsRowHalf > span {
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -1110,7 +1050,7 @@
.audioList { .audioList {
display: grid; display: grid;
gap: 6px; gap: 6px;
max-height: 160px; max-height: 280px;
overflow: auto; overflow: auto;
} }
@@ -1120,10 +1060,32 @@
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
background: var(--color-overlay-dark-3); background: var(--color-overlay-dark-3);
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr);
gap: 8px; gap: 6px;
align-items: center; align-items: start;
min-width: 0; min-width: 0;
cursor: default;
}
.audioDragHandle {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
cursor: grab;
user-select: none;
}
.audioDragHandle:active {
cursor: grabbing;
}
.audioDragGrip {
flex-shrink: 0;
color: var(--text2);
font-size: 10px;
letter-spacing: -2px;
line-height: 1;
} }
.audioName { .audioName {
@@ -1135,13 +1097,35 @@
.audioControls { .audioControls {
color: var(--text2); color: var(--text2);
flex-shrink: 0;
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
} }
.audioVolumeLabel {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.audioVolumeRange {
width: 72px;
accent-color: var(--accent, #3b82f6);
}
.audioPreviewBtn {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0 4px;
line-height: 1;
font-size: 12px;
}
.audioRemove { .audioRemove {
flex-shrink: 0; flex-shrink: 0;
min-width: 28px; min-width: 28px;
+262 -220
View File
@@ -16,6 +16,7 @@ import {
} from '../../shared/ipc/contracts'; } from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { GameSaveListItem } from '../../shared/types/gameSave';
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension'; import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
import type { import type {
AssetId, AssetId,
@@ -30,14 +31,16 @@ import type {
} from '../../shared/types'; } from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo'; import { AppLogo } from '../shared/branding/AppLogo';
import { ContainedVideo } from '../shared/ContainedVideo'; import { ContainedVideo } from '../shared/ContainedVideo';
import { stopAllEditorAudio } from '../shared/audio/editorAudioStop';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage'; import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls'; 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 { import {
filterAudioFilePaths, filterAudioFilePaths,
partitionSceneMediaDrops, partitionSceneMediaDrops,
@@ -59,6 +62,9 @@ import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/Edito
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals'; import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals'; import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal'; import { LaunchPlayersModal } from './LaunchPlayersModal';
import { PresentationSettingsModal } from './PresentationSettingsModal';
import { GameSystemsModal } from './GameSystemsModal';
import { PlaylistsManagerModal } from './PlaylistsModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml'; import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal'; import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState'; import type { ProjectNoticeCode } from './state/projectState';
@@ -125,10 +131,7 @@ export function EditorApp() {
const [projectMenuOpen, setProjectMenuOpen] = useState(false); const [projectMenuOpen, setProjectMenuOpen] = useState(false);
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false); const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false); const [settingsLangSubOpen, setSettingsLangSubOpen] = 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);
@@ -160,10 +163,34 @@ export function EditorApp() {
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false); const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false); const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false); const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [gameSaves, setGameSaves] = useState<GameSaveListItem[]>([]);
const [loadSaveOpen, setLoadSaveOpen] = useState(false);
const [loadSaveId, setLoadSaveId] = useState<string | null>(null);
const [loadSaveError, setLoadSaveError] = useState('');
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [gameSystemsOpen, setGameSystemsOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false); const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null); const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
const runSplitRef = useRef<HTMLDivElement | null>(null); const runSplitRef = useRef<HTMLDivElement | null>(null);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null); const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const listGameSystems = useCallback(async () => {
const res = await getDndApi().invoke(ipcChannels.gameSystems.list, {});
return res.systems;
}, []);
const importGameSystem = useCallback(async () => {
const res = await getDndApi().invoke(ipcChannels.gameSystems.importFromFile, {});
return res.canceled ? null : res.system;
}, []);
const deleteGameSystem = useCallback(async (id: string) => {
await getDndApi().invoke(ipcChannels.gameSystems.delete, { id });
}, []);
const previewGameSystem = useCallback(async (id: string) => {
console.log('[gameSystems] preview stub', id);
window.alert(`Preview (stub): ${id}`);
}, []);
const onProjectNotice = useCallback( const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => { (code: ProjectNoticeCode) => {
const handlers: Record<ProjectNoticeCode, () => void> = { const handlers: Record<ProjectNoticeCode, () => void> = {
@@ -256,11 +283,9 @@ export function EditorApp() {
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null); const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null); const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null); const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const aboutMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null); const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null);
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null); const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null); const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null);
const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null); const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
const [sceneListDrop, setSceneListDrop] = useState<{ const [sceneListDrop, setSceneListDrop] = useState<{
targetId: SceneId; targetId: SceneId;
@@ -359,9 +384,59 @@ 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 refreshGameSaves = useCallback(() => {
if (!state.project) {
setGameSaves([]);
return;
}
void getDndApi()
.invoke(ipcChannels.gameSave.list, {})
.then(
({ saves }) => setGameSaves(saves),
() => setGameSaves([]),
);
}, [state.project]);
useEffect(() => {
refreshGameSaves();
}, [refreshGameSaves]);
const launchFromSave = useCallback(
(saveId: string) => {
if (!licenseActive || launching) return;
stopAllEditorAudio();
setLaunching(true);
setRunMenuOpen(false);
setLoadSaveError('');
void (async () => {
try {
const res = await getDndApi().invoke(ipcChannels.windows.openMultiWindow, { saveId });
if (res.project) actions.noteProject(res.project);
setLoadSaveOpen(false);
} catch (err) {
setLaunching(false);
const message = err instanceof Error ? err.message : String(err);
setLoadSaveError(
message.includes('GAME_SAVE_CARD_MISSING') ? t('top.loadSave.cardMissing') : message,
);
}
})();
},
[licenseActive, launching, t, actions],
);
const launchFromGraphNode = useCallback( const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId, playerIds?: string[]) => { (graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return; if (!licenseActive || launching) return;
stopAllEditorAudio();
setLaunching(true); setLaunching(true);
setRunMenuOpen(false); setRunMenuOpen(false);
setLaunchPlayersOpen(false); setLaunchPlayersOpen(false);
@@ -380,7 +455,10 @@ export function EditorApp() {
); );
useEffect(() => { useEffect(() => {
if (presentationOpen) setLaunching(false); if (presentationOpen) {
setLaunching(false);
setPlaylistsManagerOpen(false);
}
}, [presentationOpen]); }, [presentationOpen]);
const currentProjectName = state.project?.meta.name ?? ''; const currentProjectName = state.project?.meta.name ?? '';
@@ -454,26 +532,6 @@ export function EditorApp() {
if (!settingsMenuOpen) setSettingsLangSubOpen(false); if (!settingsMenuOpen) setSettingsLangSubOpen(false);
}, [settingsMenuOpen]); }, [settingsMenuOpen]);
useEffect(() => {
if (!aboutMenuOpen) return;
const r = aboutMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setAboutMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setAboutMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-aboutmenu-root="1"]')) return;
setAboutMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [aboutMenuOpen]);
useEffect(() => { useEffect(() => {
if (!runMenuOpen) return; if (!runMenuOpen) return;
const r = runSplitRef.current?.getBoundingClientRect() ?? null; const r = runSplitRef.current?.getBoundingClientRect() ?? null;
@@ -501,6 +559,7 @@ export function EditorApp() {
// ignore // ignore
} }
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => { off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
if (open) stopAllEditorAudio();
setPresentationOpen(open); setPresentationOpen(open);
}); });
})(); })();
@@ -782,7 +841,6 @@ export function EditorApp() {
onClick={() => { onClick={() => {
setFileMenuOpen(false); setFileMenuOpen(false);
setProjectMenuOpen(false); setProjectMenuOpen(false);
setAboutMenuOpen(false);
setSettingsMenuOpen((v) => { setSettingsMenuOpen((v) => {
const next = !v; const next = !v;
if (next) setSettingsLangSubOpen(false); if (next) setSettingsLangSubOpen(false);
@@ -793,16 +851,14 @@ export function EditorApp() {
{t('top.settings')} {t('top.settings')}
</button> </button>
<button <button
ref={aboutMenuBtnRef}
type="button" type="button"
data-aboutmenu-root="1"
className={styles.fileMenuTrigger} className={styles.fileMenuTrigger}
onClick={() => { onClick={() => {
setFileMenuOpen(false); setFileMenuOpen(false);
setProjectMenuOpen(false); setProjectMenuOpen(false);
setSettingsMenuOpen(false); setSettingsMenuOpen(false);
setSettingsLangSubOpen(false); setSettingsLangSubOpen(false);
setAboutMenuOpen((v) => !v); setAppAboutOpen(true);
}} }}
> >
{t('top.aboutApp')} {t('top.aboutApp')}
@@ -818,7 +874,6 @@ export function EditorApp() {
if (!licenseActive) return; if (!licenseActive) return;
setFileMenuOpen(false); setFileMenuOpen(false);
setSettingsMenuOpen(false); setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setProjectMenuOpen((v) => !v); setProjectMenuOpen((v) => !v);
}} }}
> >
@@ -836,7 +891,6 @@ export function EditorApp() {
if (!licenseActive) return; if (!licenseActive) return;
setProjectMenuOpen(false); setProjectMenuOpen(false);
setSettingsMenuOpen(false); setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen((v) => !v); setFileMenuOpen((v) => !v);
}} }}
> >
@@ -854,7 +908,6 @@ export function EditorApp() {
if (!licenseActive) return; if (!licenseActive) return;
setProjectMenuOpen(false); setProjectMenuOpen(false);
setSettingsMenuOpen(false); setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen(false); setFileMenuOpen(false);
setPlayersManagerOpen(true); setPlayersManagerOpen(true);
}} }}
@@ -862,6 +915,22 @@ export function EditorApp() {
{t('top.players')} {t('top.players')}
</button> </button>
) : null} ) : null}
<button
type="button"
data-testid="playlists-header-btn"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setFileMenuOpen(false);
setPlaylistsManagerOpen(true);
}}
>
{t('top.playlists')}
</button>
</div> </div>
<div className={styles.flex1} /> <div className={styles.flex1} />
{appVersionText ? ( {appVersionText ? (
@@ -869,6 +938,19 @@ export function EditorApp() {
{appVersionText} {appVersionText}
</div> </div>
) : null} ) : null}
<Button
variant="ghost"
iconOnly
tooltipPlacement="bottom"
title={t('control.helpTool')}
ariaLabel={t('control.helpTool')}
data-testid="editor-help-tool"
onClick={() => openInstructions('overview')}
>
<span className={styles.helpMark} aria-hidden>
?
</span>
</Button>
<div className={styles.headerActions}> <div className={styles.headerActions}>
{state.project ? ( {state.project ? (
<> <>
@@ -907,6 +989,7 @@ export function EditorApp() {
data-testid="run-menu-btn" data-testid="run-menu-btn"
onClick={() => { onClick={() => {
if (runDisabled || launching) return; if (runDisabled || launching) return;
refreshGameSaves();
setRunMenuOpen((v) => !v); setRunMenuOpen((v) => !v);
}} }}
> >
@@ -943,8 +1026,7 @@ export function EditorApp() {
title={runHelpTooltip} title={runHelpTooltip}
tooltipPlacement="bottom-left" tooltipPlacement="bottom-left"
onClick={() => { onClick={() => {
setInstructionsSection(runHelpSection); openInstructions(runHelpSection);
setInstructionsOpen(true);
}} }}
> >
? ?
@@ -1184,7 +1266,6 @@ export function EditorApp() {
previewVideoAutostart={sc?.previewVideoAutostart ?? false} previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewVideoLoop={sc?.settings.loopVideo ?? false} previewVideoLoop={sc?.settings.loopVideo ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0} previewRotationDeg={sc?.previewRotationDeg ?? 0}
darkenScene={sc?.darkenScene ?? false}
previewBusy={previewBusy} previewBusy={previewBusy}
previewBusyText={previewBusyText} previewBusyText={previewBusyText}
mediaAssets={sceneMediaAssets} mediaAssets={sceneMediaAssets}
@@ -1198,7 +1279,6 @@ export function EditorApp() {
onPreviewVideoLoopChange={(next) => onPreviewVideoLoopChange={(next) =>
void actions.updateScene(sid, { settings: { loopVideo: next } }) void actions.updateScene(sid, { settings: { loopVideo: next } })
} }
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
onTitleChange={(title) => void actions.updateScene(sid, { title })} onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) => onDescriptionChange={(description) =>
void actions.updateScene(sid, { description }) void actions.updateScene(sid, { description })
@@ -1320,6 +1400,30 @@ export function EditorApp() {
{t('menu.checkUpdates')} {t('menu.checkUpdates')}
</button> </button>
) : null} ) : null}
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setPresentationSettingsOpen(true);
}}
>
{t('menu.presentationSettings')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setGameSystemsOpen(true);
}}
>
{t('menu.gameSystems')}
</button>
<div className={styles.fileMenuSubHost} role="presentation"> <div className={styles.fileMenuSubHost} role="presentation">
<button <button
type="button" type="button"
@@ -1401,46 +1505,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
? createPortal(
<div
role="menu"
data-aboutmenu-root="1"
className={styles.fileMenu}
style={{ left: aboutMenuPos.left, top: aboutMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setAboutMenuOpen(false);
setAppAboutOpen(true);
}}
>
{t('menu.aboutProgram')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setAboutMenuOpen(false);
setInstructionsSection('overview');
setInstructionsOpen(true);
}}
>
{t('menu.instructions')}
</button>
</div>,
document.body,
)
: null}
{projectMenuOpen && projectMenuPos {projectMenuOpen && projectMenuPos
? createPortal( ? createPortal(
<div <div
@@ -1547,8 +1611,8 @@ export function EditorApp() {
storylineLabels={storylineLabels} storylineLabels={storylineLabels}
loadStorylines={loadProjectStorylines} loadStorylines={loadProjectStorylines}
onClose={() => setExportModalOpen(false)} onClose={() => setExportModalOpen(false)}
onExport={async (projectId, selections, npcIds) => { onExport={async (projectId, selections, npcIds, playlistIds) => {
await actions.exportProject(projectId, selections, npcIds, storylineLabels); await actions.exportProject(projectId, selections, npcIds, storylineLabels, playlistIds);
}} }}
/> />
<ImportSourceModal <ImportSourceModal
@@ -1638,6 +1702,30 @@ export function EditorApp() {
}} }}
/> />
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} /> <CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<PresentationSettingsModal
open={presentationSettingsOpen}
onClose={() => setPresentationSettingsOpen(false)}
/>
<GameSystemsModal
open={gameSystemsOpen}
onClose={() => setGameSystemsOpen(false)}
labels={{
title: t('gameSystems.title'),
import: t('gameSystems.import'),
preview: t('gameSystems.preview'),
remove: t('gameSystems.remove'),
empty: t('gameSystems.empty'),
close: t('gameSystems.close'),
}}
listSystems={listGameSystems}
importSystem={importGameSystem}
deleteSystem={deleteGameSystem}
previewSystem={previewGameSystem}
/>
<PlaylistsManagerModal
open={playlistsManagerOpen}
onClose={() => setPlaylistsManagerOpen(false)}
/>
{USERS_BRANCH_FEATURES_ENABLED ? ( {USERS_BRANCH_FEATURES_ENABLED ? (
<> <>
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} /> <PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
@@ -1649,6 +1737,69 @@ export function EditorApp() {
launchFromGraphNode(graphStartGraphNodeId, playerIds); launchFromGraphNode(graphStartGraphNodeId, playerIds);
}} }}
/> />
{loadSaveOpen
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => {
if (!launching) setLoadSaveOpen(false);
}}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog} data-testid="load-save-modal">
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('top.loadSave.title')}</div>
<button
type="button"
className={styles.modalClose}
aria-label={t('common.close')}
onClick={() => {
if (!launching) setLoadSaveOpen(false);
}}
>
×
</button>
</div>
<div className={styles.saveList}>
{gameSaves.map((item) => (
<button
key={item.id}
type="button"
className={[
styles.saveRow,
loadSaveId === item.id ? styles.saveRowSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setLoadSaveId(item.id)}
>
{item.name}
</button>
))}
</div>
{loadSaveError ? <div className={styles.fieldError}>{loadSaveError}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={() => setLoadSaveOpen(false)} disabled={launching}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
data-testid="load-save-commit"
disabled={!loadSaveId || launching}
onClick={() => {
if (loadSaveId) launchFromSave(loadSaveId);
}}
>
{t('top.loadSave.action')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</> </>
) : null} ) : null}
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos {USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
@@ -1671,6 +1822,22 @@ export function EditorApp() {
> >
{t('top.runWithPlayers')} {t('top.runWithPlayers')}
</button> </button>
<button
type="button"
className={styles.fileMenuItem}
role="menuitem"
data-testid="run-load-save-item"
disabled={gameSaves.length === 0}
onClick={() => {
if (gameSaves.length === 0) return;
setRunMenuOpen(false);
setLoadSaveId(null);
setLoadSaveError('');
setLoadSaveOpen(true);
}}
>
{t('top.loadSave')}
</button>
</div>, </div>,
document.body, document.body,
) )
@@ -2451,7 +2618,6 @@ type SceneInspectorProps = {
previewVideoAutostart: boolean; previewVideoAutostart: boolean;
previewVideoLoop: boolean; previewVideoLoop: boolean;
previewRotationDeg: 0 | 90 | 180 | 270; previewRotationDeg: 0 | 90 | 180 | 270;
darkenScene: boolean;
previewBusy: boolean; previewBusy: boolean;
previewBusyText: string; previewBusyText: string;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
@@ -2459,7 +2625,6 @@ type SceneInspectorProps = {
onAudioRefsChange: (next: SceneAudioRef[]) => void; onAudioRefsChange: (next: SceneAudioRef[]) => void;
onPreviewVideoAutostartChange: (next: boolean) => void; onPreviewVideoAutostartChange: (next: boolean) => void;
onPreviewVideoLoopChange: (next: boolean) => void; onPreviewVideoLoopChange: (next: boolean) => void;
onDarkenSceneChange: (next: boolean) => void;
onTitleChange: (v: string) => void; onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void; onDescriptionChange: (v: string) => void;
onImportPreview: () => void; onImportPreview: () => void;
@@ -2487,7 +2652,6 @@ function CampaignInspector({
onDropAudio, onDropAudio,
}: CampaignInspectorProps) { }: CampaignInspectorProps) {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
const audioDrop = useFileDropZone({ const audioDrop = useFileDropZone({
onDropPaths: onDropAudio, onDropPaths: onDropAudio,
filterPaths: filterAudioFilePaths, filterPaths: filterAudioFilePaths,
@@ -2503,71 +2667,19 @@ function CampaignInspector({
onDrop={audioDrop.onDrop} onDrop={audioDrop.onDrop}
> >
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null} {audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? ( {audioRefs.length === 0 ? (
<div className={styles.audioDropEmpty}> <div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div> <div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button> <Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
</div> </div>
) : ( ) : (
<> <>
<div className={styles.audioList}> <EditorAudioTrackList
{mediaAssets audioRefs={audioRefs}
.filter((a) => a.type === 'audio') mediaAssets={mediaAssets}
.map((a) => ( removeTitle={t('campaign.removeTitle')}
<div key={a.id} className={styles.audioRow}> onAudioRefsChange={onAudioRefsChange}
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/> />
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('campaign.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div>
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button> <Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
</> </>
)} )}
@@ -2585,7 +2697,6 @@ function SceneInspector({
previewVideoAutostart, previewVideoAutostart,
previewVideoLoop, previewVideoLoop,
previewRotationDeg, previewRotationDeg,
darkenScene,
previewBusy, previewBusy,
previewBusyText, previewBusyText,
mediaAssets, mediaAssets,
@@ -2593,7 +2704,6 @@ function SceneInspector({
onAudioRefsChange, onAudioRefsChange,
onPreviewVideoAutostartChange, onPreviewVideoAutostartChange,
onPreviewVideoLoopChange, onPreviewVideoLoopChange,
onDarkenSceneChange,
onTitleChange, onTitleChange,
onDescriptionChange, onDescriptionChange,
onImportPreview, onImportPreview,
@@ -2607,7 +2717,6 @@ function SceneInspector({
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const [descriptionModalOpen, setDescriptionModalOpen] = useState(false); const [descriptionModalOpen, setDescriptionModalOpen] = useState(false);
const previewUrl = useAssetUrl(previewAssetId); const previewUrl = useAssetUrl(previewAssetId);
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
const descriptionEmpty = isSceneDescriptionEmpty(description); const descriptionEmpty = isSceneDescriptionEmpty(description);
const descriptionPreviewHtml = useMemo( const descriptionPreviewHtml = useMemo(
() => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)), () => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)),
@@ -2748,8 +2857,7 @@ function SceneInspector({
</div> </div>
) : null} ) : null}
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? ( {previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
<> <div className={styles.actionsRowHalf}>
<div className={styles.spacer6} />
<Button <Button
onClick={() => { onClick={() => {
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270; const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
@@ -2758,28 +2866,14 @@ function SceneInspector({
> >
{t('scene.rotate')} {t('scene.rotate')}
</Button> </Button>
</>
) : null}
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
<>
<div className={styles.spacer6} />
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={darkenScene}
onChange={(e) => onDarkenSceneChange(e.target.checked)}
/>
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
</label>
<div className={styles.spacer6} />
<Button <Button
onClick={() => { onClick={() => {
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {}); void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
}} }}
> >
Редактор сцены {t('sceneEditor.open')}
</Button> </Button>
</> </div>
) : null} ) : null}
<div className={styles.spacer6} /> <div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.audio')}</div> <div className={styles.labelSm}>{t('scene.audio')}</div>
@@ -2791,71 +2885,19 @@ function SceneInspector({
onDrop={sceneAudioDrop.onDrop} onDrop={sceneAudioDrop.onDrop}
> >
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null} {sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? ( {audioRefs.length === 0 ? (
<div className={styles.audioDropEmpty}> <div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div> <div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button> <Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
</div> </div>
) : ( ) : (
<> <>
<div className={styles.audioList}> <EditorAudioTrackList
{mediaAssets audioRefs={audioRefs}
.filter((a) => a.type === 'audio') mediaAssets={mediaAssets}
.map((a) => ( removeTitle={t('scene.removeTitle')}
<div key={a.id} className={styles.audioRow}> onAudioRefsChange={onAudioRefsChange}
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/> />
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('scene.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div>
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button> <Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
</> </>
)} )}
@@ -0,0 +1,287 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import {
clampSceneAudioVolume,
type AssetId,
type MediaAsset,
type SceneAudioRef,
} from '../../shared/types';
import { getDndApi } from '../shared/dndApi';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { registerEditorAudioStopper } from '../shared/audio/editorAudioStop';
import styles from './EditorApp.module.css';
const AUDIO_REF_DND = 'application/x-dnd-scene-audio-ref';
function reorderRefs(refs: SceneAudioRef[], draggedId: AssetId, targetId: AssetId): SceneAudioRef[] {
if (draggedId === targetId) return refs;
const from = refs.findIndex((r) => r.assetId === draggedId);
const to = refs.findIndex((r) => r.assetId === targetId);
if (from < 0 || to < 0) return refs;
const next = [...refs];
const [item] = next.splice(from, 1);
if (!item) return refs;
next.splice(to, 0, item);
return next;
}
/** Локальный draft — не пишет в проект на каждый tick ползунка (иначе мигает граф). */
function AudioVolumeSlider({
assetId,
volume,
isPlaying,
previewElRef,
label,
onCommit,
}: {
assetId: AssetId;
volume: number;
isPlaying: boolean;
previewElRef: React.MutableRefObject<HTMLAudioElement | null>;
label: string;
onCommit: (volume: number) => void;
}) {
const [draft, setDraft] = useState(() => clampSceneAudioVolume(volume));
const draftRef = useRef(draft);
draftRef.current = draft;
useEffect(() => {
setDraft(clampSceneAudioVolume(volume));
}, [assetId, volume]);
const applyLocal = (v: number) => {
const next = clampSceneAudioVolume(v);
setDraft(next);
if (isPlaying && previewElRef.current) previewElRef.current.volume = next;
};
const commit = () => {
const next = clampSceneAudioVolume(draftRef.current);
if (next === clampSceneAudioVolume(volume)) return;
onCommit(next);
};
return (
<label className={styles.audioVolumeLabel} title={label}>
<span className={styles.spanXs}>{label}</span>
<input
type="range"
className={styles.audioVolumeRange}
min={0}
max={1}
step={0.01}
value={draft}
aria-label={label}
onChange={(e) => applyLocal(Number(e.target.value))}
onPointerUp={commit}
onBlur={commit}
onKeyUp={(e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') {
commit();
}
}}
/>
</label>
);
}
export type EditorAudioTrackListProps = {
audioRefs: SceneAudioRef[];
mediaAssets: MediaAsset[];
removeTitle: string;
onAudioRefsChange: (next: SceneAudioRef[]) => void;
};
export function EditorAudioTrackList({
audioRefs,
mediaAssets,
removeTitle,
onAudioRefsChange,
}: EditorAudioTrackListProps) {
const { t } = useEditorI18n();
const assetById = useMemo(() => new Map(mediaAssets.map((a) => [a.id, a])), [mediaAssets]);
const [playingId, setPlayingId] = useState<AssetId | null>(null);
const previewElRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
return () => {
const el = previewElRef.current;
if (el) {
el.pause();
el.src = '';
previewElRef.current = null;
}
};
}, []);
useEffect(() => {
return registerEditorAudioStopper(() => {
const el = previewElRef.current;
if (el) {
el.pause();
el.onended = null;
el.src = '';
}
previewElRef.current = null;
setPlayingId(null);
});
}, []);
const stopPreview = () => {
const el = previewElRef.current;
if (el) {
el.pause();
el.onended = null;
el.src = '';
}
previewElRef.current = null;
setPlayingId(null);
};
const togglePreview = async (assetId: AssetId, volume: number) => {
if (playingId === assetId) {
stopPreview();
return;
}
stopPreview();
try {
const r = await getDndApi().invoke(ipcChannels.project.assetFileUrl, { assetId });
const url = r.url;
if (!url) return;
const el = new Audio(url);
el.volume = clampSceneAudioVolume(volume);
el.onended = () => {
previewElRef.current = null;
setPlayingId(null);
};
previewElRef.current = el;
setPlayingId(assetId);
await el.play();
} catch {
stopPreview();
}
};
if (audioRefs.length === 0) {
return (
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
);
}
return (
<div className={styles.audioList}>
{audioRefs.map((ref) => {
const asset = assetById.get(ref.assetId);
const name = asset?.originalName ?? ref.assetId;
const volume = clampSceneAudioVolume(ref.volume);
const isPlaying = playingId === ref.assetId;
return (
<div
key={ref.assetId}
className={styles.audioRow}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(AUDIO_REF_DND)) e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const dragged = e.dataTransfer.getData(AUDIO_REF_DND) as AssetId;
if (!dragged) return;
const next = reorderRefs(audioRefs, dragged, ref.assetId);
if (next !== audioRefs) onAudioRefsChange(next);
}}
>
<div
className={styles.audioDragHandle}
draggable
title={name}
onDragStart={(e) => {
e.dataTransfer.setData(AUDIO_REF_DND, ref.assetId);
e.dataTransfer.effectAllowed = 'move';
}}
>
<span className={styles.audioDragGrip} aria-hidden>
⋮⋮
</span>
<span className={styles.audioName}>{name}</span>
</div>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={ref.autoplay}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === ref.assetId ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={ref.loop}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === ref.assetId ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<AudioVolumeSlider
assetId={ref.assetId}
volume={volume}
isPlaying={isPlaying}
previewElRef={previewElRef}
label={t('campaign.volume')}
onCommit={(v) => {
const next = audioRefs.map((x) =>
x.assetId === ref.assetId ? { ...x, volume: v } : x,
);
onAudioRefsChange(next);
}}
/>
<button
type="button"
className={styles.audioPreviewBtn}
title={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
aria-label={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
onClick={() => void togglePreview(ref.assetId, volume)}
>
{isPlaying ? '■' : '▶'}
</button>
<button
type="button"
title={removeTitle}
className={styles.audioRemove}
onClick={() => {
if (playingId === ref.assetId) stopPreview();
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== ref.assetId));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
);
})}
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import type { GameSystemListItem } from '../../shared/ipc/contracts';
export type { GameSystemListItem };
type Props = {
open: boolean;
onClose: () => void;
labels: {
title: string;
import: string;
preview: string;
remove: string;
empty: string;
close: string;
};
listSystems: () => Promise<GameSystemListItem[]>;
importSystem: () => Promise<GameSystemListItem | null>;
deleteSystem: (id: string) => Promise<void>;
previewSystem: (id: string) => Promise<void>;
};
export function GameSystemsModal({
open,
onClose,
labels,
listSystems,
importSystem,
deleteSystem,
previewSystem,
}: Props) {
const [items, setItems] = useState<GameSystemListItem[]>([]);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
void (async () => {
setItems(await listSystems());
})();
}, [open, listSystems]);
if (!open) return null;
return (
<div
data-testid="game-systems-modal"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'grid',
placeItems: 'center',
zIndex: 10000,
}}
>
<div
style={{
width: 560,
maxHeight: '80vh',
overflow: 'auto',
background: 'rgba(10,10,14,0.98)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 12,
padding: 16,
color: 'rgba(255,255,255,0.92)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<strong>{labels.title}</strong>
<button type="button" data-testid="game-systems-close" onClick={onClose}>
{labels.close}
</button>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button
type="button"
data-testid="game-systems-import"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await importSystem();
setItems(await listSystems());
} finally {
setBusy(false);
}
}}
>
{labels.import}
</button>
</div>
{items.length === 0 ? (
<div style={{ opacity: 0.6 }}>{labels.empty}</div>
) : (
items.map((s) => (
<div
key={s.id}
data-testid={`game-system-${s.id}`}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 8,
padding: '10px 0',
borderBottom: '1px solid rgba(255,255,255,0.08)',
}}
>
<div>
<div>{s.name}</div>
<div style={{ opacity: 0.55, fontSize: 12 }}>
{s.id} · r{s.revision}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => void previewSystem(s.id)}>
{labels.preview}
</button>
<button
type="button"
onClick={async () => {
await deleteSystem(s.id);
setItems(await listSystems());
}}
>
{labels.remove}
</button>
</div>
</div>
))
)}
</div>
</div>
);
}
+6
View File
@@ -11,6 +11,7 @@ import styles from './EditorApp.module.css';
import { import {
filterMaterialImagePaths, filterMaterialImagePaths,
getDroppedFileEntries, getDroppedFileEntries,
nameFromFilePath,
pickFirstMaterialImagePath, pickFirstMaterialImagePath,
useFileDropZone, useFileDropZone,
} from './fileDrop'; } from './fileDrop';
@@ -86,6 +87,11 @@ export function MaterialEditModal({
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => { const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
setFilePath(path); setFilePath(path);
setName((prev) => {
if (prev.trim()) return prev;
const fromFile = nameFromFilePath(path);
return fromFile || prev;
});
setLocalPreviewUrl((prev) => { setLocalPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return previewUrl; return previewUrl;
@@ -0,0 +1,14 @@
.dialog {
width: min(960px, calc(100vw - 48px));
height: min(720px, calc(100vh - 48px));
display: flex;
flex-direction: column;
}
.body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 4px 8px;
}
+52
View File
@@ -0,0 +1,52 @@
import React, { useMemo } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../shared/ui/controls';
import {
PlaylistsPanel,
playlistsPanelLabelsFromT,
} from '../shared/playlists/PlaylistsPanel';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import panelStyles from './PlaylistsModal.module.css';
export function PlaylistsManagerModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { t } = useEditorI18n();
const labels = useMemo(() => playlistsPanelLabelsFromT(t), [t]);
if (!open) return null;
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div
className={[styles.modalDialog, panelStyles.dialog].join(' ')}
role="dialog"
aria-modal="true"
data-testid="playlists-modal"
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('playlists.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
×
</button>
</div>
<div className={panelStyles.body}>
<PlaylistsPanel labels={labels} />
</div>
<div className={styles.modalFooter}>
<Button variant="ghost" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
@@ -0,0 +1,71 @@
.dialog {
width: min(480px, calc(100vw - 48px));
}
.hint {
margin: 0 0 12px;
color: var(--text2);
font-size: var(--text-sm);
line-height: 1.4;
}
.section {
display: grid;
gap: 8px;
margin-bottom: 14px;
}
.sectionTitle {
font-size: 11px;
font-weight: 700;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.list {
display: grid;
gap: 4px;
max-height: 240px;
overflow: auto;
padding: 8px;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: var(--color-overlay-dark-3);
}
.row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
color: var(--text);
font-size: var(--text-sm);
line-height: 1.35;
}
.row:hover {
background: var(--accent-fill-soft);
}
.rowMeta {
color: var(--text2);
font-size: 12px;
}
.fullScreenRow {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 4px;
cursor: pointer;
font-size: var(--text-sm);
}
.loading {
padding: 10px 8px;
color: var(--text2);
font-size: var(--text-sm);
}
@@ -0,0 +1,139 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { DisplayInfo, PresentationSettings } from '../../shared/types/presentationSettings';
import { getDndApi } from '../shared/dndApi';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import modalStyles from './PresentationSettingsModal.module.css';
export function PresentationSettingsModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { t } = useEditorI18n();
const api = getDndApi();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [displays, setDisplays] = useState<DisplayInfo[]>([]);
const [draft, setDraft] = useState<PresentationSettings>({ displayId: null, fullScreen: true });
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
void (async () => {
try {
const [{ displays: list }, { settings }] = await Promise.all([
api.invoke(ipcChannels.windows.listDisplays, {}),
api.invoke(ipcChannels.preferences.getPresentation, {}),
]);
if (cancelled) return;
setDisplays(list);
setDraft(settings);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [api, open]);
if (!open) return null;
const save = async () => {
setSaving(true);
try {
const primaryId = displays.find((d) => d.isPrimary)?.id ?? null;
const settings: PresentationSettings = {
...draft,
displayId: draft.displayId === primaryId ? null : draft.displayId,
};
await api.invoke(ipcChannels.preferences.setPresentation, { settings });
onClose();
} finally {
setSaving(false);
}
};
const isDisplaySelected = (d: DisplayInfo) =>
draft.displayId === d.id || (draft.displayId === null && d.isPrimary);
const displayLabel = (d: DisplayInfo) => {
const primary = d.isPrimary ? ` (${t('settings.presentation.primary')})` : '';
return `${d.label}${primary} — ${d.width}×${d.height}`;
};
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div
className={[styles.modalDialog, modalStyles.dialog].join(' ')}
role="dialog"
aria-modal="true"
data-testid="presentation-settings-modal"
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('settings.presentation.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div>
<p className={modalStyles.hint}>{t('settings.presentation.hint')}</p>
<section className={modalStyles.section}>
<div className={modalStyles.sectionTitle}>{t('settings.presentation.display')}</div>
{loading ? (
<div className={modalStyles.loading}>{t('settings.presentation.loading')}</div>
) : (
<div className={modalStyles.list} role="radiogroup" aria-label={t('settings.presentation.display')}>
{displays.map((d) => (
<label key={d.id} className={modalStyles.row}>
<input
type="radio"
name="presentation-display"
checked={isDisplaySelected(d)}
onChange={() => setDraft((prev) => ({ ...prev, displayId: d.id }))}
data-testid={`presentation-display-${d.id}`}
/>
<span>{displayLabel(d)}</span>
</label>
))}
</div>
)}
</section>
<label className={modalStyles.fullScreenRow}>
<input
type="checkbox"
checked={draft.fullScreen}
onChange={(e) => setDraft((prev) => ({ ...prev, fullScreen: e.target.checked }))}
data-testid="presentation-fullscreen"
/>
<span>{t('settings.presentation.fullScreen')}</span>
</label>
</div>
<div className={styles.modalFooter}>
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button variant="primary" onClick={() => void save()} disabled={loading || saving} data-testid="presentation-settings-save">
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
@@ -18,6 +18,7 @@ import {
} from '../../shared/graph/storylineExportImport'; } from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types'; import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button, Select } from '../shared/ui/controls'; import { Button, Select } from '../shared/ui/controls';
import { useAppPlaylists } from '../shared/playlists/useAppPlaylists';
import styles from './EditorApp.module.css'; import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext'; import { useEditorI18n } from './i18n/EditorI18nContext';
@@ -37,6 +38,7 @@ type ExportProjectModalProps = {
projectId: ProjectId, projectId: ProjectId,
selections: StorylineSelection[], selections: StorylineSelection[],
npcIds: string[], npcIds: string[],
playlistIds: string[],
) => Promise<void>; ) => Promise<void>;
}; };
@@ -50,12 +52,14 @@ export function ExportProjectModal({
onExport, onExport,
}: ExportProjectModalProps) { }: ExportProjectModalProps) {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const playlistsState = useAppPlaylists();
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines'); const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId); const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [storylines, setStorylines] = useState<StorylineListItem[]>([]); const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]); const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set()); const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set()); const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
const [selectedPlaylistIds, setSelectedPlaylistIds] = useState<Set<string>>(new Set());
const [loadingStorylines, setLoadingStorylines] = useState(false); const [loadingStorylines, setLoadingStorylines] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -67,6 +71,7 @@ export function ExportProjectModal({
setError(null); setError(null);
setSelectedKeys(new Set()); setSelectedKeys(new Set());
setSelectedNpcIds(new Set()); setSelectedNpcIds(new Set());
setSelectedPlaylistIds(new Set());
setStep('storylines'); setStep('storylines');
setNpcs([]); setNpcs([]);
}, [initialProjectId, open]); }, [initialProjectId, open]);
@@ -156,7 +161,7 @@ export function ExportProjectModal({
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await onExport(projectId, selectedSelections, npcIds); await onExport(projectId, selectedSelections, npcIds, [...selectedPlaylistIds]);
onClose(); onClose();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
@@ -168,7 +173,7 @@ export function ExportProjectModal({
const goNextFromStorylines = () => { const goNextFromStorylines = () => {
if (!canContinueStorylines) return; if (!canContinueStorylines) return;
if (npcs.length === 0) { if (npcs.length === 0 && playlistsState.playlists.length === 0) {
runExport([]); runExport([]);
return; return;
} }
@@ -248,6 +253,8 @@ export function ExportProjectModal({
</div> </div>
) : ( ) : (
<div className={styles.fieldGrid}> <div className={styles.fieldGrid}>
{npcs.length > 0 ? (
<>
<div className={styles.muted}>{t('export.npcsHint')}</div> <div className={styles.muted}>{t('export.npcsHint')}</div>
<div className={styles.storylineChecklist}> <div className={styles.storylineChecklist}>
{npcs.map((n) => ( {npcs.map((n) => (
@@ -268,6 +275,36 @@ export function ExportProjectModal({
> >
{t('export.selectAllNpcs')} {t('export.selectAllNpcs')}
</Button> </Button>
</>
) : null}
{playlistsState.playlists.length > 0 ? (
<>
<div className={styles.fieldLabel}>{t('export.playlists')}</div>
<div className={styles.muted}>{t('export.playlistsHint')}</div>
<div className={styles.storylineChecklist}>
{playlistsState.playlists.map((pl) => (
<label key={pl.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedPlaylistIds.has(pl.id)}
disabled={saving}
onChange={() => {
setSelectedPlaylistIds((prev) => {
const next = new Set(prev);
if (next.has(pl.id)) next.delete(pl.id);
else next.add(pl.id);
return next;
});
}}
/>
<span>
{pl.name} ({pl.tracks.length})
</span>
</label>
))}
</div>
</>
) : null}
</div> </div>
)} )}
@@ -289,7 +326,7 @@ export function ExportProjectModal({
disabled={!canContinueStorylines || saving} disabled={!canContinueStorylines || saving}
onClick={goNextFromStorylines} onClick={goNextFromStorylines}
> >
{npcs.length > 0 ? t('export.next') : t('export.saveAs')} {npcs.length > 0 || playlistsState.playlists.length > 0 ? t('export.next') : t('export.saveAs')}
</Button> </Button>
) : ( ) : (
<Button <Button
+28 -126
View File
@@ -1,18 +1,24 @@
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 = [
{ 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 Чурсин' },
] as const;
type AppAboutModalProps = { type AppAboutModalProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -80,123 +86,25 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
{t('app.about.websiteUrl')} {t('app.about.websiteUrl')}
</a> </a>
</div> </div>
</div> <div className={styles.fieldLabel}>{t('app.about.sponsorsLabel')}</div>
</div> <div className={styles.aboutSponsorsList}>
<div className={styles.modalFooter}> {ABOUT_SPONSORS.map((sponsor) =>
<Button variant="primary" onClick={onClose}> sponsor.kind === 'link' ? (
{t('common.close')} <a
</Button> key={sponsor.name}
</div> className={styles.aboutLink}
</div> href={sponsor.url}
</>, target="_blank"
document.body, rel="noreferrer"
);
}
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}
> >
× {sponsor.name}
</button> </a>
</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 <div key={sponsor.name}>{sponsor.name}</div>
key={j}
type="button"
className={styles.instructionsInlineLink}
onClick={() => goToSection(part.id)}
>
{part.value}
</button>
), ),
)} )}
</p>
))}
</div> </div>
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}> </div>
{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>
<div className={styles.modalFooter}> <div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}> <Button variant="primary" onClick={onClose}>
@@ -208,9 +116,3 @@ function InstructionsModalBody({
document.body, 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} />;
}
+6 -2
View File
@@ -69,10 +69,14 @@ export function isPreviewMediaPath(filePath: string): boolean {
return PREVIEW_EXTENSIONS.has(fileExtension(filePath)); return PREVIEW_EXTENSIONS.has(fileExtension(filePath));
} }
export function sceneTitleFromMediaPath(filePath: string): string { export function nameFromFilePath(filePath: string): string {
const base = filePath.split(/[/\\]/).pop() ?? filePath; const base = filePath.split(/[/\\]/).pop() ?? filePath;
const dot = base.lastIndexOf('.'); const dot = base.lastIndexOf('.');
const title = (dot > 0 ? base.slice(0, dot) : base).trim(); return (dot > 0 ? base.slice(0, dot) : base).trim();
}
export function sceneTitleFromMediaPath(filePath: string): string {
const title = nameFromFilePath(filePath);
return title.length > 0 ? title : 'Новая сцена'; return title.length > 0 ? title : 'Новая сцена';
} }
@@ -23,6 +23,7 @@ function minimalProject(overrides: Partial<Project>): Project {
assets: {}, assets: {},
campaignAudios: [], campaignAudios: [],
materials: [], materials: [],
gridTemplates: [],
npcs: [], npcs: [],
npcRelations: [], npcRelations: [],
currentSceneId: null, currentSceneId: null,
@@ -41,7 +42,7 @@ void test('buildNextSceneCardById: does not change refs when irrelevant fields c
id: sid, id: sid,
title: 'T', title: 'T',
description: 'A', description: 'A',
media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true }] }, media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true, volume: 1 }] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
connections: [], connections: [],
layout: { x: 0, y: 0 }, layout: { x: 0, y: 0 },
+7 -1
View File
@@ -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');
});
+1
View File
@@ -8,6 +8,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
effects: ['Эффекты', 'Effects'], effects: ['Эффекты', 'Effects'],
presentation: ['Презентация', 'Presentation'], presentation: ['Презентация', 'Presentation'],
grid: ['Сетка', 'Grid'], grid: ['Сетка', 'Grid'],
sceneEditor: ['Затемнение сцены', 'Scene darkness', 'Редактор сцены', 'Scene editor'],
controlPanel: ['Пульт'], controlPanel: ['Пульт'],
players: ['Игроки', 'Players'], players: ['Игроки', 'Players'],
tokens: ['Токены', 'Tokens'], tokens: ['Токены', 'Tokens'],
+6
View File
@@ -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';
}
File diff suppressed because one or more lines are too long
+17
View File
@@ -43,6 +43,7 @@ type Actions = {
createProject: (name: string) => Promise<void>; createProject: (name: string) => Promise<void>;
openProject: (id: ProjectId) => Promise<void>; openProject: (id: ProjectId) => Promise<void>;
closeProject: () => Promise<void>; closeProject: () => Promise<void>;
noteProject: (project: Project) => void;
createScene: () => Promise<void>; createScene: () => Promise<void>;
createScenesFromMediaPaths: ( createScenesFromMediaPaths: (
items: { filePath: string; title: string }[], items: { filePath: string; title: string }[],
@@ -71,6 +72,7 @@ type Actions = {
previewVideoAutostart?: boolean; previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
darknessStrokes?: Scene['darknessStrokes'];
settings?: Partial<Scene['settings']>; settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>; media?: Partial<Scene['media']>;
layout?: { x: number; y: number }; layout?: { x: number; y: number };
@@ -157,6 +159,7 @@ type Actions = {
storylineSelections: StorylineSelection[], storylineSelections: StorylineSelection[],
npcIds: string[], npcIds: string[],
labels: StorylineLabels, labels: StorylineLabels,
playlistIds?: string[],
) => Promise<void>; ) => Promise<void>;
deleteProject: (projectId: ProjectId) => Promise<void>; deleteProject: (projectId: ProjectId) => Promise<void>;
}; };
@@ -353,6 +356,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewVideoAutostart: false, previewVideoAutostart: false,
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
darknessStrokes: [],
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [], npcTokens: [],
@@ -563,6 +567,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewVideoAutostart?: boolean; previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
darknessStrokes?: Scene['darknessStrokes'];
traps?: import('../../../shared/types').SceneTrap[]; traps?: import('../../../shared/types').SceneTrap[];
tokens?: import('../../../shared/types').SceneToken[]; tokens?: import('../../../shared/types').SceneToken[];
npcTokens?: import('../../../shared/types').SceneNpcToken[]; npcTokens?: import('../../../shared/types').SceneNpcToken[];
@@ -593,6 +598,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
? { previewRotationDeg: patch.previewRotationDeg } ? { previewRotationDeg: patch.previewRotationDeg }
: null), : null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.darknessStrokes !== undefined ? { darknessStrokes: patch.darknessStrokes } : null),
...(patch.traps !== undefined ? { traps: patch.traps } : null), ...(patch.traps !== undefined ? { traps: patch.traps } : null),
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null), ...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null), ...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null),
@@ -901,6 +907,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
storylineSelections: StorylineSelection[], storylineSelections: StorylineSelection[],
npcIds: string[], npcIds: string[],
labels: StorylineLabels, labels: StorylineLabels,
playlistIds: string[] = [],
) => { ) => {
try { try {
const res = await api.invoke(ipcChannels.project.exportZip, { const res = await api.invoke(ipcChannels.project.exportZip, {
@@ -908,6 +915,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
storylineSelections, storylineSelections,
npcIds, npcIds,
labels, labels,
playlistIds,
}); });
if (res.canceled) return; if (res.canceled) return;
} finally { } finally {
@@ -915,6 +923,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
} }
}; };
const noteProject = (project: Project) => {
setState((s) => ({
...s,
project,
selectedSceneId: project.currentSceneId ?? s.selectedSceneId,
}));
};
const deleteProject = async (projectId: ProjectId) => { const deleteProject = async (projectId: ProjectId) => {
projectDataEpochRef.current += 1; projectDataEpochRef.current += 1;
await api.invoke(ipcChannels.project.deleteProject, { projectId }); await api.invoke(ipcChannels.project.deleteProject, { projectId });
@@ -933,6 +949,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
createProject, createProject,
openProject, openProject,
closeProject, closeProject,
noteProject,
createScene, createScene,
createScenesFromMediaPaths, createScenesFromMediaPaths,
selectScene, selectScene,
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/initiative/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,222 @@
.page {
height: 100vh;
box-sizing: border-box;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg);
color: var(--text);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.addRow {
display: flex;
flex: 1;
gap: 8px;
min-width: 220px;
}
.addRow > :first-child {
flex: 1;
}
.sectionLabel {
font-size: var(--text-xs);
font-weight: 800;
letter-spacing: 0.06em;
color: var(--text2);
text-transform: uppercase;
}
.queue {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
overflow-x: auto;
overflow-y: visible;
padding: 8px 4px 12px;
min-height: 160px;
}
.empty {
color: var(--text2);
font-size: var(--text-sm);
padding: 12px 4px;
}
.slot {
position: relative;
flex: 0 0 auto;
width: 110px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px 6px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
background: transparent;
cursor: grab;
user-select: none;
overflow: visible;
}
.slot:active {
cursor: grabbing;
}
.slotActive {
border-color: rgba(245, 197, 66, 0.55);
background: rgba(245, 197, 66, 0.08);
}
.slotActive .avatar {
border-color: #f5c542;
box-shadow:
0 0 0 2px rgba(0, 0, 0, 0.45),
0 0 12px rgba(245, 197, 66, 0.45);
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
border: 3px solid #3f3f46;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #18181b;
flex-shrink: 0;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.letter {
font-size: 26px;
font-weight: 800;
color: #f8fafc;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
}
.slotName {
width: 100%;
text-align: center;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slotActions {
display: flex;
gap: 4px;
}
.miniBtn {
appearance: none;
width: 22px;
height: 22px;
border-radius: 6px;
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.35));
color: var(--text);
cursor: pointer;
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.miniBtn:hover {
border-color: var(--accent, #a78bfa);
}
.conditionMenu {
position: fixed;
z-index: 10000;
box-sizing: border-box;
padding: 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--bg-elevated, #111827);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
overflow-x: hidden;
overflow-y: auto;
}
.conditionItem {
display: block;
width: 100%;
text-align: left;
appearance: none;
border: none;
background: transparent;
color: var(--text);
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.conditionItem:hover {
background: rgba(255, 255, 255, 0.06);
}
.conditionEmpty {
color: var(--text2);
font-size: 12px;
padding: 6px 8px;
}
.reserve {
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 8px;
}
.reserveRow {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.25));
}
.reserveRow .avatar {
width: 36px;
height: 36px;
border-width: 2px;
}
.reserveRow .letter {
font-size: 16px;
}
.reserveName {
flex: 1;
font-size: var(--text-sm);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+381
View File
@@ -0,0 +1,381 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import {
INITIATIVE_CONDITION_IDS,
type InitiativeConditionId,
type InitiativeEntry,
} from '../../shared/types/initiative';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { InitiativeConditionChips } from '../shared/initiative/InitiativeConditionChips';
import { useInitiativeState } from '../shared/initiative/useInitiativeState';
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { Button, Input } from '../shared/ui/controls';
import styles from './InitiativeApp.module.css';
const DND_MIME = 'application/x-dnd-initiative-entry-id';
const CONDITION_MENU_WIDTH = 200;
/** Компактная высота меню; фактическая не больше доступного места на экране. */
const CONDITION_MENU_MAX_H = 220;
const CONDITION_MENU_GAP = 6;
const CONDITION_MENU_EDGE = 8;
function EntryAvatar({ entry }: { entry: InitiativeEntry }) {
if (entry.kind === 'manual') {
return (
<div className={styles.avatar} style={{ background: entry.gradient }} aria-hidden>
<span className={styles.letter}>{entry.letter}</span>
</div>
);
}
if (entry.kind === 'npc') {
return <NpcAvatar assetId={entry.avatarAssetId} name={entry.name} />;
}
return <PlayerAvatar playerId={entry.playerId} name={entry.name} />;
}
function NpcAvatar({ assetId, name }: { assetId: string | null; name: string }) {
const url = useAssetUrl(assetId as import('../../shared/types').AssetId | null);
if (url) {
return (
<div className={styles.avatar}>
<img src={url} alt="" className={styles.avatarImg} />
</div>
);
}
const letter = (name.trim()[0] ?? '?').toUpperCase();
return (
<div className={styles.avatar} style={{ background: 'linear-gradient(135deg,#334155,#0f172a)' }}>
<span className={styles.letter}>{letter}</span>
</div>
);
}
function PlayerAvatar({ playerId, name }: { playerId: string; name: string }) {
const url = usePlayerImageUrl(playerId as import('../../shared/types').PlayerId);
if (url) {
return (
<div className={styles.avatar}>
<img src={url} alt="" className={styles.avatarImg} />
</div>
);
}
const letter = (name.trim()[0] ?? '?').toUpperCase();
return (
<div className={styles.avatar} style={{ background: 'linear-gradient(135deg,#1d4ed8,#0f172a)' }}>
<span className={styles.letter}>{letter}</span>
</div>
);
}
function QueueSlot({
entry,
active,
conditions,
onSelect,
onRemove,
onDragStart,
onDragOver,
onDrop,
conditionsOpen,
onToggleConditions,
onAddCondition,
onRemoveCondition,
}: {
entry: InitiativeEntry;
active: boolean;
conditions: readonly InitiativeConditionId[];
onSelect: () => void;
onRemove: () => void;
onDragStart: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
conditionsOpen: boolean;
onToggleConditions: () => void;
onAddCondition: (id: InitiativeConditionId) => void;
onRemoveCondition: (id: InitiativeConditionId) => void;
}) {
const { t } = useEditorI18n();
const available = INITIATIVE_CONDITION_IDS.filter((id) => !conditions.includes(id));
const addBtnRef = useRef<HTMLButtonElement | null>(null);
const [menuPos, setMenuPos] = useState<{ top: number; left: number; maxHeight: number } | null>(
null,
);
useLayoutEffect(() => {
if (!conditionsOpen) {
setMenuPos(null);
return;
}
const btn = addBtnRef.current;
if (!btn) return;
const place = () => {
const r = btn.getBoundingClientRect();
const left = Math.max(
CONDITION_MENU_EDGE,
Math.min(
window.innerWidth - CONDITION_MENU_WIDTH - CONDITION_MENU_EDGE,
r.left,
),
);
const spaceBelow = window.innerHeight - r.bottom - CONDITION_MENU_GAP - CONDITION_MENU_EDGE;
const spaceAbove = r.top - CONDITION_MENU_GAP - CONDITION_MENU_EDGE;
// Предпочитаем под кнопкой; если снизу мало места — над ней.
const openBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove;
const available = Math.max(80, openBelow ? spaceBelow : spaceAbove);
const maxHeight = Math.min(CONDITION_MENU_MAX_H, available);
const top = openBelow
? r.bottom + CONDITION_MENU_GAP
: Math.max(CONDITION_MENU_EDGE, r.top - CONDITION_MENU_GAP - maxHeight);
setMenuPos({ top, left, maxHeight });
};
place();
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
};
}, [conditionsOpen]);
useEffect(() => {
if (!conditionsOpen) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (addBtnRef.current?.contains(target)) return;
const menu = document.getElementById(`initiative-condition-menu-${entry.id}`);
if (menu?.contains(target)) return;
onToggleConditions();
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [conditionsOpen, entry.id, onToggleConditions]);
return (
<div
className={[styles.slot, active ? styles.slotActive : ''].filter(Boolean).join(' ')}
data-testid={`initiative-slot-${entry.id}`}
data-active={active ? '1' : '0'}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={onSelect}
>
<EntryAvatar entry={entry} />
<div className={styles.slotName} title={entry.name}>
{entry.name}
</div>
<InitiativeConditionChips
conditions={conditions}
removable
onRemove={onRemoveCondition}
/>
<div className={styles.slotActions}>
<button
ref={addBtnRef}
type="button"
className={styles.miniBtn}
title={t('initiative.conditions')}
onClick={(e) => {
e.stopPropagation();
onToggleConditions();
}}
>
+
</button>
<button
type="button"
className={styles.miniBtn}
title={t('initiative.toReserve')}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
×
</button>
</div>
{conditionsOpen && menuPos
? createPortal(
<div
id={`initiative-condition-menu-${entry.id}`}
className={styles.conditionMenu}
style={{
top: menuPos.top,
left: menuPos.left,
width: CONDITION_MENU_WIDTH,
maxHeight: menuPos.maxHeight,
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{available.length === 0 ? (
<div className={styles.conditionEmpty}>{t('initiative.conditionsEmpty')}</div>
) : (
available.map((id) => (
<button
key={id}
type="button"
className={styles.conditionItem}
onClick={() => onAddCondition(id)}
>
{t(`initiative.condition.${id}`)}
</button>
))
)}
</div>,
document.body,
)
: null}
</div>
);
}
export function InitiativeApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [state, initiativeApi] = useInitiativeState();
const [manualName, setManualName] = useState('');
const [conditionsFor, setConditionsFor] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeInitiative, {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
const orderEntries = useMemo(() => {
if (!state) return [];
return state.order.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[];
}, [state]);
const reserveEntries = useMemo(() => {
if (!state) return [];
return state.reserve.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[];
}, [state]);
if (!state) {
return <div className={styles.page} data-testid="initiative-app" />;
}
const reorderDrop = (targetId: string, e: React.DragEvent) => {
e.preventDefault();
const fromId = e.dataTransfer.getData(DND_MIME);
if (!fromId || fromId === targetId) return;
const next = [...state.order];
const fromIdx = next.indexOf(fromId);
const toIdx = next.indexOf(targetId);
if (fromIdx < 0 || toIdx < 0) return;
next.splice(fromIdx, 1);
next.splice(toIdx, 0, fromId);
void initiativeApi.dispatch({ kind: 'reorder', order: next });
};
const addManual = () => {
const name = manualName.trim();
if (!name) return;
void initiativeApi.dispatch({ kind: 'addManual', name });
setManualName('');
};
return (
<div className={styles.page} data-testid="initiative-app">
<div className={styles.toolbar}>
<Button variant="primary" onClick={() => void initiativeApi.dispatch({ kind: 'next' })}>
{t('initiative.next')}
</Button>
<div className={styles.addRow}>
<Input
value={manualName}
onChange={setManualName}
placeholder={t('initiative.manualPlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addManual();
}
}}
/>
<Button variant="ghost" onClick={addManual}>
{t('initiative.addManual')}
</Button>
</div>
</div>
<div className={styles.sectionLabel}>{t('initiative.queue')}</div>
<div className={styles.queue} data-testid="initiative-queue">
{orderEntries.length === 0 ? (
<div className={styles.empty}>{t('initiative.queueEmpty')}</div>
) : (
orderEntries.map((entry) => (
<QueueSlot
key={entry.id}
entry={entry}
active={state.activeId === entry.id}
conditions={state.conditions[entry.id] ?? []}
onSelect={() => void initiativeApi.dispatch({ kind: 'setActive', id: entry.id })}
onRemove={() => void initiativeApi.dispatch({ kind: 'moveToReserve', id: entry.id })}
onDragStart={(e) => {
e.dataTransfer.setData(DND_MIME, entry.id);
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => reorderDrop(entry.id, e)}
conditionsOpen={conditionsFor === entry.id}
onToggleConditions={() =>
setConditionsFor((cur) => (cur === entry.id ? null : entry.id))
}
onAddCondition={(conditionId) => {
void initiativeApi.dispatch({ kind: 'addCondition', id: entry.id, conditionId });
setConditionsFor(null);
}}
onRemoveCondition={(conditionId) => {
void initiativeApi.dispatch({ kind: 'removeCondition', id: entry.id, conditionId });
}}
/>
))
)}
</div>
<div className={styles.sectionLabel}>{t('initiative.reserve')}</div>
<div className={styles.reserve} data-testid="initiative-reserve">
{reserveEntries.length === 0 ? (
<div className={styles.empty}>{t('initiative.reserveEmpty')}</div>
) : (
reserveEntries.map((entry) => (
<div key={entry.id} className={styles.reserveRow}>
<EntryAvatar entry={entry} />
<span className={styles.reserveName}>{entry.name}</span>
<Button
variant="ghost"
onClick={() => void initiativeApi.dispatch({ kind: 'restoreFromReserve', id: entry.id })}
>
{t('initiative.restore')}
</Button>
{entry.kind === 'manual' ? (
<Button
variant="ghost"
onClick={() => void initiativeApi.dispatch({ kind: 'deleteEntry', id: entry.id })}
>
{t('initiative.delete')}
</Button>
) : null}
</div>
))
)}
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { InitiativeApp } from './InitiativeApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Initiative">
<EditorI18nProvider>
<InitiativeApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/instructions/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,98 @@
.page {
height: 100vh;
padding: 16px;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(160px, 220px);
gap: 14px;
background: var(--color-surface-elevated);
min-height: 0;
}
.content {
min-height: 0;
overflow: auto;
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--bg0);
}
.contentTitle {
font-weight: 800;
font-size: var(--text-md);
margin-bottom: 12px;
}
.paragraph {
margin: 0 0 12px;
color: var(--text1);
font-size: var(--text-sm);
line-height: 1.55;
white-space: pre-wrap;
}
.paragraph:last-child {
margin-bottom: 0;
}
.inlineLink {
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
color: var(--accent, #a78bfa);
font: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.inlineLink:hover {
color: var(--text0);
}
.nav {
min-height: 0;
overflow: auto;
display: grid;
gap: 4px;
align-content: start;
padding: 6px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--panel2);
}
.navItem {
text-align: left;
padding: 9px 10px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
font: inherit;
font-size: var(--text-xs);
line-height: 1.35;
}
.navItem:hover {
background: var(--scene-list-hover-bg);
color: var(--text0);
}
.navItemActive {
background: rgba(167, 139, 250, 0.18);
color: var(--text0);
font-weight: 700;
}
@media (max-width: 720px) {
.page {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) minmax(120px, 32vh);
}
}
@@ -0,0 +1,105 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../editor/help/helpLinkify';
import {
HELP_SECTION_IDS,
helpSectionBodyKey,
helpSectionTitleKey,
parseHelpSectionId,
type HelpSectionId,
} from '../editor/help/helpSections';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import styles from './InstructionsApp.module.css';
function InstructionsPanel({ initialSection }: { initialSection: HelpSectionId }) {
const { t } = useEditorI18n();
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
const contentRef = useRef<HTMLDivElement | null>(null);
const navRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setActiveId(initialSection);
}, [initialSection]);
useEffect(() => {
contentRef.current?.scrollTo({ top: 0 });
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
activeNav?.scrollIntoView({ block: 'nearest' });
}, [activeId]);
const linkCatalog = useMemo(
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
[t],
);
const body = t(helpSectionBodyKey(activeId));
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
return (
<div className={styles.page} data-testid="instructions-app">
<div ref={contentRef} className={styles.content}>
<div className={styles.contentTitle}>{t(helpSectionTitleKey(activeId))}</div>
{paragraphs.map((p, i) => (
<p key={i} className={styles.paragraph}>
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
part.type === 'text' ? (
<React.Fragment key={j}>{part.value}</React.Fragment>
) : (
<button
key={j}
type="button"
className={styles.inlineLink}
onClick={() => setActiveId(part.id)}
>
{part.value}
</button>
),
)}
</p>
))}
</div>
<nav ref={navRef} className={styles.nav} aria-label={t('help.navAria')}>
{HELP_SECTION_IDS.map((id) => (
<button
key={id}
type="button"
className={[styles.navItem, id === activeId ? styles.navItemActive : '']
.filter(Boolean)
.join(' ')}
aria-current={id === activeId ? 'true' : undefined}
onClick={() => setActiveId(id)}
>
{t(helpSectionTitleKey(id))}
</button>
))}
</nav>
</div>
);
}
export function InstructionsApp() {
const [section, setSection] = useState<HelpSectionId>('overview');
useEffect(() => {
const api = getDndApi();
void api.invoke(ipcChannels.windows.getInstructionsSection, {}).then(({ section: next }) => {
setSection(parseHelpSectionId(next));
});
return api.on(ipcChannels.windows.instructionsSection, ({ section: next }) => {
setSection(parseHelpSectionId(next));
});
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') window.close();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return <InstructionsPanel initialSection={section} />;
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { InstructionsApp } from './InstructionsApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Instructions">
<EditorI18nProvider>
<InstructionsApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
+6
View File
@@ -8,6 +8,7 @@ import editorStyles from '../editor/EditorApp.module.css';
import { import {
filterMaterialImagePaths, filterMaterialImagePaths,
getDroppedFileEntries, getDroppedFileEntries,
nameFromFilePath,
pickFirstMaterialImagePath, pickFirstMaterialImagePath,
useFileDropZone, useFileDropZone,
} from '../editor/fileDrop'; } from '../editor/fileDrop';
@@ -111,6 +112,11 @@ export function NpcEditModal({
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => { const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
setFilePath(path); setFilePath(path);
setName((prev) => {
if (prev.trim()) return prev;
const fromFile = nameFromFilePath(path);
return fromFile || prev;
});
setLocalPreviewUrl((prev) => { setLocalPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return previewUrl; return previewUrl;
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/playlists/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,15 @@
.page {
height: 100vh;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
box-sizing: border-box;
background: var(--bg);
color: var(--text);
}
.panel {
flex: 1;
min-height: 0;
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect, useMemo } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import {
PlaylistsPanel,
playlistsPanelLabelsFromT,
} from '../shared/playlists/PlaylistsPanel';
import styles from './PlaylistsApp.module.css';
export function PlaylistsApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const labels = useMemo(() => playlistsPanelLabelsFromT(t), [t]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closePlaylists, {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
return (
<div className={styles.page} data-testid="playlists-app">
<PlaylistsPanel labels={labels} className={styles.panel} readOnly />
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { PlaylistsApp } from './PlaylistsApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Playlists">
<EditorI18nProvider>
<PlaylistsApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
void test('PresentationApp: Escape запрашивает confirm, не закрывает сразу', () => {
const src = fs.readFileSync(path.join(here, 'PresentationApp.tsx'), 'utf8');
assert.ok(src.includes('requestCloseMultiWindowConfirm'));
assert.match(
src,
/e\.key === 'Escape'[\s\S]*requestCloseMultiWindowConfirm/,
);
assert.doesNotMatch(
src,
/e\.key === 'Escape'[\s\S]*closeMultiWindow/,
);
});
@@ -23,7 +23,8 @@ export function PresentationApp() {
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeMultiWindow, {}); e.preventDefault();
void api.invoke(ipcChannels.windows.requestCloseMultiWindowConfirm, {});
return; return;
} }
if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) { if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) {
+3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css'; import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary'; import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { PresentationApp } from './PresentationApp'; import { PresentationApp } from './PresentationApp';
@@ -14,7 +15,9 @@ if (!rootEl) {
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<WindowErrorBoundary title="Презентация"> <WindowErrorBoundary title="Презентация">
<EditorI18nProvider>
<PresentationApp /> <PresentationApp />
</EditorI18nProvider>
</WindowErrorBoundary> </WindowErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );

Some files were not shown because too many files have changed in this diff Show More