Compare commits
52 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 | |||
| 0a9168b320 | |||
| 028c1be059 | |||
| 9172660afb | |||
| 0f4bb76b41 | |||
| 1a9f0f2557 | |||
| 1ab6ffd593 | |||
| 46bec1a86a | |||
| 7362a36fe5 | |||
| 1d94c95cc8 | |||
| 1fbaaa6e77 | |||
| 8d5a68c71e | |||
| 4456eb0277 | |||
| e53d1ea934 | |||
| cbb6edc378 | |||
| 4e6f7321b8 | |||
| f75444a5dd | |||
| 10cbb3b256 | |||
| c46ff34393 | |||
| 7a25b18268 | |||
| d4dc4e7f3c | |||
| 2979d06f1c | |||
| 61446dacfc | |||
| 41b112159f | |||
| 0f01950cc1 | |||
| cc50e64e21 | |||
| 101f595bac | |||
| a3a03eb9e3 | |||
| e08f5ef550 | |||
| 04c75cd725 | |||
| e687303c57 |
@@ -17,3 +17,6 @@ Thumbs.db
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.tsbuildinfo
|
||||
test-results/
|
||||
playwright-report/
|
||||
e2e/fixtures/sample.png
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DiceRollSessionStore } from './diceRollSessionStore';
|
||||
import {
|
||||
D100_FACE_VALUES,
|
||||
formatPredeterminedDiceNotation,
|
||||
snapD100Value,
|
||||
} from '../../shared/types/diceRoll';
|
||||
|
||||
void test('diceRoll: roll produces values in range and notation', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'roll', count: 3, sides: 6 });
|
||||
const st = store.getState();
|
||||
assert.equal(st.active, true);
|
||||
assert.equal(st.visibleToPlayers, true);
|
||||
assert.equal(st.notation, '3d6');
|
||||
assert.equal(st.values.length, 3);
|
||||
assert.ok(st.values.every((v) => v >= 1 && v <= 6));
|
||||
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
|
||||
});
|
||||
|
||||
void test('diceRoll: roll without visibleToPlayers keeps master result only', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
store.dispatch({ kind: 'roll', count: 2, sides: 8 });
|
||||
const st = store.getState();
|
||||
assert.equal(st.visibleToPlayers, false);
|
||||
assert.equal(st.active, false);
|
||||
assert.equal(st.notation, '2d8');
|
||||
assert.equal(st.values.length, 2);
|
||||
assert.ok(st.values.every((v) => v >= 1 && v <= 8));
|
||||
assert.equal(st.startedAt, null);
|
||||
});
|
||||
|
||||
void test('diceRoll: unchecking visibleToPlayers hides overlay', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
|
||||
assert.equal(store.getState().active, true);
|
||||
const values = [...store.getState().values];
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
assert.equal(store.getState().active, false);
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
assert.deepEqual(store.getState().values, values);
|
||||
});
|
||||
|
||||
void test('diceRoll: d100 values are tens faces only', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'roll', count: 5, sides: 100 });
|
||||
const st = store.getState();
|
||||
assert.equal(st.notation, '5d100');
|
||||
assert.equal(st.values.length, 5);
|
||||
const allowed = new Set<number>(D100_FACE_VALUES);
|
||||
assert.ok(st.values.every((v) => allowed.has(v)));
|
||||
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
|
||||
});
|
||||
|
||||
void test('diceRoll: clear resets but keeps visibleToPlayers', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
|
||||
store.dispatch({ kind: 'clear' });
|
||||
assert.equal(store.getState().active, false);
|
||||
assert.deepEqual(store.getState().values, []);
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
});
|
||||
|
||||
void test('diceRoll: hideOverlay keeps values for master UI', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'roll', count: 2, sides: 6 });
|
||||
const values = [...store.getState().values];
|
||||
const total = store.getState().total;
|
||||
store.dispatch({ kind: 'hideOverlay' });
|
||||
assert.equal(store.getState().active, false);
|
||||
assert.deepEqual(store.getState().values, values);
|
||||
assert.equal(store.getState().total, total);
|
||||
assert.equal(store.getState().notation, '2d6');
|
||||
assert.equal(store.getState().visibleToPlayers, true);
|
||||
});
|
||||
|
||||
void test('diceRoll: roll with zero count is no-op', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
const before = store.getState().revision;
|
||||
store.dispatch({ kind: 'roll', count: 0, sides: 6 });
|
||||
assert.equal(store.getState().revision, before);
|
||||
assert.deepEqual(store.getState().values, []);
|
||||
});
|
||||
|
||||
void test('formatPredeterminedDiceNotation', () => {
|
||||
assert.equal(formatPredeterminedDiceNotation(2, 20, [15, 7]), '2d20@15,7');
|
||||
assert.equal(formatPredeterminedDiceNotation(2, 100, [20, 100]), '2d100@20,100');
|
||||
assert.equal(formatPredeterminedDiceNotation(1, 100, [27]), '1d100@30');
|
||||
});
|
||||
|
||||
void test('snapD100Value', () => {
|
||||
assert.equal(snapD100Value(20), 20);
|
||||
assert.equal(snapD100Value(21), 30);
|
||||
assert.equal(snapD100Value(100), 100);
|
||||
assert.equal(snapD100Value(1), 10);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,10 @@ void test('SceneDarknessStore: draft синхронизируется и сбр
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'draft.set',
|
||||
draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05 },
|
||||
draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05, mode: 'cover' },
|
||||
});
|
||||
assert.ok(store.getState().draft);
|
||||
assert.equal(store.getState().draft?.mode, 'cover');
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
@@ -59,8 +60,42 @@ void test('SceneDarknessStore: draft синхронизируется и сбр
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.1, y: 0.1, tMs: 1 }],
|
||||
radiusN: 0.05,
|
||||
mode: 'cover',
|
||||
},
|
||||
});
|
||||
assert.equal(store.getState().draft, null);
|
||||
assert.equal(store.getState().strokes.length, 1);
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'open1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
mode: 'reveal',
|
||||
},
|
||||
});
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'close1',
|
||||
seed: 2,
|
||||
createdAtMs: 200,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
|
||||
radiusN: 0.08,
|
||||
mode: 'cover',
|
||||
},
|
||||
});
|
||||
assert.equal(store.getState().strokes.length, 2);
|
||||
assert.equal(store.getState().strokes[1]?.mode, 'cover');
|
||||
store.switchScene('scene_b', true);
|
||||
store.switchScene('scene_a', true);
|
||||
assert.equal(store.getState().strokes.length, 2);
|
||||
assert.equal(store.getState().strokes[1]?.mode, 'cover');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
FoundryPlaylistDoc,
|
||||
FoundrySceneDoc,
|
||||
} from '../../shared/foundry/foundryTypes';
|
||||
import { noneBinding } from '../../shared/npcs/npcBinding';
|
||||
import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups';
|
||||
import type {
|
||||
MediaAsset,
|
||||
@@ -280,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(
|
||||
@@ -373,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] = {
|
||||
@@ -388,6 +394,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: audioRefs },
|
||||
settings: {
|
||||
@@ -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);
|
||||
@@ -447,7 +467,11 @@ export async function buildProjectFromFoundryDocuments(
|
||||
x: 80 + (npcIndex % 4) * 220,
|
||||
y: 80 + Math.floor(npcIndex / 4) * 200,
|
||||
groupId,
|
||||
binding: noneBinding(),
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
externalUrl: '',
|
||||
});
|
||||
npcIndex += 1;
|
||||
}
|
||||
@@ -525,6 +549,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
assets: ctx.assets,
|
||||
campaignAudios,
|
||||
materials: [],
|
||||
gridTemplates: [],
|
||||
npcs,
|
||||
npcGroups,
|
||||
npcRelations: [],
|
||||
@@ -534,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');
|
||||
}
|
||||
+1094
-165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { InitiativeSessionStore } from './initiativeSessionStore';
|
||||
import {
|
||||
initiativeNpcEntryId,
|
||||
initiativePlayerEntryId,
|
||||
} from '../../shared/types/initiative';
|
||||
|
||||
void test('initiative: addManual + next cycles order', () => {
|
||||
const store = new InitiativeSessionStore();
|
||||
store.dispatch({ kind: 'addManual', name: 'A' });
|
||||
store.dispatch({ kind: 'addManual', name: 'B' });
|
||||
const a = store.getState().order[0]!;
|
||||
const b = store.getState().order[1]!;
|
||||
assert.equal(store.getState().activeId, a);
|
||||
store.dispatch({ kind: 'next' });
|
||||
assert.equal(store.getState().activeId, b);
|
||||
store.dispatch({ kind: 'next' });
|
||||
assert.equal(store.getState().activeId, a);
|
||||
});
|
||||
|
||||
void test('initiative: syncPresence adds npcs and players', () => {
|
||||
const store = new InitiativeSessionStore();
|
||||
store.dispatch({
|
||||
kind: 'syncPresence',
|
||||
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
|
||||
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||
playersVisible: true,
|
||||
});
|
||||
const st = store.getState();
|
||||
assert.ok(st.entries[initiativeNpcEntryId('p1')]);
|
||||
assert.ok(st.entries[initiativePlayerEntryId('pl1')]);
|
||||
assert.deepEqual(st.order, [initiativeNpcEntryId('p1'), initiativePlayerEntryId('pl1')]);
|
||||
});
|
||||
|
||||
void test('initiative: hide players moves them to reserve', () => {
|
||||
const store = new InitiativeSessionStore();
|
||||
store.dispatch({
|
||||
kind: 'syncPresence',
|
||||
npcs: [],
|
||||
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||
playersVisible: true,
|
||||
});
|
||||
const pid = initiativePlayerEntryId('pl1');
|
||||
assert.ok(store.getState().order.includes(pid));
|
||||
store.dispatch({
|
||||
kind: 'syncPresence',
|
||||
npcs: [],
|
||||
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||
playersVisible: false,
|
||||
});
|
||||
assert.ok(!store.getState().order.includes(pid));
|
||||
assert.ok(store.getState().reserve.includes(pid));
|
||||
store.dispatch({
|
||||
kind: 'syncPresence',
|
||||
npcs: [],
|
||||
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||
playersVisible: true,
|
||||
});
|
||||
assert.ok(store.getState().reserve.includes(pid));
|
||||
assert.ok(!store.getState().order.includes(pid));
|
||||
});
|
||||
|
||||
void test('initiative: conditions unique + reserve/restore', () => {
|
||||
const store = new InitiativeSessionStore();
|
||||
store.dispatch({ kind: 'addManual', name: 'X' });
|
||||
const id = store.getState().order[0]!;
|
||||
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
|
||||
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
|
||||
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
|
||||
store.dispatch({ kind: 'moveToReserve', id });
|
||||
assert.deepEqual(store.getState().order, []);
|
||||
assert.deepEqual(store.getState().reserve, [id]);
|
||||
store.dispatch({ kind: 'restoreFromReserve', id });
|
||||
assert.deepEqual(store.getState().order, [id]);
|
||||
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
|
||||
});
|
||||
|
||||
void test('initiative: clear keeps enabled', () => {
|
||||
const store = new InitiativeSessionStore();
|
||||
store.dispatch({ kind: 'setEnabled', enabled: true });
|
||||
store.dispatch({ kind: 'addManual', name: 'Z' });
|
||||
store.clear();
|
||||
assert.equal(store.getState().enabled, true);
|
||||
assert.deepEqual(store.getState().order, []);
|
||||
});
|
||||
|
||||
void test('initiative: despawn removes npc entry', () => {
|
||||
const store = new InitiativeSessionStore();
|
||||
store.dispatch({
|
||||
kind: 'syncPresence',
|
||||
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
|
||||
players: [],
|
||||
playersVisible: false,
|
||||
});
|
||||
store.dispatch({
|
||||
kind: 'syncPresence',
|
||||
npcs: [],
|
||||
players: [],
|
||||
playersVisible: false,
|
||||
});
|
||||
assert.deepEqual(store.getState().order, []);
|
||||
assert.deepEqual(Object.keys(store.getState().entries), []);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
createInitialInitiativeState,
|
||||
initiativeManualLetter,
|
||||
initiativeNpcEntryId,
|
||||
initiativePlayerEntryId,
|
||||
isInitiativeConditionId,
|
||||
newInitiativeManualId,
|
||||
pickInitiativeManualGradient,
|
||||
type InitiativeEntry,
|
||||
type InitiativeEvent,
|
||||
type InitiativePresenceNpc,
|
||||
type InitiativePresencePlayer,
|
||||
type InitiativeState,
|
||||
} from '../../shared/types/initiative';
|
||||
|
||||
function withoutId(ids: readonly string[], id: string): string[] {
|
||||
return ids.filter((x) => x !== id);
|
||||
}
|
||||
|
||||
function ensureActiveValid(state: InitiativeState): InitiativeState {
|
||||
if (state.activeId != null && state.order.includes(state.activeId)) return state;
|
||||
const nextActive = state.order[0] ?? null;
|
||||
if (state.activeId === nextActive) return state;
|
||||
return { ...state, activeId: nextActive };
|
||||
}
|
||||
|
||||
export class InitiativeSessionStore {
|
||||
private state: InitiativeState = createInitialInitiativeState();
|
||||
|
||||
getState(): InitiativeState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
clear(): InitiativeState {
|
||||
const enabled = this.state.enabled;
|
||||
this.state = { ...createInitialInitiativeState(), enabled, revision: this.state.revision + 1 };
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: InitiativeEvent): InitiativeState {
|
||||
switch (event.kind) {
|
||||
case 'clear': {
|
||||
this.state = {
|
||||
...createInitialInitiativeState(),
|
||||
enabled: this.state.enabled,
|
||||
revision: this.state.revision + 1,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setEnabled': {
|
||||
if (this.state.enabled === event.enabled) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
enabled: event.enabled,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'syncPresence': {
|
||||
return this.syncPresence(event.npcs, event.players, event.playersVisible);
|
||||
}
|
||||
case 'reorder': {
|
||||
const allowed = new Set(this.state.order);
|
||||
const nextOrder = event.order.filter((id) => allowed.has(id));
|
||||
for (const id of this.state.order) {
|
||||
if (!nextOrder.includes(id)) nextOrder.push(id);
|
||||
}
|
||||
if (nextOrder.length === this.state.order.length && nextOrder.every((id, i) => id === this.state.order[i])) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = ensureActiveValid({
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
order: nextOrder,
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'setActive': {
|
||||
if (event.id != null && !this.state.order.includes(event.id)) return this.state;
|
||||
if (this.state.activeId === event.id) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
activeId: event.id,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'next': {
|
||||
if (this.state.order.length === 0) {
|
||||
if (this.state.activeId == null) return this.state;
|
||||
this.state = { ...this.state, revision: this.state.revision + 1, activeId: null };
|
||||
return this.state;
|
||||
}
|
||||
const cur = this.state.activeId ? this.state.order.indexOf(this.state.activeId) : -1;
|
||||
const nextIdx = cur < 0 ? 0 : (cur + 1) % this.state.order.length;
|
||||
const nextId = this.state.order[nextIdx] ?? null;
|
||||
if (nextId === this.state.activeId) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
activeId: nextId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'addManual': {
|
||||
const name = event.name.trim();
|
||||
if (!name) return this.state;
|
||||
const id = newInitiativeManualId();
|
||||
const entry: InitiativeEntry = {
|
||||
id,
|
||||
kind: 'manual',
|
||||
name,
|
||||
letter: initiativeManualLetter(name),
|
||||
gradient: pickInitiativeManualGradient(),
|
||||
};
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
entries: { ...this.state.entries, [id]: entry },
|
||||
order: [...this.state.order, id],
|
||||
activeId: this.state.activeId ?? id,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'moveToReserve': {
|
||||
if (!this.state.order.includes(event.id)) return this.state;
|
||||
if (!this.state.entries[event.id]) return this.state;
|
||||
const order = withoutId(this.state.order, event.id);
|
||||
const reserve = this.state.reserve.includes(event.id)
|
||||
? this.state.reserve
|
||||
: [...this.state.reserve, event.id];
|
||||
this.state = ensureActiveValid({
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
order,
|
||||
reserve,
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'restoreFromReserve': {
|
||||
if (!this.state.reserve.includes(event.id)) return this.state;
|
||||
if (!this.state.entries[event.id]) return this.state;
|
||||
const reserve = withoutId(this.state.reserve, event.id);
|
||||
const order = this.state.order.includes(event.id)
|
||||
? this.state.order
|
||||
: [...this.state.order, event.id];
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
order,
|
||||
reserve,
|
||||
activeId: this.state.activeId ?? event.id,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'deleteEntry': {
|
||||
if (!this.state.entries[event.id]) return this.state;
|
||||
const { [event.id]: _removed, ...entries } = this.state.entries;
|
||||
const { [event.id]: _c, ...conditions } = this.state.conditions;
|
||||
this.state = ensureActiveValid({
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
entries,
|
||||
conditions,
|
||||
order: withoutId(this.state.order, event.id),
|
||||
reserve: withoutId(this.state.reserve, event.id),
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'addCondition': {
|
||||
if (!this.state.entries[event.id]) return this.state;
|
||||
if (!isInitiativeConditionId(event.conditionId)) return this.state;
|
||||
const cur = this.state.conditions[event.id] ?? [];
|
||||
if (cur.includes(event.conditionId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
conditions: {
|
||||
...this.state.conditions,
|
||||
[event.id]: [...cur, event.conditionId],
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'removeCondition': {
|
||||
const cur = this.state.conditions[event.id];
|
||||
if (!cur?.includes(event.conditionId)) return this.state;
|
||||
const next = cur.filter((c) => c !== event.conditionId);
|
||||
const conditions = { ...this.state.conditions };
|
||||
if (next.length === 0) delete conditions[event.id];
|
||||
else conditions[event.id] = next;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
conditions,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private syncPresence(
|
||||
npcs: readonly InitiativePresenceNpc[],
|
||||
players: readonly InitiativePresencePlayer[],
|
||||
playersVisible: boolean,
|
||||
): InitiativeState {
|
||||
const npcByPlacement = new Map(npcs.map((n) => [n.placementId, n]));
|
||||
const playerById = new Map(players.map((p) => [p.playerId, p]));
|
||||
|
||||
let entries: Record<string, InitiativeEntry> = { ...this.state.entries };
|
||||
let order = [...this.state.order];
|
||||
let reserve = [...this.state.reserve];
|
||||
let conditions = { ...this.state.conditions };
|
||||
let changed = false;
|
||||
|
||||
for (const [id, entry] of Object.entries(entries)) {
|
||||
if (entry.kind === 'manual') continue;
|
||||
if (entry.kind === 'npc') {
|
||||
const live = npcByPlacement.get(entry.placementId);
|
||||
if (!live) {
|
||||
delete entries[id];
|
||||
delete conditions[id];
|
||||
order = withoutId(order, id);
|
||||
reserve = withoutId(reserve, id);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (entry.name !== live.name || entry.avatarAssetId !== live.avatarAssetId || entry.npcId !== live.npcId) {
|
||||
entries[id] = {
|
||||
...entry,
|
||||
npcId: live.npcId,
|
||||
name: live.name,
|
||||
avatarAssetId: live.avatarAssetId,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const live = playerById.get(entry.playerId);
|
||||
if (!live) {
|
||||
delete entries[id];
|
||||
delete conditions[id];
|
||||
order = withoutId(order, id);
|
||||
reserve = withoutId(reserve, id);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (entry.name !== live.name) {
|
||||
entries[id] = { ...entry, name: live.name };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const npc of npcs) {
|
||||
const id = initiativeNpcEntryId(npc.placementId);
|
||||
if (entries[id]) continue;
|
||||
entries[id] = {
|
||||
id,
|
||||
kind: 'npc',
|
||||
placementId: npc.placementId,
|
||||
npcId: npc.npcId,
|
||||
name: npc.name,
|
||||
avatarAssetId: npc.avatarAssetId,
|
||||
};
|
||||
order.push(id);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (const player of players) {
|
||||
const id = initiativePlayerEntryId(player.playerId);
|
||||
if (entries[id]) continue;
|
||||
entries[id] = {
|
||||
id,
|
||||
kind: 'player',
|
||||
playerId: player.playerId,
|
||||
name: player.name,
|
||||
};
|
||||
order.push(id);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const wasVisible = this.state.playersVisible;
|
||||
if (wasVisible && !playersVisible) {
|
||||
const playerIdsInOrder = order.filter((id) => entries[id]?.kind === 'player');
|
||||
if (playerIdsInOrder.length > 0) {
|
||||
for (const id of playerIdsInOrder) {
|
||||
order = withoutId(order, id);
|
||||
if (!reserve.includes(id)) reserve.push(id);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.playersVisible !== playersVisible) changed = true;
|
||||
|
||||
if (!changed) {
|
||||
// Still update playersVisible if somehow same structure
|
||||
if (this.state.playersVisible === playersVisible) return this.state;
|
||||
}
|
||||
|
||||
let next: InitiativeState = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
entries,
|
||||
order,
|
||||
reserve,
|
||||
conditions,
|
||||
playersVisible,
|
||||
};
|
||||
next = ensureActiveValid(next);
|
||||
this.state = next;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
clampMaterialsLayout,
|
||||
DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
defaultLegendLayoutForMaterial,
|
||||
type MaterialId,
|
||||
type MaterialsOverlayEvent,
|
||||
type MaterialsOverlayLayout,
|
||||
@@ -12,9 +13,10 @@ import {
|
||||
function emptyState(): MaterialsOverlayState {
|
||||
return {
|
||||
revision: 1,
|
||||
activeMaterialId: null,
|
||||
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
|
||||
legendLayout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT, cx: 0.82, cy: 0.5, scale: 0.85 },
|
||||
activeMaterialIds: [],
|
||||
layouts: {},
|
||||
legendLayouts: {},
|
||||
focusMaterialId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
}
|
||||
@@ -26,14 +28,12 @@ function initialLayout(rotationDeg?: number): MaterialsOverlayLayout {
|
||||
});
|
||||
}
|
||||
|
||||
function initialLegendLayout(): MaterialsOverlayLayout {
|
||||
return clampMaterialsLayout({
|
||||
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
cx: 0.82,
|
||||
cy: 0.5,
|
||||
scale: 0.85,
|
||||
rotationDeg: 0,
|
||||
});
|
||||
function layoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
|
||||
return state.layouts[materialId] ?? { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT };
|
||||
}
|
||||
|
||||
function legendLayoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
|
||||
return state.legendLayouts[materialId] ?? defaultLegendLayoutForMaterial();
|
||||
}
|
||||
|
||||
export class MaterialsOverlayStore {
|
||||
@@ -44,14 +44,15 @@ export class MaterialsOverlayStore {
|
||||
}
|
||||
|
||||
clear(): MaterialsOverlayState {
|
||||
if (this.state.activeMaterialId === null && this.state.zoomTool === null) {
|
||||
if (this.state.activeMaterialIds.length === 0 && this.state.zoomTool === null) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialId: null,
|
||||
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
|
||||
legendLayout: initialLegendLayout(),
|
||||
activeMaterialIds: [],
|
||||
layouts: {},
|
||||
legendLayouts: {},
|
||||
focusMaterialId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
return this.state;
|
||||
@@ -61,50 +62,98 @@ export class MaterialsOverlayStore {
|
||||
switch (event.kind) {
|
||||
case 'hide':
|
||||
return this.clear();
|
||||
case 'show':
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialId: event.materialId,
|
||||
layout: initialLayout(event.rotationDeg),
|
||||
legendLayout: initialLegendLayout(),
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
case 'toggle': {
|
||||
if (this.state.activeMaterialId === event.materialId) {
|
||||
return this.clear();
|
||||
case 'show': {
|
||||
if (this.state.activeMaterialIds.includes(event.materialId)) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusMaterialId: event.materialId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialId: event.materialId,
|
||||
layout: initialLayout(event.rotationDeg),
|
||||
legendLayout: initialLegendLayout(),
|
||||
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: initialLayout(event.rotationDeg),
|
||||
},
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: defaultLegendLayoutForMaterial(),
|
||||
},
|
||||
focusMaterialId: event.materialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'toggle': {
|
||||
if (this.state.activeMaterialIds.includes(event.materialId)) {
|
||||
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => id !== event.materialId);
|
||||
const layouts = { ...this.state.layouts };
|
||||
const legendLayouts = { ...this.state.legendLayouts };
|
||||
delete layouts[event.materialId];
|
||||
delete legendLayouts[event.materialId];
|
||||
const focusMaterialId =
|
||||
this.state.focusMaterialId === event.materialId
|
||||
? (activeMaterialIds[activeMaterialIds.length - 1] ?? null)
|
||||
: this.state.focusMaterialId;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds,
|
||||
layouts,
|
||||
legendLayouts,
|
||||
focusMaterialId,
|
||||
zoomTool: activeMaterialIds.length === 0 ? null : this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: initialLayout(event.rotationDeg),
|
||||
},
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: defaultLegendLayoutForMaterial(),
|
||||
},
|
||||
focusMaterialId: event.materialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'layout.set': {
|
||||
if (this.state.activeMaterialId === null) return this.state;
|
||||
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
layout: clampMaterialsLayout({
|
||||
...this.state.layout,
|
||||
...event.layout,
|
||||
}),
|
||||
focusMaterialId: event.materialId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: clampMaterialsLayout({
|
||||
...layoutFor(this.state, event.materialId),
|
||||
...event.layout,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'legendLayout.set': {
|
||||
if (this.state.activeMaterialId === null) return this.state;
|
||||
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
legendLayout: clampMaterialsLayout({
|
||||
...this.state.legendLayout,
|
||||
...event.layout,
|
||||
rotationDeg: 0,
|
||||
}),
|
||||
focusMaterialId: event.materialId,
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: clampMaterialsLayout({
|
||||
...legendLayoutFor(this.state, event.materialId),
|
||||
...event.layout,
|
||||
rotationDeg: 0,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
@@ -118,10 +167,18 @@ export class MaterialsOverlayStore {
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomAt': {
|
||||
if (this.state.activeMaterialId === null || !this.state.zoomTool) return this.state;
|
||||
if (this.state.activeMaterialIds.length === 0 || !this.state.zoomTool) return this.state;
|
||||
const targetId =
|
||||
(event.materialId && this.state.activeMaterialIds.includes(event.materialId)
|
||||
? event.materialId
|
||||
: null) ??
|
||||
this.state.focusMaterialId ??
|
||||
this.state.activeMaterialIds[this.state.activeMaterialIds.length - 1] ??
|
||||
null;
|
||||
if (!targetId) return this.state;
|
||||
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
|
||||
const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt(
|
||||
this.state.layout,
|
||||
layoutFor(this.state, targetId),
|
||||
event.nx,
|
||||
event.ny,
|
||||
factor,
|
||||
@@ -129,7 +186,11 @@ export class MaterialsOverlayStore {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
layout,
|
||||
focusMaterialId: targetId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[targetId]: layout,
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
@@ -139,8 +200,29 @@ export class MaterialsOverlayStore {
|
||||
}
|
||||
|
||||
ensureMaterialStillExists(materialIds: ReadonlySet<MaterialId>): MaterialsOverlayState {
|
||||
const active = this.state.activeMaterialId;
|
||||
if (active === null || materialIds.has(active)) return this.state;
|
||||
return this.clear();
|
||||
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => materialIds.has(id));
|
||||
if (activeMaterialIds.length === this.state.activeMaterialIds.length) return this.state;
|
||||
if (activeMaterialIds.length === 0) return this.clear();
|
||||
const layouts: Record<string, MaterialsOverlayLayout> = {};
|
||||
const legendLayouts: Record<string, MaterialsOverlayLayout> = {};
|
||||
for (const id of activeMaterialIds) {
|
||||
const layout = this.state.layouts[id];
|
||||
if (layout) layouts[id] = layout;
|
||||
const legend = this.state.legendLayouts[id];
|
||||
if (legend) legendLayouts[id] = legend;
|
||||
}
|
||||
const focusMaterialId =
|
||||
this.state.focusMaterialId && activeMaterialIds.includes(this.state.focusMaterialId)
|
||||
? this.state.focusMaterialId
|
||||
: (activeMaterialIds[activeMaterialIds.length - 1] ?? null);
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds,
|
||||
layouts,
|
||||
legendLayouts,
|
||||
focusMaterialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { asNpcId } from '../../shared/types/ids';
|
||||
|
||||
import { NpcsOverlayStore } from './npcsOverlayStore';
|
||||
|
||||
const a = asNpcId('npc_a');
|
||||
const b = asNpcId('npc_b');
|
||||
|
||||
void test('NpcsOverlayStore: inspect is independent from overlay show', () => {
|
||||
const store = new NpcsOverlayStore();
|
||||
store.dispatch({ kind: 'inspect.toggle', npcId: a });
|
||||
store.dispatch({ kind: 'inspect.show', npcId: b });
|
||||
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
|
||||
assert.equal(store.getState().inspectFocusNpcId, b);
|
||||
assert.deepEqual(store.getState().activeNpcIds, []);
|
||||
|
||||
store.dispatch({ kind: 'show', npcId: a });
|
||||
assert.deepEqual(store.getState().activeNpcIds, [a]);
|
||||
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
|
||||
|
||||
store.dispatch({ kind: 'hide' });
|
||||
assert.deepEqual(store.getState().activeNpcIds, []);
|
||||
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
|
||||
|
||||
store.dispatch({ kind: 'inspect.toggle', npcId: a });
|
||||
assert.deepEqual(store.getState().inspectNpcIds, [b]);
|
||||
});
|
||||
|
||||
void test('NpcsOverlayStore: clear wipes inspect; ensureNpcStillExists prunes', () => {
|
||||
const store = new NpcsOverlayStore();
|
||||
store.dispatch({ kind: 'inspect.show', npcId: a });
|
||||
store.dispatch({ kind: 'show', npcId: b });
|
||||
store.ensureNpcStillExists(new Set([a]));
|
||||
assert.deepEqual(store.getState().inspectNpcIds, [a]);
|
||||
assert.deepEqual(store.getState().activeNpcIds, []);
|
||||
store.clear();
|
||||
assert.deepEqual(store.getState().inspectNpcIds, []);
|
||||
});
|
||||
@@ -16,6 +16,8 @@ function emptyState(): NpcsOverlayState {
|
||||
layouts: {},
|
||||
focusNpcId: null,
|
||||
zoomTool: null,
|
||||
inspectNpcIds: [],
|
||||
inspectFocusNpcId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +25,15 @@ function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
|
||||
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
|
||||
}
|
||||
|
||||
function addInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
|
||||
if (ids.includes(npcId)) return ids;
|
||||
return [...ids, npcId];
|
||||
}
|
||||
|
||||
function removeInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
|
||||
return ids.filter((id) => id !== npcId);
|
||||
}
|
||||
|
||||
export class NpcsOverlayStore {
|
||||
private state: NpcsOverlayState = emptyState();
|
||||
|
||||
@@ -30,11 +41,29 @@ export class NpcsOverlayStore {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Полный сброс (закрытие проекта / конец сессии). */
|
||||
clear(): NpcsOverlayState {
|
||||
const s = this.state;
|
||||
if (
|
||||
s.activeNpcIds.length === 0 &&
|
||||
s.zoomTool === null &&
|
||||
s.inspectNpcIds.length === 0 &&
|
||||
s.inspectFocusNpcId === null
|
||||
) {
|
||||
return s;
|
||||
}
|
||||
this.state = emptyState();
|
||||
this.state.revision = s.revision + 1;
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Снять аватары с экрана, карточки описания оставить. */
|
||||
hideAvatars(): NpcsOverlayState {
|
||||
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds: [],
|
||||
layouts: {},
|
||||
@@ -47,7 +76,7 @@ export class NpcsOverlayStore {
|
||||
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
|
||||
switch (event.kind) {
|
||||
case 'hide':
|
||||
return this.clear();
|
||||
return this.hideAvatars();
|
||||
case 'show': {
|
||||
if (this.state.activeNpcIds.includes(event.npcId)) {
|
||||
this.state = {
|
||||
@@ -58,6 +87,7 @@ export class NpcsOverlayStore {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
|
||||
layouts: {
|
||||
@@ -65,7 +95,6 @@ export class NpcsOverlayStore {
|
||||
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
},
|
||||
focusNpcId: event.npcId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
@@ -79,6 +108,7 @@ export class NpcsOverlayStore {
|
||||
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
|
||||
: this.state.focusNpcId;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds,
|
||||
layouts,
|
||||
@@ -88,6 +118,7 @@ export class NpcsOverlayStore {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
|
||||
layouts: {
|
||||
@@ -95,7 +126,38 @@ export class NpcsOverlayStore {
|
||||
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
},
|
||||
focusNpcId: event.npcId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'inspect.toggle': {
|
||||
if (this.state.inspectNpcIds.includes(event.npcId)) {
|
||||
const inspectNpcIds = removeInspect(this.state.inspectNpcIds, event.npcId);
|
||||
const inspectFocusNpcId =
|
||||
this.state.inspectFocusNpcId === event.npcId
|
||||
? (inspectNpcIds[inspectNpcIds.length - 1] ?? null)
|
||||
: this.state.inspectFocusNpcId;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
inspectNpcIds,
|
||||
inspectFocusNpcId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
|
||||
inspectFocusNpcId: event.npcId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'inspect.show': {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
|
||||
inspectFocusNpcId: event.npcId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
@@ -157,8 +219,19 @@ export class NpcsOverlayStore {
|
||||
|
||||
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
|
||||
const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
|
||||
if (activeNpcIds.length === this.state.activeNpcIds.length) return this.state;
|
||||
if (activeNpcIds.length === 0) return this.clear();
|
||||
const inspectNpcIds = this.state.inspectNpcIds.filter((id) => npcIds.has(id));
|
||||
const sameActive = activeNpcIds.length === this.state.activeNpcIds.length;
|
||||
const sameInspect = inspectNpcIds.length === this.state.inspectNpcIds.length;
|
||||
if (sameActive && sameInspect) return this.state;
|
||||
if (activeNpcIds.length === 0 && inspectNpcIds.length === 0) {
|
||||
const zoomTool = this.state.zoomTool;
|
||||
this.state = {
|
||||
...emptyState(),
|
||||
revision: this.state.revision + 1,
|
||||
zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
const layouts: Record<string, NpcsOverlayLayout> = {};
|
||||
for (const id of activeNpcIds) {
|
||||
const layout = this.state.layouts[id];
|
||||
@@ -168,12 +241,18 @@ export class NpcsOverlayStore {
|
||||
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
|
||||
? this.state.focusNpcId
|
||||
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
|
||||
const inspectFocusNpcId =
|
||||
this.state.inspectFocusNpcId && inspectNpcIds.includes(this.state.inspectFocusNpcId)
|
||||
? this.state.inspectFocusNpcId
|
||||
: (inspectNpcIds[inspectNpcIds.length - 1] ?? null);
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds,
|
||||
layouts,
|
||||
focusNpcId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
|
||||
inspectNpcIds,
|
||||
inspectFocusNpcId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PlayersStore } from './playersStore';
|
||||
import { asPlayerId } from '../../shared/types/ids';
|
||||
|
||||
async function withTempStore(run: (store: PlayersStore, root: string) => Promise<void>) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-players-'));
|
||||
const store = new PlayersStore(root);
|
||||
try {
|
||||
await run(store, root);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
void test('PlayersStore upsert list delete and teams', async () => {
|
||||
await withTempStore(async (store, root) => {
|
||||
const png = path.join(root, 'sample.png');
|
||||
// minimal 1x1 png
|
||||
const buf = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
await fs.writeFile(png, buf);
|
||||
|
||||
const progress: number[] = [];
|
||||
const player = await store.upsert(
|
||||
{ name: 'Ada', filePath: png, ringColor: '#112233', imageOffset: { x: 0.1, y: -0.1 } },
|
||||
(p) => progress.push(p.percent),
|
||||
);
|
||||
assert.equal(player.name, 'Ada');
|
||||
assert.equal(player.ringColor, '#112233');
|
||||
assert.ok(progress.length > 0);
|
||||
assert.equal(store.listPlayers().length, 1);
|
||||
|
||||
const team = await store.upsertTeam({ name: 'Party', color: '#abcdef' });
|
||||
assert.equal(team.name, 'Party');
|
||||
const assigned = await store.assignPlayerTeam(player.id, team.id);
|
||||
assert.equal(assigned?.teamId, team.id);
|
||||
|
||||
await store.deleteTeam(team.id);
|
||||
assert.equal(store.listTeams().length, 0);
|
||||
assert.equal(store.getById(player.id)?.teamId, null);
|
||||
|
||||
await store.delete(player.id);
|
||||
assert.equal(store.listPlayers().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
void test('PlayersStore persists across reload', async () => {
|
||||
await withTempStore(async (store, root) => {
|
||||
const png = path.join(root, 'sample.png');
|
||||
await fs.writeFile(
|
||||
png,
|
||||
Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
),
|
||||
);
|
||||
const created = await store.upsert({ name: 'Bob', filePath: png });
|
||||
const store2 = new PlayersStore(root);
|
||||
await store2.ensureLoaded();
|
||||
assert.equal(store2.listPlayers().length, 1);
|
||||
assert.equal(store2.getById(asPlayerId(created.id))?.name, 'Bob');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
assignPlayerToTeam,
|
||||
createPlayerTeamDraft,
|
||||
deletePlayerTeam,
|
||||
normalizeAppPlayerTeams,
|
||||
uniquePlayerTeamName,
|
||||
} from '../../shared/players/playerTeams';
|
||||
import type {
|
||||
AppPlayer,
|
||||
AppPlayerTeam,
|
||||
PlayerId,
|
||||
PlayerImageOffset,
|
||||
PlayersUpsertProgressEvent,
|
||||
PlayerTeamId,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import {
|
||||
clampPlayerImageOffset,
|
||||
clampPlayerImageScale,
|
||||
DEFAULT_PLAYER_IMAGE_OFFSET,
|
||||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
normalizeAppPlayer,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { asPlayerId } from '../../shared/types/ids';
|
||||
import { normalizeHexColor } from '../../shared/npcs/npcGroups';
|
||||
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
|
||||
|
||||
type PlayersManifest = {
|
||||
players: AppPlayer[];
|
||||
teams: AppPlayerTeam[];
|
||||
};
|
||||
|
||||
function mimeFromExt(ext: string): string {
|
||||
const e = ext.toLowerCase();
|
||||
if (e === '.png') return 'image/png';
|
||||
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
|
||||
if (e === '.webp') return 'image/webp';
|
||||
if (e === '.gif') return 'image/gif';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function safeFileBase(name: string): string {
|
||||
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
|
||||
return base || 'player';
|
||||
}
|
||||
|
||||
function randomPlayerId(): PlayerId {
|
||||
return asPlayerId(`player_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||
}
|
||||
|
||||
export class PlayersStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly filesDir: string;
|
||||
private readonly manifestPath: string;
|
||||
private players: AppPlayer[] = [];
|
||||
private teams: AppPlayerTeam[] = [];
|
||||
private loaded = false;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.rootDir = path.join(userData, 'players');
|
||||
this.filesDir = path.join(this.rootDir, 'files');
|
||||
this.manifestPath = path.join(this.rootDir, 'players.json');
|
||||
}
|
||||
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
await fs.mkdir(this.filesDir, { recursive: true });
|
||||
try {
|
||||
const raw = await fs.readFile(this.manifestPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as PlayersManifest;
|
||||
this.teams = normalizeAppPlayerTeams(parsed.teams);
|
||||
const teamIds = new Set(this.teams.map((t) => t.id));
|
||||
this.players = (Array.isArray(parsed.players) ? parsed.players : [])
|
||||
.map((p) => normalizeAppPlayer(p, teamIds))
|
||||
.filter((p): p is AppPlayer => Boolean(p));
|
||||
} catch {
|
||||
this.players = [];
|
||||
this.teams = [];
|
||||
await this.persist();
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
listPlayers(): AppPlayer[] {
|
||||
return [...this.players];
|
||||
}
|
||||
|
||||
listTeams(): AppPlayerTeam[] {
|
||||
return [...this.teams];
|
||||
}
|
||||
|
||||
getById(id: PlayerId): AppPlayer | null {
|
||||
return this.players.find((p) => p.id === id) ?? null;
|
||||
}
|
||||
|
||||
getImageReadInfo(id: PlayerId): { absPath: string; mime: string } | null {
|
||||
const player = this.getById(id);
|
||||
if (!player) return null;
|
||||
const absPath = path.join(this.rootDir, player.imageRelPath);
|
||||
return { absPath, mime: mimeFromExt(path.extname(player.imageRelPath)) };
|
||||
}
|
||||
|
||||
getImageUrl(id: PlayerId): string | null {
|
||||
if (!this.getImageReadInfo(id)) return null;
|
||||
return `dnd://player?id=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
input: {
|
||||
id?: PlayerId | null;
|
||||
name: string;
|
||||
filePath?: string | null;
|
||||
teamId?: PlayerTeamId | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: PlayerImageOffset;
|
||||
imageScale?: number;
|
||||
},
|
||||
onProgress?: (p: PlayersUpsertProgressEvent) => void,
|
||||
): Promise<AppPlayer> {
|
||||
await this.ensureLoaded();
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error('Player name is required');
|
||||
|
||||
const existing = input.id ? this.getById(input.id) : null;
|
||||
if (input.id && !existing) throw new Error('Player not found');
|
||||
if (!existing && !input.filePath) throw new Error('Player image is required');
|
||||
|
||||
const emit = (percent: number, stage: string, detail?: string) => {
|
||||
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
|
||||
};
|
||||
|
||||
emit(5, 'prepare', 'Подготовка…');
|
||||
|
||||
let imageRelPath = existing?.imageRelPath ?? '';
|
||||
let sha256 = existing?.sha256 ?? '';
|
||||
const id = existing?.id ?? randomPlayerId();
|
||||
|
||||
if (input.filePath) {
|
||||
emit(15, 'read', 'Чтение изображения…');
|
||||
let buf = await fs.readFile(input.filePath);
|
||||
emit(40, 'optimize', 'Оптимизация…');
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
/* keep original */
|
||||
}
|
||||
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const ext = path.extname(input.filePath) || '.png';
|
||||
const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`;
|
||||
imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
|
||||
const abs = path.join(this.rootDir, imageRelPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
emit(75, 'write', 'Сохранение…');
|
||||
await fs.writeFile(abs, buf);
|
||||
if (existing && existing.imageRelPath !== imageRelPath) {
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const teamIds = new Set(this.teams.map((t) => t.id));
|
||||
let teamId: PlayerTeamId | null =
|
||||
input.teamId !== undefined ? input.teamId : (existing?.teamId ?? null);
|
||||
if (teamId && !teamIds.has(teamId)) teamId = null;
|
||||
|
||||
const player: AppPlayer = {
|
||||
id,
|
||||
name,
|
||||
imageRelPath,
|
||||
sha256,
|
||||
teamId,
|
||||
ringColor: normalizeHexColor(
|
||||
input.ringColor ?? existing?.ringColor,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
),
|
||||
imageOffset: clampPlayerImageOffset(
|
||||
input.imageOffset ?? existing?.imageOffset ?? DEFAULT_PLAYER_IMAGE_OFFSET,
|
||||
),
|
||||
imageScale: clampPlayerImageScale(
|
||||
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
),
|
||||
};
|
||||
if (existing?.systemId !== undefined) player.systemId = existing.systemId;
|
||||
if (existing?.characterSheet !== undefined) player.characterSheet = existing.characterSheet;
|
||||
|
||||
if (existing) {
|
||||
this.players = this.players.map((p) => (p.id === player.id ? player : p));
|
||||
} else {
|
||||
this.players = [...this.players, player];
|
||||
}
|
||||
emit(95, 'persist', 'Запись…');
|
||||
await this.persist();
|
||||
emit(100, 'done', 'Готово');
|
||||
return player;
|
||||
}
|
||||
|
||||
async delete(id: PlayerId): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return;
|
||||
this.players = this.players.filter((p) => p.id !== id);
|
||||
await this.persist();
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async setPlayersOrder(playerIds: PlayerId[]): Promise<AppPlayer[]> {
|
||||
await this.ensureLoaded();
|
||||
const byId = new Map(this.players.map((p) => [p.id, p]));
|
||||
const next: AppPlayer[] = [];
|
||||
for (const id of playerIds) {
|
||||
const p = byId.get(id);
|
||||
if (p) {
|
||||
next.push(p);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
for (const p of byId.values()) next.push(p);
|
||||
this.players = next;
|
||||
await this.persist();
|
||||
return this.listPlayers();
|
||||
}
|
||||
|
||||
async upsertTeam(input: {
|
||||
id?: PlayerTeamId | null;
|
||||
name: string;
|
||||
color?: string;
|
||||
}): Promise<AppPlayerTeam> {
|
||||
await this.ensureLoaded();
|
||||
const existing = input.id ? this.teams.find((t) => t.id === input.id) : null;
|
||||
if (input.id && !existing) throw new Error('Team not found');
|
||||
if (existing) {
|
||||
const team: AppPlayerTeam = {
|
||||
id: existing.id,
|
||||
name: uniquePlayerTeamName(input.name, this.teams, existing.id),
|
||||
color: normalizeHexColor(input.color ?? existing.color, DEFAULT_PLAYER_RING_COLOR),
|
||||
};
|
||||
this.teams = this.teams.map((t) => (t.id === team.id ? team : t));
|
||||
await this.persist();
|
||||
return team;
|
||||
}
|
||||
const team = createPlayerTeamDraft(input.name, input.color, this.teams);
|
||||
this.teams = [...this.teams, team];
|
||||
await this.persist();
|
||||
return team;
|
||||
}
|
||||
|
||||
async deleteTeam(id: PlayerTeamId): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const next = deletePlayerTeam(this.teams, this.players, id);
|
||||
this.teams = next.teams;
|
||||
this.players = next.players;
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async setTeamsOrder(teamIds: PlayerTeamId[]): Promise<AppPlayerTeam[]> {
|
||||
await this.ensureLoaded();
|
||||
const byId = new Map(this.teams.map((t) => [t.id, t]));
|
||||
const next: AppPlayerTeam[] = [];
|
||||
for (const id of teamIds) {
|
||||
const t = byId.get(id);
|
||||
if (t) {
|
||||
next.push(t);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
for (const t of byId.values()) next.push(t);
|
||||
this.teams = next;
|
||||
await this.persist();
|
||||
return this.listTeams();
|
||||
}
|
||||
|
||||
async assignPlayerTeam(playerId: PlayerId, teamId: PlayerTeamId | null): Promise<AppPlayer | null> {
|
||||
await this.ensureLoaded();
|
||||
const teamIds = new Set(this.teams.map((t) => t.id as string));
|
||||
this.players = assignPlayerToTeam(this.players, playerId, teamId, teamIds);
|
||||
await this.persist();
|
||||
return this.getById(playerId);
|
||||
}
|
||||
|
||||
/** Clears systemId / characterSheet on players bound to a removed game system. */
|
||||
async clearSystemBindings(systemId: string): Promise<number> {
|
||||
await this.ensureLoaded();
|
||||
let changed = 0;
|
||||
this.players = this.players.map((p) => {
|
||||
const hit = p.systemId === systemId || p.characterSheet?.systemId === systemId;
|
||||
if (!hit) return p;
|
||||
changed += 1;
|
||||
return { ...p, systemId: null, characterSheet: null };
|
||||
});
|
||||
if (changed > 0) await this.persist();
|
||||
return changed;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
const payload: PlayersManifest = { players: this.players, teams: this.teams };
|
||||
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
|
||||
|
||||
import { SceneNpcTokensSessionStore } from './sceneNpcTokensSessionStore';
|
||||
|
||||
void test('SceneNpcTokensSessionStore move and reset', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
|
||||
assert.equal(s1.byPlacementId.a?.nx, 0.2);
|
||||
assert.equal(s1.revision, 2);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
|
||||
assert.equal(s2.revision, s1.revision); // no-op
|
||||
const s3 = store.reset();
|
||||
assert.deepEqual(s3.byPlacementId, {});
|
||||
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
|
||||
assert.ok(s3.revision > s1.revision);
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setScale', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setScale', scale: 1.5 });
|
||||
assert.equal(s1.scale, 1.5);
|
||||
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.1, ny: 0.2 });
|
||||
const s2 = store.dispatch({ kind: 'setScale', scale: 0.1 });
|
||||
assert.equal(s2.scale, 0.4); // clamped min
|
||||
assert.ok(s2.byPlacementId.a);
|
||||
const s3 = store.reset();
|
||||
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
|
||||
assert.deepEqual(s3.byPlacementId, {});
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setDisposition without inventing position', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({
|
||||
kind: 'setDisposition',
|
||||
placementId: 'a',
|
||||
disposition: 'hostile',
|
||||
});
|
||||
assert.equal(s1.byPlacementId.a?.disposition, 'hostile');
|
||||
assert.equal(s1.byPlacementId.a?.nx, undefined);
|
||||
assert.equal(s1.byPlacementId.a?.ny, undefined);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.4, ny: 0.6 });
|
||||
assert.equal(s2.byPlacementId.a?.nx, 0.4);
|
||||
assert.equal(s2.byPlacementId.a?.disposition, 'hostile');
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore spawn is session-only and despawn removes it', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({
|
||||
kind: 'spawn',
|
||||
npcId: 'npc1',
|
||||
sceneId: 'sceneA',
|
||||
nx: 0.4,
|
||||
ny: 0.6,
|
||||
disposition: 'hostile',
|
||||
});
|
||||
const ids = Object.keys(s1.spawned);
|
||||
assert.equal(ids.length, 1);
|
||||
const spawn = s1.spawned[ids[0]!];
|
||||
assert.equal(spawn?.npcId, 'npc1');
|
||||
assert.equal(spawn?.sceneId, 'sceneA');
|
||||
assert.equal(spawn?.nx, 0.4);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: ids[0]!, nx: 0.7, ny: 0.2 });
|
||||
assert.equal(s2.spawned[ids[0]!]?.nx, 0.7);
|
||||
const s3 = store.dispatch({ kind: 'despawn', placementId: ids[0]! });
|
||||
assert.deepEqual(s3.spawned, {});
|
||||
assert.equal(s3.byPlacementId[ids[0]!], undefined);
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
|
||||
assert.equal(s1.byPlacementId.a?.inactive, true);
|
||||
const s2 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
|
||||
assert.deepEqual(s2.byPlacementId, {});
|
||||
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
|
||||
store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
|
||||
const s3 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
|
||||
assert.equal(s3.byPlacementId.a?.nx, 0.2);
|
||||
assert.equal(s3.byPlacementId.a?.inactive, undefined);
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore hide from players keeps the token for the GM', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: true });
|
||||
assert.equal(s1.byPlacementId.a?.hiddenFromPlayers, true);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.4 });
|
||||
assert.equal(s2.byPlacementId.a?.hiddenFromPlayers, true);
|
||||
assert.equal(s2.byPlacementId.a?.nx, 0.2);
|
||||
const s3 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: false });
|
||||
assert.equal(s3.byPlacementId.a?.hiddenFromPlayers, undefined);
|
||||
assert.equal(s3.byPlacementId.a?.nx, 0.2);
|
||||
const only = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: true });
|
||||
store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: false });
|
||||
assert.equal(store.getState().byPlacementId.b, undefined);
|
||||
assert.ok(only.byPlacementId.b?.hiddenFromPlayers);
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
|
||||
assert.equal(s1.byPlacementId.a?.sizeN, 0.2);
|
||||
assert.equal(s1.byPlacementId.a?.nx, undefined);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.3, ny: 0.4 });
|
||||
assert.equal(s2.byPlacementId.a?.sizeN, 0.2);
|
||||
assert.equal(s2.byPlacementId.a?.nx, 0.3);
|
||||
const spawned = store.dispatch({
|
||||
kind: 'spawn',
|
||||
npcId: 'npc1',
|
||||
sceneId: 'sceneA',
|
||||
nx: 0.5,
|
||||
ny: 0.5,
|
||||
});
|
||||
const id = Object.keys(spawned.spawned)[0]!;
|
||||
const s3 = store.dispatch({ kind: 'setSize', placementId: id, sizeN: 0.18 });
|
||||
assert.equal(s3.spawned[id]?.sizeN, 0.18);
|
||||
assert.equal(s3.byPlacementId[id]?.sizeN, 0.18);
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
clampNpcTokenSessionScale,
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
asSceneNpcTokenId,
|
||||
emptySceneNpcTokensSessionState,
|
||||
newSceneNpcSessionSpawnId,
|
||||
type SceneNpcSessionSpawn,
|
||||
type SceneNpcTokensSessionEvent,
|
||||
type SceneNpcTokensSessionPlacement,
|
||||
type SceneNpcTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { asNpcId } from '../../shared/types/ids';
|
||||
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
||||
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||
|
||||
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
return (
|
||||
p.nx === undefined &&
|
||||
p.ny === undefined &&
|
||||
p.sizeN === undefined &&
|
||||
!p.disposition &&
|
||||
!p.inactive &&
|
||||
!p.hiddenFromPlayers
|
||||
);
|
||||
}
|
||||
|
||||
function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcTokensSessionPlacement {
|
||||
return {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withMove(
|
||||
prev: SceneNpcTokensSessionPlacement | undefined,
|
||||
nx: number,
|
||||
ny: number,
|
||||
): SceneNpcTokensSessionPlacement {
|
||||
return {
|
||||
...restOf(prev),
|
||||
nx,
|
||||
ny,
|
||||
};
|
||||
}
|
||||
|
||||
function withoutPlacement(
|
||||
state: SceneNpcTokensSessionState,
|
||||
placementId: string,
|
||||
): SceneNpcTokensSessionState {
|
||||
if (!(placementId in state.byPlacementId)) return state;
|
||||
const { [placementId]: _removed, ...rest } = state.byPlacementId;
|
||||
return {
|
||||
revision: state.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: state.scale,
|
||||
spawned: state.spawned,
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneNpcTokensSessionStore {
|
||||
private state: SceneNpcTokensSessionState = emptySceneNpcTokensSessionState();
|
||||
|
||||
getState(): SceneNpcTokensSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): SceneNpcTokensSessionState {
|
||||
if (
|
||||
Object.keys(this.state.byPlacementId).length === 0 &&
|
||||
Object.keys(this.state.spawned).length === 0 &&
|
||||
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = emptySceneNpcTokensSessionState(this.state.revision + 1);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
|
||||
rotateMapCwSteps(steps: number): SceneNpcTokensSessionState {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) return this.state;
|
||||
const ids = Object.keys(this.state.byPlacementId);
|
||||
const spawnIds = Object.keys(this.state.spawned);
|
||||
if (ids.length === 0 && spawnIds.length === 0) return this.state;
|
||||
let changed = false;
|
||||
const byPlacementId: SceneNpcTokensSessionState['byPlacementId'] = {};
|
||||
for (const id of ids) {
|
||||
const prev = this.state.byPlacementId[id];
|
||||
if (!prev) continue;
|
||||
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
|
||||
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
byPlacementId[id] = { ...prev, nx: p.nx, ny: p.ny };
|
||||
changed = true;
|
||||
} else {
|
||||
byPlacementId[id] = prev;
|
||||
}
|
||||
}
|
||||
const spawned: SceneNpcTokensSessionState['spawned'] = {};
|
||||
for (const id of spawnIds) {
|
||||
const prev = this.state.spawned[id];
|
||||
if (!prev) continue;
|
||||
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
spawned[id] = { ...prev, nx: p.nx, ny: p.ny };
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId,
|
||||
scale: this.state.scale,
|
||||
spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'setScale': {
|
||||
const scale = clampNpcTokenSessionScale(event.scale);
|
||||
if (this.state.scale === scale) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'spawn': {
|
||||
const npcId = String(event.npcId ?? '');
|
||||
const sceneId = String(event.sceneId ?? '');
|
||||
if (!npcId || !sceneId) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const id = event.placementId
|
||||
? asSceneNpcTokenId(String(event.placementId))
|
||||
: newSceneNpcSessionSpawnId();
|
||||
const spawn: SceneNpcSessionSpawn = {
|
||||
id,
|
||||
sceneId,
|
||||
npcId: asNpcId(npcId),
|
||||
nx,
|
||||
ny,
|
||||
sizeN: clampSceneNpcTokenSizeN(
|
||||
typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
),
|
||||
disposition: normalizeNpcDisposition(event.disposition),
|
||||
};
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: this.state.byPlacementId,
|
||||
scale: this.state.scale,
|
||||
spawned: { ...this.state.spawned, [String(spawn.id)]: spawn },
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'despawn': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
if (!(placementId in this.state.spawned) && !(placementId in this.state.byPlacementId)) {
|
||||
return this.state;
|
||||
}
|
||||
const { [placementId]: _s, ...spawned } = this.state.spawned;
|
||||
const { [placementId]: _p, ...byPlacementId } = this.state.byPlacementId;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId,
|
||||
scale: this.state.scale,
|
||||
spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'move': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
const spawn = this.state.spawned[placementId];
|
||||
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
||||
const spawned = spawn
|
||||
? { ...this.state.spawned, [placementId]: { ...spawn, nx, ny } }
|
||||
: this.state.spawned;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: withMove(prev, nx, ny),
|
||||
},
|
||||
scale: this.state.scale,
|
||||
spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setDisposition': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const disposition = normalizeNpcDisposition(event.disposition);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
const spawn = this.state.spawned[placementId];
|
||||
if (prev?.disposition === disposition && (!spawn || spawn.disposition === disposition)) {
|
||||
return this.state;
|
||||
}
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...restOf(prev),
|
||||
disposition,
|
||||
};
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
spawned: spawn
|
||||
? { ...this.state.spawned, [placementId]: { ...spawn, disposition } }
|
||||
: this.state.spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setInactive': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const inactive = Boolean(event.inactive);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (Boolean(prev?.inactive) === inactive) return this.state;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
if (isEmptyPlacement(next)) {
|
||||
this.state = withoutPlacement(this.state, placementId);
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
spawned: this.state.spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setHiddenFromPlayers': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const hidden = Boolean(event.hidden);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (Boolean(prev?.hiddenFromPlayers) === hidden) return this.state;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...restOf(prev),
|
||||
...(hidden ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
if (!hidden) delete next.hiddenFromPlayers;
|
||||
if (isEmptyPlacement(next)) {
|
||||
this.state = withoutPlacement(this.state, placementId);
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
spawned: this.state.spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setSize': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
const spawn = this.state.spawned[placementId];
|
||||
if (prev?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: { ...restOf(prev), sizeN },
|
||||
},
|
||||
scale: this.state.scale,
|
||||
spawned: spawn
|
||||
? { ...this.state.spawned, [placementId]: { ...spawn, sizeN } }
|
||||
: this.state.spawned,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ScenePlayerTokensSessionStore } from './scenePlayerTokensSessionStore';
|
||||
|
||||
void test('ScenePlayerTokensSessionStore selection and show/hide', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['a', 'b', 'a'] });
|
||||
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
|
||||
assert.equal(store.getState().visible, false);
|
||||
|
||||
store.dispatch({ kind: 'show', sizeN: 0.08 });
|
||||
assert.equal(store.getState().visible, true);
|
||||
assert.equal(Object.keys(store.getState().byPlayerId).length, 2);
|
||||
assert.ok(store.getState().byPlayerId.a);
|
||||
assert.equal(store.getState().byPlayerId.a?.ny, 0.88);
|
||||
|
||||
store.dispatch({ kind: 'setVisible', visible: false });
|
||||
assert.equal(store.getState().visible, false);
|
||||
assert.ok(store.getState().byPlayerId.a);
|
||||
|
||||
store.dispatch({ kind: 'clearPlacements' });
|
||||
assert.equal(store.getState().visible, false);
|
||||
assert.deepEqual(store.getState().byPlayerId, {});
|
||||
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
|
||||
});
|
||||
|
||||
void test('ScenePlayerTokensSessionStore move and reset', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
|
||||
store.dispatch({ kind: 'show', sizeN: 0.1 });
|
||||
const s1 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.3, ny: 0.4 });
|
||||
assert.equal(s1.byPlayerId.p1?.nx, 0.3);
|
||||
assert.equal(s1.byPlayerId.p1?.ny, 0.4);
|
||||
const s2 = store.reset();
|
||||
assert.deepEqual(s2.selectedPlayerIds, []);
|
||||
assert.equal(s2.visible, false);
|
||||
assert.deepEqual(s2.byPlayerId, {});
|
||||
});
|
||||
|
||||
void test('ScenePlayerTokensSessionStore rotateMapCwSteps', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
|
||||
store.dispatch({ kind: 'show', sizeN: 0.1 });
|
||||
store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.2, ny: 0.1 });
|
||||
const s = store.rotateMapCwSteps(1);
|
||||
assert.equal(s.byPlayerId.p1?.nx, 0.9);
|
||||
assert.equal(s.byPlayerId.p1?.ny, 0.2);
|
||||
});
|
||||
|
||||
void test('ScenePlayerTokensSessionStore setSize updates placement', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
|
||||
store.dispatch({ kind: 'show', sizeN: 0.1 });
|
||||
const nxBefore = store.getState().byPlayerId.p1?.nx;
|
||||
const s1 = store.dispatch({ kind: 'setSize', playerId: 'p1', sizeN: 0.22 });
|
||||
assert.equal(s1.byPlayerId.p1?.sizeN, 0.22);
|
||||
assert.equal(s1.byPlayerId.p1?.nx, nxBefore);
|
||||
const s2 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.4, ny: 0.5 });
|
||||
assert.equal(s2.byPlayerId.p1?.sizeN, 0.22);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
layoutPlayerTokensBottom,
|
||||
type ScenePlayerTokensSessionEvent,
|
||||
type ScenePlayerTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||
|
||||
function emptyState(revision = 1): ScenePlayerTokensSessionState {
|
||||
return {
|
||||
revision,
|
||||
selectedPlayerIds: [],
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerIds(raw: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of raw) {
|
||||
const s = String(id ?? '').trim();
|
||||
if (!s || seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class ScenePlayerTokensSessionStore {
|
||||
private state: ScenePlayerTokensSessionState = emptyState();
|
||||
|
||||
getState(): ScenePlayerTokensSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): ScenePlayerTokensSessionState {
|
||||
if (
|
||||
this.state.selectedPlayerIds.length === 0 &&
|
||||
!this.state.visible &&
|
||||
Object.keys(this.state.byPlayerId).length === 0
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = emptyState(this.state.revision + 1);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Смена сцены: спрятать токены, сбросить позиции; выбор игроков сохранить. */
|
||||
clearPlacements(): ScenePlayerTokensSessionState {
|
||||
if (!this.state.visible && Object.keys(this.state.byPlayerId).length === 0) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Rotate live player tokens with the scene preview (CSS rotate steps). */
|
||||
rotateMapCwSteps(steps: number): ScenePlayerTokensSessionState {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) return this.state;
|
||||
const ids = Object.keys(this.state.byPlayerId);
|
||||
if (ids.length === 0) return this.state;
|
||||
const byPlayerId: ScenePlayerTokensSessionState['byPlayerId'] = {};
|
||||
for (const id of ids) {
|
||||
const prev = this.state.byPlayerId[id];
|
||||
if (!prev) continue;
|
||||
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
byPlayerId[id] = { ...prev, nx: p.nx, ny: p.ny };
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'clearPlacements':
|
||||
return this.clearPlacements();
|
||||
case 'setSelection': {
|
||||
const selectedPlayerIds = normalizePlayerIds(event.playerIds);
|
||||
const same =
|
||||
selectedPlayerIds.length === this.state.selectedPlayerIds.length &&
|
||||
selectedPlayerIds.every((id, i) => id === this.state.selectedPlayerIds[i]);
|
||||
if (same) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
selectedPlayerIds,
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setVisible': {
|
||||
const visible = Boolean(event.visible);
|
||||
if (this.state.visible === visible) return this.state;
|
||||
if (visible && this.state.selectedPlayerIds.length === 0) return this.state;
|
||||
this.state = { ...this.state, revision: this.state.revision + 1, visible };
|
||||
return this.state;
|
||||
}
|
||||
case 'show': {
|
||||
if (this.state.selectedPlayerIds.length === 0) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
const byPlayerId =
|
||||
Object.keys(this.state.byPlayerId).length > 0
|
||||
? this.state.byPlayerId
|
||||
: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN);
|
||||
if (this.state.visible && byPlayerId === this.state.byPlayerId) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
visible: true,
|
||||
byPlayerId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'seedBottom': {
|
||||
if (this.state.selectedPlayerIds.length === 0) return this.state;
|
||||
if (Object.keys(this.state.byPlayerId).length > 0) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN),
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'move': {
|
||||
const playerId = String(event.playerId ?? '');
|
||||
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const prev = this.state.byPlayerId[playerId];
|
||||
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(prev?.sizeN ?? DEFAULT_SCENE_NPC_TOKEN_SIZE_N);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId: {
|
||||
...this.state.byPlayerId,
|
||||
[playerId]: { nx, ny, sizeN },
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setSize': {
|
||||
const playerId = String(event.playerId ?? '');
|
||||
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
|
||||
const prev = this.state.byPlayerId[playerId];
|
||||
if (!prev) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
if (prev.sizeN === sizeN) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId: {
|
||||
...this.state.byPlayerId,
|
||||
[playerId]: { ...prev, sizeN },
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Visually lossless re-encode for imported raster images (same pixel dimensions).
|
||||
* Node-only; shared by the main app and ../project-converter (monorepo sibling).
|
||||
*/
|
||||
import sharp from 'sharp';
|
||||
import { getSharp } from './sharpRuntime.mjs';
|
||||
|
||||
/** @typedef {import('node:buffer').Buffer} Buffer */
|
||||
|
||||
@@ -102,6 +102,7 @@ function makePassthrough(buf, meta) {
|
||||
* @param {number} h0
|
||||
*/
|
||||
async function sameDimensionsOrThrow(outBuf, w0, h0) {
|
||||
const sharp = getSharp();
|
||||
const m = await sharp(outBuf).metadata();
|
||||
if ((m.width ?? 0) !== w0 || (m.height ?? 0) !== h0) {
|
||||
const err = new Error('encode changed dimensions');
|
||||
@@ -120,6 +121,13 @@ export async function optimizeImageBufferVisuallyLossless(src) {
|
||||
return makePassthrough(input, { width: 0, height: 0, format: 'png' });
|
||||
}
|
||||
|
||||
let sharp;
|
||||
try {
|
||||
sharp = getSharp();
|
||||
} catch {
|
||||
return makePassthrough(input, null);
|
||||
}
|
||||
|
||||
let meta0;
|
||||
try {
|
||||
meta0 = await sharp(input, { failOn: 'error', unlimited: true }).metadata();
|
||||
|
||||
@@ -33,3 +33,19 @@ void test('generateScenePreviewThumbnailBytes: image scales to max edge', async
|
||||
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
void test('generateScenePreviewThumbnailBytes: accepts image Buffer', async () => {
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 120,
|
||||
height: 80,
|
||||
channels: 3,
|
||||
background: { r: 10, g: 20, b: 30 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const buf = await generateScenePreviewThumbnailBytes(png, 'image');
|
||||
assert.ok(buf !== null);
|
||||
assert.ok(buf.length > 0);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import ffmpegStatic from 'ffmpeg-static';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getSharp } from './sharpRuntime.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -14,14 +15,16 @@ export const SCENE_PREVIEW_THUMB_MAX_PX = 320;
|
||||
|
||||
/**
|
||||
* Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds).
|
||||
* For images, prefer a Buffer so callers are not racy with reconcileAssetFiles unlinking paths.
|
||||
*/
|
||||
export async function generateScenePreviewThumbnailBytes(
|
||||
sourceAbsPath: string,
|
||||
source: string | Buffer,
|
||||
kind: 'image' | 'video',
|
||||
): Promise<Buffer | null> {
|
||||
try {
|
||||
const sharp = getSharp();
|
||||
if (kind === 'image') {
|
||||
return await sharp(sourceAbsPath)
|
||||
return await sharp(source)
|
||||
.rotate()
|
||||
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
|
||||
fit: 'inside',
|
||||
@@ -31,6 +34,9 @@ export async function generateScenePreviewThumbnailBytes(
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const sourceAbsPath = typeof source === 'string' ? source : null;
|
||||
if (!sourceAbsPath) return null;
|
||||
|
||||
const ffmpegPath = ffmpegStatic;
|
||||
if (!ffmpegPath) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Lazy `sharp` load so a corrupt/missing native install does not crash Electron at import time.
|
||||
* Call only from image-processing paths; errors are recoverable for the rest of the app.
|
||||
*
|
||||
* Note: main is bundled to CJS (esbuild). `import.meta.url` is empty there — prefer `__filename`.
|
||||
*/
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
function requireBaseFilename() {
|
||||
// CJS bundle / Electron main
|
||||
if (typeof __filename === 'string' && __filename.length > 0) {
|
||||
return __filename;
|
||||
}
|
||||
// Direct ESM (unit tests)
|
||||
const metaUrl = import.meta.url;
|
||||
if (typeof metaUrl === 'string' && metaUrl.startsWith('file:')) {
|
||||
return fileURLToPath(metaUrl);
|
||||
}
|
||||
return path.join(process.cwd(), 'package.json');
|
||||
}
|
||||
|
||||
const require = createRequire(requireBaseFilename());
|
||||
|
||||
/** @type {typeof import('sharp') | null} */
|
||||
let cached = null;
|
||||
/** @type {Error | null} */
|
||||
let loadError = null;
|
||||
|
||||
/**
|
||||
* @param {unknown} err
|
||||
* @returns {Error}
|
||||
*/
|
||||
export function sharpLoadFailure(err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return new Error(
|
||||
[
|
||||
'Не удалось загрузить модуль обработки изображений (sharp).',
|
||||
'Переустановите приложение полностью (удалите и поставьте заново)',
|
||||
'или исключите папку установки из проверки антивируса.',
|
||||
detail ? `Детали: ${detail}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {typeof import('sharp')}
|
||||
*/
|
||||
export function getSharp() {
|
||||
if (cached) return cached;
|
||||
if (loadError) throw loadError;
|
||||
try {
|
||||
cached = require('sharp');
|
||||
return cached;
|
||||
} catch (err) {
|
||||
loadError = sharpLoadFailure(err);
|
||||
throw loadError;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset cache (tests only). */
|
||||
export function __resetSharpRuntimeForTests() {
|
||||
cached = null;
|
||||
loadError = null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
__resetSharpRuntimeForTests,
|
||||
getSharp,
|
||||
sharpLoadFailure,
|
||||
} from './sharpRuntime.mjs';
|
||||
|
||||
void test('getSharp: loads sharp when install is healthy', () => {
|
||||
__resetSharpRuntimeForTests();
|
||||
const sharp = getSharp();
|
||||
assert.equal(typeof sharp, 'function');
|
||||
});
|
||||
|
||||
void test('sharpLoadFailure: includes reinstall hint', () => {
|
||||
const err = sharpLoadFailure(new Error('SyntaxError: Unexpected end of input'));
|
||||
assert.match(err.message, /переустановите/i);
|
||||
assert.match(err.message, /SyntaxError/);
|
||||
});
|
||||
@@ -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: [],
|
||||
|
||||
@@ -50,6 +50,17 @@ void test('zipStore: pack and open operations are serialized', () => {
|
||||
assert.match(src, /enqueueProjectSwitch/);
|
||||
});
|
||||
|
||||
void test('zipStore: project updates and preview finalize are serialized', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /private projectUpdateChain: Promise<unknown>/);
|
||||
assert.match(src, /private previewFinalizeChain: Promise<unknown>/);
|
||||
assert.match(src, /enqueueProjectUpdate/);
|
||||
assert.match(src, /enqueuePreviewFinalize/);
|
||||
assert.match(src, /finalizeScenePreviewImportInner/);
|
||||
assert.match(src, /drainProjectMutations/);
|
||||
assert.match(src, /addBuffer/);
|
||||
});
|
||||
|
||||
void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/);
|
||||
@@ -69,6 +80,23 @@ void test('zipStore: normalizeScene defaults previewThumbAssetId for older proje
|
||||
assert.match(src, /function normalizeScene\(/);
|
||||
});
|
||||
|
||||
void test('zipStore: NPC parse defaults externalUrl', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /externalUrl: normalizeNpcExternalUrl/);
|
||||
});
|
||||
|
||||
void test('zipStore: updateScene merges patch inside updateProject mutator (no stale base race)', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
const i = src.indexOf('async updateScene(sceneId: SceneId, patch: ScenePatch)');
|
||||
assert.ok(i >= 0);
|
||||
const j = src.indexOf('async updateConnections(', i);
|
||||
assert.ok(j > i);
|
||||
const block = src.slice(i, j);
|
||||
assert.match(block, /await this\.updateProject\(\(p\) => \{/);
|
||||
assert.match(block, /const existing = p\.scenes\[sceneId\]/);
|
||||
assert.doesNotMatch(block, /const existing = open\.project\.scenes\[sceneId\]/);
|
||||
});
|
||||
|
||||
void test('zipStore: listProjects skips unreadable archives', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(
|
||||
@@ -77,6 +105,50 @@ void test('zipStore: listProjects skips unreadable archives', () => {
|
||||
);
|
||||
});
|
||||
|
||||
void test('zipStore: setSessionPointer updates pointer in memory without disk write', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
const m = src.match(/setSessionPointer\([\s\S]*?\n \}/);
|
||||
assert.ok(m);
|
||||
assert.doesNotMatch(m[0], /writeCacheProject|queueSave|enqueueProjectUpdate/);
|
||||
});
|
||||
|
||||
void test('main index: setCurrentGraphNode uses setSessionPointer not updateProject', () => {
|
||||
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
|
||||
const m = src.match(
|
||||
/registerHandler\(ipcChannels\.project\.setCurrentGraphNode[\s\S]*?emitSessionState\(\)/,
|
||||
);
|
||||
assert.ok(m);
|
||||
assert.match(m[0], /setSessionPointer/);
|
||||
assert.doesNotMatch(m[0], /updateProject/);
|
||||
});
|
||||
|
||||
void test('main index: full zip import loads app-tokens like playlists', () => {
|
||||
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
|
||||
assert.match(src, /async function importAppTokensFromOpenProjectCache/);
|
||||
assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/);
|
||||
assert.match(src, /remapProjectSceneTokenIds/);
|
||||
const importZip = src.match(
|
||||
/registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/,
|
||||
);
|
||||
assert.ok(importZip);
|
||||
assert.match(importZip[0], /importAppTokensFromOpenProjectCache/);
|
||||
const importFromPath = src.match(
|
||||
/registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/,
|
||||
);
|
||||
assert.ok(importFromPath);
|
||||
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
|
||||
});
|
||||
|
||||
void test('zipStore: zipDir uses ensureParentDir (no mkdir of drive root)', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /from '\.\/ensureParentDir'/);
|
||||
const i = src.indexOf('async function zipDir(');
|
||||
assert.ok(i >= 0);
|
||||
const block = src.slice(i, i + 800);
|
||||
assert.match(block, /await ensureParentDir\(outZipPath\)/);
|
||||
assert.doesNotMatch(block, /fs\.mkdir\(path\.dirname\(outZipPath\)/);
|
||||
});
|
||||
|
||||
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
||||
const i = src.indexOf('export async function replaceFileAtomic');
|
||||
|
||||
+582
-282
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@ import fs from 'node:fs/promises';
|
||||
|
||||
import { session } from 'electron';
|
||||
|
||||
import { asAssetId, asTokenId } from '../../shared/types/ids';
|
||||
import { asAssetId, asPlayerId, asPlaylistAudioId, asTokenId } from '../../shared/types/ids';
|
||||
import type { PlayersStore } from '../players/playersStore';
|
||||
import type { PlaylistsStore } from '../playlists/playlistsStore';
|
||||
import type { ZipProjectStore } from '../project/zipStore';
|
||||
import type { TokensStore } from '../tokens/tokensStore';
|
||||
|
||||
@@ -75,11 +77,13 @@ async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Обслуживает `dnd://asset?...` и `dnd://token?...`.
|
||||
* Обслуживает `dnd://asset?...`, `dnd://token?...`, `dnd://player?...` и `dnd://playlist-audio?...`.
|
||||
*/
|
||||
export function registerDndAssetProtocol(
|
||||
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);
|
||||
@@ -92,6 +96,10 @@ export function registerDndAssetProtocol(
|
||||
info = projectStore.getAssetReadInfo(asAssetId(id));
|
||||
} else if (url.hostname === 'token') {
|
||||
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 });
|
||||
|
||||
@@ -52,3 +52,14 @@ void test('session IPC: нет emitSessionState на graph/NPC position hot-path
|
||||
);
|
||||
assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/);
|
||||
});
|
||||
|
||||
void test('video IPC heartbeat does not fan out to every BrowserWindow', () => {
|
||||
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
|
||||
const emit =
|
||||
/function emitVideoState\(\): void \{([\s\S]*?)\n\}/.exec(index);
|
||||
assert.ok(emit, 'emitVideoState');
|
||||
assert.doesNotMatch(emit[1] ?? '', /getAllWindows/);
|
||||
assert.match(emit[1] ?? '', /sendToAppWindows\(ipcChannels\.video\.stateChanged/);
|
||||
assert.match(emit[1] ?? '', /'control'/);
|
||||
assert.match(emit[1] ?? '', /'presentation'/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { TimerSessionStore } from './timerSessionStore';
|
||||
import {
|
||||
clampSessionTimerLayout,
|
||||
formatSessionTimerMs,
|
||||
getSessionTimerRemainingMs,
|
||||
parseSessionTimerDurationInput,
|
||||
} from '../../shared/types/sessionTimer';
|
||||
|
||||
void test('getSessionTimerRemainingMs: paused uses remainingMs', () => {
|
||||
const store = new TimerSessionStore();
|
||||
const s = store.getState();
|
||||
assert.equal(getSessionTimerRemainingMs(s, 1_000_000), s.remainingMs);
|
||||
});
|
||||
|
||||
void test('start pause start preserves remaining', () => {
|
||||
const store = new TimerSessionStore();
|
||||
const t0 = 1_000_000;
|
||||
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
|
||||
store.dispatch({ kind: 'start' }, t0);
|
||||
store.dispatch({ kind: 'pause' }, t0 + 10_000);
|
||||
const afterPause = store.getState();
|
||||
assert.equal(afterPause.running, false);
|
||||
assert.equal(afterPause.remainingMs, 50_000);
|
||||
store.dispatch({ kind: 'start' }, t0 + 20_000);
|
||||
assert.equal(getSessionTimerRemainingMs(store.getState(), t0 + 25_000), 45_000);
|
||||
});
|
||||
|
||||
void test('reset restores duration', () => {
|
||||
const store = new TimerSessionStore();
|
||||
store.dispatch({ kind: 'setDuration', durationMs: 90_000 });
|
||||
store.dispatch({ kind: 'start' }, 0);
|
||||
store.dispatch({ kind: 'pause' }, 30_000);
|
||||
store.dispatch({ kind: 'reset' });
|
||||
assert.equal(store.getState().remainingMs, 90_000);
|
||||
assert.equal(store.getState().running, false);
|
||||
});
|
||||
|
||||
void test('setVisibleToPlayers and layout.set', () => {
|
||||
const store = new TimerSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
|
||||
assert.equal(store.getState().visibleToPlayers, true);
|
||||
store.dispatch({
|
||||
kind: 'layout.set',
|
||||
layout: { cx: 0.2, cy: 0.3, scale: 2, rotationDeg: 45 },
|
||||
});
|
||||
assert.equal(store.getState().layout.cx, 0.2);
|
||||
assert.equal(store.getState().layout.scale, 2);
|
||||
assert.equal(store.getState().layout.rotationDeg, 45);
|
||||
});
|
||||
|
||||
void test('settleIfExpired stops at zero', () => {
|
||||
const store = new TimerSessionStore();
|
||||
store.dispatch({ kind: 'setDuration', durationMs: 5_000 }, 0);
|
||||
store.dispatch({ kind: 'start' }, 0);
|
||||
store.settleIfExpired(10_000);
|
||||
assert.equal(store.getState().running, false);
|
||||
assert.equal(store.getState().remainingMs, 0);
|
||||
});
|
||||
|
||||
void test('clampSessionTimerLayout / format / parse', () => {
|
||||
const clamped = clampSessionTimerLayout({ cx: 9, cy: -9, scale: 100, rotationDeg: 370 });
|
||||
assert.ok(clamped.cx <= 1.2);
|
||||
assert.ok(clamped.cy >= -0.2);
|
||||
assert.ok(clamped.scale <= 8);
|
||||
assert.equal(clamped.rotationDeg, 10);
|
||||
assert.equal(formatSessionTimerMs(65_000), '01:05');
|
||||
assert.equal(formatSessionTimerMs(3_661_000), '1:01:01');
|
||||
assert.equal(parseSessionTimerDurationInput('5:00'), 300_000);
|
||||
assert.equal(parseSessionTimerDurationInput('1:02:03'), 3723_000);
|
||||
assert.equal(parseSessionTimerDurationInput('bad'), null);
|
||||
});
|
||||
|
||||
void test('clear restores defaults', () => {
|
||||
const store = new TimerSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
|
||||
store.dispatch({ kind: 'start' }, 0);
|
||||
store.clear();
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
assert.equal(store.getState().running, false);
|
||||
});
|
||||
|
||||
void test('dismiss on window close: pause + hide from players', () => {
|
||||
const store = new TimerSessionStore();
|
||||
const t0 = 1_000_000;
|
||||
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
|
||||
store.dispatch({ kind: 'start' }, t0);
|
||||
store.dispatch({ kind: 'pause' }, t0 + 5_000);
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
assert.equal(store.getState().running, false);
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
assert.equal(store.getState().remainingMs, 55_000);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
clampSessionTimerDurationMs,
|
||||
clampSessionTimerLayout,
|
||||
createInitialSessionTimerState,
|
||||
getSessionTimerRemainingMs,
|
||||
type SessionTimerEvent,
|
||||
type SessionTimerState,
|
||||
} from '../../shared/types/sessionTimer';
|
||||
|
||||
export class TimerSessionStore {
|
||||
private state: SessionTimerState = createInitialSessionTimerState();
|
||||
|
||||
getState(): SessionTimerState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
clear(): SessionTimerState {
|
||||
this.state = createInitialSessionTimerState();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SessionTimerEvent, now = Date.now()): SessionTimerState {
|
||||
switch (event.kind) {
|
||||
case 'setDuration': {
|
||||
const durationMs = clampSessionTimerDurationMs(event.durationMs);
|
||||
if (this.state.running) {
|
||||
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
durationMs,
|
||||
remainingMs: Math.min(remaining, durationMs),
|
||||
endsAt: now + Math.min(remaining, durationMs),
|
||||
};
|
||||
} else {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
durationMs,
|
||||
remainingMs: durationMs,
|
||||
endsAt: null,
|
||||
running: false,
|
||||
};
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
case 'start': {
|
||||
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||
if (remaining <= 0) {
|
||||
// Restart from full duration if already at zero.
|
||||
const durationMs = this.state.durationMs;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
remainingMs: durationMs,
|
||||
running: true,
|
||||
endsAt: now + durationMs,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
running: true,
|
||||
endsAt: now + remaining,
|
||||
remainingMs: remaining,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'pause': {
|
||||
if (!this.state.running) return this.state;
|
||||
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
running: false,
|
||||
endsAt: null,
|
||||
remainingMs: remaining,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'reset': {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
running: false,
|
||||
endsAt: null,
|
||||
remainingMs: this.state.durationMs,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setVisibleToPlayers': {
|
||||
if (this.state.visibleToPlayers === event.visible) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
visibleToPlayers: event.visible,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'layout.set': {
|
||||
const layout = clampSessionTimerLayout(event.layout);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
layout,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Когда running и время вышло — зафиксировать 00:00 (вызывается из UI/main по желанию). */
|
||||
settleIfExpired(now = Date.now()): SessionTimerState {
|
||||
if (!this.state.running) return this.state;
|
||||
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||
if (remaining > 0) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
running: false,
|
||||
endsAt: null,
|
||||
remainingMs: 0,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SceneTokensSessionStore } from './sceneTokensSessionStore';
|
||||
|
||||
void test('SceneTokensSessionStore setSize is session-only and survives move', () => {
|
||||
const store = new SceneTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.2 });
|
||||
assert.equal(s1.byPlacementId.t1?.sizeN, 0.2);
|
||||
assert.equal(s1.byPlacementId.t1?.nx, undefined);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 't1', nx: 0.3, ny: 0.4 });
|
||||
assert.equal(s2.byPlacementId.t1?.sizeN, 0.2);
|
||||
assert.equal(s2.byPlacementId.t1?.nx, 0.3);
|
||||
const s3 = store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.01 });
|
||||
assert.equal(s3.byPlacementId.t1?.sizeN, 0.02); // clamped min
|
||||
});
|
||||
|
||||
void test('SceneTokensSessionStore rotateMapCwSteps keeps sizeN', () => {
|
||||
const store = new SceneTokensSessionStore();
|
||||
store.dispatch({ kind: 'move', placementId: 't1', nx: 0.2, ny: 0.1 });
|
||||
store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.15 });
|
||||
const s = store.rotateMapCwSteps(1);
|
||||
assert.equal(s.byPlacementId.t1?.nx, 0.9);
|
||||
assert.equal(s.byPlacementId.t1?.ny, 0.2);
|
||||
assert.equal(s.byPlacementId.t1?.sizeN, 0.15);
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
|
||||
import {
|
||||
clampSceneTokenSizeN,
|
||||
type SceneTokensSessionEvent,
|
||||
type SceneTokensSessionPlacement,
|
||||
type SceneTokensSessionState,
|
||||
} from '../../shared/types';
|
||||
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||
|
||||
function emptyState(): SceneTokensSessionState {
|
||||
return {
|
||||
@@ -7,6 +13,18 @@ function emptyState(): SceneTokensSessionState {
|
||||
};
|
||||
}
|
||||
|
||||
function withMove(
|
||||
prev: SceneTokensSessionPlacement | undefined,
|
||||
nx: number,
|
||||
ny: number,
|
||||
): SceneTokensSessionPlacement {
|
||||
return {
|
||||
nx,
|
||||
ny,
|
||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneTokensSessionStore {
|
||||
private state: SceneTokensSessionState = emptyState();
|
||||
|
||||
@@ -23,6 +41,30 @@ export class SceneTokensSessionStore {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
|
||||
rotateMapCwSteps(steps: number): SceneTokensSessionState {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) return this.state;
|
||||
const ids = Object.keys(this.state.byPlacementId);
|
||||
if (ids.length === 0) return this.state;
|
||||
const byPlacementId: SceneTokensSessionState['byPlacementId'] = {};
|
||||
for (const id of ids) {
|
||||
const prev = this.state.byPlacementId[id];
|
||||
if (!prev) continue;
|
||||
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
|
||||
const next = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
byPlacementId[id] = { ...prev, nx: next.nx, ny: next.ny };
|
||||
} else {
|
||||
byPlacementId[id] = prev;
|
||||
}
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
@@ -39,7 +81,26 @@ export class SceneTokensSessionStore {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: { nx, ny },
|
||||
[placementId]: withMove(prev, nx, ny),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setSize': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const sizeN = clampSceneTokenSizeN(event.sizeN);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (prev?.sizeN === sizeN) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: {
|
||||
...(typeof prev?.nx === 'number' ? { nx: prev.nx } : {}),
|
||||
...(typeof prev?.ny === 'number' ? { ny: prev.ny } : {}),
|
||||
sizeN,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { TokenGridSnapSessionStore } from './tokenGridSnapSessionStore';
|
||||
|
||||
void test('TokenGridSnapSessionStore: set and reset', () => {
|
||||
const store = new TokenGridSnapSessionStore();
|
||||
assert.equal(store.getState().enabled, false);
|
||||
assert.equal(store.setEnabled(true).enabled, true);
|
||||
assert.equal(store.getState().enabled, true);
|
||||
assert.equal(store.reset().enabled, false);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export type TokenGridSnapSessionState = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export class TokenGridSnapSessionStore {
|
||||
private enabled = false;
|
||||
|
||||
getState(): TokenGridSnapSessionState {
|
||||
return { enabled: this.enabled };
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): TokenGridSnapSessionState {
|
||||
this.enabled = Boolean(enabled);
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
reset(): TokenGridSnapSessionState {
|
||||
this.enabled = false;
|
||||
return this.getState();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
emptyTokenPathSessionState,
|
||||
tokenPathKey,
|
||||
type TokenPathPlaybackEntry,
|
||||
type TokenPathSessionEvent,
|
||||
type TokenPathSessionState,
|
||||
type TokenPathTargetRef,
|
||||
} from '../../shared/types/tokenPathSession';
|
||||
|
||||
function bump(
|
||||
state: TokenPathSessionState,
|
||||
patch: Partial<Omit<TokenPathSessionState, 'revision' | 'serverNowMs'>>,
|
||||
): TokenPathSessionState {
|
||||
return {
|
||||
...state,
|
||||
...patch,
|
||||
revision: state.revision + 1,
|
||||
serverNowMs: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export class TokenPathSessionStore {
|
||||
private state: TokenPathSessionState = emptyTokenPathSessionState();
|
||||
|
||||
getState(): TokenPathSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Refresh clock without logical change (optional heartbeat). */
|
||||
touchClock(): TokenPathSessionState {
|
||||
this.state = { ...this.state, serverNowMs: Date.now() };
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): TokenPathSessionState {
|
||||
if (
|
||||
Object.keys(this.state.playback).length === 0 &&
|
||||
Object.keys(this.state.presentationVisible).length === 0
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = emptyTokenPathSessionState(this.state.revision + 1);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: TokenPathSessionEvent): TokenPathSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'showPresentation': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
if (this.state.presentationVisible[key]) return this.state;
|
||||
this.state = bump(this.state, {
|
||||
presentationVisible: { ...this.state.presentationVisible, [key]: true },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'hidePresentation': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
if (!this.state.presentationVisible[key]) return this.state;
|
||||
const { [key]: _removed, ...rest } = this.state.presentationVisible;
|
||||
this.state = bump(this.state, { presentationVisible: rest });
|
||||
return this.state;
|
||||
}
|
||||
case 'stop': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
if (!prev || prev.phase === 'stopped') return this.state;
|
||||
const atDist =
|
||||
typeof event.atDist === 'number' && Number.isFinite(event.atDist)
|
||||
? Math.max(0, event.atDist)
|
||||
: prev.baseDist;
|
||||
this.state = bump(this.state, {
|
||||
playback: {
|
||||
...this.state.playback,
|
||||
[key]: {
|
||||
...prev,
|
||||
phase: 'stopped',
|
||||
baseDist: atDist,
|
||||
rejoinDist: null,
|
||||
segmentStartedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'resume': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
if (!prev) return this.state;
|
||||
if (prev.phase !== 'stopped' && prev.phase !== 'done') return this.state;
|
||||
const entry: TokenPathPlaybackEntry = {
|
||||
...prev,
|
||||
phase: 'moving',
|
||||
segmentStartedAtMs: Date.now(),
|
||||
// Jump to nearest-ahead on path, then continue (v1: no off-path lerp).
|
||||
baseDist: Math.max(0, event.fromDist),
|
||||
rejoinDist: null,
|
||||
direction: 1,
|
||||
};
|
||||
this.state = bump(this.state, {
|
||||
playback: { ...this.state.playback, [key]: entry },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'resetToStart': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
const entry: TokenPathPlaybackEntry = {
|
||||
kind: event.target.kind,
|
||||
placementId: event.target.placementId,
|
||||
phase: 'moving',
|
||||
segmentStartedAtMs: Date.now(),
|
||||
baseDist: 0,
|
||||
direction: 1,
|
||||
rejoinDist: null,
|
||||
durationSec: prev?.durationSec ?? 8,
|
||||
pathLength: prev?.pathLength ?? 1,
|
||||
};
|
||||
this.state = bump(this.state, {
|
||||
playback: { ...this.state.playback, [key]: entry },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'seedPlayback': {
|
||||
const e = event.entry;
|
||||
const key = tokenPathKey(e.kind, e.placementId);
|
||||
const entry: TokenPathPlaybackEntry = {
|
||||
kind: e.kind,
|
||||
placementId: e.placementId,
|
||||
phase: e.phase,
|
||||
segmentStartedAtMs: e.segmentStartedAtMs ?? Date.now(),
|
||||
baseDist: e.baseDist,
|
||||
direction: e.direction,
|
||||
rejoinDist: e.rejoinDist,
|
||||
durationSec: e.durationSec,
|
||||
pathLength: e.pathLength,
|
||||
};
|
||||
this.state = bump(this.state, {
|
||||
playback: { ...this.state.playback, [key]: entry },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'markDone': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
if (!prev || prev.phase === 'done') return this.state;
|
||||
this.state = bump(this.state, {
|
||||
playback: {
|
||||
...this.state.playback,
|
||||
[key]: {
|
||||
...prev,
|
||||
phase: 'done',
|
||||
baseDist: prev.pathLength,
|
||||
rejoinDist: null,
|
||||
segmentStartedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { TokenPathTargetRef };
|
||||
@@ -92,11 +92,7 @@ export class TokensStore {
|
||||
return path.join(this.rootDir, relPath);
|
||||
}
|
||||
|
||||
async upsert(input: {
|
||||
id?: TokenId | null;
|
||||
name: string;
|
||||
filePath?: string | null;
|
||||
}): Promise<AppToken> {
|
||||
async upsert(input: { id?: TokenId | null; name: string; filePath?: string | null }): Promise<AppToken> {
|
||||
await this.ensureLoaded();
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error('Token name is required');
|
||||
@@ -166,8 +162,7 @@ export class TokensStore {
|
||||
}): Promise<{ token: AppToken; remappedFrom: TokenId }> {
|
||||
await this.ensureLoaded();
|
||||
let buf = await fs.readFile(input.absFilePath);
|
||||
const sha256 =
|
||||
input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const sha256 = input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const existingByHash = this.findBySha256(sha256);
|
||||
if (existingByHash) {
|
||||
return { token: existingByHash, remappedFrom: input.preferredId };
|
||||
@@ -262,7 +257,7 @@ export class TokensStore {
|
||||
preferredId: asTokenId(t.id),
|
||||
name: typeof t.name === 'string' ? t.name : 'Token',
|
||||
absFilePath: abs,
|
||||
sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined,
|
||||
...(typeof t.sha256 === 'string' ? { sha256: t.sha256 } : {}),
|
||||
});
|
||||
remap.set(remappedFrom, token.id);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ void test('createWindows: окно описания сцены закрывае
|
||||
assert.ok(src.includes('closeSceneDescriptionWindow'));
|
||||
assert.ok(src.includes("createWindow('sceneDescription'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeSceneDescriptionWindow/);
|
||||
assert.match(src, /kind !== 'presentation' && kind !== 'control'[\s\S]*closeSceneDescriptionWindow/);
|
||||
assert.match(src, /kind !== 'presentation'[\s\S]*closeSceneDescriptionWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно материалов закрывается с multi-window', () => {
|
||||
@@ -52,14 +52,71 @@ 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'));
|
||||
assert.ok(src.includes('closeNpcsWindow'));
|
||||
assert.ok(src.includes('openNpcsEditorWindow'));
|
||||
assert.ok(src.includes('warmNpcsEditorWindow'));
|
||||
assert.ok(src.includes("createWindow('npcs'"));
|
||||
assert.ok(src.includes("createWindow('npcsEditor'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
||||
assert.ok(src.includes('webviewTag: true'));
|
||||
assert.ok(src.includes('will-attach-webview'));
|
||||
});
|
||||
|
||||
void test('createWindows: закрытие пульта и презентации спрашивает confirm', () => {
|
||||
const src = readCreateWindows();
|
||||
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://)', () => {
|
||||
@@ -80,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 { windowChromeTitle } from '../../shared/appBranding';
|
||||
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,9 +20,15 @@ export type WindowKind =
|
||||
| 'control'
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'playlists'
|
||||
| 'timer'
|
||||
| 'initiative'
|
||||
| 'dice'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'npcs';
|
||||
| 'tokenPathEditor'
|
||||
| 'npcs'
|
||||
| 'instructions';
|
||||
|
||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||||
@@ -28,24 +38,70 @@ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||||
'npcs',
|
||||
'npcsEditor',
|
||||
'sceneEditor',
|
||||
'tokenPathEditor',
|
||||
] as const;
|
||||
|
||||
const windows = new Map<WindowKind, BrowserWindow>();
|
||||
|
||||
/** Язык заголовков окон (из редактора); иначе `app.getLocale()`. */
|
||||
let chromeLocaleTagOverride: string | null = null;
|
||||
|
||||
function resolveChromeLocaleTag(): string {
|
||||
return chromeLocaleTagOverride ?? app.getLocale();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/** Пульт НПС: деталь слева + список справа. */
|
||||
const NPCS_WINDOW_WIDTH = 720;
|
||||
const NPCS_WINDOW_HEIGHT = 720;
|
||||
/** Пульт НПС: деталь слева (webview) + список справа. */
|
||||
const NPCS_WINDOW_WIDTH = 960;
|
||||
const NPCS_WINDOW_HEIGHT = 800;
|
||||
|
||||
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
|
||||
function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
@@ -60,6 +116,35 @@ function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function getPresentationContentSize(): { width: number; height: number } | null {
|
||||
const pres = windows.get('presentation');
|
||||
if (!pres || pres.isDestroyed()) return null;
|
||||
const [width, height] = pres.getContentSize();
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function broadcastPresentationContentSize(): void {
|
||||
const size = getPresentationContentSize();
|
||||
if (!size) return;
|
||||
for (const w of BrowserWindow.getAllWindows()) {
|
||||
if (w.isDestroyed() || w.webContents.isDestroyed()) continue;
|
||||
try {
|
||||
w.webContents.send(ipcChannels.windows.presentationContentSizeChanged, size);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindPresentationContentSizeTracking(win: BrowserWindow): void {
|
||||
const emit = () => broadcastPresentationContentSize();
|
||||
win.on('resize', emit);
|
||||
win.on('enter-full-screen', emit);
|
||||
win.on('leave-full-screen', emit);
|
||||
win.webContents.once('did-finish-load', emit);
|
||||
}
|
||||
|
||||
function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||
try {
|
||||
@@ -69,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;
|
||||
@@ -91,6 +185,41 @@ export function sendToAppWindows(
|
||||
}
|
||||
}
|
||||
|
||||
function applyWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
||||
if (win.isDestroyed()) return;
|
||||
win.setTitle(windowChromeTitle(kind as AppWindowKind, resolveChromeLocaleTag()));
|
||||
}
|
||||
|
||||
export function syncAllWindowChromeTitles(localeTag: string): void {
|
||||
chromeLocaleTagOverride = localeTag.trim() || null;
|
||||
for (const [kind, win] of windows.entries()) {
|
||||
applyWindowChromeTitle(win, kind);
|
||||
}
|
||||
}
|
||||
|
||||
function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
||||
const apply = () => applyWindowChromeTitle(win, kind);
|
||||
apply();
|
||||
win.webContents.on('page-title-updated', (event) => {
|
||||
event.preventDefault();
|
||||
apply();
|
||||
});
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
apply();
|
||||
});
|
||||
}
|
||||
|
||||
/** Закрыть окна сессии, кроме редактора и его дочерних окон. */
|
||||
function closePlaySessionAuxiliaryWindows(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closePlaylistsWindow();
|
||||
closeTimerWindow();
|
||||
closeInitiativeWindow();
|
||||
closeDiceWindow();
|
||||
closeNpcsWindow();
|
||||
}
|
||||
|
||||
function quitAppFromEditorClose(): void {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
@@ -122,12 +251,24 @@ 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':
|
||||
return 'sceneEditor.html';
|
||||
case 'tokenPathEditor':
|
||||
return 'tokenPathEditor.html';
|
||||
case 'npcs':
|
||||
return 'npcs.html';
|
||||
case 'instructions':
|
||||
return 'instructions.html';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +304,7 @@ export function applyDockIconIfNeeded(): void {
|
||||
type CreateWindowOpts = {
|
||||
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
||||
parent?: BrowserWindow;
|
||||
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
|
||||
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
|
||||
deferVisibility?: boolean;
|
||||
};
|
||||
|
||||
@@ -195,24 +336,30 @@ 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 };
|
||||
}
|
||||
|
||||
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||||
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
|
||||
const deferShow = opts?.deferVisibility === true;
|
||||
const icon = loadBrandingWindowIcon();
|
||||
const size = windowSizeForKind(kind);
|
||||
const win = new BrowserWindow({
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
autoHideMenuBar: true,
|
||||
...(kind === 'sceneDescription'
|
||||
? {
|
||||
minWidth: 520,
|
||||
minHeight: 420,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'materials'
|
||||
@@ -222,7 +369,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
minWidth: 260,
|
||||
maxWidth: 360,
|
||||
minHeight: 480,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'playlists'
|
||||
? {
|
||||
width: PLAYLISTS_WINDOW_WIDTH,
|
||||
height: PLAYLISTS_WINDOW_HEIGHT,
|
||||
minWidth: PLAYLISTS_WINDOW_MIN_WIDTH,
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcsEditor'
|
||||
@@ -231,7 +385,6 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
height: NPCS_EDITOR_WINDOW_HEIGHT,
|
||||
minWidth: 1100,
|
||||
minHeight: 640,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'sceneEditor'
|
||||
@@ -240,17 +393,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'tokenPathEditor'
|
||||
? {
|
||||
width: 1100,
|
||||
height: 760,
|
||||
minWidth: 900,
|
||||
minHeight: 560,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcs'
|
||||
? {
|
||||
width: NPCS_WINDOW_WIDTH,
|
||||
height: NPCS_WINDOW_HEIGHT,
|
||||
minWidth: 560,
|
||||
maxWidth: 900,
|
||||
minWidth: 720,
|
||||
maxWidth: 1280,
|
||||
minHeight: 560,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'instructions'
|
||||
? {
|
||||
width: INSTRUCTIONS_WINDOW_WIDTH,
|
||||
height: INSTRUCTIONS_WINDOW_HEIGHT,
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
show: false,
|
||||
@@ -266,6 +433,17 @@ 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,
|
||||
// webviewTag в Electron не сочетается с sandbox хоста.
|
||||
sandbox: false,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (icon) {
|
||||
@@ -276,13 +454,33 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
}
|
||||
}
|
||||
|
||||
win.setTitle(windowChromeTitle(kind, app.getLocale()));
|
||||
bindWindowChromeTitle(win, kind);
|
||||
if (kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor') {
|
||||
win.webContents.setBackgroundThrottling(false);
|
||||
}
|
||||
if (kind === 'npcs') {
|
||||
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
|
||||
delete guestPrefs.preload;
|
||||
guestPrefs.nodeIntegration = false;
|
||||
guestPrefs.contextIsolation = true;
|
||||
guestPrefs.sandbox = true;
|
||||
guestPrefs.webSecurity = true;
|
||||
const src = String(params.src ?? '');
|
||||
if (!src.startsWith('https:') && !src.startsWith('http:')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (
|
||||
kind === 'sceneDescription' ||
|
||||
kind === 'materials' ||
|
||||
kind === 'playlists' ||
|
||||
kind === 'timer' ||
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'sceneEditor' ||
|
||||
kind === 'npcs'
|
||||
kind === 'tokenPathEditor' ||
|
||||
kind === 'npcs' ||
|
||||
kind === 'instructions'
|
||||
) {
|
||||
win.setMenuBarVisibility(false);
|
||||
}
|
||||
@@ -290,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;
|
||||
@@ -300,7 +509,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
|
||||
});
|
||||
|
||||
if (!deferEditor) {
|
||||
if (!deferShow) {
|
||||
ensureWindowBecomesVisible(win);
|
||||
}
|
||||
loadWindowPage(win, kind);
|
||||
@@ -311,14 +520,43 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
quitAppFromEditorClose();
|
||||
});
|
||||
}
|
||||
if (kind === 'control') {
|
||||
win.on('close', (e) => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
e.preventDefault();
|
||||
requestCloseMultiWindowConfirm();
|
||||
});
|
||||
}
|
||||
if (kind === 'presentation') {
|
||||
bindPresentationContentSizeTracking(win);
|
||||
win.on('close', (e) => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
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();
|
||||
});
|
||||
}
|
||||
win.on('closed', () => {
|
||||
if (kind !== 'presentation' && kind !== 'control') return;
|
||||
const open = windows.has('presentation') || windows.has('control');
|
||||
if (!open) {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
closingPlaySession = false;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
}
|
||||
broadcastMultiWindowStateChanged(open);
|
||||
});
|
||||
@@ -383,32 +621,64 @@ 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);
|
||||
presentation.maximize();
|
||||
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();
|
||||
}
|
||||
|
||||
export function closeMultiWindow(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
const pres = windows.get('presentation');
|
||||
const ctrl = windows.get('control');
|
||||
if (pres) pres.close();
|
||||
if (ctrl) ctrl.close();
|
||||
if (pres && !pres.isDestroyed()) pres.close();
|
||||
if (ctrl && !ctrl.isDestroyed()) ctrl.close();
|
||||
if (!windows.has('presentation') && !windows.has('control')) {
|
||||
closingPlaySession = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Фокус пульта + событие: выход из показа спрашивает подтверждение на пульте. */
|
||||
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 {
|
||||
@@ -429,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()) {
|
||||
@@ -437,12 +735,73 @@ export function closeNpcsEditorWindow(): void {
|
||||
}
|
||||
|
||||
export function closeSceneEditorWindow(): void {
|
||||
closeTokenPathEditorWindow();
|
||||
const win = windows.get('sceneEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeTokenPathEditorWindow(): void {
|
||||
const win = windows.get('tokenPathEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
let pendingTokenPathEditorTarget: { kind: 'token' | 'npcToken'; placementId: string } | null = null;
|
||||
|
||||
export function getTokenPathEditorTarget(): { kind: 'token' | 'npcToken'; placementId: string } | null {
|
||||
return pendingTokenPathEditorTarget;
|
||||
}
|
||||
|
||||
function broadcastTokenPathEditorTarget(): void {
|
||||
const win = windows.get('tokenPathEditor');
|
||||
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.windows.tokenPathEditorTargetChanged, pendingTokenPathEditorTarget);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Одно окно пути: смена токена = фокус + targetChanged, без второго окна. */
|
||||
export function openTokenPathEditorWindow(kind: 'token' | 'npcToken', placementId: string): void {
|
||||
pendingTokenPathEditorTarget = { kind, placementId };
|
||||
const existing = windows.get('tokenPathEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
broadcastTokenPathEditorTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('sceneEditor') ?? windows.get('editor');
|
||||
const win = createWindow('tokenPathEditor', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
broadcastTokenPathEditorTarget();
|
||||
}
|
||||
});
|
||||
win.on('closed', () => {
|
||||
pendingTokenPathEditorTarget = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function closeNpcsWindow(): void {
|
||||
const win = windows.get('npcs');
|
||||
if (win && !win.isDestroyed()) {
|
||||
@@ -454,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;
|
||||
@@ -527,10 +924,10 @@ export function openMaterialsWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||
export function openNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
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();
|
||||
@@ -538,8 +935,8 @@ export function openNpcsEditorWindow(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', parent ? { parent } : undefined);
|
||||
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;
|
||||
@@ -558,6 +955,155 @@ export function openNpcsEditorWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
|
||||
export function warmNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) return;
|
||||
const parent = windows.get('editor');
|
||||
createWindow('npcsEditor', {
|
||||
...(parent ? { parent } : {}),
|
||||
deferVisibility: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||
export function openNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
positionNpcsEditorWindow(existing);
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', {
|
||||
...(parent ? { parent } : {}),
|
||||
deferVisibility: true,
|
||||
});
|
||||
positionNpcsEditorWindow(win);
|
||||
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор сцены: расстановка ловушек и легенды материалов. */
|
||||
export function openSceneEditorWindow(): void {
|
||||
const existing = windows.get('sceneEditor');
|
||||
@@ -598,7 +1144,7 @@ export function openNpcsWindow(): void {
|
||||
existing.setBounds({
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)),
|
||||
width: Math.min(1280, Math.max(720, b.width)),
|
||||
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
|
||||
});
|
||||
existing.show();
|
||||
|
||||
@@ -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;
|
||||
@@ -271,6 +318,7 @@
|
||||
|
||||
.historyTitle {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.emptyStory {
|
||||
@@ -291,18 +339,86 @@
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.snapToGrid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.snapToGrid input {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.snapToGridLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.npcTokenScaleLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npcTokenScaleRange {
|
||||
flex: 1 1 auto;
|
||||
min-width: 72px;
|
||||
width: min(140px, 100%);
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
accent-color: var(--color-accent, #c9a227);
|
||||
}
|
||||
|
||||
.videoHint {
|
||||
@@ -326,6 +442,22 @@
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.previewHostSpawnDrag {
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.npcSpawnGhost {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
border: 2px dashed rgba(96, 165, 250, 0.95);
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.brushCursor {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
@@ -341,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;
|
||||
@@ -356,7 +543,7 @@
|
||||
|
||||
.branchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -367,6 +554,8 @@
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.branchCardHeader {
|
||||
@@ -383,6 +572,7 @@
|
||||
|
||||
.branchName {
|
||||
font-weight: 900;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branchCardReturn {
|
||||
@@ -575,3 +765,43 @@
|
||||
.audioScrubDefault {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: 41;
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background: var(--color-surface-menu, #1a1e28);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ctxItem {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text1, #e8eaef);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctxItem:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
+1629
-156
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
/* Above brush / traps layers so transport stays usable on video scenes. */
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.scrub {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneViewCamera } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
|
||||
@@ -25,6 +26,13 @@ function fmt(sec: number): string {
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function readVideoTimes(v: HTMLVideoElement | null): { cur: number; dur: number } {
|
||||
if (!v) return { cur: 0, dur: 0 };
|
||||
const dur = Number.isFinite(v.duration) ? v.duration : 0;
|
||||
const cur = Number.isFinite(v.currentTime) ? v.currentTime : 0;
|
||||
return { cur, dur };
|
||||
}
|
||||
|
||||
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
|
||||
const { t } = useEditorI18n();
|
||||
const [vp, video] = useVideoPlaybackState();
|
||||
@@ -35,40 +43,44 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
const isVideo = scene?.previewAssetType === 'video';
|
||||
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
||||
const autostart = scene?.previewVideoAutostart ?? false;
|
||||
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
|
||||
const scrubRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrubFillRef = useRef<HTMLDivElement | null>(null);
|
||||
const curTimeRef = useRef<HTMLDivElement | null>(null);
|
||||
const durTimeRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const dur = useMemo(
|
||||
() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return 0;
|
||||
return Number.isFinite(v.duration) ? v.duration : 0;
|
||||
},
|
||||
// tick: timeupdate / loadedmetadata перечитывают duration и currentTime
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
|
||||
[tick, videoRef],
|
||||
);
|
||||
const cur = useMemo(
|
||||
() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return 0;
|
||||
return Number.isFinite(v.currentTime) ? v.currentTime : 0;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
|
||||
[tick, videoRef],
|
||||
);
|
||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||
const paintScrub = (): void => {
|
||||
const { cur, dur } = readVideoTimes(videoRef.current);
|
||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||
if (scrubFillRef.current) {
|
||||
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||
}
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = fmt(cur);
|
||||
if (durTimeRef.current) durTimeRef.current.textContent = dur ? fmt(dur) : '—:—';
|
||||
if (scrubRef.current) {
|
||||
scrubRef.current.setAttribute('aria-valuemin', '0');
|
||||
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
|
||||
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
|
||||
scrubRef.current.classList.toggle(styles.scrubPointer, dur > 0);
|
||||
scrubRef.current.classList.toggle(styles.scrubDefault, dur <= 0);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
if (!assetId) return;
|
||||
// `target.set` bumps revision and resets anchors; avoid firing on every render.
|
||||
if (vp?.targetAssetId === assetId) return;
|
||||
const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
|
||||
const prev = lastTargetRef.current;
|
||||
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
|
||||
return;
|
||||
}
|
||||
lastTargetRef.current = { sceneKey, assetId, autostart };
|
||||
void video.dispatch({
|
||||
kind: 'target.set',
|
||||
assetId,
|
||||
autostart,
|
||||
});
|
||||
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
|
||||
}, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
@@ -85,10 +97,21 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
} else {
|
||||
v.pause();
|
||||
}
|
||||
paintScrub();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
|
||||
}, [assetId, url, vp?.revision, vp?.targetAssetId, vp?.playing, vp?.playbackRate, videoRef]);
|
||||
|
||||
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
|
||||
const seekByClientX = (clientX: number): void => {
|
||||
const v = videoRef.current;
|
||||
const { dur } = readVideoTimes(v);
|
||||
if (!v || !dur || !scrubRef.current) return;
|
||||
const rect = scrubRef.current.getBoundingClientRect();
|
||||
const next = (clientX - rect.left) / Math.max(1, rect.width);
|
||||
const timeSec = Math.max(0, Math.min(dur, next * dur));
|
||||
void video.dispatch({ kind: 'seek', timeSec });
|
||||
v.currentTime = timeSec;
|
||||
paintScrub();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
@@ -101,19 +124,20 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
onContentRectChange={onContentRectChange}
|
||||
/>
|
||||
) : url && isVideo ? (
|
||||
<video
|
||||
ref={(el) => {
|
||||
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el;
|
||||
}}
|
||||
className={styles.video}
|
||||
src={url}
|
||||
<ContainedVideo
|
||||
url={url}
|
||||
rotationDeg={rot}
|
||||
videoRef={videoRef}
|
||||
playsInline
|
||||
loop={Boolean(scene?.settings?.loopVideo)}
|
||||
preload="auto"
|
||||
onTimeUpdate={() => setTick((x) => x + 1)}
|
||||
onLoadedMetadata={() => setTick((x) => x + 1)}
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={onContentRectChange}
|
||||
onTimeUpdate={paintScrub}
|
||||
onLoadedMetadata={paintScrub}
|
||||
>
|
||||
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
|
||||
</video>
|
||||
</ContainedVideo>
|
||||
) : (
|
||||
<div className={styles.placeholder} />
|
||||
)}
|
||||
@@ -121,30 +145,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
{isVideo ? (
|
||||
<div className={styles.controls}>
|
||||
<div
|
||||
ref={scrubRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
|
||||
aria-valuenow={Math.round(cur)}
|
||||
className={scrubClass}
|
||||
aria-valuemax={0}
|
||||
aria-valuenow={0}
|
||||
className={[styles.scrub, styles.scrubDefault].join(' ')}
|
||||
onClick={(e) => {
|
||||
const v = videoRef.current;
|
||||
if (!v || !dur) return;
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const next = (e.clientX - rect.left) / rect.width;
|
||||
void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) });
|
||||
setTick((x) => x + 1);
|
||||
seekByClientX(e.clientX);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
const { cur, dur } = readVideoTimes(videoRef.current);
|
||||
if (!dur) return;
|
||||
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
|
||||
if (e.key === 'ArrowRight')
|
||||
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1);
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') paintScrub();
|
||||
}}
|
||||
title={t('control.scrubSeek')}
|
||||
>
|
||||
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
|
||||
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.transport}>
|
||||
@@ -169,7 +190,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
className={styles.transportBtn}
|
||||
onClick={() => {
|
||||
void video.dispatch({ kind: 'stop' });
|
||||
setTick((x) => x + 1);
|
||||
paintScrub();
|
||||
}}
|
||||
title={t('control.transportStop')}
|
||||
>
|
||||
@@ -177,7 +198,9 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
|
||||
<span ref={curTimeRef}>0:00</span>
|
||||
{' / '}
|
||||
<span ref={durTimeRef}>—:—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ void test('ControlApp: эффект «взрыв» + ловушка исполь
|
||||
const appSrc = readControlApp();
|
||||
const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8');
|
||||
assert.ok(appSrc.includes("title={t('control.explosion')}"));
|
||||
assert.ok(appSrc.includes("tool: 'explosion'"));
|
||||
assert.ok(appSrc.includes("selectEffectTool('explosion')"));
|
||||
assert.ok(appSrc.includes("type: 'explosion'"));
|
||||
assert.ok(appSrc.includes('getExplosionEffectLifeMs'));
|
||||
assert.ok(appSrc.includes('playExplosionEffectSound'));
|
||||
@@ -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'));
|
||||
@@ -87,6 +96,9 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
||||
assert.ok(src.includes("t('control.actionEffects')"));
|
||||
assert.ok(src.includes("t('control.darknessControl')"));
|
||||
assert.ok(src.includes("t('control.explorerBrush')"));
|
||||
assert.ok(src.includes("t('control.closerBrush')"));
|
||||
assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
|
||||
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
|
||||
assert.ok(src.includes('SceneDarknessOverlay'));
|
||||
assert.ok(src.includes('useSceneDarknessState'));
|
||||
assert.ok(src.includes("t('control.sunbeam')"));
|
||||
@@ -110,20 +122,51 @@ 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: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
assert.ok(src.includes('useTokenGridSnapSession'));
|
||||
assert.ok(src.includes('snapNormToGridCell'));
|
||||
assert.ok(src.includes('data-testid="token-grid-snap"'));
|
||||
assert.ok(src.includes("t('control.snapTokensToGrid')"));
|
||||
assert.ok(src.includes('snapAllTokensToGrid'));
|
||||
assert.ok(src.includes('snapNorm={snapNormActive}'));
|
||||
assert.ok(css.includes('.snapToGrid'));
|
||||
assert.ok(css.includes('.previewTokenTools'));
|
||||
assert.match(css, /\.previewTokenTools[\s\S]*?repeat\(auto-fit/);
|
||||
assert.match(css, /\.previewHeader[\s\S]*?flex-wrap:\s*wrap/);
|
||||
assert.match(css, /\.previewHeader[\s\S]*?align-items:\s*center/);
|
||||
});
|
||||
|
||||
void test('ControlApp: история — свёрнута по умолчанию, колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
const src = readControlApp();
|
||||
const 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'));
|
||||
@@ -158,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')");
|
||||
@@ -252,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'));
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { ControlApp } from './ControlApp';
|
||||
|
||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<ControlApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Пульт">
|
||||
<EditorI18nProvider>
|
||||
<ControlApp />
|
||||
</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="/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,12 +53,80 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.helpMark {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.splitRun {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-fill-solid);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.splitRun:disabled,
|
||||
.splitRun[aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.splitRunMain,
|
||||
.splitRunChevron {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.splitRunMain {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.splitRunMain:hover:not(:disabled),
|
||||
.splitRunChevron:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent-fill-solid) 72%, white);
|
||||
}
|
||||
|
||||
.splitRunMain:active:not(:disabled),
|
||||
.splitRunChevron:active:not(:disabled),
|
||||
.splitRunChevronOpen {
|
||||
background: color-mix(in srgb, var(--accent-fill-solid) 78%, black);
|
||||
}
|
||||
|
||||
.splitRunDivider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.splitRunChevron {
|
||||
width: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.splitRunChevronSvg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editorSidebar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -265,6 +333,18 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.projectNameLabel {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspectorScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -476,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;
|
||||
@@ -614,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;
|
||||
@@ -934,6 +914,33 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actionsRowHalf {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowVideoChecks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -1009,7 +1016,7 @@
|
||||
.audioList {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 160px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -1019,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 {
|
||||
@@ -1034,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;
|
||||
@@ -1076,6 +1127,7 @@
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sceneCard:not(.sceneCardActive):hover {
|
||||
@@ -1163,21 +1215,23 @@
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sceneCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badgeCurrent {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sceneMenuBtn {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--panel2);
|
||||
border-radius: var(--radius-xs);
|
||||
@@ -1191,6 +1245,11 @@
|
||||
|
||||
.sceneCardTitle {
|
||||
font-weight: 750;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menuBackdrop {
|
||||
|
||||
+345
-280
@@ -2,6 +2,7 @@ import React, { startTransition, useCallback, useEffect, useMemo, useRef, useSta
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
||||
import type {
|
||||
NpcImportResolution,
|
||||
SceneImportResolution,
|
||||
@@ -28,14 +29,17 @@ import type {
|
||||
SceneId,
|
||||
} 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,
|
||||
@@ -55,6 +59,11 @@ import type { HelpSectionId } from './help/helpSections';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
|
||||
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';
|
||||
@@ -121,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);
|
||||
@@ -134,9 +140,9 @@ export function EditorApp() {
|
||||
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
|
||||
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
|
||||
const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
|
||||
const [importNpcConflicts, setImportNpcConflicts] = useState<
|
||||
ReturnType<typeof computeNpcImportConflicts>
|
||||
>([]);
|
||||
const [importNpcConflicts, setImportNpcConflicts] = useState<ReturnType<typeof computeNpcImportConflicts>>(
|
||||
[],
|
||||
);
|
||||
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
|
||||
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
|
||||
const [importReportOpen, setImportReportOpen] = useState(false);
|
||||
@@ -154,7 +160,32 @@ export function EditorApp() {
|
||||
const licenseActive = licenseSnap?.active === true;
|
||||
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
|
||||
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> = {
|
||||
@@ -247,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;
|
||||
@@ -350,14 +379,27 @@ 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) => {
|
||||
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
||||
if (!licenseActive || launching) return;
|
||||
stopAllEditorAudio();
|
||||
setLaunching(true);
|
||||
setRunMenuOpen(false);
|
||||
setLaunchPlayersOpen(false);
|
||||
void (async () => {
|
||||
try {
|
||||
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
|
||||
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
|
||||
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {
|
||||
...(playerIds && playerIds.length > 0 ? { playerIds } : {}),
|
||||
});
|
||||
} catch {
|
||||
setLaunching(false);
|
||||
}
|
||||
@@ -367,7 +409,10 @@ export function EditorApp() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (presentationOpen) setLaunching(false);
|
||||
if (presentationOpen) {
|
||||
setLaunching(false);
|
||||
setPlaylistsManagerOpen(false);
|
||||
}
|
||||
}, [presentationOpen]);
|
||||
|
||||
const currentProjectName = state.project?.meta.name ?? '';
|
||||
@@ -442,24 +487,21 @@ export function EditorApp() {
|
||||
}, [settingsMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aboutMenuOpen) return;
|
||||
const r = aboutMenuBtnRef.current?.getBoundingClientRect() ?? null;
|
||||
if (!runMenuOpen) return;
|
||||
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
|
||||
queueMicrotask(() => {
|
||||
if (r) {
|
||||
setAboutMenuPos({ left: r.left, top: r.bottom + 10 });
|
||||
} else {
|
||||
setAboutMenuPos(null);
|
||||
}
|
||||
if (r) setRunMenuPos({ left: r.right, top: r.bottom + 8 });
|
||||
else setRunMenuPos(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);
|
||||
if (t.closest('[data-runmenu-root="1"]')) return;
|
||||
setRunMenuOpen(false);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [aboutMenuOpen]);
|
||||
}, [runMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let off: (() => void) | null = null;
|
||||
@@ -471,6 +513,7 @@ export function EditorApp() {
|
||||
// ignore
|
||||
}
|
||||
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
||||
if (open) stopAllEditorAudio();
|
||||
setPresentationOpen(open);
|
||||
});
|
||||
})();
|
||||
@@ -546,12 +589,7 @@ export function EditorApp() {
|
||||
sceneResolutions,
|
||||
npcResolutions,
|
||||
)
|
||||
: await actions.mergeImportZip(
|
||||
importPeek.filePath!,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
npcResolutions,
|
||||
);
|
||||
: await actions.mergeImportZip(importPeek.filePath!, selections, sceneResolutions, npcResolutions);
|
||||
setImportReport(report);
|
||||
setImportReportOpen(true);
|
||||
clearImportFlow();
|
||||
@@ -562,11 +600,7 @@ export function EditorApp() {
|
||||
const continueImportAfterScenes = useCallback(
|
||||
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
|
||||
if (!importPeek || !state.project) return;
|
||||
const npcConflicts = computeNpcImportConflicts(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
selections,
|
||||
);
|
||||
const npcConflicts = computeNpcImportConflicts(state.project, importPeek.sourceProject);
|
||||
setPendingImportSelections(selections);
|
||||
setPendingSceneResolutions(sceneResolutions);
|
||||
setImportConflictsOpen(false);
|
||||
@@ -575,13 +609,7 @@ export function EditorApp() {
|
||||
setImportNpcConflictsOpen(true);
|
||||
return;
|
||||
}
|
||||
const npcResolutions = buildNpcResolutionsForImport(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
selections,
|
||||
[],
|
||||
[],
|
||||
);
|
||||
const npcResolutions = buildNpcResolutionsForImport(state.project, importPeek.sourceProject, [], []);
|
||||
void runStorylineMerge(selections, sceneResolutions, npcResolutions);
|
||||
},
|
||||
[importPeek, runStorylineMerge, state.project],
|
||||
@@ -729,9 +757,7 @@ export function EditorApp() {
|
||||
{t('scenes.batchProgress')
|
||||
.replace('{current}', String(state.sceneBatchImport.current))
|
||||
.replace('{total}', String(state.sceneBatchImport.total))}
|
||||
{state.sceneBatchImport.fileName
|
||||
? `: ${state.sceneBatchImport.fileName}`
|
||||
: ''}
|
||||
{state.sceneBatchImport.fileName ? `: ${state.sceneBatchImport.fileName}` : ''}
|
||||
</div>
|
||||
<div>
|
||||
{Math.round(
|
||||
@@ -769,7 +795,6 @@ export function EditorApp() {
|
||||
onClick={() => {
|
||||
setFileMenuOpen(false);
|
||||
setProjectMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setSettingsMenuOpen((v) => {
|
||||
const next = !v;
|
||||
if (next) setSettingsLangSubOpen(false);
|
||||
@@ -780,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')}
|
||||
@@ -805,7 +828,6 @@ export function EditorApp() {
|
||||
if (!licenseActive) return;
|
||||
setFileMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setProjectMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
@@ -823,13 +845,46 @@ export function EditorApp() {
|
||||
if (!licenseActive) return;
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setFileMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
{t('top.file')}
|
||||
</button>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="players-header-btn"
|
||||
className={styles.fileMenuTrigger}
|
||||
disabled={!licenseActive}
|
||||
title={!licenseActive ? t('top.afterLicense') : undefined}
|
||||
onClick={() => {
|
||||
if (!licenseActive) return;
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setFileMenuOpen(false);
|
||||
setPlayersManagerOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('top.players')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="playlists-header-btn"
|
||||
className={styles.fileMenuTrigger}
|
||||
disabled={!licenseActive}
|
||||
title={!licenseActive ? t('top.afterLicense') : undefined}
|
||||
onClick={() => {
|
||||
if (!licenseActive) return;
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setFileMenuOpen(false);
|
||||
setPlaylistsManagerOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('top.playlists')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.flex1} />
|
||||
{appVersionText ? (
|
||||
@@ -837,19 +892,85 @@ 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 ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={runDisabled || launching}
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId || launching) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
}}
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<div
|
||||
ref={runSplitRef}
|
||||
className={styles.splitRun}
|
||||
data-runmenu-root="1"
|
||||
aria-disabled={runDisabled || launching ? true : undefined}
|
||||
>
|
||||
{t('top.run')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.splitRunMain}
|
||||
disabled={runDisabled || launching}
|
||||
data-testid="run-main-btn"
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId || launching) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
}}
|
||||
>
|
||||
{t('top.run')}
|
||||
</button>
|
||||
<span className={styles.splitRunDivider} aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.splitRunChevron,
|
||||
runMenuOpen ? styles.splitRunChevronOpen : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
disabled={runDisabled || launching}
|
||||
aria-label={t('top.runMenuAria')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={runMenuOpen}
|
||||
data-testid="run-menu-btn"
|
||||
onClick={() => {
|
||||
if (runDisabled || launching) return;
|
||||
setRunMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
<svg className={styles.splitRunChevronSvg} viewBox="0 0 12 12" aria-hidden>
|
||||
<path
|
||||
d="M2.5 4.25 L6 7.75 L9.5 4.25"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={runDisabled || launching}
|
||||
data-testid="run-main-btn"
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId || launching) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
}}
|
||||
>
|
||||
{t('top.run')}
|
||||
</Button>
|
||||
)}
|
||||
{runDisabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -858,8 +979,7 @@ export function EditorApp() {
|
||||
title={runHelpTooltip}
|
||||
tooltipPlacement="bottom-left"
|
||||
onClick={() => {
|
||||
setInstructionsSection(runHelpSection);
|
||||
setInstructionsOpen(true);
|
||||
openInstructions(runHelpSection);
|
||||
}}
|
||||
>
|
||||
?
|
||||
@@ -905,9 +1025,7 @@ export function EditorApp() {
|
||||
scene={s}
|
||||
reorderEnabled={sceneListReorderEnabled}
|
||||
listDragActive={draggingListSceneId !== null}
|
||||
dropPlace={
|
||||
sceneListDrop?.targetId === s.id ? sceneListDrop.place : null
|
||||
}
|
||||
dropPlace={sceneListDrop?.targetId === s.id ? sceneListDrop.place : null}
|
||||
isDragging={draggingListSceneId === s.id}
|
||||
onSelect={() => {
|
||||
setSelectedGraphNodeId(null);
|
||||
@@ -930,9 +1048,7 @@ export function EditorApp() {
|
||||
return;
|
||||
}
|
||||
setSceneListDrop((cur) =>
|
||||
cur?.targetId === targetId && cur.place === place
|
||||
? cur
|
||||
: { targetId, place },
|
||||
cur?.targetId === targetId && cur.place === place ? cur : { targetId, place },
|
||||
);
|
||||
}}
|
||||
onDropListReorder={(draggedId, targetId, place) => {
|
||||
@@ -1023,6 +1139,9 @@ export function EditorApp() {
|
||||
<div className={styles.inspectorScroll}>
|
||||
{state.project ? (
|
||||
<>
|
||||
<div className={styles.projectNameLabel} title={state.project.meta.name}>
|
||||
{t('scenes.projectLabel', { name: state.project.meta.name })}
|
||||
</div>
|
||||
<div className={styles.inspectorTitle}>{t('scenes.inspectorGame')}</div>
|
||||
<CampaignInspector
|
||||
audioRefs={campaignAudioRefs}
|
||||
@@ -1098,6 +1217,7 @@ export function EditorApp() {
|
||||
previewAssetId={sc?.previewAssetId ?? null}
|
||||
previewAssetType={sc?.previewAssetType ?? null}
|
||||
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
||||
previewVideoLoop={sc?.settings.loopVideo ?? false}
|
||||
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
||||
darkenScene={sc?.darkenScene ?? false}
|
||||
previewBusy={previewBusy}
|
||||
@@ -1110,6 +1230,9 @@ export function EditorApp() {
|
||||
onPreviewVideoAutostartChange={(next) =>
|
||||
void actions.updateScene(sid, { previewVideoAutostart: next })
|
||||
}
|
||||
onPreviewVideoLoopChange={(next) =>
|
||||
void actions.updateScene(sid, { settings: { loopVideo: next } })
|
||||
}
|
||||
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
|
||||
onTitleChange={(title) => void actions.updateScene(sid, { title })}
|
||||
onDescriptionChange={(description) =>
|
||||
@@ -1232,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"
|
||||
@@ -1313,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
|
||||
@@ -1459,8 +1566,8 @@ export function EditorApp() {
|
||||
storylineLabels={storylineLabels}
|
||||
loadStorylines={loadProjectStorylines}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
onExport={async (projectId, selections) => {
|
||||
await actions.exportProject(projectId, selections, storylineLabels);
|
||||
onExport={async (projectId, selections, npcIds, playlistIds) => {
|
||||
await actions.exportProject(projectId, selections, npcIds, storylineLabels, playlistIds);
|
||||
}}
|
||||
/>
|
||||
<ImportSourceModal
|
||||
@@ -1535,7 +1642,6 @@ export function EditorApp() {
|
||||
const npcResolutions = buildNpcResolutionsForImport(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
pendingImportSelections,
|
||||
importNpcConflicts,
|
||||
userNpcResolutions,
|
||||
);
|
||||
@@ -1551,6 +1657,67 @@ 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)} />
|
||||
<LaunchPlayersModal
|
||||
open={launchPlayersOpen}
|
||||
onClose={() => setLaunchPlayersOpen(false)}
|
||||
onConfirm={(playerIds) => {
|
||||
if (!graphStartGraphNodeId) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId, playerIds);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
|
||||
? createPortal(
|
||||
<div
|
||||
data-runmenu-root="1"
|
||||
className={styles.fileMenu}
|
||||
style={{ left: runMenuPos.left, top: runMenuPos.top, transform: 'translateX(-100%)' }}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.fileMenuItem}
|
||||
role="menuitem"
|
||||
data-testid="run-with-players-item"
|
||||
onClick={() => {
|
||||
setRunMenuOpen(false);
|
||||
setLaunchPlayersOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('top.runWithPlayers')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
<MaterialsManagerModal
|
||||
open={materialsManagerOpen}
|
||||
materials={state.project?.materials ?? []}
|
||||
@@ -2325,6 +2492,7 @@ type SceneInspectorProps = {
|
||||
previewAssetId: AssetId | null;
|
||||
previewAssetType: 'image' | 'video' | null;
|
||||
previewVideoAutostart: boolean;
|
||||
previewVideoLoop: boolean;
|
||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||
darkenScene: boolean;
|
||||
previewBusy: boolean;
|
||||
@@ -2333,6 +2501,7 @@ type SceneInspectorProps = {
|
||||
audioRefs: SceneAudioRef[];
|
||||
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||
onPreviewVideoAutostartChange: (next: boolean) => void;
|
||||
onPreviewVideoLoopChange: (next: boolean) => void;
|
||||
onDarkenSceneChange: (next: boolean) => void;
|
||||
onTitleChange: (v: string) => void;
|
||||
onDescriptionChange: (v: string) => void;
|
||||
@@ -2361,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,
|
||||
@@ -2376,74 +2544,20 @@ function CampaignInspector({
|
||||
onDragOver={audioDrop.onDragOver}
|
||||
onDrop={audioDrop.onDrop}
|
||||
>
|
||||
{audioDrop.dragOver ? (
|
||||
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
|
||||
) : null}
|
||||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
||||
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
||||
{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);
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<EditorAudioTrackList
|
||||
audioRefs={audioRefs}
|
||||
mediaAssets={mediaAssets}
|
||||
removeTitle={t('campaign.removeTitle')}
|
||||
onAudioRefsChange={onAudioRefsChange}
|
||||
/>
|
||||
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -2459,6 +2573,7 @@ function SceneInspector({
|
||||
previewAssetId,
|
||||
previewAssetType,
|
||||
previewVideoAutostart,
|
||||
previewVideoLoop,
|
||||
previewRotationDeg,
|
||||
darkenScene,
|
||||
previewBusy,
|
||||
@@ -2467,6 +2582,7 @@ function SceneInspector({
|
||||
audioRefs,
|
||||
onAudioRefsChange,
|
||||
onPreviewVideoAutostartChange,
|
||||
onPreviewVideoLoopChange,
|
||||
onDarkenSceneChange,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
@@ -2481,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)),
|
||||
@@ -2543,10 +2658,7 @@ function SceneInspector({
|
||||
{sideStoryStartNodes.map((gn) => (
|
||||
<div key={gn.id}>
|
||||
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
|
||||
<Input
|
||||
value={gn.sideStoryLineTitle}
|
||||
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
|
||||
/>
|
||||
<Input value={gn.sideStoryLineTitle} onChange={(v) => onSideStoryLineTitleChange(gn.id, v)} />
|
||||
<div className={styles.spacer8} />
|
||||
</div>
|
||||
))}
|
||||
@@ -2562,9 +2674,7 @@ function SceneInspector({
|
||||
onDragOver={previewDrop.onDragOver}
|
||||
onDrop={previewDrop.onDrop}
|
||||
>
|
||||
{previewDrop.dragOver ? (
|
||||
<div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div>
|
||||
) : null}
|
||||
{previewDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div> : null}
|
||||
{previewUrl && previewAssetType === 'image' ? (
|
||||
<div className={styles.previewFill}>
|
||||
<RotatedImage
|
||||
@@ -2576,14 +2686,16 @@ function SceneInspector({
|
||||
</div>
|
||||
) : previewUrl && previewAssetType === 'video' ? (
|
||||
<div className={styles.previewFill}>
|
||||
<video
|
||||
src={previewUrl}
|
||||
<ContainedVideo
|
||||
url={previewUrl}
|
||||
rotationDeg={previewRotationDeg}
|
||||
mode="cover"
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={previewVideoAutostart}
|
||||
loop
|
||||
loop={previewVideoLoop}
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -2598,12 +2710,14 @@ function SceneInspector({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.actionsRow}>
|
||||
<div className={styles.actionsRowHalf}>
|
||||
<Button variant="primary" disabled={previewBusy} onClick={onImportPreview}>
|
||||
{previewAssetId ? t('scene.change') : t('campaign.upload')}
|
||||
</Button>
|
||||
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : null}
|
||||
{previewAssetId && previewAssetType === 'video' ? (
|
||||
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : <span aria-hidden />}
|
||||
</div>
|
||||
{previewAssetId && previewAssetType === 'video' ? (
|
||||
<div className={styles.actionsRowVideoChecks}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -2612,8 +2726,19 @@ function SceneInspector({
|
||||
/>
|
||||
<span className={styles.spanSm}>{t('scene.autostart')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={previewVideoLoop}
|
||||
onChange={(e) => onPreviewVideoLoopChange(e.target.checked)}
|
||||
/>
|
||||
<span className={styles.spanSm}>{t('campaign.loop')}</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
@@ -2622,9 +2747,9 @@ function SceneInspector({
|
||||
>
|
||||
{t('scene.rotate')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
</>
|
||||
) : null}
|
||||
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<label className={styles.checkboxLabel}>
|
||||
@@ -2641,7 +2766,7 @@ function SceneInspector({
|
||||
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
|
||||
}}
|
||||
>
|
||||
Редактор сцены
|
||||
{t('sceneEditor.open')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
@@ -2654,74 +2779,20 @@ function SceneInspector({
|
||||
onDragOver={sceneAudioDrop.onDragOver}
|
||||
onDrop={sceneAudioDrop.onDrop}
|
||||
>
|
||||
{sceneAudioDrop.dragOver ? (
|
||||
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
|
||||
) : null}
|
||||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
||||
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
||||
{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);
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<EditorAudioTrackList
|
||||
audioRefs={audioRefs}
|
||||
mediaAssets={mediaAssets}
|
||||
removeTitle={t('scene.removeTitle')}
|
||||
onAudioRefsChange={onAudioRefsChange}
|
||||
/>
|
||||
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -2765,6 +2836,8 @@ function SceneListCard({
|
||||
const previewUrl = useAssetUrl(scene.previewAssetId);
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState(false);
|
||||
const titleRef = useRef<HTMLDivElement | null>(null);
|
||||
const [titleTooltip, setTitleTooltip] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
@@ -2854,33 +2927,28 @@ function SceneListCard({
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
) : previewUrl && scene.previewAssetType === 'video' ? (
|
||||
<div className={styles.sceneThumbInner}>
|
||||
<video
|
||||
src={previewUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
draggable={false}
|
||||
className={styles.sceneThumbVideo}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.sceneThumbEmptyInner} aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.sceneCardBody}>
|
||||
<div className={styles.sceneCardHeader}>
|
||||
{scene.active ? <div className={styles.badgeCurrent}>{t('sceneCard.current')}</div> : null}
|
||||
<div
|
||||
ref={titleRef}
|
||||
className={styles.sceneCardTitle}
|
||||
title={titleTooltip}
|
||||
onMouseEnter={() => {
|
||||
const el = titleRef.current;
|
||||
if (!el) {
|
||||
setTitleTooltip(undefined);
|
||||
return;
|
||||
}
|
||||
setTitleTooltip(el.scrollWidth > el.clientWidth + 1 ? scene.title : undefined);
|
||||
}}
|
||||
onMouseLeave={() => setTitleTooltip(undefined)}
|
||||
>
|
||||
{scene.title}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('sceneCard.menu')}
|
||||
@@ -2894,7 +2962,6 @@ function SceneListCard({
|
||||
⋮
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.sceneCardTitle}>{scene.title}</div>
|
||||
</div>
|
||||
{menu && menuPos
|
||||
? createPortal(
|
||||
@@ -2953,9 +3020,7 @@ function SceneListCard({
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.muted}>
|
||||
{t('confirmDeleteScene.body', { name: scene.title })}
|
||||
</div>
|
||||
<div className={styles.muted}>{t('confirmDeleteScene.body', { name: scene.title })}</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</Button>
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
clampSceneAudioVolume,
|
||||
type AssetId,
|
||||
type MediaAsset,
|
||||
type SceneAudioRef,
|
||||
} from '../../shared/types';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { registerEditorAudioStopper } from '../shared/audio/editorAudioStop';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
|
||||
const AUDIO_REF_DND = 'application/x-dnd-scene-audio-ref';
|
||||
|
||||
function reorderRefs(refs: SceneAudioRef[], draggedId: AssetId, targetId: AssetId): SceneAudioRef[] {
|
||||
if (draggedId === targetId) return refs;
|
||||
const from = refs.findIndex((r) => r.assetId === draggedId);
|
||||
const to = refs.findIndex((r) => r.assetId === targetId);
|
||||
if (from < 0 || to < 0) return refs;
|
||||
const next = [...refs];
|
||||
const [item] = next.splice(from, 1);
|
||||
if (!item) return refs;
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Локальный draft — не пишет в проект на каждый tick ползунка (иначе мигает граф). */
|
||||
function AudioVolumeSlider({
|
||||
assetId,
|
||||
volume,
|
||||
isPlaying,
|
||||
previewElRef,
|
||||
label,
|
||||
onCommit,
|
||||
}: {
|
||||
assetId: AssetId;
|
||||
volume: number;
|
||||
isPlaying: boolean;
|
||||
previewElRef: React.MutableRefObject<HTMLAudioElement | null>;
|
||||
label: string;
|
||||
onCommit: (volume: number) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => clampSceneAudioVolume(volume));
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(clampSceneAudioVolume(volume));
|
||||
}, [assetId, volume]);
|
||||
|
||||
const applyLocal = (v: number) => {
|
||||
const next = clampSceneAudioVolume(v);
|
||||
setDraft(next);
|
||||
if (isPlaying && previewElRef.current) previewElRef.current.volume = next;
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
const next = clampSceneAudioVolume(draftRef.current);
|
||||
if (next === clampSceneAudioVolume(volume)) return;
|
||||
onCommit(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={styles.audioVolumeLabel} title={label}>
|
||||
<span className={styles.spanXs}>{label}</span>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.audioVolumeRange}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={draft}
|
||||
aria-label={label}
|
||||
onChange={(e) => applyLocal(Number(e.target.value))}
|
||||
onPointerUp={commit}
|
||||
onBlur={commit}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') {
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export type EditorAudioTrackListProps = {
|
||||
audioRefs: SceneAudioRef[];
|
||||
mediaAssets: MediaAsset[];
|
||||
removeTitle: string;
|
||||
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||
};
|
||||
|
||||
export function EditorAudioTrackList({
|
||||
audioRefs,
|
||||
mediaAssets,
|
||||
removeTitle,
|
||||
onAudioRefsChange,
|
||||
}: EditorAudioTrackListProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const assetById = useMemo(() => new Map(mediaAssets.map((a) => [a.id, a])), [mediaAssets]);
|
||||
const [playingId, setPlayingId] = useState<AssetId | null>(null);
|
||||
const previewElRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const el = previewElRef.current;
|
||||
if (el) {
|
||||
el.pause();
|
||||
el.src = '';
|
||||
previewElRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return registerEditorAudioStopper(() => {
|
||||
const el = previewElRef.current;
|
||||
if (el) {
|
||||
el.pause();
|
||||
el.onended = null;
|
||||
el.src = '';
|
||||
}
|
||||
previewElRef.current = null;
|
||||
setPlayingId(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const stopPreview = () => {
|
||||
const el = previewElRef.current;
|
||||
if (el) {
|
||||
el.pause();
|
||||
el.onended = null;
|
||||
el.src = '';
|
||||
}
|
||||
previewElRef.current = null;
|
||||
setPlayingId(null);
|
||||
};
|
||||
|
||||
const togglePreview = async (assetId: AssetId, volume: number) => {
|
||||
if (playingId === assetId) {
|
||||
stopPreview();
|
||||
return;
|
||||
}
|
||||
stopPreview();
|
||||
try {
|
||||
const r = await getDndApi().invoke(ipcChannels.project.assetFileUrl, { assetId });
|
||||
const url = r.url;
|
||||
if (!url) return;
|
||||
const el = new Audio(url);
|
||||
el.volume = clampSceneAudioVolume(volume);
|
||||
el.onended = () => {
|
||||
previewElRef.current = null;
|
||||
setPlayingId(null);
|
||||
};
|
||||
previewElRef.current = el;
|
||||
setPlayingId(assetId);
|
||||
await el.play();
|
||||
} catch {
|
||||
stopPreview();
|
||||
}
|
||||
};
|
||||
|
||||
if (audioRefs.length === 0) {
|
||||
return (
|
||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.audioList}>
|
||||
{audioRefs.map((ref) => {
|
||||
const asset = assetById.get(ref.assetId);
|
||||
const name = asset?.originalName ?? ref.assetId;
|
||||
const volume = clampSceneAudioVolume(ref.volume);
|
||||
const isPlaying = playingId === ref.assetId;
|
||||
return (
|
||||
<div
|
||||
key={ref.assetId}
|
||||
className={styles.audioRow}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes(AUDIO_REF_DND)) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const dragged = e.dataTransfer.getData(AUDIO_REF_DND) as AssetId;
|
||||
if (!dragged) return;
|
||||
const next = reorderRefs(audioRefs, dragged, ref.assetId);
|
||||
if (next !== audioRefs) onAudioRefsChange(next);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.audioDragHandle}
|
||||
draggable
|
||||
title={name}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(AUDIO_REF_DND, ref.assetId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
>
|
||||
<span className={styles.audioDragGrip} aria-hidden>
|
||||
⋮⋮
|
||||
</span>
|
||||
<span className={styles.audioName}>{name}</span>
|
||||
</div>
|
||||
<span className={styles.audioControls}>
|
||||
<label className={styles.checkboxLabelSm}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ref.autoplay}
|
||||
onChange={(e) => {
|
||||
const next = audioRefs.map((x) =>
|
||||
x.assetId === ref.assetId ? { ...x, autoplay: e.target.checked } : x,
|
||||
);
|
||||
onAudioRefsChange(next);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.spanXs}>{t('campaign.auto')}</span>
|
||||
</label>
|
||||
<label className={styles.checkboxLabelSm}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ref.loop}
|
||||
onChange={(e) => {
|
||||
const next = audioRefs.map((x) =>
|
||||
x.assetId === ref.assetId ? { ...x, loop: e.target.checked } : x,
|
||||
);
|
||||
onAudioRefsChange(next);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.spanXs}>{t('campaign.loop')}</span>
|
||||
</label>
|
||||
<AudioVolumeSlider
|
||||
assetId={ref.assetId}
|
||||
volume={volume}
|
||||
isPlaying={isPlaying}
|
||||
previewElRef={previewElRef}
|
||||
label={t('campaign.volume')}
|
||||
onCommit={(v) => {
|
||||
const next = audioRefs.map((x) =>
|
||||
x.assetId === ref.assetId ? { ...x, volume: v } : x,
|
||||
);
|
||||
onAudioRefsChange(next);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.audioPreviewBtn}
|
||||
title={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
|
||||
aria-label={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
|
||||
onClick={() => void togglePreview(ref.assetId, volume)}
|
||||
>
|
||||
{isPlaying ? '■' : '▶'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={removeTitle}
|
||||
className={styles.audioRemove}
|
||||
onClick={() => {
|
||||
if (playingId === ref.assetId) stopPreview();
|
||||
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== ref.assetId));
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className={styles.audioRemoveIcon}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
height={16}
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { GameSystemListItem } from '../../shared/ipc/contracts';
|
||||
|
||||
export type { GameSystemListItem };
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
labels: {
|
||||
title: string;
|
||||
import: string;
|
||||
preview: string;
|
||||
remove: string;
|
||||
empty: string;
|
||||
close: string;
|
||||
};
|
||||
listSystems: () => Promise<GameSystemListItem[]>;
|
||||
importSystem: () => Promise<GameSystemListItem | null>;
|
||||
deleteSystem: (id: string) => Promise<void>;
|
||||
previewSystem: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function GameSystemsModal({
|
||||
open,
|
||||
onClose,
|
||||
labels,
|
||||
listSystems,
|
||||
importSystem,
|
||||
deleteSystem,
|
||||
previewSystem,
|
||||
}: Props) {
|
||||
const [items, setItems] = useState<GameSystemListItem[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void (async () => {
|
||||
setItems(await listSystems());
|
||||
})();
|
||||
}, [open, listSystems]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="game-systems-modal"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 560,
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
background: 'rgba(10,10,14,0.98)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<strong>{labels.title}</strong>
|
||||
<button type="button" data-testid="game-systems-close" onClick={onClose}>
|
||||
{labels.close}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="game-systems-import"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await importSystem();
|
||||
setItems(await listSystems());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{labels.import}
|
||||
</button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div style={{ opacity: 0.6 }}>{labels.empty}</div>
|
||||
) : (
|
||||
items.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
data-testid={`game-system-${s.id}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
padding: '10px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div>{s.name}</div>
|
||||
<div style={{ opacity: 0.55, fontSize: 12 }}>
|
||||
{s.id} · r{s.revision}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={() => void previewSystem(s.id)}>
|
||||
{labels.preview}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await deleteSystem(s.id);
|
||||
setItems(await listSystems());
|
||||
}}
|
||||
>
|
||||
{labels.remove}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.dialog {
|
||||
width: min(440px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.teamDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 10px 8px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
|
||||
import { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
|
||||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import launchStyles from './LaunchPlayersModal.module.css';
|
||||
|
||||
export { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
|
||||
|
||||
export function LaunchPlayersModal({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (playerIds: string[]) => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const { players, teams } = useAppPlayers();
|
||||
const [selectedPlayers, setSelectedPlayers] = useState<Set<string>>(() => new Set());
|
||||
const [selectedTeams, setSelectedTeams] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedPlayers(new Set());
|
||||
setSelectedTeams(new Set());
|
||||
}, [open]);
|
||||
|
||||
const resolvedIds = useMemo(
|
||||
() => resolveLaunchPlayerIds(players, selectedPlayers, selectedTeams),
|
||||
[players, selectedPlayers, selectedTeams],
|
||||
);
|
||||
|
||||
const standalonePlayers = useMemo(
|
||||
() =>
|
||||
players.filter((p) => !(p.teamId && selectedTeams.has(String(p.teamId)))),
|
||||
[players, selectedTeams],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const togglePlayer = (id: PlayerId) => {
|
||||
setSelectedPlayers((prev) => {
|
||||
const next = new Set(prev);
|
||||
const key = String(id);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTeam = (id: PlayerTeamId) => {
|
||||
const key = String(id);
|
||||
setSelectedTeams((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
// Участники выбранной команды не показываются в «Игроки» — снимаем их индивидуальный выбор.
|
||||
setSelectedPlayers((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set(prev);
|
||||
let changed = false;
|
||||
for (const p of players) {
|
||||
if (String(p.teamId) !== key) continue;
|
||||
if (next.delete(String(p.id))) changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
|
||||
<div
|
||||
className={[styles.modalDialog, launchStyles.dialog].join(' ')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="launch-players-modal"
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('top.runWithPlayersTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className={launchStyles.hint}>{t('top.runWithPlayersHint')}</p>
|
||||
{teams.length > 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersTeams')}</div>
|
||||
<div className={launchStyles.list}>
|
||||
{teams.map((team: AppPlayerTeam) => {
|
||||
const count = players.filter((p) => p.teamId === team.id).length;
|
||||
return (
|
||||
<label key={team.id} className={launchStyles.row}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTeams.has(String(team.id))}
|
||||
onChange={() => toggleTeam(team.id)}
|
||||
data-testid={`launch-team-${team.id}`}
|
||||
/>
|
||||
<span className={launchStyles.teamDot} style={{ background: team.color }} />
|
||||
<span>
|
||||
{team.name} ({count})
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{standalonePlayers.length > 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersPlayers')}</div>
|
||||
<div className={launchStyles.list}>
|
||||
{standalonePlayers.map((player: AppPlayer) => (
|
||||
<label key={player.id} className={launchStyles.row}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPlayers.has(String(player.id))}
|
||||
onChange={() => togglePlayer(player.id)}
|
||||
data-testid={`launch-player-${player.id}`}
|
||||
/>
|
||||
<span>{player.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : players.length === 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.empty}>{t('top.runWithPlayersEmpty')}</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={resolvedIds.length === 0}
|
||||
data-testid="launch-players-confirm"
|
||||
onClick={() => onConfirm(resolvedIds)}
|
||||
>
|
||||
{t('top.runWithPlayersConfirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,17 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legendHeadRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.legendHeadRow .row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -27,6 +27,8 @@ type Props = {
|
||||
/** Крупная карта (окно «Материалы»). */
|
||||
largeMap?: boolean;
|
||||
onChange: (legend: MaterialLegend) => void;
|
||||
onRotate?: () => void;
|
||||
rotateLabel?: string;
|
||||
};
|
||||
|
||||
type DragMode =
|
||||
@@ -55,6 +57,8 @@ export function MaterialLegendEditor({
|
||||
rotationDeg = 0,
|
||||
largeMap = false,
|
||||
onChange,
|
||||
onRotate,
|
||||
rotateLabel = 'Повернуть',
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend));
|
||||
const [activeItemId, setActiveItemId] = useState<string | null>(null);
|
||||
@@ -404,10 +408,15 @@ export function MaterialLegendEditor({
|
||||
<div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}>
|
||||
{largeMap ? mapBlock : null}
|
||||
|
||||
<label className={styles.row}>
|
||||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>Легенда</span>
|
||||
</label>
|
||||
<div className={styles.legendHeadRow}>
|
||||
{onRotate ? (
|
||||
<Button onClick={onRotate}>{rotateLabel}</Button>
|
||||
) : null}
|
||||
<label className={styles.row}>
|
||||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>Легенда</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.enabled ? (
|
||||
<>
|
||||
|
||||
@@ -19,7 +19,7 @@ export type MaterialsBrowserProps = {
|
||||
mode: 'editor' | 'runtime';
|
||||
selectedId: MaterialId | null;
|
||||
onSelect: (id: MaterialId | null) => void;
|
||||
activeMaterialId?: MaterialId | null;
|
||||
activeMaterialIds?: readonly MaterialId[];
|
||||
onAdd?: () => void;
|
||||
onEdit?: (material: ProjectMaterial) => void;
|
||||
onDelete?: (materialId: MaterialId) => Promise<void>;
|
||||
@@ -40,7 +40,7 @@ export function MaterialsBrowser({
|
||||
mode,
|
||||
selectedId,
|
||||
onSelect,
|
||||
activeMaterialId = null,
|
||||
activeMaterialIds = [],
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -74,6 +74,7 @@ export function MaterialsBrowser({
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [menuFor]);
|
||||
|
||||
const activeSet = useMemo(() => new Set(activeMaterialIds), [activeMaterialIds]);
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return materials;
|
||||
@@ -126,7 +127,7 @@ export function MaterialsBrowser({
|
||||
key={m.id}
|
||||
material={m}
|
||||
selected={m.id === selectedId}
|
||||
active={m.id === activeMaterialId}
|
||||
active={activeSet.has(m.id)}
|
||||
showMenu={mode === 'editor'}
|
||||
dragging={dragId === m.id}
|
||||
dropPlace={dropPlace?.id === m.id ? dropPlace.place : null}
|
||||
@@ -200,6 +201,16 @@ export function MaterialsBrowser({
|
||||
legend={selected.legend}
|
||||
previewUrl={selectedUrl}
|
||||
rotationDeg={selected.rotationDeg ?? 0}
|
||||
rotateLabel={t('scene.rotate')}
|
||||
onRotate={
|
||||
onRotate
|
||||
? () => {
|
||||
const cur = selected.rotationDeg ?? 0;
|
||||
const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
onRotate(selected.id, next);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={(next) => {
|
||||
void onLegendChange(selected.id, next);
|
||||
}}
|
||||
@@ -220,7 +231,7 @@ export function MaterialsBrowser({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selected && onRotate ? (
|
||||
{selected && onRotate && !onLegendChange ? (
|
||||
<div className={matStyles.previewActions}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -33,6 +33,28 @@
|
||||
.browserToolbarRow > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.browserToolbarRow > * > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browserToolbarZoomRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browserToolbarFullBtn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browserToolbarFullBtn > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browserToolbarHint {
|
||||
|
||||
@@ -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,243 @@
|
||||
.dialog {
|
||||
width: min(1080px, calc(100vw - 48px));
|
||||
max-width: 1080px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 330px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 560px;
|
||||
max-height: min(78vh, 760px);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.editor {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebarActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebarActions > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebarActions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.teamForm {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 10px;
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.teamFormField {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.teamFormField input[type='color'] {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.teamFormActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.teamFormActions > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.teamFormActions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.team {
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 10px;
|
||||
background: var(--color-overlay-dark-2);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.teamHeader {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 34px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.teamDot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.teamName {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.teamMenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.teamMenu summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: 4px 7px;
|
||||
}
|
||||
|
||||
.teamMenuPopup {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 130px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: var(--bg1, #1a1d24);
|
||||
box-shadow: 0 10px 24px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.teamMenuPopup button {
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.teamMenuPopup button:hover {
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.teamBody {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.playerRow {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.playerRow:hover,
|
||||
.playerRowSelected {
|
||||
border-color: var(--color-accent, #c9a227);
|
||||
background: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.playerRow > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropTarget {
|
||||
padding: 8px;
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 12px;
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.editorDropOver {
|
||||
border-color: var(--color-accent, #c9a227);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: min(420px, 100%);
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field input[type='color'] {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
|
||||
import {
|
||||
DEFAULT_PLAYER_IMAGE_OFFSET,
|
||||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||||
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from './fileDrop';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import playerStyles from './PlayersModals.module.css';
|
||||
|
||||
const PLAYER_DND_MIME = 'application/x-dnd-player-id';
|
||||
|
||||
function PlayerRow({
|
||||
player,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const url = usePlayerImageUrl(player.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`player-row-${player.id}`}
|
||||
className={[playerStyles.playerRow, selected ? playerStyles.playerRowSelected : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(PLAYER_DND_MIME, player.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={player.name}
|
||||
imageUrl={url}
|
||||
ringColor={player.ringColor}
|
||||
imageOffset={player.imageOffset}
|
||||
imageScale={player.imageScale}
|
||||
sizePx={48}
|
||||
hideName
|
||||
/>
|
||||
<span>{player.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamSection({
|
||||
team,
|
||||
players,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onAssign,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
team: AppPlayerTeam | null;
|
||||
players: AppPlayer[];
|
||||
selectedId: PlayerId | null;
|
||||
onSelect: (id: PlayerId) => void;
|
||||
onAssign: (id: PlayerId, teamId: PlayerTeamId | null) => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
return (
|
||||
<section
|
||||
className={playerStyles.team}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes(PLAYER_DND_MIME)) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData(PLAYER_DND_MIME);
|
||||
if (id) onAssign(id as PlayerId, team?.id ?? null);
|
||||
}}
|
||||
>
|
||||
<div className={playerStyles.teamHeader}>
|
||||
{team ? <span className={playerStyles.teamDot} style={{ background: team.color }} /> : null}
|
||||
<span className={playerStyles.teamName}>{team?.name ?? t('players.ungrouped')}</span>
|
||||
{team ? (
|
||||
<details className={playerStyles.teamMenu}>
|
||||
<summary aria-label={t('players.teamMenu')}>⋮</summary>
|
||||
<div className={playerStyles.teamMenuPopup}>
|
||||
<button type="button" onClick={onEdit}>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button type="button" onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={playerStyles.teamBody}>
|
||||
{players.map((player) => (
|
||||
<PlayerRow
|
||||
key={player.id}
|
||||
player={player}
|
||||
selected={player.id === selectedId}
|
||||
onSelect={() => onSelect(player.id)}
|
||||
/>
|
||||
))}
|
||||
{players.length === 0 ? <div className={playerStyles.dropTarget}>{t('players.dropHere')}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const { players, teams } = useAppPlayers();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<PlayerId | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [ringColor, setRingColor] = useState(DEFAULT_PLAYER_RING_COLOR);
|
||||
const [imageOffset, setImageOffset] = useState(DEFAULT_PLAYER_IMAGE_OFFSET);
|
||||
const [imageScale, setImageScale] = useState(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [progress, setProgress] = useState({ percent: 0, detail: '' });
|
||||
/** Electron не поддерживает window.prompt — форма команды в сайдбаре. */
|
||||
const [teamForm, setTeamForm] = useState<{
|
||||
id: PlayerTeamId | null;
|
||||
name: string;
|
||||
color: string;
|
||||
} | null>(null);
|
||||
const [pendingDeletePlayer, setPendingDeletePlayer] = useState<AppPlayer | null>(null);
|
||||
const [pendingDeleteTeam, setPendingDeleteTeam] = useState<AppPlayerTeam | null>(null);
|
||||
const appearanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selected = players.find((p) => p.id === selectedId) ?? null;
|
||||
const selectedUrl = usePlayerImageUrl(selected?.id);
|
||||
|
||||
const resetModalState = useCallback(() => {
|
||||
if (appearanceTimerRef.current) {
|
||||
clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = null;
|
||||
}
|
||||
setQuery('');
|
||||
setSelectedId(null);
|
||||
setName('');
|
||||
setRingColor(DEFAULT_PLAYER_RING_COLOR);
|
||||
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
|
||||
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setAdding(false);
|
||||
setSaving(false);
|
||||
setProgress({ percent: 0, detail: '' });
|
||||
setTeamForm(null);
|
||||
setPendingDeletePlayer(null);
|
||||
setPendingDeleteTeam(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (saving) return;
|
||||
resetModalState();
|
||||
onClose();
|
||||
}, [onClose, resetModalState, saving]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedId((current) =>
|
||||
current && players.some((p) => p.id === current) ? current : (players[0]?.id ?? null),
|
||||
);
|
||||
}, [open, players]);
|
||||
|
||||
// Синхронизировать форму только при смене выбранного игрока — не при каждом
|
||||
// players.stateChanged (иначе сбрасывается несохранённый zoom/pan).
|
||||
useEffect(() => {
|
||||
if (adding) return;
|
||||
if (!selectedId) return;
|
||||
const p = players.find((item) => item.id === selectedId);
|
||||
if (!p) return;
|
||||
setName(p.name);
|
||||
setRingColor(p.ringColor);
|
||||
setImageOffset(p.imageOffset);
|
||||
setImageScale(p.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- только selectedId / adding
|
||||
}, [adding, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return api.on(ipcChannels.players.upsertProgress, (event) => {
|
||||
setProgress({
|
||||
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
|
||||
detail: event.detail?.trim() ?? t('players.savingWait'),
|
||||
});
|
||||
});
|
||||
}, [api, open, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || saving) return;
|
||||
if (pendingDeletePlayer) {
|
||||
setPendingDeletePlayer(null);
|
||||
return;
|
||||
}
|
||||
if (pendingDeleteTeam) {
|
||||
setPendingDeleteTeam(null);
|
||||
return;
|
||||
}
|
||||
if (teamForm) {
|
||||
setTeamForm(null);
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [handleClose, open, pendingDeletePlayer, pendingDeleteTeam, saving, teamForm]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? players.filter((p) => p.name.toLowerCase().includes(q)) : players;
|
||||
}, [players, query]);
|
||||
|
||||
const beginAdd = () => {
|
||||
setAdding(true);
|
||||
setSelectedId(null);
|
||||
setName('');
|
||||
setRingColor(DEFAULT_PLAYER_RING_COLOR);
|
||||
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
|
||||
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl(null);
|
||||
};
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, url: string) => {
|
||||
setFilePath(path);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return url || null;
|
||||
});
|
||||
};
|
||||
|
||||
const pickImage = async () => {
|
||||
const result = await api.invoke(ipcChannels.players.pickImage, {});
|
||||
if (result.canceled) return;
|
||||
setPreviewFromPathAndUrl(result.filePath, result.previewDataUrl);
|
||||
};
|
||||
|
||||
const applyDroppedImage = (path: string, previewFromFile: string) => {
|
||||
if (!adding && !selected) beginAdd();
|
||||
setPreviewFromPathAndUrl(path, previewFromFile);
|
||||
};
|
||||
|
||||
const drop = useFileDropZone({
|
||||
disabled: saving,
|
||||
filterPaths: filterMaterialImagePaths,
|
||||
onDropPaths: (paths) => {
|
||||
const picked = pickFirstMaterialImagePath(paths);
|
||||
if (!picked) return;
|
||||
applyDroppedImage(picked, '');
|
||||
},
|
||||
});
|
||||
|
||||
const appearanceLatestRef = useRef({
|
||||
selected,
|
||||
adding,
|
||||
saving,
|
||||
ringColor,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
});
|
||||
appearanceLatestRef.current = { selected, adding, saving, ringColor, imageOffset, imageScale };
|
||||
|
||||
const persistAppearance = (patch: {
|
||||
ringColor?: string;
|
||||
imageOffset?: typeof imageOffset;
|
||||
imageScale?: number;
|
||||
}) => {
|
||||
const snap = appearanceLatestRef.current;
|
||||
if (!snap.selected || snap.adding || snap.saving) return;
|
||||
const payload = {
|
||||
id: snap.selected.id,
|
||||
teamId: snap.selected.teamId,
|
||||
name: snap.selected.name,
|
||||
ringColor: patch.ringColor ?? snap.ringColor,
|
||||
imageOffset: patch.imageOffset ?? snap.imageOffset,
|
||||
imageScale: patch.imageScale ?? snap.imageScale,
|
||||
};
|
||||
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = setTimeout(() => {
|
||||
appearanceTimerRef.current = null;
|
||||
void api.invoke(ipcChannels.players.upsert, payload);
|
||||
}, 180);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || (selected === null && filePath === null)) return;
|
||||
if (appearanceTimerRef.current) {
|
||||
clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = null;
|
||||
}
|
||||
flushSync(() => {
|
||||
setSaving(true);
|
||||
setProgress({ percent: 0, detail: t('players.savingWait') });
|
||||
});
|
||||
try {
|
||||
const result = await api.invoke(ipcChannels.players.upsert, {
|
||||
...(selected ? { id: selected.id, teamId: selected.teamId } : {}),
|
||||
name: trimmed,
|
||||
...(filePath ? { filePath } : {}),
|
||||
ringColor,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
});
|
||||
setAdding(false);
|
||||
setSelectedId(result.player.id);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
const activeUrl = previewUrl ?? selectedUrl;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={styles.modalBackdrop} aria-hidden />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="players-modal"
|
||||
className={[styles.modalDialog, playerStyles.dialog].join(' ')}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.managerTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={handleClose}
|
||||
className={styles.modalClose}
|
||||
disabled={saving}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={playerStyles.body}>
|
||||
<aside className={playerStyles.sidebar}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('players.search')} />
|
||||
<div className={playerStyles.sidebarActions}>
|
||||
<Button variant="primary" data-testid="players-add" onClick={beginAdd}>
|
||||
{t('players.add')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="players-add-team"
|
||||
onClick={() =>
|
||||
setTeamForm({
|
||||
id: null,
|
||||
name: '',
|
||||
color: DEFAULT_PLAYER_RING_COLOR,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('players.addTeam')}
|
||||
</Button>
|
||||
</div>
|
||||
{teamForm ? (
|
||||
<div className={playerStyles.teamForm} data-testid="players-team-form">
|
||||
<label className={playerStyles.teamFormField}>
|
||||
<span>{t('players.teamName')}</span>
|
||||
<Input
|
||||
value={teamForm.name}
|
||||
onChange={(v) => setTeamForm((prev) => (prev ? { ...prev, name: v } : prev))}
|
||||
placeholder={t('players.teamName')}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className={playerStyles.teamFormField}>
|
||||
<span>{t('players.teamColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={teamForm.color}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setTeamForm((prev) => (prev ? { ...prev, color: next } : prev));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className={playerStyles.teamFormActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!teamForm.name.trim()}
|
||||
onClick={() => {
|
||||
const trimmed = teamForm.name.trim();
|
||||
if (!trimmed) return;
|
||||
void api
|
||||
.invoke(ipcChannels.players.upsertTeam, {
|
||||
...(teamForm.id ? { id: teamForm.id } : {}),
|
||||
name: trimmed,
|
||||
color: teamForm.color,
|
||||
})
|
||||
.then(() => setTeamForm(null));
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button onClick={() => setTeamForm(null)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={playerStyles.list}>
|
||||
{teams.map((team) => (
|
||||
<TeamSection
|
||||
key={team.id}
|
||||
team={team}
|
||||
players={filtered.filter((p) => p.teamId === team.id)}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
setAdding(false);
|
||||
setSelectedId(id);
|
||||
}}
|
||||
onAssign={(playerId, teamId) =>
|
||||
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
|
||||
}
|
||||
onEdit={() =>
|
||||
setTeamForm({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
color: team.color,
|
||||
})
|
||||
}
|
||||
onDelete={() => setPendingDeleteTeam(team)}
|
||||
/>
|
||||
))}
|
||||
<TeamSection
|
||||
team={null}
|
||||
players={filtered.filter((p) => p.teamId === null)}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
setAdding(false);
|
||||
setSelectedId(id);
|
||||
}}
|
||||
onAssign={(playerId, teamId) =>
|
||||
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<main
|
||||
className={[playerStyles.editor, drop.dragOver ? playerStyles.editorDropOver : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onDragEnter={drop.onDragEnter}
|
||||
onDragLeave={drop.onDragLeave}
|
||||
onDragOver={drop.onDragOver}
|
||||
onDrop={(e) => {
|
||||
drop.onDrop(e);
|
||||
const entries = getDroppedFileEntries(e);
|
||||
const files = e.dataTransfer?.files;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i]!;
|
||||
if (!pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files?.[i];
|
||||
applyDroppedImage(entry.path, file ? URL.createObjectURL(file) : '');
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('players.dropHint')}</div> : null}
|
||||
{adding || selected ? (
|
||||
<>
|
||||
<PlayerTokenView
|
||||
data-testid="player-token-preview"
|
||||
name={name}
|
||||
imageUrl={activeUrl}
|
||||
ringColor={ringColor}
|
||||
imageOffset={imageOffset}
|
||||
imageScale={imageScale}
|
||||
panEnabled
|
||||
onImageOffsetChange={(next) => {
|
||||
setImageOffset(next);
|
||||
persistAppearance({ imageOffset: next });
|
||||
}}
|
||||
onImageScaleChange={(next) => {
|
||||
setImageScale(next);
|
||||
persistAppearance({ imageScale: next });
|
||||
}}
|
||||
sizePx={220}
|
||||
/>
|
||||
<label className={playerStyles.field}>
|
||||
<span>{t('players.name')}</span>
|
||||
<Input value={name} onChange={setName} placeholder={t('players.namePlaceholder')} />
|
||||
</label>
|
||||
<label className={playerStyles.field}>
|
||||
<span>{t('players.ringColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={ringColor}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setRingColor(next);
|
||||
persistAppearance({ ringColor: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className={playerStyles.actions}>
|
||||
<Button data-testid="players-choose-image" onClick={() => void pickImage()}>
|
||||
{t('players.chooseImage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
data-testid="players-save"
|
||||
disabled={!name.trim() || (!selected && !filePath) || saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
{selected ? (
|
||||
<Button onClick={() => setPendingDeletePlayer(selected)}>{t('common.delete')}</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('players.selectPrompt')}</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{pendingDeletePlayer
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => setPendingDeletePlayer(null)}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="players-delete-confirm"
|
||||
className={styles.modalDialog}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.deleteTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => setPendingDeletePlayer(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.muted}>
|
||||
{t('players.deleteConfirm', { name: pendingDeletePlayer.name })}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeletePlayer(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeletePlayer.id;
|
||||
setPendingDeletePlayer(null);
|
||||
void api.invoke(ipcChannels.players.delete, { id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{pendingDeleteTeam
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => setPendingDeleteTeam(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.deleteTeamTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => setPendingDeleteTeam(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.muted}>
|
||||
{t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeleteTeam(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeleteTeam.id;
|
||||
setPendingDeleteTeam(null);
|
||||
void api.invoke(ipcChannels.players.deleteTeam, { id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{saving ? (
|
||||
<div className={styles.progressOverlay} role="dialog" aria-busy data-testid="players-save-progress">
|
||||
<div className={styles.progressModal}>
|
||||
<div className={styles.progressTitle}>{t('players.savingTitle')}</div>
|
||||
<div className={styles.previewSpinner} aria-hidden />
|
||||
<div className={styles.progressBar}>
|
||||
<div className={styles.progressFill} style={{ width: `${String(progress.percent)}%` }} />
|
||||
</div>
|
||||
<div className={styles.progressMeta}>
|
||||
<div>{progress.detail}</div>
|
||||
<div>{progress.percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.dialog {
|
||||
width: min(960px, calc(100vw - 48px));
|
||||
height: min(720px, calc(100vh - 48px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 4px 8px;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: initialHtml || '',
|
||||
immediatelyRender: true,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
@@ -94,21 +94,56 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
||||
const toolbarState = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: ed }) => ({
|
||||
bold: ed.isActive('bold'),
|
||||
italic: ed.isActive('italic'),
|
||||
underline: ed.isActive('underline'),
|
||||
bulletList: ed.isActive('bulletList'),
|
||||
orderedList: ed.isActive('orderedList'),
|
||||
h2: ed.isActive('heading', { level: 2 }),
|
||||
h3: ed.isActive('heading', { level: 3 }),
|
||||
blockquote: ed.isActive('blockquote'),
|
||||
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||||
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||||
underline: Boolean(ed && !ed.isDestroyed && ed.isActive('underline')),
|
||||
bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
|
||||
orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
|
||||
h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
|
||||
h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
|
||||
blockquote: Boolean(ed && !ed.isDestroyed && ed.isActive('blockquote')),
|
||||
}),
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(normalizeSceneDescriptionHtml(editor.getHTML()));
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
let raw = '';
|
||||
try {
|
||||
raw = editor.getHTML();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
onSave(normalizeSceneDescriptionHtml(raw));
|
||||
};
|
||||
|
||||
if (!editor || editor.isDestroyed) {
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={[styles.modalDialog, modalStyles.dialog].join(' ')}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('scene.descriptionModalTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={modalStyles.editorShell} />
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
@@ -135,21 +170,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionBold')}
|
||||
active={toolbarState.bold}
|
||||
active={toolbarState?.bold ?? false}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionItalic')}
|
||||
active={toolbarState.italic}
|
||||
active={toolbarState?.italic ?? false}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<em>I</em>
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionUnderline')}
|
||||
active={toolbarState.underline}
|
||||
active={toolbarState?.underline ?? false}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<span style={{ textDecoration: 'underline' }}>U</span>
|
||||
@@ -159,21 +194,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionHeading2')}
|
||||
active={toolbarState.h2}
|
||||
active={toolbarState?.h2 ?? false}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
H2
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionHeading3')}
|
||||
active={toolbarState.h3}
|
||||
active={toolbarState?.h3 ?? false}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
H3
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionQuote')}
|
||||
active={toolbarState.blockquote}
|
||||
active={toolbarState?.blockquote ?? false}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
|
||||
@@ -183,14 +218,14 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionBulletList')}
|
||||
active={toolbarState.bulletList}
|
||||
active={toolbarState?.bulletList ?? false}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<ToolbarIcon path="M4 6h2v2H4V6zm0 5h2v2H4v-2zm0 5h2v2H4v-2zm4-10h12v2H8V6zm0 5h12v2H8v-2zm0 5h12v2H8v-2z" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionOrderedList')}
|
||||
active={toolbarState.orderedList}
|
||||
active={toolbarState?.orderedList ?? false}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
|
||||
|
||||
@@ -3,9 +3,9 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
collectSceneIdsForSelections,
|
||||
filterNpcsForStorylineExport,
|
||||
findNpcNameConflicts,
|
||||
findSceneTitleConflicts,
|
||||
listExportedNpcsFromBundle,
|
||||
storylineSelectionKey,
|
||||
type NpcImportResolution,
|
||||
type NpcNameConflict,
|
||||
@@ -18,33 +18,48 @@ 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';
|
||||
|
||||
export type ExportNpcOption = { id: string; name: string };
|
||||
|
||||
type ExportProjectModalProps = {
|
||||
open: boolean;
|
||||
projects: { id: ProjectId; name: string; fileName: string }[];
|
||||
initialProjectId: ProjectId | null;
|
||||
storylineLabels: StorylineLabels;
|
||||
loadStorylines: (projectId: ProjectId) => Promise<StorylineListItem[]>;
|
||||
loadStorylines: (
|
||||
projectId: ProjectId,
|
||||
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
|
||||
onClose: () => void;
|
||||
onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise<void>;
|
||||
onExport: (
|
||||
projectId: ProjectId,
|
||||
selections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
playlistIds: string[],
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ExportProjectModal({
|
||||
open,
|
||||
projects,
|
||||
initialProjectId,
|
||||
storylineLabels,
|
||||
storylineLabels: _storylineLabels,
|
||||
loadStorylines,
|
||||
onClose,
|
||||
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);
|
||||
@@ -55,23 +70,39 @@ export function ExportProjectModal({
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setSelectedPlaylistIds(new Set());
|
||||
setStep('storylines');
|
||||
setNpcs([]);
|
||||
}, [initialProjectId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) {
|
||||
setStorylines([]);
|
||||
setNpcs([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingStorylines(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await loadStorylines(projectId);
|
||||
const res = await loadStorylines(projectId);
|
||||
if (cancelled) return;
|
||||
const list = Array.isArray(res?.storylines) ? res.storylines : [];
|
||||
const npcList = Array.isArray(res?.npcs) ? res.npcs : [];
|
||||
setStorylines(list);
|
||||
setNpcs(npcList);
|
||||
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
|
||||
setSelectedNpcIds(new Set(npcList.map((n) => n.id)));
|
||||
setStep('storylines');
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
if (!cancelled) {
|
||||
setStorylines([]);
|
||||
setNpcs([]);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingStorylines(false);
|
||||
}
|
||||
@@ -84,15 +115,18 @@ export function ExportProjectModal({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') {
|
||||
if (step === 'npcs') setStep('storylines');
|
||||
else onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
}, [onClose, open, step]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canExport =
|
||||
const canContinueStorylines =
|
||||
projectId !== null &&
|
||||
projects.some((p) => p.id === projectId) &&
|
||||
selectedKeys.size > 0 &&
|
||||
@@ -108,10 +142,44 @@ export function ExportProjectModal({
|
||||
});
|
||||
};
|
||||
|
||||
const toggleNpc = (id: string) => {
|
||||
setSelectedNpcIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedSelections = storylines
|
||||
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
|
||||
.map((item) => item.selection);
|
||||
|
||||
const runExport = (npcIds: string[]) => {
|
||||
if (!projectId || !canContinueStorylines) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onExport(projectId, selectedSelections, npcIds, [...selectedPlaylistIds]);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const goNextFromStorylines = () => {
|
||||
if (!canContinueStorylines) return;
|
||||
if (npcs.length === 0 && playlistsState.playlists.length === 0) {
|
||||
runExport([]);
|
||||
return;
|
||||
}
|
||||
setStep('npcs');
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
@@ -122,7 +190,9 @@ export function ExportProjectModal({
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('export.title')}</div>
|
||||
<div className={styles.modalTitle}>
|
||||
{step === 'storylines' ? t('export.title') : t('export.npcsTitle')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
@@ -133,81 +203,140 @@ export function ExportProjectModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
||||
<Select
|
||||
value={projectId ?? ''}
|
||||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||||
disabled={projects.length === 0}
|
||||
ariaLabel={t('export.project')}
|
||||
options={projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
{step === 'storylines' ? (
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
||||
<Select
|
||||
value={projectId ?? ''}
|
||||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||||
disabled={projects.length === 0 || saving}
|
||||
ariaLabel={t('export.project')}
|
||||
options={projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{loadingStorylines ? (
|
||||
<div className={styles.muted}>{t('storyline.loading')}</div>
|
||||
) : storylines.length === 0 ? (
|
||||
<div className={styles.muted}>{t('storyline.empty')}</div>
|
||||
) : (
|
||||
<div className={styles.storylineChecklist}>
|
||||
{storylines.map((item) => {
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const checked = selectedKeys.has(key);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleKey(key, disabled)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{disabled && item.disabledReason === 'main_exists' ? (
|
||||
<span className={styles.muted}> — {t('storyline.mainExistsHint')}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{loadingStorylines ? (
|
||||
<div className={styles.muted}>{t('storyline.loading')}</div>
|
||||
) : storylines.length === 0 ? (
|
||||
<div className={styles.muted}>{t('storyline.empty')}</div>
|
||||
) : (
|
||||
<div className={styles.storylineChecklist}>
|
||||
{storylines.map((item) => {
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const checked = selectedKeys.has(key);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled || saving}
|
||||
onChange={() => toggleKey(key, disabled)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{disabled && item.disabledReason === 'main_exists' ? (
|
||||
<span className={styles.muted}> — {t('storyline.mainExistsHint')}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.muted}>{t('export.hint')}</div>
|
||||
</div>
|
||||
<div className={styles.muted}>{t('export.hint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.fieldGrid}>
|
||||
{npcs.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
||||
<div className={styles.storylineChecklist}>
|
||||
{npcs.map((n) => (
|
||||
<label key={n.id} className={styles.storylineCheck}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNpcIds.has(n.id)}
|
||||
disabled={saving}
|
||||
onChange={() => toggleNpc(n.id)}
|
||||
/>
|
||||
<span>{n.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
disabled={saving || npcs.length === 0}
|
||||
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canExport || saving}
|
||||
onClick={() => {
|
||||
if (!projectId || !canExport) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onExport(projectId, selectedSelections);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('export.saveAs')}
|
||||
</Button>
|
||||
{step === 'npcs' ? (
|
||||
<Button onClick={() => setStep('storylines')} disabled={saving}>
|
||||
{t('export.back')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{step === 'storylines' ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canContinueStorylines || saving}
|
||||
onClick={goNextFromStorylines}
|
||||
>
|
||||
{npcs.length > 0 || playlistsState.playlists.length > 0 ? t('export.next') : t('export.saveAs')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={saving}
|
||||
onClick={() => runExport([...selectedNpcIds])}
|
||||
>
|
||||
{t('export.saveAs')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
@@ -831,11 +960,10 @@ export function computeImportConflicts(
|
||||
export function buildNpcResolutionsForImport(
|
||||
_targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
conflicts: NpcNameConflict[],
|
||||
userResolutions: NpcImportResolution[],
|
||||
): NpcImportResolution[] {
|
||||
const exported = filterNpcsForStorylineExport(sourceProject, selections);
|
||||
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
|
||||
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
|
||||
const out: NpcImportResolution[] = [];
|
||||
@@ -853,9 +981,8 @@ export function buildNpcResolutionsForImport(
|
||||
export function computeNpcImportConflicts(
|
||||
targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
): NpcNameConflict[] {
|
||||
const exported = filterNpcsForStorylineExport(sourceProject, selections);
|
||||
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||
return findNpcNameConflicts(
|
||||
targetProject,
|
||||
sourceProject,
|
||||
|
||||
@@ -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,6 +86,24 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
|
||||
{t('app.about.websiteUrl')}
|
||||
</a>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{sponsor.name}
|
||||
</a>
|
||||
) : (
|
||||
<div key={sponsor.name}>{sponsor.name}</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
@@ -92,125 +116,3 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type InstructionsModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialSection?: HelpSectionId;
|
||||
};
|
||||
|
||||
function InstructionsModalBody({
|
||||
initialSection,
|
||||
onClose,
|
||||
}: {
|
||||
initialSection: HelpSectionId;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0 });
|
||||
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
||||
activeNav?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeId]);
|
||||
|
||||
const linkCatalog = useMemo(
|
||||
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
|
||||
[t],
|
||||
);
|
||||
|
||||
const body = t(helpSectionBodyKey(activeId));
|
||||
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
||||
|
||||
const goToSection = (id: HelpSectionId) => {
|
||||
setActiveId(id);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.instructionsDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('help.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.instructionsLayout}>
|
||||
<div ref={contentRef} className={styles.instructionsContent}>
|
||||
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} className={styles.instructionsParagraph}>
|
||||
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
|
||||
part.type === 'text' ? (
|
||||
<React.Fragment key={j}>{part.value}</React.Fragment>
|
||||
) : (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
className={styles.instructionsInlineLink}
|
||||
onClick={() => goToSection(part.id)}
|
||||
>
|
||||
{part.value}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}>
|
||||
{HELP_SECTION_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={[
|
||||
styles.instructionsNavItem,
|
||||
id === activeId ? styles.instructionsNavItemActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={id === activeId ? 'true' : undefined}
|
||||
onClick={() => goToSection(id)}
|
||||
>
|
||||
{t(helpSectionTitleKey(id))}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) {
|
||||
if (!open) return null;
|
||||
const section = initialSection ?? 'overview';
|
||||
return <InstructionsModalBody key={section} initialSection={section} onClose={onClose} />;
|
||||
}
|
||||
|
||||
@@ -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 : 'Новая сцена';
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.musicParams {
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
} from '../../../shared/graph/sceneGraphLineage';
|
||||
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
|
||||
import { RotatedImage } from '../../shared/RotatedImage';
|
||||
import { EllipsisText } from '../../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
|
||||
import { useAssetUrl } from '../../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './SceneGraph.module.css';
|
||||
@@ -257,23 +259,6 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : previewUrl && data.previewAssetType === 'video' ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.previewPlaceholder} aria-hidden />
|
||||
)}
|
||||
@@ -293,7 +278,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.nodeBody}>
|
||||
<div className={styles.title}>{data.title || ui.untitled}</div>
|
||||
<EllipsisText text={data.title || ui.untitled} className={[styles.title, ellipsisStyles.root].join(' ')} />
|
||||
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParams}>
|
||||
{data.hasAnyAudioLoop ? (
|
||||
|
||||
@@ -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> = {
|
||||
@@ -18,6 +18,7 @@ const RU_TITLES: Record<HelpSectionId, string> = {
|
||||
tokens: 'Неигровые токены',
|
||||
campaignAudio: 'Аудио игры',
|
||||
materials: 'Материалы',
|
||||
players: 'Игроки',
|
||||
npcs: 'НПС',
|
||||
session: 'Запуск сессии',
|
||||
controlPanel: 'Пульт управления',
|
||||
@@ -31,8 +32,7 @@ const RU_TITLES: Record<HelpSectionId, string> = {
|
||||
|
||||
void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => {
|
||||
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
|
||||
const text =
|
||||
'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
|
||||
const text = 'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
|
||||
const ranges = findHelpLinkRanges(text, catalog);
|
||||
assert.deepEqual(
|
||||
ranges.map((r) => r.id),
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
|
||||
presentation: ['Презентация', 'Presentation'],
|
||||
grid: ['Сетка', 'Grid'],
|
||||
controlPanel: ['Пульт'],
|
||||
players: ['Игроки', 'Players'],
|
||||
tokens: ['Токены', 'Tokens'],
|
||||
npcs: ['NPC'],
|
||||
};
|
||||
|
||||
export type HelpLinkCatalogEntry = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Порядок разделов в окне «Инструкция». */
|
||||
export const HELP_SECTION_IDS = [
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../../shared/features/usersBranchFeatures';
|
||||
|
||||
const HELP_SECTION_IDS_ALL = [
|
||||
'overview',
|
||||
'license',
|
||||
'projects',
|
||||
@@ -13,6 +15,7 @@ export const HELP_SECTION_IDS = [
|
||||
'tokens',
|
||||
'campaignAudio',
|
||||
'materials',
|
||||
'players',
|
||||
'npcs',
|
||||
'session',
|
||||
'controlPanel',
|
||||
@@ -24,7 +27,11 @@ export const HELP_SECTION_IDS = [
|
||||
'settings',
|
||||
] as const;
|
||||
|
||||
export type HelpSectionId = (typeof HELP_SECTION_IDS)[number];
|
||||
export type HelpSectionId = (typeof HELP_SECTION_IDS_ALL)[number];
|
||||
|
||||
export const HELP_SECTION_IDS: readonly HelpSectionId[] = USERS_BRANCH_FEATURES_ENABLED
|
||||
? HELP_SECTION_IDS_ALL
|
||||
: HELP_SECTION_IDS_ALL.filter((id) => id !== 'players');
|
||||
|
||||
export function helpSectionTitleKey(id: HelpSectionId): string {
|
||||
return `help.section.${id}.title`;
|
||||
@@ -33,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';
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
translateEditorMessage,
|
||||
type EditorLocale,
|
||||
} from './editorMessages';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
|
||||
type EditorI18nContextValue = {
|
||||
locale: EditorLocale;
|
||||
@@ -36,6 +38,15 @@ export function EditorI18nProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const tag = locale === 'ru' ? 'ru-RU' : 'en-US';
|
||||
try {
|
||||
void getDndApi().invoke(ipcChannels.windows.syncChromeTitles, { localeTag: tag });
|
||||
} catch {
|
||||
// preload ещё не готов (редко при первом кадре)
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
// Другие окна Electron (пульт, материалы) подхватывают смену языка из редактора.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
import { EditorApp } from './EditorApp';
|
||||
import { EditorI18nProvider } from './i18n/EditorI18nContext';
|
||||
|
||||
@@ -12,8 +13,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<EditorApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Редактор">
|
||||
<EditorI18nProvider>
|
||||
<EditorApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from '../../../shared/types';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
|
||||
import { applyPreviewRotationToSceneMarkers } from '../../../shared/types/scenePreviewRotation';
|
||||
|
||||
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
|
||||
|
||||
@@ -50,11 +51,7 @@ type Actions = {
|
||||
importCampaignAudio: () => Promise<void>;
|
||||
importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
|
||||
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
|
||||
upsertMaterial: (input: {
|
||||
materialId?: MaterialId;
|
||||
name: string;
|
||||
filePath?: string;
|
||||
}) => Promise<void>;
|
||||
upsertMaterial: (input: { materialId?: MaterialId; name: string; filePath?: string }) => Promise<void>;
|
||||
deleteMaterial: (materialId: MaterialId) => Promise<void>;
|
||||
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
|
||||
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
|
||||
@@ -104,7 +101,10 @@ type Actions = {
|
||||
mode: 'folder' | 'archive',
|
||||
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
|
||||
importFoundryProject: (sourcePath: string) => Promise<void>;
|
||||
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
|
||||
peekImportZip: (
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
) => Promise<
|
||||
| { canceled: true }
|
||||
| {
|
||||
canceled: false;
|
||||
@@ -148,11 +148,16 @@ type Actions = {
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
importProjectFromPath: (filePath: string) => Promise<void>;
|
||||
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
|
||||
getProjectStorylines: (
|
||||
projectId: ProjectId,
|
||||
labels: StorylineLabels,
|
||||
) => Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }>;
|
||||
exportProject: (
|
||||
projectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
labels: StorylineLabels,
|
||||
playlistIds?: string[],
|
||||
) => Promise<void>;
|
||||
deleteProject: (projectId: ProjectId) => Promise<void>;
|
||||
};
|
||||
@@ -351,6 +356,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||
@@ -505,11 +511,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const upsertMaterial = async (input: {
|
||||
materialId?: MaterialId;
|
||||
name: string;
|
||||
filePath?: string;
|
||||
}) => {
|
||||
const upsertMaterial = async (input: { materialId?: MaterialId; name: string; filePath?: string }) => {
|
||||
const res = await api.invoke(ipcChannels.project.upsertMaterial, input);
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
@@ -527,10 +529,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const setMaterialRotation = async (
|
||||
materialId: MaterialId,
|
||||
rotationDeg: 0 | 90 | 180 | 270,
|
||||
) => {
|
||||
const setMaterialRotation = async (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => {
|
||||
const res = await api.invoke(ipcChannels.project.setMaterialRotation, {
|
||||
materialId,
|
||||
rotationDeg,
|
||||
@@ -566,6 +565,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
traps?: import('../../../shared/types').SceneTrap[];
|
||||
tokens?: import('../../../shared/types').SceneToken[];
|
||||
npcTokens?: import('../../../shared/types').SceneNpcToken[];
|
||||
grid?: import('../../../shared/types').SceneGrid;
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
layout?: { x: number; y: number };
|
||||
@@ -594,11 +596,31 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
||||
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
|
||||
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null),
|
||||
...(patch.grid !== undefined ? { grid: patch.grid } : null),
|
||||
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
||||
};
|
||||
if (patch.previewRotationDeg !== undefined) {
|
||||
const remapped = applyPreviewRotationToSceneMarkers(
|
||||
{
|
||||
previewRotationDeg: scene.previewRotationDeg ?? 0,
|
||||
tokens: scene.tokens ?? [],
|
||||
npcTokens: scene.npcTokens ?? [],
|
||||
traps: scene.traps ?? [],
|
||||
},
|
||||
patch.previewRotationDeg,
|
||||
{
|
||||
tokensProvided: patch.tokens !== undefined,
|
||||
npcTokensProvided: patch.npcTokens !== undefined,
|
||||
trapsProvided: patch.traps !== undefined,
|
||||
},
|
||||
);
|
||||
if (patch.tokens === undefined) next.tokens = remapped.tokens;
|
||||
if (patch.npcTokens === undefined) next.npcTokens = remapped.npcTokens;
|
||||
if (patch.traps === undefined) next.traps = remapped.traps;
|
||||
}
|
||||
const scenes = { ...p.scenes, [sceneId]: next };
|
||||
const project: Project = { ...p, scenes };
|
||||
return { ...s, project };
|
||||
@@ -869,19 +891,26 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
|
||||
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
|
||||
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
|
||||
return res.storylines;
|
||||
return {
|
||||
storylines: Array.isArray(res?.storylines) ? res.storylines : [],
|
||||
npcs: Array.isArray(res?.npcs) ? res.npcs : [],
|
||||
};
|
||||
};
|
||||
|
||||
const exportProject = async (
|
||||
projectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
labels: StorylineLabels,
|
||||
playlistIds: string[] = [],
|
||||
) => {
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.exportZip, {
|
||||
projectId,
|
||||
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>,
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user