Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6c301a12b | |||
| 060bbd471a | |||
| d228b9aab7 | |||
| 143d141aa6 | |||
| a29c72b307 | |||
| 712086ddca | |||
| 3d0474fe97 | |||
| 48a9c933af | |||
| 34dd39791b | |||
| fdccc0deb5 | |||
| 586166cacd | |||
| dbe8ab1fda | |||
| 9ac01d217f | |||
| 86d6d157f0 | |||
| db1168a46c | |||
| a4d7ab77e7 | |||
| 278eb78637 | |||
| cfb6e62331 | |||
| 746b862cb9 | |||
| 8b28a9b11a | |||
| 153be98780 | |||
| ea718a26df |
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,10 +279,17 @@ function collectSceneAudioPaths(
|
||||
return paths;
|
||||
}
|
||||
|
||||
export type FoundryPendingAppPlaylist = {
|
||||
name: string;
|
||||
tracks: { title: string; absPath: string; mime: string }[];
|
||||
};
|
||||
|
||||
export type FoundryBuiltProject = {
|
||||
project: Project;
|
||||
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
|
||||
previewSources: { sceneId: SceneId; absPath: string }[];
|
||||
/** Неиспользуемые на сценах Foundry-плейлисты → локальная библиотека. */
|
||||
pendingAppPlaylists: FoundryPendingAppPlaylist[];
|
||||
};
|
||||
|
||||
export async function buildProjectFromFoundryDocuments(
|
||||
@@ -372,7 +379,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
const assetId = await resolveAndImport(ctx, manifest, audioPath);
|
||||
if (!assetId) 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] = {
|
||||
@@ -406,15 +413,28 @@ export async function buildProjectFromFoundryDocuments(
|
||||
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
|
||||
);
|
||||
const campaignAudios: Project['campaignAudios'] = [];
|
||||
const pendingAppPlaylists: FoundryPendingAppPlaylist[] = [];
|
||||
for (const pl of docs.playlists) {
|
||||
if (usedPlaylistIds.has(pl._id)) continue;
|
||||
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
||||
const tracks: FoundryPendingAppPlaylist['tracks'] = [];
|
||||
for (const s of sounds) {
|
||||
if (typeof s.path !== 'string' || !s.path.trim()) continue;
|
||||
const assetId = await resolveAndImport(ctx, manifest, s.path);
|
||||
if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue;
|
||||
campaignAudios.push({ assetId, autoplay: false, loop: true });
|
||||
const abs = await resolveFoundryAssetPath(manifest, s.path);
|
||||
if (!abs) continue;
|
||||
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);
|
||||
@@ -529,6 +549,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
assets: ctx.assets,
|
||||
campaignAudios,
|
||||
materials: [],
|
||||
gridTemplates: [],
|
||||
npcs,
|
||||
npcGroups,
|
||||
npcRelations: [],
|
||||
@@ -538,7 +559,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
sceneGraphEdges,
|
||||
};
|
||||
|
||||
return { project, previewSources };
|
||||
return { project, previewSources, pendingAppPlaylists };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
+527
-13
@@ -1,13 +1,14 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
|
||||
import { app, BrowserWindow, dialog, Menu, protocol, shell } from 'electron';
|
||||
|
||||
import { installStdoutEpipeGuards } from './safeConsole';
|
||||
|
||||
installStdoutEpipeGuards();
|
||||
|
||||
import { openDialogFilterLabel } from '../shared/appBranding';
|
||||
import type { SystemDefinition } from '../shared/characterSheet/types';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures';
|
||||
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
|
||||
import {
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
} from '../shared/project/projectZipExtension';
|
||||
import type { Project } from '../shared/types';
|
||||
import { asNpcId } from '../shared/types/ids';
|
||||
import { listVisibleSceneNpcTokens } from '../shared/types/appPlayers';
|
||||
import { remapProjectSceneTokenIds } from '../shared/graph/storylineExportImport';
|
||||
import {
|
||||
asPreviewRotationDeg,
|
||||
previewRotationStepsCw,
|
||||
@@ -27,15 +30,22 @@ import {
|
||||
|
||||
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
|
||||
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||
import { SceneMarkerStore } from './effects/sceneMarkerStore';
|
||||
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
|
||||
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
|
||||
import { LicenseService } from './license/licenseService';
|
||||
import { readPresentationSettings, writePresentationSettings } from './preferences/appPreferences';
|
||||
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
||||
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
||||
import { TimerSessionStore } from './sessionTimer/timerSessionStore';
|
||||
import { InitiativeSessionStore } from './initiative/initiativeSessionStore';
|
||||
import { DiceRollSessionStore } from './diceRoll/diceRollSessionStore';
|
||||
import { ZipProjectStore } from './project/zipStore';
|
||||
import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||
import { PlayersStore } from './players/playersStore';
|
||||
import { GameSystemsStore } from './gameSystems/gameSystemsStore';
|
||||
import { PlaylistsStore } from './playlists/playlistsStore';
|
||||
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
|
||||
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
|
||||
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
|
||||
@@ -63,23 +73,38 @@ import {
|
||||
isMultiWindowOpen,
|
||||
markAppQuitting,
|
||||
openMaterialsWindow,
|
||||
openPlaylistsWindow,
|
||||
openTimerWindow,
|
||||
openInitiativeWindow,
|
||||
openDiceWindow,
|
||||
openMultiWindow,
|
||||
openNpcsEditorWindow,
|
||||
openSceneEditorWindow,
|
||||
openTokenPathEditorWindow,
|
||||
openNpcsWindow,
|
||||
openSceneDescriptionWindow,
|
||||
openInstructionsWindow,
|
||||
getInstructionsSection,
|
||||
closeMaterialsWindow,
|
||||
closePlaylistsWindow,
|
||||
closeTimerWindow,
|
||||
closeInitiativeWindow,
|
||||
closeDiceWindow,
|
||||
closeNpcsEditorWindow,
|
||||
closeSceneEditorWindow,
|
||||
closeTokenPathEditorWindow,
|
||||
closeNpcsWindow,
|
||||
requestCloseMultiWindowConfirm,
|
||||
sendToAppWindows,
|
||||
setDiceWindowClosedListener,
|
||||
setTimerWindowClosedListener,
|
||||
syncAllWindowChromeTitles,
|
||||
togglePresentationFullscreen,
|
||||
waitForEditorWindowReady,
|
||||
warmNpcsEditorWindow,
|
||||
} from './windows/createWindows';
|
||||
import { listDisplayInfos } from './windows/presentationDisplay';
|
||||
import { asPlaylistId } from '../shared/types/ids';
|
||||
import { TokenPathSessionStore } from './tokens/tokenPathSessionStore';
|
||||
import { tokenPathTotalLength } from '../shared/types/tokenPath';
|
||||
import type { TokenPathTargetKind } from '../shared/types/tokenPathSession';
|
||||
@@ -165,11 +190,15 @@ function installAppMenuForSession(): void {
|
||||
|
||||
const effectsStore = new EffectsStore();
|
||||
const sceneDarknessStore = new SceneDarknessStore();
|
||||
const sceneMarkerStore = new SceneMarkerStore();
|
||||
const sceneTrapsStore = new SceneTrapsStore();
|
||||
const sceneViewStore = new SceneViewStore();
|
||||
const videoStore = new VideoPlaybackStore();
|
||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||
const npcsOverlayStore = new NpcsOverlayStore();
|
||||
const timerSessionStore = new TimerSessionStore();
|
||||
const initiativeSessionStore = new InitiativeSessionStore();
|
||||
const diceRollSessionStore = new DiceRollSessionStore();
|
||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||
const tokenPathSessionStore = new TokenPathSessionStore();
|
||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||
@@ -177,6 +206,8 @@ const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
||||
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
|
||||
let tokensStore: TokensStore | null = null;
|
||||
let playersStore: PlayersStore | null = null;
|
||||
let gameSystemsStore: GameSystemsStore | null = null;
|
||||
let playlistsStore: PlaylistsStore | null = null;
|
||||
|
||||
function emitTokenPathSessionState(): void {
|
||||
const state = tokenPathSessionStore.getState();
|
||||
@@ -263,6 +294,108 @@ function syncNpcsOverlayWithProject(project: Project | null): void {
|
||||
npcsOverlayStore.ensureNpcStillExists(ids);
|
||||
}
|
||||
|
||||
function emitSessionTimerState(): void {
|
||||
const state = timerSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.sessionTimer.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function dismissSessionTimerWhenWindowCloses(): void {
|
||||
const st = timerSessionStore.getState();
|
||||
let changed = false;
|
||||
if (st.running) {
|
||||
timerSessionStore.dispatch({ kind: 'pause' });
|
||||
changed = true;
|
||||
}
|
||||
if (st.visibleToPlayers) {
|
||||
timerSessionStore.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
changed = true;
|
||||
}
|
||||
if (changed) emitSessionTimerState();
|
||||
}
|
||||
|
||||
setTimerWindowClosedListener(dismissSessionTimerWhenWindowCloses);
|
||||
|
||||
function emitInitiativeState(): void {
|
||||
const state = initiativeSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.initiative.stateChanged, { state });
|
||||
} catch {
|
||||
// Окно может умирать при закрытии пульта/сессии (дочернее initiative).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitDiceRollState(): void {
|
||||
const state = diceRollSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.diceRoll.stateChanged, { state });
|
||||
} catch {
|
||||
/* teardown */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideDiceOverlayWhenWindowCloses(): void {
|
||||
const st = diceRollSessionStore.getState();
|
||||
if (!st.visibleToPlayers || !st.active) return;
|
||||
diceRollSessionStore.dispatch({ kind: 'hideOverlay' });
|
||||
emitDiceRollState();
|
||||
}
|
||||
|
||||
setDiceWindowClosedListener(hideDiceOverlayWhenWindowCloses);
|
||||
|
||||
function syncInitiativePresenceFromSession(): void {
|
||||
const project = projectStore.getOpenProject();
|
||||
const sceneId = project?.currentSceneId ?? null;
|
||||
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
|
||||
const npcSession = sceneNpcTokensSessionStore.getState();
|
||||
const placements = listVisibleSceneNpcTokens(scene?.npcTokens, npcSession.spawned, sceneId);
|
||||
const npcById = new Map((project?.npcs ?? []).map((n) => [String(n.id), n]));
|
||||
const npcs = placements.map((p) => {
|
||||
const npc = npcById.get(String(p.npcId));
|
||||
return {
|
||||
placementId: String(p.id),
|
||||
npcId: String(p.npcId),
|
||||
name: npc?.name?.trim() || 'NPC',
|
||||
avatarAssetId: npc?.avatarAssetId ? String(npc.avatarAssetId) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const playerSession = scenePlayerTokensSessionStore.getState();
|
||||
const playersLib = playersStore?.listPlayers() ?? [];
|
||||
const playerById = new Map(playersLib.map((p) => [String(p.id), p]));
|
||||
const players = playerSession.selectedPlayerIds.map((id) => {
|
||||
const p = playerById.get(String(id));
|
||||
return {
|
||||
playerId: String(id),
|
||||
name: p?.name?.trim() || 'Player',
|
||||
};
|
||||
});
|
||||
|
||||
const before = initiativeSessionStore.getState().revision;
|
||||
initiativeSessionStore.dispatch({
|
||||
kind: 'syncPresence',
|
||||
npcs,
|
||||
players,
|
||||
playersVisible: playerSession.visible,
|
||||
});
|
||||
if (initiativeSessionStore.getState().revision !== before) {
|
||||
emitInitiativeState();
|
||||
}
|
||||
}
|
||||
|
||||
function clearAndResyncInitiative(): void {
|
||||
initiativeSessionStore.clear();
|
||||
syncInitiativePresenceFromSession();
|
||||
emitInitiativeState();
|
||||
}
|
||||
|
||||
function emitSceneDarknessState(): void {
|
||||
const state = sceneDarknessStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
@@ -270,6 +403,13 @@ function emitSceneDarknessState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function emitSceneMarkerState(): void {
|
||||
const state = sceneMarkerStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.sceneMarker.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function emitSceneViewState(): void {
|
||||
const state = sceneViewStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
@@ -284,6 +424,18 @@ function emitTokensState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Подтянуть `app-tokens/` из открытого кэша (экспорт сюжетной линии / полный zip). */
|
||||
async function importAppTokensFromOpenProjectCache(): Promise<Project | null> {
|
||||
const cacheDir = projectStore.getOpenCacheDir();
|
||||
if (!tokensStore || !cacheDir) return null;
|
||||
const remap = await tokensStore.importFromExportDir(cacheDir);
|
||||
if (remap.size === 0) return null;
|
||||
emitTokensState();
|
||||
const needsRemap = [...remap.entries()].some(([from, to]) => from !== to);
|
||||
if (!needsRemap) return null;
|
||||
return projectStore.updateProject((p) => remapProjectSceneTokenIds(p, remap));
|
||||
}
|
||||
|
||||
function emitSceneTokensSessionState(): void {
|
||||
const state = sceneTokensSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
@@ -306,6 +458,14 @@ function emitPlayersState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function emitPlaylistsState(): void {
|
||||
if (!playlistsStore) return;
|
||||
const state = playlistsStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.playlists.stateChanged, state);
|
||||
}
|
||||
}
|
||||
|
||||
function emitPlayerUpsertProgress(p: { percent: number; stage: string; detail?: string }): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.players.upsertProgress, p);
|
||||
@@ -357,6 +517,11 @@ function syncSceneDarknessForProject(project: Project): void {
|
||||
sceneDarknessStore.switchScene(cacheKey, enabled);
|
||||
}
|
||||
|
||||
function syncSceneMarkerForProject(project: Project): void {
|
||||
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
|
||||
sceneMarkerStore.switchScene(cacheKey);
|
||||
}
|
||||
|
||||
function emitSceneTrapsState(): void {
|
||||
const state = sceneTrapsStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
@@ -501,11 +666,15 @@ async function main() {
|
||||
playersStore = new PlayersStore(app.getPath('userData'));
|
||||
await playersStore.ensureLoaded();
|
||||
}
|
||||
gameSystemsStore = new GameSystemsStore(app.getPath('userData'));
|
||||
await gameSystemsStore.ensureLoaded();
|
||||
playlistsStore = new PlaylistsStore(app.getPath('userData'));
|
||||
await playlistsStore.ensureLoaded();
|
||||
setLicenseAssert(() => {
|
||||
licenseService.assertForIpc();
|
||||
});
|
||||
installAppMenuForSession();
|
||||
registerDndAssetProtocol(projectStore, tokensStore, playersStore);
|
||||
registerDndAssetProtocol(projectStore, tokensStore, playersStore, playlistsStore);
|
||||
registerHandler(ipcChannels.app.quit, () => {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
@@ -516,12 +685,25 @@ async function main() {
|
||||
buildNumber: getOptionalBuildNumber(),
|
||||
packaged: app.isPackaged,
|
||||
}));
|
||||
registerHandler(ipcChannels.app.openExternal, async ({ url }) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return { ok: false as const, error: 'Only http(s) URLs are allowed' };
|
||||
}
|
||||
await shell.openExternal(parsed.toString());
|
||||
return { ok: true as const };
|
||||
} catch (err) {
|
||||
return { ok: false as const, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
});
|
||||
registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus());
|
||||
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
|
||||
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
|
||||
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
|
||||
registerHandler(ipcChannels.windows.openMultiWindow, (req) => {
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneMarkerStore.resetSession();
|
||||
sceneTrapsStore.resetSession();
|
||||
sceneTokensSessionStore.reset();
|
||||
tokenPathSessionStore.reset();
|
||||
@@ -529,6 +711,9 @@ async function main() {
|
||||
clearNpcMapSpawnDrag();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
timerSessionStore.clear();
|
||||
initiativeSessionStore.clear();
|
||||
diceRollSessionStore.clear();
|
||||
if (USERS_BRANCH_FEATURES_ENABLED) {
|
||||
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
||||
if (playerIds.length > 0) {
|
||||
@@ -540,26 +725,42 @@ async function main() {
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
syncSceneDarknessForProject(project);
|
||||
syncSceneMarkerForProject(project);
|
||||
syncSceneTrapsForProject(project);
|
||||
seedTokenPathPlaybackForProject(project);
|
||||
} else {
|
||||
emitTokenPathSessionState();
|
||||
}
|
||||
emitSceneDarknessState();
|
||||
emitSceneMarkerState();
|
||||
emitSceneTrapsState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitTokenGridSnapState();
|
||||
emitEffectsState();
|
||||
emitSessionTimerState();
|
||||
syncInitiativePresenceFromSession();
|
||||
emitInitiativeState();
|
||||
emitDiceRollState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||
closeMultiWindow();
|
||||
timerSessionStore.clear();
|
||||
initiativeSessionStore.clear();
|
||||
diceRollSessionStore.clear();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
emitSessionTimerState();
|
||||
emitInitiativeState();
|
||||
emitDiceRollState();
|
||||
emitTokenGridSnapState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.requestCloseMultiWindowConfirm, () => {
|
||||
requestCloseMultiWindowConfirm();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
|
||||
syncAllWindowChromeTitles(localeTag);
|
||||
return { ok: true };
|
||||
@@ -575,10 +776,27 @@ async function main() {
|
||||
const size = getPresentationContentSize();
|
||||
return size ?? { width: null, height: null };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.listDisplays, () => {
|
||||
return { displays: listDisplayInfos() };
|
||||
});
|
||||
registerHandler(ipcChannels.preferences.getPresentation, () => {
|
||||
return { settings: readPresentationSettings(app.getPath('userData')) };
|
||||
});
|
||||
registerHandler(ipcChannels.preferences.setPresentation, ({ settings }) => {
|
||||
const next = writePresentationSettings(app.getPath('userData'), settings);
|
||||
return { settings: next };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openSceneDescription, ({ html }) => {
|
||||
openSceneDescriptionWindow(html);
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openInstructions, ({ section }) => {
|
||||
openInstructionsWindow(section);
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.getInstructionsSection, () => {
|
||||
return { section: getInstructionsSection() };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeSceneDescription, () => {
|
||||
closeSceneDescriptionWindow();
|
||||
return { ok: true };
|
||||
@@ -594,6 +812,48 @@ async function main() {
|
||||
closeMaterialsWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openPlaylists, () => {
|
||||
openPlaylistsWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closePlaylists, () => {
|
||||
closePlaylistsWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openTimer, () => {
|
||||
openTimerWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeTimer, () => {
|
||||
closeTimerWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openInitiative, () => {
|
||||
openInitiativeWindow({
|
||||
onClosed: () => {
|
||||
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: false });
|
||||
emitInitiativeState();
|
||||
},
|
||||
});
|
||||
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: true });
|
||||
syncInitiativePresenceFromSession();
|
||||
emitInitiativeState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeInitiative, () => {
|
||||
closeInitiativeWindow();
|
||||
initiativeSessionStore.dispatch({ kind: 'setEnabled', enabled: false });
|
||||
emitInitiativeState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openDice, () => {
|
||||
openDiceWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeDice, () => {
|
||||
closeDiceWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openNpcsEditor, () => {
|
||||
openNpcsEditorWindow();
|
||||
return { ok: true };
|
||||
@@ -649,6 +909,31 @@ async function main() {
|
||||
emitNpcsOverlayState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.sessionTimer.getState, () => {
|
||||
return { state: timerSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.sessionTimer.dispatch, ({ event }) => {
|
||||
timerSessionStore.dispatch(event);
|
||||
timerSessionStore.settleIfExpired();
|
||||
emitSessionTimerState();
|
||||
return { state: timerSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.initiative.getState, () => {
|
||||
return { state: initiativeSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.initiative.dispatch, ({ event }) => {
|
||||
initiativeSessionStore.dispatch(event);
|
||||
emitInitiativeState();
|
||||
return { state: initiativeSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.diceRoll.getState, () => {
|
||||
return { state: diceRollSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.diceRoll.dispatch, ({ event }) => {
|
||||
diceRollSessionStore.dispatch(event);
|
||||
emitDiceRollState();
|
||||
return { state: diceRollSessionStore.getState() };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.project.list, async () => {
|
||||
const projects = await projectStore.listProjects();
|
||||
@@ -668,16 +953,20 @@ async function main() {
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||
const project = await projectStore.openProjectById(projectId);
|
||||
let project = await projectStore.openProjectById(projectId);
|
||||
const remapped = await importAppTokensFromOpenProjectCache();
|
||||
if (remapped) project = remapped;
|
||||
sceneViewStore.reset();
|
||||
sceneTokensSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
clearNpcMapSpawnDrag();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
syncSceneMarkerForProject(project);
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitSceneMarkerState();
|
||||
emitSessionState();
|
||||
warmNpcsEditorWindow();
|
||||
return { project };
|
||||
@@ -688,7 +977,11 @@ async function main() {
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
timerSessionStore.clear();
|
||||
initiativeSessionStore.clear();
|
||||
diceRollSessionStore.clear();
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneMarkerStore.resetSession();
|
||||
sceneTrapsStore.resetSession();
|
||||
sceneViewStore.reset();
|
||||
sceneTokensSessionStore.reset();
|
||||
@@ -699,7 +992,11 @@ async function main() {
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSessionTimerState();
|
||||
emitInitiativeState();
|
||||
emitDiceRollState();
|
||||
emitSceneDarknessState();
|
||||
emitSceneMarkerState();
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
@@ -723,16 +1020,19 @@ async function main() {
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
scenePlayerTokensSessionStore.clearPlacements();
|
||||
clearAndResyncInitiative();
|
||||
// Token moves persist for the whole play session (reset only on project open/close).
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
syncSceneDarknessForProject(project);
|
||||
syncSceneMarkerForProject(project);
|
||||
syncSceneTrapsForProject(project);
|
||||
}
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSceneDarknessState();
|
||||
emitSceneMarkerState();
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
@@ -745,26 +1045,25 @@ async function main() {
|
||||
const open = projectStore.getOpenProject();
|
||||
if (!open) throw new Error('No open project');
|
||||
const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null;
|
||||
await projectStore.updateProject((p) => ({
|
||||
...p,
|
||||
currentGraphNodeId: graphNodeId,
|
||||
currentSceneId: gn ? gn.sceneId : null,
|
||||
}));
|
||||
projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null);
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
scenePlayerTokensSessionStore.clearPlacements();
|
||||
clearAndResyncInitiative();
|
||||
// Token moves persist for the whole play session (reset only on project open/close).
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
syncSceneDarknessForProject(project);
|
||||
syncSceneMarkerForProject(project);
|
||||
syncSceneTrapsForProject(project);
|
||||
}
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSceneDarknessState();
|
||||
emitSceneMarkerState();
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
@@ -812,6 +1111,9 @@ async function main() {
|
||||
syncSceneTrapsForProject(project);
|
||||
emitSceneTrapsState();
|
||||
}
|
||||
if (project?.currentSceneId === sceneId && patch.npcTokens !== undefined) {
|
||||
syncInitiativePresenceFromSession();
|
||||
}
|
||||
emitSessionState();
|
||||
return { scene: next };
|
||||
});
|
||||
@@ -944,6 +1246,26 @@ async function main() {
|
||||
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
||||
return { canceled: false as const, filePath, previewDataUrl };
|
||||
});
|
||||
registerHandler(ipcChannels.project.addGridTemplate, async ({ name, type, sizeN, color }) => {
|
||||
const project = await projectStore.addGridTemplate({ name, type, sizeN, color });
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.renameGridTemplate, async ({ templateId, name }) => {
|
||||
const project = await projectStore.renameGridTemplate(templateId, name);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.deleteGridTemplate, async ({ templateId }) => {
|
||||
const project = await projectStore.deleteGridTemplate(templateId);
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.applyGridTemplate, async ({ sceneId, templateId }) => {
|
||||
const scene = await projectStore.applyGridTemplate(sceneId, templateId);
|
||||
emitSessionState();
|
||||
return { scene };
|
||||
});
|
||||
registerHandler(
|
||||
ipcChannels.project.upsertNpc,
|
||||
async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, disposition, imageOffset }) => {
|
||||
@@ -1196,7 +1518,7 @@ async function main() {
|
||||
}
|
||||
const srcPath = filePaths[0];
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
|
||||
const project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
|
||||
let project = await projectStore.importProjectFromExternalZip(srcPath, (p) => {
|
||||
emitZipProgress({
|
||||
kind: 'import',
|
||||
stage: p.stage,
|
||||
@@ -1204,6 +1526,13 @@ async function main() {
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
});
|
||||
const cacheDir = projectStore.getOpenCacheDir();
|
||||
const remapped = await importAppTokensFromOpenProjectCache();
|
||||
if (remapped) project = remapped;
|
||||
if (playlistsStore && cacheDir) {
|
||||
await playlistsStore.importFromExportDir(cacheDir);
|
||||
emitPlaylistsState();
|
||||
}
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
return { canceled: false as const, project };
|
||||
@@ -1262,6 +1591,10 @@ async function main() {
|
||||
async (cacheDir) => {
|
||||
const remap = await tokensStore!.importFromExportDir(cacheDir);
|
||||
emitTokensState();
|
||||
if (playlistsStore) {
|
||||
await playlistsStore.importFromExportDir(cacheDir);
|
||||
emitPlaylistsState();
|
||||
}
|
||||
return remap;
|
||||
},
|
||||
);
|
||||
@@ -1292,6 +1625,10 @@ async function main() {
|
||||
// Если в sourceCache нет app-tokens, remap пустой.
|
||||
const remap = await tokensStore!.importFromExportDir(cacheDir);
|
||||
emitTokensState();
|
||||
if (playlistsStore) {
|
||||
await playlistsStore.importFromExportDir(cacheDir);
|
||||
emitPlaylistsState();
|
||||
}
|
||||
return remap;
|
||||
},
|
||||
);
|
||||
@@ -1302,7 +1639,7 @@ async function main() {
|
||||
);
|
||||
registerHandler(ipcChannels.project.importZipFromPath, async ({ filePath }) => {
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Копирование…' });
|
||||
const project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
|
||||
let project = await projectStore.importProjectFromExternalZip(filePath, (p) => {
|
||||
emitZipProgress({
|
||||
kind: 'import',
|
||||
stage: p.stage,
|
||||
@@ -1310,6 +1647,13 @@ async function main() {
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
});
|
||||
const cacheDir = projectStore.getOpenCacheDir();
|
||||
const remapped = await importAppTokensFromOpenProjectCache();
|
||||
if (remapped) project = remapped;
|
||||
if (playlistsStore && cacheDir) {
|
||||
await playlistsStore.importFromExportDir(cacheDir);
|
||||
emitPlaylistsState();
|
||||
}
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
return { project };
|
||||
@@ -1337,7 +1681,7 @@ async function main() {
|
||||
registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
|
||||
try {
|
||||
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
|
||||
const { project, pendingAppPlaylists } = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
|
||||
emitZipProgress({
|
||||
kind: 'import',
|
||||
stage:
|
||||
@@ -1346,6 +1690,16 @@ async function main() {
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
});
|
||||
if (playlistsStore && pendingAppPlaylists.length > 0) {
|
||||
for (const pl of pendingAppPlaylists) {
|
||||
const created = await playlistsStore.createPlaylist(pl.name);
|
||||
const paths = pl.tracks.map((t) => t.absPath);
|
||||
if (paths.length > 0) {
|
||||
await playlistsStore.addAudioFiles(created.id, paths);
|
||||
}
|
||||
}
|
||||
emitPlaylistsState();
|
||||
}
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
return { project };
|
||||
@@ -1356,7 +1710,7 @@ async function main() {
|
||||
});
|
||||
registerHandler(
|
||||
ipcChannels.project.exportZip,
|
||||
async ({ projectId, storylineSelections, npcIds, labels }) => {
|
||||
async ({ projectId, storylineSelections, npcIds, labels, playlistIds }) => {
|
||||
const list = await projectStore.listProjects();
|
||||
const entry = list.find((p) => p.id === projectId);
|
||||
if (!entry) {
|
||||
@@ -1394,12 +1748,19 @@ async function main() {
|
||||
async (tokenIds, exportRoot) => {
|
||||
await tokensStore!.packForExport(tokenIds, exportRoot);
|
||||
},
|
||||
async (exportRoot) => {
|
||||
if (!playlistsStore || !playlistIds?.length) return;
|
||||
await playlistsStore.packForExport(
|
||||
playlistIds.map((id) => asPlaylistId(id)),
|
||||
exportRoot,
|
||||
);
|
||||
},
|
||||
);
|
||||
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
return { canceled: false as const };
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
|
||||
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
|
||||
emitZipProgress({ kind: 'export', stage: 'done', percent: 0, detail });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@@ -1431,6 +1792,15 @@ async function main() {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.sceneMarker.getState, () => {
|
||||
return { state: sceneMarkerStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.sceneMarker.dispatch, ({ event }) => {
|
||||
sceneMarkerStore.dispatch(event);
|
||||
emitSceneMarkerState();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.sceneTraps.getState, () => {
|
||||
return { state: sceneTrapsStore.getState() };
|
||||
});
|
||||
@@ -1632,12 +2002,146 @@ async function main() {
|
||||
}
|
||||
return { url: playersStore.getImageUrl(id) };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.gameSystems.list, async () => {
|
||||
if (!gameSystemsStore) return { systems: [] };
|
||||
return { systems: await gameSystemsStore.list() };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSystems.importFromFile, async () => {
|
||||
if (!gameSystemsStore) throw new Error('Game systems store unavailable');
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{
|
||||
name: app.getLocale().toLowerCase().startsWith('ru') ? 'Системы (JSON)' : 'Systems (JSON)',
|
||||
extensions: ['json'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (canceled || filePaths.length === 0) return { canceled: true as const };
|
||||
const filePath = filePaths[0]!;
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as SystemDefinition;
|
||||
const system = await gameSystemsStore.importSystem(parsed);
|
||||
return { canceled: false as const, system };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSystems.delete, async ({ id }) => {
|
||||
if (!gameSystemsStore) return { ok: true as const };
|
||||
await gameSystemsStore.delete(id);
|
||||
if (playersStore) {
|
||||
const cleared = await playersStore.clearSystemBindings(id);
|
||||
if (cleared > 0) emitPlayersState();
|
||||
}
|
||||
return { ok: true as const };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSystems.get, async ({ id }) => {
|
||||
if (!gameSystemsStore) return { system: null };
|
||||
return { system: await gameSystemsStore.get(id) };
|
||||
});
|
||||
registerHandler(ipcChannels.gameSystems.bindSnapshot, async ({ systemId }) => {
|
||||
if (!gameSystemsStore) throw new Error('Game systems store unavailable');
|
||||
return gameSystemsStore.bindSnapshot(systemId);
|
||||
});
|
||||
registerHandler(ipcChannels.gameSystems.getSnapshot, async ({ hash }) => {
|
||||
if (!gameSystemsStore) return { system: null };
|
||||
return { system: await gameSystemsStore.getSnapshot(hash) };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.playlists.getState, async () => {
|
||||
if (!playlistsStore) return { playlists: [], audios: [] };
|
||||
await playlistsStore.ensureLoaded();
|
||||
return playlistsStore.getState();
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.create, async ({ name }) => {
|
||||
if (!playlistsStore) throw new Error('Playlists store unavailable');
|
||||
const playlist = await playlistsStore.createPlaylist(name);
|
||||
const state = playlistsStore.getState();
|
||||
emitPlaylistsState();
|
||||
return { playlist, state };
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.rename, async ({ id, name }) => {
|
||||
if (!playlistsStore) throw new Error('Playlists store unavailable');
|
||||
const playlist = await playlistsStore.renamePlaylist(id, name);
|
||||
const state = playlistsStore.getState();
|
||||
emitPlaylistsState();
|
||||
return { playlist, state };
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.duplicate, async ({ id }) => {
|
||||
if (!playlistsStore) throw new Error('Playlists store unavailable');
|
||||
const playlist = await playlistsStore.duplicatePlaylist(id);
|
||||
const state = playlistsStore.getState();
|
||||
emitPlaylistsState();
|
||||
return { playlist, state };
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.delete, async ({ id }) => {
|
||||
if (!playlistsStore) throw new Error('Playlists store unavailable');
|
||||
await playlistsStore.deletePlaylist(id);
|
||||
const state = playlistsStore.getState();
|
||||
emitPlaylistsState();
|
||||
return { state };
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.setOrder, async ({ playlistIds }) => {
|
||||
if (!playlistsStore) throw new Error('Playlists store unavailable');
|
||||
await playlistsStore.setPlaylistsOrder(playlistIds);
|
||||
const state = playlistsStore.getState();
|
||||
emitPlaylistsState();
|
||||
return { state };
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.setTracksOrder, async ({ playlistId, trackIds }) => {
|
||||
if (!playlistsStore) throw new Error('Playlists store unavailable');
|
||||
const playlist = await playlistsStore.setTracksOrder(playlistId, trackIds);
|
||||
const state = playlistsStore.getState();
|
||||
emitPlaylistsState();
|
||||
return { playlist, state };
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.addAudioFiles, async ({ playlistId, filePaths }) => {
|
||||
if (!playlistsStore) throw new Error('Playlists store unavailable');
|
||||
const playlist = await playlistsStore.addAudioFiles(playlistId, filePaths);
|
||||
const state = playlistsStore.getState();
|
||||
emitPlaylistsState();
|
||||
return { playlist, state };
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.pickAudioFiles, async () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(win ?? undefined, {
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [
|
||||
{
|
||||
name: openDialogFilterLabel('audio', app.getLocale()),
|
||||
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (canceled) return { filePaths: [] };
|
||||
return { filePaths };
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.removeTrack, async ({ playlistId, trackId }) => {
|
||||
if (!playlistsStore) throw new Error('Playlists store unavailable');
|
||||
const playlist = await playlistsStore.removeTrack(playlistId, trackId);
|
||||
const state = playlistsStore.getState();
|
||||
emitPlaylistsState();
|
||||
return { playlist, state };
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.renameAudio, async ({ audioId, title }) => {
|
||||
if (!playlistsStore) throw new Error('Playlists store unavailable');
|
||||
const audio = await playlistsStore.renameAudio(audioId, title);
|
||||
const state = playlistsStore.getState();
|
||||
emitPlaylistsState();
|
||||
return { audio, state };
|
||||
});
|
||||
registerHandler(ipcChannels.playlists.audioUrl, ({ id }) => {
|
||||
return { url: playlistsStore?.getAudioUrl(id) ?? null };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.sceneNpcTokensSession.getState, () => {
|
||||
return { state: sceneNpcTokensSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.sceneNpcTokensSession.dispatch, ({ event }) => {
|
||||
sceneNpcTokensSessionStore.dispatch(event);
|
||||
emitSceneNpcTokensSessionState();
|
||||
if (event.kind === 'spawn' || event.kind === 'despawn' || event.kind === 'clear') {
|
||||
syncInitiativePresenceFromSession();
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.npcMapSpawn.getState, () => {
|
||||
@@ -1694,6 +2198,7 @@ async function main() {
|
||||
disposition: npc.disposition,
|
||||
});
|
||||
emitSceneNpcTokensSessionState();
|
||||
syncInitiativePresenceFromSession();
|
||||
return { spawned: true };
|
||||
});
|
||||
registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => {
|
||||
@@ -1702,6 +2207,15 @@ async function main() {
|
||||
registerHandler(ipcChannels.scenePlayerTokensSession.dispatch, ({ event }) => {
|
||||
scenePlayerTokensSessionStore.dispatch(event);
|
||||
emitScenePlayerTokensSessionState();
|
||||
if (
|
||||
event.kind === 'setSelection' ||
|
||||
event.kind === 'setVisible' ||
|
||||
event.kind === 'show' ||
|
||||
event.kind === 'clear' ||
|
||||
event.kind === 'clearPlacements'
|
||||
) {
|
||||
syncInitiativePresenceFromSession();
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ function channelRequiresLicense(channel: string): boolean {
|
||||
if (channel.startsWith('license.')) return false;
|
||||
if (channel.startsWith('app.')) 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.closeMaterials) return false;
|
||||
if (channel === ipcChannels.windows.closeNpcs) return false;
|
||||
|
||||
@@ -188,6 +188,8 @@ export class PlayersStore {
|
||||
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));
|
||||
@@ -287,6 +289,20 @@ export class PlayersStore {
|
||||
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 };
|
||||
|
||||
@@ -82,6 +82,22 @@ void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
|
||||
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 });
|
||||
|
||||
@@ -21,7 +21,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
p.ny === undefined &&
|
||||
p.sizeN === undefined &&
|
||||
!p.disposition &&
|
||||
!p.inactive
|
||||
!p.inactive &&
|
||||
!p.hiddenFromPlayers
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcToken
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,6 +240,7 @@ export class SceneNpcTokensSessionStore {
|
||||
...(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);
|
||||
@@ -254,6 +257,32 @@ export class SceneNpcTokensSessionStore {
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,11 +17,11 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
|
||||
previewThumbAssetId: 'th' as AssetId,
|
||||
media: {
|
||||
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 }],
|
||||
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
|
||||
} as unknown as Project;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -69,7 +69,7 @@ void test('readProjectJsonFromZip: campaignAudios round-trips inside project.jso
|
||||
},
|
||||
scenes: {},
|
||||
assets: {},
|
||||
campaignAudios: [{ assetId, autoplay: true, loop: false }],
|
||||
campaignAudios: [{ assetId, autoplay: true, loop: false, volume: 1 }],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
|
||||
@@ -105,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', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
||||
const i = src.indexOf('export async function replaceFileAtomic');
|
||||
|
||||
+143
-30
@@ -45,12 +45,13 @@ import type {
|
||||
ProjectNpcGroup,
|
||||
ProjectNpcRelation,
|
||||
Scene,
|
||||
SceneAudioRef,
|
||||
SceneGraphEdge,
|
||||
SceneGraphNode,
|
||||
SceneId,
|
||||
SceneTrap,
|
||||
} from '../../shared/types';
|
||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
||||
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
|
||||
import {
|
||||
@@ -67,7 +68,8 @@ import {
|
||||
type NpcDisposition,
|
||||
} from '../../shared/types/npcDisposition';
|
||||
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
|
||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid, normalizeSceneGridTemplates, sceneGridFromTemplate, sceneGridTemplateStyleFromGrid, MAX_SCENE_GRID_TEMPLATES } from '../../shared/types/sceneGrid';
|
||||
import type { SceneGridTemplate } from '../../shared/types/sceneGrid';
|
||||
import {
|
||||
asPreviewRotationDeg,
|
||||
previewRotationStepsCw,
|
||||
@@ -78,6 +80,7 @@ import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
||||
import type {
|
||||
AssetId,
|
||||
GraphNodeId,
|
||||
GridTemplateId,
|
||||
MaterialId,
|
||||
NpcGroupId,
|
||||
NpcId,
|
||||
@@ -86,6 +89,7 @@ import type {
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
asGridTemplateId,
|
||||
asMaterialId,
|
||||
asNpcGroupId,
|
||||
asNpcId,
|
||||
@@ -108,6 +112,7 @@ import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { reconcileAssetFiles } from './assetPrune';
|
||||
import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
|
||||
import { ensureParentDir } from './ensureParentDir';
|
||||
import { rmWithRetries } from './fsRetry';
|
||||
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
|
||||
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
|
||||
@@ -320,6 +325,7 @@ export class ZipProjectStore {
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
gridTemplates: [],
|
||||
npcs: [],
|
||||
npcGroups: [],
|
||||
npcRelations: [],
|
||||
@@ -411,6 +417,22 @@ export class ZipProjectStore {
|
||||
return this.openProject?.project ?? null;
|
||||
}
|
||||
|
||||
getOpenCacheDir(): string | null {
|
||||
return this.openProject?.cacheDir ?? null;
|
||||
}
|
||||
|
||||
/** Только указатель сессии в памяти — без записи project.json на диск. */
|
||||
setSessionPointer(currentGraphNodeId: GraphNodeId | null, currentSceneId: SceneId | null): Project {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
open.project = {
|
||||
...open.project,
|
||||
currentGraphNodeId,
|
||||
currentSceneId,
|
||||
};
|
||||
return open.project;
|
||||
}
|
||||
|
||||
/** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */
|
||||
getAssetFileUrl(assetId: AssetId): string | null {
|
||||
if (!this.getAssetReadInfo(assetId)) return null;
|
||||
@@ -454,7 +476,7 @@ export class ZipProjectStore {
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await ensureParentDir(abs);
|
||||
await fs.writeFile(abs, buf);
|
||||
const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);
|
||||
|
||||
@@ -797,7 +819,14 @@ export class ZipProjectStore {
|
||||
: null),
|
||||
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
|
||||
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
||||
...(patch.media
|
||||
? {
|
||||
media: {
|
||||
videos: patch.media.videos ?? base.media.videos,
|
||||
audios: (patch.media.audios ?? base.media.audios).map((a) => normalizeSceneAudioRef(a)),
|
||||
},
|
||||
}
|
||||
: null),
|
||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||
};
|
||||
|
||||
@@ -1137,7 +1166,7 @@ export class ZipProjectStore {
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await ensureParentDir(abs);
|
||||
await fs.copyFile(filePath, abs);
|
||||
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
|
||||
}
|
||||
@@ -1160,8 +1189,9 @@ export class ZipProjectStore {
|
||||
else
|
||||
media.audios.push({
|
||||
assetId: asset.id,
|
||||
autoplay: sc.settings.autoplayAudio,
|
||||
loop: sc.settings.loopAudio,
|
||||
autoplay: false,
|
||||
loop: false,
|
||||
volume: 1,
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -1197,7 +1227,7 @@ export class ZipProjectStore {
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await ensureParentDir(abs);
|
||||
await fs.copyFile(filePath, abs);
|
||||
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
|
||||
}
|
||||
@@ -1212,7 +1242,7 @@ export class ZipProjectStore {
|
||||
for (const asset of staged) {
|
||||
assets[asset.id] = asset;
|
||||
if (asset.type !== 'audio') continue;
|
||||
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false });
|
||||
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false, volume: 1 });
|
||||
}
|
||||
return { ...p, assets, campaignAudios };
|
||||
});
|
||||
@@ -1225,7 +1255,10 @@ export class ZipProjectStore {
|
||||
async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] }));
|
||||
const normalized = (Array.isArray(audios) ? audios : [])
|
||||
.filter((a) => a && typeof a === 'object' && a.assetId)
|
||||
.map((a) => normalizeSceneAudioRef(a));
|
||||
await this.updateProject((p) => ({ ...p, campaignAudios: normalized }));
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
@@ -1284,7 +1317,7 @@ export class ZipProjectStore {
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await ensureParentDir(abs);
|
||||
await fs.writeFile(abs, buf);
|
||||
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
||||
nextAssetId = id;
|
||||
@@ -1395,6 +1428,70 @@ export class ZipProjectStore {
|
||||
return latest;
|
||||
}
|
||||
|
||||
async addGridTemplate(input: {
|
||||
name: string;
|
||||
type: SceneGridTemplate['type'];
|
||||
sizeN: number;
|
||||
color: string;
|
||||
}): Promise<Project> {
|
||||
if (!this.openProject) throw new Error('No open project');
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error('Grid template name is required');
|
||||
const style = sceneGridTemplateStyleFromGrid(input);
|
||||
await this.updateProject((p) => {
|
||||
const list = normalizeSceneGridTemplates(p.gridTemplates);
|
||||
if (list.length >= MAX_SCENE_GRID_TEMPLATES) {
|
||||
throw new Error('Grid template limit reached');
|
||||
}
|
||||
const template: SceneGridTemplate = {
|
||||
id: asGridTemplateId(`gt_${this.randomId()}`),
|
||||
name,
|
||||
...style,
|
||||
};
|
||||
return { ...p, gridTemplates: [...list, template] };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async renameGridTemplate(templateId: GridTemplateId, name: string): Promise<Project> {
|
||||
if (!this.openProject) throw new Error('No open project');
|
||||
const nextName = name.trim();
|
||||
if (!nextName) throw new Error('Grid template name is required');
|
||||
await this.updateProject((p) => {
|
||||
const list = normalizeSceneGridTemplates(p.gridTemplates);
|
||||
const idx = list.findIndex((t) => t.id === templateId);
|
||||
if (idx < 0) throw new Error('Grid template not found');
|
||||
const next = [...list];
|
||||
next[idx] = { ...next[idx]!, name: nextName };
|
||||
return { ...p, gridTemplates: next };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async deleteGridTemplate(templateId: GridTemplateId): Promise<Project> {
|
||||
if (!this.openProject) throw new Error('No open project');
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
gridTemplates: normalizeSceneGridTemplates(p.gridTemplates).filter((t) => t.id !== templateId),
|
||||
}));
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async applyGridTemplate(sceneId: SceneId, templateId: GridTemplateId): Promise<Scene> {
|
||||
if (!this.openProject) throw new Error('No open project');
|
||||
const open = this.openProject;
|
||||
const template = normalizeSceneGridTemplates(open.project.gridTemplates).find((t) => t.id === templateId);
|
||||
if (!template) throw new Error('Grid template not found');
|
||||
if (!open.project.scenes[sceneId]) throw new Error('Scene not found');
|
||||
return this.updateScene(sceneId, { grid: sceneGridFromTemplate(template) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт или обновляет НПС.
|
||||
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
|
||||
@@ -1455,7 +1552,7 @@ export class ZipProjectStore {
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await ensureParentDir(abs);
|
||||
await fs.writeFile(abs, buf);
|
||||
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
||||
nextAssetId = id;
|
||||
@@ -1942,7 +2039,7 @@ export class ZipProjectStore {
|
||||
|
||||
private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> {
|
||||
const tmpPath = `${zipPath}.tmp`;
|
||||
await fs.mkdir(path.dirname(zipPath), { recursive: true });
|
||||
await ensureParentDir(zipPath);
|
||||
await zipDir(cacheDir, tmpPath);
|
||||
const st = await fs.stat(tmpPath).catch(() => null);
|
||||
if (!st?.isFile() || st.size < 22) {
|
||||
@@ -2027,7 +2124,7 @@ export class ZipProjectStore {
|
||||
async importProjectFromFoundry(
|
||||
sourcePath: string,
|
||||
onProgress?: (p: FoundryImportProgress) => void,
|
||||
): Promise<Project> {
|
||||
): Promise<{ project: Project; pendingAppPlaylists: import('../foundry/foundryImport').FoundryPendingAppPlaylist[] }> {
|
||||
await this.ensureRoots();
|
||||
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
|
||||
try {
|
||||
@@ -2037,7 +2134,7 @@ export class ZipProjectStore {
|
||||
await fs.rm(cacheDir, { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
|
||||
|
||||
const { project } = await buildProjectFromFoundryDocuments(
|
||||
const { project, pendingAppPlaylists } = await buildProjectFromFoundryDocuments(
|
||||
loaded.manifest,
|
||||
loaded.docs,
|
||||
cacheDir,
|
||||
@@ -2058,7 +2155,7 @@ export class ZipProjectStore {
|
||||
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
|
||||
await this.enqueuePack(cacheDir, zipPath);
|
||||
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
|
||||
return this.openProject.project;
|
||||
return { project: this.openProject.project, pendingAppPlaylists };
|
||||
} finally {
|
||||
await loaded.cleanup();
|
||||
}
|
||||
@@ -2082,7 +2179,7 @@ export class ZipProjectStore {
|
||||
}
|
||||
const src = path.join(getProjectsRootDir(), entry.fileName);
|
||||
const dest = path.resolve(destinationPath);
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await ensureParentDir(dest);
|
||||
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
|
||||
await copyFileWithProgress(src, dest, (pct) => {
|
||||
if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
|
||||
@@ -2172,6 +2269,7 @@ export class ZipProjectStore {
|
||||
labels: StorylineLabels,
|
||||
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
||||
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
|
||||
packAppPlaylists?: (exportRoot: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
|
||||
await this.ensureRoots();
|
||||
@@ -2194,7 +2292,7 @@ export class ZipProjectStore {
|
||||
if (!a) continue;
|
||||
const srcAbs = path.join(snap.cacheDir, a.relPath);
|
||||
const destAbs = path.join(exportCache, a.relPath);
|
||||
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
||||
await ensureParentDir(destAbs);
|
||||
await fs.copyFile(srcAbs, destAbs);
|
||||
if (onProgress) {
|
||||
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
|
||||
@@ -2205,6 +2303,9 @@ export class ZipProjectStore {
|
||||
const tokenIds = collectTokenIdsFromProject(partial);
|
||||
await packAppTokens(tokenIds, exportCache);
|
||||
}
|
||||
if (packAppPlaylists) {
|
||||
await packAppPlaylists(exportCache);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const toWrite: Project = {
|
||||
...partial,
|
||||
@@ -2328,7 +2429,7 @@ export class ZipProjectStore {
|
||||
if (!srcAsset || !tgtMeta) continue;
|
||||
const srcAbs = path.join(sourceCache, srcAsset.relPath);
|
||||
const destAbs = path.join(targetCache, tgtMeta.relPath);
|
||||
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
||||
await ensureParentDir(destAbs);
|
||||
await fs.copyFile(srcAbs, destAbs);
|
||||
if (onProgress) {
|
||||
const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
|
||||
@@ -2536,20 +2637,26 @@ function normalizeScene(s: Scene): Scene {
|
||||
const audios = rawAudios
|
||||
.map((a) => {
|
||||
if (typeof a === 'string') {
|
||||
return {
|
||||
return normalizeSceneAudioRef({
|
||||
assetId: a as AssetId,
|
||||
autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio),
|
||||
loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio),
|
||||
};
|
||||
volume: 1,
|
||||
});
|
||||
}
|
||||
if (a && typeof a === 'object') {
|
||||
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
|
||||
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown };
|
||||
if (!obj.assetId) return null;
|
||||
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
|
||||
return normalizeSceneAudioRef({
|
||||
assetId: obj.assetId,
|
||||
autoplay: Boolean(obj.autoplay),
|
||||
loop: Boolean(obj.loop),
|
||||
volume: obj.volume,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
|
||||
.filter((x): x is SceneAudioRef => Boolean(x));
|
||||
|
||||
return {
|
||||
...s,
|
||||
@@ -2594,15 +2701,20 @@ function normalizeProject(p: Project): Project {
|
||||
const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
|
||||
const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
|
||||
.map((a) => {
|
||||
if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false };
|
||||
if (typeof a === 'string') return normalizeSceneAudioRef({ assetId: a as AssetId, autoplay: false, loop: false, volume: 1 });
|
||||
if (a && typeof a === 'object') {
|
||||
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
|
||||
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown };
|
||||
if (!obj.assetId) return null;
|
||||
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
|
||||
return normalizeSceneAudioRef({
|
||||
assetId: obj.assetId,
|
||||
autoplay: Boolean(obj.autoplay),
|
||||
loop: Boolean(obj.loop),
|
||||
volume: obj.volume,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
|
||||
.filter((x): x is SceneAudioRef => Boolean(x));
|
||||
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
|
||||
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
|
||||
.map((m) => {
|
||||
@@ -2736,6 +2848,7 @@ function normalizeProject(p: Project): Project {
|
||||
scenes: scenesPruned,
|
||||
campaignAudios,
|
||||
materials,
|
||||
gridTemplates: normalizeSceneGridTemplates((p as unknown as { gridTemplates?: unknown }).gridTemplates),
|
||||
npcs,
|
||||
npcGroups,
|
||||
npcRelations,
|
||||
@@ -2782,7 +2895,7 @@ async function copyFileWithProgress(
|
||||
onPercent(100);
|
||||
return;
|
||||
}
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await ensureParentDir(dest);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = 0;
|
||||
const rs = fssync.createReadStream(src);
|
||||
@@ -2915,7 +3028,7 @@ async function zipDir(srcDir: string, outZipPath: string): Promise<void> {
|
||||
}
|
||||
zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel));
|
||||
}
|
||||
await fs.mkdir(path.dirname(outZipPath), { recursive: true });
|
||||
await ensureParentDir(outZipPath);
|
||||
const out = fssync.createWriteStream(outZipPath);
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
out.on('close', resolve);
|
||||
|
||||
@@ -2,8 +2,9 @@ import fs from 'node:fs/promises';
|
||||
|
||||
import { session } from 'electron';
|
||||
|
||||
import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids';
|
||||
import { asAssetId, asPlayerId, asPlaylistAudioId, asTokenId } from '../../shared/types/ids';
|
||||
import type { PlayersStore } from '../players/playersStore';
|
||||
import type { PlaylistsStore } from '../playlists/playlistsStore';
|
||||
import type { ZipProjectStore } from '../project/zipStore';
|
||||
import type { TokensStore } from '../tokens/tokensStore';
|
||||
|
||||
@@ -76,12 +77,13 @@ async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`.
|
||||
* Обслуживает `dnd://asset?...`, `dnd://token?...`, `dnd://player?...` и `dnd://playlist-audio?...`.
|
||||
*/
|
||||
export function registerDndAssetProtocol(
|
||||
projectStore: ZipProjectStore,
|
||||
tokensStore: TokensStore,
|
||||
playersStore: PlayersStore | null,
|
||||
playlistsStore: PlaylistsStore | null = null,
|
||||
): void {
|
||||
session.defaultSession.protocol.handle('dnd', async (request) => {
|
||||
const url = new URL(request.url);
|
||||
@@ -96,6 +98,8 @@ export function registerDndAssetProtocol(
|
||||
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) {
|
||||
return new Response(null, { status: 404 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,53 @@ void test('createWindows: окно материалов закрывается
|
||||
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', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openNpcsWindow'));
|
||||
@@ -65,10 +112,11 @@ void test('createWindows: окно НПС закрывается с multi-window
|
||||
assert.ok(src.includes('will-attach-webview'));
|
||||
});
|
||||
|
||||
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
|
||||
void test('createWindows: закрытие пульта и презентации спрашивает confirm', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.match(src, /kind === 'control'/);
|
||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/);
|
||||
assert.match(src, /kind === 'control'[\s\S]*?e\.preventDefault\(\)[\s\S]*?requestCloseMultiWindowConfirm/);
|
||||
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://)', () => {
|
||||
@@ -89,3 +137,10 @@ void test('createWindows: логи окон не валят main через EPIP
|
||||
assert.ok(src.includes('errorCode === -3'));
|
||||
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\)/);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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 { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { resolvePresentationDisplay } from './presentationDisplay';
|
||||
|
||||
import { safeConsoleError } from '../safeConsole';
|
||||
|
||||
@@ -16,10 +20,15 @@ export type WindowKind =
|
||||
| 'control'
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'playlists'
|
||||
| 'timer'
|
||||
| 'initiative'
|
||||
| 'dice'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'tokenPathEditor'
|
||||
| 'npcs';
|
||||
| 'npcs'
|
||||
| 'instructions';
|
||||
|
||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||||
@@ -45,11 +54,47 @@ let appQuitting = false;
|
||||
/** Защита от каскада close(control) ↔ close(presentation). */
|
||||
let closingPlaySession = false;
|
||||
let pendingSceneDescriptionHtml = '';
|
||||
let pendingInstructionsSection = 'overview';
|
||||
|
||||
/** Окно материалов — только колонка списка. */
|
||||
const MATERIALS_WINDOW_WIDTH = 300;
|
||||
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_HEIGHT = 860;
|
||||
@@ -109,6 +154,15 @@ function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function sendInstructionsSection(win: BrowserWindow, section: string): void {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.windows.instructionsSection, { section });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
|
||||
export function markAppQuitting(): void {
|
||||
appQuitting = true;
|
||||
@@ -159,23 +213,13 @@ function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
||||
function closePlaySessionAuxiliaryWindows(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closePlaylistsWindow();
|
||||
closeTimerWindow();
|
||||
closeInitiativeWindow();
|
||||
closeDiceWindow();
|
||||
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 {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
@@ -207,6 +251,14 @@ function pageNameForKind(kind: WindowKind): string {
|
||||
return 'sceneDescription.html';
|
||||
case 'materials':
|
||||
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':
|
||||
return 'npcsEditor.html';
|
||||
case 'sceneEditor':
|
||||
@@ -215,6 +267,8 @@ function pageNameForKind(kind: WindowKind): string {
|
||||
return 'tokenPathEditor.html';
|
||||
case 'npcs':
|
||||
return 'npcs.html';
|
||||
case 'instructions':
|
||||
return 'instructions.html';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,10 +336,15 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
||||
if (kind === 'control') return { width: 1200, height: 800 };
|
||||
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
||||
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 === 'sceneEditor') return { width: 1280, height: 800 };
|
||||
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
||||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||||
if (kind === 'instructions') return { width: INSTRUCTIONS_WINDOW_WIDTH, height: INSTRUCTIONS_WINDOW_HEIGHT };
|
||||
return { width: 1280, height: 800 };
|
||||
}
|
||||
|
||||
@@ -312,6 +371,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'playlists'
|
||||
? {
|
||||
width: PLAYLISTS_WINDOW_WIDTH,
|
||||
height: PLAYLISTS_WINDOW_HEIGHT,
|
||||
minWidth: PLAYLISTS_WINDOW_MIN_WIDTH,
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcsEditor'
|
||||
? {
|
||||
width: NPCS_EDITOR_WINDOW_WIDTH,
|
||||
@@ -345,6 +412,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
minHeight: 560,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'instructions'
|
||||
? {
|
||||
width: INSTRUCTIONS_WINDOW_WIDTH,
|
||||
height: INSTRUCTIONS_WINDOW_HEIGHT,
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
show: false,
|
||||
backgroundColor: '#09090B',
|
||||
...(icon ? { icon } : {}),
|
||||
@@ -358,6 +433,10 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
autoplayPolicy: 'no-user-gesture-required',
|
||||
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
|
||||
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,
|
||||
@@ -376,6 +455,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -392,10 +474,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
if (
|
||||
kind === 'sceneDescription' ||
|
||||
kind === 'materials' ||
|
||||
kind === 'playlists' ||
|
||||
kind === 'timer' ||
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'sceneEditor' ||
|
||||
kind === 'tokenPathEditor' ||
|
||||
kind === 'npcs'
|
||||
kind === 'npcs' ||
|
||||
kind === 'instructions'
|
||||
) {
|
||||
win.setMenuBarVisibility(false);
|
||||
}
|
||||
@@ -403,6 +488,17 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
win.webContents.on('preload-error', (_event, 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) => {
|
||||
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
|
||||
if (errorCode === -3) return;
|
||||
@@ -425,23 +521,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
});
|
||||
}
|
||||
if (kind === 'control') {
|
||||
win.on('close', () => {
|
||||
win.on('close', (e) => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
closePresentationWindow();
|
||||
e.preventDefault();
|
||||
requestCloseMultiWindowConfirm();
|
||||
});
|
||||
}
|
||||
if (kind === 'presentation') {
|
||||
bindPresentationContentSizeTracking(win);
|
||||
win.on('close', () => {
|
||||
win.on('close', (e) => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
closeControlWindow();
|
||||
e.preventDefault();
|
||||
requestCloseMultiWindowConfirm();
|
||||
});
|
||||
}
|
||||
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();
|
||||
@@ -517,20 +621,36 @@ export function focusEditorWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function openMultiWindow() {
|
||||
export function openMultiWindow(presentationSettings?: PresentationSettings) {
|
||||
const settings = presentationSettings ?? readPresentationSettings(app.getPath('userData'));
|
||||
let presentation = windows.get('presentation');
|
||||
if (!presentation) {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
const display = resolvePresentationDisplay(settings.displayId);
|
||||
const { x, y, width, height } = display.bounds;
|
||||
presentation = createWindow('presentation');
|
||||
presentation.setBounds({ x, y, width, height });
|
||||
presentation.setMenuBarVisibility(false);
|
||||
if (settings.fullScreen) {
|
||||
presentation.setFullScreen(true);
|
||||
} else {
|
||||
presentation.maximize();
|
||||
}
|
||||
}
|
||||
if (!windows.has('control')) {
|
||||
// macOS: parent-child window binding moves child with the parent (unlike Windows behavior we want).
|
||||
// Keep control window independent on darwin.
|
||||
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
|
||||
const editor = windows.get('editor');
|
||||
const editorDisplay = editor
|
||||
? 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);
|
||||
broadcastPresentationContentSize();
|
||||
@@ -548,6 +668,19 @@ export function closeMultiWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Фокус пульта + событие: выход из показа спрашивает подтверждение на пульте. */
|
||||
export function requestCloseMultiWindowConfirm(): void {
|
||||
const ctrl = windows.get('control');
|
||||
if (!ctrl || ctrl.isDestroyed()) {
|
||||
closeMultiWindow();
|
||||
return;
|
||||
}
|
||||
if (ctrl.isMinimized()) ctrl.restore();
|
||||
ctrl.show();
|
||||
ctrl.focus();
|
||||
ctrl.webContents.send(ipcChannels.windows.closeMultiWindowConfirmRequested, {});
|
||||
}
|
||||
|
||||
export function isMultiWindowOpen(): boolean {
|
||||
return windows.has('presentation') || windows.has('control');
|
||||
}
|
||||
@@ -566,6 +699,34 @@ export function closeMaterialsWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function closePlaylistsWindow(): void {
|
||||
const win = windows.get('playlists');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeTimerWindow(): void {
|
||||
const win = windows.get('timer');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeInitiativeWindow(): void {
|
||||
const win = windows.get('initiative');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDiceWindow(): void {
|
||||
const win = windows.get('dice');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsEditorWindow(): void {
|
||||
const win = windows.get('npcsEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
@@ -652,6 +813,44 @@ export function getSceneDescriptionContent(): string {
|
||||
return pendingSceneDescriptionHtml;
|
||||
}
|
||||
|
||||
export function getInstructionsSection(): string {
|
||||
return pendingInstructionsSection;
|
||||
}
|
||||
|
||||
/** Окно инструкции из редактора: переиспользовать существующее или создать новое. */
|
||||
export function openInstructionsWindow(section?: string): void {
|
||||
pendingInstructionsSection = String(section ?? '').trim() || 'overview';
|
||||
const existing = windows.get('instructions');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
sendInstructionsSection(existing, pendingInstructionsSection);
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('instructions', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
sendInstructionsSection(win, pendingInstructionsSection);
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Одно окно описания: переиспользовать существующее или создать новое. */
|
||||
export function openSceneDescriptionWindow(html: string): void {
|
||||
pendingSceneDescriptionHtml = html;
|
||||
@@ -725,6 +924,132 @@ export function openMaterialsWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function openPlaylistsWindow(): void {
|
||||
const existing = windows.get('playlists');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
existing.setMinimumSize(PLAYLISTS_WINDOW_MIN_WIDTH, 480);
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('playlists', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function openTimerWindow(): void {
|
||||
const existing = windows.get('timer');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('timer', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function openInitiativeWindow(opts?: { onClosed?: () => void }): void {
|
||||
const existing = windows.get('initiative');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('initiative', parent ? { parent } : undefined);
|
||||
if (opts?.onClosed) {
|
||||
win.once('closed', () => {
|
||||
opts.onClosed?.();
|
||||
});
|
||||
}
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function openDiceWindow(): void {
|
||||
const existing = windows.get('dice');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('dice', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||
const parent = windows.get('editor');
|
||||
const { width, height } = win.getBounds();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -217,11 +217,58 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.storyScroll {
|
||||
flex: 0 0 70%;
|
||||
.storyWrapCollapsed {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.storyAccordions {
|
||||
flex: 1;
|
||||
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-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);
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
@@ -292,19 +339,39 @@
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 900;
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.previewActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.previewTokenTools {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
|
||||
.npcTokenScale {
|
||||
@@ -312,6 +379,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.snapToGrid {
|
||||
@@ -319,26 +387,37 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.snapToGrid input {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.snapToGridLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.npcTokenScaleLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npcTokenScaleRange {
|
||||
width: min(140px, 22vw);
|
||||
flex: 1 1 auto;
|
||||
min-width: 72px;
|
||||
width: min(140px, 100%);
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
accent-color: var(--color-accent, #c9a227);
|
||||
}
|
||||
|
||||
@@ -394,6 +473,61 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,4 +32,18 @@ void test('ControlApp: draft кисти без корневого draftFxTick',
|
||||
assert.ok(pixi.includes('setDraft'));
|
||||
assert.ok(pixi.includes('mergeDraftState'));
|
||||
assert.ok(pixi.includes('forwardRef'));
|
||||
assert.ok(app.includes('perfProfile="preview"'));
|
||||
assert.ok(app.includes('OVERLAY_DRAFT_IPC_MS'));
|
||||
});
|
||||
|
||||
void test('ControlApp: path playback не гоняет setState корня', () => {
|
||||
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
const hook = fs.readFileSync(
|
||||
path.join(here, '../shared/tokens/useTokenPathLivePoses.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(app, /\bpathLivePoses\b/);
|
||||
assert.doesNotMatch(hook, /\bsetPoses\b/);
|
||||
assert.ok(hook.includes('applyLivePathPose'));
|
||||
assert.ok(hook.includes('requestAnimationFrame'));
|
||||
});
|
||||
|
||||
@@ -78,6 +78,15 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes("t('control.instruments')"));
|
||||
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('openSceneDescription'));
|
||||
assert.ok(!src.includes('SceneDescriptionViewModal'));
|
||||
@@ -113,7 +122,7 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
||||
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: чекбокс привязки токенов к сетке', () => {
|
||||
@@ -126,19 +135,38 @@ void test('ControlApp: чекбокс привязки токенов к сет
|
||||
assert.ok(src.includes('snapAllTokensToGrid'));
|
||||
assert.ok(src.includes('snapNorm={snapNormActive}'));
|
||||
assert.ok(css.includes('.snapToGrid'));
|
||||
assert.ok(css.includes('.previewTokenTools'));
|
||||
assert.match(css, /\.previewTokenTools[\s\S]*?repeat\(auto-fit/);
|
||||
assert.match(css, /\.previewHeader[\s\S]*?flex-wrap:\s*wrap/);
|
||||
assert.match(css, /\.previewHeader[\s\S]*?align-items:\s*center/);
|
||||
});
|
||||
|
||||
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
void test('ControlApp: история — свёрнута по умолчанию, колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
const story = src.indexOf("t('control.storyLine')");
|
||||
assert.ok(story !== -1);
|
||||
assert.ok(src.includes('data-testid="control-history-accordion"'));
|
||||
assert.ok(src.includes('aria-expanded={historyOpen}'));
|
||||
assert.ok(src.includes('const [historyOpen, setHistoryOpen] = useState(false)'));
|
||||
assert.ok(src.includes('className={styles.storyScroll}'));
|
||||
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
||||
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||
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: клик по истории добавляет новый шаг, не подавляет запись', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(!src.includes('suppressNextHistoryPushRef'));
|
||||
@@ -173,6 +201,27 @@ void test('ControlApp: слой кисти не использует курсо
|
||||
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: радиус кисти не в блоке предпросмотра', () => {
|
||||
const src = readControlApp();
|
||||
const previewLabel = src.indexOf("t('control.screenPreview')");
|
||||
@@ -267,3 +316,17 @@ void test('ControlApp: загрузка камп. аудио — useEffect за
|
||||
assert.ok(!/\bproject\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'));
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -53,6 +53,12 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.helpMark {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -550,113 +556,6 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.instructionsDialog {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(920px, calc(100vw - 32px));
|
||||
max-height: min(86vh, 720px);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.instructionsLayout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 220px;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
max-height: min(62vh, 560px);
|
||||
}
|
||||
|
||||
.instructionsContent {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
.instructionsContentTitle {
|
||||
font-weight: 800;
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.instructionsParagraph {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.instructionsParagraph:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.instructionsInlineLink {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent, #a78bfa);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.instructionsInlineLink:hover {
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.instructionsNav {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--panel2);
|
||||
}
|
||||
|
||||
.instructionsNavItem {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.instructionsNavItem:hover {
|
||||
background: var(--scene-list-hover-bg);
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.instructionsNavItemActive {
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
color: var(--text0);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.aboutBody {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -688,6 +587,13 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.aboutSponsorsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.aboutLink {
|
||||
color: var(--color-accent, #a78bfa);
|
||||
text-decoration: none;
|
||||
@@ -1110,7 +1016,7 @@
|
||||
.audioList {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 160px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -1120,10 +1026,32 @@
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-overlay-dark-3);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: start;
|
||||
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 {
|
||||
@@ -1135,13 +1063,35 @@
|
||||
|
||||
.audioControls {
|
||||
color: var(--text2);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
min-width: 28px;
|
||||
|
||||
+134
-197
@@ -30,14 +30,16 @@ import type {
|
||||
} from '../../shared/types';
|
||||
import { AppLogo } from '../shared/branding/AppLogo';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { stopAllEditorAudio } from '../shared/audio/editorAudioStop';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { LayoutShell } from '../shared/ui/LayoutShell';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
|
||||
import { AppAboutModal } from './about/EditorAboutModals';
|
||||
import styles from './EditorApp.module.css';
|
||||
import { EditorAudioTrackList } from './EditorAudioTrackList';
|
||||
import {
|
||||
filterAudioFilePaths,
|
||||
partitionSceneMediaDrops,
|
||||
@@ -59,6 +61,9 @@ import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/Edito
|
||||
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 { SceneDescriptionModal } from './SceneDescriptionModal';
|
||||
import type { ProjectNoticeCode } from './state/projectState';
|
||||
@@ -125,10 +130,7 @@ export function EditorApp() {
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
||||
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
|
||||
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
|
||||
const [appAboutOpen, setAppAboutOpen] = useState(false);
|
||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [importSourceOpen, setImportSourceOpen] = useState(false);
|
||||
@@ -160,10 +162,30 @@ export function EditorApp() {
|
||||
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 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(
|
||||
(code: ProjectNoticeCode) => {
|
||||
const handlers: Record<ProjectNoticeCode, () => void> = {
|
||||
@@ -256,11 +278,9 @@ export function EditorApp() {
|
||||
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectMenuBtnRef = 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 [projectMenuPos, setProjectMenuPos] = 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 [sceneListDrop, setSceneListDrop] = useState<{
|
||||
targetId: SceneId;
|
||||
@@ -359,9 +379,18 @@ export function EditorApp() {
|
||||
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
|
||||
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
|
||||
|
||||
const openInstructions = useCallback((section: HelpSectionId = 'overview') => {
|
||||
void getDndApi()
|
||||
.invoke(ipcChannels.windows.openInstructions, { section })
|
||||
.catch((err) => {
|
||||
console.error('[editor] openInstructions failed', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const launchFromGraphNode = useCallback(
|
||||
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
||||
if (!licenseActive || launching) return;
|
||||
stopAllEditorAudio();
|
||||
setLaunching(true);
|
||||
setRunMenuOpen(false);
|
||||
setLaunchPlayersOpen(false);
|
||||
@@ -380,7 +409,10 @@ export function EditorApp() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (presentationOpen) setLaunching(false);
|
||||
if (presentationOpen) {
|
||||
setLaunching(false);
|
||||
setPlaylistsManagerOpen(false);
|
||||
}
|
||||
}, [presentationOpen]);
|
||||
|
||||
const currentProjectName = state.project?.meta.name ?? '';
|
||||
@@ -454,26 +486,6 @@ export function EditorApp() {
|
||||
if (!settingsMenuOpen) setSettingsLangSubOpen(false);
|
||||
}, [settingsMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aboutMenuOpen) return;
|
||||
const r = aboutMenuBtnRef.current?.getBoundingClientRect() ?? null;
|
||||
queueMicrotask(() => {
|
||||
if (r) {
|
||||
setAboutMenuPos({ left: r.left, top: r.bottom + 10 });
|
||||
} else {
|
||||
setAboutMenuPos(null);
|
||||
}
|
||||
});
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (!t) return;
|
||||
if (t.closest('[data-aboutmenu-root="1"]')) return;
|
||||
setAboutMenuOpen(false);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [aboutMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runMenuOpen) return;
|
||||
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
|
||||
@@ -501,6 +513,7 @@ export function EditorApp() {
|
||||
// ignore
|
||||
}
|
||||
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
||||
if (open) stopAllEditorAudio();
|
||||
setPresentationOpen(open);
|
||||
});
|
||||
})();
|
||||
@@ -782,7 +795,6 @@ export function EditorApp() {
|
||||
onClick={() => {
|
||||
setFileMenuOpen(false);
|
||||
setProjectMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setSettingsMenuOpen((v) => {
|
||||
const next = !v;
|
||||
if (next) setSettingsLangSubOpen(false);
|
||||
@@ -793,16 +805,14 @@ export function EditorApp() {
|
||||
{t('top.settings')}
|
||||
</button>
|
||||
<button
|
||||
ref={aboutMenuBtnRef}
|
||||
type="button"
|
||||
data-aboutmenu-root="1"
|
||||
className={styles.fileMenuTrigger}
|
||||
onClick={() => {
|
||||
setFileMenuOpen(false);
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setSettingsLangSubOpen(false);
|
||||
setAboutMenuOpen((v) => !v);
|
||||
setAppAboutOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('top.aboutApp')}
|
||||
@@ -818,7 +828,6 @@ export function EditorApp() {
|
||||
if (!licenseActive) return;
|
||||
setFileMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setProjectMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
@@ -836,7 +845,6 @@ export function EditorApp() {
|
||||
if (!licenseActive) return;
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setFileMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
@@ -854,7 +862,6 @@ export function EditorApp() {
|
||||
if (!licenseActive) return;
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setFileMenuOpen(false);
|
||||
setPlayersManagerOpen(true);
|
||||
}}
|
||||
@@ -862,6 +869,22 @@ export function EditorApp() {
|
||||
{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 className={styles.flex1} />
|
||||
{appVersionText ? (
|
||||
@@ -869,6 +892,19 @@ export function EditorApp() {
|
||||
{appVersionText}
|
||||
</div>
|
||||
) : 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}>
|
||||
{state.project ? (
|
||||
<>
|
||||
@@ -943,8 +979,7 @@ export function EditorApp() {
|
||||
title={runHelpTooltip}
|
||||
tooltipPlacement="bottom-left"
|
||||
onClick={() => {
|
||||
setInstructionsSection(runHelpSection);
|
||||
setInstructionsOpen(true);
|
||||
openInstructions(runHelpSection);
|
||||
}}
|
||||
>
|
||||
?
|
||||
@@ -1320,6 +1355,30 @@ export function EditorApp() {
|
||||
{t('menu.checkUpdates')}
|
||||
</button>
|
||||
) : 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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1401,46 +1460,6 @@ export function EditorApp() {
|
||||
snapshot={licenseSnap}
|
||||
/>
|
||||
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
|
||||
<InstructionsModal
|
||||
open={instructionsOpen}
|
||||
initialSection={instructionsSection}
|
||||
onClose={() => setInstructionsOpen(false)}
|
||||
/>
|
||||
{aboutMenuOpen && aboutMenuPos
|
||||
? createPortal(
|
||||
<div
|
||||
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
|
||||
? createPortal(
|
||||
<div
|
||||
@@ -1547,8 +1566,8 @@ export function EditorApp() {
|
||||
storylineLabels={storylineLabels}
|
||||
loadStorylines={loadProjectStorylines}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
onExport={async (projectId, selections, npcIds) => {
|
||||
await actions.exportProject(projectId, selections, npcIds, storylineLabels);
|
||||
onExport={async (projectId, selections, npcIds, playlistIds) => {
|
||||
await actions.exportProject(projectId, selections, npcIds, storylineLabels, playlistIds);
|
||||
}}
|
||||
/>
|
||||
<ImportSourceModal
|
||||
@@ -1638,6 +1657,30 @@ export function EditorApp() {
|
||||
}}
|
||||
/>
|
||||
<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)} />
|
||||
@@ -2487,7 +2530,6 @@ function CampaignInspector({
|
||||
onDropAudio,
|
||||
}: CampaignInspectorProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
|
||||
const audioDrop = useFileDropZone({
|
||||
onDropPaths: onDropAudio,
|
||||
filterPaths: filterAudioFilePaths,
|
||||
@@ -2503,71 +2545,19 @@ function CampaignInspector({
|
||||
onDrop={audioDrop.onDrop}
|
||||
>
|
||||
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
||||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
||||
{audioRefs.length === 0 ? (
|
||||
<div className={styles.audioDropEmpty}>
|
||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.audioList}>
|
||||
{mediaAssets
|
||||
.filter((a) => a.type === 'audio')
|
||||
.map((a) => (
|
||||
<div key={a.id} className={styles.audioRow}>
|
||||
<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);
|
||||
}}
|
||||
<EditorAudioTrackList
|
||||
audioRefs={audioRefs}
|
||||
mediaAssets={mediaAssets}
|
||||
removeTitle={t('campaign.removeTitle')}
|
||||
onAudioRefsChange={onAudioRefsChange}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -2607,7 +2597,6 @@ function SceneInspector({
|
||||
const { t } = useEditorI18n();
|
||||
const [descriptionModalOpen, setDescriptionModalOpen] = useState(false);
|
||||
const previewUrl = useAssetUrl(previewAssetId);
|
||||
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
|
||||
const descriptionEmpty = isSceneDescriptionEmpty(description);
|
||||
const descriptionPreviewHtml = useMemo(
|
||||
() => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)),
|
||||
@@ -2777,7 +2766,7 @@ function SceneInspector({
|
||||
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
|
||||
}}
|
||||
>
|
||||
Редактор сцены
|
||||
{t('sceneEditor.open')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
@@ -2791,71 +2780,19 @@ function SceneInspector({
|
||||
onDrop={sceneAudioDrop.onDrop}
|
||||
>
|
||||
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
||||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
||||
{audioRefs.length === 0 ? (
|
||||
<div className={styles.audioDropEmpty}>
|
||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.audioList}>
|
||||
{mediaAssets
|
||||
.filter((a) => a.type === 'audio')
|
||||
.map((a) => (
|
||||
<div key={a.id} className={styles.audioRow}>
|
||||
<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);
|
||||
}}
|
||||
<EditorAudioTrackList
|
||||
audioRefs={audioRefs}
|
||||
mediaAssets={mediaAssets}
|
||||
removeTitle={t('scene.removeTitle')}
|
||||
onAudioRefsChange={onAudioRefsChange}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import styles from './EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
nameFromFilePath,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from './fileDrop';
|
||||
@@ -86,6 +87,11 @@ export function MaterialEditModal({
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||
setFilePath(path);
|
||||
setName((prev) => {
|
||||
if (prev.trim()) return prev;
|
||||
const fromFile = nameFromFilePath(path);
|
||||
return fromFile || prev;
|
||||
});
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return previewUrl;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.dialog {
|
||||
width: min(960px, calc(100vw - 48px));
|
||||
height: min(720px, calc(100vh - 48px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 4px 8px;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Button } from '../shared/ui/controls';
|
||||
import {
|
||||
PlaylistsPanel,
|
||||
playlistsPanelLabelsFromT,
|
||||
} from '../shared/playlists/PlaylistsPanel';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import panelStyles from './PlaylistsModal.module.css';
|
||||
|
||||
export function PlaylistsManagerModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const labels = useMemo(() => playlistsPanelLabelsFromT(t), [t]);
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
|
||||
<div
|
||||
className={[styles.modalDialog, panelStyles.dialog].join(' ')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="playlists-modal"
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('playlists.title')}</div>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={panelStyles.body}>
|
||||
<PlaylistsPanel labels={labels} />
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
.dialog {
|
||||
width: min(480px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.rowMeta {
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fullScreenRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 4px;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 10px 8px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { DisplayInfo, PresentationSettings } from '../../shared/types/presentationSettings';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import modalStyles from './PresentationSettingsModal.module.css';
|
||||
|
||||
export function PresentationSettingsModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [displays, setDisplays] = useState<DisplayInfo[]>([]);
|
||||
const [draft, setDraft] = useState<PresentationSettings>({ displayId: null, fullScreen: true });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const [{ displays: list }, { settings }] = await Promise.all([
|
||||
api.invoke(ipcChannels.windows.listDisplays, {}),
|
||||
api.invoke(ipcChannels.preferences.getPresentation, {}),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setDisplays(list);
|
||||
setDraft(settings);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [api, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const primaryId = displays.find((d) => d.isPrimary)?.id ?? null;
|
||||
const settings: PresentationSettings = {
|
||||
...draft,
|
||||
displayId: draft.displayId === primaryId ? null : draft.displayId,
|
||||
};
|
||||
await api.invoke(ipcChannels.preferences.setPresentation, { settings });
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisplaySelected = (d: DisplayInfo) =>
|
||||
draft.displayId === d.id || (draft.displayId === null && d.isPrimary);
|
||||
|
||||
const displayLabel = (d: DisplayInfo) => {
|
||||
const primary = d.isPrimary ? ` (${t('settings.presentation.primary')})` : '';
|
||||
return `${d.label}${primary} — ${d.width}×${d.height}`;
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
|
||||
<div
|
||||
className={[styles.modalDialog, modalStyles.dialog].join(' ')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="presentation-settings-modal"
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('settings.presentation.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className={modalStyles.hint}>{t('settings.presentation.hint')}</p>
|
||||
<section className={modalStyles.section}>
|
||||
<div className={modalStyles.sectionTitle}>{t('settings.presentation.display')}</div>
|
||||
{loading ? (
|
||||
<div className={modalStyles.loading}>{t('settings.presentation.loading')}</div>
|
||||
) : (
|
||||
<div className={modalStyles.list} role="radiogroup" aria-label={t('settings.presentation.display')}>
|
||||
{displays.map((d) => (
|
||||
<label key={d.id} className={modalStyles.row}>
|
||||
<input
|
||||
type="radio"
|
||||
name="presentation-display"
|
||||
checked={isDisplaySelected(d)}
|
||||
onChange={() => setDraft((prev) => ({ ...prev, displayId: d.id }))}
|
||||
data-testid={`presentation-display-${d.id}`}
|
||||
/>
|
||||
<span>{displayLabel(d)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<label className={modalStyles.fullScreenRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.fullScreen}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, fullScreen: e.target.checked }))}
|
||||
data-testid="presentation-fullscreen"
|
||||
/>
|
||||
<span>{t('settings.presentation.fullScreen')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabled={loading || saving} data-testid="presentation-settings-save">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../shared/graph/storylineExportImport';
|
||||
import type { Project, ProjectId, SceneId } from '../../shared/types';
|
||||
import { Button, Select } from '../shared/ui/controls';
|
||||
import { useAppPlaylists } from '../shared/playlists/useAppPlaylists';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
@@ -37,6 +38,7 @@ type ExportProjectModalProps = {
|
||||
projectId: ProjectId,
|
||||
selections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
playlistIds: string[],
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -50,12 +52,14 @@ export function ExportProjectModal({
|
||||
onExport,
|
||||
}: ExportProjectModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const playlistsState = useAppPlaylists();
|
||||
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
|
||||
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
||||
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
||||
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = 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 [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -67,6 +71,7 @@ export function ExportProjectModal({
|
||||
setError(null);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setSelectedPlaylistIds(new Set());
|
||||
setStep('storylines');
|
||||
setNpcs([]);
|
||||
}, [initialProjectId, open]);
|
||||
@@ -156,7 +161,7 @@ export function ExportProjectModal({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onExport(projectId, selectedSelections, npcIds);
|
||||
await onExport(projectId, selectedSelections, npcIds, [...selectedPlaylistIds]);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -168,7 +173,7 @@ export function ExportProjectModal({
|
||||
|
||||
const goNextFromStorylines = () => {
|
||||
if (!canContinueStorylines) return;
|
||||
if (npcs.length === 0) {
|
||||
if (npcs.length === 0 && playlistsState.playlists.length === 0) {
|
||||
runExport([]);
|
||||
return;
|
||||
}
|
||||
@@ -248,6 +253,8 @@ export function ExportProjectModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.fieldGrid}>
|
||||
{npcs.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
||||
<div className={styles.storylineChecklist}>
|
||||
{npcs.map((n) => (
|
||||
@@ -268,6 +275,36 @@ export function ExportProjectModal({
|
||||
>
|
||||
{t('export.selectAllNpcs')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -289,7 +326,7 @@ export function ExportProjectModal({
|
||||
disabled={!canContinueStorylines || saving}
|
||||
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
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
|
||||
import { Button } from '../../shared/ui/controls';
|
||||
import styles from '../EditorApp.module.css';
|
||||
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../help/helpLinkify';
|
||||
import {
|
||||
HELP_SECTION_IDS,
|
||||
helpSectionBodyKey,
|
||||
helpSectionTitleKey,
|
||||
type HelpSectionId,
|
||||
} from '../help/helpSections';
|
||||
import { useEditorI18n } from '../i18n/EditorI18nContext';
|
||||
|
||||
const ABOUT_SPONSORS = [
|
||||
{ kind: 'text', name: 'Тифлинг бард 😈❤️' },
|
||||
{
|
||||
kind: '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 = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -80,123 +86,25 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
|
||||
{t('app.about.websiteUrl')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type InstructionsModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialSection?: HelpSectionId;
|
||||
};
|
||||
|
||||
function InstructionsModalBody({
|
||||
initialSection,
|
||||
onClose,
|
||||
}: {
|
||||
initialSection: HelpSectionId;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0 });
|
||||
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
||||
activeNav?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeId]);
|
||||
|
||||
const linkCatalog = useMemo(
|
||||
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
|
||||
[t],
|
||||
);
|
||||
|
||||
const body = t(helpSectionBodyKey(activeId));
|
||||
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
||||
|
||||
const goToSection = (id: HelpSectionId) => {
|
||||
setActiveId(id);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.instructionsDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('help.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
<div className={styles.fieldLabel}>{t('app.about.sponsorsLabel')}</div>
|
||||
<div className={styles.aboutSponsorsList}>
|
||||
{ABOUT_SPONSORS.map((sponsor) =>
|
||||
sponsor.kind === 'link' ? (
|
||||
<a
|
||||
key={sponsor.name}
|
||||
className={styles.aboutLink}
|
||||
href={sponsor.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.instructionsLayout}>
|
||||
<div ref={contentRef} className={styles.instructionsContent}>
|
||||
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} className={styles.instructionsParagraph}>
|
||||
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
|
||||
part.type === 'text' ? (
|
||||
<React.Fragment key={j}>{part.value}</React.Fragment>
|
||||
{sponsor.name}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
className={styles.instructionsInlineLink}
|
||||
onClick={() => goToSection(part.id)}
|
||||
>
|
||||
{part.value}
|
||||
</button>
|
||||
<div key={sponsor.name}>{sponsor.name}</div>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}>
|
||||
{HELP_SECTION_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={[
|
||||
styles.instructionsNavItem,
|
||||
id === activeId ? styles.instructionsNavItemActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={id === activeId ? 'true' : undefined}
|
||||
onClick={() => goToSection(id)}
|
||||
>
|
||||
{t(helpSectionTitleKey(id))}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
@@ -208,9 +116,3 @@ function InstructionsModalBody({
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -69,10 +69,14 @@ export function isPreviewMediaPath(filePath: string): boolean {
|
||||
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 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 : 'Новая сцена';
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ function minimalProject(overrides: Partial<Project>): Project {
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
gridTemplates: [],
|
||||
npcs: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
@@ -41,7 +42,7 @@ void test('buildNextSceneCardById: does not change refs when irrelevant fields c
|
||||
id: sid,
|
||||
title: 'T',
|
||||
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 },
|
||||
connections: [],
|
||||
layout: { x: 0, y: 0 },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { HelpSectionId } from './helpSections';
|
||||
import { parseHelpSectionId, type HelpSectionId } from './helpSections';
|
||||
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
|
||||
|
||||
const RU_TITLES: Record<HelpSectionId, string> = {
|
||||
@@ -80,3 +80,9 @@ void test('findHelpLinkRanges: полное «Пульт управления»
|
||||
assert.equal(ranges[0]?.id, 'controlPanel');
|
||||
assert.equal(ranges[0]?.text, '«Пульт управления»');
|
||||
});
|
||||
|
||||
void test('parseHelpSectionId: unknown falls back to overview', () => {
|
||||
assert.equal(parseHelpSectionId('grid'), 'grid');
|
||||
assert.equal(parseHelpSectionId(''), 'overview');
|
||||
assert.equal(parseHelpSectionId('not-a-section'), 'overview');
|
||||
});
|
||||
|
||||
@@ -40,3 +40,9 @@ export function helpSectionTitleKey(id: HelpSectionId): string {
|
||||
export function helpSectionBodyKey(id: HelpSectionId): string {
|
||||
return `help.section.${id}.body`;
|
||||
}
|
||||
|
||||
export function parseHelpSectionId(raw: unknown): HelpSectionId {
|
||||
const s = String(raw ?? '').trim();
|
||||
if ((HELP_SECTION_IDS as readonly string[]).includes(s)) return s as HelpSectionId;
|
||||
return 'overview';
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'presentation.overlay': 'Презентация запущена',
|
||||
'presentation.title': 'Презентация запущена',
|
||||
'presentation.body':
|
||||
'Редактор заблокирован. Закройте окна «Презентация» и «Панель управления», чтобы продолжить.',
|
||||
'Редактор заблокирован. На пульте нажмите «Завершить» и подтвердите, чтобы продолжить.',
|
||||
|
||||
'zip.progress': 'Прогресс операции',
|
||||
'zip.importTitle': 'Импорт проекта',
|
||||
@@ -111,6 +111,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'top.project': 'Проект',
|
||||
'top.file': 'Файл',
|
||||
'top.players': 'Игроки',
|
||||
'top.playlists': 'Плейлисты',
|
||||
'top.backToProjects': 'К списку проектов',
|
||||
'top.appVersion': 'Версия приложения',
|
||||
'top.run': 'Запустить',
|
||||
@@ -130,12 +131,21 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'menu.enterKey': 'Указать ключ',
|
||||
'menu.aboutLicense': 'О лицензии',
|
||||
'menu.checkUpdates': 'Проверить обновления',
|
||||
'menu.presentationSettings': 'Экран презентации',
|
||||
'menu.gameSystems': 'Игровые системы',
|
||||
'menu.language': 'Язык',
|
||||
'menu.langRu': 'Русский',
|
||||
'menu.langEn': 'English',
|
||||
'menu.aboutProgram': 'О программе',
|
||||
'menu.instructions': 'Инструкция',
|
||||
|
||||
'gameSystems.title': 'Игровые системы',
|
||||
'gameSystems.import': 'Импорт…',
|
||||
'gameSystems.preview': 'Просмотр',
|
||||
'gameSystems.remove': 'Удалить',
|
||||
'gameSystems.empty': 'Нет импортированных систем',
|
||||
'gameSystems.close': 'Закрыть',
|
||||
|
||||
'top.aboutApp': 'О приложении',
|
||||
|
||||
'app.about.title': 'О программе',
|
||||
@@ -150,6 +160,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'app.about.supportEmail': 'player.ttrpg@gmail.com',
|
||||
'app.about.websiteLabel': 'САЙТ И ОБНОВЛЕНИЯ',
|
||||
'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
|
||||
'app.about.sponsorsLabel': 'СПОНСОРЫ',
|
||||
|
||||
'help.title': 'Инструкция',
|
||||
'help.navAria': 'Разделы инструкции',
|
||||
@@ -180,7 +191,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.sceneProps.title': 'Свойства сцены',
|
||||
'help.section.sceneProps.body':
|
||||
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «Сюжетная линия».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF, видео и др.).\n\n3) Для картинки и видео можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки и видео можно включить «Затемнить сцену»: при показе игроки сначала увидят кадр в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Кнопка «Редактор сцены» доступна и для картинки, и для видео — сетка, ловушки, неигровые токены и токены НПС поверх превью (см. «Редактор сцены», «Генератор сетки», «Ловушки», «Неигровые токены», «НПС»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков, и при необходимости «Цикл».\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
|
||||
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «История».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF, видео и др.).\n\n3) Для картинки и видео можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки и видео можно включить «Затемнить сцену»: при показе игроки сначала увидят кадр в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Кнопка «Редактор сцены» доступна и для картинки, и для видео — сетка, ловушки, неигровые токены и токены НПС поверх превью (см. «Редактор сцены», «Генератор сетки», «Ловушки», «Неигровые токены», «НПС»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков, и при необходимости «Цикл».\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
|
||||
|
||||
'help.section.sceneEditor.title': 'Редактор сцены',
|
||||
'help.section.sceneEditor.body':
|
||||
@@ -188,7 +199,67 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.grid.title': 'Генератор сетки',
|
||||
'help.section.grid.body':
|
||||
'Генератор сетки накладывает на превью сцены (картинку или видео) боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nНастройки сетки хранятся в проекте вместе со сценой. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.\n\nВо время сессии на пульте можно включить «Привязка токенов к сетке»: при перетаскивании неигровые токены, токены НПС и игроков «прилипают» к клеткам (квадрат или гекс — по типу сетки). Если сетка выключена, галочка не действует.',
|
||||
'Генератор сетки накладывает на превью сцены (картинку или видео) боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nШаблоны сетки кампании:\n\n1) Настройте сетку на сцене.\n\n2) Нажмите «Сохранить как шаблон» и задайте имя — стиль (тип, цвет, размер) запишется в файл проекта. Автоматически на другие сцены он не подставляется.\n\n3) На любой сцене в списке шаблонов нажмите «Применить» — на сцену запишутся параметры шаблона и сетка включится.\n\n4) Шаблон можно переименовать или удалить. В кампании можно хранить несколько шаблонов (до 20).\n\nНастройки сетки каждой сцены и список шаблонов хранятся в проекте. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.\n\nВо время сессии на пульте можно включить «Привязка токенов к сетке»: при перетаскивании неигровые токены, токены НПС и игроков «прилипают» к клеткам (квадрат или гекс — по типу сетки). Если сетка выключена, галочка не действует.',
|
||||
|
||||
'sceneEditor.open': 'Редактор сцены',
|
||||
'sceneEditor.grid': 'Сетка',
|
||||
'sceneEditor.enableGrid': 'Наложить сетку',
|
||||
'sceneEditor.gridType': 'Тип',
|
||||
'sceneEditor.gridTypeAria': 'Тип сетки',
|
||||
'sceneEditor.gridSquare': 'Квадратная',
|
||||
'sceneEditor.gridHex': 'Гексогональная',
|
||||
'sceneEditor.gridColor': 'Цвет',
|
||||
'sceneEditor.gridColorAria': 'Цвет сетки',
|
||||
'sceneEditor.gridSize': 'Размер',
|
||||
'sceneEditor.npcs': 'НПС',
|
||||
'sceneEditor.npcSearch': 'Поиск НПС…',
|
||||
'sceneEditor.noNpcs': 'Нет НПС',
|
||||
'sceneEditor.tokens': 'Неигровые токены',
|
||||
'sceneEditor.add': 'Добавить',
|
||||
'sceneEditor.tokenSearch': 'Поиск…',
|
||||
'sceneEditor.noTokens': 'Нет токенов',
|
||||
'sceneEditor.traps': 'Ловушки',
|
||||
'sceneEditor.dragToMap': 'Перетащите на карту',
|
||||
'sceneEditor.clearScene': 'Очистить сцену',
|
||||
'sceneEditor.needMedia': 'Нужно изображение или видео сцены',
|
||||
'sceneEditor.mapHint': 'Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.',
|
||||
'sceneEditor.untitledScene': 'Сцена',
|
||||
'sceneEditor.deleteTokenTitle': 'Удалить токен',
|
||||
'sceneEditor.deleteTokenBody': 'Удалить токен «{name}» из пула? Он также будет убран с текущей сцены.',
|
||||
'sceneEditor.setPath': 'Указать движение',
|
||||
'sceneEditor.resetPath': 'Сбросить на старт пути',
|
||||
'sceneEditor.trap.mimic': 'Мимик',
|
||||
'sceneEditor.trap.explosion': 'Взрыв',
|
||||
'sceneEditor.trap.poison': 'Яд',
|
||||
'sceneEditor.trap.pit': 'Пропасть',
|
||||
'sceneEditor.trap.arrow': 'Стрела',
|
||||
'sceneEditor.trap.laser': 'Лазер',
|
||||
'sceneEditor.trap.freeform': 'Метка',
|
||||
'sceneEditor.editToken': 'Изменить токен',
|
||||
'sceneEditor.addToken': 'Добавить токен',
|
||||
'sceneEditor.tokenName': 'Название',
|
||||
'sceneEditor.tokenNamePlaceholder': 'Название токена',
|
||||
'sceneEditor.tokenNameRequired': 'Укажите название',
|
||||
'sceneEditor.tokenNameDuplicate': 'Такое название уже есть',
|
||||
'sceneEditor.image': 'Изображение',
|
||||
'sceneEditor.dropImage': 'Отпустите, чтобы загрузить изображение',
|
||||
'sceneEditor.dropOrPick': 'Перетащите изображение или выберите файл',
|
||||
'sceneEditor.chooseImage': 'Выбрать изображение',
|
||||
'sceneEditor.imageRequired': 'Нужно изображение',
|
||||
'sceneEditor.saveFailed': 'Не удалось сохранить',
|
||||
'sceneEditor.dragDelete': 'Перетащите · ПКМ — удалить',
|
||||
'sceneEditor.rotate': 'Повернуть',
|
||||
'sceneEditor.actions': 'Действия',
|
||||
'sceneEditor.edit': 'Изменить',
|
||||
'sceneGrid.templates': 'Шаблоны кампании',
|
||||
'sceneGrid.saveTemplate': 'Сохранить как шаблон',
|
||||
'sceneGrid.applyTemplate': 'Применить',
|
||||
'sceneGrid.renameTemplate': 'Переименовать',
|
||||
'sceneGrid.deleteTemplate': 'Удалить',
|
||||
'sceneGrid.deleteTemplateTitle': 'Удаление шаблона сетки',
|
||||
'sceneGrid.deleteTemplateConfirm': 'Удалить шаблон «{name}»?',
|
||||
'sceneGrid.emptyTemplates': 'Пока нет шаблонов. Сохраните текущие настройки сетки.',
|
||||
'sceneGrid.templatesFull': 'Достигнут лимит шаблонов ({max}).',
|
||||
|
||||
'help.section.traps.title': 'Ловушки',
|
||||
'help.section.traps.body':
|
||||
@@ -215,15 +286,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.session.title': 'Запуск сессии',
|
||||
'help.section.session.body':
|
||||
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео, эффекты, токены и материалы — без ваших кнопок.',
|
||||
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nЭкран для игроков можно заранее выбрать в «Настройки» → «Экран презентации»: монитор и полноэкранный режим. При «Запустить» окно презентации откроется на выбранном экране автоматически.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Завершить» (у предпросмотра или в блоке ветвления, если переходов нет). Тот же вопрос задают Escape и закрытие окна презентации или пульта.\n\n2) В окне «Завершить показ?» нажмите «Завершить» и дождитесь закрытия обоих окон.',
|
||||
|
||||
'help.section.controlPanel.title': 'Пульт управления',
|
||||
'help.section.controlPanel.body':
|
||||
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы». Кнопка НПС (цветная иконка человека) открывает окно персонажей: карточки описания, чекбокс «Показать» для аватара поверх сцены и постановка токена на карту на время сессии — см. раздел «НПС».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии. Линии путей движения видны вам всегда; игрокам — только после «Показать путь» в меню токена. На превью можно перетащить плитку из окна НПС — появится сессионный токен. ПКМ по неигровому токену — показать / скрыть путь, остановить / продолжить движение, сброс на старт. ПКМ по токену НПС — активировать / открыть информацию / сменить тип / сделать неактивным / убрать с карты / путь (см. «НПС» и «Неигровые токены»). Аватары с чекбокса «Показать» лежат поверх сцены отдельно от токенов на карте: их двигают, масштабируют лупами из окна НПС и закрывают «Закрыть всех». На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размеры игр. токенов» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
|
||||
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты, «История» и «Сцены»; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты»: маркер (рисунок на карте), книга (описание текущей сцены; если описания нет, кнопка неактивна и подсказка «Описание отсутствует»), материалы (см. «Материалы»), плейлисты, таймер (обратный отсчёт: «Показать игрокам» выводит виджет на предпросмотр и презентацию), инициатива (очередь хода), кубики, НПС (карточки, чекбокс «Показать» для аватара и постановка токена на карту — см. «НПС») и «?» («Справка»: открывает этот раздел, не закрывая показ).\n\n«Предпросмотр экрана» показывает то же, что видят игроки. Здесь же рисуют эффекты — они сразу появляются на большом экране (и на картинке, и на видео). Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены, токены НПС и игроков тоже видны на предпросмотре: их можно двигать и менять размер за жёлтый уголок до конца сессии. Линии путей движения видны вам всегда; игрокам — только после «Показать путь» в меню токена. На превью можно перетащить плитку из окна НПС — появится сессионный токен. ПКМ по неигровому токену — показать / скрыть путь, остановить / продолжить движение, сброс на старт. ПКМ по токену НПС — скрыть от игроков или показать игрокам (на пульте токен остаётся), активировать / открыть информацию / сменить тип / сделать неактивным / убрать с карты / путь (см. «НПС» и «Неигровые токены»). Аватары с чекбокса «Показать» лежат поверх сцены отдельно от токенов на карте: их двигают, масштабируют лупами из окна НПС и закрывают «Закрыть всех». На видео-сценах внизу превью остаются кнопки воспроизведения и полоса перемотки.\n\nНад превью:\n• «Привязка токенов к сетке» — если на сцене включена сетка, перетаскиваемые токены (неигровые, НПС, игроки) встают по клеткам;\n• «Размер токенов игроков» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«История» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю. «Сцены» — сцены текущей линии, чтобы перейти к любой из них.\n\n«Завершить» у предпросмотра и в конце ветки (когда переходов нет) спрашивает подтверждение и после него закрывает показ. Escape на пульте и на презентации, а также закрытие этих окон, задают тот же вопрос.',
|
||||
|
||||
'help.section.transitions.title': 'Переходы между сценами',
|
||||
'help.section.transitions.body':
|
||||
'Куда можно пойти дальше — видно на пульте в «Варианты ветвления». Это исходящие стрелки с текущей карточки на карте.\n\n1) Посмотрите карточки «ОПЦИЯ 1», «ОПЦИЯ 2» — там названия целевых сцен.\n\n2) Нажмите «Переключить» у нужного варианта.\n\n3) На презентации сменятся картинка и музыка, в сюжетной линии появится новый шаг.\n\nЕсли вариантов нет (конец ветки) — «Нет вариантов перехода». Нажмите «Завершить показ», чтобы закрыть окна.\n\nВарианты появляются только там, где вы провели стрелки на карте в редакторе.',
|
||||
'Куда можно пойти дальше — видно на пульте в «Варианты ветвления». Это исходящие стрелки с текущей карточки на карте.\n\n1) Посмотрите карточки «ОПЦИЯ 1», «ОПЦИЯ 2» — там названия целевых сцен.\n\n2) Нажмите «Переключить» у нужного варианта.\n\n3) На презентации сменятся картинка и музыка, в сюжетной линии появится новый шаг.\n\nЕсли вариантов нет (конец ветки) — «Нет вариантов перехода». Нажмите «Завершить», чтобы закрыть окна.\n\nВарианты появляются только там, где вы провели стрелки на карте в редакторе.',
|
||||
|
||||
'help.section.music.title': 'Музыка на пульте',
|
||||
'help.section.music.body':
|
||||
@@ -235,7 +306,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.presentation.title': 'Экран презентации',
|
||||
'help.section.presentation.body':
|
||||
'«Презентация» — то, что видят игроки: картинка или видео сцены (с учётом поворота из редактора) по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны. Токены на карте и большие аватары НПС с чекбокса «Показать» видны, но не интерактивны. Линию пути игроки видят только если мастер включил «Показать путь».\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
|
||||
'«Презентация» — то, что видят игроки: картинка или видео сцены (с учётом поворота из редактора) по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны. Токены на карте и большие аватары НПС с чекбокса «Показать» видны, но не интерактивны. Линию пути игроки видят только если мастер включил «Показать путь».\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Монитор и полноэкранный режим задаются в «Настройки» → «Экран презентации»; при запуске сессии окно открывается на выбранном экране.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
|
||||
|
||||
'help.section.importExport.title': 'Импорт и экспорт',
|
||||
'help.section.importExport.body':
|
||||
@@ -243,7 +314,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.settings.title': 'Настройки, язык и обновления',
|
||||
'help.section.settings.body':
|
||||
'«Настройки» → «Указать ключ» — ввести или сменить лицензию. «О лицензии» — проверить статус и срок.\n\n«Проверить обновления» (в установленной версии при активной лицензии) — если есть новая версия, можно скачать и перезапустить программу.\n\n«Язык» → «Русский» или «English» — меняет язык интерфейса. Выбор сохраняется между запусками.\n\nНомер версии — в шапке справа от меню. «О приложении» → «О программе» — о продукте и контактах поддержки.',
|
||||
'«Настройки» → «Указать ключ» — ввести или сменить лицензию. «О лицензии» — проверить статус и срок.\n\n«Экран презентации» — выбрать монитор для окна игроков и включить или выключить полноэкранный режим. Настройки сохраняются и применяются при каждом «Запустить».\n\n«Проверить обновления» (в установленной версии при активной лицензии) — если есть новая версия, можно скачать и перезапустить программу.\n\n«Язык» → «Русский» или «English» — меняет язык интерфейса. Выбор сохраняется между запусками.\n\nНомер версии — в шапке справа от меню, сразу за ним иконка «?» («Справка»). «О приложении» открывает окно «О программе» — о продукте и контактах поддержки.',
|
||||
|
||||
'settings.presentation.title': 'Экран презентации',
|
||||
'settings.presentation.hint':
|
||||
'При запуске сессии окно «Презентация» откроется на выбранном мониторе. Пульт управления всегда открывается на основном экране.',
|
||||
'settings.presentation.display': 'Монитор',
|
||||
'settings.presentation.fullScreen': 'Полноэкранный режим',
|
||||
'settings.presentation.primary': 'основной',
|
||||
'settings.presentation.loading': 'Загрузка списка мониторов…',
|
||||
|
||||
'updates.dialogTitle': 'Обновления',
|
||||
'updates.checking': 'Проверка наличия обновлений…',
|
||||
@@ -316,6 +395,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'export.npcsHint':
|
||||
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
|
||||
'export.selectAllNpcs': 'Отметить всех',
|
||||
'export.playlists': 'Плейлисты',
|
||||
'export.playlistsHint': 'По умолчанию не выбраны. Отметьте плейлисты, которые нужно упаковать в архив.',
|
||||
'export.next': 'Далее',
|
||||
'export.back': 'Назад',
|
||||
'export.exporting': 'Экспорт…',
|
||||
@@ -389,6 +470,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'campaign.noFiles': 'Файлов пока нет. Добавьте аудио.',
|
||||
'campaign.auto': 'Авто',
|
||||
'campaign.loop': 'Цикл',
|
||||
'campaign.volume': 'Громк.',
|
||||
'campaign.preview': 'Прослушать',
|
||||
'campaign.previewStop': 'Стоп',
|
||||
'campaign.removeTitle': 'Убрать из кампании',
|
||||
'campaign.upload': 'Загрузить',
|
||||
'drop.hintAudio': 'Перетащите аудиофайлы сюда',
|
||||
@@ -450,6 +534,31 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'players.deleteTeamTitle': 'Удаление команды',
|
||||
'players.deleteTeamConfirm': 'Вы уверены, что хотите удалить команду «{name}»? Игроки останутся без команды.',
|
||||
|
||||
'playlists.title': 'Плейлисты',
|
||||
'playlists.search': 'Поиск по плейлистам и трекам…',
|
||||
'playlists.create': 'Создать',
|
||||
'playlists.rename': 'Переименовать',
|
||||
'playlists.duplicate': 'Дублировать',
|
||||
'playlists.delete': 'Удалить',
|
||||
'playlists.deleteTitle': 'Удаление плейлиста',
|
||||
'playlists.deleteConfirm':
|
||||
'Удалить плейлист «{name}»? Если он сейчас играет — воспроизведение остановится.',
|
||||
'playlists.addAudio': 'Добавить аудио',
|
||||
'playlists.empty': 'Нет плейлистов. Создайте первый.',
|
||||
'playlists.emptyTracks': 'В плейлисте пока нет треков.',
|
||||
'playlists.dropAudioHint': 'Перетащите аудиофайлы сюда',
|
||||
'playlists.untitled': 'Без названия',
|
||||
'playlists.play': 'Играть',
|
||||
'playlists.pause': 'Пауза',
|
||||
'playlists.prev': 'Предыдущий',
|
||||
'playlists.next': 'Следующий',
|
||||
'playlists.volume': 'Громкость',
|
||||
'playlists.scrub': 'Перемотка',
|
||||
'playlists.emptyPlayer': 'Ничего не играет',
|
||||
'playlists.playPlaylist': 'Играть плейлист',
|
||||
'playlists.removeTrack': 'Убрать',
|
||||
'playlists.renameAudio': 'Переименовать трек',
|
||||
|
||||
'npcs.open': 'НПС',
|
||||
'npcs.editorTitle': 'НПС',
|
||||
'npcs.add': 'Добавить',
|
||||
@@ -485,6 +594,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.makeFriendly': 'Сделать дружественным',
|
||||
'npcs.inactive': 'Неактивен',
|
||||
'npcs.makeActive': 'Сделать активным',
|
||||
'npcs.hideFromPlayers': 'Скрыть от игроков',
|
||||
'npcs.showToPlayers': 'Показать игрокам',
|
||||
'npcs.openInfo': 'Открыть информацию',
|
||||
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
|
||||
'npcs.description': 'ОПИСАНИЕ',
|
||||
@@ -606,9 +717,68 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
|
||||
'control.instruments': 'ИНСТРУМЕНТЫ',
|
||||
'control.helpTool': 'Справка',
|
||||
'control.previewPanHint': 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан',
|
||||
'control.descriptionTool': 'Описание',
|
||||
'control.markerTool': 'Маркер',
|
||||
'control.markerColor': 'Цвет маркера',
|
||||
'control.materialsTool': 'Материалы',
|
||||
'control.playlistsTool': 'Плейлисты',
|
||||
'control.timerTool': 'Таймер',
|
||||
'control.initiativeTool': 'Инициатива',
|
||||
'control.diceTool': 'Кубики',
|
||||
'control.npcsTool': 'НПС',
|
||||
|
||||
'dice.sides': 'Кубик',
|
||||
'dice.count': 'Количество',
|
||||
'dice.roll': 'Бросить',
|
||||
'dice.clear': 'Убрать',
|
||||
'dice.result': 'Результат',
|
||||
'dice.total': 'Сумма',
|
||||
'dice.rolling': 'Анимация на экране…',
|
||||
'dice.noResult': 'Пока нет броска',
|
||||
'dice.inc': 'Больше',
|
||||
'dice.dec': 'Меньше',
|
||||
'dice.showAnimationToPlayers': 'Показать анимацию игрокам',
|
||||
|
||||
'sessionTimer.duration': 'Длительность (MM:SS)',
|
||||
'sessionTimer.start': 'Старт',
|
||||
'sessionTimer.pause': 'Пауза',
|
||||
'sessionTimer.reset': 'Сброс',
|
||||
'sessionTimer.showToPlayers': 'Показать игрокам',
|
||||
'sessionTimer.positionHint':
|
||||
'Когда показ включён, перетащите таймер на предпросмотре пульта; углы — размер, ручка сверху — поворот.',
|
||||
|
||||
'initiative.next': 'Следующий',
|
||||
'initiative.queue': 'Очередь',
|
||||
'initiative.queueEmpty': 'Очередь пуста — добавьте вручную или разместите НПС на сцене',
|
||||
'initiative.reserve': 'Запас',
|
||||
'initiative.reserveEmpty': 'Запас пуст',
|
||||
'initiative.addManual': 'Добавить',
|
||||
'initiative.manualPlaceholder': 'Имя вручную',
|
||||
'initiative.toReserve': 'В запас',
|
||||
'initiative.restore': 'Вернуть',
|
||||
'initiative.delete': 'Удалить',
|
||||
'initiative.conditions': 'Состояния',
|
||||
'initiative.conditionsEmpty': 'Все состояния уже добавлены',
|
||||
'initiative.removeCondition': 'Убрать состояние',
|
||||
'initiative.condition.frightened': 'Испуган',
|
||||
'initiative.condition.exhaustion': 'Истощение',
|
||||
'initiative.condition.concentration': 'Концентрация',
|
||||
'initiative.condition.invisible': 'Невидимый',
|
||||
'initiative.condition.incapacitated': 'Недееспособен',
|
||||
'initiative.condition.petrified': 'Окаменевший',
|
||||
'initiative.condition.restrained': 'Опутан',
|
||||
'initiative.condition.blinded': 'Ослеплён',
|
||||
'initiative.condition.poisoned': 'Отравлен',
|
||||
'initiative.condition.charmed': 'Очарован',
|
||||
'initiative.condition.stunned': 'Ошеломлён',
|
||||
'initiative.condition.paralyzed': 'Парализован',
|
||||
'initiative.condition.prone': 'Сбит с ног',
|
||||
'initiative.condition.grappled': 'Схвачен',
|
||||
'initiative.condition.unconscious': 'Без сознания',
|
||||
'initiative.condition.deafened': 'Оглохший',
|
||||
|
||||
'control.descriptionMissing': 'Описание отсутствует',
|
||||
'control.effects': 'ЭФФЕКТЫ',
|
||||
'control.tools': 'Очистка',
|
||||
@@ -630,18 +800,25 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.poisonCloud': 'Облако яда',
|
||||
'control.explosion': 'Взрыв',
|
||||
'control.brushRadius': 'Радиус кисти',
|
||||
'control.brushShape': 'Форма кисти',
|
||||
'control.brushShapeCircle': 'Круглая',
|
||||
'control.brushShapeSquare': 'Квадратная',
|
||||
'control.effectsSound': 'Звук эффектов',
|
||||
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
|
||||
'control.storyLine': 'ИСТОРИЯ',
|
||||
'control.scenesAccordion': 'СЦЕНЫ',
|
||||
'control.gotoScene': 'Перейти к этой сцене',
|
||||
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
|
||||
'control.passed': 'Пройдено',
|
||||
'control.noActiveScene': 'Нет активной сцены.',
|
||||
'control.screenPreview': 'Предпросмотр экрана',
|
||||
'control.npcTokenScale': 'Размеры игр. токенов',
|
||||
'control.npcTokenScale': 'Размер токенов игроков',
|
||||
'control.resizeToken': 'Изменить размер',
|
||||
'control.snapTokensToGrid': 'Привязка токенов к сетке',
|
||||
'control.snapTokensToGridNoGrid': 'Сетка на текущей сцене выключена — привязка не применяется',
|
||||
'control.stopPresentation': 'Выключить',
|
||||
'control.stopPresentation': 'Завершить',
|
||||
'control.stopConfirm.title': 'Завершить показ?',
|
||||
'control.stopConfirm.body': 'Закрыть пульт и презентацию и вернуться в редактор?',
|
||||
'control.stopConfirm.confirm': 'Завершить',
|
||||
'control.showPlayers': 'Показать игроков',
|
||||
'control.hidePlayers': 'Скрыть игроков',
|
||||
'control.videoBrushHint':
|
||||
@@ -653,7 +830,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.unnamed': 'Без названия',
|
||||
'control.switchScene': 'Переключить',
|
||||
'control.noBranches': 'Нет вариантов перехода.',
|
||||
'control.endPresentation': 'Завершить показ',
|
||||
'control.endPresentation': 'Завершить',
|
||||
'control.music': 'Музыка',
|
||||
'control.sceneMusic': 'МУЗЫКА СЦЕНЫ',
|
||||
'control.gameMusic': 'МУЗЫКА ИГРЫ',
|
||||
@@ -736,7 +913,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'presentation.overlay': 'Presentation running',
|
||||
'presentation.title': 'Presentation running',
|
||||
'presentation.body': 'The editor is locked. Close the Presentation and Control windows to continue.',
|
||||
'presentation.body': 'The editor is locked. On the control panel click End and confirm to continue.',
|
||||
|
||||
'zip.progress': 'Operation progress',
|
||||
'zip.importTitle': 'Import project',
|
||||
@@ -746,6 +923,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'top.project': 'Project',
|
||||
'top.file': 'File',
|
||||
'top.players': 'Players',
|
||||
'top.playlists': 'Playlists',
|
||||
'top.backToProjects': 'Back to projects',
|
||||
'top.appVersion': 'App version',
|
||||
'top.run': 'Run',
|
||||
@@ -765,12 +943,21 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'menu.enterKey': 'Enter license key',
|
||||
'menu.aboutLicense': 'About license',
|
||||
'menu.checkUpdates': 'Check for updates',
|
||||
'menu.presentationSettings': 'Presentation display',
|
||||
'menu.gameSystems': 'Game systems',
|
||||
'menu.language': 'Language',
|
||||
'menu.langRu': 'Русский',
|
||||
'menu.langEn': 'English',
|
||||
'menu.aboutProgram': 'About',
|
||||
'menu.instructions': 'Instructions',
|
||||
|
||||
'gameSystems.title': 'Game systems',
|
||||
'gameSystems.import': 'Import…',
|
||||
'gameSystems.preview': 'Preview',
|
||||
'gameSystems.remove': 'Remove',
|
||||
'gameSystems.empty': 'No imported systems',
|
||||
'gameSystems.close': 'Close',
|
||||
|
||||
'top.aboutApp': 'About',
|
||||
|
||||
'app.about.title': 'About',
|
||||
@@ -785,13 +972,14 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'app.about.supportEmail': 'player.ttrpg@gmail.com',
|
||||
'app.about.websiteLabel': 'WEBSITE & UPDATES',
|
||||
'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
|
||||
'app.about.sponsorsLabel': 'SPONSORS',
|
||||
|
||||
'help.title': 'Instructions',
|
||||
'help.navAria': 'Instruction sections',
|
||||
|
||||
'help.section.overview.title': 'App overview',
|
||||
'help.section.overview.body':
|
||||
'When you launch the app, you land in the Editor — where you prepare your campaign: scenes, images, music, and how episodes connect. When it is time to play, click Run. That opens Presentation for your players and the Control panel for you.\n\nIn the editor, the left column lists scenes, the center shows the story map, and the right column holds game and scene settings. Game properties include NPCs, materials, and campaign audio; the header has Players.\n\nWhile a session is running, the editor is temporarily locked — that is normal. Close presentation and the control panel to edit again.\n\nInternet is only needed for license activation and update checks. All projects and files stay on your computer.',
|
||||
'When you launch the app, you land in the Editor — where you prepare your campaign: scenes, images, music, and how episodes connect. When it is time to play, click Run. That opens Presentation for your players and the Control panel for you.\n\nIn the editor, the left column lists scenes, the center shows the story map, and the right column holds game and scene settings. Game properties include NPCs, materials, and campaign audio; the header has Players.\n\nWhile a session is running, the editor is temporarily locked — that is normal. On the control panel click End and confirm to edit again.\n\nInternet is only needed for license activation and update checks. All projects and files stay on your computer.',
|
||||
|
||||
'help.section.license.title': 'License and first launch',
|
||||
'help.section.license.body':
|
||||
@@ -815,7 +1003,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.sceneProps.title': 'Scene properties',
|
||||
'help.section.sceneProps.body':
|
||||
'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the Storyline list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, video, etc.).\n\n3) For images and video, use Rotate (90° steps). Clear removes the preview.\n\n4) For images and video, enable Darken scene so players start in full darkness and you reveal the frame with the Opening brush on the control panel (see Effects).\n\n5) Scene editor works for both images and video — battle grid, traps, non-player tokens, and NPC tokens over the preview (see Scene editor, Grid generator, Traps, Non-player tokens, and NPCs).\n\nFor video, enable Autostart if the clip should start on its own on the player screen, and Loop if needed.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
|
||||
'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the History list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, video, etc.).\n\n3) For images and video, use Rotate (90° steps). Clear removes the preview.\n\n4) For images and video, enable Darken scene so players start in full darkness and you reveal the frame with the Opening brush on the control panel (see Effects).\n\n5) Scene editor works for both images and video — battle grid, traps, non-player tokens, and NPC tokens over the preview (see Scene editor, Grid generator, Traps, Non-player tokens, and NPCs).\n\nFor video, enable Autostart if the clip should start on its own on the player screen, and Loop if needed.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
|
||||
|
||||
'help.section.sceneEditor.title': 'Scene editor',
|
||||
'help.section.sceneEditor.body':
|
||||
@@ -823,7 +1011,67 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.grid.title': 'Grid generator',
|
||||
'help.section.grid.body':
|
||||
'The grid generator overlays a battle grid on the scene preview (image or video) — square or hexagonal. It helps track cells in combat and is visible both on your control panel and on the players’ presentation.\n\nSet it up:\n\n1) Open Scene editor (see Scene editor).\n\n2) Expand the Grid accordion on the left.\n\n3) Enable Overlay grid — lines appear over the map.\n\n4) Type — Square or Hexagonal.\n\n5) Color — line tint (pick contrast that suits the map).\n\n6) Size — cell size slider: higher values mean larger cells.\n\nWhile the grid is off, type, color, and size stay disabled, but the saved values return when you turn it back on.\n\nGrid settings are stored with the scene in the project. The grid draws under trap and token markers and does not block placing them.\n\nDuring a session you can enable Snap tokens to grid on the control panel: dragging non-player tokens, NPC tokens, and player tokens snaps them to cells (square or hex, matching the grid type). If the grid is off, the checkbox has no effect.',
|
||||
'The grid generator overlays a battle grid on the scene preview (image or video) — square or hexagonal. It helps track cells in combat and is visible both on your control panel and on the players’ presentation.\n\nSet it up:\n\n1) Open Scene editor (see Scene editor).\n\n2) Expand the Grid accordion on the left.\n\n3) Enable Overlay grid — lines appear over the map.\n\n4) Type — Square or Hexagonal.\n\n5) Color — line tint (pick contrast that suits the map).\n\n6) Size — cell size slider: higher values mean larger cells.\n\nWhile the grid is off, type, color, and size stay disabled, but the saved values return when you turn it back on.\n\nCampaign grid templates:\n\n1) Configure the grid on a scene.\n\n2) Click Save as template and name it — type, color, and size are stored in the project file. Nothing is applied to other scenes automatically.\n\n3) On any scene, click Apply on a template — that scene gets the template style and the grid turns on.\n\n4) Rename or delete templates anytime. A campaign can keep several templates (up to 20).\n\nPer-scene grid settings and the template list live in the project. The grid draws under trap and token markers and does not block placing them.\n\nDuring a session you can enable Snap tokens to grid on the control panel: dragging non-player tokens, NPC tokens, and player tokens snaps them to cells (square or hex, matching the grid type). If the grid is off, the checkbox has no effect.',
|
||||
|
||||
'sceneEditor.open': 'Scene editor',
|
||||
'sceneEditor.grid': 'Grid',
|
||||
'sceneEditor.enableGrid': 'Show grid',
|
||||
'sceneEditor.gridType': 'Type',
|
||||
'sceneEditor.gridTypeAria': 'Grid type',
|
||||
'sceneEditor.gridSquare': 'Square',
|
||||
'sceneEditor.gridHex': 'Hexagonal',
|
||||
'sceneEditor.gridColor': 'Color',
|
||||
'sceneEditor.gridColorAria': 'Grid color',
|
||||
'sceneEditor.gridSize': 'Size',
|
||||
'sceneEditor.npcs': 'NPCs',
|
||||
'sceneEditor.npcSearch': 'Search NPCs…',
|
||||
'sceneEditor.noNpcs': 'No NPCs',
|
||||
'sceneEditor.tokens': 'Non-player tokens',
|
||||
'sceneEditor.add': 'Add',
|
||||
'sceneEditor.tokenSearch': 'Search…',
|
||||
'sceneEditor.noTokens': 'No tokens',
|
||||
'sceneEditor.traps': 'Traps',
|
||||
'sceneEditor.dragToMap': 'Drag onto the map',
|
||||
'sceneEditor.clearScene': 'Clear scene',
|
||||
'sceneEditor.needMedia': 'The scene needs an image or video',
|
||||
'sceneEditor.mapHint': 'Wheel — zoom. Middle button / Space+left — pan. Delete — remove the selection.',
|
||||
'sceneEditor.untitledScene': 'Scene',
|
||||
'sceneEditor.deleteTokenTitle': 'Delete token',
|
||||
'sceneEditor.deleteTokenBody': 'Delete token “{name}” from the library? It will also be removed from the current scene.',
|
||||
'sceneEditor.setPath': 'Set movement',
|
||||
'sceneEditor.resetPath': 'Reset to path start',
|
||||
'sceneEditor.trap.mimic': 'Mimic',
|
||||
'sceneEditor.trap.explosion': 'Explosion',
|
||||
'sceneEditor.trap.poison': 'Poison',
|
||||
'sceneEditor.trap.pit': 'Pit',
|
||||
'sceneEditor.trap.arrow': 'Arrow',
|
||||
'sceneEditor.trap.laser': 'Laser',
|
||||
'sceneEditor.trap.freeform': 'Marker',
|
||||
'sceneEditor.editToken': 'Edit token',
|
||||
'sceneEditor.addToken': 'Add token',
|
||||
'sceneEditor.tokenName': 'Name',
|
||||
'sceneEditor.tokenNamePlaceholder': 'Token name',
|
||||
'sceneEditor.tokenNameRequired': 'Enter a name',
|
||||
'sceneEditor.tokenNameDuplicate': 'That name is already used',
|
||||
'sceneEditor.image': 'Image',
|
||||
'sceneEditor.dropImage': 'Drop to upload the image',
|
||||
'sceneEditor.dropOrPick': 'Drop an image or choose a file',
|
||||
'sceneEditor.chooseImage': 'Choose image',
|
||||
'sceneEditor.imageRequired': 'An image is required',
|
||||
'sceneEditor.saveFailed': 'Could not save',
|
||||
'sceneEditor.dragDelete': 'Drag · right-click to delete',
|
||||
'sceneEditor.rotate': 'Rotate',
|
||||
'sceneEditor.actions': 'Actions',
|
||||
'sceneEditor.edit': 'Edit',
|
||||
'sceneGrid.templates': 'Campaign templates',
|
||||
'sceneGrid.saveTemplate': 'Save as template',
|
||||
'sceneGrid.applyTemplate': 'Apply',
|
||||
'sceneGrid.renameTemplate': 'Rename',
|
||||
'sceneGrid.deleteTemplate': 'Delete',
|
||||
'sceneGrid.deleteTemplateTitle': 'Delete grid template',
|
||||
'sceneGrid.deleteTemplateConfirm': 'Delete template “{name}”?',
|
||||
'sceneGrid.emptyTemplates': 'No templates yet. Save the current grid settings.',
|
||||
'sceneGrid.templatesFull': 'Template limit reached ({max}).',
|
||||
|
||||
'help.section.traps.title': 'Traps',
|
||||
'help.section.traps.body':
|
||||
@@ -850,15 +1098,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.session.title': 'Starting a session',
|
||||
'help.section.session.body':
|
||||
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Turn off or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, effects, tokens, and materials — not your buttons.',
|
||||
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nChoose the player screen ahead of time in Settings → Presentation display: pick a monitor and fullscreen mode. When you click Run, the presentation window opens on that display automatically.\n\nReturn to prep:\n\n1) On the control panel click End (by the preview, or in the branch block when there is nowhere left to go). Escape and closing the presentation or control window ask the same question.\n\n2) In the End presentation? dialog click End and wait until both windows close.',
|
||||
|
||||
'help.section.controlPanel.title': 'Control panel',
|
||||
'help.section.controlPanel.body':
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The NPCs button (colored person icon) opens the characters window: description cards, the Show checkbox for an avatar over the scene, and placing a token on the map for this session — see NPCs.\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends. Path lines are always visible to you; players see a line only after Show path in the token menu. You can drag a tile from the NPCs window onto the preview to spawn a session token. Right-click a non-player token to show / hide path, stop / resume movement, or reset to start. Right-click an NPC token to activate / open information / change type / make inactive / remove from map / set a path (see NPCs and Non-player tokens). Avatars from the Show checkbox sit over the scene separately from map tokens: drag them, zoom with the NPCs window magnifiers, and clear them with Close all. On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Play token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects, History, and Scenes. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools: marker (draw on the map), book (current scene description; if there is none the button is disabled and the tooltip reads “No description”), materials (see Materials), playlists, timer (countdown: Show to players places the widget on the preview and presentation), initiative (turn order), dice, NPCs (cards, the Show checkbox for an avatar, and placing a token on the map — see NPCs), and “?” (Help: opens this section without ending the show).\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player, NPC, and player tokens are also visible on the preview: you can move them and resize them from the yellow corner until the session ends. Path lines are always visible to you; players see a line only after Show path in the token menu. You can drag a tile from the NPCs window onto the preview to spawn a session token. Right-click a non-player token to show / hide path, stop / resume movement, or reset to start. Right-click an NPC token to hide it from players or show it to players (it stays on the control panel), activate / open information / change type / make inactive / remove from map / set a path (see NPCs and Non-player tokens). Avatars from the Show checkbox sit over the scene separately from map tokens: drag them, zoom with the NPCs window magnifiers, and clear them with Close all. On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Player token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\n\nHistory lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry. Scenes lists scenes on the current line so you can jump to any of them.\n\nEnd by the preview and at the end of a branch (when there is nowhere left to go) asks for confirmation and then closes the show. Escape on the control panel and on presentation, and closing those windows, ask the same question.',
|
||||
|
||||
'help.section.transitions.title': 'Scene transitions',
|
||||
'help.section.transitions.body':
|
||||
'Where you can go next appears under Branch options on the control panel. These are outgoing arrows from the current card on the map.\n\n1) Read the OPTION 1, OPTION 2 cards — they show target scene names.\n\n2) Click Switch on the choice you want.\n\n3) Presentation updates image and music; storyline adds a new step.\n\nIf there are no options (end of a branch), you will see No transitions available. Click End presentation to close the show windows.\n\nOptions only exist where you drew arrows on the map in the editor.',
|
||||
'Where you can go next appears under Branch options on the control panel. These are outgoing arrows from the current card on the map.\n\n1) Read the OPTION 1, OPTION 2 cards — they show target scene names.\n\n2) Click Switch on the choice you want.\n\n3) Presentation updates image and music; storyline adds a new step.\n\nIf there are no options (end of a branch), you will see No transitions available. Click End to close the show windows.\n\nOptions only exist where you drew arrows on the map in the editor.',
|
||||
|
||||
'help.section.music.title': 'Music on the control panel',
|
||||
'help.section.music.body':
|
||||
@@ -870,7 +1118,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.presentation.title': 'Presentation screen',
|
||||
'help.section.presentation.body':
|
||||
'Presentation is what players see: the scene image or video (with rotation from the editor) according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens. Map tokens and large NPC avatars from the Show checkbox are visible but not interactive. Players see a path line only if the GM turned on Show path.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
|
||||
'Presentation is what players see: the scene image or video (with rotation from the editor) according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens. Map tokens and large NPC avatars from the Show checkbox are visible but not interactive. Players see a path line only if the GM turned on Show path.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Monitor and fullscreen mode are set in Settings → Presentation display; when you start a session, the window opens on that display.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
|
||||
|
||||
'help.section.importExport.title': 'Import and export',
|
||||
'help.section.importExport.body':
|
||||
@@ -878,7 +1126,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.settings.title': 'Settings, language and updates',
|
||||
'help.section.settings.body':
|
||||
'Settings → Enter license key — add or change your license. About license — check status and expiry.\n\nCheck for updates (installed app with active license) — if a new version exists, you can download and restart.\n\nLanguage → Русский or English changes the interface. Your choice is saved between launches.\n\nThe version number is in the header, right of the menus. About → About opens product info and support contacts.',
|
||||
'Settings → Enter license key — add or change your license. About license — check status and expiry.\n\nPresentation display — choose the monitor for the player window and turn fullscreen on or off. Settings are saved and applied every time you click Run.\n\nCheck for updates (installed app with active license) — if a new version exists, you can download and restart.\n\nLanguage → Русский or English changes the interface. Your choice is saved between launches.\n\nThe version number is in the header, right of the menus, with a “?” (Help) button immediately after it. About opens the product and support window.',
|
||||
|
||||
'settings.presentation.title': 'Presentation display',
|
||||
'settings.presentation.hint':
|
||||
'When you start a session, the Presentation window opens on the selected monitor. The control panel always opens on your primary display.',
|
||||
'settings.presentation.display': 'Display',
|
||||
'settings.presentation.fullScreen': 'Fullscreen',
|
||||
'settings.presentation.primary': 'primary',
|
||||
'settings.presentation.loading': 'Loading displays…',
|
||||
|
||||
'updates.dialogTitle': 'Updates',
|
||||
'updates.checking': 'Checking for updates…',
|
||||
@@ -951,6 +1207,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'export.npcsHint':
|
||||
'Select NPCs to export. Relations between selected NPCs and their groups are included.',
|
||||
'export.selectAllNpcs': 'Select all',
|
||||
'export.playlists': 'Playlists',
|
||||
'export.playlistsHint': 'None selected by default. Check playlists to include in the archive.',
|
||||
'export.next': 'Next',
|
||||
'export.back': 'Back',
|
||||
'export.exporting': 'Exporting…',
|
||||
@@ -1025,6 +1283,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'campaign.noFiles': 'No files yet. Add audio.',
|
||||
'campaign.auto': 'Auto',
|
||||
'campaign.loop': 'Loop',
|
||||
'campaign.volume': 'Vol.',
|
||||
'campaign.preview': 'Preview',
|
||||
'campaign.previewStop': 'Stop',
|
||||
'campaign.removeTitle': 'Remove from campaign',
|
||||
'campaign.upload': 'Upload',
|
||||
'drop.hintAudio': 'Drop audio files here',
|
||||
@@ -1087,6 +1348,31 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'players.deleteTeamConfirm':
|
||||
'Are you sure you want to delete team “{name}”? Players will become ungrouped.',
|
||||
|
||||
'playlists.title': 'Playlists',
|
||||
'playlists.search': 'Search playlists and tracks…',
|
||||
'playlists.create': 'Create',
|
||||
'playlists.rename': 'Rename',
|
||||
'playlists.duplicate': 'Duplicate',
|
||||
'playlists.delete': 'Delete',
|
||||
'playlists.deleteTitle': 'Delete playlist',
|
||||
'playlists.deleteConfirm':
|
||||
'Delete playlist “{name}”? If it is playing, playback will stop.',
|
||||
'playlists.addAudio': 'Add audio',
|
||||
'playlists.empty': 'No playlists yet. Create one.',
|
||||
'playlists.emptyTracks': 'This playlist has no tracks yet.',
|
||||
'playlists.dropAudioHint': 'Drop audio files here',
|
||||
'playlists.untitled': 'Untitled',
|
||||
'playlists.play': 'Play',
|
||||
'playlists.pause': 'Pause',
|
||||
'playlists.prev': 'Previous',
|
||||
'playlists.next': 'Next',
|
||||
'playlists.volume': 'Volume',
|
||||
'playlists.scrub': 'Seek',
|
||||
'playlists.emptyPlayer': 'Nothing playing',
|
||||
'playlists.playPlaylist': 'Play playlist',
|
||||
'playlists.removeTrack': 'Remove',
|
||||
'playlists.renameAudio': 'Rename track',
|
||||
|
||||
'npcs.open': 'NPCs',
|
||||
'npcs.editorTitle': 'NPCs',
|
||||
'npcs.add': 'Add',
|
||||
@@ -1122,6 +1408,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.makeFriendly': 'Make Friendly',
|
||||
'npcs.inactive': 'Inactive',
|
||||
'npcs.makeActive': 'Make Active',
|
||||
'npcs.hideFromPlayers': 'Hide from players',
|
||||
'npcs.showToPlayers': 'Show to players',
|
||||
'npcs.openInfo': 'Open information',
|
||||
'npcs.ringColor': 'TOKEN RING COLOR',
|
||||
'npcs.description': 'DESCRIPTION',
|
||||
@@ -1243,9 +1531,68 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'control.remoteTitle': 'CONTROL PANEL',
|
||||
'control.instruments': 'TOOLS',
|
||||
'control.helpTool': 'Help',
|
||||
'control.previewPanHint': 'Wheel — zoom; middle/right drag or Space+left drag — pan',
|
||||
'control.descriptionTool': 'Description',
|
||||
'control.markerTool': 'Marker',
|
||||
'control.markerColor': 'Marker color',
|
||||
'control.materialsTool': 'Materials',
|
||||
'control.playlistsTool': 'Playlists',
|
||||
'control.timerTool': 'Timer',
|
||||
'control.initiativeTool': 'Initiative',
|
||||
'control.diceTool': 'Dice',
|
||||
'control.npcsTool': 'NPCs',
|
||||
|
||||
'dice.sides': 'Die',
|
||||
'dice.count': 'Count',
|
||||
'dice.roll': 'Roll',
|
||||
'dice.clear': 'Clear',
|
||||
'dice.result': 'Result',
|
||||
'dice.total': 'Total',
|
||||
'dice.rolling': 'Animating on screen…',
|
||||
'dice.noResult': 'No roll yet',
|
||||
'dice.inc': 'More',
|
||||
'dice.dec': 'Fewer',
|
||||
'dice.showAnimationToPlayers': 'Show animation to players',
|
||||
|
||||
'sessionTimer.duration': 'Duration (MM:SS)',
|
||||
'sessionTimer.start': 'Start',
|
||||
'sessionTimer.pause': 'Pause',
|
||||
'sessionTimer.reset': 'Reset',
|
||||
'sessionTimer.showToPlayers': 'Show to players',
|
||||
'sessionTimer.positionHint':
|
||||
'When shown, drag the timer on the control preview; corners resize, the top handle rotates.',
|
||||
|
||||
'initiative.next': 'Next',
|
||||
'initiative.queue': 'Queue',
|
||||
'initiative.queueEmpty': 'Queue is empty — add manually or place NPCs on the scene',
|
||||
'initiative.reserve': 'Reserve',
|
||||
'initiative.reserveEmpty': 'Reserve is empty',
|
||||
'initiative.addManual': 'Add',
|
||||
'initiative.manualPlaceholder': 'Manual name',
|
||||
'initiative.toReserve': 'To reserve',
|
||||
'initiative.restore': 'Restore',
|
||||
'initiative.delete': 'Delete',
|
||||
'initiative.conditions': 'Conditions',
|
||||
'initiative.conditionsEmpty': 'All conditions already added',
|
||||
'initiative.removeCondition': 'Remove condition',
|
||||
'initiative.condition.frightened': 'Frightened',
|
||||
'initiative.condition.exhaustion': 'Exhaustion',
|
||||
'initiative.condition.concentration': 'Concentration',
|
||||
'initiative.condition.invisible': 'Invisible',
|
||||
'initiative.condition.incapacitated': 'Incapacitated',
|
||||
'initiative.condition.petrified': 'Petrified',
|
||||
'initiative.condition.restrained': 'Restrained',
|
||||
'initiative.condition.blinded': 'Blinded',
|
||||
'initiative.condition.poisoned': 'Poisoned',
|
||||
'initiative.condition.charmed': 'Charmed',
|
||||
'initiative.condition.stunned': 'Stunned',
|
||||
'initiative.condition.paralyzed': 'Paralyzed',
|
||||
'initiative.condition.prone': 'Prone',
|
||||
'initiative.condition.grappled': 'Grappled',
|
||||
'initiative.condition.unconscious': 'Unconscious',
|
||||
'initiative.condition.deafened': 'Deafened',
|
||||
|
||||
'control.descriptionMissing': 'No description',
|
||||
'control.effects': 'EFFECTS',
|
||||
'control.tools': 'Cleanup',
|
||||
@@ -1267,18 +1614,25 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.poisonCloud': 'Poison cloud',
|
||||
'control.explosion': 'Explosion',
|
||||
'control.brushRadius': 'Brush radius',
|
||||
'control.brushShape': 'Brush shape',
|
||||
'control.brushShapeCircle': 'Circle',
|
||||
'control.brushShapeSquare': 'Square',
|
||||
'control.effectsSound': 'Effects sound',
|
||||
'control.storyLine': 'STORYLINE',
|
||||
'control.storyLine': 'HISTORY',
|
||||
'control.scenesAccordion': 'SCENES',
|
||||
'control.gotoScene': 'Go to this scene',
|
||||
'control.currentSceneBadge': 'CURRENT SCENE',
|
||||
'control.passed': 'Visited',
|
||||
'control.noActiveScene': 'No active scene.',
|
||||
'control.screenPreview': 'Screen preview',
|
||||
'control.npcTokenScale': 'Play token size',
|
||||
'control.npcTokenScale': 'Player token size',
|
||||
'control.resizeToken': 'Resize',
|
||||
'control.snapTokensToGrid': 'Snap tokens to grid',
|
||||
'control.snapTokensToGridNoGrid': 'Grid is off on this scene — snap has no effect',
|
||||
'control.stopPresentation': 'Turn off',
|
||||
'control.stopPresentation': 'End',
|
||||
'control.stopConfirm.title': 'End presentation?',
|
||||
'control.stopConfirm.body': 'Close the control panel and presentation and return to the editor?',
|
||||
'control.stopConfirm.confirm': 'End',
|
||||
'control.showPlayers': 'Show players',
|
||||
'control.hidePlayers': 'Hide players',
|
||||
'control.videoBrushHint':
|
||||
@@ -1290,7 +1644,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.unnamed': 'Untitled',
|
||||
'control.switchScene': 'Switch',
|
||||
'control.noBranches': 'No transitions available.',
|
||||
'control.endPresentation': 'End presentation',
|
||||
'control.endPresentation': 'End',
|
||||
'control.music': 'Music',
|
||||
'control.sceneMusic': 'SCENE MUSIC',
|
||||
'control.gameMusic': 'GAME MUSIC',
|
||||
|
||||
@@ -157,6 +157,7 @@ type Actions = {
|
||||
storylineSelections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
labels: StorylineLabels,
|
||||
playlistIds?: string[],
|
||||
) => Promise<void>;
|
||||
deleteProject: (projectId: ProjectId) => Promise<void>;
|
||||
};
|
||||
@@ -901,6 +902,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
storylineSelections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
labels: StorylineLabels,
|
||||
playlistIds: string[] = [],
|
||||
) => {
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.exportZip, {
|
||||
@@ -908,6 +910,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
storylineSelections,
|
||||
npcIds,
|
||||
labels,
|
||||
playlistIds,
|
||||
});
|
||||
if (res.canceled) return;
|
||||
} finally {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>TTRPG</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/instructions/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(160px, 220px);
|
||||
gap: 14px;
|
||||
background: var(--color-surface-elevated);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
.contentTitle {
|
||||
font-weight: 800;
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.paragraph:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.inlineLink {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent, #a78bfa);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inlineLink:hover {
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.nav {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--panel2);
|
||||
}
|
||||
|
||||
.navItem {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
background: var(--scene-list-hover-bg);
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.navItemActive {
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
color: var(--text0);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(120px, 32vh);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../editor/help/helpLinkify';
|
||||
import {
|
||||
HELP_SECTION_IDS,
|
||||
helpSectionBodyKey,
|
||||
helpSectionTitleKey,
|
||||
parseHelpSectionId,
|
||||
type HelpSectionId,
|
||||
} from '../editor/help/helpSections';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
|
||||
import styles from './InstructionsApp.module.css';
|
||||
|
||||
function InstructionsPanel({ initialSection }: { initialSection: HelpSectionId }) {
|
||||
const { t } = useEditorI18n();
|
||||
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(initialSection);
|
||||
}, [initialSection]);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0 });
|
||||
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
||||
activeNav?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeId]);
|
||||
|
||||
const linkCatalog = useMemo(
|
||||
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
|
||||
[t],
|
||||
);
|
||||
|
||||
const body = t(helpSectionBodyKey(activeId));
|
||||
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="instructions-app">
|
||||
<div ref={contentRef} className={styles.content}>
|
||||
<div className={styles.contentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} className={styles.paragraph}>
|
||||
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
|
||||
part.type === 'text' ? (
|
||||
<React.Fragment key={j}>{part.value}</React.Fragment>
|
||||
) : (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
className={styles.inlineLink}
|
||||
onClick={() => setActiveId(part.id)}
|
||||
>
|
||||
{part.value}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<nav ref={navRef} className={styles.nav} aria-label={t('help.navAria')}>
|
||||
{HELP_SECTION_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={[styles.navItem, id === activeId ? styles.navItemActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={id === activeId ? 'true' : undefined}
|
||||
onClick={() => setActiveId(id)}
|
||||
>
|
||||
{t(helpSectionTitleKey(id))}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstructionsApp() {
|
||||
const [section, setSection] = useState<HelpSectionId>('overview');
|
||||
|
||||
useEffect(() => {
|
||||
const api = getDndApi();
|
||||
void api.invoke(ipcChannels.windows.getInstructionsSection, {}).then(({ section: next }) => {
|
||||
setSection(parseHelpSectionId(next));
|
||||
});
|
||||
return api.on(ipcChannels.windows.instructionsSection, ({ section: next }) => {
|
||||
setSection(parseHelpSectionId(next));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') window.close();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return <InstructionsPanel initialSection={section} />;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { InstructionsApp } from './InstructionsApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Instructions">
|
||||
<EditorI18nProvider>
|
||||
<InstructionsApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -8,6 +8,7 @@ import editorStyles from '../editor/EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
nameFromFilePath,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from '../editor/fileDrop';
|
||||
@@ -111,6 +112,11 @@ export function NpcEditModal({
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||
setFilePath(path);
|
||||
setName((prev) => {
|
||||
if (prev.trim()) return prev;
|
||||
const fromFile = nameFromFilePath(path);
|
||||
return fromFile || prev;
|
||||
});
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return previewUrl;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { PlaylistsApp } from './PlaylistsApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Playlists">
|
||||
<EditorI18nProvider>
|
||||
<PlaylistsApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
void test('PresentationApp: Escape запрашивает confirm, не закрывает сразу', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'PresentationApp.tsx'), 'utf8');
|
||||
assert.ok(src.includes('requestCloseMultiWindowConfirm'));
|
||||
assert.match(
|
||||
src,
|
||||
/e\.key === 'Escape'[\s\S]*requestCloseMultiWindowConfirm/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/e\.key === 'Escape'[\s\S]*closeMultiWindow/,
|
||||
);
|
||||
});
|
||||
@@ -23,7 +23,8 @@ export function PresentationApp() {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
|
||||
e.preventDefault();
|
||||
void api.invoke(ipcChannels.windows.requestCloseMultiWindowConfirm, {});
|
||||
return;
|
||||
}
|
||||
if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { PresentationApp } from './PresentationApp';
|
||||
@@ -14,7 +15,9 @@ if (!rootEl) {
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Презентация">
|
||||
<EditorI18nProvider>
|
||||
<PresentationApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user