41 Commits

Author SHA1 Message Date
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
Ivan Fontosh 0a9168b320 fix: scene clear race, path editor anchors, avatar cover, team color crash
Clear scene now patches traps/tokens/npcTokens atomically and updateScene merges against a fresh scene snapshot. Align NPC path-editor previews on token center, default new paths to fixed facing, size avatars by natural aspect for cover, and read team color synchronously from the picker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 15:30:34 +08:00
     Фонтош Иван Сергеевич 028c1be059 chore: bump version to 1.0.29
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 08:10:44 +08:00
Ivan Fontosh 9172660afb docs(help): document NPC overlay, session spawn, and token paths
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 10:54:28 +08:00
Ivan Fontosh 0f4bb76b41 feat(npcs): inspect cards, Show overlay, and optional external link
Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 10:42:57 +08:00
Ivan Fontosh 1a9f0f2557 feat(control): spawn session NPCs and resize tokens in preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:42:06 +08:00
Ivan Fontosh 1ab6ffd593 feat(tokens): animated paths for scene and NPC tokens
Add path editor window, session playback on control/presentation, and RMB controls. Fix live pose clock so motion no longer freezes after ~250ms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 08:48:41 +08:00
Ivan Fontosh 46bec1a86a feat(scene): keep map tokens with preview rotation
When previewRotationDeg changes, remap non-player, NPC and trap
markers (plus live session token overrides) so they stay on the art.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:29:20 +08:00
Ivan Fontosh 7362a36fe5 feat(scene): rotate video previews like images
Reuse previewRotationDeg for video scenes across editor, control,
presentation and overlays via ContainedVideo layout parity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:17:57 +08:00
Ivan Fontosh 1d94c95cc8 fix(release): upload Linux AppImage under feed x64 name
Prevent stale TTRPGPlayer-x64.AppImage on the updates host when
electron-builder leaves an x86_64 alias beside an older x64 file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:47:20 +08:00
Ivan Fontosh 1fbaaa6e77 feat(scene): video map editor parity and help updates
Enable scene editor, overlays, effects, and darkness on video scenes; brighten GM trap markers; document snap, NPC types, materials, and control controls in RU/EN help.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 08:23:08 +08:00
Ivan Fontosh 8d5a68c71e fix(pack): lazy-load sharp and verify unpacked natives
Avoid crashing Electron at startup when sharp is corrupt, and fail pack if asarUnpack natives look truncated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 07:47:36 +08:00
Ivan Fontosh 4456eb0277 feat(control): grid snap, NPC context actions, and overlay dim fix
Re-enable users-branch UI, snap session tokens to square/hex grid from the control preview, refine inactive/open-info NPC menus, block marker actions while an effect brush is active, and dim materials/NPC overlays only when they are open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:48:59 +08:00
     Фонтош Иван Сергеевич e53d1ea934 chore: bump version to 1.0.28
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 10:19:41 +08:00
Ivan Fontosh cbb6edc378 chore(release): gate users-branch features for hotfix
Add USERS_BRANCH_FEATURES_ENABLED (off by default) to hide Players library, session tokens, and related UI while keeping the preview import fix. Restore primary Run button when the flag is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 10:15:28 +08:00
Ivan Fontosh 4e6f7321b8 fix(project): race-safe batch scene preview import and close
Serialize updateProject and preview finalize, drain before pack/close, buffer zip entries, and abort finalize cleanly when leaving the project to avoid ENOENT on multi-image drops.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 09:44:45 +08:00
Ivan Fontosh f75444a5dd Merge branch 'users' into main
Players library, circular NPC tokens, disposition types; keep main overlay/release fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 08:03:22 +08:00
Ivan Fontosh 0f01950cc1 feat(effects): add Closing brush as inverse of Opening brush
Allow covering revealed darkness again during darkened scene sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:21:40 +08:00
Ivan Fontosh cc50e64e21 feat(players): NPC disposition types and launch-with-players session tokens
Add Hostile/Neutral/Friendly ring types with session-only inactive overrides on control, plus launch-with-players flow and live player tokens on the map.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:11:01 +08:00
Ivan Fontosh 101f595bac feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:38:35 +08:00
389 changed files with 27104 additions and 1616 deletions
+3
View File
@@ -17,3 +17,6 @@ Thumbs.db
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
*.tsbuildinfo *.tsbuildinfo
test-results/
playwright-report/
e2e/fixtures/sample.png
@@ -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;
}
}
}
}
+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);
});
+108
View File
@@ -0,0 +1,108 @@
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();
}
}
}
}
+32 -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] = {
@@ -387,6 +394,7 @@ export async function buildProjectFromFoundryDocuments(
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: audioRefs }, media: { videos: [], audios: audioRefs },
settings: { settings: {
@@ -405,15 +413,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);
@@ -446,6 +467,11 @@ export async function buildProjectFromFoundryDocuments(
x: 80 + (npcIndex % 4) * 220, x: 80 + (npcIndex % 4) * 220,
y: 80 + Math.floor(npcIndex / 4) * 200, y: 80 + Math.floor(npcIndex / 4) * 200,
groupId, groupId,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
externalUrl: '',
}); });
npcIndex += 1; npcIndex += 1;
} }
@@ -523,6 +549,7 @@ export async function buildProjectFromFoundryDocuments(
assets: ctx.assets, assets: ctx.assets,
campaignAudios, campaignAudios,
materials: [], materials: [],
gridTemplates: [],
npcs, npcs,
npcGroups, npcGroups,
npcRelations: [], npcRelations: [],
@@ -532,7 +559,7 @@ export async function buildProjectFromFoundryDocuments(
sceneGraphEdges, sceneGraphEdges,
}; };
return { project, previewSources }; return { project, previewSources, pendingAppPlaylists };
} }
/** Actor folders Foundry → дерево групп НПС. */ /** Actor folders Foundry → дерево групп НПС. */
@@ -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');
}
+978 -51
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,318 @@
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;
}
}
+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;
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { asNpcId } from '../../shared/types/ids';
import { NpcsOverlayStore } from './npcsOverlayStore';
const a = asNpcId('npc_a');
const b = asNpcId('npc_b');
void test('NpcsOverlayStore: inspect is independent from overlay show', () => {
const store = new NpcsOverlayStore();
store.dispatch({ kind: 'inspect.toggle', npcId: a });
store.dispatch({ kind: 'inspect.show', npcId: b });
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
assert.equal(store.getState().inspectFocusNpcId, b);
assert.deepEqual(store.getState().activeNpcIds, []);
store.dispatch({ kind: 'show', npcId: a });
assert.deepEqual(store.getState().activeNpcIds, [a]);
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
store.dispatch({ kind: 'hide' });
assert.deepEqual(store.getState().activeNpcIds, []);
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
store.dispatch({ kind: 'inspect.toggle', npcId: a });
assert.deepEqual(store.getState().inspectNpcIds, [b]);
});
void test('NpcsOverlayStore: clear wipes inspect; ensureNpcStillExists prunes', () => {
const store = new NpcsOverlayStore();
store.dispatch({ kind: 'inspect.show', npcId: a });
store.dispatch({ kind: 'show', npcId: b });
store.ensureNpcStillExists(new Set([a]));
assert.deepEqual(store.getState().inspectNpcIds, [a]);
assert.deepEqual(store.getState().activeNpcIds, []);
store.clear();
assert.deepEqual(store.getState().inspectNpcIds, []);
});
+85 -6
View File
@@ -16,6 +16,8 @@ function emptyState(): NpcsOverlayState {
layouts: {}, layouts: {},
focusNpcId: null, focusNpcId: null,
zoomTool: null, zoomTool: null,
inspectNpcIds: [],
inspectFocusNpcId: null,
}; };
} }
@@ -23,6 +25,15 @@ function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT }; return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
} }
function addInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
if (ids.includes(npcId)) return ids;
return [...ids, npcId];
}
function removeInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
return ids.filter((id) => id !== npcId);
}
export class NpcsOverlayStore { export class NpcsOverlayStore {
private state: NpcsOverlayState = emptyState(); private state: NpcsOverlayState = emptyState();
@@ -30,11 +41,29 @@ export class NpcsOverlayStore {
return this.state; return this.state;
} }
/** Полный сброс (закрытие проекта / конец сессии). */
clear(): NpcsOverlayState { clear(): NpcsOverlayState {
const s = this.state;
if (
s.activeNpcIds.length === 0 &&
s.zoomTool === null &&
s.inspectNpcIds.length === 0 &&
s.inspectFocusNpcId === null
) {
return s;
}
this.state = emptyState();
this.state.revision = s.revision + 1;
return this.state;
}
/** Снять аватары с экрана, карточки описания оставить. */
hideAvatars(): NpcsOverlayState {
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) { if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
return this.state; return this.state;
} }
this.state = { this.state = {
...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeNpcIds: [], activeNpcIds: [],
layouts: {}, layouts: {},
@@ -47,7 +76,7 @@ export class NpcsOverlayStore {
dispatch(event: NpcsOverlayEvent): NpcsOverlayState { dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
switch (event.kind) { switch (event.kind) {
case 'hide': case 'hide':
return this.clear(); return this.hideAvatars();
case 'show': { case 'show': {
if (this.state.activeNpcIds.includes(event.npcId)) { if (this.state.activeNpcIds.includes(event.npcId)) {
this.state = { this.state = {
@@ -58,6 +87,7 @@ export class NpcsOverlayStore {
return this.state; return this.state;
} }
this.state = { this.state = {
...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId], activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: { layouts: {
@@ -65,7 +95,6 @@ export class NpcsOverlayStore {
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, [event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
}, },
focusNpcId: event.npcId, focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
}; };
return this.state; return this.state;
} }
@@ -79,6 +108,7 @@ export class NpcsOverlayStore {
? (activeNpcIds[activeNpcIds.length - 1] ?? null) ? (activeNpcIds[activeNpcIds.length - 1] ?? null)
: this.state.focusNpcId; : this.state.focusNpcId;
this.state = { this.state = {
...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeNpcIds, activeNpcIds,
layouts, layouts,
@@ -88,6 +118,7 @@ export class NpcsOverlayStore {
return this.state; return this.state;
} }
this.state = { this.state = {
...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId], activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: { layouts: {
@@ -95,7 +126,38 @@ export class NpcsOverlayStore {
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT }, [event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
}, },
focusNpcId: event.npcId, focusNpcId: event.npcId,
zoomTool: this.state.zoomTool, };
return this.state;
}
case 'inspect.toggle': {
if (this.state.inspectNpcIds.includes(event.npcId)) {
const inspectNpcIds = removeInspect(this.state.inspectNpcIds, event.npcId);
const inspectFocusNpcId =
this.state.inspectFocusNpcId === event.npcId
? (inspectNpcIds[inspectNpcIds.length - 1] ?? null)
: this.state.inspectFocusNpcId;
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds,
inspectFocusNpcId,
};
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
inspectFocusNpcId: event.npcId,
};
return this.state;
}
case 'inspect.show': {
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
inspectFocusNpcId: event.npcId,
}; };
return this.state; return this.state;
} }
@@ -157,8 +219,19 @@ export class NpcsOverlayStore {
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState { ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id)); const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
if (activeNpcIds.length === this.state.activeNpcIds.length) return this.state; const inspectNpcIds = this.state.inspectNpcIds.filter((id) => npcIds.has(id));
if (activeNpcIds.length === 0) return this.clear(); const sameActive = activeNpcIds.length === this.state.activeNpcIds.length;
const sameInspect = inspectNpcIds.length === this.state.inspectNpcIds.length;
if (sameActive && sameInspect) return this.state;
if (activeNpcIds.length === 0 && inspectNpcIds.length === 0) {
const zoomTool = this.state.zoomTool;
this.state = {
...emptyState(),
revision: this.state.revision + 1,
zoomTool,
};
return this.state;
}
const layouts: Record<string, NpcsOverlayLayout> = {}; const layouts: Record<string, NpcsOverlayLayout> = {};
for (const id of activeNpcIds) { for (const id of activeNpcIds) {
const layout = this.state.layouts[id]; const layout = this.state.layouts[id];
@@ -168,12 +241,18 @@ export class NpcsOverlayStore {
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId) this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
? this.state.focusNpcId ? this.state.focusNpcId
: (activeNpcIds[activeNpcIds.length - 1] ?? null); : (activeNpcIds[activeNpcIds.length - 1] ?? null);
const inspectFocusNpcId =
this.state.inspectFocusNpcId && inspectNpcIds.includes(this.state.inspectFocusNpcId)
? this.state.inspectFocusNpcId
: (inspectNpcIds[inspectNpcIds.length - 1] ?? null);
this.state = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeNpcIds, activeNpcIds,
layouts, layouts,
focusNpcId, focusNpcId,
zoomTool: this.state.zoomTool, zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
inspectNpcIds,
inspectFocusNpcId,
}; };
return this.state; return this.state;
} }
+70
View File
@@ -0,0 +1,70 @@
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 { PlayersStore } from './playersStore';
import { asPlayerId } from '../../shared/types/ids';
async function withTempStore(run: (store: PlayersStore, root: string) => Promise<void>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-players-'));
const store = new PlayersStore(root);
try {
await run(store, root);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
void test('PlayersStore upsert list delete and teams', async () => {
await withTempStore(async (store, root) => {
const png = path.join(root, 'sample.png');
// minimal 1x1 png
const buf = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
await fs.writeFile(png, buf);
const progress: number[] = [];
const player = await store.upsert(
{ name: 'Ada', filePath: png, ringColor: '#112233', imageOffset: { x: 0.1, y: -0.1 } },
(p) => progress.push(p.percent),
);
assert.equal(player.name, 'Ada');
assert.equal(player.ringColor, '#112233');
assert.ok(progress.length > 0);
assert.equal(store.listPlayers().length, 1);
const team = await store.upsertTeam({ name: 'Party', color: '#abcdef' });
assert.equal(team.name, 'Party');
const assigned = await store.assignPlayerTeam(player.id, team.id);
assert.equal(assigned?.teamId, team.id);
await store.deleteTeam(team.id);
assert.equal(store.listTeams().length, 0);
assert.equal(store.getById(player.id)?.teamId, null);
await store.delete(player.id);
assert.equal(store.listPlayers().length, 0);
});
});
void test('PlayersStore persists across reload', async () => {
await withTempStore(async (store, root) => {
const png = path.join(root, 'sample.png');
await fs.writeFile(
png,
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
),
);
const created = await store.upsert({ name: 'Bob', filePath: png });
const store2 = new PlayersStore(root);
await store2.ensureLoaded();
assert.equal(store2.listPlayers().length, 1);
assert.equal(store2.getById(asPlayerId(created.id))?.name, 'Bob');
});
});
+311
View File
@@ -0,0 +1,311 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
assignPlayerToTeam,
createPlayerTeamDraft,
deletePlayerTeam,
normalizeAppPlayerTeams,
uniquePlayerTeamName,
} from '../../shared/players/playerTeams';
import type {
AppPlayer,
AppPlayerTeam,
PlayerId,
PlayerImageOffset,
PlayersUpsertProgressEvent,
PlayerTeamId,
} from '../../shared/types/appPlayers';
import {
clampPlayerImageOffset,
clampPlayerImageScale,
DEFAULT_PLAYER_IMAGE_OFFSET,
DEFAULT_PLAYER_IMAGE_SCALE,
DEFAULT_PLAYER_RING_COLOR,
normalizeAppPlayer,
} from '../../shared/types/appPlayers';
import { asPlayerId } from '../../shared/types/ids';
import { normalizeHexColor } from '../../shared/npcs/npcGroups';
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
type PlayersManifest = {
players: AppPlayer[];
teams: AppPlayerTeam[];
};
function mimeFromExt(ext: string): string {
const e = ext.toLowerCase();
if (e === '.png') return 'image/png';
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
if (e === '.webp') return 'image/webp';
if (e === '.gif') return 'image/gif';
return 'application/octet-stream';
}
function safeFileBase(name: string): string {
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
return base || 'player';
}
function randomPlayerId(): PlayerId {
return asPlayerId(`player_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
export class PlayersStore {
private readonly rootDir: string;
private readonly filesDir: string;
private readonly manifestPath: string;
private players: AppPlayer[] = [];
private teams: AppPlayerTeam[] = [];
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'players');
this.filesDir = path.join(this.rootDir, 'files');
this.manifestPath = path.join(this.rootDir, 'players.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 PlayersManifest;
this.teams = normalizeAppPlayerTeams(parsed.teams);
const teamIds = new Set(this.teams.map((t) => t.id));
this.players = (Array.isArray(parsed.players) ? parsed.players : [])
.map((p) => normalizeAppPlayer(p, teamIds))
.filter((p): p is AppPlayer => Boolean(p));
} catch {
this.players = [];
this.teams = [];
await this.persist();
}
this.loaded = true;
}
listPlayers(): AppPlayer[] {
return [...this.players];
}
listTeams(): AppPlayerTeam[] {
return [...this.teams];
}
getById(id: PlayerId): AppPlayer | null {
return this.players.find((p) => p.id === id) ?? null;
}
getImageReadInfo(id: PlayerId): { absPath: string; mime: string } | null {
const player = this.getById(id);
if (!player) return null;
const absPath = path.join(this.rootDir, player.imageRelPath);
return { absPath, mime: mimeFromExt(path.extname(player.imageRelPath)) };
}
getImageUrl(id: PlayerId): string | null {
if (!this.getImageReadInfo(id)) return null;
return `dnd://player?id=${encodeURIComponent(id)}`;
}
async upsert(
input: {
id?: PlayerId | null;
name: string;
filePath?: string | null;
teamId?: PlayerTeamId | null;
ringColor?: string;
imageOffset?: PlayerImageOffset;
imageScale?: number;
},
onProgress?: (p: PlayersUpsertProgressEvent) => void,
): Promise<AppPlayer> {
await this.ensureLoaded();
const name = input.name.trim();
if (!name) throw new Error('Player name is required');
const existing = input.id ? this.getById(input.id) : null;
if (input.id && !existing) throw new Error('Player not found');
if (!existing && !input.filePath) throw new Error('Player image is required');
const emit = (percent: number, stage: string, detail?: string) => {
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
};
emit(5, 'prepare', 'Подготовка…');
let imageRelPath = existing?.imageRelPath ?? '';
let sha256 = existing?.sha256 ?? '';
const id = existing?.id ?? randomPlayerId();
if (input.filePath) {
emit(15, 'read', 'Чтение изображения…');
let buf = await fs.readFile(input.filePath);
emit(40, 'optimize', 'Оптимизация…');
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
/* keep original */
}
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const ext = path.extname(input.filePath) || '.png';
const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`;
imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
const abs = path.join(this.rootDir, imageRelPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
emit(75, 'write', 'Сохранение…');
await fs.writeFile(abs, buf);
if (existing && existing.imageRelPath !== imageRelPath) {
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
}
const teamIds = new Set(this.teams.map((t) => t.id));
let teamId: PlayerTeamId | null =
input.teamId !== undefined ? input.teamId : (existing?.teamId ?? null);
if (teamId && !teamIds.has(teamId)) teamId = null;
const player: AppPlayer = {
id,
name,
imageRelPath,
sha256,
teamId,
ringColor: normalizeHexColor(
input.ringColor ?? existing?.ringColor,
DEFAULT_PLAYER_RING_COLOR,
),
imageOffset: clampPlayerImageOffset(
input.imageOffset ?? existing?.imageOffset ?? DEFAULT_PLAYER_IMAGE_OFFSET,
),
imageScale: clampPlayerImageScale(
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) {
this.players = this.players.map((p) => (p.id === player.id ? player : p));
} else {
this.players = [...this.players, player];
}
emit(95, 'persist', 'Запись…');
await this.persist();
emit(100, 'done', 'Готово');
return player;
}
async delete(id: PlayerId): Promise<void> {
await this.ensureLoaded();
const existing = this.getById(id);
if (!existing) return;
this.players = this.players.filter((p) => p.id !== id);
await this.persist();
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
async setPlayersOrder(playerIds: PlayerId[]): Promise<AppPlayer[]> {
await this.ensureLoaded();
const byId = new Map(this.players.map((p) => [p.id, p]));
const next: AppPlayer[] = [];
for (const id of playerIds) {
const p = byId.get(id);
if (p) {
next.push(p);
byId.delete(id);
}
}
for (const p of byId.values()) next.push(p);
this.players = next;
await this.persist();
return this.listPlayers();
}
async upsertTeam(input: {
id?: PlayerTeamId | null;
name: string;
color?: string;
}): Promise<AppPlayerTeam> {
await this.ensureLoaded();
const existing = input.id ? this.teams.find((t) => t.id === input.id) : null;
if (input.id && !existing) throw new Error('Team not found');
if (existing) {
const team: AppPlayerTeam = {
id: existing.id,
name: uniquePlayerTeamName(input.name, this.teams, existing.id),
color: normalizeHexColor(input.color ?? existing.color, DEFAULT_PLAYER_RING_COLOR),
};
this.teams = this.teams.map((t) => (t.id === team.id ? team : t));
await this.persist();
return team;
}
const team = createPlayerTeamDraft(input.name, input.color, this.teams);
this.teams = [...this.teams, team];
await this.persist();
return team;
}
async deleteTeam(id: PlayerTeamId): Promise<void> {
await this.ensureLoaded();
const next = deletePlayerTeam(this.teams, this.players, id);
this.teams = next.teams;
this.players = next.players;
await this.persist();
}
async setTeamsOrder(teamIds: PlayerTeamId[]): Promise<AppPlayerTeam[]> {
await this.ensureLoaded();
const byId = new Map(this.teams.map((t) => [t.id, t]));
const next: AppPlayerTeam[] = [];
for (const id of teamIds) {
const t = byId.get(id);
if (t) {
next.push(t);
byId.delete(id);
}
}
for (const t of byId.values()) next.push(t);
this.teams = next;
await this.persist();
return this.listTeams();
}
async assignPlayerTeam(playerId: PlayerId, teamId: PlayerTeamId | null): Promise<AppPlayer | null> {
await this.ensureLoaded();
const teamIds = new Set(this.teams.map((t) => t.id as string));
this.players = assignPlayerToTeam(this.players, playerId, teamId, teamIds);
await this.persist();
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> {
await fs.mkdir(this.rootDir, { recursive: true });
const payload: PlayersManifest = { players: this.players, teams: this.teams };
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
}
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
import { SceneNpcTokensSessionStore } from './sceneNpcTokensSessionStore';
void test('SceneNpcTokensSessionStore move and reset', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
assert.equal(s1.byPlacementId.a?.nx, 0.2);
assert.equal(s1.revision, 2);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
assert.equal(s2.revision, s1.revision); // no-op
const s3 = store.reset();
assert.deepEqual(s3.byPlacementId, {});
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
assert.ok(s3.revision > s1.revision);
});
void test('SceneNpcTokensSessionStore setScale', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setScale', scale: 1.5 });
assert.equal(s1.scale, 1.5);
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.1, ny: 0.2 });
const s2 = store.dispatch({ kind: 'setScale', scale: 0.1 });
assert.equal(s2.scale, 0.4); // clamped min
assert.ok(s2.byPlacementId.a);
const s3 = store.reset();
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
assert.deepEqual(s3.byPlacementId, {});
});
void test('SceneNpcTokensSessionStore setDisposition without inventing position', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({
kind: 'setDisposition',
placementId: 'a',
disposition: 'hostile',
});
assert.equal(s1.byPlacementId.a?.disposition, 'hostile');
assert.equal(s1.byPlacementId.a?.nx, undefined);
assert.equal(s1.byPlacementId.a?.ny, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.4, ny: 0.6 });
assert.equal(s2.byPlacementId.a?.nx, 0.4);
assert.equal(s2.byPlacementId.a?.disposition, 'hostile');
});
void test('SceneNpcTokensSessionStore spawn is session-only and despawn removes it', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({
kind: 'spawn',
npcId: 'npc1',
sceneId: 'sceneA',
nx: 0.4,
ny: 0.6,
disposition: 'hostile',
});
const ids = Object.keys(s1.spawned);
assert.equal(ids.length, 1);
const spawn = s1.spawned[ids[0]!];
assert.equal(spawn?.npcId, 'npc1');
assert.equal(spawn?.sceneId, 'sceneA');
assert.equal(spawn?.nx, 0.4);
const s2 = store.dispatch({ kind: 'move', placementId: ids[0]!, nx: 0.7, ny: 0.2 });
assert.equal(s2.spawned[ids[0]!]?.nx, 0.7);
const s3 = store.dispatch({ kind: 'despawn', placementId: ids[0]! });
assert.deepEqual(s3.spawned, {});
assert.equal(s3.byPlacementId[ids[0]!], undefined);
});
void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
assert.equal(s1.byPlacementId.a?.inactive, true);
const s2 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
assert.deepEqual(s2.byPlacementId, {});
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
const s3 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
assert.equal(s3.byPlacementId.a?.nx, 0.2);
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', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
assert.equal(s1.byPlacementId.a?.sizeN, 0.2);
assert.equal(s1.byPlacementId.a?.nx, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.3, ny: 0.4 });
assert.equal(s2.byPlacementId.a?.sizeN, 0.2);
assert.equal(s2.byPlacementId.a?.nx, 0.3);
const spawned = store.dispatch({
kind: 'spawn',
npcId: 'npc1',
sceneId: 'sceneA',
nx: 0.5,
ny: 0.5,
});
const id = Object.keys(spawned.spawned)[0]!;
const s3 = store.dispatch({ kind: 'setSize', placementId: id, sizeN: 0.18 });
assert.equal(s3.spawned[id]?.sizeN, 0.18);
assert.equal(s3.byPlacementId[id]?.sizeN, 0.18);
});
@@ -0,0 +1,313 @@
import {
clampNpcTokenSessionScale,
clampSceneNpcTokenSizeN,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
asSceneNpcTokenId,
emptySceneNpcTokensSessionState,
newSceneNpcSessionSpawnId,
type SceneNpcSessionSpawn,
type SceneNpcTokensSessionEvent,
type SceneNpcTokensSessionPlacement,
type SceneNpcTokensSessionState,
} from '../../shared/types/appPlayers';
import { asNpcId } from '../../shared/types/ids';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return (
p.nx === undefined &&
p.ny === undefined &&
p.sizeN === undefined &&
!p.disposition &&
!p.inactive &&
!p.hiddenFromPlayers
);
}
function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcTokensSessionPlacement {
return {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}),
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
};
}
function withMove(
prev: SceneNpcTokensSessionPlacement | undefined,
nx: number,
ny: number,
): SceneNpcTokensSessionPlacement {
return {
...restOf(prev),
nx,
ny,
};
}
function withoutPlacement(
state: SceneNpcTokensSessionState,
placementId: string,
): SceneNpcTokensSessionState {
if (!(placementId in state.byPlacementId)) return state;
const { [placementId]: _removed, ...rest } = state.byPlacementId;
return {
revision: state.revision + 1,
byPlacementId: rest,
scale: state.scale,
spawned: state.spawned,
};
}
export class SceneNpcTokensSessionStore {
private state: SceneNpcTokensSessionState = emptySceneNpcTokensSessionState();
getState(): SceneNpcTokensSessionState {
return this.state;
}
reset(): SceneNpcTokensSessionState {
if (
Object.keys(this.state.byPlacementId).length === 0 &&
Object.keys(this.state.spawned).length === 0 &&
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
) {
return this.state;
}
this.state = emptySceneNpcTokensSessionState(this.state.revision + 1);
return this.state;
}
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
rotateMapCwSteps(steps: number): SceneNpcTokensSessionState {
const n = ((steps % 4) + 4) % 4;
if (n === 0) return this.state;
const ids = Object.keys(this.state.byPlacementId);
const spawnIds = Object.keys(this.state.spawned);
if (ids.length === 0 && spawnIds.length === 0) return this.state;
let changed = false;
const byPlacementId: SceneNpcTokensSessionState['byPlacementId'] = {};
for (const id of ids) {
const prev = this.state.byPlacementId[id];
if (!prev) continue;
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
byPlacementId[id] = { ...prev, nx: p.nx, ny: p.ny };
changed = true;
} else {
byPlacementId[id] = prev;
}
}
const spawned: SceneNpcTokensSessionState['spawned'] = {};
for (const id of spawnIds) {
const prev = this.state.spawned[id];
if (!prev) continue;
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
spawned[id] = { ...prev, nx: p.nx, ny: p.ny };
changed = true;
}
if (!changed) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId,
scale: this.state.scale,
spawned,
};
return this.state;
}
dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'setScale': {
const scale = clampNpcTokenSessionScale(event.scale);
if (this.state.scale === scale) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
scale,
};
return this.state;
}
case 'spawn': {
const npcId = String(event.npcId ?? '');
const sceneId = String(event.sceneId ?? '');
if (!npcId || !sceneId) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const id = event.placementId
? asSceneNpcTokenId(String(event.placementId))
: newSceneNpcSessionSpawnId();
const spawn: SceneNpcSessionSpawn = {
id,
sceneId,
npcId: asNpcId(npcId),
nx,
ny,
sizeN: clampSceneNpcTokenSizeN(
typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
),
disposition: normalizeNpcDisposition(event.disposition),
};
this.state = {
revision: this.state.revision + 1,
byPlacementId: this.state.byPlacementId,
scale: this.state.scale,
spawned: { ...this.state.spawned, [String(spawn.id)]: spawn },
};
return this.state;
}
case 'despawn': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
if (!(placementId in this.state.spawned) && !(placementId in this.state.byPlacementId)) {
return this.state;
}
const { [placementId]: _s, ...spawned } = this.state.spawned;
const { [placementId]: _p, ...byPlacementId } = this.state.byPlacementId;
this.state = {
revision: this.state.revision + 1,
byPlacementId,
scale: this.state.scale,
spawned,
};
return this.state;
}
case 'move': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlacementId[placementId];
const spawn = this.state.spawned[placementId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
const spawned = spawn
? { ...this.state.spawned, [placementId]: { ...spawn, nx, ny } }
: this.state.spawned;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: withMove(prev, nx, ny),
},
scale: this.state.scale,
spawned,
};
return this.state;
}
case 'setDisposition': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const disposition = normalizeNpcDisposition(event.disposition);
const prev = this.state.byPlacementId[placementId];
const spawn = this.state.spawned[placementId];
if (prev?.disposition === disposition && (!spawn || spawn.disposition === disposition)) {
return this.state;
}
const next: SceneNpcTokensSessionPlacement = {
...restOf(prev),
disposition,
};
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
spawned: spawn
? { ...this.state.spawned, [placementId]: { ...spawn, disposition } }
: this.state.spawned,
};
return this.state;
}
case 'setInactive': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const inactive = Boolean(event.inactive);
const prev = this.state.byPlacementId[placementId];
if (Boolean(prev?.inactive) === inactive) return this.state;
const next: SceneNpcTokensSessionPlacement = {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(inactive ? { inactive: true } : {}),
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
};
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 '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': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
const prev = this.state.byPlacementId[placementId];
const spawn = this.state.spawned[placementId];
if (prev?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: { ...restOf(prev), sizeN },
},
scale: this.state.scale,
spawned: spawn
? { ...this.state.spawned, [placementId]: { ...spawn, sizeN } }
: this.state.spawned,
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ScenePlayerTokensSessionStore } from './scenePlayerTokensSessionStore';
void test('ScenePlayerTokensSessionStore selection and show/hide', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['a', 'b', 'a'] });
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
assert.equal(store.getState().visible, false);
store.dispatch({ kind: 'show', sizeN: 0.08 });
assert.equal(store.getState().visible, true);
assert.equal(Object.keys(store.getState().byPlayerId).length, 2);
assert.ok(store.getState().byPlayerId.a);
assert.equal(store.getState().byPlayerId.a?.ny, 0.88);
store.dispatch({ kind: 'setVisible', visible: false });
assert.equal(store.getState().visible, false);
assert.ok(store.getState().byPlayerId.a);
store.dispatch({ kind: 'clearPlacements' });
assert.equal(store.getState().visible, false);
assert.deepEqual(store.getState().byPlayerId, {});
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
});
void test('ScenePlayerTokensSessionStore move and reset', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
store.dispatch({ kind: 'show', sizeN: 0.1 });
const s1 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.3, ny: 0.4 });
assert.equal(s1.byPlayerId.p1?.nx, 0.3);
assert.equal(s1.byPlayerId.p1?.ny, 0.4);
const s2 = store.reset();
assert.deepEqual(s2.selectedPlayerIds, []);
assert.equal(s2.visible, false);
assert.deepEqual(s2.byPlayerId, {});
});
void test('ScenePlayerTokensSessionStore rotateMapCwSteps', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
store.dispatch({ kind: 'show', sizeN: 0.1 });
store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.2, ny: 0.1 });
const s = store.rotateMapCwSteps(1);
assert.equal(s.byPlayerId.p1?.nx, 0.9);
assert.equal(s.byPlayerId.p1?.ny, 0.2);
});
void test('ScenePlayerTokensSessionStore setSize updates placement', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
store.dispatch({ kind: 'show', sizeN: 0.1 });
const nxBefore = store.getState().byPlayerId.p1?.nx;
const s1 = store.dispatch({ kind: 'setSize', playerId: 'p1', sizeN: 0.22 });
assert.equal(s1.byPlayerId.p1?.sizeN, 0.22);
assert.equal(s1.byPlayerId.p1?.nx, nxBefore);
const s2 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.4, ny: 0.5 });
assert.equal(s2.byPlayerId.p1?.sizeN, 0.22);
});
@@ -0,0 +1,182 @@
import {
clampSceneNpcTokenSizeN,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
layoutPlayerTokensBottom,
type ScenePlayerTokensSessionEvent,
type ScenePlayerTokensSessionState,
} from '../../shared/types/appPlayers';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function emptyState(revision = 1): ScenePlayerTokensSessionState {
return {
revision,
selectedPlayerIds: [],
visible: false,
byPlayerId: {},
};
}
function normalizePlayerIds(raw: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const id of raw) {
const s = String(id ?? '').trim();
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
export class ScenePlayerTokensSessionStore {
private state: ScenePlayerTokensSessionState = emptyState();
getState(): ScenePlayerTokensSessionState {
return this.state;
}
reset(): ScenePlayerTokensSessionState {
if (
this.state.selectedPlayerIds.length === 0 &&
!this.state.visible &&
Object.keys(this.state.byPlayerId).length === 0
) {
return this.state;
}
this.state = emptyState(this.state.revision + 1);
return this.state;
}
/** Смена сцены: спрятать токены, сбросить позиции; выбор игроков сохранить. */
clearPlacements(): ScenePlayerTokensSessionState {
if (!this.state.visible && Object.keys(this.state.byPlayerId).length === 0) {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
visible: false,
byPlayerId: {},
};
return this.state;
}
/** Rotate live player tokens with the scene preview (CSS rotate steps). */
rotateMapCwSteps(steps: number): ScenePlayerTokensSessionState {
const n = ((steps % 4) + 4) % 4;
if (n === 0) return this.state;
const ids = Object.keys(this.state.byPlayerId);
if (ids.length === 0) return this.state;
const byPlayerId: ScenePlayerTokensSessionState['byPlayerId'] = {};
for (const id of ids) {
const prev = this.state.byPlayerId[id];
if (!prev) continue;
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
byPlayerId[id] = { ...prev, nx: p.nx, ny: p.ny };
}
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId,
};
return this.state;
}
dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'clearPlacements':
return this.clearPlacements();
case 'setSelection': {
const selectedPlayerIds = normalizePlayerIds(event.playerIds);
const same =
selectedPlayerIds.length === this.state.selectedPlayerIds.length &&
selectedPlayerIds.every((id, i) => id === this.state.selectedPlayerIds[i]);
if (same) return this.state;
this.state = {
revision: this.state.revision + 1,
selectedPlayerIds,
visible: false,
byPlayerId: {},
};
return this.state;
}
case 'setVisible': {
const visible = Boolean(event.visible);
if (this.state.visible === visible) return this.state;
if (visible && this.state.selectedPlayerIds.length === 0) return this.state;
this.state = { ...this.state, revision: this.state.revision + 1, visible };
return this.state;
}
case 'show': {
if (this.state.selectedPlayerIds.length === 0) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
const byPlayerId =
Object.keys(this.state.byPlayerId).length > 0
? this.state.byPlayerId
: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN);
if (this.state.visible && byPlayerId === this.state.byPlayerId) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
visible: true,
byPlayerId,
};
return this.state;
}
case 'seedBottom': {
if (this.state.selectedPlayerIds.length === 0) return this.state;
if (Object.keys(this.state.byPlayerId).length > 0) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN),
};
return this.state;
}
case 'move': {
const playerId = String(event.playerId ?? '');
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlayerId[playerId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
const sizeN = clampSceneNpcTokenSizeN(prev?.sizeN ?? DEFAULT_SCENE_NPC_TOKEN_SIZE_N);
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: {
...this.state.byPlayerId,
[playerId]: { nx, ny, sizeN },
},
};
return this.state;
}
case 'setSize': {
const playerId = String(event.playerId ?? '');
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
const prev = this.state.byPlayerId[playerId];
if (!prev) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
if (prev.sizeN === sizeN) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: {
...this.state.byPlayerId,
[playerId]: { ...prev, sizeN },
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
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 });
}
+9 -1
View File
@@ -2,7 +2,7 @@
* Visually lossless re-encode for imported raster images (same pixel dimensions). * Visually lossless re-encode for imported raster images (same pixel dimensions).
* Node-only; shared by the main app and ../project-converter (monorepo sibling). * Node-only; shared by the main app and ../project-converter (monorepo sibling).
*/ */
import sharp from 'sharp'; import { getSharp } from './sharpRuntime.mjs';
/** @typedef {import('node:buffer').Buffer} Buffer */ /** @typedef {import('node:buffer').Buffer} Buffer */
@@ -102,6 +102,7 @@ function makePassthrough(buf, meta) {
* @param {number} h0 * @param {number} h0
*/ */
async function sameDimensionsOrThrow(outBuf, w0, h0) { async function sameDimensionsOrThrow(outBuf, w0, h0) {
const sharp = getSharp();
const m = await sharp(outBuf).metadata(); const m = await sharp(outBuf).metadata();
if ((m.width ?? 0) !== w0 || (m.height ?? 0) !== h0) { if ((m.width ?? 0) !== w0 || (m.height ?? 0) !== h0) {
const err = new Error('encode changed dimensions'); const err = new Error('encode changed dimensions');
@@ -120,6 +121,13 @@ export async function optimizeImageBufferVisuallyLossless(src) {
return makePassthrough(input, { width: 0, height: 0, format: 'png' }); return makePassthrough(input, { width: 0, height: 0, format: 'png' });
} }
let sharp;
try {
sharp = getSharp();
} catch {
return makePassthrough(input, null);
}
let meta0; let meta0;
try { try {
meta0 = await sharp(input, { failOn: 'error', unlimited: true }).metadata(); meta0 = await sharp(input, { failOn: 'error', unlimited: true }).metadata();
@@ -33,3 +33,19 @@ void test('generateScenePreviewThumbnailBytes: image scales to max edge', async
await fs.rm(tmp, { recursive: true, force: true }); await fs.rm(tmp, { recursive: true, force: true });
}); });
void test('generateScenePreviewThumbnailBytes: accepts image Buffer', async () => {
const png = await sharp({
create: {
width: 120,
height: 80,
channels: 3,
background: { r: 10, g: 20, b: 30 },
},
})
.png()
.toBuffer();
const buf = await generateScenePreviewThumbnailBytes(png, 'image');
assert.ok(buf !== null);
assert.ok(buf.length > 0);
});
+9 -3
View File
@@ -5,7 +5,8 @@ import path from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import ffmpegStatic from 'ffmpeg-static'; import ffmpegStatic from 'ffmpeg-static';
import sharp from 'sharp';
import { getSharp } from './sharpRuntime.mjs';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -14,14 +15,16 @@ export const SCENE_PREVIEW_THUMB_MAX_PX = 320;
/** /**
* Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds). * Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds).
* For images, prefer a Buffer so callers are not racy with reconcileAssetFiles unlinking paths.
*/ */
export async function generateScenePreviewThumbnailBytes( export async function generateScenePreviewThumbnailBytes(
sourceAbsPath: string, source: string | Buffer,
kind: 'image' | 'video', kind: 'image' | 'video',
): Promise<Buffer | null> { ): Promise<Buffer | null> {
try { try {
const sharp = getSharp();
if (kind === 'image') { if (kind === 'image') {
return await sharp(sourceAbsPath) return await sharp(source)
.rotate() .rotate()
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, { .resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
fit: 'inside', fit: 'inside',
@@ -31,6 +34,9 @@ export async function generateScenePreviewThumbnailBytes(
.toBuffer(); .toBuffer();
} }
const sourceAbsPath = typeof source === 'string' ? source : null;
if (!sourceAbsPath) return null;
const ffmpegPath = ffmpegStatic; const ffmpegPath = ffmpegStatic;
if (!ffmpegPath) return null; if (!ffmpegPath) return null;
+71
View File
@@ -0,0 +1,71 @@
/**
* Lazy `sharp` load so a corrupt/missing native install does not crash Electron at import time.
* Call only from image-processing paths; errors are recoverable for the rest of the app.
*
* Note: main is bundled to CJS (esbuild). `import.meta.url` is empty there — prefer `__filename`.
*/
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* @returns {string}
*/
function requireBaseFilename() {
// CJS bundle / Electron main
if (typeof __filename === 'string' && __filename.length > 0) {
return __filename;
}
// Direct ESM (unit tests)
const metaUrl = import.meta.url;
if (typeof metaUrl === 'string' && metaUrl.startsWith('file:')) {
return fileURLToPath(metaUrl);
}
return path.join(process.cwd(), 'package.json');
}
const require = createRequire(requireBaseFilename());
/** @type {typeof import('sharp') | null} */
let cached = null;
/** @type {Error | null} */
let loadError = null;
/**
* @param {unknown} err
* @returns {Error}
*/
export function sharpLoadFailure(err) {
const detail = err instanceof Error ? err.message : String(err);
return new Error(
[
'Не удалось загрузить модуль обработки изображений (sharp).',
'Переустановите приложение полностью (удалите и поставьте заново)',
'или исключите папку установки из проверки антивируса.',
detail ? `Детали: ${detail}` : '',
]
.filter(Boolean)
.join(' '),
);
}
/**
* @returns {typeof import('sharp')}
*/
export function getSharp() {
if (cached) return cached;
if (loadError) throw loadError;
try {
cached = require('sharp');
return cached;
} catch (err) {
loadError = sharpLoadFailure(err);
throw loadError;
}
}
/** Reset cache (tests only). */
export function __resetSharpRuntimeForTests() {
cached = null;
loadError = null;
}
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
__resetSharpRuntimeForTests,
getSharp,
sharpLoadFailure,
} from './sharpRuntime.mjs';
void test('getSharp: loads sharp when install is healthy', () => {
__resetSharpRuntimeForTests();
const sharp = getSharp();
assert.equal(typeof sharp, 'function');
});
void test('sharpLoadFailure: includes reinstall hint', () => {
const err = sharpLoadFailure(new Error('SyntaxError: Unexpected end of input'));
assert.match(err.message, /переустановите/i);
assert.match(err.message, /SyntaxError/);
});
+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: [],
@@ -50,6 +50,17 @@ void test('zipStore: pack and open operations are serialized', () => {
assert.match(src, /enqueueProjectSwitch/); assert.match(src, /enqueueProjectSwitch/);
}); });
void test('zipStore: project updates and preview finalize are serialized', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /private projectUpdateChain: Promise<unknown>/);
assert.match(src, /private previewFinalizeChain: Promise<unknown>/);
assert.match(src, /enqueueProjectUpdate/);
assert.match(src, /enqueuePreviewFinalize/);
assert.match(src, /finalizeScenePreviewImportInner/);
assert.match(src, /drainProjectMutations/);
assert.match(src, /addBuffer/);
});
void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => { void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/); assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/);
@@ -69,6 +80,23 @@ void test('zipStore: normalizeScene defaults previewThumbAssetId for older proje
assert.match(src, /function normalizeScene\(/); assert.match(src, /function normalizeScene\(/);
}); });
void test('zipStore: NPC parse defaults externalUrl', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /externalUrl: normalizeNpcExternalUrl/);
});
void test('zipStore: updateScene merges patch inside updateProject mutator (no stale base race)', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
const i = src.indexOf('async updateScene(sceneId: SceneId, patch: ScenePatch)');
assert.ok(i >= 0);
const j = src.indexOf('async updateConnections(', i);
assert.ok(j > i);
const block = src.slice(i, j);
assert.match(block, /await this\.updateProject\(\(p\) => \{/);
assert.match(block, /const existing = p\.scenes\[sceneId\]/);
assert.doesNotMatch(block, /const existing = open\.project\.scenes\[sceneId\]/);
});
void test('zipStore: listProjects skips unreadable archives', () => { void test('zipStore: listProjects skips unreadable archives', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match( assert.match(
@@ -77,6 +105,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');
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -2,7 +2,9 @@ import fs from 'node:fs/promises';
import { session } from 'electron'; import { session } from 'electron';
import { asAssetId, asTokenId } from '../../shared/types/ids'; import { asAssetId, asPlayerId, asPlaylistAudioId, asTokenId } from '../../shared/types/ids';
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';
@@ -75,11 +77,13 @@ async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
} }
/** /**
* Обслуживает `dnd://asset?...` и `dnd://token?...`. * Обслуживает `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,
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);
@@ -92,6 +96,10 @@ export function registerDndAssetProtocol(
info = projectStore.getAssetReadInfo(asAssetId(id)); info = projectStore.getAssetReadInfo(asAssetId(id));
} else if (url.hostname === 'token') { } else if (url.hostname === 'token') {
info = tokensStore.getImageReadInfo(asTokenId(id)); info = tokensStore.getImageReadInfo(asTokenId(id));
} else if (url.hostname === 'player') {
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 });
@@ -52,3 +52,14 @@ void test('session IPC: нет emitSessionState на graph/NPC position hot-path
); );
assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/); assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/);
}); });
void test('video IPC heartbeat does not fan out to every BrowserWindow', () => {
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
const emit =
/function emitVideoState\(\): void \{([\s\S]*?)\n\}/.exec(index);
assert.ok(emit, 'emitVideoState');
assert.doesNotMatch(emit[1] ?? '', /getAllWindows/);
assert.match(emit[1] ?? '', /sendToAppWindows\(ipcChannels\.video\.stateChanged/);
assert.match(emit[1] ?? '', /'control'/);
assert.match(emit[1] ?? '', /'presentation'/);
});
@@ -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);
});
+131
View File
@@ -0,0 +1,131 @@
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;
}
}
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneTokensSessionStore } from './sceneTokensSessionStore';
void test('SceneTokensSessionStore setSize is session-only and survives move', () => {
const store = new SceneTokensSessionStore();
const s1 = store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.2 });
assert.equal(s1.byPlacementId.t1?.sizeN, 0.2);
assert.equal(s1.byPlacementId.t1?.nx, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 't1', nx: 0.3, ny: 0.4 });
assert.equal(s2.byPlacementId.t1?.sizeN, 0.2);
assert.equal(s2.byPlacementId.t1?.nx, 0.3);
const s3 = store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.01 });
assert.equal(s3.byPlacementId.t1?.sizeN, 0.02); // clamped min
});
void test('SceneTokensSessionStore rotateMapCwSteps keeps sizeN', () => {
const store = new SceneTokensSessionStore();
store.dispatch({ kind: 'move', placementId: 't1', nx: 0.2, ny: 0.1 });
store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.15 });
const s = store.rotateMapCwSteps(1);
assert.equal(s.byPlacementId.t1?.nx, 0.9);
assert.equal(s.byPlacementId.t1?.ny, 0.2);
assert.equal(s.byPlacementId.t1?.sizeN, 0.15);
});
+63 -2
View File
@@ -1,4 +1,10 @@
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types'; import {
clampSceneTokenSizeN,
type SceneTokensSessionEvent,
type SceneTokensSessionPlacement,
type SceneTokensSessionState,
} from '../../shared/types';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function emptyState(): SceneTokensSessionState { function emptyState(): SceneTokensSessionState {
return { return {
@@ -7,6 +13,18 @@ function emptyState(): SceneTokensSessionState {
}; };
} }
function withMove(
prev: SceneTokensSessionPlacement | undefined,
nx: number,
ny: number,
): SceneTokensSessionPlacement {
return {
nx,
ny,
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
};
}
export class SceneTokensSessionStore { export class SceneTokensSessionStore {
private state: SceneTokensSessionState = emptyState(); private state: SceneTokensSessionState = emptyState();
@@ -23,6 +41,30 @@ export class SceneTokensSessionStore {
return this.state; return this.state;
} }
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
rotateMapCwSteps(steps: number): SceneTokensSessionState {
const n = ((steps % 4) + 4) % 4;
if (n === 0) return this.state;
const ids = Object.keys(this.state.byPlacementId);
if (ids.length === 0) return this.state;
const byPlacementId: SceneTokensSessionState['byPlacementId'] = {};
for (const id of ids) {
const prev = this.state.byPlacementId[id];
if (!prev) continue;
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
const next = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
byPlacementId[id] = { ...prev, nx: next.nx, ny: next.ny };
} else {
byPlacementId[id] = prev;
}
}
this.state = {
revision: this.state.revision + 1,
byPlacementId,
};
return this.state;
}
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState { dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
switch (event.kind) { switch (event.kind) {
case 'clear': case 'clear':
@@ -39,7 +81,26 @@ export class SceneTokensSessionStore {
revision: this.state.revision + 1, revision: this.state.revision + 1,
byPlacementId: { byPlacementId: {
...this.state.byPlacementId, ...this.state.byPlacementId,
[placementId]: { nx, ny }, [placementId]: withMove(prev, nx, ny),
},
};
return this.state;
}
case 'setSize': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const sizeN = clampSceneTokenSizeN(event.sizeN);
const prev = this.state.byPlacementId[placementId];
if (prev?.sizeN === sizeN) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: {
...(typeof prev?.nx === 'number' ? { nx: prev.nx } : {}),
...(typeof prev?.ny === 'number' ? { ny: prev.ny } : {}),
sizeN,
},
}, },
}; };
return this.state; return this.state;
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TokenGridSnapSessionStore } from './tokenGridSnapSessionStore';
void test('TokenGridSnapSessionStore: set and reset', () => {
const store = new TokenGridSnapSessionStore();
assert.equal(store.getState().enabled, false);
assert.equal(store.setEnabled(true).enabled, true);
assert.equal(store.getState().enabled, true);
assert.equal(store.reset().enabled, false);
});
@@ -0,0 +1,21 @@
export type TokenGridSnapSessionState = {
enabled: boolean;
};
export class TokenGridSnapSessionStore {
private enabled = false;
getState(): TokenGridSnapSessionState {
return { enabled: this.enabled };
}
setEnabled(enabled: boolean): TokenGridSnapSessionState {
this.enabled = Boolean(enabled);
return this.getState();
}
reset(): TokenGridSnapSessionState {
this.enabled = false;
return this.getState();
}
}
+171
View File
@@ -0,0 +1,171 @@
import {
emptyTokenPathSessionState,
tokenPathKey,
type TokenPathPlaybackEntry,
type TokenPathSessionEvent,
type TokenPathSessionState,
type TokenPathTargetRef,
} from '../../shared/types/tokenPathSession';
function bump(
state: TokenPathSessionState,
patch: Partial<Omit<TokenPathSessionState, 'revision' | 'serverNowMs'>>,
): TokenPathSessionState {
return {
...state,
...patch,
revision: state.revision + 1,
serverNowMs: Date.now(),
};
}
export class TokenPathSessionStore {
private state: TokenPathSessionState = emptyTokenPathSessionState();
getState(): TokenPathSessionState {
return this.state;
}
/** Refresh clock without logical change (optional heartbeat). */
touchClock(): TokenPathSessionState {
this.state = { ...this.state, serverNowMs: Date.now() };
return this.state;
}
reset(): TokenPathSessionState {
if (
Object.keys(this.state.playback).length === 0 &&
Object.keys(this.state.presentationVisible).length === 0
) {
return this.state;
}
this.state = emptyTokenPathSessionState(this.state.revision + 1);
return this.state;
}
dispatch(event: TokenPathSessionEvent): TokenPathSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'showPresentation': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
if (this.state.presentationVisible[key]) return this.state;
this.state = bump(this.state, {
presentationVisible: { ...this.state.presentationVisible, [key]: true },
});
return this.state;
}
case 'hidePresentation': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
if (!this.state.presentationVisible[key]) return this.state;
const { [key]: _removed, ...rest } = this.state.presentationVisible;
this.state = bump(this.state, { presentationVisible: rest });
return this.state;
}
case 'stop': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
const prev = this.state.playback[key];
if (!prev || prev.phase === 'stopped') return this.state;
const atDist =
typeof event.atDist === 'number' && Number.isFinite(event.atDist)
? Math.max(0, event.atDist)
: prev.baseDist;
this.state = bump(this.state, {
playback: {
...this.state.playback,
[key]: {
...prev,
phase: 'stopped',
baseDist: atDist,
rejoinDist: null,
segmentStartedAtMs: Date.now(),
},
},
});
return this.state;
}
case 'resume': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
const prev = this.state.playback[key];
if (!prev) return this.state;
if (prev.phase !== 'stopped' && prev.phase !== 'done') return this.state;
const entry: TokenPathPlaybackEntry = {
...prev,
phase: 'moving',
segmentStartedAtMs: Date.now(),
// Jump to nearest-ahead on path, then continue (v1: no off-path lerp).
baseDist: Math.max(0, event.fromDist),
rejoinDist: null,
direction: 1,
};
this.state = bump(this.state, {
playback: { ...this.state.playback, [key]: entry },
});
return this.state;
}
case 'resetToStart': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
const prev = this.state.playback[key];
const entry: TokenPathPlaybackEntry = {
kind: event.target.kind,
placementId: event.target.placementId,
phase: 'moving',
segmentStartedAtMs: Date.now(),
baseDist: 0,
direction: 1,
rejoinDist: null,
durationSec: prev?.durationSec ?? 8,
pathLength: prev?.pathLength ?? 1,
};
this.state = bump(this.state, {
playback: { ...this.state.playback, [key]: entry },
});
return this.state;
}
case 'seedPlayback': {
const e = event.entry;
const key = tokenPathKey(e.kind, e.placementId);
const entry: TokenPathPlaybackEntry = {
kind: e.kind,
placementId: e.placementId,
phase: e.phase,
segmentStartedAtMs: e.segmentStartedAtMs ?? Date.now(),
baseDist: e.baseDist,
direction: e.direction,
rejoinDist: e.rejoinDist,
durationSec: e.durationSec,
pathLength: e.pathLength,
};
this.state = bump(this.state, {
playback: { ...this.state.playback, [key]: entry },
});
return this.state;
}
case 'markDone': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
const prev = this.state.playback[key];
if (!prev || prev.phase === 'done') return this.state;
this.state = bump(this.state, {
playback: {
...this.state.playback,
[key]: {
...prev,
phase: 'done',
baseDist: prev.pathLength,
rejoinDist: null,
segmentStartedAtMs: Date.now(),
},
},
});
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
export type { TokenPathTargetRef };
+3 -8
View File
@@ -92,11 +92,7 @@ export class TokensStore {
return path.join(this.rootDir, relPath); return path.join(this.rootDir, relPath);
} }
async upsert(input: { async upsert(input: { id?: TokenId | null; name: string; filePath?: string | null }): Promise<AppToken> {
id?: TokenId | null;
name: string;
filePath?: string | null;
}): Promise<AppToken> {
await this.ensureLoaded(); await this.ensureLoaded();
const name = input.name.trim(); const name = input.name.trim();
if (!name) throw new Error('Token name is required'); if (!name) throw new Error('Token name is required');
@@ -166,8 +162,7 @@ export class TokensStore {
}): Promise<{ token: AppToken; remappedFrom: TokenId }> { }): Promise<{ token: AppToken; remappedFrom: TokenId }> {
await this.ensureLoaded(); await this.ensureLoaded();
let buf = await fs.readFile(input.absFilePath); let buf = await fs.readFile(input.absFilePath);
const sha256 = const sha256 = input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
const existingByHash = this.findBySha256(sha256); const existingByHash = this.findBySha256(sha256);
if (existingByHash) { if (existingByHash) {
return { token: existingByHash, remappedFrom: input.preferredId }; return { token: existingByHash, remappedFrom: input.preferredId };
@@ -262,7 +257,7 @@ export class TokensStore {
preferredId: asTokenId(t.id), preferredId: asTokenId(t.id),
name: typeof t.name === 'string' ? t.name : 'Token', name: typeof t.name === 'string' ? t.name : 'Token',
absFilePath: abs, absFilePath: abs,
sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined, ...(typeof t.sha256 === 'string' ? { sha256: t.sha256 } : {}),
}); });
remap.set(remappedFrom, token.id); remap.set(remappedFrom, token.id);
} }
@@ -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'));
@@ -61,12 +108,15 @@ void test('createWindows: окно НПС закрывается с multi-window
assert.ok(src.includes("createWindow('npcs'")); assert.ok(src.includes("createWindow('npcs'"));
assert.ok(src.includes("createWindow('npcsEditor'")); assert.ok(src.includes("createWindow('npcsEditor'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/); assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
assert.ok(src.includes('webviewTag: true'));
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://)', () => {
@@ -87,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\)/);
});
+461 -36
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,9 +20,15 @@ export type WindowKind =
| 'control' | 'control'
| 'sceneDescription' | 'sceneDescription'
| 'materials' | 'materials'
| 'playlists'
| 'timer'
| 'initiative'
| 'dice'
| 'npcsEditor' | 'npcsEditor'
| 'sceneEditor' | 'sceneEditor'
| 'npcs'; | 'tokenPathEditor'
| 'npcs'
| 'instructions';
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */ /** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
@@ -28,6 +38,7 @@ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
'npcs', 'npcs',
'npcsEditor', 'npcsEditor',
'sceneEditor', 'sceneEditor',
'tokenPathEditor',
] as const; ] as const;
const windows = new Map<WindowKind, BrowserWindow>(); const windows = new Map<WindowKind, BrowserWindow>();
@@ -43,18 +54,54 @@ 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;
/** Пульт НПС: деталь слева + список справа. */ /** Пульт НПС: деталь слева (webview) + список справа. */
const NPCS_WINDOW_WIDTH = 720; const NPCS_WINDOW_WIDTH = 960;
const NPCS_WINDOW_HEIGHT = 720; const NPCS_WINDOW_HEIGHT = 800;
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */ /** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
function broadcastMultiWindowStateChanged(open: boolean): void { function broadcastMultiWindowStateChanged(open: boolean): void {
@@ -107,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;
@@ -157,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();
@@ -205,12 +251,24 @@ 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':
return 'sceneEditor.html'; return 'sceneEditor.html';
case 'tokenPathEditor':
return 'tokenPathEditor.html';
case 'npcs': case 'npcs':
return 'npcs.html'; return 'npcs.html';
case 'instructions':
return 'instructions.html';
} }
} }
@@ -278,9 +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 === '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 };
} }
@@ -307,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,
@@ -323,12 +395,28 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
minHeight: 600, minHeight: 600,
} }
: {}), : {}),
...(kind === 'tokenPathEditor'
? {
width: 1100,
height: 760,
minWidth: 900,
minHeight: 560,
}
: {}),
...(kind === 'npcs' ...(kind === 'npcs'
? { ? {
width: NPCS_WINDOW_WIDTH, width: NPCS_WINDOW_WIDTH,
height: NPCS_WINDOW_HEIGHT, height: NPCS_WINDOW_HEIGHT,
minWidth: 560, minWidth: 720,
maxWidth: 900, maxWidth: 1280,
minHeight: 560,
}
: {}),
...(kind === 'instructions'
? {
width: INSTRUCTIONS_WINDOW_WIDTH,
height: INSTRUCTIONS_WINDOW_HEIGHT,
minWidth: 640,
minHeight: 480, minHeight: 480,
} }
: {}), : {}),
@@ -345,6 +433,17 @@ 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'
? {
webviewTag: true,
// webviewTag в Electron не сочетается с sandbox хоста.
sandbox: false,
}
: {}),
}, },
}); });
if (icon) { if (icon) {
@@ -356,12 +455,32 @@ 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') {
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
delete guestPrefs.preload;
guestPrefs.nodeIntegration = false;
guestPrefs.contextIsolation = true;
guestPrefs.sandbox = true;
guestPrefs.webSecurity = true;
const src = String(params.src ?? '');
if (!src.startsWith('https:') && !src.startsWith('http:')) {
event.preventDefault();
}
});
}
if ( if (
kind === 'sceneDescription' || kind === 'sceneDescription' ||
kind === 'materials' || kind === 'materials' ||
kind === 'playlists' ||
kind === 'timer' ||
kind === 'npcsEditor' || kind === 'npcsEditor' ||
kind === 'sceneEditor' || kind === 'sceneEditor' ||
kind === 'npcs' kind === 'tokenPathEditor' ||
kind === 'npcs' ||
kind === 'instructions'
) { ) {
win.setMenuBarVisibility(false); win.setMenuBarVisibility(false);
} }
@@ -369,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;
@@ -391,23 +521,36 @@ 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') {
win.on('closed', () => {
closeTokenPathEditorWindow();
});
}
win.on('closed', () => { win.on('closed', () => {
if (kind !== 'presentation' && kind !== 'control') return; if (kind !== 'presentation' && kind !== 'control') return;
const open = windows.has('presentation') || windows.has('control'); const open = windows.has('presentation') || windows.has('control');
@@ -478,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();
@@ -509,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');
} }
@@ -527,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()) {
@@ -535,12 +735,73 @@ export function closeNpcsEditorWindow(): void {
} }
export function closeSceneEditorWindow(): void { export function closeSceneEditorWindow(): void {
closeTokenPathEditorWindow();
const win = windows.get('sceneEditor'); const win = windows.get('sceneEditor');
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
win.close(); win.close();
} }
} }
export function closeTokenPathEditorWindow(): void {
const win = windows.get('tokenPathEditor');
if (win && !win.isDestroyed()) {
win.close();
}
}
let pendingTokenPathEditorTarget: { kind: 'token' | 'npcToken'; placementId: string } | null = null;
export function getTokenPathEditorTarget(): { kind: 'token' | 'npcToken'; placementId: string } | null {
return pendingTokenPathEditorTarget;
}
function broadcastTokenPathEditorTarget(): void {
const win = windows.get('tokenPathEditor');
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return;
try {
win.webContents.send(ipcChannels.windows.tokenPathEditorTargetChanged, pendingTokenPathEditorTarget);
} catch {
/* ignore */
}
}
/** Одно окно пути: смена токена = фокус + targetChanged, без второго окна. */
export function openTokenPathEditorWindow(kind: 'token' | 'npcToken', placementId: string): void {
pendingTokenPathEditorTarget = { kind, placementId };
const existing = windows.get('tokenPathEditor');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
broadcastTokenPathEditorTarget();
return;
}
const parent = windows.get('sceneEditor') ?? windows.get('editor');
const win = createWindow('tokenPathEditor', 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();
broadcastTokenPathEditorTarget();
}
});
win.on('closed', () => {
pendingTokenPathEditorTarget = null;
});
}
export function closeNpcsWindow(): void { export function closeNpcsWindow(): void {
const win = windows.get('npcs'); const win = windows.get('npcs');
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
@@ -552,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;
@@ -625,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();
@@ -719,7 +1144,7 @@ export function openNpcsWindow(): void {
existing.setBounds({ existing.setBounds({
x: b.x, x: b.x,
y: b.y, y: b.y,
width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)), width: Math.min(1280, Math.max(720, b.width)),
height: Math.max(NPCS_WINDOW_HEIGHT, b.height), height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
}); });
existing.show(); existing.show();
+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();
}
+229 -3
View File
@@ -217,11 +217,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,18 +339,86 @@
.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;
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 {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 34px;
}
.snapToGrid {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
min-height: 34px;
cursor: pointer;
user-select: none;
}
.snapToGrid input {
margin: 0;
flex-shrink: 0;
}
.snapToGridLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
line-height: 1.25;
}
.npcTokenScaleLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
line-height: 1.25;
white-space: nowrap;
}
.npcTokenScaleRange {
flex: 1 1 auto;
min-width: 72px;
width: min(140px, 100%);
height: 34px;
margin: 0;
accent-color: var(--color-accent, #c9a227);
} }
.videoHint { .videoHint {
@@ -327,6 +442,22 @@
inset: 0; inset: 0;
} }
.previewHostSpawnDrag {
cursor: copy;
}
.npcSpawnGhost {
position: absolute;
z-index: 12;
width: 28px;
height: 28px;
border-radius: 999px;
transform: translate(-50%, -50%);
pointer-events: none;
border: 2px dashed rgba(96, 165, 250, 0.95);
background: rgba(96, 165, 250, 0.2);
}
.brushCursor { .brushCursor {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
@@ -342,6 +473,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;
@@ -579,3 +765,43 @@
.audioScrubDefault { .audioScrubDefault {
cursor: default; cursor: default;
} }
.ctxMenuBackdrop {
position: fixed;
inset: 0;
z-index: 40;
border: none;
padding: 0;
margin: 0;
background: transparent;
cursor: default;
}
.ctxMenu {
position: fixed;
z-index: 41;
min-width: 200px;
padding: 6px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background: var(--color-surface-menu, #1a1e28);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.ctxItem {
text-align: left;
padding: 8px 10px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text1, #e8eaef);
font-size: 13px;
cursor: pointer;
width: 100%;
}
.ctxItem:hover {
background: rgba(255, 255, 255, 0.06);
}
File diff suppressed because it is too large Load Diff
@@ -25,6 +25,8 @@
display: grid; display: grid;
gap: 8px; gap: 8px;
pointer-events: auto; pointer-events: auto;
/* Above brush / traps layers so transport stays usable on video scenes. */
z-index: 50;
} }
.scrub { .scrub {
+61 -44
View File
@@ -1,9 +1,10 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useRef } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore'; import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts'; import type { SessionState } from '../../shared/ipc/contracts';
import type { SceneViewCamera } from '../../shared/types'; import type { SceneViewCamera } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { ContainedVideo } from '../shared/ContainedVideo';
import { RotatedImage } from '../shared/RotatedImage'; import { RotatedImage } from '../shared/RotatedImage';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState'; import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
@@ -25,6 +26,13 @@ function fmt(sec: number): string {
return `${String(m)}:${String(r).padStart(2, '0')}`; return `${String(m)}:${String(r).padStart(2, '0')}`;
} }
function readVideoTimes(v: HTMLVideoElement | null): { cur: number; dur: number } {
if (!v) return { cur: 0, dur: 0 };
const dur = Number.isFinite(v.duration) ? v.duration : 0;
const cur = Number.isFinite(v.currentTime) ? v.currentTime : 0;
return { cur, dur };
}
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) { export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const [vp, video] = useVideoPlaybackState(); const [vp, video] = useVideoPlaybackState();
@@ -36,28 +44,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null; const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
const autostart = scene?.previewVideoAutostart ?? false; const autostart = scene?.previewVideoAutostart ?? false;
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null); const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
const scrubRef = useRef<HTMLDivElement | null>(null);
const scrubFillRef = useRef<HTMLDivElement | null>(null);
const curTimeRef = useRef<HTMLDivElement | null>(null);
const durTimeRef = useRef<HTMLDivElement | null>(null);
const [tick, setTick] = useState(0); const paintScrub = (): void => {
const dur = useMemo( const { cur, dur } = readVideoTimes(videoRef.current);
() => {
const v = videoRef.current;
if (!v) return 0;
return Number.isFinite(v.duration) ? v.duration : 0;
},
// tick: timeupdate / loadedmetadata перечитывают duration и currentTime
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
[tick, videoRef],
);
const cur = useMemo(
() => {
const v = videoRef.current;
if (!v) return 0;
return Number.isFinite(v.currentTime) ? v.currentTime : 0;
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
[tick, videoRef],
);
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0; const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
if (scrubFillRef.current) {
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}
if (curTimeRef.current) curTimeRef.current.textContent = fmt(cur);
if (durTimeRef.current) durTimeRef.current.textContent = dur ? fmt(dur) : '—:—';
if (scrubRef.current) {
scrubRef.current.setAttribute('aria-valuemin', '0');
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
scrubRef.current.classList.toggle(styles.scrubPointer, dur > 0);
scrubRef.current.classList.toggle(styles.scrubDefault, dur <= 0);
}
};
useEffect(() => { useEffect(() => {
if (!isVideo) return; if (!isVideo) return;
@@ -90,10 +97,21 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
} else { } else {
v.pause(); v.pause();
} }
paintScrub();
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates) // eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
}, [assetId, url, vp?.revision, vp?.targetAssetId, vp?.playing, vp?.playbackRate, videoRef]); }, [assetId, url, vp?.revision, vp?.targetAssetId, vp?.playing, vp?.playbackRate, videoRef]);
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' '); const seekByClientX = (clientX: number): void => {
const v = videoRef.current;
const { dur } = readVideoTimes(v);
if (!v || !dur || !scrubRef.current) return;
const rect = scrubRef.current.getBoundingClientRect();
const next = (clientX - rect.left) / Math.max(1, rect.width);
const timeSec = Math.max(0, Math.min(dur, next * dur));
void video.dispatch({ kind: 'seek', timeSec });
v.currentTime = timeSec;
paintScrub();
};
return ( return (
<div className={styles.root}> <div className={styles.root}>
@@ -106,20 +124,20 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
onContentRectChange={onContentRectChange} onContentRectChange={onContentRectChange}
/> />
) : url && isVideo ? ( ) : url && isVideo ? (
<video <ContainedVideo
ref={(el) => { url={url}
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el; rotationDeg={rot}
}} videoRef={videoRef}
className={styles.video}
src={url}
playsInline playsInline
loop={Boolean(scene?.settings?.loopVideo)} loop={Boolean(scene?.settings?.loopVideo)}
preload="auto" preload="auto"
onTimeUpdate={() => setTick((x) => x + 1)} viewCamera={viewCamera}
onLoadedMetadata={() => setTick((x) => x + 1)} onContentRectChange={onContentRectChange}
onTimeUpdate={paintScrub}
onLoadedMetadata={paintScrub}
> >
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} /> <track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
</video> </ContainedVideo>
) : ( ) : (
<div className={styles.placeholder} /> <div className={styles.placeholder} />
)} )}
@@ -127,30 +145,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
{isVideo ? ( {isVideo ? (
<div className={styles.controls}> <div className={styles.controls}>
<div <div
ref={scrubRef}
role="slider" role="slider"
tabIndex={0} tabIndex={0}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0} aria-valuemax={0}
aria-valuenow={Math.round(cur)} aria-valuenow={0}
className={scrubClass} className={[styles.scrub, styles.scrubDefault].join(' ')}
onClick={(e) => { onClick={(e) => {
const v = videoRef.current; seekByClientX(e.clientX);
if (!v || !dur) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const next = (e.clientX - rect.left) / rect.width;
void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) });
setTick((x) => x + 1);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
const { cur, dur } = readVideoTimes(videoRef.current);
if (!dur) return; if (!dur) return;
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) }); if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
if (e.key === 'ArrowRight') if (e.key === 'ArrowRight')
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) }); void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1); if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') paintScrub();
}} }}
title={t('control.scrubSeek')} title={t('control.scrubSeek')}
> >
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} /> <div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
</div> </div>
<div className={styles.row}> <div className={styles.row}>
<div className={styles.transport}> <div className={styles.transport}>
@@ -175,7 +190,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
className={styles.transportBtn} className={styles.transportBtn}
onClick={() => { onClick={() => {
void video.dispatch({ kind: 'stop' }); void video.dispatch({ kind: 'stop' });
setTick((x) => x + 1); paintScrub();
}} }}
title={t('control.transportStop')} title={t('control.transportStop')}
> >
@@ -183,7 +198,9 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
</button> </button>
</div> </div>
<div> <div>
{fmt(cur)} / {dur ? fmt(dur) : '—:—'} <span ref={curTimeRef}>0:00</span>
{' / '}
<span ref={durTimeRef}>—:—</span>
</div> </div>
</div> </div>
</div> </div>
@@ -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'));
}); });
@@ -58,7 +58,7 @@ void test('ControlApp: эффект «взрыв» + ловушка исполь
const appSrc = readControlApp(); const appSrc = readControlApp();
const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8'); const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8');
assert.ok(appSrc.includes("title={t('control.explosion')}")); assert.ok(appSrc.includes("title={t('control.explosion')}"));
assert.ok(appSrc.includes("tool: 'explosion'")); assert.ok(appSrc.includes("selectEffectTool('explosion')"));
assert.ok(appSrc.includes("type: 'explosion'")); assert.ok(appSrc.includes("type: 'explosion'"));
assert.ok(appSrc.includes('getExplosionEffectLifeMs')); assert.ok(appSrc.includes('getExplosionEffectLifeMs'));
assert.ok(appSrc.includes('playExplosionEffectSound')); assert.ok(appSrc.includes('playExplosionEffectSound'));
@@ -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'));
@@ -113,20 +122,51 @@ 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: чекбокс привязки токенов к сетке', () => {
const src = readControlApp();
const css = readControlAppCss();
assert.ok(src.includes('useTokenGridSnapSession'));
assert.ok(src.includes('snapNormToGridCell'));
assert.ok(src.includes('data-testid="token-grid-snap"'));
assert.ok(src.includes("t('control.snapTokensToGrid')"));
assert.ok(src.includes('snapAllTokensToGrid'));
assert.ok(src.includes('snapNorm={snapNormActive}'));
assert.ok(css.includes('.snapToGrid'));
assert.ok(css.includes('.previewTokenTools'));
assert.match(css, /\.previewTokenTools[\s\S]*?repeat\(auto-fit/);
assert.match(css, /\.previewHeader[\s\S]*?flex-wrap:\s*wrap/);
assert.match(css, /\.previewHeader[\s\S]*?align-items:\s*center/);
});
void test('ControlApp: история — свёрнута по умолчанию, колонка сверху вниз и фон как у карточек ветвления', () => {
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'));
@@ -161,6 +201,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')");
@@ -255,3 +316,17 @@ 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, 1);
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>,
);
+124 -112
View File
@@ -53,12 +53,80 @@
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;
gap: 10px; gap: 10px;
} }
.splitRun {
display: inline-flex;
align-items: stretch;
height: 34px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent-border);
background: var(--accent-fill-solid);
overflow: hidden;
}
.splitRun:disabled,
.splitRun[aria-disabled='true'] {
opacity: 0.45;
pointer-events: none;
}
.splitRunMain,
.splitRunChevron {
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.95);
cursor: pointer;
font: inherit;
font-weight: 600;
}
.splitRunMain {
padding: 0 14px;
}
.splitRunMain:hover:not(:disabled),
.splitRunChevron:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent-fill-solid) 72%, white);
}
.splitRunMain:active:not(:disabled),
.splitRunChevron:active:not(:disabled),
.splitRunChevronOpen {
background: color-mix(in srgb, var(--accent-fill-solid) 78%, black);
}
.splitRunDivider {
width: 1px;
align-self: stretch;
background: rgba(0, 0, 0, 0.28);
flex: 0 0 auto;
}
.splitRunChevron {
width: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.splitRunChevronSvg {
width: 12px;
height: 12px;
display: block;
}
.editorSidebar { .editorSidebar {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
@@ -488,113 +556,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;
@@ -626,6 +587,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;
@@ -1048,7 +1016,7 @@
.audioList { .audioList {
display: grid; display: grid;
gap: 6px; gap: 6px;
max-height: 160px; max-height: 280px;
overflow: auto; overflow: auto;
} }
@@ -1058,10 +1026,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 {
@@ -1073,13 +1063,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;
+287 -252
View File
@@ -2,6 +2,7 @@ import React, { startTransition, useCallback, useEffect, useMemo, useRef, useSta
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder'; import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import type { import type {
NpcImportResolution, NpcImportResolution,
SceneImportResolution, SceneImportResolution,
@@ -28,14 +29,17 @@ import type {
SceneId, SceneId,
} from '../../shared/types'; } from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo'; import { AppLogo } from '../shared/branding/AppLogo';
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,
@@ -55,6 +59,11 @@ import type { HelpSectionId } from './help/helpSections';
import { useEditorI18n } from './i18n/EditorI18nContext'; import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals'; import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
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';
@@ -121,10 +130,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);
@@ -134,9 +140,9 @@ export function EditorApp() {
const [importConflictsOpen, setImportConflictsOpen] = useState(false); const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]); const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false); const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
const [importNpcConflicts, setImportNpcConflicts] = useState< const [importNpcConflicts, setImportNpcConflicts] = useState<ReturnType<typeof computeNpcImportConflicts>>(
ReturnType<typeof computeNpcImportConflicts> [],
>([]); );
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]); const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]); const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false); const [importReportOpen, setImportReportOpen] = useState(false);
@@ -154,7 +160,32 @@ export function EditorApp() {
const licenseActive = licenseSnap?.active === true; const licenseActive = licenseSnap?.active === true;
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null); const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false); const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
const [gameSystemsOpen, setGameSystemsOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | 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> = {
@@ -247,11 +278,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;
@@ -350,14 +379,27 @@ export function EditorApp() {
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph'; const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene'); const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
const openInstructions = useCallback((section: HelpSectionId = 'overview') => {
void getDndApi()
.invoke(ipcChannels.windows.openInstructions, { section })
.catch((err) => {
console.error('[editor] openInstructions failed', err);
});
}, []);
const launchFromGraphNode = useCallback( const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId) => { (graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return; if (!licenseActive || launching) return;
stopAllEditorAudio();
setLaunching(true); setLaunching(true);
setRunMenuOpen(false);
setLaunchPlayersOpen(false);
void (async () => { void (async () => {
try { try {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId }); await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {}); await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {
...(playerIds && playerIds.length > 0 ? { playerIds } : {}),
});
} catch { } catch {
setLaunching(false); setLaunching(false);
} }
@@ -367,7 +409,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 ?? '';
@@ -442,24 +487,21 @@ export function EditorApp() {
}, [settingsMenuOpen]); }, [settingsMenuOpen]);
useEffect(() => { useEffect(() => {
if (!aboutMenuOpen) return; if (!runMenuOpen) return;
const r = aboutMenuBtnRef.current?.getBoundingClientRect() ?? null; const r = runSplitRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => { queueMicrotask(() => {
if (r) { if (r) setRunMenuPos({ left: r.right, top: r.bottom + 8 });
setAboutMenuPos({ left: r.left, top: r.bottom + 10 }); else setRunMenuPos(null);
} else {
setAboutMenuPos(null);
}
}); });
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null; const t = e.target as HTMLElement | null;
if (!t) return; if (!t) return;
if (t.closest('[data-aboutmenu-root="1"]')) return; if (t.closest('[data-runmenu-root="1"]')) return;
setAboutMenuOpen(false); setRunMenuOpen(false);
}; };
window.addEventListener('mousedown', onDown); window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown);
}, [aboutMenuOpen]); }, [runMenuOpen]);
useEffect(() => { useEffect(() => {
let off: (() => void) | null = null; let off: (() => void) | null = null;
@@ -471,6 +513,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);
}); });
})(); })();
@@ -546,12 +589,7 @@ export function EditorApp() {
sceneResolutions, sceneResolutions,
npcResolutions, npcResolutions,
) )
: await actions.mergeImportZip( : await actions.mergeImportZip(importPeek.filePath!, selections, sceneResolutions, npcResolutions);
importPeek.filePath!,
selections,
sceneResolutions,
npcResolutions,
);
setImportReport(report); setImportReport(report);
setImportReportOpen(true); setImportReportOpen(true);
clearImportFlow(); clearImportFlow();
@@ -571,12 +609,7 @@ export function EditorApp() {
setImportNpcConflictsOpen(true); setImportNpcConflictsOpen(true);
return; return;
} }
const npcResolutions = buildNpcResolutionsForImport( const npcResolutions = buildNpcResolutionsForImport(state.project, importPeek.sourceProject, [], []);
state.project,
importPeek.sourceProject,
[],
[],
);
void runStorylineMerge(selections, sceneResolutions, npcResolutions); void runStorylineMerge(selections, sceneResolutions, npcResolutions);
}, },
[importPeek, runStorylineMerge, state.project], [importPeek, runStorylineMerge, state.project],
@@ -724,9 +757,7 @@ export function EditorApp() {
{t('scenes.batchProgress') {t('scenes.batchProgress')
.replace('{current}', String(state.sceneBatchImport.current)) .replace('{current}', String(state.sceneBatchImport.current))
.replace('{total}', String(state.sceneBatchImport.total))} .replace('{total}', String(state.sceneBatchImport.total))}
{state.sceneBatchImport.fileName {state.sceneBatchImport.fileName ? `: ${state.sceneBatchImport.fileName}` : ''}
? `: ${state.sceneBatchImport.fileName}`
: ''}
</div> </div>
<div> <div>
{Math.round( {Math.round(
@@ -764,7 +795,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);
@@ -775,16 +805,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')}
@@ -800,7 +828,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);
}} }}
> >
@@ -818,13 +845,46 @@ 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);
}} }}
> >
{t('top.file')} {t('top.file')}
</button> </button>
) : null} ) : null}
{USERS_BRANCH_FEATURES_ENABLED ? (
<button
type="button"
data-testid="players-header-btn"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setFileMenuOpen(false);
setPlayersManagerOpen(true);
}}
>
{t('top.players')}
</button>
) : 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 ? (
@@ -832,12 +892,77 @@ 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 ? (
<> <>
{USERS_BRANCH_FEATURES_ENABLED ? (
<div
ref={runSplitRef}
className={styles.splitRun}
data-runmenu-root="1"
aria-disabled={runDisabled || launching ? true : undefined}
>
<button
type="button"
className={styles.splitRunMain}
disabled={runDisabled || launching}
data-testid="run-main-btn"
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId || launching) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
>
{t('top.run')}
</button>
<span className={styles.splitRunDivider} aria-hidden />
<button
type="button"
className={[
styles.splitRunChevron,
runMenuOpen ? styles.splitRunChevronOpen : '',
]
.filter(Boolean)
.join(' ')}
disabled={runDisabled || launching}
aria-label={t('top.runMenuAria')}
aria-haspopup="menu"
aria-expanded={runMenuOpen}
data-testid="run-menu-btn"
onClick={() => {
if (runDisabled || launching) return;
setRunMenuOpen((v) => !v);
}}
>
<svg className={styles.splitRunChevronSvg} viewBox="0 0 12 12" aria-hidden>
<path
d="M2.5 4.25 L6 7.75 L9.5 4.25"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
) : (
<Button <Button
variant="primary" variant="primary"
disabled={runDisabled || launching} disabled={runDisabled || launching}
data-testid="run-main-btn"
onClick={() => { onClick={() => {
if (!licenseActive || !graphStartGraphNodeId || launching) return; if (!licenseActive || !graphStartGraphNodeId || launching) return;
launchFromGraphNode(graphStartGraphNodeId); launchFromGraphNode(graphStartGraphNodeId);
@@ -845,6 +970,7 @@ export function EditorApp() {
> >
{t('top.run')} {t('top.run')}
</Button> </Button>
)}
{runDisabled ? ( {runDisabled ? (
<Button <Button
variant="ghost" variant="ghost"
@@ -853,8 +979,7 @@ export function EditorApp() {
title={runHelpTooltip} title={runHelpTooltip}
tooltipPlacement="bottom-left" tooltipPlacement="bottom-left"
onClick={() => { onClick={() => {
setInstructionsSection(runHelpSection); openInstructions(runHelpSection);
setInstructionsOpen(true);
}} }}
> >
? ?
@@ -900,9 +1025,7 @@ export function EditorApp() {
scene={s} scene={s}
reorderEnabled={sceneListReorderEnabled} reorderEnabled={sceneListReorderEnabled}
listDragActive={draggingListSceneId !== null} listDragActive={draggingListSceneId !== null}
dropPlace={ dropPlace={sceneListDrop?.targetId === s.id ? sceneListDrop.place : null}
sceneListDrop?.targetId === s.id ? sceneListDrop.place : null
}
isDragging={draggingListSceneId === s.id} isDragging={draggingListSceneId === s.id}
onSelect={() => { onSelect={() => {
setSelectedGraphNodeId(null); setSelectedGraphNodeId(null);
@@ -925,9 +1048,7 @@ export function EditorApp() {
return; return;
} }
setSceneListDrop((cur) => setSceneListDrop((cur) =>
cur?.targetId === targetId && cur.place === place cur?.targetId === targetId && cur.place === place ? cur : { targetId, place },
? cur
: { targetId, place },
); );
}} }}
onDropListReorder={(draggedId, targetId, place) => { onDropListReorder={(draggedId, targetId, place) => {
@@ -1234,6 +1355,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"
@@ -1315,46 +1460,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
@@ -1461,8 +1566,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
@@ -1552,6 +1657,67 @@ 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 ? (
<>
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
<LaunchPlayersModal
open={launchPlayersOpen}
onClose={() => setLaunchPlayersOpen(false)}
onConfirm={(playerIds) => {
if (!graphStartGraphNodeId) return;
launchFromGraphNode(graphStartGraphNodeId, playerIds);
}}
/>
</>
) : null}
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
? createPortal(
<div
data-runmenu-root="1"
className={styles.fileMenu}
style={{ left: runMenuPos.left, top: runMenuPos.top, transform: 'translateX(-100%)' }}
role="menu"
>
<button
type="button"
className={styles.fileMenuItem}
role="menuitem"
data-testid="run-with-players-item"
onClick={() => {
setRunMenuOpen(false);
setLaunchPlayersOpen(true);
}}
>
{t('top.runWithPlayers')}
</button>
</div>,
document.body,
)
: null}
<MaterialsManagerModal <MaterialsManagerModal
open={materialsManagerOpen} open={materialsManagerOpen}
materials={state.project?.materials ?? []} materials={state.project?.materials ?? []}
@@ -2364,7 +2530,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,
@@ -2379,74 +2544,20 @@ function CampaignInspector({
onDragOver={audioDrop.onDragOver} onDragOver={audioDrop.onDragOver}
onDrop={audioDrop.onDrop} onDrop={audioDrop.onDrop}
> >
{audioDrop.dragOver ? ( {audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> {audioRefs.length === 0 ? (
) : null}
{mediaAssets.filter((a) => a.type === 'audio').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>
</> </>
)} )}
@@ -2486,7 +2597,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)),
@@ -2548,10 +2658,7 @@ function SceneInspector({
{sideStoryStartNodes.map((gn) => ( {sideStoryStartNodes.map((gn) => (
<div key={gn.id}> <div key={gn.id}>
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div> <div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
<Input <Input value={gn.sideStoryLineTitle} onChange={(v) => onSideStoryLineTitleChange(gn.id, v)} />
value={gn.sideStoryLineTitle}
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
/>
<div className={styles.spacer8} /> <div className={styles.spacer8} />
</div> </div>
))} ))}
@@ -2567,9 +2674,7 @@ function SceneInspector({
onDragOver={previewDrop.onDragOver} onDragOver={previewDrop.onDragOver}
onDrop={previewDrop.onDrop} onDrop={previewDrop.onDrop}
> >
{previewDrop.dragOver ? ( {previewDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div> : null}
<div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div>
) : null}
{previewUrl && previewAssetType === 'image' ? ( {previewUrl && previewAssetType === 'image' ? (
<div className={styles.previewFill}> <div className={styles.previewFill}>
<RotatedImage <RotatedImage
@@ -2581,14 +2686,16 @@ function SceneInspector({
</div> </div>
) : previewUrl && previewAssetType === 'video' ? ( ) : previewUrl && previewAssetType === 'video' ? (
<div className={styles.previewFill}> <div className={styles.previewFill}>
<video <ContainedVideo
src={previewUrl} url={previewUrl}
rotationDeg={previewRotationDeg}
mode="cover"
muted muted
playsInline playsInline
autoPlay={previewVideoAutostart} autoPlay={previewVideoAutostart}
loop={previewVideoLoop} loop={previewVideoLoop}
preload="metadata" preload="metadata"
className={styles.videoCover} style={{ width: '100%', height: '100%' }}
/> />
</div> </div>
) : ( ) : (
@@ -2629,7 +2736,7 @@ function SceneInspector({
</label> </label>
</div> </div>
) : null} ) : null}
{previewAssetId && previewAssetType === 'image' ? ( {previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
<> <>
<div className={styles.spacer6} /> <div className={styles.spacer6} />
<Button <Button
@@ -2640,6 +2747,10 @@ function SceneInspector({
> >
{t('scene.rotate')} {t('scene.rotate')}
</Button> </Button>
</>
) : null}
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
<>
<div className={styles.spacer6} /> <div className={styles.spacer6} />
<label className={styles.checkboxLabel}> <label className={styles.checkboxLabel}>
<input <input
@@ -2655,7 +2766,7 @@ function SceneInspector({
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {}); void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
}} }}
> >
Редактор сцены {t('sceneEditor.open')}
</Button> </Button>
</> </>
) : null} ) : null}
@@ -2668,74 +2779,20 @@ function SceneInspector({
onDragOver={sceneAudioDrop.onDragOver} onDragOver={sceneAudioDrop.onDragOver}
onDrop={sceneAudioDrop.onDrop} onDrop={sceneAudioDrop.onDrop}
> >
{sceneAudioDrop.dragOver ? ( {sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> {audioRefs.length === 0 ? (
) : null}
{mediaAssets.filter((a) => a.type === 'audio').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>
</> </>
)} )}
@@ -2870,26 +2927,6 @@ function SceneListCard({
decoding="async" decoding="async"
/> />
</div> </div>
) : previewUrl && scene.previewAssetType === 'video' ? (
<div className={styles.sceneThumbInner}>
<video
src={previewUrl}
muted
playsInline
preload="metadata"
draggable={false}
className={styles.sceneThumbVideo}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
</div>
) : ( ) : (
<div className={styles.sceneThumbEmptyInner} aria-hidden /> <div className={styles.sceneThumbEmptyInner} aria-hidden />
)} )}
@@ -2983,9 +3020,7 @@ function SceneListCard({
</button> </button>
</div> </div>
<div className={styles.fieldGrid}> <div className={styles.fieldGrid}>
<div className={styles.muted}> <div className={styles.muted}>{t('confirmDeleteScene.body', { name: scene.title })}</div>
{t('confirmDeleteScene.body', { name: scene.title })}
</div>
</div> </div>
<div className={styles.modalFooter}> <div className={styles.modalFooter}>
<Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</Button> <Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</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>
);
}
@@ -0,0 +1,63 @@
.dialog {
width: min(440px, 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: 220px;
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: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
color: var(--text);
font-size: var(--text-sm);
}
.row:hover {
background: var(--accent-fill-soft);
}
.teamDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
}
.empty {
padding: 10px 8px;
color: var(--text2);
font-size: var(--text-sm);
}
+165
View File
@@ -0,0 +1,165 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
import { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import launchStyles from './LaunchPlayersModal.module.css';
export { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
export function LaunchPlayersModal({
open,
onClose,
onConfirm,
}: {
open: boolean;
onClose: () => void;
onConfirm: (playerIds: string[]) => void;
}) {
const { t } = useEditorI18n();
const { players, teams } = useAppPlayers();
const [selectedPlayers, setSelectedPlayers] = useState<Set<string>>(() => new Set());
const [selectedTeams, setSelectedTeams] = useState<Set<string>>(() => new Set());
useEffect(() => {
if (!open) return;
setSelectedPlayers(new Set());
setSelectedTeams(new Set());
}, [open]);
const resolvedIds = useMemo(
() => resolveLaunchPlayerIds(players, selectedPlayers, selectedTeams),
[players, selectedPlayers, selectedTeams],
);
const standalonePlayers = useMemo(
() =>
players.filter((p) => !(p.teamId && selectedTeams.has(String(p.teamId)))),
[players, selectedTeams],
);
if (!open) return null;
const togglePlayer = (id: PlayerId) => {
setSelectedPlayers((prev) => {
const next = new Set(prev);
const key = String(id);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleTeam = (id: PlayerTeamId) => {
const key = String(id);
setSelectedTeams((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
// Участники выбранной команды не показываются в «Игроки» — снимаем их индивидуальный выбор.
setSelectedPlayers((prev) => {
if (prev.size === 0) return prev;
const next = new Set(prev);
let changed = false;
for (const p of players) {
if (String(p.teamId) !== key) continue;
if (next.delete(String(p.id))) changed = true;
}
return changed ? next : prev;
});
};
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div
className={[styles.modalDialog, launchStyles.dialog].join(' ')}
role="dialog"
aria-modal="true"
data-testid="launch-players-modal"
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('top.runWithPlayersTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div>
<p className={launchStyles.hint}>{t('top.runWithPlayersHint')}</p>
{teams.length > 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersTeams')}</div>
<div className={launchStyles.list}>
{teams.map((team: AppPlayerTeam) => {
const count = players.filter((p) => p.teamId === team.id).length;
return (
<label key={team.id} className={launchStyles.row}>
<input
type="checkbox"
checked={selectedTeams.has(String(team.id))}
onChange={() => toggleTeam(team.id)}
data-testid={`launch-team-${team.id}`}
/>
<span className={launchStyles.teamDot} style={{ background: team.color }} />
<span>
{team.name} ({count})
</span>
</label>
);
})}
</div>
</section>
) : null}
{standalonePlayers.length > 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersPlayers')}</div>
<div className={launchStyles.list}>
{standalonePlayers.map((player: AppPlayer) => (
<label key={player.id} className={launchStyles.row}>
<input
type="checkbox"
checked={selectedPlayers.has(String(player.id))}
onChange={() => togglePlayer(player.id)}
data-testid={`launch-player-${player.id}`}
/>
<span>{player.name}</span>
</label>
))}
</div>
</section>
) : players.length === 0 ? (
<section className={launchStyles.section}>
<div className={launchStyles.empty}>{t('top.runWithPlayersEmpty')}</div>
</section>
) : null}
</div>
<div className={styles.modalFooter}>
<Button variant="ghost" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={resolvedIds.length === 0}
data-testid="launch-players-confirm"
onClick={() => onConfirm(resolvedIds)}
>
{t('top.runWithPlayersConfirm')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+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,243 @@
.dialog {
width: min(1080px, calc(100vw - 48px));
max-width: 1080px;
}
.body {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
gap: 16px;
min-height: 560px;
max-height: min(78vh, 760px);
}
.sidebar,
.editor {
min-height: 0;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebarActions {
display: flex;
gap: 8px;
}
.sidebarActions > * {
flex: 1 1 0;
min-width: 0;
}
.sidebarActions button {
width: 100%;
}
.teamForm {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid var(--stroke);
border-radius: 10px;
background: var(--color-overlay-dark-3);
}
.teamFormField {
display: grid;
gap: 4px;
color: var(--text2);
font-size: 11px;
font-weight: 700;
}
.teamFormField input[type='color'] {
width: 100%;
height: 34px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: 8px;
background: transparent;
}
.teamFormActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.teamFormActions > * {
flex: 1 1 0;
min-width: 0;
}
.teamFormActions button {
width: 100%;
}
.list {
display: grid;
gap: 10px;
overflow: auto;
padding-right: 3px;
}
.team {
border: 1px solid var(--stroke);
border-radius: 10px;
background: var(--color-overlay-dark-2);
overflow: visible;
}
.teamHeader {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
min-height: 34px;
padding: 4px 8px;
border-bottom: 1px solid var(--stroke);
}
.teamDot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.teamName {
overflow: hidden;
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.teamMenu {
position: relative;
}
.teamMenu summary {
cursor: pointer;
list-style: none;
padding: 4px 7px;
}
.teamMenuPopup {
position: absolute;
z-index: 10;
top: 100%;
right: 0;
min-width: 130px;
padding: 5px;
border: 1px solid var(--stroke);
border-radius: 8px;
background: var(--bg1, #1a1d24);
box-shadow: 0 10px 24px rgb(0 0 0 / 45%);
}
.teamMenuPopup button {
width: 100%;
padding: 7px 9px;
border: 0;
border-radius: 5px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.teamMenuPopup button:hover {
background: rgb(255 255 255 / 8%);
}
.teamBody {
display: grid;
gap: 5px;
min-height: 28px;
padding: 6px;
}
.playerRow {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: center;
gap: 9px;
width: 100%;
padding: 5px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: inherit;
text-align: left;
cursor: grab;
}
.playerRow:hover,
.playerRowSelected {
border-color: var(--color-accent, #c9a227);
background: rgb(255 255 255 / 5%);
}
.playerRow > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropTarget {
padding: 8px;
color: var(--text2);
font-size: 11px;
text-align: center;
}
.editor {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
overflow: auto;
padding: 22px;
border: 1px solid var(--stroke);
border-radius: 12px;
background: var(--color-overlay-dark-3);
}
.editorDropOver {
border-color: var(--color-accent, #c9a227);
}
.field {
display: grid;
gap: 6px;
width: min(420px, 100%);
color: var(--text2);
font-size: 12px;
font-weight: 700;
}
.field input[type='color'] {
width: 100%;
height: 38px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: 8px;
background: transparent;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
@media (max-width: 760px) {
.body {
grid-template-columns: 1fr;
}
}
+664
View File
@@ -0,0 +1,664 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal, flushSync } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
import {
DEFAULT_PLAYER_IMAGE_OFFSET,
DEFAULT_PLAYER_IMAGE_SCALE,
DEFAULT_PLAYER_RING_COLOR,
} from '../../shared/types/appPlayers';
import { getDndApi } from '../shared/dndApi';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
import { Button, Input } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import {
filterMaterialImagePaths,
getDroppedFileEntries,
pickFirstMaterialImagePath,
useFileDropZone,
} from './fileDrop';
import { useEditorI18n } from './i18n/EditorI18nContext';
import playerStyles from './PlayersModals.module.css';
const PLAYER_DND_MIME = 'application/x-dnd-player-id';
function PlayerRow({
player,
selected,
onSelect,
}: {
player: AppPlayer;
selected: boolean;
onSelect: () => void;
}) {
const url = usePlayerImageUrl(player.id);
return (
<button
type="button"
data-testid={`player-row-${player.id}`}
className={[playerStyles.playerRow, selected ? playerStyles.playerRowSelected : '']
.filter(Boolean)
.join(' ')}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(PLAYER_DND_MIME, player.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={onSelect}
>
<PlayerTokenView
name={player.name}
imageUrl={url}
ringColor={player.ringColor}
imageOffset={player.imageOffset}
imageScale={player.imageScale}
sizePx={48}
hideName
/>
<span>{player.name}</span>
</button>
);
}
function TeamSection({
team,
players,
selectedId,
onSelect,
onAssign,
onEdit,
onDelete,
}: {
team: AppPlayerTeam | null;
players: AppPlayer[];
selectedId: PlayerId | null;
onSelect: (id: PlayerId) => void;
onAssign: (id: PlayerId, teamId: PlayerTeamId | null) => void;
onEdit?: () => void;
onDelete?: () => void;
}) {
const { t } = useEditorI18n();
return (
<section
className={playerStyles.team}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(PLAYER_DND_MIME)) e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
const id = e.dataTransfer.getData(PLAYER_DND_MIME);
if (id) onAssign(id as PlayerId, team?.id ?? null);
}}
>
<div className={playerStyles.teamHeader}>
{team ? <span className={playerStyles.teamDot} style={{ background: team.color }} /> : null}
<span className={playerStyles.teamName}>{team?.name ?? t('players.ungrouped')}</span>
{team ? (
<details className={playerStyles.teamMenu}>
<summary aria-label={t('players.teamMenu')}>⋮</summary>
<div className={playerStyles.teamMenuPopup}>
<button type="button" onClick={onEdit}>
{t('common.edit')}
</button>
<button type="button" onClick={onDelete}>
{t('common.delete')}
</button>
</div>
</details>
) : null}
</div>
<div className={playerStyles.teamBody}>
{players.map((player) => (
<PlayerRow
key={player.id}
player={player}
selected={player.id === selectedId}
onSelect={() => onSelect(player.id)}
/>
))}
{players.length === 0 ? <div className={playerStyles.dropTarget}>{t('players.dropHere')}</div> : null}
</div>
</section>
);
}
export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useEditorI18n();
const api = getDndApi();
const { players, teams } = useAppPlayers();
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<PlayerId | null>(null);
const [name, setName] = useState('');
const [ringColor, setRingColor] = useState(DEFAULT_PLAYER_RING_COLOR);
const [imageOffset, setImageOffset] = useState(DEFAULT_PLAYER_IMAGE_OFFSET);
const [imageScale, setImageScale] = useState(DEFAULT_PLAYER_IMAGE_SCALE);
const [filePath, setFilePath] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const [progress, setProgress] = useState({ percent: 0, detail: '' });
/** Electron не поддерживает window.prompt — форма команды в сайдбаре. */
const [teamForm, setTeamForm] = useState<{
id: PlayerTeamId | null;
name: string;
color: string;
} | null>(null);
const [pendingDeletePlayer, setPendingDeletePlayer] = useState<AppPlayer | null>(null);
const [pendingDeleteTeam, setPendingDeleteTeam] = useState<AppPlayerTeam | null>(null);
const appearanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selected = players.find((p) => p.id === selectedId) ?? null;
const selectedUrl = usePlayerImageUrl(selected?.id);
const resetModalState = useCallback(() => {
if (appearanceTimerRef.current) {
clearTimeout(appearanceTimerRef.current);
appearanceTimerRef.current = null;
}
setQuery('');
setSelectedId(null);
setName('');
setRingColor(DEFAULT_PLAYER_RING_COLOR);
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
setFilePath(null);
setPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return null;
});
setAdding(false);
setSaving(false);
setProgress({ percent: 0, detail: '' });
setTeamForm(null);
setPendingDeletePlayer(null);
setPendingDeleteTeam(null);
}, []);
const handleClose = useCallback(() => {
if (saving) return;
resetModalState();
onClose();
}, [onClose, resetModalState, saving]);
useEffect(() => {
if (!open) return;
setSelectedId((current) =>
current && players.some((p) => p.id === current) ? current : (players[0]?.id ?? null),
);
}, [open, players]);
// Синхронизировать форму только при смене выбранного игрока — не при каждом
// players.stateChanged (иначе сбрасывается несохранённый zoom/pan).
useEffect(() => {
if (adding) return;
if (!selectedId) return;
const p = players.find((item) => item.id === selectedId);
if (!p) return;
setName(p.name);
setRingColor(p.ringColor);
setImageOffset(p.imageOffset);
setImageScale(p.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
setFilePath(null);
setPreviewUrl(null);
// eslint-disable-next-line react-hooks/exhaustive-deps -- только selectedId / adding
}, [adding, selectedId]);
useEffect(() => {
if (!open) return;
return api.on(ipcChannels.players.upsertProgress, (event) => {
setProgress({
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
detail: event.detail?.trim() ?? t('players.savingWait'),
});
});
}, [api, open, t]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || saving) return;
if (pendingDeletePlayer) {
setPendingDeletePlayer(null);
return;
}
if (pendingDeleteTeam) {
setPendingDeleteTeam(null);
return;
}
if (teamForm) {
setTeamForm(null);
return;
}
handleClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [handleClose, open, pendingDeletePlayer, pendingDeleteTeam, saving, teamForm]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? players.filter((p) => p.name.toLowerCase().includes(q)) : players;
}, [players, query]);
const beginAdd = () => {
setAdding(true);
setSelectedId(null);
setName('');
setRingColor(DEFAULT_PLAYER_RING_COLOR);
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
setFilePath(null);
setPreviewUrl(null);
};
const setPreviewFromPathAndUrl = (path: string, url: string) => {
setFilePath(path);
setPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return url || null;
});
};
const pickImage = async () => {
const result = await api.invoke(ipcChannels.players.pickImage, {});
if (result.canceled) return;
setPreviewFromPathAndUrl(result.filePath, result.previewDataUrl);
};
const applyDroppedImage = (path: string, previewFromFile: string) => {
if (!adding && !selected) beginAdd();
setPreviewFromPathAndUrl(path, previewFromFile);
};
const drop = useFileDropZone({
disabled: saving,
filterPaths: filterMaterialImagePaths,
onDropPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
if (!picked) return;
applyDroppedImage(picked, '');
},
});
const appearanceLatestRef = useRef({
selected,
adding,
saving,
ringColor,
imageOffset,
imageScale,
});
appearanceLatestRef.current = { selected, adding, saving, ringColor, imageOffset, imageScale };
const persistAppearance = (patch: {
ringColor?: string;
imageOffset?: typeof imageOffset;
imageScale?: number;
}) => {
const snap = appearanceLatestRef.current;
if (!snap.selected || snap.adding || snap.saving) return;
const payload = {
id: snap.selected.id,
teamId: snap.selected.teamId,
name: snap.selected.name,
ringColor: patch.ringColor ?? snap.ringColor,
imageOffset: patch.imageOffset ?? snap.imageOffset,
imageScale: patch.imageScale ?? snap.imageScale,
};
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
appearanceTimerRef.current = setTimeout(() => {
appearanceTimerRef.current = null;
void api.invoke(ipcChannels.players.upsert, payload);
}, 180);
};
useEffect(() => {
return () => {
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
};
}, []);
const save = async () => {
const trimmed = name.trim();
if (!trimmed || (selected === null && filePath === null)) return;
if (appearanceTimerRef.current) {
clearTimeout(appearanceTimerRef.current);
appearanceTimerRef.current = null;
}
flushSync(() => {
setSaving(true);
setProgress({ percent: 0, detail: t('players.savingWait') });
});
try {
const result = await api.invoke(ipcChannels.players.upsert, {
...(selected ? { id: selected.id, teamId: selected.teamId } : {}),
name: trimmed,
...(filePath ? { filePath } : {}),
ringColor,
imageOffset,
imageScale,
});
setAdding(false);
setSelectedId(result.player.id);
} finally {
setSaving(false);
}
};
if (!open) return null;
const activeUrl = previewUrl ?? selectedUrl;
return createPortal(
<>
<div className={styles.modalBackdrop} aria-hidden />
<div
role="dialog"
aria-modal="true"
data-testid="players-modal"
className={[styles.modalDialog, playerStyles.dialog].join(' ')}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.managerTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={handleClose}
className={styles.modalClose}
disabled={saving}
>
×
</button>
</div>
<div className={playerStyles.body}>
<aside className={playerStyles.sidebar}>
<Input value={query} onChange={setQuery} placeholder={t('players.search')} />
<div className={playerStyles.sidebarActions}>
<Button variant="primary" data-testid="players-add" onClick={beginAdd}>
{t('players.add')}
</Button>
<Button
data-testid="players-add-team"
onClick={() =>
setTeamForm({
id: null,
name: '',
color: DEFAULT_PLAYER_RING_COLOR,
})
}
>
{t('players.addTeam')}
</Button>
</div>
{teamForm ? (
<div className={playerStyles.teamForm} data-testid="players-team-form">
<label className={playerStyles.teamFormField}>
<span>{t('players.teamName')}</span>
<Input
value={teamForm.name}
onChange={(v) => setTeamForm((prev) => (prev ? { ...prev, name: v } : prev))}
placeholder={t('players.teamName')}
autoFocus
/>
</label>
<label className={playerStyles.teamFormField}>
<span>{t('players.teamColor')}</span>
<input
type="color"
value={teamForm.color}
onChange={(e) => {
const next = e.currentTarget.value;
setTeamForm((prev) => (prev ? { ...prev, color: next } : prev));
}}
/>
</label>
<div className={playerStyles.teamFormActions}>
<Button
variant="primary"
disabled={!teamForm.name.trim()}
onClick={() => {
const trimmed = teamForm.name.trim();
if (!trimmed) return;
void api
.invoke(ipcChannels.players.upsertTeam, {
...(teamForm.id ? { id: teamForm.id } : {}),
name: trimmed,
color: teamForm.color,
})
.then(() => setTeamForm(null));
}}
>
{t('common.save')}
</Button>
<Button onClick={() => setTeamForm(null)}>{t('common.cancel')}</Button>
</div>
</div>
) : null}
<div className={playerStyles.list}>
{teams.map((team) => (
<TeamSection
key={team.id}
team={team}
players={filtered.filter((p) => p.teamId === team.id)}
selectedId={selectedId}
onSelect={(id) => {
setAdding(false);
setSelectedId(id);
}}
onAssign={(playerId, teamId) =>
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
}
onEdit={() =>
setTeamForm({
id: team.id,
name: team.name,
color: team.color,
})
}
onDelete={() => setPendingDeleteTeam(team)}
/>
))}
<TeamSection
team={null}
players={filtered.filter((p) => p.teamId === null)}
selectedId={selectedId}
onSelect={(id) => {
setAdding(false);
setSelectedId(id);
}}
onAssign={(playerId, teamId) =>
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
}
/>
</div>
</aside>
<main
className={[playerStyles.editor, drop.dragOver ? playerStyles.editorDropOver : '']
.filter(Boolean)
.join(' ')}
onDragEnter={drop.onDragEnter}
onDragLeave={drop.onDragLeave}
onDragOver={drop.onDragOver}
onDrop={(e) => {
drop.onDrop(e);
const entries = getDroppedFileEntries(e);
const files = e.dataTransfer?.files;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i]!;
if (!pickFirstMaterialImagePath([entry.path])) continue;
const file = files?.[i];
applyDroppedImage(entry.path, file ? URL.createObjectURL(file) : '');
return;
}
}}
>
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('players.dropHint')}</div> : null}
{adding || selected ? (
<>
<PlayerTokenView
data-testid="player-token-preview"
name={name}
imageUrl={activeUrl}
ringColor={ringColor}
imageOffset={imageOffset}
imageScale={imageScale}
panEnabled
onImageOffsetChange={(next) => {
setImageOffset(next);
persistAppearance({ imageOffset: next });
}}
onImageScaleChange={(next) => {
setImageScale(next);
persistAppearance({ imageScale: next });
}}
sizePx={220}
/>
<label className={playerStyles.field}>
<span>{t('players.name')}</span>
<Input value={name} onChange={setName} placeholder={t('players.namePlaceholder')} />
</label>
<label className={playerStyles.field}>
<span>{t('players.ringColor')}</span>
<input
type="color"
value={ringColor}
onChange={(e) => {
const next = e.currentTarget.value;
setRingColor(next);
persistAppearance({ ringColor: next });
}}
/>
</label>
<div className={playerStyles.actions}>
<Button data-testid="players-choose-image" onClick={() => void pickImage()}>
{t('players.chooseImage')}
</Button>
<Button
variant="primary"
data-testid="players-save"
disabled={!name.trim() || (!selected && !filePath) || saving}
onClick={() => void save()}
>
{saving ? t('common.saving') : t('common.save')}
</Button>
{selected ? (
<Button onClick={() => setPendingDeletePlayer(selected)}>{t('common.delete')}</Button>
) : null}
</div>
</>
) : (
<div className={styles.muted}>{t('players.selectPrompt')}</div>
)}
</main>
</div>
</div>
{pendingDeletePlayer
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDeletePlayer(null)}
/>
<div
role="dialog"
aria-modal="true"
data-testid="players-delete-confirm"
className={styles.modalDialog}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.deleteTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDeletePlayer(null)}
>
×
</button>
</div>
<div className={styles.muted}>
{t('players.deleteConfirm', { name: pendingDeletePlayer.name })}
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDeletePlayer(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeletePlayer.id;
setPendingDeletePlayer(null);
void api.invoke(ipcChannels.players.delete, { id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{pendingDeleteTeam
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDeleteTeam(null)}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('players.deleteTeamTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDeleteTeam(null)}
>
×
</button>
</div>
<div className={styles.muted}>
{t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })}
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDeleteTeam(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteTeam.id;
setPendingDeleteTeam(null);
void api.invoke(ipcChannels.players.deleteTeam, { id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{saving ? (
<div className={styles.progressOverlay} role="dialog" aria-busy data-testid="players-save-progress">
<div className={styles.progressModal}>
<div className={styles.progressTitle}>{t('players.savingTitle')}</div>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{ width: `${String(progress.percent)}%` }} />
</div>
<div className={styles.progressMeta}>
<div>{progress.detail}</div>
<div>{progress.percent}%</div>
</div>
</div>
</div>
) : null}
</>,
document.body,
);
}
@@ -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,
);
}
@@ -170,21 +170,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
<div className={modalStyles.toolbarGroup}> <div className={modalStyles.toolbarGroup}>
<ToolButton <ToolButton
title={t('scene.descriptionBold')} title={t('scene.descriptionBold')}
active={toolbarState.bold} active={toolbarState?.bold ?? false}
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
> >
<strong>B</strong> <strong>B</strong>
</ToolButton> </ToolButton>
<ToolButton <ToolButton
title={t('scene.descriptionItalic')} title={t('scene.descriptionItalic')}
active={toolbarState.italic} active={toolbarState?.italic ?? false}
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
> >
<em>I</em> <em>I</em>
</ToolButton> </ToolButton>
<ToolButton <ToolButton
title={t('scene.descriptionUnderline')} title={t('scene.descriptionUnderline')}
active={toolbarState.underline} active={toolbarState?.underline ?? false}
onClick={() => editor.chain().focus().toggleUnderline().run()} onClick={() => editor.chain().focus().toggleUnderline().run()}
> >
<span style={{ textDecoration: 'underline' }}>U</span> <span style={{ textDecoration: 'underline' }}>U</span>
@@ -194,21 +194,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
<div className={modalStyles.toolbarGroup}> <div className={modalStyles.toolbarGroup}>
<ToolButton <ToolButton
title={t('scene.descriptionHeading2')} title={t('scene.descriptionHeading2')}
active={toolbarState.h2} active={toolbarState?.h2 ?? false}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
> >
H2 H2
</ToolButton> </ToolButton>
<ToolButton <ToolButton
title={t('scene.descriptionHeading3')} title={t('scene.descriptionHeading3')}
active={toolbarState.h3} active={toolbarState?.h3 ?? false}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
> >
H3 H3
</ToolButton> </ToolButton>
<ToolButton <ToolButton
title={t('scene.descriptionQuote')} title={t('scene.descriptionQuote')}
active={toolbarState.blockquote} active={toolbarState?.blockquote ?? false}
onClick={() => editor.chain().focus().toggleBlockquote().run()} onClick={() => editor.chain().focus().toggleBlockquote().run()}
> >
<ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" /> <ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
@@ -218,14 +218,14 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
<div className={modalStyles.toolbarGroup}> <div className={modalStyles.toolbarGroup}>
<ToolButton <ToolButton
title={t('scene.descriptionBulletList')} title={t('scene.descriptionBulletList')}
active={toolbarState.bulletList} active={toolbarState?.bulletList ?? false}
onClick={() => editor.chain().focus().toggleBulletList().run()} onClick={() => editor.chain().focus().toggleBulletList().run()}
> >
<ToolbarIcon path="M4 6h2v2H4V6zm0 5h2v2H4v-2zm0 5h2v2H4v-2zm4-10h12v2H8V6zm0 5h12v2H8v-2zm0 5h12v2H8v-2z" /> <ToolbarIcon path="M4 6h2v2H4V6zm0 5h2v2H4v-2zm0 5h2v2H4v-2zm4-10h12v2H8V6zm0 5h12v2H8v-2zm0 5h12v2H8v-2z" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
title={t('scene.descriptionOrderedList')} title={t('scene.descriptionOrderedList')}
active={toolbarState.orderedList} active={toolbarState?.orderedList ?? false}
onClick={() => editor.chain().focus().toggleOrderedList().run()} onClick={() => editor.chain().focus().toggleOrderedList().run()}
> >
<ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" /> <ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
@@ -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 : 'Новая сцена';
} }
-17
View File
@@ -259,23 +259,6 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
/> />
)} )}
</div> </div>
) : previewUrl && data.previewAssetType === 'video' ? (
<video
src={previewUrl}
muted
playsInline
preload="metadata"
className={styles.videoCover}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
) : ( ) : (
<div className={styles.previewPlaceholder} aria-hidden /> <div className={styles.previewPlaceholder} aria-hidden />
)} )}
@@ -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 },
+9 -3
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> = {
@@ -18,6 +18,7 @@ const RU_TITLES: Record<HelpSectionId, string> = {
tokens: 'Неигровые токены', tokens: 'Неигровые токены',
campaignAudio: 'Аудио игры', campaignAudio: 'Аудио игры',
materials: 'Материалы', materials: 'Материалы',
players: 'Игроки',
npcs: 'НПС', npcs: 'НПС',
session: 'Запуск сессии', session: 'Запуск сессии',
controlPanel: 'Пульт управления', controlPanel: 'Пульт управления',
@@ -31,8 +32,7 @@ const RU_TITLES: Record<HelpSectionId, string> = {
void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => { void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => {
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]); const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
const text = const text = 'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
const ranges = findHelpLinkRanges(text, catalog); const ranges = findHelpLinkRanges(text, catalog);
assert.deepEqual( assert.deepEqual(
ranges.map((r) => r.id), ranges.map((r) => r.id),
@@ -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');
});
+3
View File
@@ -9,6 +9,9 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
presentation: ['Презентация', 'Presentation'], presentation: ['Презентация', 'Presentation'],
grid: ['Сетка', 'Grid'], grid: ['Сетка', 'Grid'],
controlPanel: ['Пульт'], controlPanel: ['Пульт'],
players: ['Игроки', 'Players'],
tokens: ['Токены', 'Tokens'],
npcs: ['NPC'],
}; };
export type HelpLinkCatalogEntry = { export type HelpLinkCatalogEntry = {
+15 -2
View File
@@ -1,5 +1,7 @@
/** Порядок разделов в окне «Инструкция». */ /** Порядок разделов в окне «Инструкция». */
export const HELP_SECTION_IDS = [ import { USERS_BRANCH_FEATURES_ENABLED } from '../../../shared/features/usersBranchFeatures';
const HELP_SECTION_IDS_ALL = [
'overview', 'overview',
'license', 'license',
'projects', 'projects',
@@ -13,6 +15,7 @@ export const HELP_SECTION_IDS = [
'tokens', 'tokens',
'campaignAudio', 'campaignAudio',
'materials', 'materials',
'players',
'npcs', 'npcs',
'session', 'session',
'controlPanel', 'controlPanel',
@@ -24,7 +27,11 @@ export const HELP_SECTION_IDS = [
'settings', 'settings',
] as const; ] as const;
export type HelpSectionId = (typeof HELP_SECTION_IDS)[number]; export type HelpSectionId = (typeof HELP_SECTION_IDS_ALL)[number];
export const HELP_SECTION_IDS: readonly HelpSectionId[] = USERS_BRANCH_FEATURES_ENABLED
? HELP_SECTION_IDS_ALL
: HELP_SECTION_IDS_ALL.filter((id) => id !== 'players');
export function helpSectionTitleKey(id: HelpSectionId): string { export function helpSectionTitleKey(id: HelpSectionId): string {
return `help.section.${id}.title`; return `help.section.${id}.title`;
@@ -33,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
+35 -15
View File
@@ -20,6 +20,7 @@ import type {
} from '../../../shared/types'; } from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi'; import { getDndApi } from '../../shared/dndApi';
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl'; import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
import { applyPreviewRotationToSceneMarkers } from '../../../shared/types/scenePreviewRotation';
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string }; type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
@@ -50,11 +51,7 @@ type Actions = {
importCampaignAudio: () => Promise<void>; importCampaignAudio: () => Promise<void>;
importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>; importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>; updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
upsertMaterial: (input: { upsertMaterial: (input: { materialId?: MaterialId; name: string; filePath?: string }) => Promise<void>;
materialId?: MaterialId;
name: string;
filePath?: string;
}) => Promise<void>;
deleteMaterial: (materialId: MaterialId) => Promise<void>; deleteMaterial: (materialId: MaterialId) => Promise<void>;
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>; setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>; setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
@@ -104,7 +101,10 @@ type Actions = {
mode: 'folder' | 'archive', mode: 'folder' | 'archive',
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>; ) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
importFoundryProject: (sourcePath: string) => Promise<void>; importFoundryProject: (sourcePath: string) => Promise<void>;
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise< peekImportZip: (
labels: StorylineLabels,
targetHasMainStart: boolean,
) => Promise<
| { canceled: true } | { canceled: true }
| { | {
canceled: false; canceled: false;
@@ -157,6 +157,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>;
}; };
@@ -355,6 +356,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [], tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] }, media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
@@ -509,11 +511,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
await refreshProjects(); await refreshProjects();
}; };
const upsertMaterial = async (input: { const upsertMaterial = async (input: { materialId?: MaterialId; name: string; filePath?: string }) => {
materialId?: MaterialId;
name: string;
filePath?: string;
}) => {
const res = await api.invoke(ipcChannels.project.upsertMaterial, input); const res = await api.invoke(ipcChannels.project.upsertMaterial, input);
setState((s) => ({ ...s, project: res.project })); setState((s) => ({ ...s, project: res.project }));
await refreshProjects(); await refreshProjects();
@@ -531,10 +529,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
await refreshProjects(); await refreshProjects();
}; };
const setMaterialRotation = async ( const setMaterialRotation = async (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => {
materialId: MaterialId,
rotationDeg: 0 | 90 | 180 | 270,
) => {
const res = await api.invoke(ipcChannels.project.setMaterialRotation, { const res = await api.invoke(ipcChannels.project.setMaterialRotation, {
materialId, materialId,
rotationDeg, rotationDeg,
@@ -570,6 +565,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
traps?: import('../../../shared/types').SceneTrap[]; traps?: import('../../../shared/types').SceneTrap[];
tokens?: import('../../../shared/types').SceneToken[];
npcTokens?: import('../../../shared/types').SceneNpcToken[];
grid?: import('../../../shared/types').SceneGrid;
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 };
@@ -598,11 +596,31 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : 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.grid !== undefined ? { grid: patch.grid } : null), ...(patch.grid !== undefined ? { grid: patch.grid } : null),
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null), ...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null), ...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout, layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
}; };
if (patch.previewRotationDeg !== undefined) {
const remapped = applyPreviewRotationToSceneMarkers(
{
previewRotationDeg: scene.previewRotationDeg ?? 0,
tokens: scene.tokens ?? [],
npcTokens: scene.npcTokens ?? [],
traps: scene.traps ?? [],
},
patch.previewRotationDeg,
{
tokensProvided: patch.tokens !== undefined,
npcTokensProvided: patch.npcTokens !== undefined,
trapsProvided: patch.traps !== undefined,
},
);
if (patch.tokens === undefined) next.tokens = remapped.tokens;
if (patch.npcTokens === undefined) next.npcTokens = remapped.npcTokens;
if (patch.traps === undefined) next.traps = remapped.traps;
}
const scenes = { ...p.scenes, [sceneId]: next }; const scenes = { ...p.scenes, [sceneId]: next };
const project: Project = { ...p, scenes }; const project: Project = { ...p, scenes };
return { ...s, project }; return { ...s, project };
@@ -884,6 +902,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, {
@@ -891,6 +910,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 {
+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;
+1 -1
View File
@@ -302,7 +302,7 @@ function FilterToolbar({
return ( return (
<Panel position="top-left"> <Panel position="top-left">
<Select <Select
className={styles.filterSelect} {...(styles.filterSelect ? { className: styles.filterSelect } : {})}
ariaLabel={ui.graphFilter} ariaLabel={ui.graphFilter}
value={value} value={value}
onChange={(next) => onChange(next as GraphGroupFilter)} onChange={(next) => onChange(next as GraphGroupFilter)}
+156 -8
View File
@@ -39,8 +39,9 @@
padding: 14px 16px; padding: 14px 16px;
border-right: 1px solid var(--stroke); border-right: 1px solid var(--stroke);
background: #0f0f12; background: #0f0f12;
display: grid; display: flex;
gap: 14px; flex-direction: column;
gap: 0;
align-content: start; align-content: start;
} }
@@ -53,8 +54,13 @@
.detailCard { .detailCard {
display: grid; display: grid;
gap: 10px; gap: 10px;
padding-bottom: 14px; padding: 14px 0;
border-bottom: 1px solid var(--stroke); border-bottom: 1px solid var(--stroke);
min-width: 0;
}
.detailCard:first-child {
padding-top: 0;
} }
.detailCard:last-child { .detailCard:last-child {
@@ -62,11 +68,67 @@
padding-bottom: 0; padding-bottom: 0;
} }
.detailCardWebview {
min-height: 480px;
grid-template-rows: auto auto minmax(380px, 1fr);
}
.detailAccordion {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: center;
width: 100%;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
padding: 0;
}
.detailAccordionMark {
color: var(--text2);
font-size: 12px;
width: 14px;
}
.detailName { .detailName {
font-size: 18px; font-size: 18px;
font-weight: 900; font-weight: 900;
} }
.detailTabs {
display: flex;
gap: 6px;
}
.detailTab {
border: 1px solid var(--stroke);
background: #18181b;
color: var(--text2);
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.detailTabActive {
border-color: #60a5fa;
color: var(--text1);
background: #1e293b;
}
.webviewHost {
min-height: 380px;
height: 52vh;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--stroke);
background: #ffffff;
}
.detailSectionTitle { .detailSectionTitle {
font-size: 12px; font-size: 12px;
font-weight: 900; font-weight: 900;
@@ -121,19 +183,89 @@
.tile { .tile {
display: grid; display: grid;
grid-template-columns: 44px 1fr; grid-template-columns: auto 1fr;
gap: 10px; gap: 4px;
align-items: center; align-items: start;
padding: 8px; padding: 6px 8px 8px 4px;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
background: #18181b; background: #18181b;
cursor: pointer;
text-align: left; text-align: left;
color: inherit; color: inherit;
width: 100%; width: 100%;
} }
.tileMain {
display: grid;
gap: 6px;
min-width: 0;
}
.tileBody {
display: grid;
grid-template-columns: 44px 1fr;
gap: 10px;
align-items: center;
padding: 4px;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
color: inherit;
min-width: 0;
}
.tileMenuBtn {
width: 28px;
height: 28px;
margin-top: 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text2);
cursor: pointer;
flex-shrink: 0;
}
.tileMenuBtn:hover {
background: #27272a;
color: var(--text1);
}
.menu {
position: fixed;
z-index: 80;
min-width: 160px;
padding: 6px;
border-radius: 10px;
background: #18181b;
border: 1px solid var(--stroke);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.menuItem {
border: 0;
background: transparent;
color: var(--text1);
text-align: left;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
width: 100%;
}
.menuItem:hover:not(:disabled) {
background: #27272a;
}
.menuItem:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tileSelected { .tileSelected {
border-color: #60a5fa; border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa; box-shadow: 0 0 0 1px #60a5fa;
@@ -166,6 +298,22 @@
white-space: nowrap; white-space: nowrap;
} }
.tileShow {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: var(--text2);
cursor: pointer;
user-select: none;
}
.tileShow input {
margin: 0;
accent-color: #60a5fa;
}
.muted { .muted {
color: var(--text2); color: var(--text2);
font-size: 13px; font-size: 13px;
+450 -41
View File
@@ -1,13 +1,22 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups'; import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types'; import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
import {
listVisibleSceneNpcTokens,
nextSessionNpcSpawnPoint,
} from '../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
import matStyles from '../editor/MaterialsModals.module.css'; import matStyles from '../editor/MaterialsModals.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml'; import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag';
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { Button, Input } from '../shared/ui/controls'; import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -53,27 +62,185 @@ function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
function RuntimeNpcTile({ function RuntimeNpcTile({
npc, npc,
selected, selected,
shown,
accentColor, accentColor,
mapDragEnabled,
onActivate, onActivate,
onToggleShow,
onAddToMap,
onMapDragBegin,
onMapDragEnd,
}: { }: {
npc: ProjectNpc; npc: ProjectNpc;
selected: boolean; selected: boolean;
shown: boolean;
accentColor?: string | null; accentColor?: string | null;
mapDragEnabled: boolean;
onActivate: () => void; onActivate: () => void;
onToggleShow: () => void;
onAddToMap: () => void;
onMapDragBegin: (npcId: NpcId) => void;
onMapDragEnd: () => void;
}) { }) {
const { t } = useEditorI18n();
const url = useAssetUrl(npc.avatarAssetId); const url = useAssetUrl(npc.avatarAssetId);
const menuBtnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const dragRef = useRef<{
pointerId: number;
x: number;
y: number;
started: boolean;
} | null>(null);
const suppressClickRef = useRef(false);
useEffect(() => {
if (!menu) return;
const onPointerDown = (e: PointerEvent) => {
const tgt = e.target;
if (!(tgt instanceof Node)) return;
if (menuBtnRef.current?.contains(tgt)) return;
if (menuRef.current?.contains(tgt)) return;
setMenu(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
};
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('keydown', onKey);
};
}, [menu]);
return ( return (
<button <div
type="button"
className={[styles.tile, selected ? styles.tileSelected : ''].filter(Boolean).join(' ')} className={[styles.tile, selected ? styles.tileSelected : ''].filter(Boolean).join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined} style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
onClick={onActivate} >
<button
ref={menuBtnRef}
type="button"
className={styles.tileMenuBtn}
aria-label={t('npcs.tileMenu')}
aria-haspopup="menu"
aria-expanded={menu !== null}
title={t('npcs.tileMenu')}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const pad = 8;
const menuW = 180;
const menuH = 48;
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.max(pad, Math.min(rect.right + 4, window.innerWidth - menuW - pad));
const y = Math.max(pad, Math.min(rect.top, window.innerHeight - menuH - pad));
setMenu((cur) => (cur ? null : { x, y }));
}}
>
⋮
</button>
<div className={styles.tileMain}>
<button
type="button"
className={styles.tileBody}
onClick={(e) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
e.preventDefault();
e.stopPropagation();
return;
}
onActivate();
}}
onPointerDown={(e) => {
if (e.button !== 0 || !mapDragEnabled) return;
dragRef.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, started: false };
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId || d.started) return;
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 7) return;
d.started = true;
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
onMapDragBegin(npc.id);
}}
onPointerUp={(e) => {
const d = dragRef.current;
if (d && d.pointerId !== e.pointerId) return;
dragRef.current = null;
try {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
} catch {
/* ignore */
}
if (d?.started) {
suppressClickRef.current = true;
e.preventDefault();
onMapDragEnd();
}
}}
onPointerCancel={() => {
dragRef.current = null;
}}
> >
<div className={styles.tileAvatar}> <div className={styles.tileAvatar}>
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null} {url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
</div> </div>
<div className={styles.tileName}>{npc.name}</div> <div className={styles.tileName}>{npc.name}</div>
</button> </button>
<label
className={styles.tileShow}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={shown}
onChange={() => {
onToggleShow();
}}
/>
<span>{t('npcs.showOnScreen')}</span>
</label>
</div>
{menu
? createPortal(
<div
ref={menuRef}
role="menu"
className={styles.menu}
style={{ left: menu.x, top: menu.y }}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
className={styles.menuItem}
disabled={!mapDragEnabled}
title={mapDragEnabled ? t('npcs.addToMapHint') : t('npcs.addToMapNoScene')}
onClick={() => {
setMenu(null);
if (!mapDragEnabled) return;
onAddToMap();
}}
>
{t('npcs.addToMap')}
</button>
</div>,
document.body,
)
: null}
</div>
); );
} }
@@ -83,14 +250,26 @@ function RuntimeGroupSection({
isExpanded, isExpanded,
onToggleExpanded, onToggleExpanded,
selectedIds, selectedIds,
shownIds,
mapDragEnabled,
onActivate, onActivate,
onToggleShow,
onAddToMap,
onMapDragBegin,
onMapDragEnd,
}: { }: {
node: NpcGroupTreeNode; node: NpcGroupTreeNode;
depth: number; depth: number;
isExpanded: (id: NpcGroupId) => boolean; isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void; onToggleExpanded: (id: NpcGroupId) => void;
selectedIds: ReadonlySet<NpcId>; selectedIds: ReadonlySet<NpcId>;
shownIds: ReadonlySet<NpcId>;
mapDragEnabled: boolean;
onActivate: (id: NpcId) => void; onActivate: (id: NpcId) => void;
onToggleShow: (id: NpcId) => void;
onAddToMap: (id: NpcId) => void;
onMapDragBegin: (npcId: NpcId) => void;
onMapDragEnd: () => void;
}) { }) {
const g = node.group; const g = node.group;
const expanded = isExpanded(g.id); const expanded = isExpanded(g.id);
@@ -116,8 +295,14 @@ function RuntimeGroupSection({
key={n.id} key={n.id}
npc={n} npc={n}
selected={selectedIds.has(n.id)} selected={selectedIds.has(n.id)}
shown={shownIds.has(n.id)}
accentColor={g.color} accentColor={g.color}
mapDragEnabled={mapDragEnabled}
onActivate={() => onActivate(n.id)} onActivate={() => onActivate(n.id)}
onToggleShow={() => onToggleShow(n.id)}
onAddToMap={() => onAddToMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/> />
))} ))}
{node.children.map((child) => ( {node.children.map((child) => (
@@ -128,7 +313,13 @@ function RuntimeGroupSection({
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded} onToggleExpanded={onToggleExpanded}
selectedIds={selectedIds} selectedIds={selectedIds}
shownIds={shownIds}
mapDragEnabled={mapDragEnabled}
onActivate={onActivate} onActivate={onActivate}
onToggleShow={onToggleShow}
onAddToMap={onAddToMap}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/> />
))} ))}
</div> </div>
@@ -137,13 +328,138 @@ function RuntimeGroupSection({
); );
} }
function NpcExternalWebview({ src }: { src: string }) {
const hostRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const el = document.createElement('webview');
el.setAttribute('src', src);
el.setAttribute('partition', 'persist:npc-external-link');
el.style.width = '100%';
el.style.height = '100%';
el.style.border = '0';
el.style.display = 'flex';
host.replaceChildren(el);
return () => {
host.replaceChildren();
};
}, [src]);
return <div ref={hostRef} className={styles.webviewHost} />;
}
function NpcInspectCard({
npc,
relations,
expanded,
onToggleExpanded,
}: {
npc: ProjectNpc;
relations: { id: string; text: string }[];
expanded: boolean;
onToggleExpanded: () => void;
}) {
const { t } = useEditorI18n();
const externalUrl = normalizeNpcExternalUrl(npc.externalUrl);
const [tab, setTab] = useState<'description' | 'link'>('description');
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
const hasLink = Boolean(externalUrl);
const showLink = hasLink && tab === 'link';
useEffect(() => {
if (!hasLink && tab === 'link') setTab('description');
}, [hasLink, tab]);
return (
<section
className={[
styles.detailCard,
expanded && showLink ? styles.detailCardWebview : '',
]
.filter(Boolean)
.join(' ')}
>
<button
type="button"
className={styles.detailAccordion}
aria-expanded={expanded}
onClick={onToggleExpanded}
>
<span className={styles.detailAccordionMark}>{expanded ? '▾' : '▸'}</span>
<span className={styles.detailName}>{npc.name}</span>
</button>
{expanded ? (
<>
{hasLink ? (
<div className={styles.detailTabs} role="tablist">
<button
type="button"
role="tab"
aria-selected={tab === 'description'}
className={[styles.detailTab, tab === 'description' ? styles.detailTabActive : '']
.filter(Boolean)
.join(' ')}
onClick={() => setTab('description')}
>
{t('npcs.tabDescription')}
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'link'}
className={[styles.detailTab, tab === 'link' ? styles.detailTabActive : '']
.filter(Boolean)
.join(' ')}
onClick={() => setTab('link')}
>
{t('npcs.tabExternalLink')}
</button>
</div>
) : null}
{showLink && externalUrl ? (
<NpcExternalWebview src={externalUrl} />
) : (
<>
{safeHtml ? (
<div>
{!hasLink ? <div className={styles.detailSectionTitle}>{t('npcs.description')}</div> : null}
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
</div>
) : (
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
)}
{relations.length > 0 ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
<div className={styles.relationsList}>
{relations.map((r) => (
<div key={r.id} className={styles.relationItem}>
{r.text}
</div>
))}
</div>
</div>
) : null}
</>
)}
</>
) : null}
</section>
);
}
export function NpcsApp() { export function NpcsApp() {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const api = getDndApi(); const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
const [overlay, overlayApi] = useNpcsOverlayState(); const [overlay, overlayApi] = useNpcsOverlayState();
const [npcSession, npcSessionApi] = useSceneNpcTokensSession();
const [mapDrag, mapDragApi] = useNpcMapSpawnDrag();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set()); const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
const [collapsedInspect, setCollapsedInspect] = useState<Set<NpcId>>(() => new Set());
useEffect(() => { useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => { void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
@@ -155,11 +471,22 @@ export function NpcsApp() {
}, [api]); }, [api]);
const activeIds = overlay?.activeNpcIds ?? []; const activeIds = overlay?.activeNpcIds ?? [];
const [inspectIds, setInspectIds] = useState<NpcId[]>([]);
const hasActive = activeIds.length > 0; const hasActive = activeIds.length > 0;
const shownIds = useMemo(() => new Set(activeIds), [activeIds]);
useEffect(() => {
if (!overlay) return;
setInspectIds(overlay.inspectNpcIds ?? []);
}, [overlay?.inspectNpcIds]);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (mapDrag.dragging) {
void mapDragApi.cancel();
return;
}
if (hasActive) { if (hasActive) {
void overlayApi.dispatch({ kind: 'hide' }); void overlayApi.dispatch({ kind: 'hide' });
return; return;
@@ -173,19 +500,44 @@ export function NpcsApp() {
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [hasActive, overlay?.zoomTool, overlayApi]); }, [hasActive, mapDrag.dragging, mapDragApi, overlay?.zoomTool, overlayApi]);
const zoomTool = overlay?.zoomTool ?? null; const zoomTool = overlay?.zoomTool ?? null;
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]); const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]); const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]); const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
const selectedIds = useMemo(() => new Set(activeIds), [activeIds]); const selectedIds = useMemo(() => new Set(inspectIds), [inspectIds]);
const selectedNpcs = useMemo( const detailNpcs = useMemo(
() => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)), () => inspectIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
[activeIds, npcs], [inspectIds, npcs],
); );
useEffect(() => {
const focusId = overlay?.inspectFocusNpcId ?? null;
if (!focusId) return;
setCollapsedInspect((prev) => {
if (!prev.has(focusId)) return prev;
const next = new Set(prev);
next.delete(focusId);
return next;
});
}, [overlay?.inspectFocusNpcId, overlay?.revision]);
useEffect(() => {
setCollapsedInspect((prev) => {
let changed = false;
const next = new Set(prev);
for (const id of prev) {
if (!selectedIds.has(id)) {
next.delete(id);
changed = true;
}
}
return changed ? next : prev;
});
}, [selectedIds]);
const filteredNpcs = useMemo(() => { const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return npcs; if (!q) return npcs;
@@ -226,7 +578,7 @@ export function NpcsApp() {
const relationsByNpcId = useMemo(() => { const relationsByNpcId = useMemo(() => {
const map = new Map<NpcId, { id: string; text: string }[]>(); const map = new Map<NpcId, { id: string; text: string }[]>();
for (const npc of selectedNpcs) { for (const npc of detailNpcs) {
const list = relations const list = relations
.filter((r) => r.sourceNpcId === npc.id) .filter((r) => r.sourceNpcId === npc.id)
.map((r) => { .map((r) => {
@@ -236,15 +588,64 @@ export function NpcsApp() {
map.set(npc.id, list); map.set(npc.id, list);
} }
return map; return map;
}, [npcs, relations, selectedNpcs]); }, [detailNpcs, npcs, relations]);
const onSelectTile = useCallback( const onSelectTile = useCallback(
(id: NpcId) => {
setInspectIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
void overlayApi.dispatch({ kind: 'inspect.toggle', npcId: id });
},
[overlayApi],
);
const onToggleShow = useCallback(
(id: NpcId) => { (id: NpcId) => {
void overlayApi.dispatch({ kind: 'toggle', npcId: id }); void overlayApi.dispatch({ kind: 'toggle', npcId: id });
}, },
[overlayApi], [overlayApi],
); );
const sceneId = session?.currentSceneId ?? null;
const currentScene = sceneId && session?.project ? session.project.scenes[sceneId] : undefined;
const mapDragEnabled = Boolean(sceneId);
const spawnNpcOnMap = useCallback(
(npcId: NpcId) => {
if (!sceneId) return;
const npc = npcs.find((item) => item.id === npcId);
if (!npc) return;
const visible = listVisibleSceneNpcTokens(
currentScene?.npcTokens,
npcSession.spawned,
sceneId,
);
const occupied = visible.map((tok) => {
const override = npcSession.byPlacementId[String(tok.id)];
return { nx: override?.nx ?? tok.nx, ny: override?.ny ?? tok.ny };
});
const point = nextSessionNpcSpawnPoint(occupied);
npcSessionApi.dispatch({
kind: 'spawn',
npcId,
sceneId,
nx: point.nx,
ny: point.ny,
disposition: normalizeNpcDisposition(npc.disposition),
});
},
[currentScene?.npcTokens, npcSession.byPlacementId, npcSession.spawned, npcSessionApi, npcs, sceneId],
);
const onMapDragBegin = useCallback(
(id: NpcId) => {
void mapDragApi.beginDrag(id);
},
[mapDragApi],
);
const onMapDragEnd = useCallback(() => {
void mapDragApi.commit();
}, [mapDragApi]);
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.toolbar}> <div className={styles.toolbar}>
@@ -287,6 +688,9 @@ export function NpcsApp() {
? t('npcs.zoomOutHint') ? t('npcs.zoomOutHint')
: t('npcs.zoomIdleHint')} : t('npcs.zoomIdleHint')}
</div> </div>
<div className={styles.toolbarHint}>
{sceneId ? t('npcs.dragToMapHint') : t('npcs.addToMapNoScene')}
</div>
<div className={styles.toolbarRow}> <div className={styles.toolbarRow}>
<Button <Button
title={t('npcs.closeOverlay')} title={t('npcs.closeOverlay')}
@@ -304,36 +708,23 @@ export function NpcsApp() {
<div className={styles.body}> <div className={styles.body}>
<div className={styles.detail}> <div className={styles.detail}>
{selectedNpcs.length > 0 ? ( {detailNpcs.length > 0 ? (
selectedNpcs.map((npc) => { detailNpcs.map((npc) => (
const safeHtml = sanitizeSceneDescriptionHtml(npc.description); <NpcInspectCard
const npcRelations = relationsByNpcId.get(npc.id) ?? []; key={npc.id}
return ( npc={npc}
<div key={npc.id} className={styles.detailCard}> relations={relationsByNpcId.get(npc.id) ?? []}
<div className={styles.detailName}>{npc.name}</div> expanded={!collapsedInspect.has(npc.id)}
{safeHtml ? ( onToggleExpanded={() => {
<div> setCollapsedInspect((prev) => {
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div> const next = new Set(prev);
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} /> if (next.has(npc.id)) next.delete(npc.id);
</div> else next.add(npc.id);
) : ( return next;
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div> });
)} }}
{npcRelations.length > 0 ? ( />
<div> ))
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
<div className={styles.relationsList}>
{npcRelations.map((r) => (
<div key={r.id} className={styles.relationItem}>
{r.text}
</div>
))}
</div>
</div>
) : null}
</div>
);
})
) : ( ) : (
<div className={styles.detailCard}> <div className={styles.detailCard}>
<div className={styles.detailEmpty}> <div className={styles.detailEmpty}>
@@ -352,7 +743,13 @@ export function NpcsApp() {
key={n.id} key={n.id}
npc={n} npc={n}
selected={selectedIds.has(n.id)} selected={selectedIds.has(n.id)}
shown={shownIds.has(n.id)}
mapDragEnabled={mapDragEnabled}
onActivate={() => onSelectTile(n.id)} onActivate={() => onSelectTile(n.id)}
onToggleShow={() => onToggleShow(n.id)}
onAddToMap={() => spawnNpcOnMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/> />
)) ))
) : ( ) : (
@@ -365,7 +762,13 @@ export function NpcsApp() {
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
selectedIds={selectedIds} selectedIds={selectedIds}
shownIds={shownIds}
mapDragEnabled={mapDragEnabled}
onActivate={onSelectTile} onActivate={onSelectTile}
onToggleShow={onToggleShow}
onAddToMap={spawnNpcOnMap}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/> />
))} ))}
{ungrouped.length > 0 || !searching ? ( {ungrouped.length > 0 || !searching ? (
@@ -377,7 +780,13 @@ export function NpcsApp() {
key={n.id} key={n.id}
npc={n} npc={n}
selected={selectedIds.has(n.id)} selected={selectedIds.has(n.id)}
shown={shownIds.has(n.id)}
mapDragEnabled={mapDragEnabled}
onActivate={() => onSelectTile(n.id)} onActivate={() => onSelectTile(n.id)}
onToggleShow={() => onToggleShow(n.id)}
onAddToMap={() => spawnNpcOnMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/> />
))} ))}
</div> </div>
+90 -1
View File
@@ -189,13 +189,33 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.colorInput {
width: 100%;
height: 36px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: 8px;
background: transparent;
}
.avatarPick { .avatarPick {
display: grid; display: grid;
gap: 10px; gap: 10px;
justify-items: start; justify-items: center;
width: 100%;
}
.avatarPickBusy {
opacity: 0.65;
pointer-events: none;
}
.avatarPickDrop {
position: relative;
} }
.avatarPreview { .avatarPreview {
position: relative;
width: 120px; width: 120px;
height: 120px; height: 120px;
border-radius: 14px; border-radius: 14px;
@@ -204,6 +224,75 @@
background: #09090b; background: #09090b;
} }
.avatarPreviewPick {
cursor: pointer;
}
.avatarPreviewDrop {
border-color: var(--accent, #a78bfa);
border-style: dashed;
background: var(--accent-fill-soft, rgba(167, 139, 250, 0.16));
}
.avatarEmptyHint {
position: absolute;
inset: 10px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
color: var(--text2);
pointer-events: none;
user-select: none;
}
.avatarEdit {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
z-index: 1;
}
.avatarPick [data-token-circle]:hover .avatarEdit,
.avatarPreview:hover .avatarEdit {
opacity: 1;
}
.avatarPick [data-token-circle]:active .avatarEdit {
opacity: 0;
}
.avatarEditBtn {
pointer-events: auto;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: rgba(24, 24, 27, 0.88);
color: #fafafa;
cursor: pointer;
}
.avatarEditBtn:hover {
background: rgba(39, 39, 42, 0.96);
}
.avatarEditBtn:disabled {
opacity: 0.5;
cursor: default;
}
.avatarPreviewImg { .avatarPreviewImg {
width: 100%; width: 100%;
height: 100%; height: 100%;
+236 -24
View File
@@ -11,11 +11,17 @@ import type {
ProjectNpcGroup, ProjectNpcGroup,
ProjectNpcRelation, ProjectNpcRelation,
} from '../../shared/types'; } from '../../shared/types';
import { normalizeNpcExternalUrl, npcExternalUrlIsValid } from '../../shared/types/npcExternalUrl';
import editorStyles from '../editor/EditorApp.module.css'; import editorStyles from '../editor/EditorApp.module.css';
import { pickFirstMaterialImagePath, useFileDropZone } from '../editor/fileDrop';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import tokenStyles from '../shared/playerToken/PlayerTokenView.module.css';
import { Button, Input, Select } from '../shared/ui/controls'; import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { npcDispositionRingColor } from '../../shared/types/npcDisposition';
import { NpcDescriptionField } from './NpcDescriptionField'; import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal'; import { NpcEditModal } from './NpcEditModal';
@@ -330,6 +336,8 @@ export function NpcsEditorApp() {
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<ProjectNpcGroup | null>(null); const [pendingDeleteGroup, setPendingDeleteGroup] = useState<ProjectNpcGroup | null>(null);
const [graphFilter, setGraphFilter] = useState<GraphGroupFilter>('all'); const [graphFilter, setGraphFilter] = useState<GraphGroupFilter>('all');
const [nameDraft, setNameDraft] = useState(''); const [nameDraft, setNameDraft] = useState('');
const [linkDraft, setLinkDraft] = useState('');
const [linkError, setLinkError] = useState(false);
const [relationModal, setRelationModal] = useState< const [relationModal, setRelationModal] = useState<
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId } | { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
| { mode: 'edit'; relationId: NpcRelationId; label: string } | { mode: 'edit'; relationId: NpcRelationId; label: string }
@@ -361,6 +369,11 @@ export function NpcsEditorApp() {
setNameDraft(selected?.name ?? ''); setNameDraft(selected?.name ?? '');
}, [selected?.id, selected?.name]); }, [selected?.id, selected?.name]);
useEffect(() => {
setLinkDraft(selected?.externalUrl ?? '');
setLinkError(false);
}, [selected?.id, selected?.externalUrl]);
useEffect(() => { useEffect(() => {
if (selectedId && npcs.some((n) => n.id === selectedId)) return; if (selectedId && npcs.some((n) => n.id === selectedId)) return;
setSelectedId(npcs[0]?.id ?? null); setSelectedId(npcs[0]?.id ?? null);
@@ -412,6 +425,53 @@ export function NpcsEditorApp() {
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl }; return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
}, [api]); }, [api]);
const applyAvatarPath = useCallback(
async (filePath: string) => {
if (!selected || avatarBusy) return;
setAvatarBusy(true);
try {
await api.invoke(ipcChannels.project.upsertNpc, {
npcId: selected.id,
name: selected.name,
filePath,
});
} finally {
setAvatarBusy(false);
}
},
[api, avatarBusy, selected],
);
const chooseInspectorAvatar = useCallback(() => {
if (avatarBusy || !selected) return;
void (async () => {
setAvatarBusy(true);
try {
const picked = await pickAvatar();
if (!picked || !selected) return;
await api.invoke(ipcChannels.project.upsertNpc, {
npcId: selected.id,
name: selected.name,
filePath: picked.filePath,
});
} finally {
setAvatarBusy(false);
}
})();
}, [api, avatarBusy, pickAvatar, selected]);
const avatarDrop = useFileDropZone({
disabled: !selected || avatarBusy,
filterPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
return picked ? [picked] : [];
},
onDropPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
if (picked) void applyAvatarPath(picked);
},
});
const commitNpcGroupChange = useCallback( const commitNpcGroupChange = useCallback(
async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => { async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => {
const npc = npcs.find((n) => n.id === npcId); const npc = npcs.find((n) => n.id === npcId);
@@ -734,36 +794,149 @@ export function NpcsEditorApp() {
<> <>
<div> <div>
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div> <div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
<div className={styles.avatarPick}> <div
<div className={styles.avatarPreview}> className={[
{selectedUrl ? ( styles.avatarPick,
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" /> avatarDrop.dragOver ? styles.avatarPickDrop : '',
) : null} avatarBusy ? styles.avatarPickBusy : '',
</div> ]
<Button .filter(Boolean)
disabled={avatarBusy} .join(' ')}
onClick={() => { onDragEnter={avatarDrop.onDragEnter}
void (async () => { onDragLeave={avatarDrop.onDragLeave}
setAvatarBusy(true); onDragOver={avatarDrop.onDragOver}
try { onDrop={avatarDrop.onDrop}
const picked = await pickAvatar(); >
if (!picked) return; {USERS_BRANCH_FEATURES_ENABLED ? (
await api.invoke(ipcChannels.project.upsertNpc, { <PlayerTokenView
npcId: selected.id, name={selected.name}
name: selected.name, imageUrl={selectedUrl}
filePath: picked.filePath, ringColor={npcDispositionRingColor(selected.disposition ?? 'neutral')}
}); imageOffset={selected.imageOffset}
} finally { imageScale={selected.imageScale}
setAvatarBusy(false); sizePx={140}
syncKey={selected.id}
panEnabled={Boolean(selectedUrl)}
dropActive={avatarDrop.dragOver}
onCircleClick={selectedUrl ? undefined : chooseInspectorAvatar}
emptyContent={
selectedUrl || avatarDrop.dragOver ? null : (
<div className={tokenStyles.avatarEmptyHint}>{t('npcs.avatarPickHint')}</div>
)
} }
})(); circleOverlay={
<>
{avatarDrop.dragOver ? (
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
) : null}
{selectedUrl && !avatarDrop.dragOver ? (
<div className={styles.avatarEdit}>
<button
type="button"
className={styles.avatarEditBtn}
title={t('npcs.changeAvatar')}
aria-label={t('npcs.changeAvatar')}
disabled={avatarBusy}
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
chooseInspectorAvatar();
}} }}
> >
{t('npcs.chooseAvatar')} <svg viewBox="0 0 24 24" width={16} height={16} aria-hidden>
</Button> <path
fill="currentColor"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
</button>
</div>
) : null}
</>
}
onImageOffsetChange={(imageOffset) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
imageOffset,
});
}}
onImageScaleChange={(imageScale) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
imageScale,
});
}}
/>
) : (
<div
className={[
styles.avatarPreview,
avatarDrop.dragOver ? styles.avatarPreviewDrop : '',
!selectedUrl ? styles.avatarPreviewPick : '',
]
.filter(Boolean)
.join(' ')}
onClick={selectedUrl ? undefined : chooseInspectorAvatar}
>
{selectedUrl ? (
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
) : avatarDrop.dragOver ? null : (
<div className={styles.avatarEmptyHint}>{t('npcs.avatarPickHint')}</div>
)}
{avatarDrop.dragOver ? (
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
) : null}
{selectedUrl && !avatarDrop.dragOver ? (
<div className={styles.avatarEdit}>
<button
type="button"
className={styles.avatarEditBtn}
title={t('npcs.changeAvatar')}
aria-label={t('npcs.changeAvatar')}
disabled={avatarBusy}
onClick={(e) => {
e.stopPropagation();
chooseInspectorAvatar();
}}
>
<svg viewBox="0 0 24 24" width={16} height={16} aria-hidden>
<path
fill="currentColor"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
</button>
</div>
) : null}
</div>
)}
</div> </div>
</div> </div>
{USERS_BRANCH_FEATURES_ENABLED ? (
<label>
<div className={styles.fieldLabel}>{t('npcs.disposition')}</div>
<Select
value={selected.disposition ?? 'neutral'}
ariaLabel={t('npcs.disposition')}
onChange={(next) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
disposition: next as 'hostile' | 'neutral' | 'friendly',
});
}}
options={[
{ value: 'hostile', label: t('npcs.disposition.hostile') },
{ value: 'neutral', label: t('npcs.disposition.neutral') },
{ value: 'friendly', label: t('npcs.disposition.friendly') },
]}
/>
</label>
) : null}
<div> <div>
<div className={styles.fieldLabel}>{t('npcs.name')}</div> <div className={styles.fieldLabel}>{t('npcs.name')}</div>
<Input <Input
@@ -820,6 +993,45 @@ export function NpcsEditorApp() {
/> />
</div> </div>
<div>
<div className={styles.fieldLabel}>{t('npcs.externalLink')}</div>
<Input
value={linkDraft}
onChange={(next) => {
setLinkDraft(next);
if (linkError) setLinkError(!npcExternalUrlIsValid(next));
}}
onBlur={() => {
const trimmed = linkDraft.trim();
if (!trimmed) {
setLinkError(false);
setLinkDraft('');
if (selected.externalUrl) {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
externalUrl: '',
});
}
return;
}
const normalized = normalizeNpcExternalUrl(trimmed);
if (!normalized) {
setLinkError(true);
return;
}
setLinkError(false);
setLinkDraft(normalized);
if (normalized === selected.externalUrl) return;
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
externalUrl: normalized,
});
}}
placeholder={t('npcs.externalLinkPlaceholder')}
/>
{linkError ? <div className={editorStyles.fieldError}>{t('npcs.externalLinkInvalid')}</div> : null}
</div>
{relationsForSelected.length > 0 ? ( {relationsForSelected.length > 0 ? (
<div> <div>
<div className={styles.relationsTitle}>{t('npcs.relations')}</div> <div className={styles.relationsTitle}>{t('npcs.relations')}</div>
+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>,
);

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