Compare commits
24 Commits
0a9168b320
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c0c72892d | |||
| 877fbaa8e7 | |||
| f6c301a12b | |||
| 060bbd471a | |||
| d228b9aab7 | |||
| 143d141aa6 | |||
| a29c72b307 | |||
| 712086ddca | |||
| 3d0474fe97 | |||
| 48a9c933af | |||
| 34dd39791b | |||
| fdccc0deb5 | |||
| 586166cacd | |||
| dbe8ab1fda | |||
| 9ac01d217f | |||
| 86d6d157f0 | |||
| db1168a46c | |||
| a4d7ab77e7 | |||
| 278eb78637 | |||
| cfb6e62331 | |||
| 746b862cb9 | |||
| 8b28a9b11a | |||
| 153be98780 | |||
| ea718a26df |
@@ -0,0 +1,101 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { DiceRollSessionStore } from './diceRollSessionStore';
|
||||||
|
import {
|
||||||
|
D100_FACE_VALUES,
|
||||||
|
formatPredeterminedDiceNotation,
|
||||||
|
snapD100Value,
|
||||||
|
} from '../../shared/types/diceRoll';
|
||||||
|
|
||||||
|
void test('diceRoll: roll produces values in range and notation', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'roll', count: 3, sides: 6 });
|
||||||
|
const st = store.getState();
|
||||||
|
assert.equal(st.active, true);
|
||||||
|
assert.equal(st.visibleToPlayers, true);
|
||||||
|
assert.equal(st.notation, '3d6');
|
||||||
|
assert.equal(st.values.length, 3);
|
||||||
|
assert.ok(st.values.every((v) => v >= 1 && v <= 6));
|
||||||
|
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('diceRoll: roll without visibleToPlayers keeps master result only', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||||
|
store.dispatch({ kind: 'roll', count: 2, sides: 8 });
|
||||||
|
const st = store.getState();
|
||||||
|
assert.equal(st.visibleToPlayers, false);
|
||||||
|
assert.equal(st.active, false);
|
||||||
|
assert.equal(st.notation, '2d8');
|
||||||
|
assert.equal(st.values.length, 2);
|
||||||
|
assert.ok(st.values.every((v) => v >= 1 && v <= 8));
|
||||||
|
assert.equal(st.startedAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('diceRoll: unchecking visibleToPlayers hides overlay', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
|
||||||
|
assert.equal(store.getState().active, true);
|
||||||
|
const values = [...store.getState().values];
|
||||||
|
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||||
|
assert.equal(store.getState().active, false);
|
||||||
|
assert.equal(store.getState().visibleToPlayers, false);
|
||||||
|
assert.deepEqual(store.getState().values, values);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('diceRoll: d100 values are tens faces only', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'roll', count: 5, sides: 100 });
|
||||||
|
const st = store.getState();
|
||||||
|
assert.equal(st.notation, '5d100');
|
||||||
|
assert.equal(st.values.length, 5);
|
||||||
|
const allowed = new Set<number>(D100_FACE_VALUES);
|
||||||
|
assert.ok(st.values.every((v) => allowed.has(v)));
|
||||||
|
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('diceRoll: clear resets but keeps visibleToPlayers', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||||
|
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
|
||||||
|
store.dispatch({ kind: 'clear' });
|
||||||
|
assert.equal(store.getState().active, false);
|
||||||
|
assert.deepEqual(store.getState().values, []);
|
||||||
|
assert.equal(store.getState().visibleToPlayers, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('diceRoll: hideOverlay keeps values for master UI', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'roll', count: 2, sides: 6 });
|
||||||
|
const values = [...store.getState().values];
|
||||||
|
const total = store.getState().total;
|
||||||
|
store.dispatch({ kind: 'hideOverlay' });
|
||||||
|
assert.equal(store.getState().active, false);
|
||||||
|
assert.deepEqual(store.getState().values, values);
|
||||||
|
assert.equal(store.getState().total, total);
|
||||||
|
assert.equal(store.getState().notation, '2d6');
|
||||||
|
assert.equal(store.getState().visibleToPlayers, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('diceRoll: roll with zero count is no-op', () => {
|
||||||
|
const store = new DiceRollSessionStore();
|
||||||
|
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||||
|
const before = store.getState().revision;
|
||||||
|
store.dispatch({ kind: 'roll', count: 0, sides: 6 });
|
||||||
|
assert.equal(store.getState().revision, before);
|
||||||
|
assert.deepEqual(store.getState().values, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('formatPredeterminedDiceNotation', () => {
|
||||||
|
assert.equal(formatPredeterminedDiceNotation(2, 20, [15, 7]), '2d20@15,7');
|
||||||
|
assert.equal(formatPredeterminedDiceNotation(2, 100, [20, 100]), '2d100@20,100');
|
||||||
|
assert.equal(formatPredeterminedDiceNotation(1, 100, [27]), '1d100@30');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('snapD100Value', () => {
|
||||||
|
assert.equal(snapD100Value(20), 20);
|
||||||
|
assert.equal(snapD100Value(21), 30);
|
||||||
|
assert.equal(snapD100Value(100), 100);
|
||||||
|
assert.equal(snapD100Value(1), 10);
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
clampDiceCount,
|
||||||
|
createInitialDiceRollState,
|
||||||
|
formatDiceNotation,
|
||||||
|
isDiceSides,
|
||||||
|
rollDiceValues,
|
||||||
|
type DiceRollEvent,
|
||||||
|
type DiceRollState,
|
||||||
|
} from '../../shared/types/diceRoll';
|
||||||
|
|
||||||
|
export class DiceRollSessionStore {
|
||||||
|
private state: DiceRollState = createInitialDiceRollState();
|
||||||
|
|
||||||
|
getState(): DiceRollState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): DiceRollState {
|
||||||
|
this.state = createInitialDiceRollState();
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(event: DiceRollEvent): DiceRollState {
|
||||||
|
switch (event.kind) {
|
||||||
|
case 'clear': {
|
||||||
|
if (!this.state.active && this.state.values.length === 0) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...createInitialDiceRollState(),
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
visibleToPlayers: this.state.visibleToPlayers,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'setVisibleToPlayers': {
|
||||||
|
if (this.state.visibleToPlayers === event.visible) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
visibleToPlayers: event.visible,
|
||||||
|
...(event.visible === false && this.state.active
|
||||||
|
? { active: false, startedAt: null }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'hideOverlay': {
|
||||||
|
if (!this.state.active) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
active: false,
|
||||||
|
startedAt: null,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'roll': {
|
||||||
|
if (!isDiceSides(event.sides)) return this.state;
|
||||||
|
const count = clampDiceCount(event.count);
|
||||||
|
if (count < 1) return this.state;
|
||||||
|
const values = rollDiceValues(count, event.sides);
|
||||||
|
const total = values.reduce((a, b) => a + b, 0);
|
||||||
|
const showToPlayers = this.state.visibleToPlayers;
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
active: showToPlayers,
|
||||||
|
visibleToPlayers: showToPlayers,
|
||||||
|
notation: formatDiceNotation(count, event.sides),
|
||||||
|
values,
|
||||||
|
total,
|
||||||
|
startedAt: showToPlayers ? Date.now() : null,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = event;
|
||||||
|
return _exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
|
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
|
||||||
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
|
import {
|
||||||
|
isPersistentFieldEffect,
|
||||||
|
type EffectInstance,
|
||||||
|
type EffectsEvent,
|
||||||
|
type EffectsState,
|
||||||
|
type EffectToolState,
|
||||||
|
} from '../../shared/types';
|
||||||
|
|
||||||
function nowMs(): number {
|
function nowMs(): number {
|
||||||
return Date.now();
|
return Date.now();
|
||||||
@@ -23,6 +29,9 @@ export class EffectsStore {
|
|||||||
tool: defaultTool(),
|
tool: defaultTool(),
|
||||||
instances: [],
|
instances: [],
|
||||||
};
|
};
|
||||||
|
/** Постоянные эффекты по карточке (graphNodeId или sceneId) на время показа. */
|
||||||
|
private cache = new Map<string, EffectInstance[]>();
|
||||||
|
private currentKey: string | null = null;
|
||||||
|
|
||||||
getState(): EffectsState {
|
getState(): EffectsState {
|
||||||
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
|
// Всегда обновляем serverNowMs при чтении — это наш "таймкод" для рендереров.
|
||||||
@@ -36,13 +45,70 @@ export class EffectsStore {
|
|||||||
serverNowMs: nowMs(),
|
serverNowMs: nowMs(),
|
||||||
instances: [],
|
instances: [],
|
||||||
};
|
};
|
||||||
|
this.commitCurrent();
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Новый показ: и текущая карточка, и кэш остальных. */
|
||||||
|
resetSession(): EffectsState {
|
||||||
|
this.cache.clear();
|
||||||
|
this.currentKey = null;
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
serverNowMs: nowMs(),
|
||||||
|
tool: this.state.tool,
|
||||||
|
instances: [],
|
||||||
|
};
|
||||||
|
return this.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Смена карточки: туман/огонь/дождь/вода/лёд/пятно остаются на ушедшей
|
||||||
|
* и поднимаются на новой. Разовые эффекты действий сбрасываются.
|
||||||
|
*/
|
||||||
|
switchScene(cacheKey: string | null): EffectsState {
|
||||||
|
this.commitCurrent();
|
||||||
|
this.currentKey = cacheKey;
|
||||||
|
const instances = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
serverNowMs: nowMs(),
|
||||||
|
instances,
|
||||||
|
};
|
||||||
|
return this.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
exportByCard(): Record<string, EffectInstance[]> {
|
||||||
|
this.commitCurrent();
|
||||||
|
const out: Record<string, EffectInstance[]> = {};
|
||||||
|
for (const [key, instances] of this.cache) {
|
||||||
|
const kept = instances.filter((i) => isPersistentFieldEffect(i.type));
|
||||||
|
if (kept.length > 0) out[key] = kept.map((i) => ({ ...i }));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
importByCard(byCard: Record<string, EffectInstance[]>): void {
|
||||||
|
this.cache.clear();
|
||||||
|
this.currentKey = null;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
serverNowMs: nowMs(),
|
||||||
|
instances: [],
|
||||||
|
};
|
||||||
|
for (const [key, instances] of Object.entries(byCard)) {
|
||||||
|
const kept = instances.filter((i) => isPersistentFieldEffect(i.type));
|
||||||
|
if (kept.length > 0) this.cache.set(key, kept.map((i) => ({ ...i })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(event: EffectsEvent): EffectsState {
|
dispatch(event: EffectsEvent): EffectsState {
|
||||||
const s = this.state;
|
const s = this.state;
|
||||||
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
|
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
|
||||||
this.state = next;
|
this.state = next;
|
||||||
|
this.commitCurrent();
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +144,18 @@ export class EffectsStore {
|
|||||||
serverNowMs: now,
|
serverNowMs: now,
|
||||||
instances: kept,
|
instances: kept,
|
||||||
};
|
};
|
||||||
|
this.commitCurrent();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private commitCurrent(): void {
|
||||||
|
if (this.currentKey === null) return;
|
||||||
|
this.cache.set(
|
||||||
|
this.currentKey,
|
||||||
|
this.state.instances.filter((i) => isPersistentFieldEffect(i.type)).map((i) => ({ ...i })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
makeId(prefix: string): string {
|
makeId(prefix: string): string {
|
||||||
return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`;
|
return `${prefix}_${crypto.randomBytes(6).toString('hex')}_${String(nowMs())}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { EffectsStore } from './effectsStore';
|
||||||
|
import { GameSavesStore } from '../gameSave/gameSavesStore';
|
||||||
|
import { canonicalGameSave, emptyGameSaveBody, freezeTokenPathPlayback } from '../../shared/gameSave/buildSnapshot';
|
||||||
|
import type { EffectInstance } from '../../shared/types';
|
||||||
|
import type { TokenPath } from '../../shared/types/tokenPath';
|
||||||
|
import type { TokenPathPlaybackEntry } from '../../shared/types/tokenPathSession';
|
||||||
|
|
||||||
|
function fog(): EffectInstance {
|
||||||
|
return {
|
||||||
|
id: 'fog1',
|
||||||
|
type: 'fog',
|
||||||
|
seed: 1,
|
||||||
|
createdAtMs: 1,
|
||||||
|
points: [{ x: 0.2, y: 0.2, tMs: 1 }],
|
||||||
|
radiusN: 0.1,
|
||||||
|
opacity: 0.4,
|
||||||
|
lifetimeMs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bolt(): EffectInstance {
|
||||||
|
return {
|
||||||
|
id: 'lt1',
|
||||||
|
type: 'lightning',
|
||||||
|
seed: 2,
|
||||||
|
createdAtMs: 1,
|
||||||
|
start: { x: 0, y: 0 },
|
||||||
|
end: { x: 1, y: 1 },
|
||||||
|
widthN: 0.02,
|
||||||
|
intensity: 1,
|
||||||
|
lifetimeMs: 800,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('effects stay on the card and action effects do not', () => {
|
||||||
|
const store = new EffectsStore();
|
||||||
|
store.switchScene('card-a');
|
||||||
|
store.dispatch({ kind: 'instance.add', instance: fog() });
|
||||||
|
store.dispatch({ kind: 'instance.add', instance: bolt() });
|
||||||
|
store.switchScene('card-b');
|
||||||
|
assert.equal(store.getState().instances.length, 0);
|
||||||
|
store.switchScene('card-a');
|
||||||
|
assert.deepEqual(
|
||||||
|
store.getState().instances.map((i) => i.type),
|
||||||
|
['fog'],
|
||||||
|
);
|
||||||
|
store.dispatch({ kind: 'instances.clear' });
|
||||||
|
store.switchScene('card-b');
|
||||||
|
store.switchScene('card-a');
|
||||||
|
assert.equal(store.getState().instances.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('freezeTokenPathPlayback parks a moving token', () => {
|
||||||
|
const pathLine: TokenPath = {
|
||||||
|
points: [
|
||||||
|
{ nx: 0, ny: 0.5 },
|
||||||
|
{ nx: 1, ny: 0.5 },
|
||||||
|
],
|
||||||
|
closed: false,
|
||||||
|
loopMode: 'once',
|
||||||
|
durationSec: 2,
|
||||||
|
startMode: 'onEnter',
|
||||||
|
delaySec: 0,
|
||||||
|
facingMode: 'tangentSmooth',
|
||||||
|
fixedRotationDeg: 0,
|
||||||
|
};
|
||||||
|
const entry: TokenPathPlaybackEntry = {
|
||||||
|
kind: 'token',
|
||||||
|
placementId: 't1',
|
||||||
|
phase: 'moving',
|
||||||
|
segmentStartedAtMs: 0,
|
||||||
|
baseDist: 0,
|
||||||
|
direction: 1,
|
||||||
|
rejoinDist: null,
|
||||||
|
durationSec: 2,
|
||||||
|
pathLength: 1,
|
||||||
|
};
|
||||||
|
const frozen = freezeTokenPathPlayback({ 'token:t1': entry }, () => pathLine, 1000);
|
||||||
|
const next = frozen['token:t1'];
|
||||||
|
assert.ok(next);
|
||||||
|
assert.equal(next.phase, 'stopped');
|
||||||
|
assert.ok(next.baseDist > 0.4 && next.baseDist < 0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('game save file keeps id on overwrite and lists one campaign', async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-saves-'));
|
||||||
|
const store = new GameSavesStore(root);
|
||||||
|
const body = emptyGameSaveBody('p_one');
|
||||||
|
const first = await store.write('p_one', body, { id: null, name: 'Вечер' });
|
||||||
|
const again = await store.write('p_one', body, { id: first.id, name: 'Вечер 2' });
|
||||||
|
assert.equal(again.id, first.id);
|
||||||
|
assert.equal(again.name, 'Вечер 2');
|
||||||
|
const list = await store.list('p_one');
|
||||||
|
assert.equal(list.length, 1);
|
||||||
|
assert.equal(list[0]?.name, 'Вечер 2');
|
||||||
|
const other = emptyGameSaveBody('p_two');
|
||||||
|
await store.write('p_two', other, { id: null, name: 'Другая' });
|
||||||
|
assert.equal((await store.list('p_one')).length, 1);
|
||||||
|
const loaded = await store.read('p_one', first.id);
|
||||||
|
const { id: _id, name: _name, savedAt: _savedAt, ...rest } = loaded;
|
||||||
|
assert.equal(canonicalGameSave(rest), canonicalGameSave(body));
|
||||||
|
});
|
||||||
@@ -67,6 +67,66 @@ void test('SceneDarknessStore: draft синхронизируется и сбр
|
|||||||
assert.equal(store.getState().strokes.length, 1);
|
assert.equal(store.getState().strokes.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('SceneDarknessStore: cache miss берёт seedStrokes из сцены', () => {
|
||||||
|
const store = new SceneDarknessStore();
|
||||||
|
const seed = [
|
||||||
|
{
|
||||||
|
id: 'base1',
|
||||||
|
seed: 7,
|
||||||
|
createdAtMs: 50,
|
||||||
|
points: [{ x: 0.3, y: 0.4, tMs: 50 }],
|
||||||
|
radiusN: 0.06,
|
||||||
|
mode: 'reveal' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
store.switchScene('card_a', true, seed);
|
||||||
|
assert.equal(store.getState().strokes.length, 1);
|
||||||
|
assert.equal(store.getState().strokes[0]?.id, 'base1');
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'stroke.add',
|
||||||
|
stroke: {
|
||||||
|
id: 'session1',
|
||||||
|
seed: 2,
|
||||||
|
createdAtMs: 100,
|
||||||
|
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
|
||||||
|
radiusN: 0.08,
|
||||||
|
mode: 'cover',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.switchScene('card_b', true, []);
|
||||||
|
store.switchScene('card_a', true, seed);
|
||||||
|
assert.equal(store.getState().strokes.length, 2);
|
||||||
|
assert.equal(store.getState().strokes[1]?.id, 'session1');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('SceneDarknessStore: resetSession снова берёт seed', () => {
|
||||||
|
const store = new SceneDarknessStore();
|
||||||
|
const seed = [
|
||||||
|
{
|
||||||
|
id: 'base1',
|
||||||
|
seed: 1,
|
||||||
|
createdAtMs: 10,
|
||||||
|
points: [{ x: 0.1, y: 0.1, tMs: 10 }],
|
||||||
|
radiusN: 0.05,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
store.switchScene('card_a', true, seed);
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'stroke.add',
|
||||||
|
stroke: {
|
||||||
|
id: 's1',
|
||||||
|
seed: 2,
|
||||||
|
createdAtMs: 20,
|
||||||
|
points: [{ x: 0.2, y: 0.2, tMs: 20 }],
|
||||||
|
radiusN: 0.05,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.resetSession();
|
||||||
|
store.switchScene('card_a', true, seed);
|
||||||
|
assert.equal(store.getState().strokes.length, 1);
|
||||||
|
assert.equal(store.getState().strokes[0]?.id, 'base1');
|
||||||
|
});
|
||||||
|
|
||||||
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
|
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
|
||||||
const store = new SceneDarknessStore();
|
const store = new SceneDarknessStore();
|
||||||
store.switchScene('scene_a', true);
|
store.switchScene('scene_a', true);
|
||||||
|
|||||||
@@ -29,14 +29,26 @@ export class SceneDarknessStore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены.
|
* Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены.
|
||||||
|
* Если ключа ещё не было в сессии — берём seed (база из файла партии).
|
||||||
*/
|
*/
|
||||||
switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState {
|
switchScene(
|
||||||
|
cacheKey: string | null,
|
||||||
|
enabled: boolean,
|
||||||
|
seedStrokes: readonly SceneDarknessRevealStroke[] = [],
|
||||||
|
): SceneDarknessState {
|
||||||
if (this.currentKey !== null) {
|
if (this.currentKey !== null) {
|
||||||
this.cache.set(this.currentKey, [...this.state.strokes]);
|
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
|
||||||
}
|
}
|
||||||
this.currentKey = cacheKey;
|
this.currentKey = cacheKey;
|
||||||
const strokes =
|
let strokes: SceneDarknessRevealStroke[] = [];
|
||||||
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : [];
|
if (cacheKey && enabled) {
|
||||||
|
if (this.cache.has(cacheKey)) {
|
||||||
|
strokes = (this.cache.get(cacheKey) ?? []).map(cloneStroke);
|
||||||
|
} else {
|
||||||
|
strokes = seedStrokes.map(cloneStroke);
|
||||||
|
this.cache.set(cacheKey, strokes.map(cloneStroke));
|
||||||
|
}
|
||||||
|
}
|
||||||
this.state = {
|
this.state = {
|
||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -77,4 +89,32 @@ export class SceneDarknessStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Кэш по карточкам, включая текущую. Пустые карточки не входят. */
|
||||||
|
exportByCard(): Record<string, SceneDarknessRevealStroke[]> {
|
||||||
|
if (this.currentKey !== null) {
|
||||||
|
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
|
||||||
|
}
|
||||||
|
const out: Record<string, SceneDarknessRevealStroke[]> = {};
|
||||||
|
for (const [key, strokes] of this.cache) {
|
||||||
|
if (strokes.length === 0) continue;
|
||||||
|
out[key] = strokes.map(cloneStroke);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кладёт кэш, не переключая карточку. Дальше вызывают switchScene. */
|
||||||
|
importByCard(byCard: Record<string, SceneDarknessRevealStroke[]>): void {
|
||||||
|
this.cache.clear();
|
||||||
|
this.currentKey = null;
|
||||||
|
this.state = emptyState();
|
||||||
|
for (const [key, strokes] of Object.entries(byCard)) {
|
||||||
|
if (!Array.isArray(strokes) || strokes.length === 0) continue;
|
||||||
|
this.cache.set(key, strokes.map(cloneStroke));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneStroke(stroke: SceneDarknessRevealStroke): SceneDarknessRevealStroke {
|
||||||
|
return { ...stroke, points: stroke.points.map((p) => ({ ...p })) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,134 @@
|
|||||||
|
import { applySceneMarkerEraserStroke } from '../../shared/sceneMarkerEraser';
|
||||||
|
import type { SceneMarkerEvent, SceneMarkerState, SceneMarkerStroke } from '../../shared/types';
|
||||||
|
|
||||||
|
function emptyState(): SceneMarkerState {
|
||||||
|
return {
|
||||||
|
revision: 1,
|
||||||
|
cacheKey: null,
|
||||||
|
strokes: [],
|
||||||
|
draft: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStrokeId(prefix: string): string {
|
||||||
|
return `${prefix}_${String(Date.now())}_${String(Math.floor(Math.random() * 1_000_000_000))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SceneMarkerStore {
|
||||||
|
private state: SceneMarkerState = emptyState();
|
||||||
|
/** Кэш штрихов маркера по ключу (graphNodeId или sceneId) на время сессии показа. */
|
||||||
|
private cache = new Map<string, SceneMarkerStroke[]>();
|
||||||
|
private currentKey: string | null = null;
|
||||||
|
|
||||||
|
getState(): SceneMarkerState {
|
||||||
|
return { ...this.state, strokes: [...this.state.strokes] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
|
||||||
|
resetSession(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
this.currentKey = null;
|
||||||
|
this.state = emptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переключение сцены: сохраняем штрихи в кэш и загружаем новую сцену. */
|
||||||
|
switchScene(cacheKey: string | null): SceneMarkerState {
|
||||||
|
if (this.currentKey !== null) {
|
||||||
|
this.cache.set(this.currentKey, [...this.state.strokes]);
|
||||||
|
}
|
||||||
|
this.currentKey = cacheKey;
|
||||||
|
const strokes = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
cacheKey,
|
||||||
|
strokes,
|
||||||
|
draft: null,
|
||||||
|
};
|
||||||
|
return this.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(event: SceneMarkerEvent): SceneMarkerState {
|
||||||
|
switch (event.kind) {
|
||||||
|
case 'draft.set':
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
draft: event.draft,
|
||||||
|
};
|
||||||
|
return this.getState();
|
||||||
|
case 'stroke.add': {
|
||||||
|
const strokes = [...this.state.strokes, event.stroke];
|
||||||
|
if (this.currentKey) {
|
||||||
|
this.cache.set(this.currentKey, strokes);
|
||||||
|
}
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
strokes,
|
||||||
|
draft: null,
|
||||||
|
};
|
||||||
|
return this.getState();
|
||||||
|
}
|
||||||
|
case 'strokes.erase': {
|
||||||
|
const strokes = applySceneMarkerEraserStroke(
|
||||||
|
this.state.strokes,
|
||||||
|
event.points,
|
||||||
|
event.radiusN,
|
||||||
|
(prefix) => makeStrokeId(prefix),
|
||||||
|
);
|
||||||
|
if (this.currentKey) {
|
||||||
|
this.cache.set(this.currentKey, strokes);
|
||||||
|
}
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
strokes,
|
||||||
|
draft: null,
|
||||||
|
};
|
||||||
|
return this.getState();
|
||||||
|
}
|
||||||
|
case 'strokes.clear':
|
||||||
|
if (this.currentKey) {
|
||||||
|
this.cache.set(this.currentKey, []);
|
||||||
|
}
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
strokes: [],
|
||||||
|
draft: null,
|
||||||
|
};
|
||||||
|
return this.getState();
|
||||||
|
default: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const _x: never = event;
|
||||||
|
return this.getState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportByCard(): Record<string, SceneMarkerStroke[]> {
|
||||||
|
if (this.currentKey !== null) {
|
||||||
|
this.cache.set(this.currentKey, this.state.strokes.map(cloneStroke));
|
||||||
|
}
|
||||||
|
const out: Record<string, SceneMarkerStroke[]> = {};
|
||||||
|
for (const [key, strokes] of this.cache) {
|
||||||
|
if (strokes.length === 0) continue;
|
||||||
|
out[key] = strokes.map(cloneStroke);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
importByCard(byCard: Record<string, SceneMarkerStroke[]>): void {
|
||||||
|
this.cache.clear();
|
||||||
|
this.currentKey = null;
|
||||||
|
this.state = emptyState();
|
||||||
|
for (const [key, strokes] of Object.entries(byCard)) {
|
||||||
|
if (!Array.isArray(strokes) || strokes.length === 0) continue;
|
||||||
|
this.cache.set(key, strokes.map(cloneStroke));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneStroke(stroke: SceneMarkerStroke): SceneMarkerStroke {
|
||||||
|
return { ...stroke, points: stroke.points.map((p) => ({ ...p })) };
|
||||||
|
}
|
||||||
@@ -279,10 +279,17 @@ function collectSceneAudioPaths(
|
|||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FoundryPendingAppPlaylist = {
|
||||||
|
name: string;
|
||||||
|
tracks: { title: string; absPath: string; mime: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
export type FoundryBuiltProject = {
|
export type FoundryBuiltProject = {
|
||||||
project: Project;
|
project: Project;
|
||||||
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
|
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
|
||||||
previewSources: { sceneId: SceneId; absPath: string }[];
|
previewSources: { sceneId: SceneId; absPath: string }[];
|
||||||
|
/** Неиспользуемые на сценах Foundry-плейлисты → локальная библиотека. */
|
||||||
|
pendingAppPlaylists: FoundryPendingAppPlaylist[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function buildProjectFromFoundryDocuments(
|
export async function buildProjectFromFoundryDocuments(
|
||||||
@@ -372,7 +379,7 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
const assetId = await resolveAndImport(ctx, manifest, audioPath);
|
const assetId = await resolveAndImport(ctx, manifest, audioPath);
|
||||||
if (!assetId) continue;
|
if (!assetId) continue;
|
||||||
if (ctx.assets[assetId]?.type !== 'audio') continue;
|
if (ctx.assets[assetId]?.type !== 'audio') continue;
|
||||||
audioRefs.push({ assetId, autoplay: true, loop: true });
|
audioRefs.push({ assetId, autoplay: true, loop: true, volume: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
scenes[sceneId] = {
|
scenes[sceneId] = {
|
||||||
@@ -385,6 +392,7 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
previewVideoAutostart: previewAssetType === 'video',
|
previewVideoAutostart: previewAssetType === 'video',
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
|
darknessStrokes: [],
|
||||||
traps: [],
|
traps: [],
|
||||||
tokens: [],
|
tokens: [],
|
||||||
npcTokens: [],
|
npcTokens: [],
|
||||||
@@ -406,15 +414,28 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
|
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
|
||||||
);
|
);
|
||||||
const campaignAudios: Project['campaignAudios'] = [];
|
const campaignAudios: Project['campaignAudios'] = [];
|
||||||
|
const pendingAppPlaylists: FoundryPendingAppPlaylist[] = [];
|
||||||
for (const pl of docs.playlists) {
|
for (const pl of docs.playlists) {
|
||||||
if (usedPlaylistIds.has(pl._id)) continue;
|
if (usedPlaylistIds.has(pl._id)) continue;
|
||||||
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
||||||
|
const tracks: FoundryPendingAppPlaylist['tracks'] = [];
|
||||||
for (const s of sounds) {
|
for (const s of sounds) {
|
||||||
if (typeof s.path !== 'string' || !s.path.trim()) continue;
|
if (typeof s.path !== 'string' || !s.path.trim()) continue;
|
||||||
const assetId = await resolveAndImport(ctx, manifest, s.path);
|
const abs = await resolveFoundryAssetPath(manifest, s.path);
|
||||||
if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue;
|
if (!abs) continue;
|
||||||
campaignAudios.push({ assetId, autoplay: false, loop: true });
|
const kind = classifyMediaPath(abs);
|
||||||
|
if (!kind || kind.type !== 'audio') continue;
|
||||||
|
const title =
|
||||||
|
(typeof s.name === 'string' && s.name.trim()) ||
|
||||||
|
path.basename(s.path).replace(/\.[^.]+$/u, '') ||
|
||||||
|
'Track';
|
||||||
|
tracks.push({ title, absPath: abs, mime: kind.mime });
|
||||||
}
|
}
|
||||||
|
if (tracks.length === 0) continue;
|
||||||
|
pendingAppPlaylists.push({
|
||||||
|
name: (typeof pl.name === 'string' && pl.name.trim()) || 'Playlist',
|
||||||
|
tracks,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders);
|
const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders);
|
||||||
@@ -529,6 +550,7 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
assets: ctx.assets,
|
assets: ctx.assets,
|
||||||
campaignAudios,
|
campaignAudios,
|
||||||
materials: [],
|
materials: [],
|
||||||
|
gridTemplates: [],
|
||||||
npcs,
|
npcs,
|
||||||
npcGroups,
|
npcGroups,
|
||||||
npcRelations: [],
|
npcRelations: [],
|
||||||
@@ -538,7 +560,7 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
sceneGraphEdges,
|
sceneGraphEdges,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { project, previewSources };
|
return { project, previewSources, pendingAppPlaylists };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Actor folders Foundry → дерево групп НПС. */
|
/** Actor folders Foundry → дерево групп НПС. */
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { defaultGameSaveName } from '../../shared/gameSave/buildSnapshot';
|
||||||
|
import { GAME_SAVE_SCHEMA_VERSION, type GameSaveBody, type GameSaveFile, type GameSaveListItem } from '../../shared/types/gameSave';
|
||||||
|
|
||||||
|
function safeSegment(raw: string): string {
|
||||||
|
const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
|
if (!cleaned || cleaned === '.' || cleaned === '..') throw new Error('GAME_SAVE_BAD_ID');
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameSavesStore {
|
||||||
|
private readonly rootDir: string;
|
||||||
|
|
||||||
|
constructor(userData: string) {
|
||||||
|
this.rootDir = path.join(userData, 'game-saves');
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(projectId: string): Promise<GameSaveListItem[]> {
|
||||||
|
const dir = this.projectDir(projectId);
|
||||||
|
let names: string[] = [];
|
||||||
|
try {
|
||||||
|
names = await fs.readdir(dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const items: GameSaveListItem[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
if (!name.endsWith('.json')) continue;
|
||||||
|
try {
|
||||||
|
const file = await this.readFile(path.join(dir, name));
|
||||||
|
if (file.projectId !== projectId) continue;
|
||||||
|
items.push({ id: file.id, name: file.name, savedAt: file.savedAt });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.sort((a, b) => (a.savedAt < b.savedAt ? 1 : a.savedAt > b.savedAt ? -1 : 0));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(projectId: string, id: string): Promise<GameSaveFile> {
|
||||||
|
const filePath = this.filePath(projectId, id);
|
||||||
|
try {
|
||||||
|
const file = await this.readFile(filePath);
|
||||||
|
if (file.projectId !== projectId || file.id !== id) throw new Error('GAME_SAVE_NOT_FOUND');
|
||||||
|
return file;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.startsWith('GAME_SAVE_')) throw err;
|
||||||
|
throw new Error('GAME_SAVE_NOT_FOUND');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(projectId: string, body: GameSaveBody, opts: { id: string | null; name: string }): Promise<GameSaveListItem> {
|
||||||
|
if (body.projectId !== projectId) throw new Error('GAME_SAVE_PROJECT_MISMATCH');
|
||||||
|
const id = opts.id ? safeSegment(opts.id) : `gs_${crypto.randomBytes(8).toString('hex')}`;
|
||||||
|
const name = opts.name.trim() || defaultGameSaveName();
|
||||||
|
const savedAt = new Date().toISOString();
|
||||||
|
const file: GameSaveFile = { ...body, id, name, savedAt, schemaVersion: GAME_SAVE_SCHEMA_VERSION };
|
||||||
|
const filePath = this.filePath(projectId, id);
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(file), 'utf8');
|
||||||
|
return { id, name, savedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
private projectDir(projectId: string): string {
|
||||||
|
return path.join(this.rootDir, safeSegment(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private filePath(projectId: string, id: string): string {
|
||||||
|
return path.join(this.projectDir(projectId), `${safeSegment(id)}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readFile(filePath: string): Promise<GameSaveFile> {
|
||||||
|
const raw = await fs.readFile(filePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as GameSaveFile;
|
||||||
|
if (!parsed || parsed.schemaVersion !== GAME_SAVE_SCHEMA_VERSION || typeof parsed.id !== 'string') {
|
||||||
|
throw new Error('GAME_SAVE_NOT_FOUND');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import { GameSystemsStore } from './gameSystemsStore';
|
||||||
|
import type { SystemDefinition } from '../../shared/characterSheet/types';
|
||||||
|
import { FORMAT_VERSION, SYSTEM_FORMAT } from '../../shared/characterSheet/types';
|
||||||
|
|
||||||
|
function sampleSystem(): SystemDefinition {
|
||||||
|
return {
|
||||||
|
format: SYSTEM_FORMAT,
|
||||||
|
formatVersion: FORMAT_VERSION,
|
||||||
|
id: 'shadowdark',
|
||||||
|
name: 'Shadowdark',
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: 1,
|
||||||
|
bricks: { attributes: [{ key: 'str', label: 'STR', scale: 'numeric', default: 10 }] },
|
||||||
|
creationRules: [],
|
||||||
|
sheetLayout: { pageSize: 'fluid', grid: { size: 8 }, pages: [{ id: 'main', label: 'Main', widgets: [] }] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GameSystemsStore import list delete', async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gs-'));
|
||||||
|
const store = new GameSystemsStore(dir);
|
||||||
|
await store.importSystem(sampleSystem());
|
||||||
|
const list = await store.list();
|
||||||
|
assert.equal(list.length, 1);
|
||||||
|
assert.equal(list[0]!.id, 'shadowdark');
|
||||||
|
const bind = await store.bindSnapshot('shadowdark');
|
||||||
|
assert.ok(bind.snapshotHash.length === 64);
|
||||||
|
const snap = await store.getSnapshot(bind.snapshotHash);
|
||||||
|
assert.equal(snap?.id, 'shadowdark');
|
||||||
|
await store.delete('shadowdark');
|
||||||
|
assert.equal((await store.list()).length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { GameSystemListItem } from '../../shared/ipc/contracts';
|
||||||
|
import type { SystemDefinition } from '../../shared/characterSheet/types';
|
||||||
|
import { validateSystemDefinition } from '../../shared/characterSheet/systemValidate';
|
||||||
|
import { snapshotHash } from '../../shared/characterSheet/snapshot';
|
||||||
|
|
||||||
|
export type { GameSystemListItem };
|
||||||
|
|
||||||
|
type Manifest = {
|
||||||
|
systems: GameSystemListItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GameSystemsStore {
|
||||||
|
private readonly rootDir: string;
|
||||||
|
private readonly manifestPath: string;
|
||||||
|
private readonly snapshotsDir: string;
|
||||||
|
private manifest: Manifest = { systems: [] };
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
|
constructor(userData: string) {
|
||||||
|
this.rootDir = path.join(userData, 'game-systems');
|
||||||
|
this.manifestPath = path.join(this.rootDir, 'manifest.json');
|
||||||
|
this.snapshotsDir = path.join(this.rootDir, 'snapshots');
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureLoaded(): Promise<void> {
|
||||||
|
if (this.loaded) return;
|
||||||
|
await fs.mkdir(this.snapshotsDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.manifestPath, 'utf8');
|
||||||
|
this.manifest = JSON.parse(raw) as Manifest;
|
||||||
|
if (!Array.isArray(this.manifest.systems)) this.manifest.systems = [];
|
||||||
|
} catch {
|
||||||
|
this.manifest = { systems: [] };
|
||||||
|
}
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<GameSystemListItem[]> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
return [...this.manifest.systems];
|
||||||
|
}
|
||||||
|
|
||||||
|
private systemPath(id: string): string {
|
||||||
|
return path.join(this.rootDir, id, 'system.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<SystemDefinition | null> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(this.systemPath(id), 'utf8');
|
||||||
|
return JSON.parse(raw) as SystemDefinition;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async importSystem(system: SystemDefinition): Promise<GameSystemListItem> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
const check = validateSystemDefinition(system);
|
||||||
|
if (!check.ok) throw new Error(check.errors.map((e) => e.message).join('; '));
|
||||||
|
|
||||||
|
const dir = path.join(this.rootDir, system.id);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(this.systemPath(system.id), JSON.stringify(system, null, 2), 'utf8');
|
||||||
|
|
||||||
|
const hash = snapshotHash(system);
|
||||||
|
await fs.writeFile(path.join(this.snapshotsDir, `${hash}.json`), JSON.stringify(system), 'utf8');
|
||||||
|
|
||||||
|
const item: GameSystemListItem = {
|
||||||
|
id: system.id,
|
||||||
|
name: system.name,
|
||||||
|
edition: system.edition,
|
||||||
|
schemaVersion: system.schemaVersion,
|
||||||
|
revision: system.revision,
|
||||||
|
};
|
||||||
|
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== system.id).concat(item);
|
||||||
|
await this.saveManifest();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<string[]> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== id);
|
||||||
|
await this.saveManifest();
|
||||||
|
try {
|
||||||
|
await fs.rm(path.join(this.rootDir, id), { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// Caller clears player bindings; return id for convenience
|
||||||
|
return [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnapshot(hash: string): Promise<SystemDefinition | null> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(path.join(this.snapshotsDir, `${hash}.json`), 'utf8');
|
||||||
|
return JSON.parse(raw) as SystemDefinition;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async bindSnapshot(systemId: string): Promise<{ systemId: string; snapshotHash: string; system: SystemDefinition }> {
|
||||||
|
const system = await this.get(systemId);
|
||||||
|
if (!system) throw new Error(`System not found: ${systemId}`);
|
||||||
|
const hash = snapshotHash(system);
|
||||||
|
const snapPath = path.join(this.snapshotsDir, `${hash}.json`);
|
||||||
|
try {
|
||||||
|
await fs.access(snapPath);
|
||||||
|
} catch {
|
||||||
|
await fs.writeFile(snapPath, JSON.stringify(system), 'utf8');
|
||||||
|
}
|
||||||
|
return { systemId, snapshotHash: hash, system };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveManifest(): Promise<void> {
|
||||||
|
await fs.mkdir(this.rootDir, { recursive: true });
|
||||||
|
await fs.writeFile(this.manifestPath, JSON.stringify(this.manifest, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper — content hash of arbitrary buffer. */
|
||||||
|
export function sha256Hex(data: string): string {
|
||||||
|
return createHash('sha256').update(data).digest('hex');
|
||||||
|
}
|
||||||
+757
-27
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { InitiativeSessionStore } from './initiativeSessionStore';
|
||||||
|
import {
|
||||||
|
initiativeNpcEntryId,
|
||||||
|
initiativePlayerEntryId,
|
||||||
|
} from '../../shared/types/initiative';
|
||||||
|
|
||||||
|
void test('initiative: addManual + next cycles order', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({ kind: 'addManual', name: 'A' });
|
||||||
|
store.dispatch({ kind: 'addManual', name: 'B' });
|
||||||
|
const a = store.getState().order[0]!;
|
||||||
|
const b = store.getState().order[1]!;
|
||||||
|
assert.equal(store.getState().activeId, a);
|
||||||
|
store.dispatch({ kind: 'next' });
|
||||||
|
assert.equal(store.getState().activeId, b);
|
||||||
|
store.dispatch({ kind: 'next' });
|
||||||
|
assert.equal(store.getState().activeId, a);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('initiative: syncPresence adds npcs and players', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
|
||||||
|
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||||
|
playersVisible: true,
|
||||||
|
});
|
||||||
|
const st = store.getState();
|
||||||
|
assert.ok(st.entries[initiativeNpcEntryId('p1')]);
|
||||||
|
assert.ok(st.entries[initiativePlayerEntryId('pl1')]);
|
||||||
|
assert.deepEqual(st.order, [initiativeNpcEntryId('p1'), initiativePlayerEntryId('pl1')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('initiative: hide players moves them to reserve', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [],
|
||||||
|
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||||
|
playersVisible: true,
|
||||||
|
});
|
||||||
|
const pid = initiativePlayerEntryId('pl1');
|
||||||
|
assert.ok(store.getState().order.includes(pid));
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [],
|
||||||
|
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||||
|
playersVisible: false,
|
||||||
|
});
|
||||||
|
assert.ok(!store.getState().order.includes(pid));
|
||||||
|
assert.ok(store.getState().reserve.includes(pid));
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [],
|
||||||
|
players: [{ playerId: 'pl1', name: 'Hero' }],
|
||||||
|
playersVisible: true,
|
||||||
|
});
|
||||||
|
assert.ok(store.getState().reserve.includes(pid));
|
||||||
|
assert.ok(!store.getState().order.includes(pid));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('initiative: conditions unique + reserve/restore', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({ kind: 'addManual', name: 'X' });
|
||||||
|
const id = store.getState().order[0]!;
|
||||||
|
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
|
||||||
|
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
|
||||||
|
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
|
||||||
|
store.dispatch({ kind: 'moveToReserve', id });
|
||||||
|
assert.deepEqual(store.getState().order, []);
|
||||||
|
assert.deepEqual(store.getState().reserve, [id]);
|
||||||
|
store.dispatch({ kind: 'restoreFromReserve', id });
|
||||||
|
assert.deepEqual(store.getState().order, [id]);
|
||||||
|
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('initiative: clear keeps enabled', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({ kind: 'setEnabled', enabled: true });
|
||||||
|
store.dispatch({ kind: 'addManual', name: 'Z' });
|
||||||
|
store.clear();
|
||||||
|
assert.equal(store.getState().enabled, true);
|
||||||
|
assert.deepEqual(store.getState().order, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('initiative: despawn removes npc entry', () => {
|
||||||
|
const store = new InitiativeSessionStore();
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
|
||||||
|
players: [],
|
||||||
|
playersVisible: false,
|
||||||
|
});
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'syncPresence',
|
||||||
|
npcs: [],
|
||||||
|
players: [],
|
||||||
|
playersVisible: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(store.getState().order, []);
|
||||||
|
assert.deepEqual(Object.keys(store.getState().entries), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import {
|
||||||
|
createInitialInitiativeState,
|
||||||
|
initiativeManualLetter,
|
||||||
|
initiativeNpcEntryId,
|
||||||
|
initiativePlayerEntryId,
|
||||||
|
isInitiativeConditionId,
|
||||||
|
newInitiativeManualId,
|
||||||
|
pickInitiativeManualGradient,
|
||||||
|
type InitiativeEntry,
|
||||||
|
type InitiativeEvent,
|
||||||
|
type InitiativePresenceNpc,
|
||||||
|
type InitiativePresencePlayer,
|
||||||
|
type InitiativeState,
|
||||||
|
} from '../../shared/types/initiative';
|
||||||
|
|
||||||
|
function withoutId(ids: readonly string[], id: string): string[] {
|
||||||
|
return ids.filter((x) => x !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureActiveValid(state: InitiativeState): InitiativeState {
|
||||||
|
if (state.activeId != null && state.order.includes(state.activeId)) return state;
|
||||||
|
const nextActive = state.order[0] ?? null;
|
||||||
|
if (state.activeId === nextActive) return state;
|
||||||
|
return { ...state, activeId: nextActive };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InitiativeSessionStore {
|
||||||
|
private state: InitiativeState = createInitialInitiativeState();
|
||||||
|
|
||||||
|
getState(): InitiativeState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): InitiativeState {
|
||||||
|
const enabled = this.state.enabled;
|
||||||
|
this.state = { ...createInitialInitiativeState(), enabled, revision: this.state.revision + 1 };
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(event: InitiativeEvent): InitiativeState {
|
||||||
|
switch (event.kind) {
|
||||||
|
case 'clear': {
|
||||||
|
this.state = {
|
||||||
|
...createInitialInitiativeState(),
|
||||||
|
enabled: this.state.enabled,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'setEnabled': {
|
||||||
|
if (this.state.enabled === event.enabled) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
enabled: event.enabled,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'syncPresence': {
|
||||||
|
return this.syncPresence(event.npcs, event.players, event.playersVisible);
|
||||||
|
}
|
||||||
|
case 'reorder': {
|
||||||
|
const allowed = new Set(this.state.order);
|
||||||
|
const nextOrder = event.order.filter((id) => allowed.has(id));
|
||||||
|
for (const id of this.state.order) {
|
||||||
|
if (!nextOrder.includes(id)) nextOrder.push(id);
|
||||||
|
}
|
||||||
|
if (nextOrder.length === this.state.order.length && nextOrder.every((id, i) => id === this.state.order[i])) {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
this.state = ensureActiveValid({
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
order: nextOrder,
|
||||||
|
});
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'setActive': {
|
||||||
|
if (event.id != null && !this.state.order.includes(event.id)) return this.state;
|
||||||
|
if (this.state.activeId === event.id) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
activeId: event.id,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'next': {
|
||||||
|
if (this.state.order.length === 0) {
|
||||||
|
if (this.state.activeId == null) return this.state;
|
||||||
|
this.state = { ...this.state, revision: this.state.revision + 1, activeId: null };
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
const cur = this.state.activeId ? this.state.order.indexOf(this.state.activeId) : -1;
|
||||||
|
const nextIdx = cur < 0 ? 0 : (cur + 1) % this.state.order.length;
|
||||||
|
const nextId = this.state.order[nextIdx] ?? null;
|
||||||
|
if (nextId === this.state.activeId) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
activeId: nextId,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'addManual': {
|
||||||
|
const name = event.name.trim();
|
||||||
|
if (!name) return this.state;
|
||||||
|
const id = newInitiativeManualId();
|
||||||
|
const entry: InitiativeEntry = {
|
||||||
|
id,
|
||||||
|
kind: 'manual',
|
||||||
|
name,
|
||||||
|
letter: initiativeManualLetter(name),
|
||||||
|
gradient: pickInitiativeManualGradient(),
|
||||||
|
};
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
entries: { ...this.state.entries, [id]: entry },
|
||||||
|
order: [...this.state.order, id],
|
||||||
|
activeId: this.state.activeId ?? id,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'moveToReserve': {
|
||||||
|
if (!this.state.order.includes(event.id)) return this.state;
|
||||||
|
if (!this.state.entries[event.id]) return this.state;
|
||||||
|
const order = withoutId(this.state.order, event.id);
|
||||||
|
const reserve = this.state.reserve.includes(event.id)
|
||||||
|
? this.state.reserve
|
||||||
|
: [...this.state.reserve, event.id];
|
||||||
|
this.state = ensureActiveValid({
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
order,
|
||||||
|
reserve,
|
||||||
|
});
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'restoreFromReserve': {
|
||||||
|
if (!this.state.reserve.includes(event.id)) return this.state;
|
||||||
|
if (!this.state.entries[event.id]) return this.state;
|
||||||
|
const reserve = withoutId(this.state.reserve, event.id);
|
||||||
|
const order = this.state.order.includes(event.id)
|
||||||
|
? this.state.order
|
||||||
|
: [...this.state.order, event.id];
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
order,
|
||||||
|
reserve,
|
||||||
|
activeId: this.state.activeId ?? event.id,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'deleteEntry': {
|
||||||
|
if (!this.state.entries[event.id]) return this.state;
|
||||||
|
const { [event.id]: _removed, ...entries } = this.state.entries;
|
||||||
|
const { [event.id]: _c, ...conditions } = this.state.conditions;
|
||||||
|
this.state = ensureActiveValid({
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
entries,
|
||||||
|
conditions,
|
||||||
|
order: withoutId(this.state.order, event.id),
|
||||||
|
reserve: withoutId(this.state.reserve, event.id),
|
||||||
|
});
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'addCondition': {
|
||||||
|
if (!this.state.entries[event.id]) return this.state;
|
||||||
|
if (!isInitiativeConditionId(event.conditionId)) return this.state;
|
||||||
|
const cur = this.state.conditions[event.id] ?? [];
|
||||||
|
if (cur.includes(event.conditionId)) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
conditions: {
|
||||||
|
...this.state.conditions,
|
||||||
|
[event.id]: [...cur, event.conditionId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'removeCondition': {
|
||||||
|
const cur = this.state.conditions[event.id];
|
||||||
|
if (!cur?.includes(event.conditionId)) return this.state;
|
||||||
|
const next = cur.filter((c) => c !== event.conditionId);
|
||||||
|
const conditions = { ...this.state.conditions };
|
||||||
|
if (next.length === 0) delete conditions[event.id];
|
||||||
|
else conditions[event.id] = next;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
conditions,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = event;
|
||||||
|
return _exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncPresence(
|
||||||
|
npcs: readonly InitiativePresenceNpc[],
|
||||||
|
players: readonly InitiativePresencePlayer[],
|
||||||
|
playersVisible: boolean,
|
||||||
|
): InitiativeState {
|
||||||
|
const npcByPlacement = new Map(npcs.map((n) => [n.placementId, n]));
|
||||||
|
const playerById = new Map(players.map((p) => [p.playerId, p]));
|
||||||
|
|
||||||
|
let entries: Record<string, InitiativeEntry> = { ...this.state.entries };
|
||||||
|
let order = [...this.state.order];
|
||||||
|
let reserve = [...this.state.reserve];
|
||||||
|
let conditions = { ...this.state.conditions };
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const [id, entry] of Object.entries(entries)) {
|
||||||
|
if (entry.kind === 'manual') continue;
|
||||||
|
if (entry.kind === 'npc') {
|
||||||
|
const live = npcByPlacement.get(entry.placementId);
|
||||||
|
if (!live) {
|
||||||
|
delete entries[id];
|
||||||
|
delete conditions[id];
|
||||||
|
order = withoutId(order, id);
|
||||||
|
reserve = withoutId(reserve, id);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.name !== live.name || entry.avatarAssetId !== live.avatarAssetId || entry.npcId !== live.npcId) {
|
||||||
|
entries[id] = {
|
||||||
|
...entry,
|
||||||
|
npcId: live.npcId,
|
||||||
|
name: live.name,
|
||||||
|
avatarAssetId: live.avatarAssetId,
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const live = playerById.get(entry.playerId);
|
||||||
|
if (!live) {
|
||||||
|
delete entries[id];
|
||||||
|
delete conditions[id];
|
||||||
|
order = withoutId(order, id);
|
||||||
|
reserve = withoutId(reserve, id);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.name !== live.name) {
|
||||||
|
entries[id] = { ...entry, name: live.name };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const npc of npcs) {
|
||||||
|
const id = initiativeNpcEntryId(npc.placementId);
|
||||||
|
if (entries[id]) continue;
|
||||||
|
entries[id] = {
|
||||||
|
id,
|
||||||
|
kind: 'npc',
|
||||||
|
placementId: npc.placementId,
|
||||||
|
npcId: npc.npcId,
|
||||||
|
name: npc.name,
|
||||||
|
avatarAssetId: npc.avatarAssetId,
|
||||||
|
};
|
||||||
|
order.push(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
const id = initiativePlayerEntryId(player.playerId);
|
||||||
|
if (entries[id]) continue;
|
||||||
|
entries[id] = {
|
||||||
|
id,
|
||||||
|
kind: 'player',
|
||||||
|
playerId: player.playerId,
|
||||||
|
name: player.name,
|
||||||
|
};
|
||||||
|
order.push(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasVisible = this.state.playersVisible;
|
||||||
|
if (wasVisible && !playersVisible) {
|
||||||
|
const playerIdsInOrder = order.filter((id) => entries[id]?.kind === 'player');
|
||||||
|
if (playerIdsInOrder.length > 0) {
|
||||||
|
for (const id of playerIdsInOrder) {
|
||||||
|
order = withoutId(order, id);
|
||||||
|
if (!reserve.includes(id)) reserve.push(id);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.playersVisible !== playersVisible) changed = true;
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
// Still update playersVisible if somehow same structure
|
||||||
|
if (this.state.playersVisible === playersVisible) return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next: InitiativeState = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
entries,
|
||||||
|
order,
|
||||||
|
reserve,
|
||||||
|
conditions,
|
||||||
|
playersVisible,
|
||||||
|
};
|
||||||
|
next = ensureActiveValid(next);
|
||||||
|
this.state = next;
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
load(next: Omit<InitiativeState, 'revision'>): InitiativeState {
|
||||||
|
this.state = ensureActiveValid({
|
||||||
|
...next,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
entries: { ...next.entries },
|
||||||
|
order: [...next.order],
|
||||||
|
reserve: [...next.reserve],
|
||||||
|
conditions: { ...next.conditions },
|
||||||
|
});
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ function channelRequiresLicense(channel: string): boolean {
|
|||||||
if (channel.startsWith('license.')) return false;
|
if (channel.startsWith('license.')) return false;
|
||||||
if (channel.startsWith('app.')) return false;
|
if (channel.startsWith('app.')) return false;
|
||||||
if (channel === ipcChannels.windows.closeMultiWindow) return false;
|
if (channel === ipcChannels.windows.closeMultiWindow) return false;
|
||||||
|
if (channel === ipcChannels.windows.requestCloseMultiWindowConfirm) return false;
|
||||||
if (channel === ipcChannels.windows.closeSceneDescription) return false;
|
if (channel === ipcChannels.windows.closeSceneDescription) return false;
|
||||||
if (channel === ipcChannels.windows.closeMaterials) return false;
|
if (channel === ipcChannels.windows.closeMaterials) return false;
|
||||||
if (channel === ipcChannels.windows.closeNpcs) return false;
|
if (channel === ipcChannels.windows.closeNpcs) return false;
|
||||||
|
|||||||
@@ -225,4 +225,16 @@ export class MaterialsOverlayStore {
|
|||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load(next: Omit<MaterialsOverlayState, 'revision' | 'zoomTool'>): MaterialsOverlayState {
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
activeMaterialIds: [...next.activeMaterialIds],
|
||||||
|
layouts: { ...next.layouts },
|
||||||
|
legendLayouts: { ...next.legendLayouts },
|
||||||
|
focusMaterialId: next.focusMaterialId,
|
||||||
|
zoomTool: null,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,4 +256,17 @@ export class NpcsOverlayStore {
|
|||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load(next: Pick<NpcsOverlayState, 'activeNpcIds' | 'layouts' | 'focusNpcId'>): NpcsOverlayState {
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
activeNpcIds: [...next.activeNpcIds],
|
||||||
|
layouts: { ...next.layouts },
|
||||||
|
focusNpcId: next.focusNpcId,
|
||||||
|
zoomTool: null,
|
||||||
|
inspectNpcIds: [],
|
||||||
|
inspectFocusNpcId: null,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ export class PlayersStore {
|
|||||||
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE,
|
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
if (existing?.systemId !== undefined) player.systemId = existing.systemId;
|
||||||
|
if (existing?.characterSheet !== undefined) player.characterSheet = existing.characterSheet;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.players = this.players.map((p) => (p.id === player.id ? player : p));
|
this.players = this.players.map((p) => (p.id === player.id ? player : p));
|
||||||
@@ -287,6 +289,20 @@ export class PlayersStore {
|
|||||||
return this.getById(playerId);
|
return this.getById(playerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clears systemId / characterSheet on players bound to a removed game system. */
|
||||||
|
async clearSystemBindings(systemId: string): Promise<number> {
|
||||||
|
await this.ensureLoaded();
|
||||||
|
let changed = 0;
|
||||||
|
this.players = this.players.map((p) => {
|
||||||
|
const hit = p.systemId === systemId || p.characterSheet?.systemId === systemId;
|
||||||
|
if (!hit) return p;
|
||||||
|
changed += 1;
|
||||||
|
return { ...p, systemId: null, characterSheet: null };
|
||||||
|
});
|
||||||
|
if (changed > 0) await this.persist();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
private async persist(): Promise<void> {
|
private async persist(): Promise<void> {
|
||||||
await fs.mkdir(this.rootDir, { recursive: true });
|
await fs.mkdir(this.rootDir, { recursive: true });
|
||||||
const payload: PlayersManifest = { players: this.players, teams: this.teams };
|
const payload: PlayersManifest = { players: this.players, teams: this.teams };
|
||||||
|
|||||||
@@ -82,6 +82,22 @@ void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
|
|||||||
assert.equal(s3.byPlacementId.a?.inactive, undefined);
|
assert.equal(s3.byPlacementId.a?.inactive, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('SceneNpcTokensSessionStore hide from players keeps the token for the GM', () => {
|
||||||
|
const store = new SceneNpcTokensSessionStore();
|
||||||
|
const s1 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: true });
|
||||||
|
assert.equal(s1.byPlacementId.a?.hiddenFromPlayers, true);
|
||||||
|
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.4 });
|
||||||
|
assert.equal(s2.byPlacementId.a?.hiddenFromPlayers, true);
|
||||||
|
assert.equal(s2.byPlacementId.a?.nx, 0.2);
|
||||||
|
const s3 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: false });
|
||||||
|
assert.equal(s3.byPlacementId.a?.hiddenFromPlayers, undefined);
|
||||||
|
assert.equal(s3.byPlacementId.a?.nx, 0.2);
|
||||||
|
const only = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: true });
|
||||||
|
store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: false });
|
||||||
|
assert.equal(store.getState().byPlacementId.b, undefined);
|
||||||
|
assert.ok(only.byPlacementId.b?.hiddenFromPlayers);
|
||||||
|
});
|
||||||
|
|
||||||
void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
|
void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
|
||||||
const store = new SceneNpcTokensSessionStore();
|
const store = new SceneNpcTokensSessionStore();
|
||||||
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
|
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
|||||||
p.ny === undefined &&
|
p.ny === undefined &&
|
||||||
p.sizeN === undefined &&
|
p.sizeN === undefined &&
|
||||||
!p.disposition &&
|
!p.disposition &&
|
||||||
!p.inactive
|
!p.inactive &&
|
||||||
|
!p.hiddenFromPlayers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcToken
|
|||||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||||
...(prev?.inactive ? { inactive: true } : {}),
|
...(prev?.inactive ? { inactive: true } : {}),
|
||||||
|
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +240,7 @@ export class SceneNpcTokensSessionStore {
|
|||||||
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
|
||||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||||
...(inactive ? { inactive: true } : {}),
|
...(inactive ? { inactive: true } : {}),
|
||||||
|
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: true } : {}),
|
||||||
};
|
};
|
||||||
if (isEmptyPlacement(next)) {
|
if (isEmptyPlacement(next)) {
|
||||||
this.state = withoutPlacement(this.state, placementId);
|
this.state = withoutPlacement(this.state, placementId);
|
||||||
@@ -254,6 +257,32 @@ export class SceneNpcTokensSessionStore {
|
|||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
case 'setHiddenFromPlayers': {
|
||||||
|
const placementId = String(event.placementId ?? '');
|
||||||
|
if (!placementId) return this.state;
|
||||||
|
const hidden = Boolean(event.hidden);
|
||||||
|
const prev = this.state.byPlacementId[placementId];
|
||||||
|
if (Boolean(prev?.hiddenFromPlayers) === hidden) return this.state;
|
||||||
|
const next: SceneNpcTokensSessionPlacement = {
|
||||||
|
...restOf(prev),
|
||||||
|
...(hidden ? { hiddenFromPlayers: true } : {}),
|
||||||
|
};
|
||||||
|
if (!hidden) delete next.hiddenFromPlayers;
|
||||||
|
if (isEmptyPlacement(next)) {
|
||||||
|
this.state = withoutPlacement(this.state, placementId);
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
byPlacementId: {
|
||||||
|
...this.state.byPlacementId,
|
||||||
|
[placementId]: next,
|
||||||
|
},
|
||||||
|
scale: this.state.scale,
|
||||||
|
spawned: this.state.spawned,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
case 'setSize': {
|
case 'setSize': {
|
||||||
const placementId = String(event.placementId ?? '');
|
const placementId = String(event.placementId ?? '');
|
||||||
if (!placementId) return this.state;
|
if (!placementId) return this.state;
|
||||||
@@ -281,4 +310,14 @@ export class SceneNpcTokensSessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load(next: Omit<SceneNpcTokensSessionState, 'revision'>): SceneNpcTokensSessionState {
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
byPlacementId: { ...next.byPlacementId },
|
||||||
|
scale: clampNpcTokenSessionScale(next.scale),
|
||||||
|
spawned: { ...next.spawned },
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,4 +179,14 @@ export class ScenePlayerTokensSessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load(next: Omit<ScenePlayerTokensSessionState, 'revision'>): ScenePlayerTokensSessionState {
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
selectedPlayerIds: [...next.selectedPlayerIds],
|
||||||
|
visible: next.visible,
|
||||||
|
byPlayerId: { ...next.byPlayerId },
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
previewThumbAssetId: 'th' as AssetId,
|
||||||
media: {
|
media: {
|
||||||
videos: ['v1' as AssetId],
|
videos: ['v1' as AssetId],
|
||||||
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }],
|
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true, volume: 1 }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
|
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true, volume: 1 }],
|
||||||
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
|
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
|
||||||
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
|
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
|
||||||
} as unknown as Project;
|
} as unknown as Project;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { ensureParentDir } from './ensureParentDir';
|
||||||
|
|
||||||
|
void test('ensureParentDir: creates nested parent', async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-mkdir-'));
|
||||||
|
try {
|
||||||
|
const file = path.join(root, 'a', 'b', 'out.ttrpg.zip');
|
||||||
|
await ensureParentDir(file);
|
||||||
|
const st = await fs.stat(path.dirname(file));
|
||||||
|
assert.equal(st.isDirectory(), true);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('ensureParentDir: drive root is a no-op (Windows)', async () => {
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
// Must not throw EPERM the way fs.mkdir('E:\\') often does.
|
||||||
|
await ensureParentDir('E:\\campaign.ttrpg.zip');
|
||||||
|
await ensureParentDir('C:\\file.ttrpg.zip');
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт родительский каталог для файла.
|
||||||
|
* На Windows `mkdir('E:\\')` даёт EPERM — корень диска пропускаем.
|
||||||
|
*/
|
||||||
|
export async function ensureParentDir(filePath: string): Promise<void> {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
if (!dir || dir === '.') return;
|
||||||
|
const root = path.parse(path.resolve(filePath)).root;
|
||||||
|
if (root && path.resolve(dir) === path.resolve(root)) return;
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ void test('readProjectJsonFromZip: campaignAudios round-trips inside project.jso
|
|||||||
},
|
},
|
||||||
scenes: {},
|
scenes: {},
|
||||||
assets: {},
|
assets: {},
|
||||||
campaignAudios: [{ assetId, autoplay: true, loop: false }],
|
campaignAudios: [{ assetId, autoplay: true, loop: false, volume: 1 }],
|
||||||
currentSceneId: null,
|
currentSceneId: null,
|
||||||
currentGraphNodeId: null,
|
currentGraphNodeId: null,
|
||||||
sceneGraphNodes: [],
|
sceneGraphNodes: [],
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ void test('zipStore: closeOpenProject is serialized with open on projectSwitchCh
|
|||||||
assert.match(src, /Открытие проекта отменено/);
|
assert.match(src, /Открытие проекта отменено/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('zipStore: Foundry archive playlists are copied before temp unzip cleanup', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
|
const fn = src.slice(src.indexOf('async importProjectFromFoundry'));
|
||||||
|
const body = fn.slice(0, fn.indexOf('\n async exportProjectZipToPath'));
|
||||||
|
const copyAt = body.indexOf('await importPlaylists(pendingAppPlaylists)');
|
||||||
|
const cleanupAt = body.indexOf('await loaded.cleanup()');
|
||||||
|
assert.ok(copyAt > 0 && cleanupAt > copyAt);
|
||||||
|
});
|
||||||
|
|
||||||
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
|
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
|
||||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
assert.match(src, /async exportProjectZipToPath/);
|
assert.match(src, /async exportProjectZipToPath/);
|
||||||
@@ -105,6 +114,50 @@ void test('zipStore: listProjects skips unreadable archives', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('zipStore: setSessionPointer updates pointer in memory without disk write', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
|
const m = src.match(/setSessionPointer\([\s\S]*?\n \}/);
|
||||||
|
assert.ok(m);
|
||||||
|
assert.doesNotMatch(m[0], /writeCacheProject|queueSave|enqueueProjectUpdate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('main index: setCurrentGraphNode uses setSessionPointer not updateProject', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
|
||||||
|
const m = src.match(
|
||||||
|
/registerHandler\(ipcChannels\.project\.setCurrentGraphNode[\s\S]*?emitSessionState\(\)/,
|
||||||
|
);
|
||||||
|
assert.ok(m);
|
||||||
|
assert.match(m[0], /setSessionPointer/);
|
||||||
|
assert.doesNotMatch(m[0], /updateProject/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('main index: full zip import loads app-tokens like playlists', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
|
||||||
|
assert.match(src, /async function importAppTokensFromOpenProjectCache/);
|
||||||
|
assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/);
|
||||||
|
assert.match(src, /remapProjectSceneTokenIds/);
|
||||||
|
const importZip = src.match(
|
||||||
|
/registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/,
|
||||||
|
);
|
||||||
|
assert.ok(importZip);
|
||||||
|
assert.match(importZip[0], /importAppTokensFromOpenProjectCache/);
|
||||||
|
const importFromPath = src.match(
|
||||||
|
/registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/,
|
||||||
|
);
|
||||||
|
assert.ok(importFromPath);
|
||||||
|
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('zipStore: zipDir uses ensureParentDir (no mkdir of drive root)', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
|
assert.match(src, /from '\.\/ensureParentDir'/);
|
||||||
|
const i = src.indexOf('async function zipDir(');
|
||||||
|
assert.ok(i >= 0);
|
||||||
|
const block = src.slice(i, i + 800);
|
||||||
|
assert.match(block, /await ensureParentDir\(outZipPath\)/);
|
||||||
|
assert.doesNotMatch(block, /fs\.mkdir\(path\.dirname\(outZipPath\)/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
|
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
|
||||||
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
||||||
const i = src.indexOf('export async function replaceFileAtomic');
|
const i = src.indexOf('export async function replaceFileAtomic');
|
||||||
|
|||||||
+158
-30
@@ -45,12 +45,14 @@ import type {
|
|||||||
ProjectNpcGroup,
|
ProjectNpcGroup,
|
||||||
ProjectNpcRelation,
|
ProjectNpcRelation,
|
||||||
Scene,
|
Scene,
|
||||||
|
SceneAudioRef,
|
||||||
SceneGraphEdge,
|
SceneGraphEdge,
|
||||||
SceneGraphNode,
|
SceneGraphNode,
|
||||||
SceneId,
|
SceneId,
|
||||||
SceneTrap,
|
SceneTrap,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
import { normalizeSceneAudioRef, PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||||
|
import { normalizeSceneDarknessStrokes } from '../../shared/types/sceneDarkness';
|
||||||
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
||||||
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
|
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +69,8 @@ import {
|
|||||||
type NpcDisposition,
|
type NpcDisposition,
|
||||||
} from '../../shared/types/npcDisposition';
|
} from '../../shared/types/npcDisposition';
|
||||||
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
|
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
|
||||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
import { DEFAULT_SCENE_GRID, normalizeSceneGrid, normalizeSceneGridTemplates, sceneGridFromTemplate, sceneGridTemplateStyleFromGrid, MAX_SCENE_GRID_TEMPLATES } from '../../shared/types/sceneGrid';
|
||||||
|
import type { SceneGridTemplate } from '../../shared/types/sceneGrid';
|
||||||
import {
|
import {
|
||||||
asPreviewRotationDeg,
|
asPreviewRotationDeg,
|
||||||
previewRotationStepsCw,
|
previewRotationStepsCw,
|
||||||
@@ -78,6 +81,7 @@ import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
|||||||
import type {
|
import type {
|
||||||
AssetId,
|
AssetId,
|
||||||
GraphNodeId,
|
GraphNodeId,
|
||||||
|
GridTemplateId,
|
||||||
MaterialId,
|
MaterialId,
|
||||||
NpcGroupId,
|
NpcGroupId,
|
||||||
NpcId,
|
NpcId,
|
||||||
@@ -86,6 +90,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
asAssetId,
|
asAssetId,
|
||||||
asGraphNodeId,
|
asGraphNodeId,
|
||||||
|
asGridTemplateId,
|
||||||
asMaterialId,
|
asMaterialId,
|
||||||
asNpcGroupId,
|
asNpcGroupId,
|
||||||
asNpcId,
|
asNpcId,
|
||||||
@@ -103,11 +108,13 @@ import {
|
|||||||
buildProjectFromFoundryDocuments,
|
buildProjectFromFoundryDocuments,
|
||||||
loadFoundryDocumentsForImport,
|
loadFoundryDocumentsForImport,
|
||||||
type FoundryImportProgress,
|
type FoundryImportProgress,
|
||||||
|
type FoundryPendingAppPlaylist,
|
||||||
} from '../foundry/foundryImport';
|
} from '../foundry/foundryImport';
|
||||||
import { getAppSemanticVersion } from '../versionInfo';
|
import { getAppSemanticVersion } from '../versionInfo';
|
||||||
|
|
||||||
import { reconcileAssetFiles } from './assetPrune';
|
import { reconcileAssetFiles } from './assetPrune';
|
||||||
import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
|
import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
|
||||||
|
import { ensureParentDir } from './ensureParentDir';
|
||||||
import { rmWithRetries } from './fsRetry';
|
import { rmWithRetries } from './fsRetry';
|
||||||
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
|
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
|
||||||
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
|
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
|
||||||
@@ -320,6 +327,7 @@ export class ZipProjectStore {
|
|||||||
assets: {},
|
assets: {},
|
||||||
campaignAudios: [],
|
campaignAudios: [],
|
||||||
materials: [],
|
materials: [],
|
||||||
|
gridTemplates: [],
|
||||||
npcs: [],
|
npcs: [],
|
||||||
npcGroups: [],
|
npcGroups: [],
|
||||||
npcRelations: [],
|
npcRelations: [],
|
||||||
@@ -411,6 +419,22 @@ export class ZipProjectStore {
|
|||||||
return this.openProject?.project ?? null;
|
return this.openProject?.project ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOpenCacheDir(): string | null {
|
||||||
|
return this.openProject?.cacheDir ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Только указатель сессии в памяти — без записи project.json на диск. */
|
||||||
|
setSessionPointer(currentGraphNodeId: GraphNodeId | null, currentSceneId: SceneId | null): Project {
|
||||||
|
const open = this.openProject;
|
||||||
|
if (!open) throw new Error('No open project');
|
||||||
|
open.project = {
|
||||||
|
...open.project,
|
||||||
|
currentGraphNodeId,
|
||||||
|
currentSceneId,
|
||||||
|
};
|
||||||
|
return open.project;
|
||||||
|
}
|
||||||
|
|
||||||
/** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */
|
/** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */
|
||||||
getAssetFileUrl(assetId: AssetId): string | null {
|
getAssetFileUrl(assetId: AssetId): string | null {
|
||||||
if (!this.getAssetReadInfo(assetId)) return null;
|
if (!this.getAssetReadInfo(assetId)) return null;
|
||||||
@@ -454,7 +478,7 @@ export class ZipProjectStore {
|
|||||||
const relPath = `assets/${id}_${safeOrig}`;
|
const relPath = `assets/${id}_${safeOrig}`;
|
||||||
const abs = path.join(open.cacheDir, relPath);
|
const abs = path.join(open.cacheDir, relPath);
|
||||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
await ensureParentDir(abs);
|
||||||
await fs.writeFile(abs, buf);
|
await fs.writeFile(abs, buf);
|
||||||
const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);
|
const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);
|
||||||
|
|
||||||
@@ -750,6 +774,7 @@ export class ZipProjectStore {
|
|||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
|
darknessStrokes: [],
|
||||||
traps: [],
|
traps: [],
|
||||||
tokens: [],
|
tokens: [],
|
||||||
npcTokens: [],
|
npcTokens: [],
|
||||||
@@ -776,6 +801,9 @@ export class ZipProjectStore {
|
|||||||
? { previewRotationDeg: patch.previewRotationDeg }
|
? { previewRotationDeg: patch.previewRotationDeg }
|
||||||
: null),
|
: null),
|
||||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||||
|
...(patch.darknessStrokes !== undefined
|
||||||
|
? { darknessStrokes: normalizeSceneDarknessStrokes(patch.darknessStrokes) }
|
||||||
|
: null),
|
||||||
...(patch.traps !== undefined
|
...(patch.traps !== undefined
|
||||||
? {
|
? {
|
||||||
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
|
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
|
||||||
@@ -797,7 +825,14 @@ export class ZipProjectStore {
|
|||||||
: null),
|
: null),
|
||||||
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
|
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
|
||||||
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
...(patch.media
|
||||||
|
? {
|
||||||
|
media: {
|
||||||
|
videos: patch.media.videos ?? base.media.videos,
|
||||||
|
audios: (patch.media.audios ?? base.media.audios).map((a) => normalizeSceneAudioRef(a)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null),
|
||||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1137,7 +1172,7 @@ export class ZipProjectStore {
|
|||||||
const safeOrig = sanitizeFileName(orig);
|
const safeOrig = sanitizeFileName(orig);
|
||||||
const relPath = `assets/${id}_${safeOrig}`;
|
const relPath = `assets/${id}_${safeOrig}`;
|
||||||
const abs = path.join(open.cacheDir, relPath);
|
const abs = path.join(open.cacheDir, relPath);
|
||||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
await ensureParentDir(abs);
|
||||||
await fs.copyFile(filePath, abs);
|
await fs.copyFile(filePath, abs);
|
||||||
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
|
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
|
||||||
}
|
}
|
||||||
@@ -1160,8 +1195,9 @@ export class ZipProjectStore {
|
|||||||
else
|
else
|
||||||
media.audios.push({
|
media.audios.push({
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
autoplay: sc.settings.autoplayAudio,
|
autoplay: false,
|
||||||
loop: sc.settings.loopAudio,
|
loop: false,
|
||||||
|
volume: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1197,7 +1233,7 @@ export class ZipProjectStore {
|
|||||||
const safeOrig = sanitizeFileName(orig);
|
const safeOrig = sanitizeFileName(orig);
|
||||||
const relPath = `assets/${id}_${safeOrig}`;
|
const relPath = `assets/${id}_${safeOrig}`;
|
||||||
const abs = path.join(open.cacheDir, relPath);
|
const abs = path.join(open.cacheDir, relPath);
|
||||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
await ensureParentDir(abs);
|
||||||
await fs.copyFile(filePath, abs);
|
await fs.copyFile(filePath, abs);
|
||||||
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
|
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
|
||||||
}
|
}
|
||||||
@@ -1212,7 +1248,7 @@ export class ZipProjectStore {
|
|||||||
for (const asset of staged) {
|
for (const asset of staged) {
|
||||||
assets[asset.id] = asset;
|
assets[asset.id] = asset;
|
||||||
if (asset.type !== 'audio') continue;
|
if (asset.type !== 'audio') continue;
|
||||||
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false });
|
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false, volume: 1 });
|
||||||
}
|
}
|
||||||
return { ...p, assets, campaignAudios };
|
return { ...p, assets, campaignAudios };
|
||||||
});
|
});
|
||||||
@@ -1225,7 +1261,10 @@ export class ZipProjectStore {
|
|||||||
async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
|
async setCampaignAudios(audios: Project['campaignAudios']): Promise<Project> {
|
||||||
const open = this.openProject;
|
const open = this.openProject;
|
||||||
if (!open) throw new Error('No open project');
|
if (!open) throw new Error('No open project');
|
||||||
await this.updateProject((p) => ({ ...p, campaignAudios: Array.isArray(audios) ? audios : [] }));
|
const normalized = (Array.isArray(audios) ? audios : [])
|
||||||
|
.filter((a) => a && typeof a === 'object' && a.assetId)
|
||||||
|
.map((a) => normalizeSceneAudioRef(a));
|
||||||
|
await this.updateProject((p) => ({ ...p, campaignAudios: normalized }));
|
||||||
const latest = this.getOpenProject();
|
const latest = this.getOpenProject();
|
||||||
if (!latest) throw new Error('No open project');
|
if (!latest) throw new Error('No open project');
|
||||||
return latest;
|
return latest;
|
||||||
@@ -1284,7 +1323,7 @@ export class ZipProjectStore {
|
|||||||
const safeOrig = sanitizeFileName(orig);
|
const safeOrig = sanitizeFileName(orig);
|
||||||
const relPath = `assets/${id}_${safeOrig}`;
|
const relPath = `assets/${id}_${safeOrig}`;
|
||||||
const abs = path.join(open.cacheDir, relPath);
|
const abs = path.join(open.cacheDir, relPath);
|
||||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
await ensureParentDir(abs);
|
||||||
await fs.writeFile(abs, buf);
|
await fs.writeFile(abs, buf);
|
||||||
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
||||||
nextAssetId = id;
|
nextAssetId = id;
|
||||||
@@ -1395,6 +1434,70 @@ export class ZipProjectStore {
|
|||||||
return latest;
|
return latest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addGridTemplate(input: {
|
||||||
|
name: string;
|
||||||
|
type: SceneGridTemplate['type'];
|
||||||
|
sizeN: number;
|
||||||
|
color: string;
|
||||||
|
}): Promise<Project> {
|
||||||
|
if (!this.openProject) throw new Error('No open project');
|
||||||
|
const name = input.name.trim();
|
||||||
|
if (!name) throw new Error('Grid template name is required');
|
||||||
|
const style = sceneGridTemplateStyleFromGrid(input);
|
||||||
|
await this.updateProject((p) => {
|
||||||
|
const list = normalizeSceneGridTemplates(p.gridTemplates);
|
||||||
|
if (list.length >= MAX_SCENE_GRID_TEMPLATES) {
|
||||||
|
throw new Error('Grid template limit reached');
|
||||||
|
}
|
||||||
|
const template: SceneGridTemplate = {
|
||||||
|
id: asGridTemplateId(`gt_${this.randomId()}`),
|
||||||
|
name,
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
return { ...p, gridTemplates: [...list, template] };
|
||||||
|
});
|
||||||
|
const latest = this.getOpenProject();
|
||||||
|
if (!latest) throw new Error('No open project');
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameGridTemplate(templateId: GridTemplateId, name: string): Promise<Project> {
|
||||||
|
if (!this.openProject) throw new Error('No open project');
|
||||||
|
const nextName = name.trim();
|
||||||
|
if (!nextName) throw new Error('Grid template name is required');
|
||||||
|
await this.updateProject((p) => {
|
||||||
|
const list = normalizeSceneGridTemplates(p.gridTemplates);
|
||||||
|
const idx = list.findIndex((t) => t.id === templateId);
|
||||||
|
if (idx < 0) throw new Error('Grid template not found');
|
||||||
|
const next = [...list];
|
||||||
|
next[idx] = { ...next[idx]!, name: nextName };
|
||||||
|
return { ...p, gridTemplates: next };
|
||||||
|
});
|
||||||
|
const latest = this.getOpenProject();
|
||||||
|
if (!latest) throw new Error('No open project');
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGridTemplate(templateId: GridTemplateId): Promise<Project> {
|
||||||
|
if (!this.openProject) throw new Error('No open project');
|
||||||
|
await this.updateProject((p) => ({
|
||||||
|
...p,
|
||||||
|
gridTemplates: normalizeSceneGridTemplates(p.gridTemplates).filter((t) => t.id !== templateId),
|
||||||
|
}));
|
||||||
|
const latest = this.getOpenProject();
|
||||||
|
if (!latest) throw new Error('No open project');
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyGridTemplate(sceneId: SceneId, templateId: GridTemplateId): Promise<Scene> {
|
||||||
|
if (!this.openProject) throw new Error('No open project');
|
||||||
|
const open = this.openProject;
|
||||||
|
const template = normalizeSceneGridTemplates(open.project.gridTemplates).find((t) => t.id === templateId);
|
||||||
|
if (!template) throw new Error('Grid template not found');
|
||||||
|
if (!open.project.scenes[sceneId]) throw new Error('Scene not found');
|
||||||
|
return this.updateScene(sceneId, { grid: sceneGridFromTemplate(template) });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создаёт или обновляет НПС.
|
* Создаёт или обновляет НПС.
|
||||||
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
|
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
|
||||||
@@ -1455,7 +1558,7 @@ export class ZipProjectStore {
|
|||||||
const safeOrig = sanitizeFileName(orig);
|
const safeOrig = sanitizeFileName(orig);
|
||||||
const relPath = `assets/${id}_${safeOrig}`;
|
const relPath = `assets/${id}_${safeOrig}`;
|
||||||
const abs = path.join(open.cacheDir, relPath);
|
const abs = path.join(open.cacheDir, relPath);
|
||||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
await ensureParentDir(abs);
|
||||||
await fs.writeFile(abs, buf);
|
await fs.writeFile(abs, buf);
|
||||||
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
||||||
nextAssetId = id;
|
nextAssetId = id;
|
||||||
@@ -1942,7 +2045,7 @@ export class ZipProjectStore {
|
|||||||
|
|
||||||
private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> {
|
private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> {
|
||||||
const tmpPath = `${zipPath}.tmp`;
|
const tmpPath = `${zipPath}.tmp`;
|
||||||
await fs.mkdir(path.dirname(zipPath), { recursive: true });
|
await ensureParentDir(zipPath);
|
||||||
await zipDir(cacheDir, tmpPath);
|
await zipDir(cacheDir, tmpPath);
|
||||||
const st = await fs.stat(tmpPath).catch(() => null);
|
const st = await fs.stat(tmpPath).catch(() => null);
|
||||||
if (!st?.isFile() || st.size < 22) {
|
if (!st?.isFile() || st.size < 22) {
|
||||||
@@ -2027,7 +2130,8 @@ export class ZipProjectStore {
|
|||||||
async importProjectFromFoundry(
|
async importProjectFromFoundry(
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
onProgress?: (p: FoundryImportProgress) => void,
|
onProgress?: (p: FoundryImportProgress) => void,
|
||||||
): Promise<Project> {
|
importPlaylists?: (lists: FoundryPendingAppPlaylist[]) => Promise<void>,
|
||||||
|
): Promise<{ project: Project; pendingAppPlaylists: FoundryPendingAppPlaylist[] }> {
|
||||||
await this.ensureRoots();
|
await this.ensureRoots();
|
||||||
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
|
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
|
||||||
try {
|
try {
|
||||||
@@ -2037,7 +2141,7 @@ export class ZipProjectStore {
|
|||||||
await fs.rm(cacheDir, { recursive: true, force: true });
|
await fs.rm(cacheDir, { recursive: true, force: true });
|
||||||
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
|
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
|
||||||
|
|
||||||
const { project } = await buildProjectFromFoundryDocuments(
|
const { project, pendingAppPlaylists } = await buildProjectFromFoundryDocuments(
|
||||||
loaded.manifest,
|
loaded.manifest,
|
||||||
loaded.docs,
|
loaded.docs,
|
||||||
cacheDir,
|
cacheDir,
|
||||||
@@ -2057,8 +2161,12 @@ export class ZipProjectStore {
|
|||||||
await this.writeCacheProject(cacheDir, project);
|
await this.writeCacheProject(cacheDir, project);
|
||||||
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
|
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
|
||||||
await this.enqueuePack(cacheDir, zipPath);
|
await this.enqueuePack(cacheDir, zipPath);
|
||||||
|
// Архив распакован во временную папку. Плейлисты читают её по absPath — копировать до cleanup.
|
||||||
|
if (importPlaylists && pendingAppPlaylists.length > 0) {
|
||||||
|
await importPlaylists(pendingAppPlaylists);
|
||||||
|
}
|
||||||
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
|
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
|
||||||
return this.openProject.project;
|
return { project: this.openProject.project, pendingAppPlaylists };
|
||||||
} finally {
|
} finally {
|
||||||
await loaded.cleanup();
|
await loaded.cleanup();
|
||||||
}
|
}
|
||||||
@@ -2082,7 +2190,7 @@ export class ZipProjectStore {
|
|||||||
}
|
}
|
||||||
const src = path.join(getProjectsRootDir(), entry.fileName);
|
const src = path.join(getProjectsRootDir(), entry.fileName);
|
||||||
const dest = path.resolve(destinationPath);
|
const dest = path.resolve(destinationPath);
|
||||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
await ensureParentDir(dest);
|
||||||
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
|
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
|
||||||
await copyFileWithProgress(src, dest, (pct) => {
|
await copyFileWithProgress(src, dest, (pct) => {
|
||||||
if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
|
if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
|
||||||
@@ -2172,6 +2280,7 @@ export class ZipProjectStore {
|
|||||||
labels: StorylineLabels,
|
labels: StorylineLabels,
|
||||||
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
||||||
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
|
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
|
||||||
|
packAppPlaylists?: (exportRoot: string) => Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
|
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
|
||||||
await this.ensureRoots();
|
await this.ensureRoots();
|
||||||
@@ -2194,7 +2303,7 @@ export class ZipProjectStore {
|
|||||||
if (!a) continue;
|
if (!a) continue;
|
||||||
const srcAbs = path.join(snap.cacheDir, a.relPath);
|
const srcAbs = path.join(snap.cacheDir, a.relPath);
|
||||||
const destAbs = path.join(exportCache, a.relPath);
|
const destAbs = path.join(exportCache, a.relPath);
|
||||||
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
await ensureParentDir(destAbs);
|
||||||
await fs.copyFile(srcAbs, destAbs);
|
await fs.copyFile(srcAbs, destAbs);
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
|
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
|
||||||
@@ -2205,6 +2314,9 @@ export class ZipProjectStore {
|
|||||||
const tokenIds = collectTokenIdsFromProject(partial);
|
const tokenIds = collectTokenIdsFromProject(partial);
|
||||||
await packAppTokens(tokenIds, exportCache);
|
await packAppTokens(tokenIds, exportCache);
|
||||||
}
|
}
|
||||||
|
if (packAppPlaylists) {
|
||||||
|
await packAppPlaylists(exportCache);
|
||||||
|
}
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const toWrite: Project = {
|
const toWrite: Project = {
|
||||||
...partial,
|
...partial,
|
||||||
@@ -2328,7 +2440,7 @@ export class ZipProjectStore {
|
|||||||
if (!srcAsset || !tgtMeta) continue;
|
if (!srcAsset || !tgtMeta) continue;
|
||||||
const srcAbs = path.join(sourceCache, srcAsset.relPath);
|
const srcAbs = path.join(sourceCache, srcAsset.relPath);
|
||||||
const destAbs = path.join(targetCache, tgtMeta.relPath);
|
const destAbs = path.join(targetCache, tgtMeta.relPath);
|
||||||
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
await ensureParentDir(destAbs);
|
||||||
await fs.copyFile(srcAbs, destAbs);
|
await fs.copyFile(srcAbs, destAbs);
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
|
const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
|
||||||
@@ -2518,6 +2630,9 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
const previewThumbAssetId =
|
const previewThumbAssetId =
|
||||||
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
|
(s as unknown as { previewThumbAssetId?: AssetId | null }).previewThumbAssetId ?? null;
|
||||||
const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene);
|
const darkenScene = Boolean((s as unknown as { darkenScene?: boolean }).darkenScene);
|
||||||
|
const darknessStrokes = normalizeSceneDarknessStrokes(
|
||||||
|
(s as unknown as { darknessStrokes?: unknown }).darknessStrokes,
|
||||||
|
);
|
||||||
const rawTraps = (s as unknown as { traps?: unknown[] }).traps;
|
const rawTraps = (s as unknown as { traps?: unknown[] }).traps;
|
||||||
const traps = (Array.isArray(rawTraps) ? rawTraps : [])
|
const traps = (Array.isArray(rawTraps) ? rawTraps : [])
|
||||||
.map((t) => normalizeSceneTrap(t))
|
.map((t) => normalizeSceneTrap(t))
|
||||||
@@ -2536,20 +2651,26 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
const audios = rawAudios
|
const audios = rawAudios
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
if (typeof a === 'string') {
|
if (typeof a === 'string') {
|
||||||
return {
|
return normalizeSceneAudioRef({
|
||||||
assetId: a as AssetId,
|
assetId: a as AssetId,
|
||||||
autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio),
|
autoplay: Boolean((s.settings as { autoplayAudio?: boolean } | undefined)?.autoplayAudio),
|
||||||
loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio),
|
loop: Boolean((s.settings as { loopAudio?: boolean } | undefined)?.loopAudio),
|
||||||
};
|
volume: 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (a && typeof a === 'object') {
|
if (a && typeof a === 'object') {
|
||||||
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
|
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown };
|
||||||
if (!obj.assetId) return null;
|
if (!obj.assetId) return null;
|
||||||
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
|
return normalizeSceneAudioRef({
|
||||||
|
assetId: obj.assetId,
|
||||||
|
autoplay: Boolean(obj.autoplay),
|
||||||
|
loop: Boolean(obj.loop),
|
||||||
|
volume: obj.volume,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
|
.filter((x): x is SceneAudioRef => Boolean(x));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
@@ -2559,6 +2680,7 @@ function normalizeScene(s: Scene): Scene {
|
|||||||
previewVideoAutostart,
|
previewVideoAutostart,
|
||||||
previewRotationDeg,
|
previewRotationDeg,
|
||||||
darkenScene,
|
darkenScene,
|
||||||
|
darknessStrokes,
|
||||||
traps,
|
traps,
|
||||||
tokens,
|
tokens,
|
||||||
npcTokens,
|
npcTokens,
|
||||||
@@ -2594,15 +2716,20 @@ function normalizeProject(p: Project): Project {
|
|||||||
const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
|
const rawCampaignAudios = (p as unknown as { campaignAudios?: unknown[] }).campaignAudios;
|
||||||
const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
|
const campaignAudios = (Array.isArray(rawCampaignAudios) ? rawCampaignAudios : [])
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
if (typeof a === 'string') return { assetId: a as AssetId, autoplay: false, loop: false };
|
if (typeof a === 'string') return normalizeSceneAudioRef({ assetId: a as AssetId, autoplay: false, loop: false, volume: 1 });
|
||||||
if (a && typeof a === 'object') {
|
if (a && typeof a === 'object') {
|
||||||
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean };
|
const obj = a as { assetId?: AssetId; autoplay?: boolean; loop?: boolean; volume?: unknown };
|
||||||
if (!obj.assetId) return null;
|
if (!obj.assetId) return null;
|
||||||
return { assetId: obj.assetId, autoplay: Boolean(obj.autoplay), loop: Boolean(obj.loop) };
|
return normalizeSceneAudioRef({
|
||||||
|
assetId: obj.assetId,
|
||||||
|
autoplay: Boolean(obj.autoplay),
|
||||||
|
loop: Boolean(obj.loop),
|
||||||
|
volume: obj.volume,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
|
.filter((x): x is SceneAudioRef => Boolean(x));
|
||||||
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
|
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
|
||||||
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
|
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
@@ -2736,6 +2863,7 @@ function normalizeProject(p: Project): Project {
|
|||||||
scenes: scenesPruned,
|
scenes: scenesPruned,
|
||||||
campaignAudios,
|
campaignAudios,
|
||||||
materials,
|
materials,
|
||||||
|
gridTemplates: normalizeSceneGridTemplates((p as unknown as { gridTemplates?: unknown }).gridTemplates),
|
||||||
npcs,
|
npcs,
|
||||||
npcGroups,
|
npcGroups,
|
||||||
npcRelations,
|
npcRelations,
|
||||||
@@ -2782,7 +2910,7 @@ async function copyFileWithProgress(
|
|||||||
onPercent(100);
|
onPercent(100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
await ensureParentDir(dest);
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let done = 0;
|
let done = 0;
|
||||||
const rs = fssync.createReadStream(src);
|
const rs = fssync.createReadStream(src);
|
||||||
@@ -2915,7 +3043,7 @@ async function zipDir(srcDir: string, outZipPath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel));
|
zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel));
|
||||||
}
|
}
|
||||||
await fs.mkdir(path.dirname(outZipPath), { recursive: true });
|
await ensureParentDir(outZipPath);
|
||||||
const out = fssync.createWriteStream(outZipPath);
|
const out = fssync.createWriteStream(outZipPath);
|
||||||
const done = new Promise<void>((resolve, reject) => {
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
out.on('close', resolve);
|
out.on('close', resolve);
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import fs from 'node:fs/promises';
|
|||||||
|
|
||||||
import { session } from 'electron';
|
import { session } from 'electron';
|
||||||
|
|
||||||
import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids';
|
import { asAssetId, asPlayerId, asPlaylistAudioId, asTokenId } from '../../shared/types/ids';
|
||||||
import type { PlayersStore } from '../players/playersStore';
|
import type { PlayersStore } from '../players/playersStore';
|
||||||
|
import type { PlaylistsStore } from '../playlists/playlistsStore';
|
||||||
import type { ZipProjectStore } from '../project/zipStore';
|
import type { ZipProjectStore } from '../project/zipStore';
|
||||||
import type { TokensStore } from '../tokens/tokensStore';
|
import type { TokensStore } from '../tokens/tokensStore';
|
||||||
|
|
||||||
@@ -76,12 +77,13 @@ async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`.
|
* Обслуживает `dnd://asset?...`, `dnd://token?...`, `dnd://player?...` и `dnd://playlist-audio?...`.
|
||||||
*/
|
*/
|
||||||
export function registerDndAssetProtocol(
|
export function registerDndAssetProtocol(
|
||||||
projectStore: ZipProjectStore,
|
projectStore: ZipProjectStore,
|
||||||
tokensStore: TokensStore,
|
tokensStore: TokensStore,
|
||||||
playersStore: PlayersStore | null,
|
playersStore: PlayersStore | null,
|
||||||
|
playlistsStore: PlaylistsStore | null = null,
|
||||||
): void {
|
): void {
|
||||||
session.defaultSession.protocol.handle('dnd', async (request) => {
|
session.defaultSession.protocol.handle('dnd', async (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -96,6 +98,8 @@ export function registerDndAssetProtocol(
|
|||||||
info = tokensStore.getImageReadInfo(asTokenId(id));
|
info = tokensStore.getImageReadInfo(asTokenId(id));
|
||||||
} else if (url.hostname === 'player') {
|
} else if (url.hostname === 'player') {
|
||||||
info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null;
|
info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null;
|
||||||
|
} else if (url.hostname === 'playlist-audio') {
|
||||||
|
info = playlistsStore?.getAudioReadInfo(asPlaylistAudioId(id)) ?? null;
|
||||||
}
|
}
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, { status: 404 });
|
||||||
|
|||||||
@@ -131,4 +131,29 @@ export class SceneTrapsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exportByCard(): Record<string, Record<string, SceneTrapRuntime>> {
|
||||||
|
if (this.currentKey !== null) {
|
||||||
|
this.cache.set(this.currentKey, cloneById(this.state.byId));
|
||||||
|
}
|
||||||
|
const out: Record<string, Record<string, SceneTrapRuntime>> = {};
|
||||||
|
for (const [key, byId] of this.cache) {
|
||||||
|
const kept: Record<string, SceneTrapRuntime> = {};
|
||||||
|
for (const [id, runtime] of Object.entries(byId)) {
|
||||||
|
if (runtime.status === 'inactive' && !runtime.revealed) continue;
|
||||||
|
kept[id] = { ...runtime };
|
||||||
|
}
|
||||||
|
if (Object.keys(kept).length > 0) out[key] = kept;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
importByCard(byCard: Record<string, Record<string, SceneTrapRuntime>>): void {
|
||||||
|
this.cache.clear();
|
||||||
|
this.currentKey = null;
|
||||||
|
this.state = emptyState();
|
||||||
|
for (const [key, byId] of Object.entries(byCard)) {
|
||||||
|
this.cache.set(key, cloneById(byId));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { TimerSessionStore } from './timerSessionStore';
|
||||||
|
import {
|
||||||
|
clampSessionTimerLayout,
|
||||||
|
formatSessionTimerMs,
|
||||||
|
getSessionTimerRemainingMs,
|
||||||
|
parseSessionTimerDurationInput,
|
||||||
|
} from '../../shared/types/sessionTimer';
|
||||||
|
|
||||||
|
void test('getSessionTimerRemainingMs: paused uses remainingMs', () => {
|
||||||
|
const store = new TimerSessionStore();
|
||||||
|
const s = store.getState();
|
||||||
|
assert.equal(getSessionTimerRemainingMs(s, 1_000_000), s.remainingMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('start pause start preserves remaining', () => {
|
||||||
|
const store = new TimerSessionStore();
|
||||||
|
const t0 = 1_000_000;
|
||||||
|
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
|
||||||
|
store.dispatch({ kind: 'start' }, t0);
|
||||||
|
store.dispatch({ kind: 'pause' }, t0 + 10_000);
|
||||||
|
const afterPause = store.getState();
|
||||||
|
assert.equal(afterPause.running, false);
|
||||||
|
assert.equal(afterPause.remainingMs, 50_000);
|
||||||
|
store.dispatch({ kind: 'start' }, t0 + 20_000);
|
||||||
|
assert.equal(getSessionTimerRemainingMs(store.getState(), t0 + 25_000), 45_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('reset restores duration', () => {
|
||||||
|
const store = new TimerSessionStore();
|
||||||
|
store.dispatch({ kind: 'setDuration', durationMs: 90_000 });
|
||||||
|
store.dispatch({ kind: 'start' }, 0);
|
||||||
|
store.dispatch({ kind: 'pause' }, 30_000);
|
||||||
|
store.dispatch({ kind: 'reset' });
|
||||||
|
assert.equal(store.getState().remainingMs, 90_000);
|
||||||
|
assert.equal(store.getState().running, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('setVisibleToPlayers and layout.set', () => {
|
||||||
|
const store = new TimerSessionStore();
|
||||||
|
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
|
||||||
|
assert.equal(store.getState().visibleToPlayers, true);
|
||||||
|
store.dispatch({
|
||||||
|
kind: 'layout.set',
|
||||||
|
layout: { cx: 0.2, cy: 0.3, scale: 2, rotationDeg: 45 },
|
||||||
|
});
|
||||||
|
assert.equal(store.getState().layout.cx, 0.2);
|
||||||
|
assert.equal(store.getState().layout.scale, 2);
|
||||||
|
assert.equal(store.getState().layout.rotationDeg, 45);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('settleIfExpired stops at zero', () => {
|
||||||
|
const store = new TimerSessionStore();
|
||||||
|
store.dispatch({ kind: 'setDuration', durationMs: 5_000 }, 0);
|
||||||
|
store.dispatch({ kind: 'start' }, 0);
|
||||||
|
store.settleIfExpired(10_000);
|
||||||
|
assert.equal(store.getState().running, false);
|
||||||
|
assert.equal(store.getState().remainingMs, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('clampSessionTimerLayout / format / parse', () => {
|
||||||
|
const clamped = clampSessionTimerLayout({ cx: 9, cy: -9, scale: 100, rotationDeg: 370 });
|
||||||
|
assert.ok(clamped.cx <= 1.2);
|
||||||
|
assert.ok(clamped.cy >= -0.2);
|
||||||
|
assert.ok(clamped.scale <= 8);
|
||||||
|
assert.equal(clamped.rotationDeg, 10);
|
||||||
|
assert.equal(formatSessionTimerMs(65_000), '01:05');
|
||||||
|
assert.equal(formatSessionTimerMs(3_661_000), '1:01:01');
|
||||||
|
assert.equal(parseSessionTimerDurationInput('5:00'), 300_000);
|
||||||
|
assert.equal(parseSessionTimerDurationInput('1:02:03'), 3723_000);
|
||||||
|
assert.equal(parseSessionTimerDurationInput('bad'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('clear restores defaults', () => {
|
||||||
|
const store = new TimerSessionStore();
|
||||||
|
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
|
||||||
|
store.dispatch({ kind: 'start' }, 0);
|
||||||
|
store.clear();
|
||||||
|
assert.equal(store.getState().visibleToPlayers, false);
|
||||||
|
assert.equal(store.getState().running, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('dismiss on window close: pause + hide from players', () => {
|
||||||
|
const store = new TimerSessionStore();
|
||||||
|
const t0 = 1_000_000;
|
||||||
|
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
|
||||||
|
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
|
||||||
|
store.dispatch({ kind: 'start' }, t0);
|
||||||
|
store.dispatch({ kind: 'pause' }, t0 + 5_000);
|
||||||
|
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||||
|
assert.equal(store.getState().running, false);
|
||||||
|
assert.equal(store.getState().visibleToPlayers, false);
|
||||||
|
assert.equal(store.getState().remainingMs, 55_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
clampSessionTimerDurationMs,
|
||||||
|
clampSessionTimerLayout,
|
||||||
|
createInitialSessionTimerState,
|
||||||
|
getSessionTimerRemainingMs,
|
||||||
|
type SessionTimerEvent,
|
||||||
|
type SessionTimerState,
|
||||||
|
} from '../../shared/types/sessionTimer';
|
||||||
|
|
||||||
|
export class TimerSessionStore {
|
||||||
|
private state: SessionTimerState = createInitialSessionTimerState();
|
||||||
|
|
||||||
|
getState(): SessionTimerState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): SessionTimerState {
|
||||||
|
this.state = createInitialSessionTimerState();
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(event: SessionTimerEvent, now = Date.now()): SessionTimerState {
|
||||||
|
switch (event.kind) {
|
||||||
|
case 'setDuration': {
|
||||||
|
const durationMs = clampSessionTimerDurationMs(event.durationMs);
|
||||||
|
if (this.state.running) {
|
||||||
|
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
durationMs,
|
||||||
|
remainingMs: Math.min(remaining, durationMs),
|
||||||
|
endsAt: now + Math.min(remaining, durationMs),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
durationMs,
|
||||||
|
remainingMs: durationMs,
|
||||||
|
endsAt: null,
|
||||||
|
running: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'start': {
|
||||||
|
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
// Restart from full duration if already at zero.
|
||||||
|
const durationMs = this.state.durationMs;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
remainingMs: durationMs,
|
||||||
|
running: true,
|
||||||
|
endsAt: now + durationMs,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
running: true,
|
||||||
|
endsAt: now + remaining,
|
||||||
|
remainingMs: remaining,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'pause': {
|
||||||
|
if (!this.state.running) return this.state;
|
||||||
|
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
running: false,
|
||||||
|
endsAt: null,
|
||||||
|
remainingMs: remaining,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'reset': {
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
running: false,
|
||||||
|
endsAt: null,
|
||||||
|
remainingMs: this.state.durationMs,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'setVisibleToPlayers': {
|
||||||
|
if (this.state.visibleToPlayers === event.visible) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
visibleToPlayers: event.visible,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'layout.set': {
|
||||||
|
const layout = clampSessionTimerLayout(event.layout);
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
layout,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = event;
|
||||||
|
return _exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Когда running и время вышло — зафиксировать 00:00 (вызывается из UI/main по желанию). */
|
||||||
|
settleIfExpired(now = Date.now()): SessionTimerState {
|
||||||
|
if (!this.state.running) return this.state;
|
||||||
|
const remaining = getSessionTimerRemainingMs(this.state, now);
|
||||||
|
if (remaining > 0) return this.state;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
running: false,
|
||||||
|
endsAt: null,
|
||||||
|
remainingMs: 0,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Слепок: таймер на паузе, без абсолютного момента конца. */
|
||||||
|
loadPaused(next: Pick<SessionTimerState, 'durationMs' | 'remainingMs' | 'visibleToPlayers' | 'layout'>): SessionTimerState {
|
||||||
|
const durationMs = clampSessionTimerDurationMs(next.durationMs);
|
||||||
|
const remainingMs = Math.max(0, Math.min(durationMs, Math.round(next.remainingMs)));
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
durationMs,
|
||||||
|
remainingMs,
|
||||||
|
running: false,
|
||||||
|
endsAt: null,
|
||||||
|
visibleToPlayers: Boolean(next.visibleToPlayers),
|
||||||
|
layout: clampSessionTimerLayout(next.layout),
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,4 +112,12 @@ export class SceneTokensSessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load(byPlacementId: SceneTokensSessionState['byPlacementId']): SceneTokensSessionState {
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
byPlacementId: { ...byPlacementId },
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,19 @@ export class TokenPathSessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load(
|
||||||
|
playback: TokenPathSessionState['playback'],
|
||||||
|
presentationVisible: TokenPathSessionState['presentationVisible'],
|
||||||
|
): TokenPathSessionState {
|
||||||
|
this.state = {
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
playback: { ...playback },
|
||||||
|
presentationVisible: { ...presentationVisible },
|
||||||
|
serverNowMs: Date.now(),
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { TokenPathTargetRef };
|
export type { TokenPathTargetRef };
|
||||||
|
|||||||
@@ -52,6 +52,53 @@ void test('createWindows: окно материалов закрывается
|
|||||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
|
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('createWindows: Escape запрашивает confirm на пульте', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.ok(src.includes('export function requestCloseMultiWindowConfirm'));
|
||||||
|
assert.ok(src.includes('closeMultiWindowConfirmRequested'));
|
||||||
|
assert.match(src, /requestCloseMultiWindowConfirm[\s\S]*ctrl\.focus\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('createWindows: окно плейлистов — minWidth 640', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.ok(src.includes('PLAYLISTS_WINDOW_MIN_WIDTH = 640'));
|
||||||
|
assert.match(src, /kind === 'playlists'[\s\S]*?minWidth:\s*PLAYLISTS_WINDOW_MIN_WIDTH/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('createWindows: окно таймера закрывается с multi-window', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.ok(src.includes('openTimerWindow'));
|
||||||
|
assert.ok(src.includes('closeTimerWindow'));
|
||||||
|
assert.ok(src.includes("createWindow('timer'"));
|
||||||
|
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeTimerWindow/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('createWindows: окно инициативы закрывается с multi-window', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.ok(src.includes('openInitiativeWindow'));
|
||||||
|
assert.ok(src.includes('closeInitiativeWindow'));
|
||||||
|
assert.ok(src.includes("createWindow('initiative'"));
|
||||||
|
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeInitiativeWindow/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('createWindows: окно кубиков закрывается с multi-window', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.ok(src.includes('openDiceWindow'));
|
||||||
|
assert.ok(src.includes('closeDiceWindow'));
|
||||||
|
assert.ok(src.includes("createWindow('dice'"));
|
||||||
|
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeDiceWindow/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('createWindows: окно инструкции из редактора, не закрывается с сессией', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.ok(src.includes('openInstructionsWindow'));
|
||||||
|
assert.ok(src.includes("createWindow('instructions'"));
|
||||||
|
assert.ok(src.includes("return 'instructions.html'"));
|
||||||
|
const aux = /function closePlaySessionAuxiliaryWindows\(\): void \{([\s\S]*?)\n\}/.exec(src);
|
||||||
|
assert.ok(aux, 'closePlaySessionAuxiliaryWindows');
|
||||||
|
assert.doesNotMatch(aux[1] ?? '', /instructions/i);
|
||||||
|
});
|
||||||
|
|
||||||
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
||||||
const src = readCreateWindows();
|
const src = readCreateWindows();
|
||||||
assert.ok(src.includes('openNpcsWindow'));
|
assert.ok(src.includes('openNpcsWindow'));
|
||||||
@@ -65,10 +112,11 @@ void test('createWindows: окно НПС закрывается с multi-window
|
|||||||
assert.ok(src.includes('will-attach-webview'));
|
assert.ok(src.includes('will-attach-webview'));
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
|
void test('createWindows: закрытие пульта и презентации спрашивает confirm', () => {
|
||||||
const src = readCreateWindows();
|
const src = readCreateWindows();
|
||||||
assert.match(src, /kind === 'control'/);
|
assert.match(src, /kind === 'control'[\s\S]*?e\.preventDefault\(\)[\s\S]*?requestCloseMultiWindowConfirm/);
|
||||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/);
|
assert.match(src, /kind === 'presentation'[\s\S]*?e\.preventDefault\(\)[\s\S]*?requestCloseMultiWindowConfirm/);
|
||||||
|
assert.match(src, /export function closeMultiWindow[\s\S]*closePlaySessionAuxiliaryWindows/);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
||||||
@@ -89,3 +137,10 @@ void test('createWindows: логи окон не валят main через EPIP
|
|||||||
assert.ok(src.includes('errorCode === -3'));
|
assert.ok(src.includes('errorCode === -3'));
|
||||||
assert.ok(src.includes('isMainFrame'));
|
assert.ok(src.includes('isMainFrame'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('createWindows: presentation/control не троттлят RAF в фоне (macOS paths)', () => {
|
||||||
|
const src = readCreateWindows();
|
||||||
|
assert.match(src, /kind === 'presentation' \|\| kind === 'control'/);
|
||||||
|
assert.match(src, /backgroundThrottling:\s*false/);
|
||||||
|
assert.match(src, /setBackgroundThrottling\(false\)/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { app, BrowserWindow, screen } from 'electron';
|
import { app, BrowserWindow, screen, shell } from 'electron';
|
||||||
|
|
||||||
|
import type { PresentationSettings } from '../../shared/types/presentationSettings';
|
||||||
|
import { readPresentationSettings } from '../preferences/appPreferences';
|
||||||
|
|
||||||
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
|
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
|
||||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
import { resolvePresentationDisplay } from './presentationDisplay';
|
||||||
|
|
||||||
import { safeConsoleError } from '../safeConsole';
|
import { safeConsoleError } from '../safeConsole';
|
||||||
|
|
||||||
@@ -16,10 +20,15 @@ export type WindowKind =
|
|||||||
| 'control'
|
| 'control'
|
||||||
| 'sceneDescription'
|
| 'sceneDescription'
|
||||||
| 'materials'
|
| 'materials'
|
||||||
|
| 'playlists'
|
||||||
|
| 'timer'
|
||||||
|
| 'initiative'
|
||||||
|
| 'dice'
|
||||||
| 'npcsEditor'
|
| 'npcsEditor'
|
||||||
| 'sceneEditor'
|
| 'sceneEditor'
|
||||||
| 'tokenPathEditor'
|
| 'tokenPathEditor'
|
||||||
| 'npcs';
|
| 'npcs'
|
||||||
|
| 'instructions';
|
||||||
|
|
||||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||||
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||||||
@@ -45,11 +54,47 @@ let appQuitting = false;
|
|||||||
/** Защита от каскада close(control) ↔ close(presentation). */
|
/** Защита от каскада close(control) ↔ close(presentation). */
|
||||||
let closingPlaySession = false;
|
let closingPlaySession = false;
|
||||||
let pendingSceneDescriptionHtml = '';
|
let pendingSceneDescriptionHtml = '';
|
||||||
|
let pendingInstructionsSection = 'overview';
|
||||||
|
|
||||||
/** Окно материалов — только колонка списка. */
|
/** Окно материалов — только колонка списка. */
|
||||||
const MATERIALS_WINDOW_WIDTH = 300;
|
const MATERIALS_WINDOW_WIDTH = 300;
|
||||||
const MATERIALS_WINDOW_HEIGHT = 720;
|
const MATERIALS_WINDOW_HEIGHT = 720;
|
||||||
|
|
||||||
|
/** Окно плейлистов на пульте. */
|
||||||
|
const PLAYLISTS_WINDOW_WIDTH = 720;
|
||||||
|
const PLAYLISTS_WINDOW_MIN_WIDTH = 640;
|
||||||
|
const PLAYLISTS_WINDOW_HEIGHT = 800;
|
||||||
|
|
||||||
|
/** Компактное окно таймера сессии. */
|
||||||
|
const TIMER_WINDOW_WIDTH = 360;
|
||||||
|
const TIMER_WINDOW_HEIGHT = 420;
|
||||||
|
|
||||||
|
/** Колбэк при закрытии окна таймера (Esc / крестик / programmatic). */
|
||||||
|
let timerWindowClosedListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function setTimerWindowClosedListener(listener: (() => void) | null): void {
|
||||||
|
timerWindowClosedListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Окно трекера инициативы. */
|
||||||
|
const INITIATIVE_WINDOW_WIDTH = 920;
|
||||||
|
const INITIATIVE_WINDOW_HEIGHT = 420;
|
||||||
|
|
||||||
|
/** Компактное окно броска кубиков. */
|
||||||
|
const DICE_WINDOW_WIDTH = 380;
|
||||||
|
const DICE_WINDOW_HEIGHT = 460;
|
||||||
|
|
||||||
|
/** Окно инструкции из редактора (не модалка). */
|
||||||
|
const INSTRUCTIONS_WINDOW_WIDTH = 960;
|
||||||
|
const INSTRUCTIONS_WINDOW_HEIGHT = 720;
|
||||||
|
|
||||||
|
/** Колбэк при закрытии окна кубиков (Esc / крестик / programmatic). */
|
||||||
|
let diceWindowClosedListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function setDiceWindowClosedListener(listener: (() => void) | null): void {
|
||||||
|
diceWindowClosedListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||||
@@ -109,6 +154,15 @@ function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendInstructionsSection(win: BrowserWindow, section: string): void {
|
||||||
|
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||||
|
try {
|
||||||
|
win.webContents.send(ipcChannels.windows.instructionsSection, { section });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
|
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
|
||||||
export function markAppQuitting(): void {
|
export function markAppQuitting(): void {
|
||||||
appQuitting = true;
|
appQuitting = true;
|
||||||
@@ -159,23 +213,13 @@ function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
|||||||
function closePlaySessionAuxiliaryWindows(): void {
|
function closePlaySessionAuxiliaryWindows(): void {
|
||||||
closeSceneDescriptionWindow();
|
closeSceneDescriptionWindow();
|
||||||
closeMaterialsWindow();
|
closeMaterialsWindow();
|
||||||
|
closePlaylistsWindow();
|
||||||
|
closeTimerWindow();
|
||||||
|
closeInitiativeWindow();
|
||||||
|
closeDiceWindow();
|
||||||
closeNpcsWindow();
|
closeNpcsWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePresentationWindow(): void {
|
|
||||||
const pres = windows.get('presentation');
|
|
||||||
if (pres && !pres.isDestroyed()) {
|
|
||||||
pres.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeControlWindow(): void {
|
|
||||||
const ctrl = windows.get('control');
|
|
||||||
if (ctrl && !ctrl.isDestroyed()) {
|
|
||||||
ctrl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function quitAppFromEditorClose(): void {
|
function quitAppFromEditorClose(): void {
|
||||||
markAppQuitting();
|
markAppQuitting();
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -207,6 +251,14 @@ function pageNameForKind(kind: WindowKind): string {
|
|||||||
return 'sceneDescription.html';
|
return 'sceneDescription.html';
|
||||||
case 'materials':
|
case 'materials':
|
||||||
return 'materials.html';
|
return 'materials.html';
|
||||||
|
case 'playlists':
|
||||||
|
return 'playlists.html';
|
||||||
|
case 'timer':
|
||||||
|
return 'timer.html';
|
||||||
|
case 'initiative':
|
||||||
|
return 'initiative.html';
|
||||||
|
case 'dice':
|
||||||
|
return 'dice.html';
|
||||||
case 'npcsEditor':
|
case 'npcsEditor':
|
||||||
return 'npcsEditor.html';
|
return 'npcsEditor.html';
|
||||||
case 'sceneEditor':
|
case 'sceneEditor':
|
||||||
@@ -215,6 +267,8 @@ function pageNameForKind(kind: WindowKind): string {
|
|||||||
return 'tokenPathEditor.html';
|
return 'tokenPathEditor.html';
|
||||||
case 'npcs':
|
case 'npcs':
|
||||||
return 'npcs.html';
|
return 'npcs.html';
|
||||||
|
case 'instructions':
|
||||||
|
return 'instructions.html';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,10 +336,15 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
|||||||
if (kind === 'control') return { width: 1200, height: 800 };
|
if (kind === 'control') return { width: 1200, height: 800 };
|
||||||
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
||||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
||||||
|
if (kind === 'playlists') return { width: PLAYLISTS_WINDOW_WIDTH, height: PLAYLISTS_WINDOW_HEIGHT };
|
||||||
|
if (kind === 'timer') return { width: TIMER_WINDOW_WIDTH, height: TIMER_WINDOW_HEIGHT };
|
||||||
|
if (kind === 'initiative') return { width: INITIATIVE_WINDOW_WIDTH, height: INITIATIVE_WINDOW_HEIGHT };
|
||||||
|
if (kind === 'dice') return { width: DICE_WINDOW_WIDTH, height: DICE_WINDOW_HEIGHT };
|
||||||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||||||
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
||||||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||||||
|
if (kind === 'instructions') return { width: INSTRUCTIONS_WINDOW_WIDTH, height: INSTRUCTIONS_WINDOW_HEIGHT };
|
||||||
return { width: 1280, height: 800 };
|
return { width: 1280, height: 800 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +371,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
minHeight: 480,
|
minHeight: 480,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(kind === 'playlists'
|
||||||
|
? {
|
||||||
|
width: PLAYLISTS_WINDOW_WIDTH,
|
||||||
|
height: PLAYLISTS_WINDOW_HEIGHT,
|
||||||
|
minWidth: PLAYLISTS_WINDOW_MIN_WIDTH,
|
||||||
|
minHeight: 480,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(kind === 'npcsEditor'
|
...(kind === 'npcsEditor'
|
||||||
? {
|
? {
|
||||||
width: NPCS_EDITOR_WINDOW_WIDTH,
|
width: NPCS_EDITOR_WINDOW_WIDTH,
|
||||||
@@ -345,6 +412,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
minHeight: 560,
|
minHeight: 560,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(kind === 'instructions'
|
||||||
|
? {
|
||||||
|
width: INSTRUCTIONS_WINDOW_WIDTH,
|
||||||
|
height: INSTRUCTIONS_WINDOW_HEIGHT,
|
||||||
|
minWidth: 640,
|
||||||
|
minHeight: 480,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: '#09090B',
|
backgroundColor: '#09090B',
|
||||||
...(icon ? { icon } : {}),
|
...(icon ? { icon } : {}),
|
||||||
@@ -358,6 +433,10 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
autoplayPolicy: 'no-user-gesture-required',
|
autoplayPolicy: 'no-user-gesture-required',
|
||||||
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
|
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
|
||||||
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
|
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
|
||||||
|
// macOS throttles RAF in unfocused windows — token paths and video stutter on presentation.
|
||||||
|
...(kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor'
|
||||||
|
? { backgroundThrottling: false }
|
||||||
|
: {}),
|
||||||
...(kind === 'npcs'
|
...(kind === 'npcs'
|
||||||
? {
|
? {
|
||||||
webviewTag: true,
|
webviewTag: true,
|
||||||
@@ -376,6 +455,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
bindWindowChromeTitle(win, kind);
|
bindWindowChromeTitle(win, kind);
|
||||||
|
if (kind === 'presentation' || kind === 'control' || kind === 'tokenPathEditor') {
|
||||||
|
win.webContents.setBackgroundThrottling(false);
|
||||||
|
}
|
||||||
if (kind === 'npcs') {
|
if (kind === 'npcs') {
|
||||||
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
|
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
|
||||||
delete guestPrefs.preload;
|
delete guestPrefs.preload;
|
||||||
@@ -392,10 +474,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
if (
|
if (
|
||||||
kind === 'sceneDescription' ||
|
kind === 'sceneDescription' ||
|
||||||
kind === 'materials' ||
|
kind === 'materials' ||
|
||||||
|
kind === 'playlists' ||
|
||||||
|
kind === 'timer' ||
|
||||||
kind === 'npcsEditor' ||
|
kind === 'npcsEditor' ||
|
||||||
kind === 'sceneEditor' ||
|
kind === 'sceneEditor' ||
|
||||||
kind === 'tokenPathEditor' ||
|
kind === 'tokenPathEditor' ||
|
||||||
kind === 'npcs'
|
kind === 'npcs' ||
|
||||||
|
kind === 'instructions'
|
||||||
) {
|
) {
|
||||||
win.setMenuBarVisibility(false);
|
win.setMenuBarVisibility(false);
|
||||||
}
|
}
|
||||||
@@ -403,6 +488,17 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
||||||
safeConsoleError(`[preload-error] ${preloadPath}:`, error);
|
safeConsoleError(`[preload-error] ${preloadPath}:`, error);
|
||||||
});
|
});
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||||
|
void shell.openExternal(parsed.toString());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore invalid urls */
|
||||||
|
}
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||||
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
|
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
|
||||||
if (errorCode === -3) return;
|
if (errorCode === -3) return;
|
||||||
@@ -425,23 +521,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (kind === 'control') {
|
if (kind === 'control') {
|
||||||
win.on('close', () => {
|
win.on('close', (e) => {
|
||||||
if (appQuitting || closingPlaySession) return;
|
if (appQuitting || closingPlaySession) return;
|
||||||
closingPlaySession = true;
|
e.preventDefault();
|
||||||
closePlaySessionAuxiliaryWindows();
|
requestCloseMultiWindowConfirm();
|
||||||
closePresentationWindow();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (kind === 'presentation') {
|
if (kind === 'presentation') {
|
||||||
bindPresentationContentSizeTracking(win);
|
bindPresentationContentSizeTracking(win);
|
||||||
win.on('close', () => {
|
win.on('close', (e) => {
|
||||||
if (appQuitting || closingPlaySession) return;
|
if (appQuitting || closingPlaySession) return;
|
||||||
closingPlaySession = true;
|
e.preventDefault();
|
||||||
closePlaySessionAuxiliaryWindows();
|
requestCloseMultiWindowConfirm();
|
||||||
closeControlWindow();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
win.on('closed', () => windows.delete(kind));
|
win.on('closed', () => windows.delete(kind));
|
||||||
|
if (kind === 'timer') {
|
||||||
|
win.on('closed', () => {
|
||||||
|
timerWindowClosedListener?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (kind === 'dice') {
|
||||||
|
win.on('closed', () => {
|
||||||
|
diceWindowClosedListener?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
if (kind === 'sceneEditor') {
|
if (kind === 'sceneEditor') {
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
closeTokenPathEditorWindow();
|
closeTokenPathEditorWindow();
|
||||||
@@ -517,20 +621,36 @@ export function focusEditorWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openMultiWindow() {
|
export function openMultiWindow(presentationSettings?: PresentationSettings) {
|
||||||
|
const settings = presentationSettings ?? readPresentationSettings(app.getPath('userData'));
|
||||||
let presentation = windows.get('presentation');
|
let presentation = windows.get('presentation');
|
||||||
if (!presentation) {
|
if (!presentation) {
|
||||||
const display = screen.getPrimaryDisplay();
|
const display = resolvePresentationDisplay(settings.displayId);
|
||||||
const { x, y, width, height } = display.bounds;
|
const { x, y, width, height } = display.bounds;
|
||||||
presentation = createWindow('presentation');
|
presentation = createWindow('presentation');
|
||||||
presentation.setBounds({ x, y, width, height });
|
presentation.setBounds({ x, y, width, height });
|
||||||
presentation.setMenuBarVisibility(false);
|
presentation.setMenuBarVisibility(false);
|
||||||
|
if (settings.fullScreen) {
|
||||||
|
presentation.setFullScreen(true);
|
||||||
|
} else {
|
||||||
presentation.maximize();
|
presentation.maximize();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!windows.has('control')) {
|
if (!windows.has('control')) {
|
||||||
// macOS: parent-child window binding moves child with the parent (unlike Windows behavior we want).
|
const editor = windows.get('editor');
|
||||||
// Keep control window independent on darwin.
|
const editorDisplay = editor
|
||||||
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
|
? screen.getDisplayMatching(editor.getBounds())
|
||||||
|
: screen.getPrimaryDisplay();
|
||||||
|
const control = createWindow('control');
|
||||||
|
const area = editorDisplay.workArea;
|
||||||
|
const cw = Math.min(1200, Math.max(640, area.width - 80));
|
||||||
|
const ch = Math.min(800, Math.max(480, area.height - 80));
|
||||||
|
control.setBounds({
|
||||||
|
x: area.x + Math.max(0, Math.round((area.width - cw) / 2)),
|
||||||
|
y: area.y + Math.max(0, Math.round((area.height - ch) / 2)),
|
||||||
|
width: cw,
|
||||||
|
height: ch,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
broadcastMultiWindowStateChanged(true);
|
broadcastMultiWindowStateChanged(true);
|
||||||
broadcastPresentationContentSize();
|
broadcastPresentationContentSize();
|
||||||
@@ -548,6 +668,19 @@ export function closeMultiWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Фокус пульта + событие: выход из показа спрашивает подтверждение на пульте. */
|
||||||
|
export function requestCloseMultiWindowConfirm(): void {
|
||||||
|
const ctrl = windows.get('control');
|
||||||
|
if (!ctrl || ctrl.isDestroyed()) {
|
||||||
|
closeMultiWindow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctrl.isMinimized()) ctrl.restore();
|
||||||
|
ctrl.show();
|
||||||
|
ctrl.focus();
|
||||||
|
ctrl.webContents.send(ipcChannels.windows.closeMultiWindowConfirmRequested, {});
|
||||||
|
}
|
||||||
|
|
||||||
export function isMultiWindowOpen(): boolean {
|
export function isMultiWindowOpen(): boolean {
|
||||||
return windows.has('presentation') || windows.has('control');
|
return windows.has('presentation') || windows.has('control');
|
||||||
}
|
}
|
||||||
@@ -566,6 +699,34 @@ export function closeMaterialsWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function closePlaylistsWindow(): void {
|
||||||
|
const win = windows.get('playlists');
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeTimerWindow(): void {
|
||||||
|
const win = windows.get('timer');
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeInitiativeWindow(): void {
|
||||||
|
const win = windows.get('initiative');
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDiceWindow(): void {
|
||||||
|
const win = windows.get('dice');
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function closeNpcsEditorWindow(): void {
|
export function closeNpcsEditorWindow(): void {
|
||||||
const win = windows.get('npcsEditor');
|
const win = windows.get('npcsEditor');
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
@@ -652,6 +813,44 @@ export function getSceneDescriptionContent(): string {
|
|||||||
return pendingSceneDescriptionHtml;
|
return pendingSceneDescriptionHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInstructionsSection(): string {
|
||||||
|
return pendingInstructionsSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Окно инструкции из редактора: переиспользовать существующее или создать новое. */
|
||||||
|
export function openInstructionsWindow(section?: string): void {
|
||||||
|
pendingInstructionsSection = String(section ?? '').trim() || 'overview';
|
||||||
|
const existing = windows.get('instructions');
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
if (existing.isMinimized()) existing.restore();
|
||||||
|
existing.show();
|
||||||
|
existing.focus();
|
||||||
|
existing.moveTop();
|
||||||
|
sendInstructionsSection(existing, pendingInstructionsSection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = windows.get('editor');
|
||||||
|
const win = createWindow('instructions', parent ? { parent } : undefined);
|
||||||
|
const { width, height } = win.getBounds();
|
||||||
|
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||||
|
const { x, y, width: dw, height: dh } = display.workArea;
|
||||||
|
win.setBounds({
|
||||||
|
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||||
|
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
sendInstructionsSection(win, pendingInstructionsSection);
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
win.moveTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Одно окно описания: переиспользовать существующее или создать новое. */
|
/** Одно окно описания: переиспользовать существующее или создать новое. */
|
||||||
export function openSceneDescriptionWindow(html: string): void {
|
export function openSceneDescriptionWindow(html: string): void {
|
||||||
pendingSceneDescriptionHtml = html;
|
pendingSceneDescriptionHtml = html;
|
||||||
@@ -725,6 +924,132 @@ export function openMaterialsWindow(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function openPlaylistsWindow(): void {
|
||||||
|
const existing = windows.get('playlists');
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
existing.setMinimumSize(PLAYLISTS_WINDOW_MIN_WIDTH, 480);
|
||||||
|
if (existing.isMinimized()) existing.restore();
|
||||||
|
existing.show();
|
||||||
|
existing.focus();
|
||||||
|
existing.moveTop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = windows.get('control') ?? windows.get('presentation');
|
||||||
|
const win = createWindow('playlists', parent ? { parent } : undefined);
|
||||||
|
const { width, height } = win.getBounds();
|
||||||
|
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||||
|
const { x, y, width: dw, height: dh } = display.workArea;
|
||||||
|
win.setBounds({
|
||||||
|
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||||
|
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
win.moveTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openTimerWindow(): void {
|
||||||
|
const existing = windows.get('timer');
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
if (existing.isMinimized()) existing.restore();
|
||||||
|
existing.show();
|
||||||
|
existing.focus();
|
||||||
|
existing.moveTop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = windows.get('control') ?? windows.get('presentation');
|
||||||
|
const win = createWindow('timer', parent ? { parent } : undefined);
|
||||||
|
const { width, height } = win.getBounds();
|
||||||
|
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||||
|
const { x, y, width: dw, height: dh } = display.workArea;
|
||||||
|
win.setBounds({
|
||||||
|
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||||
|
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
win.moveTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openInitiativeWindow(opts?: { onClosed?: () => void }): void {
|
||||||
|
const existing = windows.get('initiative');
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
if (existing.isMinimized()) existing.restore();
|
||||||
|
existing.show();
|
||||||
|
existing.focus();
|
||||||
|
existing.moveTop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = windows.get('control') ?? windows.get('presentation');
|
||||||
|
const win = createWindow('initiative', parent ? { parent } : undefined);
|
||||||
|
if (opts?.onClosed) {
|
||||||
|
win.once('closed', () => {
|
||||||
|
opts.onClosed?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { width, height } = win.getBounds();
|
||||||
|
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||||
|
const { x, y, width: dw, height: dh } = display.workArea;
|
||||||
|
win.setBounds({
|
||||||
|
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||||
|
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
win.moveTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openDiceWindow(): void {
|
||||||
|
const existing = windows.get('dice');
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
if (existing.isMinimized()) existing.restore();
|
||||||
|
existing.show();
|
||||||
|
existing.focus();
|
||||||
|
existing.moveTop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = windows.get('control') ?? windows.get('presentation');
|
||||||
|
const win = createWindow('dice', parent ? { parent } : undefined);
|
||||||
|
const { width, height } = win.getBounds();
|
||||||
|
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||||
|
const { x, y, width: dw, height: dh } = display.workArea;
|
||||||
|
win.setBounds({
|
||||||
|
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||||
|
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
win.moveTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||||
const parent = windows.get('editor');
|
const parent = windows.get('editor');
|
||||||
const { width, height } = win.getBounds();
|
const { width, height } = win.getBounds();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -160,6 +160,50 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modalFooterWrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveRow {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveRowSelected {
|
||||||
|
border-color: var(--scene-list-selected-border);
|
||||||
|
background: var(--scene-list-selected-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveNameInput {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveError {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.descriptionViewBody {
|
.descriptionViewBody {
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
max-height: min(560px, calc(100vh - 200px));
|
max-height: min(560px, calc(100vh - 200px));
|
||||||
@@ -217,11 +261,58 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.storyScroll {
|
.storyWrapCollapsed {
|
||||||
flex: 0 0 70%;
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyAccordions {
|
||||||
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-radius: var(--radius-lg);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyAccordionExpanded {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyAccordionCollapsed {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyWrapCollapsed .historyAccordion {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyAccordion {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyAccordionHead {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text2);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyScroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border-top: 1px solid var(--stroke);
|
||||||
background: var(--color-overlay-dark-2);
|
background: var(--color-overlay-dark-2);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -292,19 +383,39 @@
|
|||||||
|
|
||||||
.previewHeader {
|
.previewHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewTitle {
|
.previewTitle {
|
||||||
font-size: var(--text-md);
|
font-size: var(--text-md);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewActions {
|
.previewActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewTokenTools {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.npcTokenScale {
|
.npcTokenScale {
|
||||||
@@ -312,6 +423,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.snapToGrid {
|
.snapToGrid {
|
||||||
@@ -319,26 +431,37 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 34px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.snapToGrid input {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.snapToGridLabel {
|
.snapToGridLabel {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.npcTokenScaleLabel {
|
.npcTokenScaleLabel {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.npcTokenScaleRange {
|
.npcTokenScaleRange {
|
||||||
width: min(140px, 22vw);
|
flex: 1 1 auto;
|
||||||
|
min-width: 72px;
|
||||||
|
width: min(140px, 100%);
|
||||||
|
height: 34px;
|
||||||
|
margin: 0;
|
||||||
accent-color: var(--color-accent, #c9a227);
|
accent-color: var(--color-accent, #c9a227);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,6 +517,61 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brushCursorSquare {
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brushShapeRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brushShapeButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brushShapeIcon {
|
||||||
|
display: flex;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerColorInput {
|
||||||
|
flex: 0 0 40px;
|
||||||
|
width: 40px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerColorInput::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerColorInput::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerColorInput::-moz-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.brushLayer {
|
.brushLayer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -32,4 +32,18 @@ void test('ControlApp: draft кисти без корневого draftFxTick',
|
|||||||
assert.ok(pixi.includes('setDraft'));
|
assert.ok(pixi.includes('setDraft'));
|
||||||
assert.ok(pixi.includes('mergeDraftState'));
|
assert.ok(pixi.includes('mergeDraftState'));
|
||||||
assert.ok(pixi.includes('forwardRef'));
|
assert.ok(pixi.includes('forwardRef'));
|
||||||
|
assert.ok(app.includes('perfProfile="preview"'));
|
||||||
|
assert.ok(app.includes('OVERLAY_DRAFT_IPC_MS'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: path playback не гоняет setState корня', () => {
|
||||||
|
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||||
|
const hook = fs.readFileSync(
|
||||||
|
path.join(here, '../shared/tokens/useTokenPathLivePoses.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(app, /\bpathLivePoses\b/);
|
||||||
|
assert.doesNotMatch(hook, /\bsetPoses\b/);
|
||||||
|
assert.ok(hook.includes('applyLivePathPose'));
|
||||||
|
assert.ok(hook.includes('requestAnimationFrame'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
|||||||
const src = readControlApp();
|
const src = readControlApp();
|
||||||
assert.ok(src.includes("t('control.instruments')"));
|
assert.ok(src.includes("t('control.instruments')"));
|
||||||
assert.ok(src.includes("t('control.descriptionTool')"));
|
assert.ok(src.includes("t('control.descriptionTool')"));
|
||||||
|
assert.ok(src.includes("t('control.markerTool')"));
|
||||||
|
assert.ok(src.includes("selectEffectTool('marker')"));
|
||||||
|
assert.ok(src.includes('SceneMarkerOverlay'));
|
||||||
|
assert.ok(src.includes('useSceneMarkerState'));
|
||||||
|
assert.ok(src.includes("t('control.markerColor')"));
|
||||||
|
assert.ok(src.includes('dispatchMarkerEraserPoints'));
|
||||||
|
assert.ok(src.includes('markerViewport'));
|
||||||
|
assert.ok(src.includes('toMarkerNPoint'));
|
||||||
|
assert.ok(src.includes("kind: 'strokes.clear'"));
|
||||||
assert.ok(src.includes("t('control.descriptionMissing')"));
|
assert.ok(src.includes("t('control.descriptionMissing')"));
|
||||||
assert.ok(src.includes('openSceneDescription'));
|
assert.ok(src.includes('openSceneDescription'));
|
||||||
assert.ok(!src.includes('SceneDescriptionViewModal'));
|
assert.ok(!src.includes('SceneDescriptionViewModal'));
|
||||||
@@ -88,6 +97,8 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
|||||||
assert.ok(src.includes("t('control.darknessControl')"));
|
assert.ok(src.includes("t('control.darknessControl')"));
|
||||||
assert.ok(src.includes("t('control.explorerBrush')"));
|
assert.ok(src.includes("t('control.explorerBrush')"));
|
||||||
assert.ok(src.includes("t('control.closerBrush')"));
|
assert.ok(src.includes("t('control.closerBrush')"));
|
||||||
|
assert.ok(src.includes("t('control.bakeDarknessToScene')"));
|
||||||
|
assert.ok(src.includes('bake-darkness-to-scene'));
|
||||||
assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
|
assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
|
||||||
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
|
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
|
||||||
assert.ok(src.includes('SceneDarknessOverlay'));
|
assert.ok(src.includes('SceneDarknessOverlay'));
|
||||||
@@ -113,7 +124,7 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
|||||||
instruments !== -1 && fx !== -1 && instruments < fx,
|
instruments !== -1 && fx !== -1 && instruments < fx,
|
||||||
'Блок инструментов должен быть выше эффектов',
|
'Блок инструментов должен быть выше эффектов',
|
||||||
);
|
);
|
||||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше истории');
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
|
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
|
||||||
@@ -126,19 +137,38 @@ void test('ControlApp: чекбокс привязки токенов к сет
|
|||||||
assert.ok(src.includes('snapAllTokensToGrid'));
|
assert.ok(src.includes('snapAllTokensToGrid'));
|
||||||
assert.ok(src.includes('snapNorm={snapNormActive}'));
|
assert.ok(src.includes('snapNorm={snapNormActive}'));
|
||||||
assert.ok(css.includes('.snapToGrid'));
|
assert.ok(css.includes('.snapToGrid'));
|
||||||
|
assert.ok(css.includes('.previewTokenTools'));
|
||||||
|
assert.match(css, /\.previewTokenTools[\s\S]*?repeat\(auto-fit/);
|
||||||
|
assert.match(css, /\.previewHeader[\s\S]*?flex-wrap:\s*wrap/);
|
||||||
|
assert.match(css, /\.previewHeader[\s\S]*?align-items:\s*center/);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
void test('ControlApp: история — свёрнута по умолчанию, колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||||
const src = readControlApp();
|
const src = readControlApp();
|
||||||
const css = readControlAppCss();
|
const css = readControlAppCss();
|
||||||
const story = src.indexOf("t('control.storyLine')");
|
const story = src.indexOf("t('control.storyLine')");
|
||||||
assert.ok(story !== -1);
|
assert.ok(story !== -1);
|
||||||
|
assert.ok(src.includes('data-testid="control-history-accordion"'));
|
||||||
|
assert.ok(src.includes('aria-expanded={historyOpen}'));
|
||||||
|
assert.ok(src.includes('const [historyOpen, setHistoryOpen] = useState(false)'));
|
||||||
assert.ok(src.includes('className={styles.storyScroll}'));
|
assert.ok(src.includes('className={styles.storyScroll}'));
|
||||||
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
||||||
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||||
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: блок «Сцены» — аккордеон под историей, свёрнут по умолчанию', () => {
|
||||||
|
const src = readControlApp();
|
||||||
|
assert.ok(src.includes('data-testid="control-scenes-accordion"'));
|
||||||
|
assert.ok(src.includes("t('control.scenesAccordion')"));
|
||||||
|
assert.ok(src.includes('const [scenesOpen, setScenesOpen] = useState(false)'));
|
||||||
|
assert.ok(src.includes('listActiveStorylineScenes'));
|
||||||
|
assert.ok(src.includes('storylineScenes.map'));
|
||||||
|
const historyIdx = src.indexOf('data-testid="control-history-accordion"');
|
||||||
|
const scenesIdx = src.indexOf('data-testid="control-scenes-accordion"');
|
||||||
|
assert.ok(historyIdx !== -1 && scenesIdx !== -1 && historyIdx < scenesIdx);
|
||||||
|
});
|
||||||
|
|
||||||
void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => {
|
void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => {
|
||||||
const src = readControlApp();
|
const src = readControlApp();
|
||||||
assert.ok(!src.includes('suppressNextHistoryPushRef'));
|
assert.ok(!src.includes('suppressNextHistoryPushRef'));
|
||||||
@@ -173,6 +203,27 @@ void test('ControlApp: слой кисти не использует курсо
|
|||||||
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
|
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: форма кисти затемнения — UI, shape в штрихах и сброс', () => {
|
||||||
|
const src = readControlApp();
|
||||||
|
const css = readControlAppCss();
|
||||||
|
assert.ok(src.includes("t('control.brushShape')"));
|
||||||
|
assert.ok(src.includes("t('control.brushShapeCircle')"));
|
||||||
|
assert.ok(src.includes("t('control.brushShapeSquare')"));
|
||||||
|
assert.ok(src.includes('darknessBrushShape'));
|
||||||
|
assert.ok(src.includes('darknessBrushDraft'));
|
||||||
|
assert.ok(src.includes("shape: darknessBrushShapeRef.current"));
|
||||||
|
assert.ok(src.includes('setDarknessBrushShape'));
|
||||||
|
assert.ok(src.includes('styles.brushShapeRow'));
|
||||||
|
assert.ok(src.includes("tool.tool === 'exploreBrush' || tool.tool === 'closeBrush'"));
|
||||||
|
assert.ok(css.includes('.brushCursorSquare'));
|
||||||
|
assert.ok(css.includes('.brushShapeRow'));
|
||||||
|
const radius = src.indexOf("t('control.brushRadius')");
|
||||||
|
const shape = src.indexOf("t('control.brushShape')");
|
||||||
|
const sound = src.indexOf("t('control.effectsSound')");
|
||||||
|
assert.ok(radius !== -1 && shape !== -1 && sound !== -1);
|
||||||
|
assert.ok(radius < shape && shape < sound, 'Форма кисти между радиусом и звуком эффектов');
|
||||||
|
});
|
||||||
|
|
||||||
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
|
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
|
||||||
const src = readControlApp();
|
const src = readControlApp();
|
||||||
const previewLabel = src.indexOf("t('control.screenPreview')");
|
const previewLabel = src.indexOf("t('control.screenPreview')");
|
||||||
@@ -267,3 +318,20 @@ void test('ControlApp: загрузка камп. аудио — useEffect за
|
|||||||
assert.ok(!/\bproject\b/.test(depList));
|
assert.ok(!/\bproject\b/.test(depList));
|
||||||
assert.ok(!/\bcampaignAudioRefs\b/.test(depList));
|
assert.ok(!/\bcampaignAudioRefs\b/.test(depList));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: Escape → своя модалка подтверждения завершения показа', () => {
|
||||||
|
const src = readControlApp();
|
||||||
|
assert.ok(src.includes('closeMultiWindowConfirmRequested'));
|
||||||
|
assert.ok(src.includes('stopConfirmOpen'));
|
||||||
|
assert.ok(src.includes('control.stopConfirm.title'));
|
||||||
|
assert.ok(src.includes('data-testid="stop-confirm-modal"'));
|
||||||
|
assert.ok(src.includes('data-testid="control-help-tool"'));
|
||||||
|
assert.match(src, /data-testid="control-help-tool"[\s\S]{0,180}iconOnly|iconOnly[\s\S]{0,180}data-testid="control-help-tool"/);
|
||||||
|
assert.ok(src.includes("section: 'controlPanel'"));
|
||||||
|
assert.equal((src.match(/windows\.closeMultiWindow,/g) ?? []).length, 3);
|
||||||
|
assert.ok(src.includes('data-testid="control-save-game"'));
|
||||||
|
assert.ok(src.includes('data-testid="stop-confirm-save-and-end"'));
|
||||||
|
assert.ok(src.includes('data-testid="stop-confirm-discard"'));
|
||||||
|
assert.ok(!src.includes('showMessageBox'));
|
||||||
|
assert.ok(!src.includes('window.confirm'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
|
<title>TTRPG</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/dice/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
.page {
|
||||||
|
height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidesGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidesGrid > * {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidesGrid button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px 1fr 48px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countValue {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
|
||||||
|
color: inherit;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countValue:focus {
|
||||||
|
border-color: rgba(147, 197, 253, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions > * {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastResult {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastNotation {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueChips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueChip {
|
||||||
|
min-width: 36px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(245, 197, 66, 0.55);
|
||||||
|
background: rgba(245, 197, 66, 0.12);
|
||||||
|
color: #fef3c7;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastTotal {
|
||||||
|
font-size: 16px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rollingHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyResult {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
import {
|
||||||
|
DICE_COUNT_MAX,
|
||||||
|
DICE_COUNT_MIN,
|
||||||
|
DICE_SIDES,
|
||||||
|
clampDiceCount,
|
||||||
|
type DiceSides,
|
||||||
|
} from '../../shared/types/diceRoll';
|
||||||
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { getDndApi } from '../shared/dndApi';
|
||||||
|
import { useDiceRollState } from '../shared/dice/useDiceRollState';
|
||||||
|
import { Button } from '../shared/ui/controls';
|
||||||
|
|
||||||
|
import styles from './DiceApp.module.css';
|
||||||
|
|
||||||
|
export function DiceApp() {
|
||||||
|
const { t } = useEditorI18n();
|
||||||
|
const api = getDndApi();
|
||||||
|
const [state, diceApi] = useDiceRollState();
|
||||||
|
const [sides, setSides] = useState<DiceSides>(20);
|
||||||
|
const [count, setCount] = useState(1);
|
||||||
|
const [countDraft, setCountDraft] = useState('1');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
void api.invoke(ipcChannels.windows.closeDice, {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const commitCountDraft = () => {
|
||||||
|
const trimmed = countDraft.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
setCount(DICE_COUNT_MIN);
|
||||||
|
setCountDraft(String(DICE_COUNT_MIN));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < DICE_COUNT_MIN) {
|
||||||
|
setCount(DICE_COUNT_MIN);
|
||||||
|
setCountDraft(String(DICE_COUNT_MIN));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = clampDiceCount(parsed);
|
||||||
|
setCount(next);
|
||||||
|
setCountDraft(String(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCountClamped = (n: number) => {
|
||||||
|
const next = clampDiceCount(n);
|
||||||
|
setCount(next);
|
||||||
|
setCountDraft(String(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const showToPlayers = state?.visibleToPlayers ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page} data-testid="dice-app">
|
||||||
|
<div className={styles.sectionLabel}>{t('dice.sides')}</div>
|
||||||
|
<div className={styles.sidesGrid}>
|
||||||
|
{DICE_SIDES.map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s}
|
||||||
|
variant={sides === s ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => setSides(s)}
|
||||||
|
data-testid={`dice-side-${s}`}
|
||||||
|
>
|
||||||
|
d{s}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.sectionLabel}>{t('dice.count')}</div>
|
||||||
|
<div className={styles.countRow}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setCountClamped(count - 1)}
|
||||||
|
ariaLabel={t('dice.dec')}
|
||||||
|
disabled={count <= DICE_COUNT_MIN}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
className={styles.countValue}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={countDraft}
|
||||||
|
onChange={(e) => {
|
||||||
|
const digits = e.target.value.replace(/\D/g, '');
|
||||||
|
if (digits === '') {
|
||||||
|
setCountDraft('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Number(digits);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
setCountDraft(String(DICE_COUNT_MIN));
|
||||||
|
setCount(DICE_COUNT_MIN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (n > DICE_COUNT_MAX) {
|
||||||
|
setCountDraft(String(DICE_COUNT_MAX));
|
||||||
|
setCount(DICE_COUNT_MAX);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCountDraft(digits);
|
||||||
|
}}
|
||||||
|
onBlur={() => commitCountDraft()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
commitCountDraft();
|
||||||
|
(e.currentTarget as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={t('dice.count')}
|
||||||
|
data-testid="dice-count"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setCountClamped(count + 1)}
|
||||||
|
ariaLabel={t('dice.inc')}
|
||||||
|
disabled={count >= DICE_COUNT_MAX}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className={styles.checkRow}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showToPlayers}
|
||||||
|
onChange={(e) =>
|
||||||
|
void diceApi.dispatch({ kind: 'setVisibleToPlayers', visible: e.target.checked })
|
||||||
|
}
|
||||||
|
data-testid="dice-show-to-players"
|
||||||
|
/>
|
||||||
|
<span>{t('dice.showAnimationToPlayers')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={clampDiceCount(Number(countDraft.trim()) || count) < 1}
|
||||||
|
onClick={() => {
|
||||||
|
const next = clampDiceCount(Number(countDraft.trim()));
|
||||||
|
setCount(next);
|
||||||
|
setCountDraft(String(next));
|
||||||
|
if (next < 1) return;
|
||||||
|
void diceApi.dispatch({ kind: 'roll', count: next, sides });
|
||||||
|
}}
|
||||||
|
data-testid="dice-roll"
|
||||||
|
>
|
||||||
|
{t('dice.roll')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!state?.values.length && !state?.active}
|
||||||
|
onClick={() => void diceApi.dispatch({ kind: 'clear' })}
|
||||||
|
>
|
||||||
|
{t('dice.clear')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state && state.values.length > 0 ? (
|
||||||
|
<div className={styles.lastResult} data-testid="dice-last-result">
|
||||||
|
<div className={styles.lastLabel}>{t('dice.result')}</div>
|
||||||
|
<div className={styles.lastNotation}>{state.notation}</div>
|
||||||
|
<div className={styles.valueChips} aria-label={t('dice.result')}>
|
||||||
|
{state.values.map((v, i) => (
|
||||||
|
<span key={`${state.revision}-${i}`} className={styles.valueChip}>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.lastTotal}>
|
||||||
|
{t('dice.total')}: <strong>{state.total}</strong>
|
||||||
|
</div>
|
||||||
|
{state.active ? <div className={styles.rollingHint}>{t('dice.rolling')}</div> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyResult}>{t('dice.noResult')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import '../shared/ui/globals.css';
|
||||||
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
|
import { DiceApp } from './DiceApp';
|
||||||
|
|
||||||
|
const rootEl = document.getElementById('root');
|
||||||
|
if (!rootEl) {
|
||||||
|
throw new Error('Missing #root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Dice">
|
||||||
|
<EditorI18nProvider>
|
||||||
|
<DiceApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -53,6 +53,12 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.helpMark {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.headerActions {
|
.headerActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -379,6 +385,11 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fileMenuItem:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.fileMenuItemDanger {
|
.fileMenuItemDanger {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -519,6 +530,30 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.saveList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveRow {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveRowSelected {
|
||||||
|
border-color: var(--scene-list-selected-border);
|
||||||
|
background: var(--scene-list-selected-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.licenseBlockTitle {
|
.licenseBlockTitle {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
@@ -550,113 +585,6 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instructionsDialog {
|
|
||||||
position: fixed;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: min(920px, calc(100vw - 32px));
|
|
||||||
max-height: min(86vh, 720px);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--stroke);
|
|
||||||
background: var(--color-surface-elevated);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
padding: 16px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto 1fr auto;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsLayout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 220px;
|
|
||||||
gap: 14px;
|
|
||||||
min-height: 0;
|
|
||||||
max-height: min(62vh, 560px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsContent {
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--stroke);
|
|
||||||
background: var(--bg0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsContentTitle {
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: var(--text-md);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsParagraph {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
color: var(--text1);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
line-height: 1.55;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsParagraph:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsInlineLink {
|
|
||||||
display: inline;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--accent, #a78bfa);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsInlineLink:hover {
|
|
||||||
color: var(--text0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsNav {
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
align-content: start;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--stroke);
|
|
||||||
background: var(--panel2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsNavItem {
|
|
||||||
text-align: left;
|
|
||||||
padding: 9px 10px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text2);
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsNavItem:hover {
|
|
||||||
background: var(--scene-list-hover-bg);
|
|
||||||
color: var(--text0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructionsNavItemActive {
|
|
||||||
background: rgba(167, 139, 250, 0.18);
|
|
||||||
color: var(--text0);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aboutBody {
|
.aboutBody {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -688,6 +616,13 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aboutSponsorsList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.aboutLink {
|
.aboutLink {
|
||||||
color: var(--color-accent, #a78bfa);
|
color: var(--color-accent, #a78bfa);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -1015,6 +950,11 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actionsRowHalf > button {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.actionsRowHalf > span {
|
.actionsRowHalf > span {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1110,7 +1050,7 @@
|
|||||||
.audioList {
|
.audioList {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
max-height: 160px;
|
max-height: 280px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,10 +1060,32 @@
|
|||||||
border-radius: var(--radius-xs);
|
border-radius: var(--radius-xs);
|
||||||
background: var(--color-overlay-dark-3);
|
background: var(--color-overlay-dark-3);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioDragHandle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioDragHandle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioDragGrip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audioName {
|
.audioName {
|
||||||
@@ -1135,13 +1097,35 @@
|
|||||||
|
|
||||||
.audioControls {
|
.audioControls {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audioVolumeLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioVolumeRange {
|
||||||
|
width: 72px;
|
||||||
|
accent-color: var(--accent, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioPreviewBtn {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.audioRemove {
|
.audioRemove {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
|
|||||||
+262
-220
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '../../shared/ipc/contracts';
|
} from '../../shared/ipc/contracts';
|
||||||
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||||
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||||
|
import type { GameSaveListItem } from '../../shared/types/gameSave';
|
||||||
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
|
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
|
||||||
import type {
|
import type {
|
||||||
AssetId,
|
AssetId,
|
||||||
@@ -30,14 +31,16 @@ import type {
|
|||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import { AppLogo } from '../shared/branding/AppLogo';
|
import { AppLogo } from '../shared/branding/AppLogo';
|
||||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||||
|
import { stopAllEditorAudio } from '../shared/audio/editorAudioStop';
|
||||||
import { getDndApi } from '../shared/dndApi';
|
import { getDndApi } from '../shared/dndApi';
|
||||||
import { RotatedImage } from '../shared/RotatedImage';
|
import { RotatedImage } from '../shared/RotatedImage';
|
||||||
import { Button, Input } from '../shared/ui/controls';
|
import { Button, Input } from '../shared/ui/controls';
|
||||||
import { LayoutShell } from '../shared/ui/LayoutShell';
|
import { LayoutShell } from '../shared/ui/LayoutShell';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
|
import { AppAboutModal } from './about/EditorAboutModals';
|
||||||
import styles from './EditorApp.module.css';
|
import styles from './EditorApp.module.css';
|
||||||
|
import { EditorAudioTrackList } from './EditorAudioTrackList';
|
||||||
import {
|
import {
|
||||||
filterAudioFilePaths,
|
filterAudioFilePaths,
|
||||||
partitionSceneMediaDrops,
|
partitionSceneMediaDrops,
|
||||||
@@ -59,6 +62,9 @@ import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/Edito
|
|||||||
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
|
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
|
||||||
import { PlayersManagerModal } from './PlayersModals';
|
import { PlayersManagerModal } from './PlayersModals';
|
||||||
import { LaunchPlayersModal } from './LaunchPlayersModal';
|
import { LaunchPlayersModal } from './LaunchPlayersModal';
|
||||||
|
import { PresentationSettingsModal } from './PresentationSettingsModal';
|
||||||
|
import { GameSystemsModal } from './GameSystemsModal';
|
||||||
|
import { PlaylistsManagerModal } from './PlaylistsModal';
|
||||||
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
||||||
import { SceneDescriptionModal } from './SceneDescriptionModal';
|
import { SceneDescriptionModal } from './SceneDescriptionModal';
|
||||||
import type { ProjectNoticeCode } from './state/projectState';
|
import type { ProjectNoticeCode } from './state/projectState';
|
||||||
@@ -125,10 +131,7 @@ export function EditorApp() {
|
|||||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
||||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
||||||
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
|
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
|
||||||
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
|
|
||||||
const [appAboutOpen, setAppAboutOpen] = useState(false);
|
const [appAboutOpen, setAppAboutOpen] = useState(false);
|
||||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
|
||||||
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
|
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
const [importSourceOpen, setImportSourceOpen] = useState(false);
|
const [importSourceOpen, setImportSourceOpen] = useState(false);
|
||||||
@@ -160,10 +163,34 @@ export function EditorApp() {
|
|||||||
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
|
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
|
||||||
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
|
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
|
||||||
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
|
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
|
||||||
|
const [gameSaves, setGameSaves] = useState<GameSaveListItem[]>([]);
|
||||||
|
const [loadSaveOpen, setLoadSaveOpen] = useState(false);
|
||||||
|
const [loadSaveId, setLoadSaveId] = useState<string | null>(null);
|
||||||
|
const [loadSaveError, setLoadSaveError] = useState('');
|
||||||
|
const [playlistsManagerOpen, setPlaylistsManagerOpen] = useState(false);
|
||||||
|
const [presentationSettingsOpen, setPresentationSettingsOpen] = useState(false);
|
||||||
|
const [gameSystemsOpen, setGameSystemsOpen] = useState(false);
|
||||||
const [runMenuOpen, setRunMenuOpen] = useState(false);
|
const [runMenuOpen, setRunMenuOpen] = useState(false);
|
||||||
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
|
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||||
const runSplitRef = useRef<HTMLDivElement | null>(null);
|
const runSplitRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
|
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
|
||||||
|
|
||||||
|
const listGameSystems = useCallback(async () => {
|
||||||
|
const res = await getDndApi().invoke(ipcChannels.gameSystems.list, {});
|
||||||
|
return res.systems;
|
||||||
|
}, []);
|
||||||
|
const importGameSystem = useCallback(async () => {
|
||||||
|
const res = await getDndApi().invoke(ipcChannels.gameSystems.importFromFile, {});
|
||||||
|
return res.canceled ? null : res.system;
|
||||||
|
}, []);
|
||||||
|
const deleteGameSystem = useCallback(async (id: string) => {
|
||||||
|
await getDndApi().invoke(ipcChannels.gameSystems.delete, { id });
|
||||||
|
}, []);
|
||||||
|
const previewGameSystem = useCallback(async (id: string) => {
|
||||||
|
console.log('[gameSystems] preview stub', id);
|
||||||
|
window.alert(`Preview (stub): ${id}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onProjectNotice = useCallback(
|
const onProjectNotice = useCallback(
|
||||||
(code: ProjectNoticeCode) => {
|
(code: ProjectNoticeCode) => {
|
||||||
const handlers: Record<ProjectNoticeCode, () => void> = {
|
const handlers: Record<ProjectNoticeCode, () => void> = {
|
||||||
@@ -256,11 +283,9 @@ export function EditorApp() {
|
|||||||
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const aboutMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null);
|
const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||||
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
|
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||||
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
|
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||||
const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null);
|
|
||||||
const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
|
const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
|
||||||
const [sceneListDrop, setSceneListDrop] = useState<{
|
const [sceneListDrop, setSceneListDrop] = useState<{
|
||||||
targetId: SceneId;
|
targetId: SceneId;
|
||||||
@@ -359,9 +384,59 @@ export function EditorApp() {
|
|||||||
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
|
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
|
||||||
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
|
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
|
||||||
|
|
||||||
|
const openInstructions = useCallback((section: HelpSectionId = 'overview') => {
|
||||||
|
void getDndApi()
|
||||||
|
.invoke(ipcChannels.windows.openInstructions, { section })
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[editor] openInstructions failed', err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshGameSaves = useCallback(() => {
|
||||||
|
if (!state.project) {
|
||||||
|
setGameSaves([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void getDndApi()
|
||||||
|
.invoke(ipcChannels.gameSave.list, {})
|
||||||
|
.then(
|
||||||
|
({ saves }) => setGameSaves(saves),
|
||||||
|
() => setGameSaves([]),
|
||||||
|
);
|
||||||
|
}, [state.project]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshGameSaves();
|
||||||
|
}, [refreshGameSaves]);
|
||||||
|
|
||||||
|
const launchFromSave = useCallback(
|
||||||
|
(saveId: string) => {
|
||||||
|
if (!licenseActive || launching) return;
|
||||||
|
stopAllEditorAudio();
|
||||||
|
setLaunching(true);
|
||||||
|
setRunMenuOpen(false);
|
||||||
|
setLoadSaveError('');
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDndApi().invoke(ipcChannels.windows.openMultiWindow, { saveId });
|
||||||
|
if (res.project) actions.noteProject(res.project);
|
||||||
|
setLoadSaveOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setLaunching(false);
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setLoadSaveError(
|
||||||
|
message.includes('GAME_SAVE_CARD_MISSING') ? t('top.loadSave.cardMissing') : message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[licenseActive, launching, t, actions],
|
||||||
|
);
|
||||||
|
|
||||||
const launchFromGraphNode = useCallback(
|
const launchFromGraphNode = useCallback(
|
||||||
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
||||||
if (!licenseActive || launching) return;
|
if (!licenseActive || launching) return;
|
||||||
|
stopAllEditorAudio();
|
||||||
setLaunching(true);
|
setLaunching(true);
|
||||||
setRunMenuOpen(false);
|
setRunMenuOpen(false);
|
||||||
setLaunchPlayersOpen(false);
|
setLaunchPlayersOpen(false);
|
||||||
@@ -380,7 +455,10 @@ export function EditorApp() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (presentationOpen) setLaunching(false);
|
if (presentationOpen) {
|
||||||
|
setLaunching(false);
|
||||||
|
setPlaylistsManagerOpen(false);
|
||||||
|
}
|
||||||
}, [presentationOpen]);
|
}, [presentationOpen]);
|
||||||
|
|
||||||
const currentProjectName = state.project?.meta.name ?? '';
|
const currentProjectName = state.project?.meta.name ?? '';
|
||||||
@@ -454,26 +532,6 @@ export function EditorApp() {
|
|||||||
if (!settingsMenuOpen) setSettingsLangSubOpen(false);
|
if (!settingsMenuOpen) setSettingsLangSubOpen(false);
|
||||||
}, [settingsMenuOpen]);
|
}, [settingsMenuOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!aboutMenuOpen) return;
|
|
||||||
const r = aboutMenuBtnRef.current?.getBoundingClientRect() ?? null;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (r) {
|
|
||||||
setAboutMenuPos({ left: r.left, top: r.bottom + 10 });
|
|
||||||
} else {
|
|
||||||
setAboutMenuPos(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const onDown = (e: MouseEvent) => {
|
|
||||||
const t = e.target as HTMLElement | null;
|
|
||||||
if (!t) return;
|
|
||||||
if (t.closest('[data-aboutmenu-root="1"]')) return;
|
|
||||||
setAboutMenuOpen(false);
|
|
||||||
};
|
|
||||||
window.addEventListener('mousedown', onDown);
|
|
||||||
return () => window.removeEventListener('mousedown', onDown);
|
|
||||||
}, [aboutMenuOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runMenuOpen) return;
|
if (!runMenuOpen) return;
|
||||||
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
|
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
|
||||||
@@ -501,6 +559,7 @@ export function EditorApp() {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
|
||||||
|
if (open) stopAllEditorAudio();
|
||||||
setPresentationOpen(open);
|
setPresentationOpen(open);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -782,7 +841,6 @@ export function EditorApp() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFileMenuOpen(false);
|
setFileMenuOpen(false);
|
||||||
setProjectMenuOpen(false);
|
setProjectMenuOpen(false);
|
||||||
setAboutMenuOpen(false);
|
|
||||||
setSettingsMenuOpen((v) => {
|
setSettingsMenuOpen((v) => {
|
||||||
const next = !v;
|
const next = !v;
|
||||||
if (next) setSettingsLangSubOpen(false);
|
if (next) setSettingsLangSubOpen(false);
|
||||||
@@ -793,16 +851,14 @@ export function EditorApp() {
|
|||||||
{t('top.settings')}
|
{t('top.settings')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
ref={aboutMenuBtnRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
data-aboutmenu-root="1"
|
|
||||||
className={styles.fileMenuTrigger}
|
className={styles.fileMenuTrigger}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFileMenuOpen(false);
|
setFileMenuOpen(false);
|
||||||
setProjectMenuOpen(false);
|
setProjectMenuOpen(false);
|
||||||
setSettingsMenuOpen(false);
|
setSettingsMenuOpen(false);
|
||||||
setSettingsLangSubOpen(false);
|
setSettingsLangSubOpen(false);
|
||||||
setAboutMenuOpen((v) => !v);
|
setAppAboutOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('top.aboutApp')}
|
{t('top.aboutApp')}
|
||||||
@@ -818,7 +874,6 @@ export function EditorApp() {
|
|||||||
if (!licenseActive) return;
|
if (!licenseActive) return;
|
||||||
setFileMenuOpen(false);
|
setFileMenuOpen(false);
|
||||||
setSettingsMenuOpen(false);
|
setSettingsMenuOpen(false);
|
||||||
setAboutMenuOpen(false);
|
|
||||||
setProjectMenuOpen((v) => !v);
|
setProjectMenuOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -836,7 +891,6 @@ export function EditorApp() {
|
|||||||
if (!licenseActive) return;
|
if (!licenseActive) return;
|
||||||
setProjectMenuOpen(false);
|
setProjectMenuOpen(false);
|
||||||
setSettingsMenuOpen(false);
|
setSettingsMenuOpen(false);
|
||||||
setAboutMenuOpen(false);
|
|
||||||
setFileMenuOpen((v) => !v);
|
setFileMenuOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -854,7 +908,6 @@ export function EditorApp() {
|
|||||||
if (!licenseActive) return;
|
if (!licenseActive) return;
|
||||||
setProjectMenuOpen(false);
|
setProjectMenuOpen(false);
|
||||||
setSettingsMenuOpen(false);
|
setSettingsMenuOpen(false);
|
||||||
setAboutMenuOpen(false);
|
|
||||||
setFileMenuOpen(false);
|
setFileMenuOpen(false);
|
||||||
setPlayersManagerOpen(true);
|
setPlayersManagerOpen(true);
|
||||||
}}
|
}}
|
||||||
@@ -862,6 +915,22 @@ export function EditorApp() {
|
|||||||
{t('top.players')}
|
{t('top.players')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="playlists-header-btn"
|
||||||
|
className={styles.fileMenuTrigger}
|
||||||
|
disabled={!licenseActive}
|
||||||
|
title={!licenseActive ? t('top.afterLicense') : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (!licenseActive) return;
|
||||||
|
setProjectMenuOpen(false);
|
||||||
|
setSettingsMenuOpen(false);
|
||||||
|
setFileMenuOpen(false);
|
||||||
|
setPlaylistsManagerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('top.playlists')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.flex1} />
|
<div className={styles.flex1} />
|
||||||
{appVersionText ? (
|
{appVersionText ? (
|
||||||
@@ -869,6 +938,19 @@ export function EditorApp() {
|
|||||||
{appVersionText}
|
{appVersionText}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
iconOnly
|
||||||
|
tooltipPlacement="bottom"
|
||||||
|
title={t('control.helpTool')}
|
||||||
|
ariaLabel={t('control.helpTool')}
|
||||||
|
data-testid="editor-help-tool"
|
||||||
|
onClick={() => openInstructions('overview')}
|
||||||
|
>
|
||||||
|
<span className={styles.helpMark} aria-hidden>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
{state.project ? (
|
{state.project ? (
|
||||||
<>
|
<>
|
||||||
@@ -907,6 +989,7 @@ export function EditorApp() {
|
|||||||
data-testid="run-menu-btn"
|
data-testid="run-menu-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (runDisabled || launching) return;
|
if (runDisabled || launching) return;
|
||||||
|
refreshGameSaves();
|
||||||
setRunMenuOpen((v) => !v);
|
setRunMenuOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -943,8 +1026,7 @@ export function EditorApp() {
|
|||||||
title={runHelpTooltip}
|
title={runHelpTooltip}
|
||||||
tooltipPlacement="bottom-left"
|
tooltipPlacement="bottom-left"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setInstructionsSection(runHelpSection);
|
openInstructions(runHelpSection);
|
||||||
setInstructionsOpen(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
@@ -1184,7 +1266,6 @@ export function EditorApp() {
|
|||||||
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
||||||
previewVideoLoop={sc?.settings.loopVideo ?? false}
|
previewVideoLoop={sc?.settings.loopVideo ?? false}
|
||||||
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
||||||
darkenScene={sc?.darkenScene ?? false}
|
|
||||||
previewBusy={previewBusy}
|
previewBusy={previewBusy}
|
||||||
previewBusyText={previewBusyText}
|
previewBusyText={previewBusyText}
|
||||||
mediaAssets={sceneMediaAssets}
|
mediaAssets={sceneMediaAssets}
|
||||||
@@ -1198,7 +1279,6 @@ export function EditorApp() {
|
|||||||
onPreviewVideoLoopChange={(next) =>
|
onPreviewVideoLoopChange={(next) =>
|
||||||
void actions.updateScene(sid, { settings: { loopVideo: next } })
|
void actions.updateScene(sid, { settings: { loopVideo: next } })
|
||||||
}
|
}
|
||||||
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
|
|
||||||
onTitleChange={(title) => void actions.updateScene(sid, { title })}
|
onTitleChange={(title) => void actions.updateScene(sid, { title })}
|
||||||
onDescriptionChange={(description) =>
|
onDescriptionChange={(description) =>
|
||||||
void actions.updateScene(sid, { description })
|
void actions.updateScene(sid, { description })
|
||||||
@@ -1320,6 +1400,30 @@ export function EditorApp() {
|
|||||||
{t('menu.checkUpdates')}
|
{t('menu.checkUpdates')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.fileMenuItem}
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsMenuOpen(false);
|
||||||
|
setSettingsLangSubOpen(false);
|
||||||
|
setPresentationSettingsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('menu.presentationSettings')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.fileMenuItem}
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsMenuOpen(false);
|
||||||
|
setSettingsLangSubOpen(false);
|
||||||
|
setGameSystemsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('menu.gameSystems')}
|
||||||
|
</button>
|
||||||
<div className={styles.fileMenuSubHost} role="presentation">
|
<div className={styles.fileMenuSubHost} role="presentation">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1401,46 +1505,6 @@ export function EditorApp() {
|
|||||||
snapshot={licenseSnap}
|
snapshot={licenseSnap}
|
||||||
/>
|
/>
|
||||||
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
|
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
|
||||||
<InstructionsModal
|
|
||||||
open={instructionsOpen}
|
|
||||||
initialSection={instructionsSection}
|
|
||||||
onClose={() => setInstructionsOpen(false)}
|
|
||||||
/>
|
|
||||||
{aboutMenuOpen && aboutMenuPos
|
|
||||||
? createPortal(
|
|
||||||
<div
|
|
||||||
role="menu"
|
|
||||||
data-aboutmenu-root="1"
|
|
||||||
className={styles.fileMenu}
|
|
||||||
style={{ left: aboutMenuPos.left, top: aboutMenuPos.top }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className={styles.fileMenuItem}
|
|
||||||
onClick={() => {
|
|
||||||
setAboutMenuOpen(false);
|
|
||||||
setAppAboutOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('menu.aboutProgram')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className={styles.fileMenuItem}
|
|
||||||
onClick={() => {
|
|
||||||
setAboutMenuOpen(false);
|
|
||||||
setInstructionsSection('overview');
|
|
||||||
setInstructionsOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('menu.instructions')}
|
|
||||||
</button>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
{projectMenuOpen && projectMenuPos
|
{projectMenuOpen && projectMenuPos
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -1547,8 +1611,8 @@ export function EditorApp() {
|
|||||||
storylineLabels={storylineLabels}
|
storylineLabels={storylineLabels}
|
||||||
loadStorylines={loadProjectStorylines}
|
loadStorylines={loadProjectStorylines}
|
||||||
onClose={() => setExportModalOpen(false)}
|
onClose={() => setExportModalOpen(false)}
|
||||||
onExport={async (projectId, selections, npcIds) => {
|
onExport={async (projectId, selections, npcIds, playlistIds) => {
|
||||||
await actions.exportProject(projectId, selections, npcIds, storylineLabels);
|
await actions.exportProject(projectId, selections, npcIds, storylineLabels, playlistIds);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ImportSourceModal
|
<ImportSourceModal
|
||||||
@@ -1638,6 +1702,30 @@ export function EditorApp() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
|
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
|
||||||
|
<PresentationSettingsModal
|
||||||
|
open={presentationSettingsOpen}
|
||||||
|
onClose={() => setPresentationSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
<GameSystemsModal
|
||||||
|
open={gameSystemsOpen}
|
||||||
|
onClose={() => setGameSystemsOpen(false)}
|
||||||
|
labels={{
|
||||||
|
title: t('gameSystems.title'),
|
||||||
|
import: t('gameSystems.import'),
|
||||||
|
preview: t('gameSystems.preview'),
|
||||||
|
remove: t('gameSystems.remove'),
|
||||||
|
empty: t('gameSystems.empty'),
|
||||||
|
close: t('gameSystems.close'),
|
||||||
|
}}
|
||||||
|
listSystems={listGameSystems}
|
||||||
|
importSystem={importGameSystem}
|
||||||
|
deleteSystem={deleteGameSystem}
|
||||||
|
previewSystem={previewGameSystem}
|
||||||
|
/>
|
||||||
|
<PlaylistsManagerModal
|
||||||
|
open={playlistsManagerOpen}
|
||||||
|
onClose={() => setPlaylistsManagerOpen(false)}
|
||||||
|
/>
|
||||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||||
<>
|
<>
|
||||||
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
|
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
|
||||||
@@ -1649,6 +1737,69 @@ export function EditorApp() {
|
|||||||
launchFromGraphNode(graphStartGraphNodeId, playerIds);
|
launchFromGraphNode(graphStartGraphNodeId, playerIds);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{loadSaveOpen
|
||||||
|
? createPortal(
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
className={styles.modalBackdrop}
|
||||||
|
onClick={() => {
|
||||||
|
if (!launching) setLoadSaveOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div role="dialog" aria-modal="true" className={styles.modalDialog} data-testid="load-save-modal">
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div className={styles.modalTitle}>{t('top.loadSave.title')}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.modalClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
onClick={() => {
|
||||||
|
if (!launching) setLoadSaveOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.saveList}>
|
||||||
|
{gameSaves.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
styles.saveRow,
|
||||||
|
loadSaveId === item.id ? styles.saveRowSelected : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={() => setLoadSaveId(item.id)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{loadSaveError ? <div className={styles.fieldError}>{loadSaveError}</div> : null}
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button onClick={() => setLoadSaveOpen(false)} disabled={launching}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
data-testid="load-save-commit"
|
||||||
|
disabled={!loadSaveId || launching}
|
||||||
|
onClick={() => {
|
||||||
|
if (loadSaveId) launchFromSave(loadSaveId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('top.loadSave.action')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
|
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
|
||||||
@@ -1671,6 +1822,22 @@ export function EditorApp() {
|
|||||||
>
|
>
|
||||||
{t('top.runWithPlayers')}
|
{t('top.runWithPlayers')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.fileMenuItem}
|
||||||
|
role="menuitem"
|
||||||
|
data-testid="run-load-save-item"
|
||||||
|
disabled={gameSaves.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (gameSaves.length === 0) return;
|
||||||
|
setRunMenuOpen(false);
|
||||||
|
setLoadSaveId(null);
|
||||||
|
setLoadSaveError('');
|
||||||
|
setLoadSaveOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('top.loadSave')}
|
||||||
|
</button>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)
|
)
|
||||||
@@ -2451,7 +2618,6 @@ type SceneInspectorProps = {
|
|||||||
previewVideoAutostart: boolean;
|
previewVideoAutostart: boolean;
|
||||||
previewVideoLoop: boolean;
|
previewVideoLoop: boolean;
|
||||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||||
darkenScene: boolean;
|
|
||||||
previewBusy: boolean;
|
previewBusy: boolean;
|
||||||
previewBusyText: string;
|
previewBusyText: string;
|
||||||
mediaAssets: MediaAsset[];
|
mediaAssets: MediaAsset[];
|
||||||
@@ -2459,7 +2625,6 @@ type SceneInspectorProps = {
|
|||||||
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||||
onPreviewVideoAutostartChange: (next: boolean) => void;
|
onPreviewVideoAutostartChange: (next: boolean) => void;
|
||||||
onPreviewVideoLoopChange: (next: boolean) => void;
|
onPreviewVideoLoopChange: (next: boolean) => void;
|
||||||
onDarkenSceneChange: (next: boolean) => void;
|
|
||||||
onTitleChange: (v: string) => void;
|
onTitleChange: (v: string) => void;
|
||||||
onDescriptionChange: (v: string) => void;
|
onDescriptionChange: (v: string) => void;
|
||||||
onImportPreview: () => void;
|
onImportPreview: () => void;
|
||||||
@@ -2487,7 +2652,6 @@ function CampaignInspector({
|
|||||||
onDropAudio,
|
onDropAudio,
|
||||||
}: CampaignInspectorProps) {
|
}: CampaignInspectorProps) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
|
|
||||||
const audioDrop = useFileDropZone({
|
const audioDrop = useFileDropZone({
|
||||||
onDropPaths: onDropAudio,
|
onDropPaths: onDropAudio,
|
||||||
filterPaths: filterAudioFilePaths,
|
filterPaths: filterAudioFilePaths,
|
||||||
@@ -2503,71 +2667,19 @@ function CampaignInspector({
|
|||||||
onDrop={audioDrop.onDrop}
|
onDrop={audioDrop.onDrop}
|
||||||
>
|
>
|
||||||
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
||||||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
{audioRefs.length === 0 ? (
|
||||||
<div className={styles.audioDropEmpty}>
|
<div className={styles.audioDropEmpty}>
|
||||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||||
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
|
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={styles.audioList}>
|
<EditorAudioTrackList
|
||||||
{mediaAssets
|
audioRefs={audioRefs}
|
||||||
.filter((a) => a.type === 'audio')
|
mediaAssets={mediaAssets}
|
||||||
.map((a) => (
|
removeTitle={t('campaign.removeTitle')}
|
||||||
<div key={a.id} className={styles.audioRow}>
|
onAudioRefsChange={onAudioRefsChange}
|
||||||
<span className={styles.audioName}>{a.originalName}</span>
|
|
||||||
<span className={styles.audioControls}>
|
|
||||||
<label className={styles.checkboxLabelSm}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={audioById.get(a.id)?.autoplay ?? false}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = audioRefs.map((x) =>
|
|
||||||
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
|
|
||||||
);
|
|
||||||
onAudioRefsChange(next);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<span className={styles.spanXs}>{t('campaign.auto')}</span>
|
|
||||||
</label>
|
|
||||||
<label className={styles.checkboxLabelSm}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={audioById.get(a.id)?.loop ?? false}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = audioRefs.map((x) =>
|
|
||||||
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
|
|
||||||
);
|
|
||||||
onAudioRefsChange(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className={styles.spanXs}>{t('campaign.loop')}</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t('campaign.removeTitle')}
|
|
||||||
className={styles.audioRemove}
|
|
||||||
onClick={() => {
|
|
||||||
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={styles.audioRemoveIcon}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
|
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -2585,7 +2697,6 @@ function SceneInspector({
|
|||||||
previewVideoAutostart,
|
previewVideoAutostart,
|
||||||
previewVideoLoop,
|
previewVideoLoop,
|
||||||
previewRotationDeg,
|
previewRotationDeg,
|
||||||
darkenScene,
|
|
||||||
previewBusy,
|
previewBusy,
|
||||||
previewBusyText,
|
previewBusyText,
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
@@ -2593,7 +2704,6 @@ function SceneInspector({
|
|||||||
onAudioRefsChange,
|
onAudioRefsChange,
|
||||||
onPreviewVideoAutostartChange,
|
onPreviewVideoAutostartChange,
|
||||||
onPreviewVideoLoopChange,
|
onPreviewVideoLoopChange,
|
||||||
onDarkenSceneChange,
|
|
||||||
onTitleChange,
|
onTitleChange,
|
||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
onImportPreview,
|
onImportPreview,
|
||||||
@@ -2607,7 +2717,6 @@ function SceneInspector({
|
|||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const [descriptionModalOpen, setDescriptionModalOpen] = useState(false);
|
const [descriptionModalOpen, setDescriptionModalOpen] = useState(false);
|
||||||
const previewUrl = useAssetUrl(previewAssetId);
|
const previewUrl = useAssetUrl(previewAssetId);
|
||||||
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
|
|
||||||
const descriptionEmpty = isSceneDescriptionEmpty(description);
|
const descriptionEmpty = isSceneDescriptionEmpty(description);
|
||||||
const descriptionPreviewHtml = useMemo(
|
const descriptionPreviewHtml = useMemo(
|
||||||
() => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)),
|
() => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)),
|
||||||
@@ -2748,8 +2857,7 @@ function SceneInspector({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
||||||
<>
|
<div className={styles.actionsRowHalf}>
|
||||||
<div className={styles.spacer6} />
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
|
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
|
||||||
@@ -2758,28 +2866,14 @@ function SceneInspector({
|
|||||||
>
|
>
|
||||||
{t('scene.rotate')}
|
{t('scene.rotate')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
|
||||||
<>
|
|
||||||
<div className={styles.spacer6} />
|
|
||||||
<label className={styles.checkboxLabel}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={darkenScene}
|
|
||||||
onChange={(e) => onDarkenSceneChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
|
|
||||||
</label>
|
|
||||||
<div className={styles.spacer6} />
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
|
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Редактор сцены
|
{t('sceneEditor.open')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={styles.spacer6} />
|
<div className={styles.spacer6} />
|
||||||
<div className={styles.labelSm}>{t('scene.audio')}</div>
|
<div className={styles.labelSm}>{t('scene.audio')}</div>
|
||||||
@@ -2791,71 +2885,19 @@ function SceneInspector({
|
|||||||
onDrop={sceneAudioDrop.onDrop}
|
onDrop={sceneAudioDrop.onDrop}
|
||||||
>
|
>
|
||||||
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
||||||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
{audioRefs.length === 0 ? (
|
||||||
<div className={styles.audioDropEmpty}>
|
<div className={styles.audioDropEmpty}>
|
||||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||||
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
|
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={styles.audioList}>
|
<EditorAudioTrackList
|
||||||
{mediaAssets
|
audioRefs={audioRefs}
|
||||||
.filter((a) => a.type === 'audio')
|
mediaAssets={mediaAssets}
|
||||||
.map((a) => (
|
removeTitle={t('scene.removeTitle')}
|
||||||
<div key={a.id} className={styles.audioRow}>
|
onAudioRefsChange={onAudioRefsChange}
|
||||||
<span className={styles.audioName}>{a.originalName}</span>
|
|
||||||
<span className={styles.audioControls}>
|
|
||||||
<label className={styles.checkboxLabelSm}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={audioById.get(a.id)?.autoplay ?? false}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = audioRefs.map((x) =>
|
|
||||||
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
|
|
||||||
);
|
|
||||||
onAudioRefsChange(next);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<span className={styles.spanXs}>{t('campaign.auto')}</span>
|
|
||||||
</label>
|
|
||||||
<label className={styles.checkboxLabelSm}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={audioById.get(a.id)?.loop ?? false}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = audioRefs.map((x) =>
|
|
||||||
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
|
|
||||||
);
|
|
||||||
onAudioRefsChange(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className={styles.spanXs}>{t('campaign.loop')}</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t('scene.removeTitle')}
|
|
||||||
className={styles.audioRemove}
|
|
||||||
onClick={() => {
|
|
||||||
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={styles.audioRemoveIcon}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
|
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
import {
|
||||||
|
clampSceneAudioVolume,
|
||||||
|
type AssetId,
|
||||||
|
type MediaAsset,
|
||||||
|
type SceneAudioRef,
|
||||||
|
} from '../../shared/types';
|
||||||
|
import { getDndApi } from '../shared/dndApi';
|
||||||
|
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||||
|
import { registerEditorAudioStopper } from '../shared/audio/editorAudioStop';
|
||||||
|
|
||||||
|
import styles from './EditorApp.module.css';
|
||||||
|
|
||||||
|
const AUDIO_REF_DND = 'application/x-dnd-scene-audio-ref';
|
||||||
|
|
||||||
|
function reorderRefs(refs: SceneAudioRef[], draggedId: AssetId, targetId: AssetId): SceneAudioRef[] {
|
||||||
|
if (draggedId === targetId) return refs;
|
||||||
|
const from = refs.findIndex((r) => r.assetId === draggedId);
|
||||||
|
const to = refs.findIndex((r) => r.assetId === targetId);
|
||||||
|
if (from < 0 || to < 0) return refs;
|
||||||
|
const next = [...refs];
|
||||||
|
const [item] = next.splice(from, 1);
|
||||||
|
if (!item) return refs;
|
||||||
|
next.splice(to, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Локальный draft — не пишет в проект на каждый tick ползунка (иначе мигает граф). */
|
||||||
|
function AudioVolumeSlider({
|
||||||
|
assetId,
|
||||||
|
volume,
|
||||||
|
isPlaying,
|
||||||
|
previewElRef,
|
||||||
|
label,
|
||||||
|
onCommit,
|
||||||
|
}: {
|
||||||
|
assetId: AssetId;
|
||||||
|
volume: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
previewElRef: React.MutableRefObject<HTMLAudioElement | null>;
|
||||||
|
label: string;
|
||||||
|
onCommit: (volume: number) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(() => clampSceneAudioVolume(volume));
|
||||||
|
const draftRef = useRef(draft);
|
||||||
|
draftRef.current = draft;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(clampSceneAudioVolume(volume));
|
||||||
|
}, [assetId, volume]);
|
||||||
|
|
||||||
|
const applyLocal = (v: number) => {
|
||||||
|
const next = clampSceneAudioVolume(v);
|
||||||
|
setDraft(next);
|
||||||
|
if (isPlaying && previewElRef.current) previewElRef.current.volume = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
const next = clampSceneAudioVolume(draftRef.current);
|
||||||
|
if (next === clampSceneAudioVolume(volume)) return;
|
||||||
|
onCommit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={styles.audioVolumeLabel} title={label}>
|
||||||
|
<span className={styles.spanXs}>{label}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={styles.audioVolumeRange}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={draft}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={(e) => applyLocal(Number(e.target.value))}
|
||||||
|
onPointerUp={commit}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') {
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditorAudioTrackListProps = {
|
||||||
|
audioRefs: SceneAudioRef[];
|
||||||
|
mediaAssets: MediaAsset[];
|
||||||
|
removeTitle: string;
|
||||||
|
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditorAudioTrackList({
|
||||||
|
audioRefs,
|
||||||
|
mediaAssets,
|
||||||
|
removeTitle,
|
||||||
|
onAudioRefsChange,
|
||||||
|
}: EditorAudioTrackListProps) {
|
||||||
|
const { t } = useEditorI18n();
|
||||||
|
const assetById = useMemo(() => new Map(mediaAssets.map((a) => [a.id, a])), [mediaAssets]);
|
||||||
|
const [playingId, setPlayingId] = useState<AssetId | null>(null);
|
||||||
|
const previewElRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const el = previewElRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.pause();
|
||||||
|
el.src = '';
|
||||||
|
previewElRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return registerEditorAudioStopper(() => {
|
||||||
|
const el = previewElRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.pause();
|
||||||
|
el.onended = null;
|
||||||
|
el.src = '';
|
||||||
|
}
|
||||||
|
previewElRef.current = null;
|
||||||
|
setPlayingId(null);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopPreview = () => {
|
||||||
|
const el = previewElRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.pause();
|
||||||
|
el.onended = null;
|
||||||
|
el.src = '';
|
||||||
|
}
|
||||||
|
previewElRef.current = null;
|
||||||
|
setPlayingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePreview = async (assetId: AssetId, volume: number) => {
|
||||||
|
if (playingId === assetId) {
|
||||||
|
stopPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopPreview();
|
||||||
|
try {
|
||||||
|
const r = await getDndApi().invoke(ipcChannels.project.assetFileUrl, { assetId });
|
||||||
|
const url = r.url;
|
||||||
|
if (!url) return;
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.volume = clampSceneAudioVolume(volume);
|
||||||
|
el.onended = () => {
|
||||||
|
previewElRef.current = null;
|
||||||
|
setPlayingId(null);
|
||||||
|
};
|
||||||
|
previewElRef.current = el;
|
||||||
|
setPlayingId(assetId);
|
||||||
|
await el.play();
|
||||||
|
} catch {
|
||||||
|
stopPreview();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (audioRefs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.audioList}>
|
||||||
|
{audioRefs.map((ref) => {
|
||||||
|
const asset = assetById.get(ref.assetId);
|
||||||
|
const name = asset?.originalName ?? ref.assetId;
|
||||||
|
const volume = clampSceneAudioVolume(ref.volume);
|
||||||
|
const isPlaying = playingId === ref.assetId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ref.assetId}
|
||||||
|
className={styles.audioRow}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (e.dataTransfer.types.includes(AUDIO_REF_DND)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const dragged = e.dataTransfer.getData(AUDIO_REF_DND) as AssetId;
|
||||||
|
if (!dragged) return;
|
||||||
|
const next = reorderRefs(audioRefs, dragged, ref.assetId);
|
||||||
|
if (next !== audioRefs) onAudioRefsChange(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.audioDragHandle}
|
||||||
|
draggable
|
||||||
|
title={name}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData(AUDIO_REF_DND, ref.assetId);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.audioDragGrip} aria-hidden>
|
||||||
|
⋮⋮
|
||||||
|
</span>
|
||||||
|
<span className={styles.audioName}>{name}</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.audioControls}>
|
||||||
|
<label className={styles.checkboxLabelSm}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ref.autoplay}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = audioRefs.map((x) =>
|
||||||
|
x.assetId === ref.assetId ? { ...x, autoplay: e.target.checked } : x,
|
||||||
|
);
|
||||||
|
onAudioRefsChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className={styles.spanXs}>{t('campaign.auto')}</span>
|
||||||
|
</label>
|
||||||
|
<label className={styles.checkboxLabelSm}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ref.loop}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = audioRefs.map((x) =>
|
||||||
|
x.assetId === ref.assetId ? { ...x, loop: e.target.checked } : x,
|
||||||
|
);
|
||||||
|
onAudioRefsChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className={styles.spanXs}>{t('campaign.loop')}</span>
|
||||||
|
</label>
|
||||||
|
<AudioVolumeSlider
|
||||||
|
assetId={ref.assetId}
|
||||||
|
volume={volume}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
previewElRef={previewElRef}
|
||||||
|
label={t('campaign.volume')}
|
||||||
|
onCommit={(v) => {
|
||||||
|
const next = audioRefs.map((x) =>
|
||||||
|
x.assetId === ref.assetId ? { ...x, volume: v } : x,
|
||||||
|
);
|
||||||
|
onAudioRefsChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.audioPreviewBtn}
|
||||||
|
title={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
|
||||||
|
aria-label={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
|
||||||
|
onClick={() => void togglePreview(ref.assetId, volume)}
|
||||||
|
>
|
||||||
|
{isPlaying ? '■' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={removeTitle}
|
||||||
|
className={styles.audioRemove}
|
||||||
|
onClick={() => {
|
||||||
|
if (playingId === ref.assetId) stopPreview();
|
||||||
|
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== ref.assetId));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={styles.audioRemoveIcon}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type { GameSystemListItem } from '../../shared/ipc/contracts';
|
||||||
|
|
||||||
|
export type { GameSystemListItem };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
labels: {
|
||||||
|
title: string;
|
||||||
|
import: string;
|
||||||
|
preview: string;
|
||||||
|
remove: string;
|
||||||
|
empty: string;
|
||||||
|
close: string;
|
||||||
|
};
|
||||||
|
listSystems: () => Promise<GameSystemListItem[]>;
|
||||||
|
importSystem: () => Promise<GameSystemListItem | null>;
|
||||||
|
deleteSystem: (id: string) => Promise<void>;
|
||||||
|
previewSystem: (id: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GameSystemsModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
labels,
|
||||||
|
listSystems,
|
||||||
|
importSystem,
|
||||||
|
deleteSystem,
|
||||||
|
previewSystem,
|
||||||
|
}: Props) {
|
||||||
|
const [items, setItems] = useState<GameSystemListItem[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void (async () => {
|
||||||
|
setItems(await listSystems());
|
||||||
|
})();
|
||||||
|
}, [open, listSystems]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="game-systems-modal"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.55)',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
zIndex: 10000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 560,
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
background: 'rgba(10,10,14,0.98)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
color: 'rgba(255,255,255,0.92)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<strong>{labels.title}</strong>
|
||||||
|
<button type="button" data-testid="game-systems-close" onClick={onClose}>
|
||||||
|
{labels.close}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="game-systems-import"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await importSystem();
|
||||||
|
setItems(await listSystems());
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labels.import}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div style={{ opacity: 0.6 }}>{labels.empty}</div>
|
||||||
|
) : (
|
||||||
|
items.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
data-testid={`game-system-${s.id}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
padding: '10px 0',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div>{s.name}</div>
|
||||||
|
<div style={{ opacity: 0.55, fontSize: 12 }}>
|
||||||
|
{s.id} · r{s.revision}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button type="button" onClick={() => void previewSystem(s.id)}>
|
||||||
|
{labels.preview}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteSystem(s.id);
|
||||||
|
setItems(await listSystems());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labels.remove}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import styles from './EditorApp.module.css';
|
|||||||
import {
|
import {
|
||||||
filterMaterialImagePaths,
|
filterMaterialImagePaths,
|
||||||
getDroppedFileEntries,
|
getDroppedFileEntries,
|
||||||
|
nameFromFilePath,
|
||||||
pickFirstMaterialImagePath,
|
pickFirstMaterialImagePath,
|
||||||
useFileDropZone,
|
useFileDropZone,
|
||||||
} from './fileDrop';
|
} from './fileDrop';
|
||||||
@@ -86,6 +87,11 @@ export function MaterialEditModal({
|
|||||||
|
|
||||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||||
setFilePath(path);
|
setFilePath(path);
|
||||||
|
setName((prev) => {
|
||||||
|
if (prev.trim()) return prev;
|
||||||
|
const fromFile = nameFromFilePath(path);
|
||||||
|
return fromFile || prev;
|
||||||
|
});
|
||||||
setLocalPreviewUrl((prev) => {
|
setLocalPreviewUrl((prev) => {
|
||||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||||
return previewUrl;
|
return previewUrl;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.dialog {
|
||||||
|
width: min(960px, calc(100vw - 48px));
|
||||||
|
height: min(720px, calc(100vh - 48px));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 4px 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { Button } from '../shared/ui/controls';
|
||||||
|
import {
|
||||||
|
PlaylistsPanel,
|
||||||
|
playlistsPanelLabelsFromT,
|
||||||
|
} from '../shared/playlists/PlaylistsPanel';
|
||||||
|
|
||||||
|
import styles from './EditorApp.module.css';
|
||||||
|
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||||
|
import panelStyles from './PlaylistsModal.module.css';
|
||||||
|
|
||||||
|
export function PlaylistsManagerModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useEditorI18n();
|
||||||
|
const labels = useMemo(() => playlistsPanelLabelsFromT(t), [t]);
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className={[styles.modalDialog, panelStyles.dialog].join(' ')}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
data-testid="playlists-modal"
|
||||||
|
>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div className={styles.modalTitle}>{t('playlists.title')}</div>
|
||||||
|
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={panelStyles.body}>
|
||||||
|
<PlaylistsPanel labels={labels} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.dialog {
|
||||||
|
width: min(480px, calc(100vw - 48px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-overlay-dark-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--accent-fill-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowMeta {
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullScreenRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 10px 8px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
import type { DisplayInfo, PresentationSettings } from '../../shared/types/presentationSettings';
|
||||||
|
import { getDndApi } from '../shared/dndApi';
|
||||||
|
import { Button } from '../shared/ui/controls';
|
||||||
|
|
||||||
|
import styles from './EditorApp.module.css';
|
||||||
|
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||||
|
import modalStyles from './PresentationSettingsModal.module.css';
|
||||||
|
|
||||||
|
export function PresentationSettingsModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useEditorI18n();
|
||||||
|
const api = getDndApi();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [displays, setDisplays] = useState<DisplayInfo[]>([]);
|
||||||
|
const [draft, setDraft] = useState<PresentationSettings>({ displayId: null, fullScreen: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [{ displays: list }, { settings }] = await Promise.all([
|
||||||
|
api.invoke(ipcChannels.windows.listDisplays, {}),
|
||||||
|
api.invoke(ipcChannels.preferences.getPresentation, {}),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setDisplays(list);
|
||||||
|
setDraft(settings);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [api, open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const primaryId = displays.find((d) => d.isPrimary)?.id ?? null;
|
||||||
|
const settings: PresentationSettings = {
|
||||||
|
...draft,
|
||||||
|
displayId: draft.displayId === primaryId ? null : draft.displayId,
|
||||||
|
};
|
||||||
|
await api.invoke(ipcChannels.preferences.setPresentation, { settings });
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisplaySelected = (d: DisplayInfo) =>
|
||||||
|
draft.displayId === d.id || (draft.displayId === null && d.isPrimary);
|
||||||
|
|
||||||
|
const displayLabel = (d: DisplayInfo) => {
|
||||||
|
const primary = d.isPrimary ? ` (${t('settings.presentation.primary')})` : '';
|
||||||
|
return `${d.label}${primary} — ${d.width}×${d.height}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className={[styles.modalDialog, modalStyles.dialog].join(' ')}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
data-testid="presentation-settings-modal"
|
||||||
|
>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div className={styles.modalTitle}>{t('settings.presentation.title')}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
onClick={onClose}
|
||||||
|
className={styles.modalClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={modalStyles.hint}>{t('settings.presentation.hint')}</p>
|
||||||
|
<section className={modalStyles.section}>
|
||||||
|
<div className={modalStyles.sectionTitle}>{t('settings.presentation.display')}</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className={modalStyles.loading}>{t('settings.presentation.loading')}</div>
|
||||||
|
) : (
|
||||||
|
<div className={modalStyles.list} role="radiogroup" aria-label={t('settings.presentation.display')}>
|
||||||
|
{displays.map((d) => (
|
||||||
|
<label key={d.id} className={modalStyles.row}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="presentation-display"
|
||||||
|
checked={isDisplaySelected(d)}
|
||||||
|
onChange={() => setDraft((prev) => ({ ...prev, displayId: d.id }))}
|
||||||
|
data-testid={`presentation-display-${d.id}`}
|
||||||
|
/>
|
||||||
|
<span>{displayLabel(d)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<label className={modalStyles.fullScreenRow}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.fullScreen}
|
||||||
|
onChange={(e) => setDraft((prev) => ({ ...prev, fullScreen: e.target.checked }))}
|
||||||
|
data-testid="presentation-fullscreen"
|
||||||
|
/>
|
||||||
|
<span>{t('settings.presentation.fullScreen')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => void save()} disabled={loading || saving} data-testid="presentation-settings-save">
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../../shared/graph/storylineExportImport';
|
} from '../../shared/graph/storylineExportImport';
|
||||||
import type { Project, ProjectId, SceneId } from '../../shared/types';
|
import type { Project, ProjectId, SceneId } from '../../shared/types';
|
||||||
import { Button, Select } from '../shared/ui/controls';
|
import { Button, Select } from '../shared/ui/controls';
|
||||||
|
import { useAppPlaylists } from '../shared/playlists/useAppPlaylists';
|
||||||
|
|
||||||
import styles from './EditorApp.module.css';
|
import styles from './EditorApp.module.css';
|
||||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||||
@@ -37,6 +38,7 @@ type ExportProjectModalProps = {
|
|||||||
projectId: ProjectId,
|
projectId: ProjectId,
|
||||||
selections: StorylineSelection[],
|
selections: StorylineSelection[],
|
||||||
npcIds: string[],
|
npcIds: string[],
|
||||||
|
playlistIds: string[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,12 +52,14 @@ export function ExportProjectModal({
|
|||||||
onExport,
|
onExport,
|
||||||
}: ExportProjectModalProps) {
|
}: ExportProjectModalProps) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
|
const playlistsState = useAppPlaylists();
|
||||||
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
|
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
|
||||||
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
||||||
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
||||||
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
|
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
|
||||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
|
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedPlaylistIds, setSelectedPlaylistIds] = useState<Set<string>>(new Set());
|
||||||
const [loadingStorylines, setLoadingStorylines] = useState(false);
|
const [loadingStorylines, setLoadingStorylines] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -67,6 +71,7 @@ export function ExportProjectModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
setSelectedNpcIds(new Set());
|
setSelectedNpcIds(new Set());
|
||||||
|
setSelectedPlaylistIds(new Set());
|
||||||
setStep('storylines');
|
setStep('storylines');
|
||||||
setNpcs([]);
|
setNpcs([]);
|
||||||
}, [initialProjectId, open]);
|
}, [initialProjectId, open]);
|
||||||
@@ -156,7 +161,7 @@ export function ExportProjectModal({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await onExport(projectId, selectedSelections, npcIds);
|
await onExport(projectId, selectedSelections, npcIds, [...selectedPlaylistIds]);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
@@ -168,7 +173,7 @@ export function ExportProjectModal({
|
|||||||
|
|
||||||
const goNextFromStorylines = () => {
|
const goNextFromStorylines = () => {
|
||||||
if (!canContinueStorylines) return;
|
if (!canContinueStorylines) return;
|
||||||
if (npcs.length === 0) {
|
if (npcs.length === 0 && playlistsState.playlists.length === 0) {
|
||||||
runExport([]);
|
runExport([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -248,6 +253,8 @@ export function ExportProjectModal({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.fieldGrid}>
|
<div className={styles.fieldGrid}>
|
||||||
|
{npcs.length > 0 ? (
|
||||||
|
<>
|
||||||
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
||||||
<div className={styles.storylineChecklist}>
|
<div className={styles.storylineChecklist}>
|
||||||
{npcs.map((n) => (
|
{npcs.map((n) => (
|
||||||
@@ -268,6 +275,36 @@ export function ExportProjectModal({
|
|||||||
>
|
>
|
||||||
{t('export.selectAllNpcs')}
|
{t('export.selectAllNpcs')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{playlistsState.playlists.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.fieldLabel}>{t('export.playlists')}</div>
|
||||||
|
<div className={styles.muted}>{t('export.playlistsHint')}</div>
|
||||||
|
<div className={styles.storylineChecklist}>
|
||||||
|
{playlistsState.playlists.map((pl) => (
|
||||||
|
<label key={pl.id} className={styles.storylineCheck}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPlaylistIds.has(pl.id)}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={() => {
|
||||||
|
setSelectedPlaylistIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(pl.id)) next.delete(pl.id);
|
||||||
|
else next.add(pl.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{pl.name} ({pl.tracks.length})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -289,7 +326,7 @@ export function ExportProjectModal({
|
|||||||
disabled={!canContinueStorylines || saving}
|
disabled={!canContinueStorylines || saving}
|
||||||
onClick={goNextFromStorylines}
|
onClick={goNextFromStorylines}
|
||||||
>
|
>
|
||||||
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
|
{npcs.length > 0 || playlistsState.playlists.length > 0 ? t('export.next') : t('export.saveAs')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
|
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
|
||||||
import { Button } from '../../shared/ui/controls';
|
import { Button } from '../../shared/ui/controls';
|
||||||
import styles from '../EditorApp.module.css';
|
import styles from '../EditorApp.module.css';
|
||||||
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../help/helpLinkify';
|
|
||||||
import {
|
|
||||||
HELP_SECTION_IDS,
|
|
||||||
helpSectionBodyKey,
|
|
||||||
helpSectionTitleKey,
|
|
||||||
type HelpSectionId,
|
|
||||||
} from '../help/helpSections';
|
|
||||||
import { useEditorI18n } from '../i18n/EditorI18nContext';
|
import { useEditorI18n } from '../i18n/EditorI18nContext';
|
||||||
|
|
||||||
|
const ABOUT_SPONSORS = [
|
||||||
|
{ kind: 'text', name: 'Тифлинг бард 😈❤️' },
|
||||||
|
{
|
||||||
|
kind: 'link',
|
||||||
|
name: 'Клуб настольных и бизнес-игр "КвестИя" - Подчуфарова Евгения Александровна',
|
||||||
|
url: 'https://vk.ru/club210361604',
|
||||||
|
},
|
||||||
|
{ kind: 'link', name: 'Старые кости', url: 'https://boosty.to/tabletoppodcasts' },
|
||||||
|
{ kind: 'text', name: 'Майер С.В. (Клуб «Серый Май»)' },
|
||||||
|
{ kind: 'link', name: 'true_rus(КАВ)', url: 'https://truecorp.su/' },
|
||||||
|
{ kind: 'text', name: 'Макс LonelyFlame Чурсин' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
type AppAboutModalProps = {
|
type AppAboutModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -80,123 +86,25 @@ export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps)
|
|||||||
{t('app.about.websiteUrl')}
|
{t('app.about.websiteUrl')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={styles.fieldLabel}>{t('app.about.sponsorsLabel')}</div>
|
||||||
</div>
|
<div className={styles.aboutSponsorsList}>
|
||||||
<div className={styles.modalFooter}>
|
{ABOUT_SPONSORS.map((sponsor) =>
|
||||||
<Button variant="primary" onClick={onClose}>
|
sponsor.kind === 'link' ? (
|
||||||
{t('common.close')}
|
<a
|
||||||
</Button>
|
key={sponsor.name}
|
||||||
</div>
|
className={styles.aboutLink}
|
||||||
</div>
|
href={sponsor.url}
|
||||||
</>,
|
target="_blank"
|
||||||
document.body,
|
rel="noreferrer"
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type InstructionsModalProps = {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
initialSection?: HelpSectionId;
|
|
||||||
};
|
|
||||||
|
|
||||||
function InstructionsModalBody({
|
|
||||||
initialSection,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
initialSection: HelpSectionId;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useEditorI18n();
|
|
||||||
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const navRef = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
contentRef.current?.scrollTo({ top: 0 });
|
|
||||||
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
|
||||||
activeNav?.scrollIntoView({ block: 'nearest' });
|
|
||||||
}, [activeId]);
|
|
||||||
|
|
||||||
const linkCatalog = useMemo(
|
|
||||||
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const body = t(helpSectionBodyKey(activeId));
|
|
||||||
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
|
||||||
|
|
||||||
const goToSection = (id: HelpSectionId) => {
|
|
||||||
setActiveId(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t('common.close')}
|
|
||||||
onClick={onClose}
|
|
||||||
className={styles.modalBackdrop}
|
|
||||||
/>
|
|
||||||
<div role="dialog" aria-modal="true" className={styles.instructionsDialog}>
|
|
||||||
<div className={styles.modalHeader}>
|
|
||||||
<div className={styles.modalTitle}>{t('help.title')}</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t('common.close')}
|
|
||||||
onClick={onClose}
|
|
||||||
className={styles.modalClose}
|
|
||||||
>
|
>
|
||||||
×
|
{sponsor.name}
|
||||||
</button>
|
</a>
|
||||||
</div>
|
|
||||||
<div className={styles.instructionsLayout}>
|
|
||||||
<div ref={contentRef} className={styles.instructionsContent}>
|
|
||||||
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
|
||||||
{paragraphs.map((p, i) => (
|
|
||||||
<p key={i} className={styles.instructionsParagraph}>
|
|
||||||
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
|
|
||||||
part.type === 'text' ? (
|
|
||||||
<React.Fragment key={j}>{part.value}</React.Fragment>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div key={sponsor.name}>{sponsor.name}</div>
|
||||||
key={j}
|
|
||||||
type="button"
|
|
||||||
className={styles.instructionsInlineLink}
|
|
||||||
onClick={() => goToSection(part.id)}
|
|
||||||
>
|
|
||||||
{part.value}
|
|
||||||
</button>
|
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}>
|
</div>
|
||||||
{HELP_SECTION_IDS.map((id) => (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
type="button"
|
|
||||||
className={[
|
|
||||||
styles.instructionsNavItem,
|
|
||||||
id === activeId ? styles.instructionsNavItemActive : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
aria-current={id === activeId ? 'true' : undefined}
|
|
||||||
onClick={() => goToSection(id)}
|
|
||||||
>
|
|
||||||
{t(helpSectionTitleKey(id))}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalFooter}>
|
<div className={styles.modalFooter}>
|
||||||
<Button variant="primary" onClick={onClose}>
|
<Button variant="primary" onClick={onClose}>
|
||||||
@@ -208,9 +116,3 @@ function InstructionsModalBody({
|
|||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) {
|
|
||||||
if (!open) return null;
|
|
||||||
const section = initialSection ?? 'overview';
|
|
||||||
return <InstructionsModalBody key={section} initialSection={section} onClose={onClose} />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -69,10 +69,14 @@ export function isPreviewMediaPath(filePath: string): boolean {
|
|||||||
return PREVIEW_EXTENSIONS.has(fileExtension(filePath));
|
return PREVIEW_EXTENSIONS.has(fileExtension(filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sceneTitleFromMediaPath(filePath: string): string {
|
export function nameFromFilePath(filePath: string): string {
|
||||||
const base = filePath.split(/[/\\]/).pop() ?? filePath;
|
const base = filePath.split(/[/\\]/).pop() ?? filePath;
|
||||||
const dot = base.lastIndexOf('.');
|
const dot = base.lastIndexOf('.');
|
||||||
const title = (dot > 0 ? base.slice(0, dot) : base).trim();
|
return (dot > 0 ? base.slice(0, dot) : base).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sceneTitleFromMediaPath(filePath: string): string {
|
||||||
|
const title = nameFromFilePath(filePath);
|
||||||
return title.length > 0 ? title : 'Новая сцена';
|
return title.length > 0 ? title : 'Новая сцена';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function minimalProject(overrides: Partial<Project>): Project {
|
|||||||
assets: {},
|
assets: {},
|
||||||
campaignAudios: [],
|
campaignAudios: [],
|
||||||
materials: [],
|
materials: [],
|
||||||
|
gridTemplates: [],
|
||||||
npcs: [],
|
npcs: [],
|
||||||
npcRelations: [],
|
npcRelations: [],
|
||||||
currentSceneId: null,
|
currentSceneId: null,
|
||||||
@@ -41,7 +42,7 @@ void test('buildNextSceneCardById: does not change refs when irrelevant fields c
|
|||||||
id: sid,
|
id: sid,
|
||||||
title: 'T',
|
title: 'T',
|
||||||
description: 'A',
|
description: 'A',
|
||||||
media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true }] },
|
media: { videos: [], audios: [{ assetId: 'a1' as AssetId, autoplay: false, loop: true, volume: 1 }] },
|
||||||
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
||||||
connections: [],
|
connections: [],
|
||||||
layout: { x: 0, y: 0 },
|
layout: { x: 0, y: 0 },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import type { HelpSectionId } from './helpSections';
|
import { parseHelpSectionId, type HelpSectionId } from './helpSections';
|
||||||
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
|
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
|
||||||
|
|
||||||
const RU_TITLES: Record<HelpSectionId, string> = {
|
const RU_TITLES: Record<HelpSectionId, string> = {
|
||||||
@@ -80,3 +80,9 @@ void test('findHelpLinkRanges: полное «Пульт управления»
|
|||||||
assert.equal(ranges[0]?.id, 'controlPanel');
|
assert.equal(ranges[0]?.id, 'controlPanel');
|
||||||
assert.equal(ranges[0]?.text, '«Пульт управления»');
|
assert.equal(ranges[0]?.text, '«Пульт управления»');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('parseHelpSectionId: unknown falls back to overview', () => {
|
||||||
|
assert.equal(parseHelpSectionId('grid'), 'grid');
|
||||||
|
assert.equal(parseHelpSectionId(''), 'overview');
|
||||||
|
assert.equal(parseHelpSectionId('not-a-section'), 'overview');
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
|
|||||||
effects: ['Эффекты', 'Effects'],
|
effects: ['Эффекты', 'Effects'],
|
||||||
presentation: ['Презентация', 'Presentation'],
|
presentation: ['Презентация', 'Presentation'],
|
||||||
grid: ['Сетка', 'Grid'],
|
grid: ['Сетка', 'Grid'],
|
||||||
|
sceneEditor: ['Затемнение сцены', 'Scene darkness', 'Редактор сцены', 'Scene editor'],
|
||||||
controlPanel: ['Пульт'],
|
controlPanel: ['Пульт'],
|
||||||
players: ['Игроки', 'Players'],
|
players: ['Игроки', 'Players'],
|
||||||
tokens: ['Токены', 'Tokens'],
|
tokens: ['Токены', 'Tokens'],
|
||||||
|
|||||||
@@ -40,3 +40,9 @@ export function helpSectionTitleKey(id: HelpSectionId): string {
|
|||||||
export function helpSectionBodyKey(id: HelpSectionId): string {
|
export function helpSectionBodyKey(id: HelpSectionId): string {
|
||||||
return `help.section.${id}.body`;
|
return `help.section.${id}.body`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseHelpSectionId(raw: unknown): HelpSectionId {
|
||||||
|
const s = String(raw ?? '').trim();
|
||||||
|
if ((HELP_SECTION_IDS as readonly string[]).includes(s)) return s as HelpSectionId;
|
||||||
|
return 'overview';
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -43,6 +43,7 @@ type Actions = {
|
|||||||
createProject: (name: string) => Promise<void>;
|
createProject: (name: string) => Promise<void>;
|
||||||
openProject: (id: ProjectId) => Promise<void>;
|
openProject: (id: ProjectId) => Promise<void>;
|
||||||
closeProject: () => Promise<void>;
|
closeProject: () => Promise<void>;
|
||||||
|
noteProject: (project: Project) => void;
|
||||||
createScene: () => Promise<void>;
|
createScene: () => Promise<void>;
|
||||||
createScenesFromMediaPaths: (
|
createScenesFromMediaPaths: (
|
||||||
items: { filePath: string; title: string }[],
|
items: { filePath: string; title: string }[],
|
||||||
@@ -71,6 +72,7 @@ type Actions = {
|
|||||||
previewVideoAutostart?: boolean;
|
previewVideoAutostart?: boolean;
|
||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||||
darkenScene?: boolean;
|
darkenScene?: boolean;
|
||||||
|
darknessStrokes?: Scene['darknessStrokes'];
|
||||||
settings?: Partial<Scene['settings']>;
|
settings?: Partial<Scene['settings']>;
|
||||||
media?: Partial<Scene['media']>;
|
media?: Partial<Scene['media']>;
|
||||||
layout?: { x: number; y: number };
|
layout?: { x: number; y: number };
|
||||||
@@ -157,6 +159,7 @@ type Actions = {
|
|||||||
storylineSelections: StorylineSelection[],
|
storylineSelections: StorylineSelection[],
|
||||||
npcIds: string[],
|
npcIds: string[],
|
||||||
labels: StorylineLabels,
|
labels: StorylineLabels,
|
||||||
|
playlistIds?: string[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
deleteProject: (projectId: ProjectId) => Promise<void>;
|
deleteProject: (projectId: ProjectId) => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -353,6 +356,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
previewVideoAutostart: false,
|
previewVideoAutostart: false,
|
||||||
previewRotationDeg: 0,
|
previewRotationDeg: 0,
|
||||||
darkenScene: false,
|
darkenScene: false,
|
||||||
|
darknessStrokes: [],
|
||||||
traps: [],
|
traps: [],
|
||||||
tokens: [],
|
tokens: [],
|
||||||
npcTokens: [],
|
npcTokens: [],
|
||||||
@@ -563,6 +567,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
previewVideoAutostart?: boolean;
|
previewVideoAutostart?: boolean;
|
||||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||||
darkenScene?: boolean;
|
darkenScene?: boolean;
|
||||||
|
darknessStrokes?: Scene['darknessStrokes'];
|
||||||
traps?: import('../../../shared/types').SceneTrap[];
|
traps?: import('../../../shared/types').SceneTrap[];
|
||||||
tokens?: import('../../../shared/types').SceneToken[];
|
tokens?: import('../../../shared/types').SceneToken[];
|
||||||
npcTokens?: import('../../../shared/types').SceneNpcToken[];
|
npcTokens?: import('../../../shared/types').SceneNpcToken[];
|
||||||
@@ -593,6 +598,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
? { previewRotationDeg: patch.previewRotationDeg }
|
? { previewRotationDeg: patch.previewRotationDeg }
|
||||||
: null),
|
: null),
|
||||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||||
|
...(patch.darknessStrokes !== undefined ? { darknessStrokes: patch.darknessStrokes } : null),
|
||||||
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
||||||
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
|
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
|
||||||
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null),
|
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null),
|
||||||
@@ -901,6 +907,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
storylineSelections: StorylineSelection[],
|
storylineSelections: StorylineSelection[],
|
||||||
npcIds: string[],
|
npcIds: string[],
|
||||||
labels: StorylineLabels,
|
labels: StorylineLabels,
|
||||||
|
playlistIds: string[] = [],
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.invoke(ipcChannels.project.exportZip, {
|
const res = await api.invoke(ipcChannels.project.exportZip, {
|
||||||
@@ -908,6 +915,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
storylineSelections,
|
storylineSelections,
|
||||||
npcIds,
|
npcIds,
|
||||||
labels,
|
labels,
|
||||||
|
playlistIds,
|
||||||
});
|
});
|
||||||
if (res.canceled) return;
|
if (res.canceled) return;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -915,6 +923,14 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const noteProject = (project: Project) => {
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
project,
|
||||||
|
selectedSceneId: project.currentSceneId ?? s.selectedSceneId,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const deleteProject = async (projectId: ProjectId) => {
|
const deleteProject = async (projectId: ProjectId) => {
|
||||||
projectDataEpochRef.current += 1;
|
projectDataEpochRef.current += 1;
|
||||||
await api.invoke(ipcChannels.project.deleteProject, { projectId });
|
await api.invoke(ipcChannels.project.deleteProject, { projectId });
|
||||||
@@ -933,6 +949,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
|||||||
createProject,
|
createProject,
|
||||||
openProject,
|
openProject,
|
||||||
closeProject,
|
closeProject,
|
||||||
|
noteProject,
|
||||||
createScene,
|
createScene,
|
||||||
createScenesFromMediaPaths,
|
createScenesFromMediaPaths,
|
||||||
selectScene,
|
selectScene,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
|
<title>TTRPG</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/initiative/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
.page {
|
||||||
|
height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addRow {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addRow > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
padding: 8px 4px 12px;
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: 12px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 110px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotActive {
|
||||||
|
border-color: rgba(245, 197, 66, 0.55);
|
||||||
|
background: rgba(245, 197, 66, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotActive .avatar {
|
||||||
|
border-color: #f5c542;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(0, 0, 0, 0.45),
|
||||||
|
0 0 12px rgba(245, 197, 66, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid #3f3f46;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #18181b;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #f8fafc;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotName {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniBtn {
|
||||||
|
appearance: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.35));
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniBtn:hover {
|
||||||
|
border-color: var(--accent, #a78bfa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionMenu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--bg-elevated, #111827);
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionItem {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionItem:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionEmpty {
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reserve {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reserveRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
.reserveRow .avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reserveRow .letter {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reserveName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
import {
|
||||||
|
INITIATIVE_CONDITION_IDS,
|
||||||
|
type InitiativeConditionId,
|
||||||
|
type InitiativeEntry,
|
||||||
|
} from '../../shared/types/initiative';
|
||||||
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { getDndApi } from '../shared/dndApi';
|
||||||
|
import { InitiativeConditionChips } from '../shared/initiative/InitiativeConditionChips';
|
||||||
|
import { useInitiativeState } from '../shared/initiative/useInitiativeState';
|
||||||
|
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
|
||||||
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
import { Button, Input } from '../shared/ui/controls';
|
||||||
|
|
||||||
|
import styles from './InitiativeApp.module.css';
|
||||||
|
|
||||||
|
const DND_MIME = 'application/x-dnd-initiative-entry-id';
|
||||||
|
const CONDITION_MENU_WIDTH = 200;
|
||||||
|
/** Компактная высота меню; фактическая не больше доступного места на экране. */
|
||||||
|
const CONDITION_MENU_MAX_H = 220;
|
||||||
|
const CONDITION_MENU_GAP = 6;
|
||||||
|
const CONDITION_MENU_EDGE = 8;
|
||||||
|
|
||||||
|
function EntryAvatar({ entry }: { entry: InitiativeEntry }) {
|
||||||
|
if (entry.kind === 'manual') {
|
||||||
|
return (
|
||||||
|
<div className={styles.avatar} style={{ background: entry.gradient }} aria-hidden>
|
||||||
|
<span className={styles.letter}>{entry.letter}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.kind === 'npc') {
|
||||||
|
return <NpcAvatar assetId={entry.avatarAssetId} name={entry.name} />;
|
||||||
|
}
|
||||||
|
return <PlayerAvatar playerId={entry.playerId} name={entry.name} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NpcAvatar({ assetId, name }: { assetId: string | null; name: string }) {
|
||||||
|
const url = useAssetUrl(assetId as import('../../shared/types').AssetId | null);
|
||||||
|
if (url) {
|
||||||
|
return (
|
||||||
|
<div className={styles.avatar}>
|
||||||
|
<img src={url} alt="" className={styles.avatarImg} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const letter = (name.trim()[0] ?? '?').toUpperCase();
|
||||||
|
return (
|
||||||
|
<div className={styles.avatar} style={{ background: 'linear-gradient(135deg,#334155,#0f172a)' }}>
|
||||||
|
<span className={styles.letter}>{letter}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerAvatar({ playerId, name }: { playerId: string; name: string }) {
|
||||||
|
const url = usePlayerImageUrl(playerId as import('../../shared/types').PlayerId);
|
||||||
|
if (url) {
|
||||||
|
return (
|
||||||
|
<div className={styles.avatar}>
|
||||||
|
<img src={url} alt="" className={styles.avatarImg} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const letter = (name.trim()[0] ?? '?').toUpperCase();
|
||||||
|
return (
|
||||||
|
<div className={styles.avatar} style={{ background: 'linear-gradient(135deg,#1d4ed8,#0f172a)' }}>
|
||||||
|
<span className={styles.letter}>{letter}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueueSlot({
|
||||||
|
entry,
|
||||||
|
active,
|
||||||
|
conditions,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
conditionsOpen,
|
||||||
|
onToggleConditions,
|
||||||
|
onAddCondition,
|
||||||
|
onRemoveCondition,
|
||||||
|
}: {
|
||||||
|
entry: InitiativeEntry;
|
||||||
|
active: boolean;
|
||||||
|
conditions: readonly InitiativeConditionId[];
|
||||||
|
onSelect: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onDragStart: (e: React.DragEvent) => void;
|
||||||
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
|
onDrop: (e: React.DragEvent) => void;
|
||||||
|
conditionsOpen: boolean;
|
||||||
|
onToggleConditions: () => void;
|
||||||
|
onAddCondition: (id: InitiativeConditionId) => void;
|
||||||
|
onRemoveCondition: (id: InitiativeConditionId) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useEditorI18n();
|
||||||
|
const available = INITIATIVE_CONDITION_IDS.filter((id) => !conditions.includes(id));
|
||||||
|
const addBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const [menuPos, setMenuPos] = useState<{ top: number; left: number; maxHeight: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!conditionsOpen) {
|
||||||
|
setMenuPos(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = addBtnRef.current;
|
||||||
|
if (!btn) return;
|
||||||
|
const place = () => {
|
||||||
|
const r = btn.getBoundingClientRect();
|
||||||
|
const left = Math.max(
|
||||||
|
CONDITION_MENU_EDGE,
|
||||||
|
Math.min(
|
||||||
|
window.innerWidth - CONDITION_MENU_WIDTH - CONDITION_MENU_EDGE,
|
||||||
|
r.left,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const spaceBelow = window.innerHeight - r.bottom - CONDITION_MENU_GAP - CONDITION_MENU_EDGE;
|
||||||
|
const spaceAbove = r.top - CONDITION_MENU_GAP - CONDITION_MENU_EDGE;
|
||||||
|
// Предпочитаем под кнопкой; если снизу мало места — над ней.
|
||||||
|
const openBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove;
|
||||||
|
const available = Math.max(80, openBelow ? spaceBelow : spaceAbove);
|
||||||
|
const maxHeight = Math.min(CONDITION_MENU_MAX_H, available);
|
||||||
|
const top = openBelow
|
||||||
|
? r.bottom + CONDITION_MENU_GAP
|
||||||
|
: Math.max(CONDITION_MENU_EDGE, r.top - CONDITION_MENU_GAP - maxHeight);
|
||||||
|
setMenuPos({ top, left, maxHeight });
|
||||||
|
};
|
||||||
|
place();
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
window.addEventListener('scroll', place, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', place);
|
||||||
|
window.removeEventListener('scroll', place, true);
|
||||||
|
};
|
||||||
|
}, [conditionsOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!conditionsOpen) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node | null;
|
||||||
|
if (addBtnRef.current?.contains(target)) return;
|
||||||
|
const menu = document.getElementById(`initiative-condition-menu-${entry.id}`);
|
||||||
|
if (menu?.contains(target)) return;
|
||||||
|
onToggleConditions();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
return () => document.removeEventListener('mousedown', onDown);
|
||||||
|
}, [conditionsOpen, entry.id, onToggleConditions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[styles.slot, active ? styles.slotActive : ''].filter(Boolean).join(' ')}
|
||||||
|
data-testid={`initiative-slot-${entry.id}`}
|
||||||
|
data-active={active ? '1' : '0'}
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<EntryAvatar entry={entry} />
|
||||||
|
<div className={styles.slotName} title={entry.name}>
|
||||||
|
{entry.name}
|
||||||
|
</div>
|
||||||
|
<InitiativeConditionChips
|
||||||
|
conditions={conditions}
|
||||||
|
removable
|
||||||
|
onRemove={onRemoveCondition}
|
||||||
|
/>
|
||||||
|
<div className={styles.slotActions}>
|
||||||
|
<button
|
||||||
|
ref={addBtnRef}
|
||||||
|
type="button"
|
||||||
|
className={styles.miniBtn}
|
||||||
|
title={t('initiative.conditions')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleConditions();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.miniBtn}
|
||||||
|
title={t('initiative.toReserve')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{conditionsOpen && menuPos
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
id={`initiative-condition-menu-${entry.id}`}
|
||||||
|
className={styles.conditionMenu}
|
||||||
|
style={{
|
||||||
|
top: menuPos.top,
|
||||||
|
left: menuPos.left,
|
||||||
|
width: CONDITION_MENU_WIDTH,
|
||||||
|
maxHeight: menuPos.maxHeight,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{available.length === 0 ? (
|
||||||
|
<div className={styles.conditionEmpty}>{t('initiative.conditionsEmpty')}</div>
|
||||||
|
) : (
|
||||||
|
available.map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
className={styles.conditionItem}
|
||||||
|
onClick={() => onAddCondition(id)}
|
||||||
|
>
|
||||||
|
{t(`initiative.condition.${id}`)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitiativeApp() {
|
||||||
|
const { t } = useEditorI18n();
|
||||||
|
const api = getDndApi();
|
||||||
|
const [state, initiativeApi] = useInitiativeState();
|
||||||
|
const [manualName, setManualName] = useState('');
|
||||||
|
const [conditionsFor, setConditionsFor] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
void api.invoke(ipcChannels.windows.closeInitiative, {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const orderEntries = useMemo(() => {
|
||||||
|
if (!state) return [];
|
||||||
|
return state.order.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[];
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const reserveEntries = useMemo(() => {
|
||||||
|
if (!state) return [];
|
||||||
|
return state.reserve.map((id) => state.entries[id]).filter(Boolean) as InitiativeEntry[];
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return <div className={styles.page} data-testid="initiative-app" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderDrop = (targetId: string, e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fromId = e.dataTransfer.getData(DND_MIME);
|
||||||
|
if (!fromId || fromId === targetId) return;
|
||||||
|
const next = [...state.order];
|
||||||
|
const fromIdx = next.indexOf(fromId);
|
||||||
|
const toIdx = next.indexOf(targetId);
|
||||||
|
if (fromIdx < 0 || toIdx < 0) return;
|
||||||
|
next.splice(fromIdx, 1);
|
||||||
|
next.splice(toIdx, 0, fromId);
|
||||||
|
void initiativeApi.dispatch({ kind: 'reorder', order: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addManual = () => {
|
||||||
|
const name = manualName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
void initiativeApi.dispatch({ kind: 'addManual', name });
|
||||||
|
setManualName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page} data-testid="initiative-app">
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<Button variant="primary" onClick={() => void initiativeApi.dispatch({ kind: 'next' })}>
|
||||||
|
{t('initiative.next')}
|
||||||
|
</Button>
|
||||||
|
<div className={styles.addRow}>
|
||||||
|
<Input
|
||||||
|
value={manualName}
|
||||||
|
onChange={setManualName}
|
||||||
|
placeholder={t('initiative.manualPlaceholder')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addManual();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" onClick={addManual}>
|
||||||
|
{t('initiative.addManual')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.sectionLabel}>{t('initiative.queue')}</div>
|
||||||
|
<div className={styles.queue} data-testid="initiative-queue">
|
||||||
|
{orderEntries.length === 0 ? (
|
||||||
|
<div className={styles.empty}>{t('initiative.queueEmpty')}</div>
|
||||||
|
) : (
|
||||||
|
orderEntries.map((entry) => (
|
||||||
|
<QueueSlot
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
active={state.activeId === entry.id}
|
||||||
|
conditions={state.conditions[entry.id] ?? []}
|
||||||
|
onSelect={() => void initiativeApi.dispatch({ kind: 'setActive', id: entry.id })}
|
||||||
|
onRemove={() => void initiativeApi.dispatch({ kind: 'moveToReserve', id: entry.id })}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData(DND_MIME, entry.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
}}
|
||||||
|
onDrop={(e) => reorderDrop(entry.id, e)}
|
||||||
|
conditionsOpen={conditionsFor === entry.id}
|
||||||
|
onToggleConditions={() =>
|
||||||
|
setConditionsFor((cur) => (cur === entry.id ? null : entry.id))
|
||||||
|
}
|
||||||
|
onAddCondition={(conditionId) => {
|
||||||
|
void initiativeApi.dispatch({ kind: 'addCondition', id: entry.id, conditionId });
|
||||||
|
setConditionsFor(null);
|
||||||
|
}}
|
||||||
|
onRemoveCondition={(conditionId) => {
|
||||||
|
void initiativeApi.dispatch({ kind: 'removeCondition', id: entry.id, conditionId });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.sectionLabel}>{t('initiative.reserve')}</div>
|
||||||
|
<div className={styles.reserve} data-testid="initiative-reserve">
|
||||||
|
{reserveEntries.length === 0 ? (
|
||||||
|
<div className={styles.empty}>{t('initiative.reserveEmpty')}</div>
|
||||||
|
) : (
|
||||||
|
reserveEntries.map((entry) => (
|
||||||
|
<div key={entry.id} className={styles.reserveRow}>
|
||||||
|
<EntryAvatar entry={entry} />
|
||||||
|
<span className={styles.reserveName}>{entry.name}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void initiativeApi.dispatch({ kind: 'restoreFromReserve', id: entry.id })}
|
||||||
|
>
|
||||||
|
{t('initiative.restore')}
|
||||||
|
</Button>
|
||||||
|
{entry.kind === 'manual' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void initiativeApi.dispatch({ kind: 'deleteEntry', id: entry.id })}
|
||||||
|
>
|
||||||
|
{t('initiative.delete')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import '../shared/ui/globals.css';
|
||||||
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
|
import { InitiativeApp } from './InitiativeApp';
|
||||||
|
|
||||||
|
const rootEl = document.getElementById('root');
|
||||||
|
if (!rootEl) {
|
||||||
|
throw new Error('Missing #root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Initiative">
|
||||||
|
<EditorI18nProvider>
|
||||||
|
<InitiativeApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
|
<title>TTRPG</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/instructions/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
.page {
|
||||||
|
height: 100vh;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(160px, 220px);
|
||||||
|
gap: 14px;
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--bg0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentTitle {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: var(--text-md);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineLink {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent, #a78bfa);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineLink:hover {
|
||||||
|
color: var(--text0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--panel2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem:hover {
|
||||||
|
background: var(--scene-list-hover-bg);
|
||||||
|
color: var(--text0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItemActive {
|
||||||
|
background: rgba(167, 139, 250, 0.18);
|
||||||
|
color: var(--text0);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(120px, 32vh);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../editor/help/helpLinkify';
|
||||||
|
import {
|
||||||
|
HELP_SECTION_IDS,
|
||||||
|
helpSectionBodyKey,
|
||||||
|
helpSectionTitleKey,
|
||||||
|
parseHelpSectionId,
|
||||||
|
type HelpSectionId,
|
||||||
|
} from '../editor/help/helpSections';
|
||||||
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { getDndApi } from '../shared/dndApi';
|
||||||
|
|
||||||
|
import styles from './InstructionsApp.module.css';
|
||||||
|
|
||||||
|
function InstructionsPanel({ initialSection }: { initialSection: HelpSectionId }) {
|
||||||
|
const { t } = useEditorI18n();
|
||||||
|
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
||||||
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const navRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveId(initialSection);
|
||||||
|
}, [initialSection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
contentRef.current?.scrollTo({ top: 0 });
|
||||||
|
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
||||||
|
activeNav?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [activeId]);
|
||||||
|
|
||||||
|
const linkCatalog = useMemo(
|
||||||
|
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = t(helpSectionBodyKey(activeId));
|
||||||
|
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page} data-testid="instructions-app">
|
||||||
|
<div ref={contentRef} className={styles.content}>
|
||||||
|
<div className={styles.contentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
||||||
|
{paragraphs.map((p, i) => (
|
||||||
|
<p key={i} className={styles.paragraph}>
|
||||||
|
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
|
||||||
|
part.type === 'text' ? (
|
||||||
|
<React.Fragment key={j}>{part.value}</React.Fragment>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={j}
|
||||||
|
type="button"
|
||||||
|
className={styles.inlineLink}
|
||||||
|
onClick={() => setActiveId(part.id)}
|
||||||
|
>
|
||||||
|
{part.value}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<nav ref={navRef} className={styles.nav} aria-label={t('help.navAria')}>
|
||||||
|
{HELP_SECTION_IDS.map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
className={[styles.navItem, id === activeId ? styles.navItemActive : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
aria-current={id === activeId ? 'true' : undefined}
|
||||||
|
onClick={() => setActiveId(id)}
|
||||||
|
>
|
||||||
|
{t(helpSectionTitleKey(id))}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstructionsApp() {
|
||||||
|
const [section, setSection] = useState<HelpSectionId>('overview');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getDndApi();
|
||||||
|
void api.invoke(ipcChannels.windows.getInstructionsSection, {}).then(({ section: next }) => {
|
||||||
|
setSection(parseHelpSectionId(next));
|
||||||
|
});
|
||||||
|
return api.on(ipcChannels.windows.instructionsSection, ({ section: next }) => {
|
||||||
|
setSection(parseHelpSectionId(next));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') window.close();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <InstructionsPanel initialSection={section} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import '../shared/ui/globals.css';
|
||||||
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
|
import { InstructionsApp } from './InstructionsApp';
|
||||||
|
|
||||||
|
const rootEl = document.getElementById('root');
|
||||||
|
if (!rootEl) {
|
||||||
|
throw new Error('Missing #root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Instructions">
|
||||||
|
<EditorI18nProvider>
|
||||||
|
<InstructionsApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -8,6 +8,7 @@ import editorStyles from '../editor/EditorApp.module.css';
|
|||||||
import {
|
import {
|
||||||
filterMaterialImagePaths,
|
filterMaterialImagePaths,
|
||||||
getDroppedFileEntries,
|
getDroppedFileEntries,
|
||||||
|
nameFromFilePath,
|
||||||
pickFirstMaterialImagePath,
|
pickFirstMaterialImagePath,
|
||||||
useFileDropZone,
|
useFileDropZone,
|
||||||
} from '../editor/fileDrop';
|
} from '../editor/fileDrop';
|
||||||
@@ -111,6 +112,11 @@ export function NpcEditModal({
|
|||||||
|
|
||||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||||
setFilePath(path);
|
setFilePath(path);
|
||||||
|
setName((prev) => {
|
||||||
|
if (prev.trim()) return prev;
|
||||||
|
const fromFile = nameFromFilePath(path);
|
||||||
|
return fromFile || prev;
|
||||||
|
});
|
||||||
setLocalPreviewUrl((prev) => {
|
setLocalPreviewUrl((prev) => {
|
||||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||||
return previewUrl;
|
return previewUrl;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
|
<title>TTRPG</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/playlists/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.page {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { getDndApi } from '../shared/dndApi';
|
||||||
|
import {
|
||||||
|
PlaylistsPanel,
|
||||||
|
playlistsPanelLabelsFromT,
|
||||||
|
} from '../shared/playlists/PlaylistsPanel';
|
||||||
|
|
||||||
|
import styles from './PlaylistsApp.module.css';
|
||||||
|
|
||||||
|
export function PlaylistsApp() {
|
||||||
|
const { t } = useEditorI18n();
|
||||||
|
const api = getDndApi();
|
||||||
|
const labels = useMemo(() => playlistsPanelLabelsFromT(t), [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
void api.invoke(ipcChannels.windows.closePlaylists, {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page} data-testid="playlists-app">
|
||||||
|
<PlaylistsPanel labels={labels} className={styles.panel} readOnly />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import '../shared/ui/globals.css';
|
||||||
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
|
import { PlaylistsApp } from './PlaylistsApp';
|
||||||
|
|
||||||
|
const rootEl = document.getElementById('root');
|
||||||
|
if (!rootEl) {
|
||||||
|
throw new Error('Missing #root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Playlists">
|
||||||
|
<EditorI18nProvider>
|
||||||
|
<PlaylistsApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
void test('PresentationApp: Escape запрашивает confirm, не закрывает сразу', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'PresentationApp.tsx'), 'utf8');
|
||||||
|
assert.ok(src.includes('requestCloseMultiWindowConfirm'));
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/e\.key === 'Escape'[\s\S]*requestCloseMultiWindowConfirm/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
src,
|
||||||
|
/e\.key === 'Escape'[\s\S]*closeMultiWindow/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -23,7 +23,8 @@ export function PresentationApp() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
|
e.preventDefault();
|
||||||
|
void api.invoke(ipcChannels.windows.requestCloseMultiWindowConfirm, {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) {
|
if (e.altKey && (e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter')) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { PresentationApp } from './PresentationApp';
|
import { PresentationApp } from './PresentationApp';
|
||||||
@@ -14,7 +15,9 @@ if (!rootEl) {
|
|||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<WindowErrorBoundary title="Презентация">
|
<WindowErrorBoundary title="Презентация">
|
||||||
|
<EditorI18nProvider>
|
||||||
<PresentationApp />
|
<PresentationApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
</WindowErrorBoundary>
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user