Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fbaaa6e77 | |||
| 8d5a68c71e | |||
| 4456eb0277 | |||
| e53d1ea934 | |||
| cbb6edc378 | |||
| 4e6f7321b8 | |||
| f75444a5dd | |||
| 10cbb3b256 | |||
| c46ff34393 | |||
| 7a25b18268 | |||
| d4dc4e7f3c | |||
| 2979d06f1c | |||
| 61446dacfc | |||
| 41b112159f | |||
| 0f01950cc1 | |||
| cc50e64e21 | |||
| 101f595bac | |||
| a3a03eb9e3 | |||
| e08f5ef550 | |||
| 04c75cd725 | |||
| e687303c57 | |||
| c7bf7cf449 | |||
| a2b418b78a | |||
| f270812219 | |||
| bdeb64e356 | |||
| 4aa0f257d5 | |||
| 9d82e74272 | |||
| fed5674468 | |||
| 02d73ddf81 | |||
| d3b1c4660d | |||
| eb127c11c2 | |||
| 2c0e6fbf09 | |||
| a797162f16 | |||
| d9fbecf5a7 | |||
| 32a5479086 | |||
| c1c332364c | |||
| 0cab7ce7ca | |||
| 1829191410 | |||
| f4c0ac1438 | |||
| 37ba855faf | |||
| 61875be857 | |||
| 195d4be086 | |||
| 8d73f7744e | |||
| e731ddb1c0 | |||
| cfa3959fb3 | |||
| 35a6e979eb | |||
| b7e6ff6915 | |||
| c8ab9dd567 | |||
| de9190959c | |||
| 10bb7013e6 | |||
| 83a326b0b9 | |||
| 089da3cae0 | |||
| f5240d1623 | |||
| 4631a1bece | |||
| d54e9ed02d | |||
| ca0cf9c0ce | |||
| 657e3c862e | |||
| 97c77a025a | |||
| 42b067615d | |||
| 0c45d58359 | |||
| 1eed2b38f8 | |||
| 60ef837e1c | |||
| 374e041321 | |||
| 3acb89c80d | |||
| 8eb88399a3 | |||
| a96e1f8465 | |||
| 80103a00e7 | |||
| b017155eaf | |||
| 5706355c5f | |||
| 428fa09224 | |||
| 1cda87fe13 | |||
| 10de99bb06 | |||
| 0ae3c39333 | |||
| d07dcae626 | |||
| dd0dd646f6 | |||
| 8ec830cdb5 | |||
| 02b3131f19 | |||
| cfa067519d | |||
| 4e5d320c36 | |||
| 411ac634f4 | |||
| ece48fe53d | |||
| 744ead383d | |||
| 9f82a541fc | |||
| 963a1f0790 | |||
| 394b42e845 | |||
| 6204359330 | |||
| 7c858ba633 | |||
| 2c03921d23 | |||
| 285a1a9667 | |||
| 0e14180044 | |||
| cd3ba5fe07 | |||
| 26f8a81631 | |||
| 2dc7015f53 | |||
| 0eadfdce30 | |||
| 7e7827224d | |||
| 8fa8467db7 |
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -1,150 +0,0 @@
|
||||
# Сборка по тегу v* и выкладка в публичный репо (ветка updates).
|
||||
#
|
||||
# Метки runs-on = labels твоих act_runner (Админка Gitea → Действия → Раннеры).
|
||||
# Не используем windows-latest/macos-latest — это только GitHub-hosted.
|
||||
# По умолчанию: один job на ubuntu-22.04 — Win (Wine+NSIS), Linux AppImage (x64+arm64), sync в updates без затирания других ОС.
|
||||
#
|
||||
# Один job без actions/upload-artifact: официальный upload-artifact@v4 с GitHub на Gitea
|
||||
# падает (GHESNotSupportedError). Сборка и sync-update-feed идут в одном окружении.
|
||||
#
|
||||
# Secrets: DND_UPDATE_FEED_URL, DND_UPDATES_SERVER, UPDATES_REPO, DND_UPDATES_PUSH_TOKEN — docs/GITEA_AUTO_UPDATE.md
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
# Не включаем i386 / wine32: на Debian с репами только amd64 (nginx, sury, …)
|
||||
# apt ломается на цепочке libgphoto2/libgd:i386. NSIS — нативный пакет `nsis`;
|
||||
# electron-builder подхватывает makensis; wine64 — для win-утилит (rcedit и т.п.).
|
||||
# Пакет `wine` (мета) тянет wine32 → снова i386. electron-builder ищет именно `wine` в PATH.
|
||||
- name: Зависимости Win-сборки на Linux (amd64, без multiarch i386)
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
nsis \
|
||||
wine64
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
W64="$(command -v wine64 2>/dev/null || true)"
|
||||
if [[ -z "$W64" || ! -x "$W64" ]]; then
|
||||
for p in /usr/bin/wine64 /usr/lib/wine/wine64; do
|
||||
if [[ -x "$p" ]]; then W64="$p"; break; fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "${W64:-}" || ! -x "$W64" ]]; then
|
||||
echo "wine64 binary not found after apt install wine64" >&2
|
||||
dpkg -L wine64 2>/dev/null | head -80 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' '#!/bin/sh' "exec $W64 \"\$@\"" | sudo tee /usr/local/bin/wine >/dev/null
|
||||
sudo chmod +x /usr/local/bin/wine
|
||||
wine --version
|
||||
|
||||
- name: Версия из тега
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME:-${GITEA_REF_NAME:-}}"
|
||||
VERSION="${TAG#v}"
|
||||
npm version "$VERSION" --allow-same-version --no-git-tag-version
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- run: npm run build
|
||||
|
||||
# Linux AppImage (x64 + arm64) до подмешивания win32-sharp в node_modules.
|
||||
- name: Зависимости Linux AppImage и кросс-сборка arm64 на amd64
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
qemu-user-static \
|
||||
binfmt-support \
|
||||
desktop-file-utils \
|
||||
squashfs-tools
|
||||
|
||||
- name: electron-builder (linux AppImage x64, arm64)
|
||||
shell: bash
|
||||
env:
|
||||
DND_UPDATE_FEED_URL: ${{ secrets.DND_UPDATE_FEED_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${DND_UPDATE_FEED_URL:-}" ]]; then
|
||||
echo "Secret DND_UPDATE_FEED_URL is not set (URL со слэшем в конце)" >&2
|
||||
exit 1
|
||||
fi
|
||||
npx electron-builder --linux AppImage --x64 --arm64 --publish never \
|
||||
--config.publish.provider=generic \
|
||||
--config.publish.url="${DND_UPDATE_FEED_URL}"
|
||||
|
||||
# Не используем `npm install`: на Linux npm падает с EBADPLATFORM для win32-пакета.
|
||||
- name: sharp (@img/sharp-win32-x64) для Windows-артефакта при сборке на Linux
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tmp="$(mktemp -d)"
|
||||
npm pack @img/sharp-win32-x64@0.34.5 --pack-destination "$tmp"
|
||||
mkdir -p node_modules/@img/sharp-win32-x64
|
||||
tar -xzf "$tmp/img-sharp-win32-x64-0.34.5.tgz" -C "$tmp"
|
||||
cp -a "$tmp/package/." node_modules/@img/sharp-win32-x64/
|
||||
test -f node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node
|
||||
rm -rf "$tmp"
|
||||
|
||||
- name: electron-builder (win)
|
||||
shell: bash
|
||||
env:
|
||||
DND_UPDATE_FEED_URL: ${{ secrets.DND_UPDATE_FEED_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${DND_UPDATE_FEED_URL:-}" ]]; then
|
||||
echo "Secret DND_UPDATE_FEED_URL is not set (URL со слэшем в конце)" >&2
|
||||
exit 1
|
||||
fi
|
||||
npx electron-builder --win --publish never \
|
||||
--config.publish.provider=generic \
|
||||
--config.publish.url="${DND_UPDATE_FEED_URL}"
|
||||
|
||||
# Артефакты читает sync напрямую из release/ (без дублирующих _win/_linux — меньше ENOSPC).
|
||||
- name: Пустой каталог mac (пока нет сборки mac в CI)
|
||||
run: mkdir -p _mac
|
||||
|
||||
- name: Push в публичный репозиторий updates
|
||||
shell: bash
|
||||
env:
|
||||
DND_UPDATES_SERVER: ${{ secrets.DND_UPDATES_SERVER }}
|
||||
UPDATES_REPO: ${{ secrets.UPDATES_REPO }}
|
||||
DND_UPDATES_PUSH_TOKEN: ${{ secrets.DND_UPDATES_PUSH_TOKEN }}
|
||||
ARTIFACT_WIN: ${{ github.workspace }}/release
|
||||
ARTIFACT_MAC: ${{ github.workspace }}/_mac
|
||||
ARTIFACT_LINUX: ${{ github.workspace }}/release
|
||||
DND_FEED_TMP_ROOT: ${{ github.workspace }}/.dnd-feed-tmp
|
||||
DND_UPDATES_SQUASH_HISTORY: '1'
|
||||
GIT_COMMIT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${{ github.workspace }}/.dnd-feed-tmp" "${{ github.workspace }}/_mac"
|
||||
node scripts/sync-update-feed.mjs
|
||||
|
||||
# Когда появится macOS-раннер: отдельный job build-macos, копирование eb-mac в _mac перед sync
|
||||
# или расширить шаг «Каталог артефактов»; сейчас всё в одном job `release`.
|
||||
#
|
||||
# build-macos:
|
||||
# runs-on: macos-14
|
||||
# steps: ...
|
||||
@@ -17,3 +17,6 @@ Thumbs.db
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.tsbuildinfo
|
||||
test-results/
|
||||
playwright-report/
|
||||
e2e/fixtures/sample.png
|
||||
|
||||
@@ -3,6 +3,11 @@ import test from 'node:test';
|
||||
|
||||
import { EffectsStore } from './effectsStore';
|
||||
|
||||
void test('defaultTool: при старте инструмент не выбран', () => {
|
||||
const store = new EffectsStore();
|
||||
assert.equal(store.getState().tool.tool, 'none');
|
||||
});
|
||||
|
||||
void test('pruneExpired: лёд не удаляется по времени', () => {
|
||||
const store = new EffectsStore();
|
||||
const createdAtMs = Date.now() - 365 * 24 * 60 * 60 * 1000;
|
||||
@@ -64,7 +69,7 @@ void test('pruneExpired: луч света удаляется после lifetim
|
||||
assert.equal(store.getState().instances.length, 0);
|
||||
});
|
||||
|
||||
void test('pruneExpired: облако яда удаляется после lifetime', () => {
|
||||
void test('pruneExpired: яд удаляется после lifetime', () => {
|
||||
const store = new EffectsStore();
|
||||
store.dispatch({
|
||||
kind: 'instance.add',
|
||||
@@ -82,3 +87,22 @@ void test('pruneExpired: облако яда удаляется после lifet
|
||||
assert.equal(store.pruneExpired(), true);
|
||||
assert.equal(store.getState().instances.length, 0);
|
||||
});
|
||||
|
||||
void test('pruneExpired: взрыв удаляется после lifetime', () => {
|
||||
const store = new EffectsStore();
|
||||
store.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: 'ex_test',
|
||||
type: 'explosion',
|
||||
seed: 1,
|
||||
createdAtMs: Date.now() - 20_000,
|
||||
at: { x: 0.5, y: 0.5 },
|
||||
radiusN: 0.08,
|
||||
intensity: 1,
|
||||
lifetimeMs: 4200,
|
||||
},
|
||||
});
|
||||
assert.equal(store.pruneExpired(), true);
|
||||
assert.equal(store.getState().instances.length, 0);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
|
||||
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
|
||||
|
||||
function nowMs(): number {
|
||||
@@ -7,7 +8,12 @@ function nowMs(): number {
|
||||
}
|
||||
|
||||
function defaultTool(): EffectToolState {
|
||||
return { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
|
||||
return { tool: 'none', radiusN: 0.08, intensity: 0.6 };
|
||||
}
|
||||
|
||||
/** Сброс выбранного инструмента (при старте сессии). */
|
||||
export function effectsDefaultTool(): EffectToolState {
|
||||
return defaultTool();
|
||||
}
|
||||
|
||||
export class EffectsStore {
|
||||
@@ -35,7 +41,7 @@ export class EffectsStore {
|
||||
|
||||
dispatch(event: EffectsEvent): EffectsState {
|
||||
const s = this.state;
|
||||
const next: EffectsState = applyEvent(s, event);
|
||||
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
|
||||
this.state = next;
|
||||
return next;
|
||||
}
|
||||
@@ -46,14 +52,20 @@ export class EffectsStore {
|
||||
const before = this.state.instances.length;
|
||||
const kept = this.state.instances.filter((i) => {
|
||||
// Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI).
|
||||
if (i.type === 'ice') return true;
|
||||
if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud') {
|
||||
if (i.type === 'ice' || i.type === 'shadow') return true;
|
||||
if (
|
||||
i.type === 'lightning' ||
|
||||
i.type === 'sunbeam' ||
|
||||
i.type === 'poisonCloud' ||
|
||||
i.type === 'explosion' ||
|
||||
i.type === 'darkness'
|
||||
) {
|
||||
return now - i.createdAtMs < i.lifetimeMs;
|
||||
}
|
||||
if (i.type === 'scorch') {
|
||||
return now - i.createdAtMs < i.lifetimeMs;
|
||||
}
|
||||
if (i.type === 'fog' || i.type === 'water') {
|
||||
if (i.type === 'fog' || i.type === 'fire' || i.type === 'rain' || i.type === 'water') {
|
||||
if (i.lifetimeMs === null) return true;
|
||||
return now - i.createdAtMs < i.lifetimeMs;
|
||||
}
|
||||
@@ -74,7 +86,11 @@ export class EffectsStore {
|
||||
}
|
||||
}
|
||||
|
||||
function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
|
||||
function applyEvent(
|
||||
state: EffectsState,
|
||||
event: EffectsEvent,
|
||||
makeId: (prefix: string) => string,
|
||||
): EffectsState {
|
||||
const bump = (patch: Omit<EffectsState, 'revision' | 'serverNowMs'>): EffectsState => ({
|
||||
...patch,
|
||||
revision: state.revision + 1,
|
||||
@@ -89,6 +105,18 @@ function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
|
||||
return bump({ ...state, instances: [...state.instances, event.instance] });
|
||||
case 'instance.remove':
|
||||
return bump({ ...state, instances: state.instances.filter((i) => i.id !== event.id) });
|
||||
case 'field.erase':
|
||||
return bump({
|
||||
...state,
|
||||
instances: applyFieldEffectEraserStroke(
|
||||
state.instances,
|
||||
event.points,
|
||||
event.radiusN,
|
||||
makeId,
|
||||
event.types,
|
||||
event.hitMode ?? 'inclusive',
|
||||
),
|
||||
});
|
||||
default: {
|
||||
// Exhaustiveness
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SceneDarknessStore } from './sceneDarknessStore';
|
||||
|
||||
void test('SceneDarknessStore: сохраняет штрихи при переключении сцен', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 's1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
},
|
||||
});
|
||||
|
||||
store.switchScene('scene_b', true);
|
||||
assert.equal(store.getState().strokes.length, 0);
|
||||
|
||||
store.switchScene('scene_a', true);
|
||||
assert.equal(store.getState().strokes.length, 1);
|
||||
assert.equal(store.getState().strokes[0]?.id, 's1');
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: resetSession очищает кэш', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 's1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.2, y: 0.2, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
},
|
||||
});
|
||||
store.resetSession();
|
||||
store.switchScene('scene_a', true);
|
||||
assert.equal(store.getState().strokes.length, 0);
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: draft синхронизируется и сбрасывается при commit', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'draft.set',
|
||||
draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05, mode: 'cover' },
|
||||
});
|
||||
assert.ok(store.getState().draft);
|
||||
assert.equal(store.getState().draft?.mode, 'cover');
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 's1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.1, y: 0.1, tMs: 1 }],
|
||||
radiusN: 0.05,
|
||||
mode: 'cover',
|
||||
},
|
||||
});
|
||||
assert.equal(store.getState().draft, null);
|
||||
assert.equal(store.getState().strokes.length, 1);
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'open1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
mode: 'reveal',
|
||||
},
|
||||
});
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'close1',
|
||||
seed: 2,
|
||||
createdAtMs: 200,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
|
||||
radiusN: 0.08,
|
||||
mode: 'cover',
|
||||
},
|
||||
});
|
||||
assert.equal(store.getState().strokes.length, 2);
|
||||
assert.equal(store.getState().strokes[1]?.mode, 'cover');
|
||||
store.switchScene('scene_b', true);
|
||||
store.switchScene('scene_a', true);
|
||||
assert.equal(store.getState().strokes.length, 2);
|
||||
assert.equal(store.getState().strokes[1]?.mode, 'cover');
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { SceneDarknessEvent, SceneDarknessRevealStroke, SceneDarknessState } from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneDarknessState {
|
||||
return {
|
||||
revision: 1,
|
||||
enabled: false,
|
||||
cacheKey: null,
|
||||
strokes: [],
|
||||
draft: null,
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneDarknessStore {
|
||||
private state: SceneDarknessState = emptyState();
|
||||
/** Кэш раскрытых областей по ключу (graphNodeId или sceneId) на время сессии показа. */
|
||||
private cache = new Map<string, SceneDarknessRevealStroke[]>();
|
||||
private currentKey: string | null = null;
|
||||
|
||||
getState(): SceneDarknessState {
|
||||
return { ...this.state, strokes: [...this.state.strokes] };
|
||||
}
|
||||
|
||||
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
|
||||
resetSession(): void {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = emptyState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены.
|
||||
*/
|
||||
switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState {
|
||||
if (this.currentKey !== null) {
|
||||
this.cache.set(this.currentKey, [...this.state.strokes]);
|
||||
}
|
||||
this.currentKey = cacheKey;
|
||||
const strokes =
|
||||
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : [];
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
enabled,
|
||||
cacheKey,
|
||||
strokes,
|
||||
draft: null,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
dispatch(event: SceneDarknessEvent): SceneDarknessState {
|
||||
if (!this.state.enabled) return this.getState();
|
||||
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();
|
||||
}
|
||||
default: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _x: never = event;
|
||||
return this.getState();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ClassicLevel } from 'classic-level';
|
||||
|
||||
import type {
|
||||
FoundryActorDoc,
|
||||
FoundryAdventureDoc,
|
||||
FoundryFolderDoc,
|
||||
FoundryJournalDoc,
|
||||
FoundryLoadedDocuments,
|
||||
FoundryPlaylistDoc,
|
||||
FoundrySceneDoc,
|
||||
} from '../../shared/foundry/foundryTypes';
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function asDocArray<T>(docs: unknown[]): T[] {
|
||||
return docs.filter((d) => isRecord(d) && typeof d._id === 'string' && typeof d.name === 'string') as T[];
|
||||
}
|
||||
|
||||
/** NeDB: по строке JSON на линию (игнор пустых / битых). */
|
||||
export async function readNedbDocuments(filePath: string): Promise<Record<string, unknown>[]> {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (const line of raw.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (isRecord(parsed) && typeof parsed._id === 'string') out.push(parsed);
|
||||
} catch {
|
||||
// skip broken line
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isLevelDbDir(entries: { name: string; isFile(): boolean; isDirectory(): boolean }[]): boolean {
|
||||
const names = new Set(entries.map((e) => e.name));
|
||||
return (
|
||||
names.has('CURRENT') ||
|
||||
names.has('LOG') ||
|
||||
[...names].some((n) => n.endsWith('.ldb') || n.startsWith('MANIFEST-'))
|
||||
);
|
||||
}
|
||||
|
||||
/** Читает primary documents из LevelDB-папки Foundry (ключи `!collection!id`). */
|
||||
export async function readLevelDbDocuments(dirPath: string): Promise<Record<string, unknown>[]> {
|
||||
const db = new ClassicLevel(dirPath, {
|
||||
keyEncoding: 'utf8',
|
||||
valueEncoding: 'json',
|
||||
createIfMissing: false,
|
||||
});
|
||||
try {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for await (const [key, value] of db.iterator()) {
|
||||
if (typeof key !== 'string') continue;
|
||||
const parts = key.split('!');
|
||||
// "", collection, id
|
||||
if (parts.length < 3) continue;
|
||||
const collection = parts[1] ?? '';
|
||||
if (!collection || collection.includes('.')) continue; // embedded
|
||||
if (!isRecord(value)) continue;
|
||||
if (typeof value._id !== 'string') continue;
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
await db.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readCollectionDocs(baseDir: string, collection: string): Promise<Record<string, unknown>[]> {
|
||||
const candidates = [
|
||||
path.join(baseDir, collection),
|
||||
path.join(baseDir, `${collection}.db`),
|
||||
path.join(baseDir, 'data', collection),
|
||||
path.join(baseDir, 'data', `${collection}.db`),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!(await pathExists(candidate))) continue;
|
||||
const st = await fs.stat(candidate);
|
||||
if (st.isFile() && candidate.endsWith('.db')) {
|
||||
return readNedbDocuments(candidate);
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
const entries = await fs.readdir(candidate, { withFileTypes: true });
|
||||
if (isLevelDbDir(entries)) {
|
||||
return readLevelDbDocuments(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function readPackDocuments(
|
||||
packAbsPath: string,
|
||||
documentType: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (!(await pathExists(packAbsPath))) return [];
|
||||
const st = await fs.stat(packAbsPath);
|
||||
if (st.isFile() && packAbsPath.endsWith('.db')) {
|
||||
return readNedbDocuments(packAbsPath);
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
const entries = await fs.readdir(packAbsPath, { withFileTypes: true });
|
||||
if (isLevelDbDir(entries)) {
|
||||
return readLevelDbDocuments(packAbsPath);
|
||||
}
|
||||
// Иногда path указывает на папку с .db внутри.
|
||||
const nestedDb = path.join(packAbsPath, `${path.basename(packAbsPath)}.db`);
|
||||
if (await pathExists(nestedDb)) return readNedbDocuments(nestedDb);
|
||||
}
|
||||
void documentType;
|
||||
return [];
|
||||
}
|
||||
|
||||
function mergeById<T extends { _id: string }>(lists: T[][]): T[] {
|
||||
const map = new Map<string, T>();
|
||||
for (const list of lists) {
|
||||
for (const doc of list) {
|
||||
if (!map.has(doc._id)) map.set(doc._id, doc);
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
function flattenAdventureDocs(adventures: FoundryAdventureDoc[]): {
|
||||
scenes: FoundrySceneDoc[];
|
||||
actors: FoundryActorDoc[];
|
||||
playlists: FoundryPlaylistDoc[];
|
||||
journals: FoundryJournalDoc[];
|
||||
folders: FoundryFolderDoc[];
|
||||
} {
|
||||
const scenes: FoundrySceneDoc[] = [];
|
||||
const actors: FoundryActorDoc[] = [];
|
||||
const playlists: FoundryPlaylistDoc[] = [];
|
||||
const journals: FoundryJournalDoc[] = [];
|
||||
const folders: FoundryFolderDoc[] = [];
|
||||
for (const adv of adventures) {
|
||||
if (Array.isArray(adv.scenes)) scenes.push(...asDocArray<FoundrySceneDoc>(adv.scenes));
|
||||
if (Array.isArray(adv.actors)) actors.push(...asDocArray<FoundryActorDoc>(adv.actors));
|
||||
if (Array.isArray(adv.playlists)) playlists.push(...asDocArray<FoundryPlaylistDoc>(adv.playlists));
|
||||
if (Array.isArray(adv.journal)) journals.push(...asDocArray<FoundryJournalDoc>(adv.journal));
|
||||
if (Array.isArray(adv.folders)) {
|
||||
for (const f of adv.folders) {
|
||||
if (f && typeof f === 'object' && typeof (f as FoundryFolderDoc)._id === 'string') {
|
||||
folders.push(f as FoundryFolderDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { scenes, actors, playlists, journals, folders };
|
||||
}
|
||||
|
||||
function asFolderArray(docs: unknown[]): FoundryFolderDoc[] {
|
||||
return docs.filter(
|
||||
(d): d is FoundryFolderDoc =>
|
||||
Boolean(d) &&
|
||||
typeof d === 'object' &&
|
||||
typeof (d as FoundryFolderDoc)._id === 'string' &&
|
||||
typeof (d as FoundryFolderDoc).name === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadWorldDocuments(rootDir: string): Promise<FoundryLoadedDocuments> {
|
||||
const [scenesRaw, actorsRaw, playlistsRaw, journalsRaw, adventuresRaw, foldersRaw] =
|
||||
await Promise.all([
|
||||
readCollectionDocs(rootDir, 'scenes'),
|
||||
readCollectionDocs(rootDir, 'actors'),
|
||||
readCollectionDocs(rootDir, 'playlists'),
|
||||
readCollectionDocs(rootDir, 'journal'),
|
||||
readCollectionDocs(rootDir, 'adventures'),
|
||||
readCollectionDocs(rootDir, 'folders'),
|
||||
]);
|
||||
|
||||
const adventures = asDocArray<FoundryAdventureDoc>(adventuresRaw);
|
||||
const embedded = flattenAdventureDocs(adventures);
|
||||
|
||||
return {
|
||||
scenes: mergeById([asDocArray<FoundrySceneDoc>(scenesRaw), embedded.scenes]),
|
||||
actors: mergeById([asDocArray<FoundryActorDoc>(actorsRaw), embedded.actors]),
|
||||
playlists: mergeById([asDocArray<FoundryPlaylistDoc>(playlistsRaw), embedded.playlists]),
|
||||
journals: mergeById([asDocArray<FoundryJournalDoc>(journalsRaw), embedded.journals]),
|
||||
adventures,
|
||||
folders: mergeById([asFolderArray(foldersRaw), embedded.folders]),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadModuleDocuments(
|
||||
rootDir: string,
|
||||
packs: { path: string; type: string }[],
|
||||
): Promise<FoundryLoadedDocuments> {
|
||||
const scenes: FoundrySceneDoc[][] = [];
|
||||
const actors: FoundryActorDoc[][] = [];
|
||||
const playlists: FoundryPlaylistDoc[][] = [];
|
||||
const journals: FoundryJournalDoc[][] = [];
|
||||
const adventures: FoundryAdventureDoc[][] = [];
|
||||
|
||||
for (const pack of packs) {
|
||||
const abs = path.isAbsolute(pack.path) ? pack.path : path.join(rootDir, pack.path);
|
||||
// path в module.json иногда с `./` и иногда ещё со старым `.db`
|
||||
const variants = [abs, abs.endsWith('.db') ? abs : `${abs}.db`, abs.replace(/\.db$/u, '')];
|
||||
let docs: Record<string, unknown>[] = [];
|
||||
for (const v of variants) {
|
||||
docs = await readPackDocuments(v, pack.type);
|
||||
if (docs.length > 0) break;
|
||||
}
|
||||
|
||||
const type = pack.type;
|
||||
if (type === 'Scene') scenes.push(asDocArray<FoundrySceneDoc>(docs));
|
||||
else if (type === 'Actor') actors.push(asDocArray<FoundryActorDoc>(docs));
|
||||
else if (type === 'Playlist') playlists.push(asDocArray<FoundryPlaylistDoc>(docs));
|
||||
else if (type === 'JournalEntry') journals.push(asDocArray<FoundryJournalDoc>(docs));
|
||||
else if (type === 'Adventure') adventures.push(asDocArray<FoundryAdventureDoc>(docs));
|
||||
}
|
||||
|
||||
const advMerged = mergeById(adventures);
|
||||
const embedded = flattenAdventureDocs(advMerged);
|
||||
|
||||
return {
|
||||
scenes: mergeById([...scenes, embedded.scenes]),
|
||||
actors: mergeById([...actors, embedded.actors]),
|
||||
playlists: mergeById([...playlists, embedded.playlists]),
|
||||
journals: mergeById([...journals, embedded.journals]),
|
||||
adventures: advMerged,
|
||||
folders: mergeById([embedded.folders]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { decodeFoundryAssetPath } from '../../shared/foundry/foundryPaths';
|
||||
import type { FoundryPackageManifest, FoundryPackRef } from '../../shared/foundry/foundryTypes';
|
||||
import { isSupportedFoundryPackage } from '../../shared/foundry/foundryVersion';
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<unknown> {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw) as unknown;
|
||||
}
|
||||
|
||||
function parsePacks(raw: unknown): FoundryPackRef[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: FoundryPackRef[] = [];
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue;
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const label = typeof item.label === 'string' ? item.label : name;
|
||||
const packPath = typeof item.path === 'string' ? item.path.replace(/^\.\//u, '') : '';
|
||||
const type =
|
||||
typeof item.type === 'string' ? item.type : typeof item.entity === 'string' ? item.entity : '';
|
||||
if (!packPath || !type) continue;
|
||||
out.push({ name: name || path.basename(packPath), label: label || name, path: packPath, type });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function titleFromManifest(data: Record<string, unknown>, fallbackId: string): string {
|
||||
if (typeof data.title === 'string' && data.title.trim()) return data.title.trim();
|
||||
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
function idFromManifest(data: Record<string, unknown>, dirName: string): string {
|
||||
if (typeof data.id === 'string' && data.id.trim()) return data.id.trim();
|
||||
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
|
||||
return dirName;
|
||||
}
|
||||
|
||||
async function tryParseManifest(dir: string): Promise<FoundryPackageManifest | null> {
|
||||
const worldPath = path.join(dir, 'world.json');
|
||||
const modulePath = path.join(dir, 'module.json');
|
||||
|
||||
try {
|
||||
await fs.access(worldPath);
|
||||
const data = await readJsonFile(worldPath);
|
||||
if (!isRecord(data)) return null;
|
||||
const id = idFromManifest(data, path.basename(dir));
|
||||
const manifest: FoundryPackageManifest = {
|
||||
kind: 'world',
|
||||
id,
|
||||
title: titleFromManifest(data, id),
|
||||
rootDir: dir,
|
||||
packs: [],
|
||||
};
|
||||
const compatibility = parseCompatibility(data.compatibility);
|
||||
if (compatibility) manifest.compatibility = compatibility;
|
||||
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
|
||||
return manifest;
|
||||
} catch {
|
||||
// not a world
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(modulePath);
|
||||
const data = await readJsonFile(modulePath);
|
||||
if (!isRecord(data)) return null;
|
||||
const id = idFromManifest(data, path.basename(dir));
|
||||
const manifest: FoundryPackageManifest = {
|
||||
kind: 'module',
|
||||
id,
|
||||
title: titleFromManifest(data, id),
|
||||
rootDir: dir,
|
||||
packs: parsePacks(data.packs),
|
||||
};
|
||||
const compatibility = parseCompatibility(data.compatibility);
|
||||
if (compatibility) manifest.compatibility = compatibility;
|
||||
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
|
||||
return manifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCompatibility(raw: unknown): FoundryPackageManifest['compatibility'] | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const out: NonNullable<FoundryPackageManifest['compatibility']> = {};
|
||||
if (typeof raw.minimum === 'string') out.minimum = raw.minimum;
|
||||
if (typeof raw.verified === 'string') out.verified = raw.verified;
|
||||
if (typeof raw.maximum === 'string') out.maximum = raw.maximum;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ищет world.json / module.json в папке и на 1–2 уровня глубже. */
|
||||
export async function detectFoundryPackage(rootPath: string): Promise<FoundryPackageManifest> {
|
||||
const abs = path.resolve(rootPath);
|
||||
const direct = await tryParseManifest(abs);
|
||||
if (direct) {
|
||||
const ver = isSupportedFoundryPackage(direct);
|
||||
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
|
||||
return direct;
|
||||
}
|
||||
|
||||
// Архив мог содержать один корневой каталог.
|
||||
const entries = await fs.readdir(abs, { withFileTypes: true });
|
||||
const dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(abs, e.name));
|
||||
|
||||
for (const dir of dirs) {
|
||||
const found = await tryParseManifest(dir);
|
||||
if (found) {
|
||||
const ver = isSupportedFoundryPackage(found);
|
||||
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// Data/worlds/<id> или Data/modules/<id>
|
||||
for (const mid of ['worlds', 'modules']) {
|
||||
const midDir = path.join(abs, mid);
|
||||
try {
|
||||
const st = await fs.stat(midDir);
|
||||
if (!st.isDirectory()) continue;
|
||||
const children = await fs.readdir(midDir, { withFileTypes: true });
|
||||
for (const child of children.filter((c) => c.isDirectory())) {
|
||||
const found = await tryParseManifest(path.join(midDir, child.name));
|
||||
if (found) {
|
||||
const ver = isSupportedFoundryPackage(found);
|
||||
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
|
||||
return found;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Data как корень выбранной папки
|
||||
const dataDir = path.join(abs, 'Data');
|
||||
try {
|
||||
const st = await fs.stat(dataDir);
|
||||
if (st.isDirectory()) {
|
||||
return await detectFoundryPackage(dataDir);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Не похоже на пакет Foundry VTT: не найдены world.json или module.json. Выберите папку мира/модуля или архив с ними.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Резолвит путь ассета Foundry относительно корня пакета / Data.
|
||||
* foundryPath — как в документах: `worlds/id/...`, `modules/id/...` или относительный.
|
||||
* Поддерживает URL-encoding (`The%20Withered%20Grove%20(day).webp`).
|
||||
*/
|
||||
export async function resolveFoundryAssetPath(
|
||||
manifest: FoundryPackageManifest,
|
||||
foundryPath: string,
|
||||
): Promise<string | null> {
|
||||
const cleaned = decodeFoundryAssetPath(foundryPath);
|
||||
if (!cleaned) return null;
|
||||
if (/^https?:\/\//iu.test(cleaned)) return null;
|
||||
|
||||
const packagePrefix = manifest.kind === 'world' ? `worlds/${manifest.id}/` : `modules/${manifest.id}/`;
|
||||
|
||||
const rawNormalized = foundryPath.trim().replace(/\\/gu, '/');
|
||||
const pathVariants = cleaned === rawNormalized ? [cleaned] : [cleaned, rawNormalized];
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const variant of pathVariants) {
|
||||
const v = variant.replace(/^\/+/u, '');
|
||||
if (!v) continue;
|
||||
if (v.toLowerCase().startsWith(packagePrefix.toLowerCase())) {
|
||||
candidates.push(path.join(manifest.rootDir, v.slice(packagePrefix.length)));
|
||||
}
|
||||
candidates.push(path.join(manifest.rootDir, v));
|
||||
|
||||
const dataRootGuesses = [
|
||||
path.resolve(manifest.rootDir, '..', '..'),
|
||||
path.resolve(manifest.rootDir, '..'),
|
||||
];
|
||||
for (const dataRoot of dataRootGuesses) {
|
||||
candidates.push(path.join(dataRoot, v));
|
||||
}
|
||||
|
||||
const base = path.basename(v);
|
||||
if (base && base !== v) {
|
||||
candidates.push(path.join(manifest.rootDir, base));
|
||||
candidates.push(path.join(manifest.rootDir, 'assets', base));
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const st = await fs.stat(c);
|
||||
if (st.isFile()) return c;
|
||||
} catch {
|
||||
// next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
actorPortraitSrc,
|
||||
extractActorDescriptionHtml,
|
||||
filterScenesForImport,
|
||||
journalDescriptionHtml,
|
||||
planFoundrySceneGraph,
|
||||
sceneBackgroundSrc,
|
||||
} from '../../shared/foundry/foundryGraph';
|
||||
import type {
|
||||
FoundryFolderDoc,
|
||||
FoundryLoadedDocuments,
|
||||
FoundryPackageManifest,
|
||||
FoundryPlaylistDoc,
|
||||
FoundrySceneDoc,
|
||||
} from '../../shared/foundry/foundryTypes';
|
||||
import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups';
|
||||
import type {
|
||||
MediaAsset,
|
||||
Project,
|
||||
ProjectNpc,
|
||||
ProjectNpcGroup,
|
||||
Scene,
|
||||
SceneGraphEdge,
|
||||
SceneGraphNode,
|
||||
} from '../../shared/types';
|
||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import type { AssetId, GraphNodeId, NpcGroupId, SceneId } from '../../shared/types/ids';
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
asNpcGroupId,
|
||||
asNpcId,
|
||||
asProjectId,
|
||||
asSceneId,
|
||||
} from '../../shared/types/ids';
|
||||
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
|
||||
import { generateScenePreviewThumbnailBytes } from '../project/scenePreviewThumbnail';
|
||||
import { unzipToDir } from '../project/yauzlProjectZip';
|
||||
import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { loadModuleDocuments, loadWorldDocuments } from './foundryDb';
|
||||
import { detectFoundryPackage, resolveFoundryAssetPath } from './foundryDetect';
|
||||
|
||||
export type FoundryImportProgress = {
|
||||
stage: 'copy' | 'unzip' | 'zip' | 'done';
|
||||
percent: number;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
/** Минимальный 1×1 PNG (серый), если у актёра нет портрета на диске. */
|
||||
const PLACEHOLDER_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
type MediaKind = { type: 'image' | 'video' | 'audio'; mime: string };
|
||||
|
||||
function classifyMediaPath(filePath: string): MediaKind | null {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.png':
|
||||
return { type: 'image', mime: 'image/png' };
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return { type: 'image', mime: 'image/jpeg' };
|
||||
case '.webp':
|
||||
return { type: 'image', mime: 'image/webp' };
|
||||
case '.gif':
|
||||
return { type: 'image', mime: 'image/gif' };
|
||||
case '.bmp':
|
||||
return { type: 'image', mime: 'image/bmp' };
|
||||
case '.mp4':
|
||||
return { type: 'video', mime: 'video/mp4' };
|
||||
case '.webm':
|
||||
return { type: 'video', mime: 'video/webm' };
|
||||
case '.mov':
|
||||
return { type: 'video', mime: 'video/quicktime' };
|
||||
case '.mp3':
|
||||
return { type: 'audio', mime: 'audio/mpeg' };
|
||||
case '.wav':
|
||||
return { type: 'audio', mime: 'audio/wav' };
|
||||
case '.ogg':
|
||||
return { type: 'audio', mime: 'audio/ogg' };
|
||||
case '.m4a':
|
||||
return { type: 'audio', mime: 'audio/mp4' };
|
||||
case '.aac':
|
||||
return { type: 'audio', mime: 'audio/aac' };
|
||||
case '.flac':
|
||||
return { type: 'audio', mime: 'audio/flac' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
const cleaned = name
|
||||
.split('')
|
||||
.map((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code < 32 || '<>:"/\\|?*'.includes(ch)) return '_';
|
||||
return ch;
|
||||
})
|
||||
.join('')
|
||||
.trim();
|
||||
return cleaned.length > 0 ? cleaned.slice(0, 180) : 'file';
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
function buildMediaAsset(
|
||||
id: AssetId,
|
||||
kind: MediaKind,
|
||||
originalName: string,
|
||||
relPath: string,
|
||||
sha256: string,
|
||||
sizeBytes: number,
|
||||
): MediaAsset {
|
||||
const createdAt = new Date().toISOString();
|
||||
const base = { id, mime: kind.mime, originalName, relPath, sha256, sizeBytes, createdAt };
|
||||
if (kind.type === 'image') return { ...base, type: 'image' };
|
||||
if (kind.type === 'video') return { ...base, type: 'video' };
|
||||
return { ...base, type: 'audio' };
|
||||
}
|
||||
|
||||
async function isZipFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const st = await fs.stat(filePath);
|
||||
if (!st.isFile()) return false;
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return ext === '.zip' || ext === '.fvtt';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareSourceDir(
|
||||
sourcePath: string,
|
||||
onProgress?: (p: FoundryImportProgress) => void,
|
||||
): Promise<{ workDir: string; cleanup: () => Promise<void> }> {
|
||||
const abs = path.resolve(sourcePath);
|
||||
if (await isZipFile(abs)) {
|
||||
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ttrpg-foundry-'));
|
||||
onProgress?.({ stage: 'unzip', percent: 5, detail: 'Распаковка архива Foundry…' });
|
||||
await unzipToDir(abs, workDir, (done, total) => {
|
||||
const pct = total > 0 ? 5 + Math.round((done / total) * 25) : 15;
|
||||
onProgress?.({ stage: 'unzip', percent: Math.min(30, pct), detail: 'Распаковка архива Foundry…' });
|
||||
});
|
||||
return {
|
||||
workDir,
|
||||
cleanup: async () => {
|
||||
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
const st = await fs.stat(abs);
|
||||
if (!st.isDirectory()) {
|
||||
throw new Error('Выберите папку мира/модуля Foundry или архив (.zip / .fvtt).');
|
||||
}
|
||||
return {
|
||||
workDir: abs,
|
||||
cleanup: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
type AssetWriteCtx = {
|
||||
cacheDir: string;
|
||||
assets: Record<AssetId, MediaAsset>;
|
||||
/** foundry path or abs path → assetId */
|
||||
bySourceKey: Map<string, AssetId>;
|
||||
};
|
||||
|
||||
async function importFileAsAsset(
|
||||
ctx: AssetWriteCtx,
|
||||
absPath: string,
|
||||
opts?: { optimizeImage?: boolean; forceKind?: MediaKind },
|
||||
): Promise<AssetId | null> {
|
||||
const key = path.resolve(absPath).toLowerCase();
|
||||
const existing = ctx.bySourceKey.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
let kind = opts?.forceKind ?? classifyMediaPath(absPath);
|
||||
if (!kind) return null;
|
||||
|
||||
let buf = await fs.readFile(absPath);
|
||||
if (kind.type === 'image' && opts?.optimizeImage !== false) {
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (!opt.passthrough && opt.buffer.length > 0) {
|
||||
buf = Buffer.from(opt.buffer);
|
||||
kind = { type: 'image', mime: opt.mime };
|
||||
}
|
||||
} catch {
|
||||
// keep original
|
||||
}
|
||||
}
|
||||
|
||||
const id = asAssetId(randomId());
|
||||
const orig = path.basename(absPath);
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const absOut = path.join(ctx.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(absOut), { recursive: true });
|
||||
await fs.writeFile(absOut, buf);
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
ctx.assets[id] = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
||||
ctx.bySourceKey.set(key, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function importPlaceholderAvatar(ctx: AssetWriteCtx, name: string): Promise<AssetId> {
|
||||
const id = asAssetId(randomId());
|
||||
const orig = `${sanitizeFileName(name)}_avatar.png`;
|
||||
const relPath = `assets/${id}_${orig}`;
|
||||
const absOut = path.join(ctx.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(absOut), { recursive: true });
|
||||
await fs.writeFile(absOut, PLACEHOLDER_PNG);
|
||||
const sha256 = crypto.createHash('sha256').update(PLACEHOLDER_PNG).digest('hex');
|
||||
ctx.assets[id] = buildMediaAsset(
|
||||
id,
|
||||
{ type: 'image', mime: 'image/png' },
|
||||
orig,
|
||||
relPath,
|
||||
sha256,
|
||||
PLACEHOLDER_PNG.length,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function resolveAndImport(
|
||||
ctx: AssetWriteCtx,
|
||||
manifest: FoundryPackageManifest,
|
||||
foundryPath: string | null | undefined,
|
||||
opts?: { optimizeImage?: boolean },
|
||||
): Promise<AssetId | null> {
|
||||
if (!foundryPath?.trim()) return null;
|
||||
const abs = await resolveFoundryAssetPath(manifest, foundryPath);
|
||||
if (!abs) return null;
|
||||
return importFileAsAsset(ctx, abs, opts);
|
||||
}
|
||||
|
||||
function uniqueNpcName(base: string, used: Set<string>): string {
|
||||
const root = base.trim() || 'NPC';
|
||||
if (!used.has(root.toLowerCase())) {
|
||||
used.add(root.toLowerCase());
|
||||
return root;
|
||||
}
|
||||
let i = 2;
|
||||
while (used.has(`${root} (${String(i)})`.toLowerCase())) i += 1;
|
||||
const name = `${root} (${String(i)})`;
|
||||
used.add(name.toLowerCase());
|
||||
return name;
|
||||
}
|
||||
|
||||
function collectSceneAudioPaths(
|
||||
scene: FoundrySceneDoc,
|
||||
playlistsById: Map<string, FoundryPlaylistDoc>,
|
||||
): string[] {
|
||||
const paths: string[] = [];
|
||||
const playlistId = typeof scene.playlist === 'string' ? scene.playlist : null;
|
||||
if (!playlistId) return paths;
|
||||
const playlist = playlistsById.get(playlistId);
|
||||
if (!playlist?.sounds?.length) return paths;
|
||||
|
||||
const soundId = typeof scene.playlistSound === 'string' ? scene.playlistSound : null;
|
||||
const sounds = soundId
|
||||
? playlist.sounds.filter((s) => s._id === soundId)
|
||||
: [...playlist.sounds].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
||||
|
||||
for (const s of sounds) {
|
||||
if (typeof s.path === 'string' && s.path.trim()) paths.push(s.path.trim());
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export type FoundryBuiltProject = {
|
||||
project: Project;
|
||||
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
|
||||
previewSources: { sceneId: SceneId; absPath: string }[];
|
||||
};
|
||||
|
||||
export async function buildProjectFromFoundryDocuments(
|
||||
manifest: FoundryPackageManifest,
|
||||
docs: FoundryLoadedDocuments,
|
||||
cacheDir: string,
|
||||
onProgress?: (p: FoundryImportProgress) => void,
|
||||
options?: { projectId?: ReturnType<typeof asProjectId> },
|
||||
): Promise<FoundryBuiltProject> {
|
||||
const projectId = options?.projectId ?? asProjectId(randomId());
|
||||
const now = new Date().toISOString();
|
||||
const appVer = getAppSemanticVersion();
|
||||
const name = manifest.title.trim() || manifest.id;
|
||||
const fileBaseName = `${sanitizeFileName(name)}_${projectId}`;
|
||||
|
||||
const ctx: AssetWriteCtx = { cacheDir, assets: {}, bySourceKey: new Map() };
|
||||
const journalsById = new Map(docs.journals.map((j) => [j._id, j]));
|
||||
const playlistsById = new Map(docs.playlists.map((p) => [p._id, p]));
|
||||
|
||||
const importScenes = filterScenesForImport(docs.scenes);
|
||||
const graphPlan = planFoundrySceneGraph(importScenes, docs.adventures, docs.journals);
|
||||
const sceneOrderIds =
|
||||
graphPlan.orderedSceneIds.length > 0 ? graphPlan.orderedSceneIds : importScenes.map((s) => s._id);
|
||||
|
||||
const scenesByFoundryId = new Map(importScenes.map((s) => [s._id, s]));
|
||||
const foundryToSceneId = new Map<string, SceneId>();
|
||||
const scenes: Record<SceneId, Scene> = {};
|
||||
const sceneListOrder: SceneId[] = [];
|
||||
const previewSources: { sceneId: SceneId; absPath: string }[] = [];
|
||||
|
||||
const totalSteps = Math.max(1, sceneOrderIds.length + docs.actors.length);
|
||||
let step = 0;
|
||||
|
||||
for (const foundrySceneId of sceneOrderIds) {
|
||||
const doc = scenesByFoundryId.get(foundrySceneId);
|
||||
if (!doc) continue;
|
||||
step += 1;
|
||||
onProgress?.({
|
||||
stage: 'copy',
|
||||
percent: 30 + Math.round((step / totalSteps) * 50),
|
||||
detail: `Сцена: ${doc.name}`,
|
||||
});
|
||||
|
||||
const sceneId = asSceneId(`s_${randomId()}`);
|
||||
foundryToSceneId.set(foundrySceneId, sceneId);
|
||||
|
||||
let previewAssetId: AssetId | null = null;
|
||||
let previewAssetType: 'image' | 'video' | null = null;
|
||||
let previewThumbAssetId: AssetId | null = null;
|
||||
|
||||
const bg = sceneBackgroundSrc(doc);
|
||||
if (bg) {
|
||||
const abs = await resolveFoundryAssetPath(manifest, bg);
|
||||
if (abs) {
|
||||
const kind = classifyMediaPath(abs);
|
||||
if (kind?.type === 'image' || kind?.type === 'video') {
|
||||
previewAssetId = await importFileAsAsset(ctx, abs, { optimizeImage: kind.type === 'image' });
|
||||
previewAssetType = kind.type;
|
||||
previewSources.push({ sceneId, absPath: abs });
|
||||
if (previewAssetId && kind.type === 'image') {
|
||||
try {
|
||||
const thumbBytes = await generateScenePreviewThumbnailBytes(abs, 'image');
|
||||
if (thumbBytes && thumbBytes.length > 0) {
|
||||
const thumbId = asAssetId(randomId());
|
||||
const thumbRel = `assets/${thumbId}_preview_thumb.webp`;
|
||||
await fs.writeFile(path.join(cacheDir, thumbRel), thumbBytes);
|
||||
ctx.assets[thumbId] = buildMediaAsset(
|
||||
thumbId,
|
||||
{ type: 'image', mime: 'image/webp' },
|
||||
`${sanitizeFileName(doc.name)}_preview_thumb.webp`,
|
||||
thumbRel,
|
||||
crypto.createHash('sha256').update(thumbBytes).digest('hex'),
|
||||
thumbBytes.length,
|
||||
);
|
||||
previewThumbAssetId = thumbId;
|
||||
}
|
||||
} catch {
|
||||
// optional
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const audioRefs: Scene['media']['audios'] = [];
|
||||
for (const audioPath of collectSceneAudioPaths(doc, playlistsById)) {
|
||||
const assetId = await resolveAndImport(ctx, manifest, audioPath);
|
||||
if (!assetId) continue;
|
||||
if (ctx.assets[assetId]?.type !== 'audio') continue;
|
||||
audioRefs.push({ assetId, autoplay: true, loop: true });
|
||||
}
|
||||
|
||||
scenes[sceneId] = {
|
||||
id: sceneId,
|
||||
title: doc.name.trim() || 'Scene',
|
||||
description: journalDescriptionHtml(doc, journalsById),
|
||||
previewAssetId,
|
||||
previewAssetType,
|
||||
previewThumbAssetId,
|
||||
previewVideoAutostart: previewAssetType === 'video',
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: audioRefs },
|
||||
settings: {
|
||||
autoplayVideo: previewAssetType === 'video',
|
||||
autoplayAudio: true,
|
||||
loopVideo: true,
|
||||
loopAudio: true,
|
||||
},
|
||||
connections: [],
|
||||
layout: { x: 0, y: 0 },
|
||||
};
|
||||
sceneListOrder.push(sceneId);
|
||||
}
|
||||
|
||||
const usedPlaylistIds = new Set(
|
||||
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
|
||||
);
|
||||
const campaignAudios: Project['campaignAudios'] = [];
|
||||
for (const pl of docs.playlists) {
|
||||
if (usedPlaylistIds.has(pl._id)) continue;
|
||||
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
||||
for (const s of sounds) {
|
||||
if (typeof s.path !== 'string' || !s.path.trim()) continue;
|
||||
const assetId = await resolveAndImport(ctx, manifest, s.path);
|
||||
if (!assetId || ctx.assets[assetId]?.type !== 'audio') continue;
|
||||
campaignAudios.push({ assetId, autoplay: false, loop: true });
|
||||
}
|
||||
}
|
||||
|
||||
const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders);
|
||||
|
||||
const npcs: ProjectNpc[] = [];
|
||||
const usedNpcNames = new Set<string>();
|
||||
let npcIndex = 0;
|
||||
for (const actor of docs.actors) {
|
||||
// Группы актёров Foundry (party/group) — не персонажи.
|
||||
if (actor.type === 'group') continue;
|
||||
step += 1;
|
||||
onProgress?.({
|
||||
stage: 'copy',
|
||||
percent: 30 + Math.round((step / totalSteps) * 50),
|
||||
detail: `НПС: ${actor.name}`,
|
||||
});
|
||||
const portrait = actorPortraitSrc(actor);
|
||||
let avatarAssetId = portrait ? await resolveAndImport(ctx, manifest, portrait) : null;
|
||||
if (!avatarAssetId || ctx.assets[avatarAssetId]?.type !== 'image') {
|
||||
avatarAssetId = await importPlaceholderAvatar(ctx, actor.name);
|
||||
}
|
||||
const npcName = uniqueNpcName(actor.name, usedNpcNames);
|
||||
const folderId = typeof actor.folder === 'string' ? actor.folder : null;
|
||||
const groupId = folderId ? (foundryFolderToGroupId.get(folderId) ?? null) : null;
|
||||
npcs.push({
|
||||
id: asNpcId(`npc_${randomId()}`),
|
||||
name: npcName,
|
||||
avatarAssetId,
|
||||
description: extractActorDescriptionHtml(actor),
|
||||
x: 80 + (npcIndex % 4) * 220,
|
||||
y: 80 + Math.floor(npcIndex / 4) * 200,
|
||||
groupId,
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
});
|
||||
npcIndex += 1;
|
||||
}
|
||||
|
||||
const sceneGraphNodes: SceneGraphNode[] = [];
|
||||
const sceneGraphEdges: SceneGraphEdge[] = [];
|
||||
const foundryToGraphNode = new Map<string, GraphNodeId>();
|
||||
|
||||
const startFoundryId = graphPlan.startSceneId;
|
||||
const startSceneId =
|
||||
(startFoundryId ? foundryToSceneId.get(startFoundryId) : null) ?? sceneListOrder[0] ?? null;
|
||||
|
||||
sceneListOrder.forEach((sceneId, index) => {
|
||||
const foundryId = [...foundryToSceneId.entries()].find(([, sid]) => sid === sceneId)?.[0];
|
||||
const col = index % 4;
|
||||
const row = Math.floor(index / 4);
|
||||
const gnId = asGraphNodeId(`gn_${randomId()}`);
|
||||
sceneGraphNodes.push({
|
||||
id: gnId,
|
||||
sceneId,
|
||||
x: 80 + col * 280,
|
||||
y: 80 + row * 200,
|
||||
isStartScene: startSceneId !== null && sceneId === startSceneId,
|
||||
isSideStoryStart: false,
|
||||
sideStoryLineTitle: '',
|
||||
});
|
||||
if (foundryId) foundryToGraphNode.set(foundryId, gnId);
|
||||
});
|
||||
|
||||
for (const edge of graphPlan.edges) {
|
||||
const source = foundryToGraphNode.get(edge.sourceId);
|
||||
const target = foundryToGraphNode.get(edge.targetId);
|
||||
if (!source || !target || source === target) continue;
|
||||
sceneGraphEdges.push({
|
||||
id: `e_${randomId()}`,
|
||||
sourceGraphNodeId: source,
|
||||
targetGraphNodeId: target,
|
||||
});
|
||||
}
|
||||
|
||||
// connections из рёбер графа
|
||||
const outgoing = new Map<SceneId, Set<SceneId>>();
|
||||
const gnMap = new Map(sceneGraphNodes.map((n) => [n.id, n]));
|
||||
for (const e of sceneGraphEdges) {
|
||||
const a = gnMap.get(e.sourceGraphNodeId);
|
||||
const b = gnMap.get(e.targetGraphNodeId);
|
||||
if (!a || !b || a.sceneId === b.sceneId) continue;
|
||||
let set = outgoing.get(a.sceneId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
outgoing.set(a.sceneId, set);
|
||||
}
|
||||
set.add(b.sceneId);
|
||||
}
|
||||
for (const [sid, set] of outgoing) {
|
||||
const sc = scenes[sid];
|
||||
if (sc) sc.connections = [...set];
|
||||
}
|
||||
|
||||
const startGraphNodeId = sceneGraphNodes.find((n) => n.isStartScene)?.id ?? null;
|
||||
|
||||
const project: Project = {
|
||||
id: projectId,
|
||||
meta: {
|
||||
name,
|
||||
fileBaseName,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdWithAppVersion: appVer,
|
||||
appVersion: appVer,
|
||||
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||
},
|
||||
scenes,
|
||||
sceneListOrder,
|
||||
assets: ctx.assets,
|
||||
campaignAudios,
|
||||
materials: [],
|
||||
npcs,
|
||||
npcGroups,
|
||||
npcRelations: [],
|
||||
currentSceneId: startSceneId,
|
||||
currentGraphNodeId: startGraphNodeId,
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
};
|
||||
|
||||
return { project, previewSources };
|
||||
}
|
||||
|
||||
/** Actor folders Foundry → дерево групп НПС. */
|
||||
function buildNpcGroupsFromFoundryFolders(folders: FoundryFolderDoc[]): {
|
||||
npcGroups: ProjectNpcGroup[];
|
||||
foundryFolderToGroupId: Map<string, NpcGroupId>;
|
||||
} {
|
||||
const actorFolders = folders
|
||||
.filter((f) => !f.type || f.type === 'Actor')
|
||||
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.name.localeCompare(b.name));
|
||||
|
||||
const foundryFolderToGroupId = new Map<string, NpcGroupId>();
|
||||
for (const f of actorFolders) {
|
||||
foundryFolderToGroupId.set(f._id, asNpcGroupId(`ng_${randomId()}`));
|
||||
}
|
||||
|
||||
const nameKeysByParent = new Map<string | null, Set<string>>();
|
||||
const npcGroups: ProjectNpcGroup[] = [];
|
||||
|
||||
// Parents first
|
||||
const remaining = [...actorFolders];
|
||||
const placed = new Set<string>();
|
||||
while (remaining.length > 0) {
|
||||
let progress = false;
|
||||
for (let i = 0; i < remaining.length; i += 1) {
|
||||
const f = remaining[i]!;
|
||||
const parentFoundry = typeof f.folder === 'string' && f.folder.trim() ? f.folder.trim() : null;
|
||||
if (parentFoundry && !foundryFolderToGroupId.has(parentFoundry)) {
|
||||
// orphan parent ref → root
|
||||
} else if (parentFoundry && !placed.has(parentFoundry) && foundryFolderToGroupId.has(parentFoundry)) {
|
||||
continue;
|
||||
}
|
||||
const parentId = parentFoundry ? (foundryFolderToGroupId.get(parentFoundry) ?? null) : null;
|
||||
const parentKey = parentId;
|
||||
let keys = nameKeysByParent.get(parentKey);
|
||||
if (!keys) {
|
||||
keys = new Set();
|
||||
nameKeysByParent.set(parentKey, keys);
|
||||
}
|
||||
let name = f.name.trim() || 'Group';
|
||||
const base = name.toLowerCase();
|
||||
if (keys.has(base)) {
|
||||
let n = 2;
|
||||
while (keys.has(`${base} (${String(n)})`)) n += 1;
|
||||
name = `${name} (${String(n)})`;
|
||||
}
|
||||
keys.add(name.toLowerCase());
|
||||
const id = foundryFolderToGroupId.get(f._id)!;
|
||||
npcGroups.push({
|
||||
id,
|
||||
name,
|
||||
color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR),
|
||||
parentId,
|
||||
});
|
||||
placed.add(f._id);
|
||||
remaining.splice(i, 1);
|
||||
progress = true;
|
||||
break;
|
||||
}
|
||||
if (!progress) {
|
||||
// cycle / leftover → force as roots
|
||||
for (const f of remaining) {
|
||||
const id = foundryFolderToGroupId.get(f._id)!;
|
||||
let name = f.name.trim() || 'Group';
|
||||
const keys = nameKeysByParent.get(null) ?? new Set();
|
||||
nameKeysByParent.set(null, keys);
|
||||
if (keys.has(name.toLowerCase())) {
|
||||
let n = 2;
|
||||
while (keys.has(`${name.toLowerCase()} (${String(n)})`)) n += 1;
|
||||
name = `${name} (${String(n)})`;
|
||||
}
|
||||
keys.add(name.toLowerCase());
|
||||
npcGroups.push({
|
||||
id,
|
||||
name,
|
||||
color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR),
|
||||
parentId: null,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { npcGroups, foundryFolderToGroupId };
|
||||
}
|
||||
|
||||
export async function loadFoundryDocumentsForImport(
|
||||
sourcePath: string,
|
||||
onProgress?: (p: FoundryImportProgress) => void,
|
||||
): Promise<{
|
||||
manifest: FoundryPackageManifest;
|
||||
docs: FoundryLoadedDocuments;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const prepared = await prepareSourceDir(sourcePath, onProgress);
|
||||
try {
|
||||
onProgress?.({ stage: 'copy', percent: 32, detail: 'Определение пакета Foundry…' });
|
||||
const manifest = await detectFoundryPackage(prepared.workDir);
|
||||
onProgress?.({
|
||||
stage: 'copy',
|
||||
percent: 36,
|
||||
detail: manifest.kind === 'world' ? 'Чтение мира…' : 'Чтение модуля…',
|
||||
});
|
||||
const docs =
|
||||
manifest.kind === 'world'
|
||||
? await loadWorldDocuments(manifest.rootDir)
|
||||
: await loadModuleDocuments(manifest.rootDir, manifest.packs);
|
||||
|
||||
if (docs.scenes.length === 0 && docs.actors.length === 0) {
|
||||
throw new Error(
|
||||
'В пакете Foundry не найдено сцен и актёров. Проверьте, что выбран мир/модуль с данными (не пустой шаблон).',
|
||||
);
|
||||
}
|
||||
|
||||
return { manifest, docs, cleanup: prepared.cleanup };
|
||||
} catch (e) {
|
||||
await prepared.cleanup();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
+1092
-94
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,10 @@ function channelRequiresLicense(channel: string): boolean {
|
||||
if (channel.startsWith('license.')) return false;
|
||||
if (channel.startsWith('app.')) return false;
|
||||
if (channel === ipcChannels.windows.closeMultiWindow) return false;
|
||||
if (channel === ipcChannels.windows.closeSceneDescription) return false;
|
||||
if (channel === ipcChannels.windows.closeMaterials) return false;
|
||||
if (channel === ipcChannels.windows.closeNpcs) return false;
|
||||
if (channel === ipcChannels.windows.closeNpcsEditor) return false;
|
||||
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
|
||||
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
|
||||
if (channel === ipcChannels.project.list) return false;
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { resolveMachineFingerprint } from './machineFingerprint';
|
||||
import { deviceIdPath } from './paths';
|
||||
|
||||
export function getOrCreateDeviceId(userData: string): string {
|
||||
const p = deviceIdPath(userData);
|
||||
/** Старый per-user UUID из userData/device.id (до привязки к железу). */
|
||||
export function readLegacyDeviceId(userData: string): string | null {
|
||||
try {
|
||||
const existing = fs.readFileSync(p, 'utf8').trim();
|
||||
const existing = fs.readFileSync(deviceIdPath(userData), 'utf8').trim();
|
||||
if (existing.length >= 8) return existing;
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
const id = randomUUID();
|
||||
fs.mkdirSync(userData, { recursive: true });
|
||||
fs.writeFileSync(p, `${id}\n`, 'utf8');
|
||||
return id;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearLegacyDeviceId(userData: string): void {
|
||||
try {
|
||||
fs.unlinkSync(deviceIdPath(userData));
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Идентификатор устройства для лицензии: отпечаток физической машины.
|
||||
* Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux).
|
||||
* `userData` — путь для дискового кэша fingerprint (без повторного reg/wmic).
|
||||
*/
|
||||
export function getOrCreateDeviceId(userData?: string): string {
|
||||
return resolveMachineFingerprint(userData ? { userData } : {});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { BrowserWindow, safeStorage } from 'electron';
|
||||
@@ -6,11 +7,11 @@ import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||
import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
|
||||
import { isDndProductKey } from '../../shared/license/productKey';
|
||||
import { isProductKey } from '../../shared/license/productKey';
|
||||
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
|
||||
|
||||
import { getOrCreateDeviceId } from './deviceId';
|
||||
import { licenseEncryptedPath, preferencesPath } from './paths';
|
||||
import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
|
||||
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
|
||||
import { verifyLicenseToken } from './verifyLicenseToken';
|
||||
|
||||
type Preferences = {
|
||||
@@ -19,6 +20,8 @@ type Preferences = {
|
||||
|
||||
type LicenseChangeListener = () => void;
|
||||
|
||||
const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii');
|
||||
|
||||
const licenseChangeListeners = new Set<LicenseChangeListener>();
|
||||
|
||||
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
|
||||
@@ -63,35 +66,121 @@ function emitLicenseStatusChanged(): void {
|
||||
export class LicenseService {
|
||||
private readonly userData: string;
|
||||
private readonly deviceId: string;
|
||||
/** UUID из старого userData/device.id — только для совместимости до повторной активации. */
|
||||
private legacyDeviceId: string | null;
|
||||
private lastRemoteRevokeCheckMs = 0;
|
||||
private lastRemoteRevoked = false;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.userData = userData;
|
||||
this.deviceId = getOrCreateDeviceId(userData);
|
||||
const legacy = readLegacyDeviceId(userData);
|
||||
this.legacyDeviceId = legacy && legacy !== this.deviceId ? legacy : null;
|
||||
}
|
||||
|
||||
private verifyOpts(nowSec: number): {
|
||||
nowSec: number;
|
||||
deviceId: string;
|
||||
alsoAcceptDeviceIds?: readonly string[];
|
||||
} {
|
||||
return {
|
||||
nowSec,
|
||||
deviceId: this.deviceId,
|
||||
...(this.legacyDeviceId ? { alsoAcceptDeviceIds: [this.legacyDeviceId] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private isSkipLicense(): boolean {
|
||||
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
|
||||
}
|
||||
|
||||
private readSealedToken(): string | null {
|
||||
const p = licenseEncryptedPath(this.userData);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе');
|
||||
}
|
||||
/** Только для окружений без OS keychain (WSL и т.п.); слабее safeStorage — см. licensing-spec. */
|
||||
private isInsecureFileStorageAllowed(): boolean {
|
||||
const v = process.env.DND_LICENSE_INSECURE_FILE_STORAGE?.trim().toLowerCase();
|
||||
return v === '1' || v === 'true' || v === 'yes';
|
||||
}
|
||||
|
||||
private deriveFallbackKey(): Buffer {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update('TTRPGPlayer.license.fallback.v1\0', 'utf8')
|
||||
.update(this.deviceId, 'utf8')
|
||||
.digest();
|
||||
}
|
||||
|
||||
private readFallbackSealedToken(): string {
|
||||
const p = licenseFallbackSealedPath(this.userData);
|
||||
const buf = fs.readFileSync(p);
|
||||
return safeStorage.decryptString(buf);
|
||||
if (buf.length < FALLBACK_MAGIC.length + 12 + 16 + 1) {
|
||||
throw new Error('license.fallback: файл повреждён или слишком короткий');
|
||||
}
|
||||
if (!buf.subarray(0, FALLBACK_MAGIC.length).equals(FALLBACK_MAGIC)) {
|
||||
throw new Error('license.fallback: неверный формат');
|
||||
}
|
||||
const iv = buf.subarray(FALLBACK_MAGIC.length, FALLBACK_MAGIC.length + 12);
|
||||
const tag = buf.subarray(FALLBACK_MAGIC.length + 12, FALLBACK_MAGIC.length + 12 + 16);
|
||||
const data = buf.subarray(FALLBACK_MAGIC.length + 12 + 16);
|
||||
const key = this.deriveFallbackKey();
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
private writeFallbackSealedToken(token: string): void {
|
||||
const key = this.deriveFallbackKey();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const enc = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
const payload = Buffer.concat([FALLBACK_MAGIC, iv, tag, enc]);
|
||||
fs.writeFileSync(licenseFallbackSealedPath(this.userData), payload, { mode: 0o600 });
|
||||
}
|
||||
|
||||
private readSealedToken(): string | null {
|
||||
const sealedPath = licenseEncryptedPath(this.userData);
|
||||
const fallbackPath = licenseFallbackSealedPath(this.userData);
|
||||
|
||||
if (fs.existsSync(sealedPath)) {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error(
|
||||
'safeStorage недоступен: есть license.sealed, но расшифровать нельзя (часто перенос профиля или WSL без keyring). Удалите файл лицензии в настройках приложения или используйте DND_LICENSE_INSECURE_FILE_STORAGE=1 и активируйте заново.',
|
||||
);
|
||||
}
|
||||
const buf = fs.readFileSync(sealedPath);
|
||||
return safeStorage.decryptString(buf);
|
||||
}
|
||||
|
||||
if (fs.existsSync(fallbackPath)) {
|
||||
return this.readFallbackSealedToken();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private writeSealedToken(token: string): void {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе');
|
||||
}
|
||||
fs.mkdirSync(this.userData, { recursive: true });
|
||||
const enc = safeStorage.encryptString(token);
|
||||
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
const enc = safeStorage.encryptString(token);
|
||||
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
|
||||
try {
|
||||
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.isInsecureFileStorageAllowed()) {
|
||||
this.writeFallbackSealedToken(token);
|
||||
try {
|
||||
fs.unlinkSync(licenseEncryptedPath(this.userData));
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'safeStorage недоступен: нельзя сохранить лицензию на этой системе (типично WSL без gnome-keyring). Запустите с переменной DND_LICENSE_INSECURE_FILE_STORAGE=1 — токен будет сохранён в зашифрованном файле (слабее OS-хранилища); либо настройте Secret Service / gnome-keyring.',
|
||||
);
|
||||
}
|
||||
|
||||
private clearSealedTokenFile(): void {
|
||||
@@ -100,22 +189,34 @@ export class LicenseService {
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
|
||||
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
|
||||
private resolveLicenseActivateBaseUrl(): string {
|
||||
const raw = process.env.DND_LICENSE_STATUS_URL?.trim();
|
||||
if (raw) return raw.endsWith('/') ? raw : `${raw}/`;
|
||||
return 'https://license.mailib.ru/';
|
||||
return 'https://license.ttrpgplayer.ru/';
|
||||
}
|
||||
|
||||
private async activateWithProductKey(productKey: string): Promise<string> {
|
||||
const base = this.resolveLicenseActivateBaseUrl();
|
||||
const url = new URL('v1/activate', base);
|
||||
const body: { productKey: string; deviceId: string; retireDeviceId?: string } = {
|
||||
productKey: productKey.trim(),
|
||||
deviceId: this.deviceId,
|
||||
};
|
||||
if (this.legacyDeviceId) {
|
||||
body.retireDeviceId = this.legacyDeviceId;
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ productKey: productKey.trim(), deviceId: this.deviceId }),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const text = await res.text();
|
||||
@@ -133,6 +234,10 @@ export class LicenseService {
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
|
||||
}
|
||||
if (this.legacyDeviceId) {
|
||||
clearLegacyDeviceId(this.userData);
|
||||
this.legacyDeviceId = null;
|
||||
}
|
||||
return normalizeLicenseTokenInput(token);
|
||||
}
|
||||
|
||||
@@ -200,7 +305,7 @@ export class LicenseService {
|
||||
};
|
||||
}
|
||||
|
||||
const v = verifyLicenseToken(token, { nowSec, deviceId: this.deviceId });
|
||||
const v = verifyLicenseToken(token, this.verifyOpts(nowSec));
|
||||
if (!v.ok) {
|
||||
return {
|
||||
active: false,
|
||||
@@ -250,10 +355,7 @@ export class LicenseService {
|
||||
if (!base.active || !base.summary) return base;
|
||||
const token = this.readSealedToken();
|
||||
if (!token?.trim()) return base;
|
||||
const v = verifyLicenseToken(token, {
|
||||
nowSec: Math.floor(Date.now() / 1000),
|
||||
deviceId: this.deviceId,
|
||||
});
|
||||
const v = verifyLicenseToken(token, this.verifyOpts(Math.floor(Date.now() / 1000)));
|
||||
if (!v.ok) return this.getStatusSync();
|
||||
void this.maybeRefreshRemoteRevocation(v.payload);
|
||||
return this.getStatusSync();
|
||||
@@ -264,11 +366,11 @@ export class LicenseService {
|
||||
return this.getStatusSync();
|
||||
}
|
||||
let trimmed = normalizeLicenseTokenInput(token);
|
||||
if (isDndProductKey(trimmed)) {
|
||||
if (isProductKey(trimmed)) {
|
||||
trimmed = await this.activateWithProductKey(trimmed);
|
||||
}
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
|
||||
const v = verifyLicenseToken(trimmed, this.verifyOpts(nowSec));
|
||||
if (!v.ok) {
|
||||
throw new Error(`LICENSE_INVALID:${v.reason}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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 {
|
||||
clearMachineFingerprintMemoryCache,
|
||||
hashMachineRawId,
|
||||
machineWideIdPath,
|
||||
parseMacIOPlatformUUID,
|
||||
parseWindowsMachineGuid,
|
||||
parseWmicUuid,
|
||||
resolveMachineFingerprint,
|
||||
} from './machineFingerprint';
|
||||
import { machineFingerprintCachePath } from './paths';
|
||||
|
||||
void test('hashMachineRawId: стабилен и не зависит от регистра GUID', () => {
|
||||
const a = hashMachineRawId('win32', 'ABCDEF00-1111-2222-3333-444455556666');
|
||||
const b = hashMachineRawId('win32', 'abcdef00-1111-2222-3333-444455556666');
|
||||
assert.equal(a, b);
|
||||
assert.equal(a.length, 64);
|
||||
assert.notEqual(a, hashMachineRawId('linux', 'abcdef00-1111-2222-3333-444455556666'));
|
||||
});
|
||||
|
||||
void test('parseWindowsMachineGuid', () => {
|
||||
const out = `
|
||||
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
|
||||
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
|
||||
`;
|
||||
assert.equal(parseWindowsMachineGuid(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||
assert.equal(parseWindowsMachineGuid('nope'), null);
|
||||
});
|
||||
|
||||
void test('parseMacIOPlatformUUID', () => {
|
||||
const out = `
|
||||
+-o IOPlatformExpertDevice <class IOPlatformExpertDevice, id 0x1000001ea, registered, matched>
|
||||
{
|
||||
"IOPlatformUUID" = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
|
||||
}
|
||||
`;
|
||||
assert.equal(parseMacIOPlatformUUID(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||
});
|
||||
|
||||
void test('parseWmicUuid', () => {
|
||||
assert.equal(parseWmicUuid('UUID\nA1B2C3D4-E5F6-7890-ABCD-EF1234567890\n'), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||
assert.equal(parseWmicUuid('UUID\n00000000-0000-0000-0000-000000000000\n'), null);
|
||||
});
|
||||
|
||||
void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID', () => {
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const id = resolveMachineFingerprint({
|
||||
platform: 'linux',
|
||||
env: { DND_LICENSE_DEVICE_ID: 'override-device-id-12345' },
|
||||
});
|
||||
assert.equal(id, 'override-device-id-12345');
|
||||
});
|
||||
|
||||
void test('resolveMachineFingerprint: Windows MachineGuid → одинаковый hash', () => {
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const exec = () =>
|
||||
`
|
||||
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
|
||||
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
|
||||
`;
|
||||
const a = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
execFileSync: exec as never,
|
||||
});
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const b = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
execFileSync: exec as never,
|
||||
});
|
||||
assert.equal(a, b);
|
||||
assert.equal(a, hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890'));
|
||||
});
|
||||
|
||||
void test('resolveMachineFingerprint: Linux /etc/machine-id', () => {
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const id = resolveMachineFingerprint({
|
||||
platform: 'linux',
|
||||
env: {},
|
||||
readFileSync: (p) => {
|
||||
if (p === '/etc/machine-id') return '0123456789abcdef0123456789abcdef\n';
|
||||
throw new Error('enoent');
|
||||
},
|
||||
});
|
||||
assert.equal(id, hashMachineRawId('linux', '0123456789abcdef0123456789abcdef'));
|
||||
});
|
||||
|
||||
void test('resolveMachineFingerprint: fallback в machine-wide путь', () => {
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-'));
|
||||
const env = { PROGRAMDATA: tmp };
|
||||
const p = machineWideIdPath('win32', env);
|
||||
const id1 = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env,
|
||||
execFileSync: () => {
|
||||
throw new Error('no reg');
|
||||
},
|
||||
});
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const id2 = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env,
|
||||
execFileSync: () => {
|
||||
throw new Error('no reg');
|
||||
},
|
||||
});
|
||||
assert.equal(id1, id2);
|
||||
assert.ok(fs.existsSync(p));
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
void test('resolveMachineFingerprint: disk cache — без повторного exec', () => {
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-cache-'));
|
||||
const expected = hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||
let execCalls = 0;
|
||||
const exec = () => {
|
||||
execCalls += 1;
|
||||
return `
|
||||
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
|
||||
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
|
||||
`;
|
||||
};
|
||||
const a = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
userData: tmp,
|
||||
execFileSync: exec as never,
|
||||
});
|
||||
assert.equal(a, expected);
|
||||
assert.equal(execCalls, 1);
|
||||
assert.ok(fs.existsSync(machineFingerprintCachePath(tmp)));
|
||||
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const b = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
userData: tmp,
|
||||
execFileSync: exec as never,
|
||||
});
|
||||
assert.equal(b, expected);
|
||||
assert.equal(execCalls, 1, 'второй вызов читает disk cache, без reg/wmic');
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { machineFingerprintCachePath } from './paths';
|
||||
|
||||
type ExecFile = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: { encoding: 'utf8'; windowsHide?: boolean; timeout?: number },
|
||||
) => string;
|
||||
|
||||
export type MachineFingerprintDeps = {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Electron userData — для дискового кэша hashed fingerprint. */
|
||||
userData?: string;
|
||||
execFileSync?: ExecFile;
|
||||
readFileSync?: (p: string, encoding: 'utf8') => string;
|
||||
existsSync?: (p: string) => boolean;
|
||||
mkdirSync?: (p: string, opts: { recursive: boolean }) => void;
|
||||
writeFileSync?: (p: string, data: string, opts?: { mode?: number }) => void;
|
||||
};
|
||||
|
||||
/** Process-level кэш: повторные вызовы в том же процессе без I/O. */
|
||||
let memoryFingerprint: string | null = null;
|
||||
|
||||
/** Только для тестов. */
|
||||
export function clearMachineFingerprintMemoryCache(): void {
|
||||
memoryFingerprint = null;
|
||||
}
|
||||
|
||||
function isPlausiblyFingerprint(id: string): boolean {
|
||||
return id.length >= 8 && id.length <= 128 && !/\s/.test(id);
|
||||
}
|
||||
|
||||
const HASH_PREFIX = 'TTRPGPlayer.machine.v1\0';
|
||||
|
||||
/** Стабильный opaque id из сырого машинного идентификатора ОС. */
|
||||
export function hashMachineRawId(platform: string, rawId: string): string {
|
||||
return createHash('sha256')
|
||||
.update(HASH_PREFIX, 'utf8')
|
||||
.update(platform, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(rawId.trim().toLowerCase(), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function parseWindowsMachineGuid(regOutput: string): string | null {
|
||||
const m = /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{8,})/.exec(regOutput);
|
||||
const id = m?.[1]?.trim();
|
||||
return id && id.length >= 8 ? id : null;
|
||||
}
|
||||
|
||||
export function parseMacIOPlatformUUID(ioregOutput: string): string | null {
|
||||
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(ioregOutput);
|
||||
const id = m?.[1]?.trim();
|
||||
return id && id.length >= 8 ? id : null;
|
||||
}
|
||||
|
||||
export function parseWmicUuid(wmicOutput: string): string | null {
|
||||
const lines = wmicOutput
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
for (const line of lines) {
|
||||
if (/^uuid$/i.test(line)) continue;
|
||||
if (/^[0-9a-fA-F-]{8,}$/.test(line) && !/^0+-?0+-?0+-?0+-?0+$/.test(line)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryExec(exec: ExecFile, file: string, args: readonly string[]): string | null {
|
||||
try {
|
||||
return exec(file, args, { encoding: 'utf8', windowsHide: true, timeout: 8_000 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readWindowsRawId(exec: ExecFile): string | null {
|
||||
const regOut = tryExec(exec, 'reg', [
|
||||
'query',
|
||||
'HKLM\\SOFTWARE\\Microsoft\\Cryptography',
|
||||
'/v',
|
||||
'MachineGuid',
|
||||
]);
|
||||
if (regOut) {
|
||||
const guid = parseWindowsMachineGuid(regOut);
|
||||
if (guid) return guid;
|
||||
}
|
||||
const wmicOut = tryExec(exec, 'wmic', ['csproduct', 'get', 'uuid']);
|
||||
if (wmicOut) {
|
||||
const uuid = parseWmicUuid(wmicOut);
|
||||
if (uuid) return uuid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDarwinRawId(exec: ExecFile): string | null {
|
||||
const out = tryExec(exec, 'ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
|
||||
if (!out) return null;
|
||||
return parseMacIOPlatformUUID(out);
|
||||
}
|
||||
|
||||
function readLinuxRawId(readFile: (p: string, encoding: 'utf8') => string): string | null {
|
||||
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
||||
try {
|
||||
const id = readFile(p, 'utf8').trim();
|
||||
if (id.length >= 8 && !/^0+$/.test(id)) return id;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
try {
|
||||
const id = readFile('/sys/class/dmi/id/product_uuid', 'utf8').trim();
|
||||
if (id.length >= 8 && !/^0+-?0+-?0+-?0+-?0+$/i.test(id)) return id;
|
||||
} catch {
|
||||
/* optional, often root-only */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Каталог на уровне машины (не профиль пользователя), для редкого fallback UUID. */
|
||||
export function machineWideIdPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {
|
||||
if (platform === 'win32') {
|
||||
const base = env.PROGRAMDATA?.trim() || path.join(env.SystemDrive || 'C:', 'ProgramData');
|
||||
return path.join(base, 'TTRPGPlayer', 'machine.id');
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
return path.join('/Library/Application Support', 'TTRPGPlayer', 'machine.id');
|
||||
}
|
||||
return path.join('/var/lib', 'ttrpg-player', 'machine.id');
|
||||
}
|
||||
|
||||
function readOrCreateMachineWideFallback(
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv,
|
||||
deps: Required<
|
||||
Pick<MachineFingerprintDeps, 'existsSync' | 'readFileSync' | 'mkdirSync' | 'writeFileSync'>
|
||||
>,
|
||||
): string | null {
|
||||
const p = machineWideIdPath(platform, env);
|
||||
try {
|
||||
if (deps.existsSync(p)) {
|
||||
const existing = deps.readFileSync(p, 'utf8').trim();
|
||||
if (existing.length >= 8) return existing;
|
||||
}
|
||||
} catch {
|
||||
/* create below */
|
||||
}
|
||||
const id = createHash('sha256')
|
||||
.update(`TTRPGPlayer.machine.fallback\0${platform}\0${os.hostname()}\0${Date.now()}\0${Math.random()}`, 'utf8')
|
||||
.digest('hex');
|
||||
try {
|
||||
deps.mkdirSync(path.dirname(p), { recursive: true });
|
||||
deps.writeFileSync(p, `${id}\n`, { mode: 0o644 });
|
||||
return id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readDiskFingerprintCache(
|
||||
cacheFile: string,
|
||||
readFile: (p: string, encoding: 'utf8') => string,
|
||||
existsSync: (p: string) => boolean,
|
||||
): string | null {
|
||||
try {
|
||||
if (!existsSync(cacheFile)) return null;
|
||||
const cached = readFile(cacheFile, 'utf8').trim();
|
||||
return isPlausiblyFingerprint(cached) ? cached : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDiskFingerprintCache(
|
||||
cacheFile: string,
|
||||
fingerprint: string,
|
||||
mkdirSync: (p: string, opts: { recursive: boolean }) => void,
|
||||
writeFileSync: (p: string, data: string, opts?: { mode?: number }) => void,
|
||||
): void {
|
||||
try {
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
writeFileSync(cacheFile, `${fingerprint}\n`, { mode: 0o644 });
|
||||
} catch {
|
||||
/* кэш необязателен */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК).
|
||||
* Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id.
|
||||
* Кэш: память процесса → userData/machine.fingerprint → sync probe ОС только при miss.
|
||||
*/
|
||||
export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): string {
|
||||
const platform = deps.platform ?? process.platform;
|
||||
const env = deps.env ?? process.env;
|
||||
const exec = deps.execFileSync ?? (execFileSync as ExecFile);
|
||||
const readFile = deps.readFileSync ?? ((p, enc) => fs.readFileSync(p, enc));
|
||||
const existsSync = deps.existsSync ?? ((p) => fs.existsSync(p));
|
||||
const mkdirSync = deps.mkdirSync ?? ((p, opts) => {
|
||||
fs.mkdirSync(p, opts);
|
||||
});
|
||||
const writeFileSync = deps.writeFileSync ?? ((p, data, opts) => {
|
||||
fs.writeFileSync(p, data, opts);
|
||||
});
|
||||
|
||||
const override = env.DND_LICENSE_DEVICE_ID?.trim();
|
||||
if (override && override.length >= 8) {
|
||||
memoryFingerprint = override;
|
||||
return override;
|
||||
}
|
||||
|
||||
if (memoryFingerprint && isPlausiblyFingerprint(memoryFingerprint)) {
|
||||
return memoryFingerprint;
|
||||
}
|
||||
|
||||
const userData = deps.userData?.trim();
|
||||
const cacheFile = userData ? machineFingerprintCachePath(userData) : null;
|
||||
if (cacheFile) {
|
||||
const fromDisk = readDiskFingerprintCache(cacheFile, readFile, existsSync);
|
||||
if (fromDisk) {
|
||||
memoryFingerprint = fromDisk;
|
||||
return fromDisk;
|
||||
}
|
||||
}
|
||||
|
||||
let raw: string | null = null;
|
||||
if (platform === 'win32') raw = readWindowsRawId(exec);
|
||||
else if (platform === 'darwin') raw = readDarwinRawId(exec);
|
||||
else if (platform === 'linux') raw = readLinuxRawId(readFile);
|
||||
|
||||
if (!raw) {
|
||||
raw = readOrCreateMachineWideFallback(platform, env, {
|
||||
existsSync,
|
||||
readFileSync: readFile,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
});
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(
|
||||
'LICENSE_MACHINE_ID_UNAVAILABLE: не удалось получить идентификатор физической машины',
|
||||
);
|
||||
}
|
||||
|
||||
const fingerprint = hashMachineRawId(platform, raw);
|
||||
memoryFingerprint = fingerprint;
|
||||
if (cacheFile) {
|
||||
writeDiskFingerprintCache(cacheFile, fingerprint, mkdirSync, writeFileSync);
|
||||
}
|
||||
return fingerprint;
|
||||
}
|
||||
@@ -4,10 +4,21 @@ export function licenseEncryptedPath(userData: string): string {
|
||||
return path.join(userData, 'license.sealed');
|
||||
}
|
||||
|
||||
/** Fallback, если нет OS keychain (WSL без gnome-keyring и т.п.); только при DND_LICENSE_INSECURE_FILE_STORAGE=1. */
|
||||
export function licenseFallbackSealedPath(userData: string): string {
|
||||
return path.join(userData, 'license.sealed.fallback');
|
||||
}
|
||||
|
||||
/** Устаревший per-user UUID; актуальный deviceId — fingerprint машины (см. machineFingerprint.ts). */
|
||||
export function deviceIdPath(userData: string): string {
|
||||
return path.join(userData, 'device.id');
|
||||
}
|
||||
|
||||
/** Кэш hashed machine fingerprint — без повторного reg/wmic/ioreg на каждом старте. */
|
||||
export function machineFingerprintCachePath(userData: string): string {
|
||||
return path.join(userData, 'machine.fingerprint');
|
||||
}
|
||||
|
||||
export function preferencesPath(userData: string): string {
|
||||
return path.join(userData, 'preferences.json');
|
||||
}
|
||||
|
||||
@@ -55,6 +55,30 @@ void test('verifyLicenseToken: неверное устройство', () => {
|
||||
assert.equal(bad.reason, 'wrong_device');
|
||||
});
|
||||
|
||||
void test('verifyLicenseToken: принимает legacy deviceId при миграции', () => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
const payload = {
|
||||
v: 1 as const,
|
||||
sub: 'lic_legacy',
|
||||
pid: 'dnd_player',
|
||||
iat: 100,
|
||||
exp: 2_000_000_000,
|
||||
did: 'legacy-uuid-from-userdata',
|
||||
};
|
||||
const body = canonicalJson(payload);
|
||||
const sig = sign(null, Buffer.from(body, 'utf8'), privateKey);
|
||||
const token = joinSignedLicenseToken(body, new Uint8Array(sig.buffer, sig.byteOffset, sig.byteLength));
|
||||
|
||||
const ok = verifyLicenseToken(token, {
|
||||
nowSec: 1_700_000_000,
|
||||
deviceId: 'machine-fingerprint-hash',
|
||||
alsoAcceptDeviceIds: ['legacy-uuid-from-userdata'],
|
||||
publicKeyOverrideSpkiDerB64: pubB64,
|
||||
});
|
||||
if (!ok.ok) assert.fail(`expected ok, got ${ok.reason}`);
|
||||
});
|
||||
|
||||
void test('verifyLicenseToken: токен с переносами строк после копирования', () => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
|
||||
@@ -23,7 +23,13 @@ function getBundledPublicKey() {
|
||||
|
||||
export function verifyLicenseToken(
|
||||
token: string,
|
||||
opts: { nowSec: number; deviceId: string; publicKeyOverrideSpkiDerB64?: string },
|
||||
opts: {
|
||||
nowSec: number;
|
||||
deviceId: string;
|
||||
/** Старые deviceId (например UUID из userData) — принимаются до повторной активации. */
|
||||
alsoAcceptDeviceIds?: readonly string[];
|
||||
publicKeyOverrideSpkiDerB64?: string;
|
||||
},
|
||||
): LicenseVerifyResult {
|
||||
const parts = splitSignedLicenseToken(token);
|
||||
if (!parts) return { ok: false, reason: 'malformed' };
|
||||
@@ -53,8 +59,11 @@ export function verifyLicenseToken(
|
||||
return { ok: false, reason: 'not_yet_valid' };
|
||||
}
|
||||
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
|
||||
if (payload.did !== null && payload.did !== opts.deviceId) {
|
||||
return { ok: false, reason: 'wrong_device' };
|
||||
if (payload.did !== null) {
|
||||
const accepted = new Set<string>([opts.deviceId, ...(opts.alsoAcceptDeviceIds ?? [])]);
|
||||
if (!accepted.has(payload.did)) {
|
||||
return { ok: false, reason: 'wrong_device' };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, payload };
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
clampMaterialsLayout,
|
||||
DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
defaultLegendLayoutForMaterial,
|
||||
type MaterialId,
|
||||
type MaterialsOverlayEvent,
|
||||
type MaterialsOverlayLayout,
|
||||
type MaterialsOverlayState,
|
||||
type MaterialsZoomTool,
|
||||
zoomMaterialsLayoutAt,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): MaterialsOverlayState {
|
||||
return {
|
||||
revision: 1,
|
||||
activeMaterialIds: [],
|
||||
layouts: {},
|
||||
legendLayouts: {},
|
||||
focusMaterialId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
}
|
||||
|
||||
function initialLayout(rotationDeg?: number): MaterialsOverlayLayout {
|
||||
return clampMaterialsLayout({
|
||||
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
rotationDeg: rotationDeg ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
function layoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
|
||||
return state.layouts[materialId] ?? { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT };
|
||||
}
|
||||
|
||||
function legendLayoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
|
||||
return state.legendLayouts[materialId] ?? defaultLegendLayoutForMaterial();
|
||||
}
|
||||
|
||||
export class MaterialsOverlayStore {
|
||||
private state: MaterialsOverlayState = emptyState();
|
||||
|
||||
getState(): MaterialsOverlayState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
clear(): MaterialsOverlayState {
|
||||
if (this.state.activeMaterialIds.length === 0 && this.state.zoomTool === null) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds: [],
|
||||
layouts: {},
|
||||
legendLayouts: {},
|
||||
focusMaterialId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: MaterialsOverlayEvent): MaterialsOverlayState {
|
||||
switch (event.kind) {
|
||||
case 'hide':
|
||||
return this.clear();
|
||||
case 'show': {
|
||||
if (this.state.activeMaterialIds.includes(event.materialId)) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusMaterialId: event.materialId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: initialLayout(event.rotationDeg),
|
||||
},
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: defaultLegendLayoutForMaterial(),
|
||||
},
|
||||
focusMaterialId: event.materialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'toggle': {
|
||||
if (this.state.activeMaterialIds.includes(event.materialId)) {
|
||||
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => id !== event.materialId);
|
||||
const layouts = { ...this.state.layouts };
|
||||
const legendLayouts = { ...this.state.legendLayouts };
|
||||
delete layouts[event.materialId];
|
||||
delete legendLayouts[event.materialId];
|
||||
const focusMaterialId =
|
||||
this.state.focusMaterialId === event.materialId
|
||||
? (activeMaterialIds[activeMaterialIds.length - 1] ?? null)
|
||||
: this.state.focusMaterialId;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds,
|
||||
layouts,
|
||||
legendLayouts,
|
||||
focusMaterialId,
|
||||
zoomTool: activeMaterialIds.length === 0 ? null : this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: initialLayout(event.rotationDeg),
|
||||
},
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: defaultLegendLayoutForMaterial(),
|
||||
},
|
||||
focusMaterialId: event.materialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'layout.set': {
|
||||
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusMaterialId: event.materialId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: clampMaterialsLayout({
|
||||
...layoutFor(this.state, event.materialId),
|
||||
...event.layout,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'legendLayout.set': {
|
||||
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusMaterialId: event.materialId,
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: clampMaterialsLayout({
|
||||
...legendLayoutFor(this.state, event.materialId),
|
||||
...event.layout,
|
||||
rotationDeg: 0,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomTool.set': {
|
||||
const tool: MaterialsZoomTool = event.tool;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
zoomTool: tool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomAt': {
|
||||
if (this.state.activeMaterialIds.length === 0 || !this.state.zoomTool) return this.state;
|
||||
const targetId =
|
||||
(event.materialId && this.state.activeMaterialIds.includes(event.materialId)
|
||||
? event.materialId
|
||||
: null) ??
|
||||
this.state.focusMaterialId ??
|
||||
this.state.activeMaterialIds[this.state.activeMaterialIds.length - 1] ??
|
||||
null;
|
||||
if (!targetId) return this.state;
|
||||
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
|
||||
const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt(
|
||||
layoutFor(this.state, targetId),
|
||||
event.nx,
|
||||
event.ny,
|
||||
factor,
|
||||
);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusMaterialId: targetId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[targetId]: layout,
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default:
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
ensureMaterialStillExists(materialIds: ReadonlySet<MaterialId>): MaterialsOverlayState {
|
||||
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => materialIds.has(id));
|
||||
if (activeMaterialIds.length === this.state.activeMaterialIds.length) return this.state;
|
||||
if (activeMaterialIds.length === 0) return this.clear();
|
||||
const layouts: Record<string, MaterialsOverlayLayout> = {};
|
||||
const legendLayouts: Record<string, MaterialsOverlayLayout> = {};
|
||||
for (const id of activeMaterialIds) {
|
||||
const layout = this.state.layouts[id];
|
||||
if (layout) layouts[id] = layout;
|
||||
const legend = this.state.legendLayouts[id];
|
||||
if (legend) legendLayouts[id] = legend;
|
||||
}
|
||||
const focusMaterialId =
|
||||
this.state.focusMaterialId && activeMaterialIds.includes(this.state.focusMaterialId)
|
||||
? this.state.focusMaterialId
|
||||
: (activeMaterialIds[activeMaterialIds.length - 1] ?? null);
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds,
|
||||
layouts,
|
||||
legendLayouts,
|
||||
focusMaterialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
clampNpcsLayout,
|
||||
DEFAULT_NPCS_OVERLAY_LAYOUT,
|
||||
type NpcId,
|
||||
type NpcsOverlayEvent,
|
||||
type NpcsOverlayLayout,
|
||||
type NpcsOverlayState,
|
||||
type NpcsZoomTool,
|
||||
zoomNpcsLayoutAt,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): NpcsOverlayState {
|
||||
return {
|
||||
revision: 1,
|
||||
activeNpcIds: [],
|
||||
layouts: {},
|
||||
focusNpcId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
}
|
||||
|
||||
function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
|
||||
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
|
||||
}
|
||||
|
||||
export class NpcsOverlayStore {
|
||||
private state: NpcsOverlayState = emptyState();
|
||||
|
||||
getState(): NpcsOverlayState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
clear(): NpcsOverlayState {
|
||||
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds: [],
|
||||
layouts: {},
|
||||
focusNpcId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
|
||||
switch (event.kind) {
|
||||
case 'hide':
|
||||
return this.clear();
|
||||
case 'show': {
|
||||
if (this.state.activeNpcIds.includes(event.npcId)) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusNpcId: event.npcId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
},
|
||||
focusNpcId: event.npcId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'toggle': {
|
||||
if (this.state.activeNpcIds.includes(event.npcId)) {
|
||||
const activeNpcIds = this.state.activeNpcIds.filter((id) => id !== event.npcId);
|
||||
const layouts = { ...this.state.layouts };
|
||||
delete layouts[event.npcId];
|
||||
const focusNpcId =
|
||||
this.state.focusNpcId === event.npcId
|
||||
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
|
||||
: this.state.focusNpcId;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds,
|
||||
layouts,
|
||||
focusNpcId,
|
||||
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
},
|
||||
focusNpcId: event.npcId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'layout.set': {
|
||||
if (!this.state.activeNpcIds.includes(event.npcId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusNpcId: event.npcId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.npcId]: clampNpcsLayout({
|
||||
...layoutFor(this.state, event.npcId),
|
||||
...event.layout,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomTool.set': {
|
||||
const tool: NpcsZoomTool = event.tool;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
zoomTool: tool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomAt': {
|
||||
if (this.state.activeNpcIds.length === 0 || !this.state.zoomTool) return this.state;
|
||||
const targetId =
|
||||
(event.npcId && this.state.activeNpcIds.includes(event.npcId) ? event.npcId : null) ??
|
||||
this.state.focusNpcId ??
|
||||
this.state.activeNpcIds[this.state.activeNpcIds.length - 1] ??
|
||||
null;
|
||||
if (!targetId) return this.state;
|
||||
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
|
||||
const layout: NpcsOverlayLayout = zoomNpcsLayoutAt(
|
||||
layoutFor(this.state, targetId),
|
||||
event.nx,
|
||||
event.ny,
|
||||
factor,
|
||||
);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusNpcId: targetId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[targetId]: layout,
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default:
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
|
||||
const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
|
||||
if (activeNpcIds.length === this.state.activeNpcIds.length) return this.state;
|
||||
if (activeNpcIds.length === 0) return this.clear();
|
||||
const layouts: Record<string, NpcsOverlayLayout> = {};
|
||||
for (const id of activeNpcIds) {
|
||||
const layout = this.state.layouts[id];
|
||||
if (layout) layouts[id] = layout;
|
||||
}
|
||||
const focusNpcId =
|
||||
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
|
||||
? this.state.focusNpcId
|
||||
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds,
|
||||
layouts,
|
||||
focusNpcId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PlayersStore } from './playersStore';
|
||||
import { asPlayerId } from '../../shared/types/ids';
|
||||
|
||||
async function withTempStore(run: (store: PlayersStore, root: string) => Promise<void>) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-players-'));
|
||||
const store = new PlayersStore(root);
|
||||
try {
|
||||
await run(store, root);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
void test('PlayersStore upsert list delete and teams', async () => {
|
||||
await withTempStore(async (store, root) => {
|
||||
const png = path.join(root, 'sample.png');
|
||||
// minimal 1x1 png
|
||||
const buf = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
await fs.writeFile(png, buf);
|
||||
|
||||
const progress: number[] = [];
|
||||
const player = await store.upsert(
|
||||
{ name: 'Ada', filePath: png, ringColor: '#112233', imageOffset: { x: 0.1, y: -0.1 } },
|
||||
(p) => progress.push(p.percent),
|
||||
);
|
||||
assert.equal(player.name, 'Ada');
|
||||
assert.equal(player.ringColor, '#112233');
|
||||
assert.ok(progress.length > 0);
|
||||
assert.equal(store.listPlayers().length, 1);
|
||||
|
||||
const team = await store.upsertTeam({ name: 'Party', color: '#abcdef' });
|
||||
assert.equal(team.name, 'Party');
|
||||
const assigned = await store.assignPlayerTeam(player.id, team.id);
|
||||
assert.equal(assigned?.teamId, team.id);
|
||||
|
||||
await store.deleteTeam(team.id);
|
||||
assert.equal(store.listTeams().length, 0);
|
||||
assert.equal(store.getById(player.id)?.teamId, null);
|
||||
|
||||
await store.delete(player.id);
|
||||
assert.equal(store.listPlayers().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
void test('PlayersStore persists across reload', async () => {
|
||||
await withTempStore(async (store, root) => {
|
||||
const png = path.join(root, 'sample.png');
|
||||
await fs.writeFile(
|
||||
png,
|
||||
Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
),
|
||||
);
|
||||
const created = await store.upsert({ name: 'Bob', filePath: png });
|
||||
const store2 = new PlayersStore(root);
|
||||
await store2.ensureLoaded();
|
||||
assert.equal(store2.listPlayers().length, 1);
|
||||
assert.equal(store2.getById(asPlayerId(created.id))?.name, 'Bob');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
assignPlayerToTeam,
|
||||
createPlayerTeamDraft,
|
||||
deletePlayerTeam,
|
||||
normalizeAppPlayerTeams,
|
||||
uniquePlayerTeamName,
|
||||
} from '../../shared/players/playerTeams';
|
||||
import type {
|
||||
AppPlayer,
|
||||
AppPlayerTeam,
|
||||
PlayerId,
|
||||
PlayerImageOffset,
|
||||
PlayersUpsertProgressEvent,
|
||||
PlayerTeamId,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import {
|
||||
clampPlayerImageOffset,
|
||||
clampPlayerImageScale,
|
||||
DEFAULT_PLAYER_IMAGE_OFFSET,
|
||||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
normalizeAppPlayer,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { asPlayerId } from '../../shared/types/ids';
|
||||
import { normalizeHexColor } from '../../shared/npcs/npcGroups';
|
||||
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
|
||||
|
||||
type PlayersManifest = {
|
||||
players: AppPlayer[];
|
||||
teams: AppPlayerTeam[];
|
||||
};
|
||||
|
||||
function mimeFromExt(ext: string): string {
|
||||
const e = ext.toLowerCase();
|
||||
if (e === '.png') return 'image/png';
|
||||
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
|
||||
if (e === '.webp') return 'image/webp';
|
||||
if (e === '.gif') return 'image/gif';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function safeFileBase(name: string): string {
|
||||
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
|
||||
return base || 'player';
|
||||
}
|
||||
|
||||
function randomPlayerId(): PlayerId {
|
||||
return asPlayerId(`player_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||
}
|
||||
|
||||
export class PlayersStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly filesDir: string;
|
||||
private readonly manifestPath: string;
|
||||
private players: AppPlayer[] = [];
|
||||
private teams: AppPlayerTeam[] = [];
|
||||
private loaded = false;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.rootDir = path.join(userData, 'players');
|
||||
this.filesDir = path.join(this.rootDir, 'files');
|
||||
this.manifestPath = path.join(this.rootDir, 'players.json');
|
||||
}
|
||||
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
await fs.mkdir(this.filesDir, { recursive: true });
|
||||
try {
|
||||
const raw = await fs.readFile(this.manifestPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as PlayersManifest;
|
||||
this.teams = normalizeAppPlayerTeams(parsed.teams);
|
||||
const teamIds = new Set(this.teams.map((t) => t.id));
|
||||
this.players = (Array.isArray(parsed.players) ? parsed.players : [])
|
||||
.map((p) => normalizeAppPlayer(p, teamIds))
|
||||
.filter((p): p is AppPlayer => Boolean(p));
|
||||
} catch {
|
||||
this.players = [];
|
||||
this.teams = [];
|
||||
await this.persist();
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
listPlayers(): AppPlayer[] {
|
||||
return [...this.players];
|
||||
}
|
||||
|
||||
listTeams(): AppPlayerTeam[] {
|
||||
return [...this.teams];
|
||||
}
|
||||
|
||||
getById(id: PlayerId): AppPlayer | null {
|
||||
return this.players.find((p) => p.id === id) ?? null;
|
||||
}
|
||||
|
||||
getImageReadInfo(id: PlayerId): { absPath: string; mime: string } | null {
|
||||
const player = this.getById(id);
|
||||
if (!player) return null;
|
||||
const absPath = path.join(this.rootDir, player.imageRelPath);
|
||||
return { absPath, mime: mimeFromExt(path.extname(player.imageRelPath)) };
|
||||
}
|
||||
|
||||
getImageUrl(id: PlayerId): string | null {
|
||||
if (!this.getImageReadInfo(id)) return null;
|
||||
return `dnd://player?id=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
input: {
|
||||
id?: PlayerId | null;
|
||||
name: string;
|
||||
filePath?: string | null;
|
||||
teamId?: PlayerTeamId | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: PlayerImageOffset;
|
||||
imageScale?: number;
|
||||
},
|
||||
onProgress?: (p: PlayersUpsertProgressEvent) => void,
|
||||
): Promise<AppPlayer> {
|
||||
await this.ensureLoaded();
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error('Player name is required');
|
||||
|
||||
const existing = input.id ? this.getById(input.id) : null;
|
||||
if (input.id && !existing) throw new Error('Player not found');
|
||||
if (!existing && !input.filePath) throw new Error('Player image is required');
|
||||
|
||||
const emit = (percent: number, stage: string, detail?: string) => {
|
||||
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
|
||||
};
|
||||
|
||||
emit(5, 'prepare', 'Подготовка…');
|
||||
|
||||
let imageRelPath = existing?.imageRelPath ?? '';
|
||||
let sha256 = existing?.sha256 ?? '';
|
||||
const id = existing?.id ?? randomPlayerId();
|
||||
|
||||
if (input.filePath) {
|
||||
emit(15, 'read', 'Чтение изображения…');
|
||||
let buf = await fs.readFile(input.filePath);
|
||||
emit(40, 'optimize', 'Оптимизация…');
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
/* keep original */
|
||||
}
|
||||
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const ext = path.extname(input.filePath) || '.png';
|
||||
const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`;
|
||||
imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
|
||||
const abs = path.join(this.rootDir, imageRelPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
emit(75, 'write', 'Сохранение…');
|
||||
await fs.writeFile(abs, buf);
|
||||
if (existing && existing.imageRelPath !== imageRelPath) {
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const teamIds = new Set(this.teams.map((t) => t.id));
|
||||
let teamId: PlayerTeamId | null =
|
||||
input.teamId !== undefined ? input.teamId : (existing?.teamId ?? null);
|
||||
if (teamId && !teamIds.has(teamId)) teamId = null;
|
||||
|
||||
const player: AppPlayer = {
|
||||
id,
|
||||
name,
|
||||
imageRelPath,
|
||||
sha256,
|
||||
teamId,
|
||||
ringColor: normalizeHexColor(
|
||||
input.ringColor ?? existing?.ringColor,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
),
|
||||
imageOffset: clampPlayerImageOffset(
|
||||
input.imageOffset ?? existing?.imageOffset ?? DEFAULT_PLAYER_IMAGE_OFFSET,
|
||||
),
|
||||
imageScale: clampPlayerImageScale(
|
||||
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
this.players = this.players.map((p) => (p.id === player.id ? player : p));
|
||||
} else {
|
||||
this.players = [...this.players, player];
|
||||
}
|
||||
emit(95, 'persist', 'Запись…');
|
||||
await this.persist();
|
||||
emit(100, 'done', 'Готово');
|
||||
return player;
|
||||
}
|
||||
|
||||
async delete(id: PlayerId): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return;
|
||||
this.players = this.players.filter((p) => p.id !== id);
|
||||
await this.persist();
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async setPlayersOrder(playerIds: PlayerId[]): Promise<AppPlayer[]> {
|
||||
await this.ensureLoaded();
|
||||
const byId = new Map(this.players.map((p) => [p.id, p]));
|
||||
const next: AppPlayer[] = [];
|
||||
for (const id of playerIds) {
|
||||
const p = byId.get(id);
|
||||
if (p) {
|
||||
next.push(p);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
for (const p of byId.values()) next.push(p);
|
||||
this.players = next;
|
||||
await this.persist();
|
||||
return this.listPlayers();
|
||||
}
|
||||
|
||||
async upsertTeam(input: {
|
||||
id?: PlayerTeamId | null;
|
||||
name: string;
|
||||
color?: string;
|
||||
}): Promise<AppPlayerTeam> {
|
||||
await this.ensureLoaded();
|
||||
const existing = input.id ? this.teams.find((t) => t.id === input.id) : null;
|
||||
if (input.id && !existing) throw new Error('Team not found');
|
||||
if (existing) {
|
||||
const team: AppPlayerTeam = {
|
||||
id: existing.id,
|
||||
name: uniquePlayerTeamName(input.name, this.teams, existing.id),
|
||||
color: normalizeHexColor(input.color ?? existing.color, DEFAULT_PLAYER_RING_COLOR),
|
||||
};
|
||||
this.teams = this.teams.map((t) => (t.id === team.id ? team : t));
|
||||
await this.persist();
|
||||
return team;
|
||||
}
|
||||
const team = createPlayerTeamDraft(input.name, input.color, this.teams);
|
||||
this.teams = [...this.teams, team];
|
||||
await this.persist();
|
||||
return team;
|
||||
}
|
||||
|
||||
async deleteTeam(id: PlayerTeamId): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const next = deletePlayerTeam(this.teams, this.players, id);
|
||||
this.teams = next.teams;
|
||||
this.players = next.players;
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async setTeamsOrder(teamIds: PlayerTeamId[]): Promise<AppPlayerTeam[]> {
|
||||
await this.ensureLoaded();
|
||||
const byId = new Map(this.teams.map((t) => [t.id, t]));
|
||||
const next: AppPlayerTeam[] = [];
|
||||
for (const id of teamIds) {
|
||||
const t = byId.get(id);
|
||||
if (t) {
|
||||
next.push(t);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
for (const t of byId.values()) next.push(t);
|
||||
this.teams = next;
|
||||
await this.persist();
|
||||
return this.listTeams();
|
||||
}
|
||||
|
||||
async assignPlayerTeam(playerId: PlayerId, teamId: PlayerTeamId | null): Promise<AppPlayer | null> {
|
||||
await this.ensureLoaded();
|
||||
const teamIds = new Set(this.teams.map((t) => t.id as string));
|
||||
this.players = assignPlayerToTeam(this.players, playerId, teamId, teamIds);
|
||||
await this.persist();
|
||||
return this.getById(playerId);
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
const payload: PlayersManifest = { players: this.players, teams: this.teams };
|
||||
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
|
||||
|
||||
import { SceneNpcTokensSessionStore } from './sceneNpcTokensSessionStore';
|
||||
|
||||
void test('SceneNpcTokensSessionStore move and reset', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
|
||||
assert.equal(s1.byPlacementId.a?.nx, 0.2);
|
||||
assert.equal(s1.revision, 2);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
|
||||
assert.equal(s2.revision, s1.revision); // no-op
|
||||
const s3 = store.reset();
|
||||
assert.deepEqual(s3.byPlacementId, {});
|
||||
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
|
||||
assert.ok(s3.revision > s1.revision);
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setScale', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setScale', scale: 1.5 });
|
||||
assert.equal(s1.scale, 1.5);
|
||||
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.1, ny: 0.2 });
|
||||
const s2 = store.dispatch({ kind: 'setScale', scale: 0.1 });
|
||||
assert.equal(s2.scale, 0.4); // clamped min
|
||||
assert.ok(s2.byPlacementId.a);
|
||||
const s3 = store.reset();
|
||||
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
|
||||
assert.deepEqual(s3.byPlacementId, {});
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setDisposition without inventing position', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({
|
||||
kind: 'setDisposition',
|
||||
placementId: 'a',
|
||||
disposition: 'hostile',
|
||||
});
|
||||
assert.equal(s1.byPlacementId.a?.disposition, 'hostile');
|
||||
assert.equal(s1.byPlacementId.a?.nx, undefined);
|
||||
assert.equal(s1.byPlacementId.a?.ny, undefined);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.4, ny: 0.6 });
|
||||
assert.equal(s2.byPlacementId.a?.nx, 0.4);
|
||||
assert.equal(s2.byPlacementId.a?.disposition, 'hostile');
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
|
||||
assert.equal(s1.byPlacementId.a?.inactive, true);
|
||||
const s2 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
|
||||
assert.deepEqual(s2.byPlacementId, {});
|
||||
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
|
||||
store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
|
||||
const s3 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
|
||||
assert.equal(s3.byPlacementId.a?.nx, 0.2);
|
||||
assert.equal(s3.byPlacementId.a?.inactive, undefined);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
clampNpcTokenSessionScale,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
type SceneNpcTokensSessionEvent,
|
||||
type SceneNpcTokensSessionPlacement,
|
||||
type SceneNpcTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
||||
|
||||
function emptyState(revision = 1): SceneNpcTokensSessionState {
|
||||
return {
|
||||
revision,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
|
||||
}
|
||||
|
||||
function withMove(
|
||||
prev: SceneNpcTokensSessionPlacement | undefined,
|
||||
nx: number,
|
||||
ny: number,
|
||||
): SceneNpcTokensSessionPlacement {
|
||||
return {
|
||||
nx,
|
||||
ny,
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withoutPlacement(
|
||||
state: SceneNpcTokensSessionState,
|
||||
placementId: string,
|
||||
): SceneNpcTokensSessionState {
|
||||
if (!(placementId in state.byPlacementId)) return state;
|
||||
const { [placementId]: _removed, ...rest } = state.byPlacementId;
|
||||
return {
|
||||
revision: state.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: state.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneNpcTokensSessionStore {
|
||||
private state: SceneNpcTokensSessionState = emptyState();
|
||||
|
||||
getState(): SceneNpcTokensSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): SceneNpcTokensSessionState {
|
||||
if (
|
||||
Object.keys(this.state.byPlacementId).length === 0 &&
|
||||
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = emptyState(this.state.revision + 1);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'setScale': {
|
||||
const scale = clampNpcTokenSessionScale(event.scale);
|
||||
if (this.state.scale === scale) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'move': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: withMove(prev, nx, ny),
|
||||
},
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setDisposition': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const disposition = normalizeNpcDisposition(event.disposition);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (prev?.disposition === disposition) return this.state;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
disposition,
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
};
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setInactive': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const inactive = Boolean(event.inactive);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (Boolean(prev?.inactive) === inactive) return this.state;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
};
|
||||
if (isEmptyPlacement(next)) {
|
||||
this.state = withoutPlacement(this.state, placementId);
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ScenePlayerTokensSessionStore } from './scenePlayerTokensSessionStore';
|
||||
|
||||
void test('ScenePlayerTokensSessionStore selection and show/hide', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['a', 'b', 'a'] });
|
||||
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
|
||||
assert.equal(store.getState().visible, false);
|
||||
|
||||
store.dispatch({ kind: 'show', sizeN: 0.08 });
|
||||
assert.equal(store.getState().visible, true);
|
||||
assert.equal(Object.keys(store.getState().byPlayerId).length, 2);
|
||||
assert.ok(store.getState().byPlayerId.a);
|
||||
assert.equal(store.getState().byPlayerId.a?.ny, 0.88);
|
||||
|
||||
store.dispatch({ kind: 'setVisible', visible: false });
|
||||
assert.equal(store.getState().visible, false);
|
||||
assert.ok(store.getState().byPlayerId.a);
|
||||
|
||||
store.dispatch({ kind: 'clearPlacements' });
|
||||
assert.equal(store.getState().visible, false);
|
||||
assert.deepEqual(store.getState().byPlayerId, {});
|
||||
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
|
||||
});
|
||||
|
||||
void test('ScenePlayerTokensSessionStore move and reset', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
|
||||
store.dispatch({ kind: 'show', sizeN: 0.1 });
|
||||
const s1 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.3, ny: 0.4 });
|
||||
assert.equal(s1.byPlayerId.p1?.nx, 0.3);
|
||||
assert.equal(s1.byPlayerId.p1?.ny, 0.4);
|
||||
const s2 = store.reset();
|
||||
assert.deepEqual(s2.selectedPlayerIds, []);
|
||||
assert.equal(s2.visible, false);
|
||||
assert.deepEqual(s2.byPlayerId, {});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
layoutPlayerTokensBottom,
|
||||
type ScenePlayerTokensSessionEvent,
|
||||
type ScenePlayerTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
|
||||
function emptyState(revision = 1): ScenePlayerTokensSessionState {
|
||||
return {
|
||||
revision,
|
||||
selectedPlayerIds: [],
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerIds(raw: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of raw) {
|
||||
const s = String(id ?? '').trim();
|
||||
if (!s || seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class ScenePlayerTokensSessionStore {
|
||||
private state: ScenePlayerTokensSessionState = emptyState();
|
||||
|
||||
getState(): ScenePlayerTokensSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): ScenePlayerTokensSessionState {
|
||||
if (
|
||||
this.state.selectedPlayerIds.length === 0 &&
|
||||
!this.state.visible &&
|
||||
Object.keys(this.state.byPlayerId).length === 0
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = emptyState(this.state.revision + 1);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Смена сцены: спрятать токены, сбросить позиции; выбор игроков сохранить. */
|
||||
clearPlacements(): ScenePlayerTokensSessionState {
|
||||
if (!this.state.visible && Object.keys(this.state.byPlayerId).length === 0) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'clearPlacements':
|
||||
return this.clearPlacements();
|
||||
case 'setSelection': {
|
||||
const selectedPlayerIds = normalizePlayerIds(event.playerIds);
|
||||
const same =
|
||||
selectedPlayerIds.length === this.state.selectedPlayerIds.length &&
|
||||
selectedPlayerIds.every((id, i) => id === this.state.selectedPlayerIds[i]);
|
||||
if (same) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
selectedPlayerIds,
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setVisible': {
|
||||
const visible = Boolean(event.visible);
|
||||
if (this.state.visible === visible) return this.state;
|
||||
if (visible && this.state.selectedPlayerIds.length === 0) return this.state;
|
||||
this.state = { ...this.state, revision: this.state.revision + 1, visible };
|
||||
return this.state;
|
||||
}
|
||||
case 'show': {
|
||||
if (this.state.selectedPlayerIds.length === 0) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
const byPlayerId =
|
||||
Object.keys(this.state.byPlayerId).length > 0
|
||||
? this.state.byPlayerId
|
||||
: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN);
|
||||
if (this.state.visible && byPlayerId === this.state.byPlayerId) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
visible: true,
|
||||
byPlayerId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'seedBottom': {
|
||||
if (this.state.selectedPlayerIds.length === 0) return this.state;
|
||||
if (Object.keys(this.state.byPlayerId).length > 0) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN),
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'move': {
|
||||
const playerId = String(event.playerId ?? '');
|
||||
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const prev = this.state.byPlayerId[playerId];
|
||||
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(prev?.sizeN ?? DEFAULT_SCENE_NPC_TOKEN_SIZE_N);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId: {
|
||||
...this.state.byPlayerId,
|
||||
[playerId]: { nx, ny, sizeN },
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,11 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
|
||||
},
|
||||
},
|
||||
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
|
||||
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
|
||||
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
|
||||
} as unknown as Project;
|
||||
const s = collectReferencedAssetIds(p);
|
||||
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'th', 'v1'].sort());
|
||||
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'mat1', 'npc1', 'pr', 'th', 'v1'].sort());
|
||||
});
|
||||
|
||||
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
|
||||
@@ -65,12 +67,14 @@ void test('reconcileAssetFiles: снимает осиротевшие assets и
|
||||
...base,
|
||||
scenes: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
assets: { orphan: asset } as Project['assets'],
|
||||
};
|
||||
const next: Project = {
|
||||
...base,
|
||||
scenes: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
assets: { orphan: asset } as Project['assets'],
|
||||
};
|
||||
|
||||
@@ -115,7 +119,7 @@ void test('reconcileAssetFiles: удаляет файл при исключен
|
||||
} as unknown as Project;
|
||||
|
||||
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
|
||||
const next: Project = { ...base, campaignAudios: [], assets: {} as Project['assets'] };
|
||||
const next: Project = { ...base, campaignAudios: [], materials: [], assets: {} as Project['assets'] };
|
||||
|
||||
const out = await reconcileAssetFiles(prev, next, tmp);
|
||||
assert.deepEqual(out.assets, {});
|
||||
|
||||
@@ -14,6 +14,8 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
|
||||
for (const au of sc.media.audios) refs.add(au.assetId);
|
||||
}
|
||||
for (const au of p.campaignAudios) refs.add(au.assetId);
|
||||
for (const m of p.materials ?? []) refs.add(m.assetId);
|
||||
for (const n of p.npcs ?? []) refs.add(n.avatarAssetId);
|
||||
return refs;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PROJECT_ZIP_EXTENSION,
|
||||
PROJECT_ZIP_EXTENSION_LEGACY,
|
||||
isProjectZipFileName,
|
||||
} from '../../shared/project/projectZipExtension';
|
||||
|
||||
import { readProjectJsonFromZip } from './yauzlProjectZip';
|
||||
|
||||
/**
|
||||
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.dnd.zip.tmp`).
|
||||
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp`).
|
||||
* Нельзя сначала удалять `finalPath`: при сбое rename после rm проект теряется (Windows/антивирус).
|
||||
*/
|
||||
export async function replaceFileAtomic(completedSrc: string, finalPath: string): Promise<void> {
|
||||
@@ -50,8 +56,10 @@ export async function replaceFileAtomic(completedSrc: string, finalPath: string)
|
||||
await fs.unlink(backupPath).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Если сохранение оборвалось, остаётся только `*.dnd.zip.tmp` — восстанавливаем в `*.dnd.zip`. */
|
||||
export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void> {
|
||||
const ZIP_TMP_SUFFIXES = [`${PROJECT_ZIP_EXTENSION}.tmp`, `${PROJECT_ZIP_EXTENSION_LEGACY}.tmp`] as const;
|
||||
|
||||
/** Если сохранение оборвалось, остаётся `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp` — восстанавливаем финальный архив. */
|
||||
export async function recoverOrphanProjectZipTmpInRoot(root: string): Promise<void> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(root);
|
||||
@@ -59,10 +67,11 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
|
||||
return;
|
||||
}
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.dnd.zip.tmp')) continue;
|
||||
const matchedSuffix = ZIP_TMP_SUFFIXES.find((s) => name.endsWith(s));
|
||||
if (!matchedSuffix) continue;
|
||||
const tmpPath = path.join(root, name);
|
||||
const finalName = name.slice(0, -'.tmp'.length);
|
||||
if (!finalName.endsWith('.dnd.zip')) continue;
|
||||
if (!isProjectZipFileName(finalName)) continue;
|
||||
const finalPath = path.join(root, finalName);
|
||||
try {
|
||||
await fs.access(finalPath);
|
||||
@@ -80,3 +89,6 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Используйте {@link recoverOrphanProjectZipTmpInRoot}. */
|
||||
export const recoverOrphanDndZipTmpInRoot = recoverOrphanProjectZipTmpInRoot;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Visually lossless re-encode for imported raster images (same pixel dimensions).
|
||||
* Node-only; shared by the main app and ../project-converter (monorepo sibling).
|
||||
*/
|
||||
import sharp from 'sharp';
|
||||
import { getSharp } from './sharpRuntime.mjs';
|
||||
|
||||
/** @typedef {import('node:buffer').Buffer} Buffer */
|
||||
|
||||
@@ -102,6 +102,7 @@ function makePassthrough(buf, meta) {
|
||||
* @param {number} h0
|
||||
*/
|
||||
async function sameDimensionsOrThrow(outBuf, w0, h0) {
|
||||
const sharp = getSharp();
|
||||
const m = await sharp(outBuf).metadata();
|
||||
if ((m.width ?? 0) !== w0 || (m.height ?? 0) !== h0) {
|
||||
const err = new Error('encode changed dimensions');
|
||||
@@ -120,6 +121,13 @@ export async function optimizeImageBufferVisuallyLossless(src) {
|
||||
return makePassthrough(input, { width: 0, height: 0, format: 'png' });
|
||||
}
|
||||
|
||||
let sharp;
|
||||
try {
|
||||
sharp = getSharp();
|
||||
} catch {
|
||||
return makePassthrough(input, null);
|
||||
}
|
||||
|
||||
let meta0;
|
||||
try {
|
||||
meta0 = await sharp(input, { failOn: 'error', unlimited: true }).metadata();
|
||||
|
||||
@@ -17,7 +17,14 @@ export function getProjectsCacheRootDir(): string {
|
||||
export function getLegacyProjectsRootDirs(): string[] {
|
||||
const cur = getProjectsRootDir();
|
||||
const parent = path.dirname(app.getPath('userData'));
|
||||
const siblingNames = ['DnD Player', 'dnd-player', 'DNDGamePlayer', 'dnd_player'];
|
||||
const siblingNames = [
|
||||
'TTRPG Player',
|
||||
'TTRPGPlayer',
|
||||
'DnD Player',
|
||||
'dnd-player',
|
||||
'DNDGamePlayer',
|
||||
'dnd_player',
|
||||
];
|
||||
const out: string[] = [];
|
||||
for (const n of siblingNames) {
|
||||
const p = path.join(parent, n, 'projects');
|
||||
|
||||
@@ -33,3 +33,19 @@ void test('generateScenePreviewThumbnailBytes: image scales to max edge', async
|
||||
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
void test('generateScenePreviewThumbnailBytes: accepts image Buffer', async () => {
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 120,
|
||||
height: 80,
|
||||
channels: 3,
|
||||
background: { r: 10, g: 20, b: 30 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const buf = await generateScenePreviewThumbnailBytes(png, 'image');
|
||||
assert.ok(buf !== null);
|
||||
assert.ok(buf.length > 0);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import ffmpegStatic from 'ffmpeg-static';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getSharp } from './sharpRuntime.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -14,14 +15,16 @@ export const SCENE_PREVIEW_THUMB_MAX_PX = 320;
|
||||
|
||||
/**
|
||||
* Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds).
|
||||
* For images, prefer a Buffer so callers are not racy with reconcileAssetFiles unlinking paths.
|
||||
*/
|
||||
export async function generateScenePreviewThumbnailBytes(
|
||||
sourceAbsPath: string,
|
||||
source: string | Buffer,
|
||||
kind: 'image' | 'video',
|
||||
): Promise<Buffer | null> {
|
||||
try {
|
||||
const sharp = getSharp();
|
||||
if (kind === 'image') {
|
||||
return await sharp(sourceAbsPath)
|
||||
return await sharp(source)
|
||||
.rotate()
|
||||
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
|
||||
fit: 'inside',
|
||||
@@ -31,6 +34,9 @@ export async function generateScenePreviewThumbnailBytes(
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const sourceAbsPath = typeof source === 'string' ? source : null;
|
||||
if (!sourceAbsPath) return null;
|
||||
|
||||
const ffmpegPath = ffmpegStatic;
|
||||
if (!ffmpegPath) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Lazy `sharp` load so a corrupt/missing native install does not crash Electron at import time.
|
||||
* Call only from image-processing paths; errors are recoverable for the rest of the app.
|
||||
*
|
||||
* Note: main is bundled to CJS (esbuild). `import.meta.url` is empty there — prefer `__filename`.
|
||||
*/
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
function requireBaseFilename() {
|
||||
// CJS bundle / Electron main
|
||||
if (typeof __filename === 'string' && __filename.length > 0) {
|
||||
return __filename;
|
||||
}
|
||||
// Direct ESM (unit tests)
|
||||
const metaUrl = import.meta.url;
|
||||
if (typeof metaUrl === 'string' && metaUrl.startsWith('file:')) {
|
||||
return fileURLToPath(metaUrl);
|
||||
}
|
||||
return path.join(process.cwd(), 'package.json');
|
||||
}
|
||||
|
||||
const require = createRequire(requireBaseFilename());
|
||||
|
||||
/** @type {typeof import('sharp') | null} */
|
||||
let cached = null;
|
||||
/** @type {Error | null} */
|
||||
let loadError = null;
|
||||
|
||||
/**
|
||||
* @param {unknown} err
|
||||
* @returns {Error}
|
||||
*/
|
||||
export function sharpLoadFailure(err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return new Error(
|
||||
[
|
||||
'Не удалось загрузить модуль обработки изображений (sharp).',
|
||||
'Переустановите приложение полностью (удалите и поставьте заново)',
|
||||
'или исключите папку установки из проверки антивируса.',
|
||||
detail ? `Детали: ${detail}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {typeof import('sharp')}
|
||||
*/
|
||||
export function getSharp() {
|
||||
if (cached) return cached;
|
||||
if (loadError) throw loadError;
|
||||
try {
|
||||
cached = require('sharp');
|
||||
return cached;
|
||||
} catch (err) {
|
||||
loadError = sharpLoadFailure(err);
|
||||
throw loadError;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset cache (tests only). */
|
||||
export function __resetSharpRuntimeForTests() {
|
||||
cached = null;
|
||||
loadError = null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
__resetSharpRuntimeForTests,
|
||||
getSharp,
|
||||
sharpLoadFailure,
|
||||
} from './sharpRuntime.mjs';
|
||||
|
||||
void test('getSharp: loads sharp when install is healthy', () => {
|
||||
__resetSharpRuntimeForTests();
|
||||
const sharp = getSharp();
|
||||
assert.equal(typeof sharp, 'function');
|
||||
});
|
||||
|
||||
void test('sharpLoadFailure: includes reinstall hint', () => {
|
||||
const err = sharpLoadFailure(new Error('SyntaxError: Unexpected end of input'));
|
||||
assert.match(err.message, /переустановите/i);
|
||||
assert.match(err.message, /SyntaxError/);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { readProjectJsonFromZip } from './yauzlProjectZip';
|
||||
|
||||
void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-read-'));
|
||||
const zipPath = path.join(tmp, 'test.dnd.zip');
|
||||
const zipPath = path.join(tmp, 'test.ttrpg.zip');
|
||||
const minimal = {
|
||||
id: 'p1',
|
||||
meta: {
|
||||
@@ -54,7 +54,7 @@ void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', as
|
||||
|
||||
void test('readProjectJsonFromZip: campaignAudios round-trips inside project.json', async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-campaign-'));
|
||||
const zipPath = path.join(tmp, 'test.dnd.zip');
|
||||
const zipPath = path.join(tmp, 'test.ttrpg.zip');
|
||||
const assetId = 'audio_asset_1';
|
||||
const minimal = {
|
||||
id: 'p1',
|
||||
|
||||
@@ -28,9 +28,43 @@ void test('zipStore: openProjectById flushes pending saveNow before cache reset'
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
// When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first.
|
||||
assert.match(src, /async openProjectById/);
|
||||
assert.match(src, /enqueueOpenProject/);
|
||||
assert.match(src, /openProjectByIdInner/);
|
||||
assert.match(src, /if \(this\.openProject\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
|
||||
assert.match(src, /await this\.drainSavePipeline\(\)/);
|
||||
assert.match(src, /await fs\.rm\(cacheDir, \{ recursive: true, force: true \}\)/);
|
||||
assert.match(src, /await unzipToDir\(zipPath, cacheDir\)/);
|
||||
assert.match(src, /await unzipToDir\(zipPath, cacheDir/);
|
||||
});
|
||||
|
||||
void test('zipStore: openProjectById skips re-unzip when project is already open', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*return this\.openProject\.project;\s*\}/);
|
||||
});
|
||||
|
||||
void test('zipStore: pack and open operations are serialized', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /private packChain: Promise<void>/);
|
||||
assert.match(src, /private projectSwitchChain: Promise<void>/);
|
||||
assert.match(src, /enqueuePack/);
|
||||
assert.match(src, /enqueueOpenProject/);
|
||||
assert.match(src, /enqueueProjectSwitch/);
|
||||
});
|
||||
|
||||
void test('zipStore: project updates and preview finalize are serialized', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /private projectUpdateChain: Promise<unknown>/);
|
||||
assert.match(src, /private previewFinalizeChain: Promise<unknown>/);
|
||||
assert.match(src, /enqueueProjectUpdate/);
|
||||
assert.match(src, /enqueuePreviewFinalize/);
|
||||
assert.match(src, /finalizeScenePreviewImportInner/);
|
||||
assert.match(src, /drainProjectMutations/);
|
||||
assert.match(src, /addBuffer/);
|
||||
});
|
||||
|
||||
void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/);
|
||||
assert.match(src, /Открытие проекта отменено/);
|
||||
});
|
||||
|
||||
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
|
||||
@@ -58,7 +92,7 @@ void test('atomicReplace: replaceFileAtomic must not rm destination before succe
|
||||
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
||||
const i = src.indexOf('export async function replaceFileAtomic');
|
||||
assert.ok(i >= 0);
|
||||
const j = src.indexOf('export async function recoverOrphanDndZipTmpInRoot', i);
|
||||
const j = src.indexOf('export async function recoverOrphanProjectZipTmpInRoot', i);
|
||||
assert.ok(j > i);
|
||||
const block = src.slice(i, j);
|
||||
assert.match(block, /rename\(finalPath, backupPath\)/);
|
||||
|
||||
+1646
-146
File diff suppressed because it is too large
Load Diff
@@ -2,72 +2,104 @@ import fs from 'node:fs/promises';
|
||||
|
||||
import { session } from 'electron';
|
||||
|
||||
import { asAssetId } from '../../shared/types/ids';
|
||||
import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids';
|
||||
import type { PlayersStore } from '../players/playersStore';
|
||||
import type { ZipProjectStore } from '../project/zipStore';
|
||||
import type { TokensStore } from '../tokens/tokensStore';
|
||||
|
||||
type ReadInfo = { absPath: string; mime: string };
|
||||
|
||||
async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
|
||||
try {
|
||||
const stat = await fs.stat(info.absPath);
|
||||
const total = stat.size;
|
||||
const range = request.headers.get('range') ?? request.headers.get('Range');
|
||||
|
||||
if (range) {
|
||||
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
|
||||
if (m) {
|
||||
const start = Number(m[1]);
|
||||
const endRaw = m[2] ? Number(m[2]) : total - 1;
|
||||
const end = Math.min(endRaw, total - 1);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
'Content-Range': `bytes */${String(total)}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
const len = end - start + 1;
|
||||
const fh = await fs.open(info.absPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(len);
|
||||
const { bytesRead } = await fh.read(buf, 0, len, start);
|
||||
if (bytesRead <= 0) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
'Content-Range': `bytes */${String(total)}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead));
|
||||
const actualEnd = start + bytesRead - 1;
|
||||
return new Response(body, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': info.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
|
||||
'Content-Length': String(body.length),
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buf = await fs.readFile(info.absPath);
|
||||
return new Response(buf, {
|
||||
headers: {
|
||||
'Content-Type': info.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обслуживает `dnd://asset?...` — без этого `<img src="file://...">` в рендерере часто ломается.
|
||||
* Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`.
|
||||
*/
|
||||
export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
|
||||
export function registerDndAssetProtocol(
|
||||
projectStore: ZipProjectStore,
|
||||
tokensStore: TokensStore,
|
||||
playersStore: PlayersStore | null,
|
||||
): void {
|
||||
session.defaultSession.protocol.handle('dnd', async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.hostname !== 'asset') {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
const id = url.searchParams.get('id');
|
||||
if (!id) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
const info = projectStore.getAssetReadInfo(asAssetId(id));
|
||||
let info: ReadInfo | null = null;
|
||||
if (url.hostname === 'asset') {
|
||||
info = projectStore.getAssetReadInfo(asAssetId(id));
|
||||
} else if (url.hostname === 'token') {
|
||||
info = tokensStore.getImageReadInfo(asTokenId(id));
|
||||
} else if (url.hostname === 'player') {
|
||||
info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null;
|
||||
}
|
||||
if (!info) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const stat = await fs.stat(info.absPath);
|
||||
const total = stat.size;
|
||||
const range = request.headers.get('range') ?? request.headers.get('Range');
|
||||
|
||||
if (range) {
|
||||
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
|
||||
if (m) {
|
||||
const start = Number(m[1]);
|
||||
const endRaw = m[2] ? Number(m[2]) : total - 1;
|
||||
const end = Math.min(endRaw, total - 1);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
|
||||
return new Response(null, { status: 416 });
|
||||
}
|
||||
const len = end - start + 1;
|
||||
const fh = await fs.open(info.absPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(len);
|
||||
await fh.read(buf, 0, len, start);
|
||||
return new Response(buf, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': info.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`,
|
||||
'Content-Length': String(len),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buf = await fs.readFile(info.absPath);
|
||||
return new Response(buf, {
|
||||
headers: {
|
||||
'Content-Type': info.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': String(buf.length),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
return serveFile(info, request);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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('main: EPIPE guards ставятся до app.whenReady', () => {
|
||||
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
|
||||
const safe = fs.readFileSync(path.join(here, 'safeConsole.ts'), 'utf8');
|
||||
assert.ok(index.includes('installStdoutEpipeGuards'));
|
||||
assert.ok(index.indexOf('installStdoutEpipeGuards()') < index.indexOf('app.requestSingleInstanceLock'));
|
||||
assert.ok(safe.includes('EPIPE'));
|
||||
assert.ok(safe.includes('safeConsoleError'));
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* В Electron (особенно после рестарта в dev) stdout/stderr часто уже закрыты.
|
||||
* Обычный `console.error` тогда даёт EPIPE и валит main process диалогом Uncaught Exception.
|
||||
*/
|
||||
|
||||
function isBrokenPipe(err: unknown): boolean {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED';
|
||||
}
|
||||
|
||||
export function installStdoutEpipeGuards(): void {
|
||||
for (const stream of [process.stdout, process.stderr]) {
|
||||
stream?.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (isBrokenPipe(err)) return;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function safeConsoleError(...args: unknown[]): void {
|
||||
try {
|
||||
console.error(...args);
|
||||
} catch (err) {
|
||||
if (isBrokenPipe(err)) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SceneTrapsStore } from './sceneTrapsStore';
|
||||
|
||||
void test('SceneTrapsStore: сохраняет runtime при переключении сцен внутри сессии', () => {
|
||||
const store = new SceneTrapsStore();
|
||||
store.switchScene('scene_a', ['t1']);
|
||||
store.dispatch({ kind: 'activate', trapId: 't1' });
|
||||
assert.equal(store.getState().byId.t1?.status, 'active');
|
||||
assert.equal(store.getState().byId.t1?.revealed, true);
|
||||
|
||||
store.switchScene('scene_b', ['t2']);
|
||||
assert.equal(store.getState().byId.t2?.status, 'inactive');
|
||||
|
||||
store.switchScene('scene_a', ['t1']);
|
||||
assert.equal(store.getState().byId.t1?.status, 'active');
|
||||
assert.equal(store.getState().byId.t1?.revealed, true);
|
||||
});
|
||||
|
||||
void test('SceneTrapsStore: resetSession сбрасывает кэш к дефолту (кнопка «Запустить»)', () => {
|
||||
const store = new SceneTrapsStore();
|
||||
store.switchScene('scene_a', ['t1', 't2']);
|
||||
store.dispatch({ kind: 'reveal', trapId: 't1' });
|
||||
store.dispatch({ kind: 'activate', trapId: 't2' });
|
||||
assert.equal(store.getState().byId.t1?.revealed, true);
|
||||
assert.equal(store.getState().byId.t2?.status, 'active');
|
||||
assert.ok(store.getState().lastActivation);
|
||||
|
||||
store.resetSession();
|
||||
store.switchScene('scene_a', ['t1', 't2']);
|
||||
|
||||
assert.equal(store.getState().byId.t1?.status, 'inactive');
|
||||
assert.equal(store.getState().byId.t1?.revealed, false);
|
||||
assert.equal(store.getState().byId.t2?.status, 'inactive');
|
||||
assert.equal(store.getState().byId.t2?.revealed, false);
|
||||
assert.equal(store.getState().lastActivation, null);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
defaultTrapRuntime,
|
||||
type SceneTrapsEvent,
|
||||
type SceneTrapsState,
|
||||
type SceneTrapRuntime,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneTrapsState {
|
||||
return {
|
||||
revision: 1,
|
||||
cacheKey: null,
|
||||
byId: {},
|
||||
lastActivation: null,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneById(byId: Record<string, SceneTrapRuntime>): Record<string, SceneTrapRuntime> {
|
||||
const out: Record<string, SceneTrapRuntime> = {};
|
||||
for (const [k, v] of Object.entries(byId)) {
|
||||
out[k] = { ...v };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class SceneTrapsStore {
|
||||
private state: SceneTrapsState = emptyState();
|
||||
/** Кэш runtime по ключу сцены/ноды на время сессии показа. */
|
||||
private cache = new Map<string, Record<string, SceneTrapRuntime>>();
|
||||
private currentKey: string | null = null;
|
||||
private activationToken = 0;
|
||||
|
||||
getState(): SceneTrapsState {
|
||||
return {
|
||||
...this.state,
|
||||
byId: cloneById(this.state.byId),
|
||||
lastActivation: this.state.lastActivation ? { ...this.state.lastActivation } : null,
|
||||
};
|
||||
}
|
||||
|
||||
resetSession(): void {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = emptyState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключение сцены: сохраняем runtime в кэш и подгружаем/инициализируем для новых trapIds.
|
||||
*/
|
||||
switchScene(cacheKey: string | null, trapIds: readonly string[]): SceneTrapsState {
|
||||
if (this.currentKey !== null) {
|
||||
this.cache.set(this.currentKey, cloneById(this.state.byId));
|
||||
}
|
||||
this.currentKey = cacheKey;
|
||||
const cached = cacheKey ? this.cache.get(cacheKey) : undefined;
|
||||
const byId: Record<string, SceneTrapRuntime> = {};
|
||||
for (const id of trapIds) {
|
||||
byId[id] = cached?.[id] ? { ...cached[id]! } : defaultTrapRuntime();
|
||||
}
|
||||
if (cacheKey) {
|
||||
this.cache.set(cacheKey, cloneById(byId));
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
cacheKey,
|
||||
byId,
|
||||
lastActivation: null,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
dispatch(event: SceneTrapsEvent): SceneTrapsState {
|
||||
switch (event.kind) {
|
||||
case 'syncTrapIds': {
|
||||
const byId: Record<string, SceneTrapRuntime> = {};
|
||||
for (const id of event.trapIds) {
|
||||
byId[id] = this.state.byId[id] ? { ...this.state.byId[id]! } : defaultTrapRuntime();
|
||||
}
|
||||
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byId,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
case 'reveal': {
|
||||
const cur = this.state.byId[event.trapId];
|
||||
if (!cur) return this.getState();
|
||||
const byId = cloneById(this.state.byId);
|
||||
byId[event.trapId] = { ...cur, revealed: true };
|
||||
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byId,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
case 'activate': {
|
||||
const cur = this.state.byId[event.trapId];
|
||||
if (!cur) return this.getState();
|
||||
const byId = cloneById(this.state.byId);
|
||||
byId[event.trapId] = { status: 'active', revealed: true };
|
||||
this.activationToken += 1;
|
||||
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byId,
|
||||
lastActivation: { trapId: event.trapId, token: this.activationToken },
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
case 'disarm': {
|
||||
const cur = this.state.byId[event.trapId];
|
||||
if (!cur) return this.getState();
|
||||
const byId = cloneById(this.state.byId);
|
||||
byId[event.trapId] = { status: 'disarmed', revealed: true };
|
||||
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byId,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
default: {
|
||||
const _x: never = event;
|
||||
void _x;
|
||||
return this.getState();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_SCENE_VIEW_CAMERA,
|
||||
sceneViewPanBy,
|
||||
sceneViewZoomAt,
|
||||
} from '../../shared/types/sceneView';
|
||||
|
||||
import { SceneViewStore } from './sceneViewStore';
|
||||
|
||||
describe('SceneViewStore', () => {
|
||||
it('resets to default camera', () => {
|
||||
const store = new SceneViewStore();
|
||||
store.dispatch({ kind: 'set', camera: { scale: 2, ox: 0.2, oy: 0.8 } });
|
||||
const next = store.dispatch({ kind: 'reset' });
|
||||
assert.equal(next.scale, 1);
|
||||
assert.equal(next.ox, 0.5);
|
||||
assert.equal(next.oy, 0.5);
|
||||
});
|
||||
|
||||
it('clamps scale and origin', () => {
|
||||
const store = new SceneViewStore();
|
||||
const next = store.dispatch({ kind: 'set', camera: { scale: 99, ox: -1, oy: 2 } });
|
||||
assert.equal(next.scale, 8);
|
||||
assert.equal(next.ox, 0);
|
||||
assert.equal(next.oy, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sceneViewZoomAt / panBy', () => {
|
||||
it('zooms toward cursor and keeps that content point under cursor', () => {
|
||||
const hostW = 1000;
|
||||
const hostH = 500;
|
||||
const containW = 800;
|
||||
const containH = 400;
|
||||
const hostX = 700;
|
||||
const hostY = 250;
|
||||
const next = sceneViewZoomAt(DEFAULT_SCENE_VIEW_CAMERA, {
|
||||
hostW,
|
||||
hostH,
|
||||
containW,
|
||||
containH,
|
||||
hostX,
|
||||
hostY,
|
||||
factor: 2,
|
||||
});
|
||||
assert.ok(next.scale > 1.5);
|
||||
const displayW = containW * next.scale;
|
||||
const displayH = containH * next.scale;
|
||||
const left = hostW / 2 - next.ox * displayW;
|
||||
const top = hostH / 2 - next.oy * displayH;
|
||||
const ix = (hostX - left) / displayW;
|
||||
const iy = (hostY - top) / displayH;
|
||||
// At scale=1 contain is centered; cursor was at content x=(700-100)/800=0.75
|
||||
assert.ok(Math.abs(ix - 0.75) < 1e-6);
|
||||
assert.ok(Math.abs(iy - 0.5) < 1e-6);
|
||||
});
|
||||
|
||||
it('pans in host pixels', () => {
|
||||
const cam = { scale: 2, ox: 0.5, oy: 0.5 };
|
||||
const next = sceneViewPanBy(cam, { containW: 400, containH: 200, dx: 80, dy: 0 });
|
||||
// displayW=800; dx=80 → ox decreases by 0.1
|
||||
assert.ok(Math.abs(next.ox - 0.4) < 1e-6);
|
||||
assert.equal(next.oy, 0.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
clampSceneViewCamera,
|
||||
DEFAULT_SCENE_VIEW_CAMERA,
|
||||
type SceneViewCamera,
|
||||
type SceneViewEvent,
|
||||
type SceneViewState,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneViewState {
|
||||
return {
|
||||
revision: 1,
|
||||
...DEFAULT_SCENE_VIEW_CAMERA,
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneViewStore {
|
||||
private state: SceneViewState = emptyState();
|
||||
|
||||
getState(): SceneViewState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): SceneViewState {
|
||||
if (
|
||||
this.state.scale === 1 &&
|
||||
this.state.ox === 0.5 &&
|
||||
this.state.oy === 0.5
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
...DEFAULT_SCENE_VIEW_CAMERA,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneViewEvent): SceneViewState {
|
||||
switch (event.kind) {
|
||||
case 'reset':
|
||||
return this.reset();
|
||||
case 'set': {
|
||||
const camera: SceneViewCamera = clampSceneViewCamera(event.camera);
|
||||
if (
|
||||
camera.scale === this.state.scale &&
|
||||
camera.ox === this.state.ox &&
|
||||
camera.oy === this.state.oy
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
...camera,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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));
|
||||
|
||||
/**
|
||||
* Фаза 6 (срез): layout-only мутации не должны слать полный Project во все окна.
|
||||
* Редактор НПС применяет позицию из ответа invoke локально.
|
||||
*/
|
||||
void test('session IPC: нет emitSessionState на graph/NPC position hot-path', () => {
|
||||
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
|
||||
const createWindows = fs.readFileSync(path.join(here, 'windows/createWindows.ts'), 'utf8');
|
||||
const npcsEditor = fs.readFileSync(
|
||||
path.join(here, '../renderer/npcs/NpcsEditorApp.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.ok(index.includes('sendToAppWindows(ipcChannels.session.stateChanged'));
|
||||
assert.ok(createWindows.includes('SESSION_STATE_WINDOW_KINDS'));
|
||||
assert.ok(createWindows.includes('sendToAppWindows'));
|
||||
const kindsBlock = /SESSION_STATE_WINDOW_KINDS: readonly WindowKind\[\] = \[([\s\S]*?)\] as const/.exec(
|
||||
createWindows,
|
||||
);
|
||||
assert.ok(kindsBlock, 'SESSION_STATE_WINDOW_KINDS объявлен');
|
||||
assert.doesNotMatch(kindsBlock[1] ?? '', /'editor'/, 'editor не получает session.stateChanged');
|
||||
assert.match(kindsBlock[1] ?? '', /'presentation'/);
|
||||
assert.match(kindsBlock[1] ?? '', /'control'/);
|
||||
|
||||
// Handlers больше не вызывают emitSessionState сразу после layout-update.
|
||||
const npcPosHandler =
|
||||
/registerHandler\(ipcChannels\.project\.updateNpcPosition,\s*async\s*\(\{ npcId, x, y \}\) => \{([\s\S]*?)\}\);/.exec(
|
||||
index,
|
||||
);
|
||||
const graphPosHandler =
|
||||
/registerHandler\(ipcChannels\.project\.updateSceneGraphNodePosition,\s*async\s*\(\{ nodeId, x, y \}\) => \{([\s\S]*?)\}\);/.exec(
|
||||
index,
|
||||
);
|
||||
assert.ok(npcPosHandler, 'updateNpcPosition handler');
|
||||
assert.ok(graphPosHandler, 'updateSceneGraphNodePosition handler');
|
||||
assert.doesNotMatch(npcPosHandler[1] ?? '', /emitSessionState/);
|
||||
assert.doesNotMatch(graphPosHandler[1] ?? '', /emitSessionState/);
|
||||
assert.match(npcPosHandler[1] ?? '', /return \{ project \}/);
|
||||
assert.match(graphPosHandler[1] ?? '', /return \{ project \}/);
|
||||
|
||||
assert.ok(npcsEditor.includes('updateNpcPosition'));
|
||||
assert.match(
|
||||
npcsEditor,
|
||||
/onNodePositionCommit[\s\S]*?setSession\([\s\S]*?npcs: prev\.project\.npcs\.map/,
|
||||
);
|
||||
assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneTokensSessionState {
|
||||
return {
|
||||
revision: 1,
|
||||
byPlacementId: {},
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneTokensSessionStore {
|
||||
private state: SceneTokensSessionState = emptyState();
|
||||
|
||||
getState(): SceneTokensSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): SceneTokensSessionState {
|
||||
if (Object.keys(this.state.byPlacementId).length === 0) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'move': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: { nx, ny },
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { TokenGridSnapSessionStore } from './tokenGridSnapSessionStore';
|
||||
|
||||
void test('TokenGridSnapSessionStore: set and reset', () => {
|
||||
const store = new TokenGridSnapSessionStore();
|
||||
assert.equal(store.getState().enabled, false);
|
||||
assert.equal(store.setEnabled(true).enabled, true);
|
||||
assert.equal(store.getState().enabled, true);
|
||||
assert.equal(store.reset().enabled, false);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export type TokenGridSnapSessionState = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export class TokenGridSnapSessionStore {
|
||||
private enabled = false;
|
||||
|
||||
getState(): TokenGridSnapSessionState {
|
||||
return { enabled: this.enabled };
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): TokenGridSnapSessionState {
|
||||
this.enabled = Boolean(enabled);
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
reset(): TokenGridSnapSessionState {
|
||||
this.enabled = false;
|
||||
return this.getState();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AppToken, TokenId } from '../../shared/types';
|
||||
import { asTokenId } from '../../shared/types/ids';
|
||||
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
|
||||
|
||||
type TokensManifest = {
|
||||
tokens: AppToken[];
|
||||
};
|
||||
|
||||
function mimeFromExt(ext: string): string {
|
||||
const e = ext.toLowerCase();
|
||||
if (e === '.png') return 'image/png';
|
||||
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
|
||||
if (e === '.webp') return 'image/webp';
|
||||
if (e === '.gif') return 'image/gif';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function safeFileBase(name: string): string {
|
||||
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
|
||||
return base || 'token';
|
||||
}
|
||||
|
||||
function randomTokenId(): TokenId {
|
||||
return asTokenId(`token_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||
}
|
||||
|
||||
export class TokensStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly filesDir: string;
|
||||
private readonly manifestPath: string;
|
||||
private tokens: AppToken[] = [];
|
||||
private loaded = false;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.rootDir = path.join(userData, 'tokens');
|
||||
this.filesDir = path.join(this.rootDir, 'files');
|
||||
this.manifestPath = path.join(this.rootDir, 'tokens.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 TokensManifest;
|
||||
this.tokens = Array.isArray(parsed.tokens)
|
||||
? parsed.tokens.filter(
|
||||
(t): t is AppToken =>
|
||||
Boolean(t) &&
|
||||
typeof t.id === 'string' &&
|
||||
typeof t.name === 'string' &&
|
||||
typeof t.imageRelPath === 'string' &&
|
||||
typeof t.sha256 === 'string',
|
||||
)
|
||||
: [];
|
||||
} catch {
|
||||
this.tokens = [];
|
||||
await this.persist();
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
list(): AppToken[] {
|
||||
return [...this.tokens];
|
||||
}
|
||||
|
||||
getById(id: TokenId): AppToken | null {
|
||||
return this.tokens.find((t) => t.id === id) ?? null;
|
||||
}
|
||||
|
||||
findBySha256(sha256: string): AppToken | null {
|
||||
return this.tokens.find((t) => t.sha256 === sha256) ?? null;
|
||||
}
|
||||
|
||||
getImageReadInfo(id: TokenId): { absPath: string; mime: string } | null {
|
||||
const token = this.getById(id);
|
||||
if (!token) return null;
|
||||
const absPath = path.join(this.rootDir, token.imageRelPath);
|
||||
return { absPath, mime: mimeFromExt(path.extname(token.imageRelPath)) };
|
||||
}
|
||||
|
||||
getImageUrl(id: TokenId): string | null {
|
||||
if (!this.getImageReadInfo(id)) return null;
|
||||
return `dnd://token?id=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
absPathForRel(relPath: string): string {
|
||||
return path.join(this.rootDir, relPath);
|
||||
}
|
||||
|
||||
async upsert(input: { id?: TokenId | null; name: string; filePath?: string | null }): Promise<AppToken> {
|
||||
await this.ensureLoaded();
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error('Token name is required');
|
||||
|
||||
const existing = input.id ? this.getById(input.id) : null;
|
||||
if (input.id && !existing) throw new Error('Token not found');
|
||||
if (!existing && !input.filePath) throw new Error('Token image is required');
|
||||
|
||||
let imageRelPath = existing?.imageRelPath ?? '';
|
||||
let sha256 = existing?.sha256 ?? '';
|
||||
|
||||
if (input.filePath) {
|
||||
let buf = await fs.readFile(input.filePath);
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
/* keep original */
|
||||
}
|
||||
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const id = existing?.id ?? randomTokenId();
|
||||
const ext = path.extname(input.filePath) || '.png';
|
||||
const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`;
|
||||
imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
|
||||
const abs = path.join(this.rootDir, imageRelPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, buf);
|
||||
if (existing && existing.imageRelPath !== imageRelPath) {
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const token: AppToken = {
|
||||
id,
|
||||
name,
|
||||
imageRelPath,
|
||||
sha256,
|
||||
};
|
||||
if (existing) {
|
||||
this.tokens = this.tokens.map((t) => (t.id === id ? token : t));
|
||||
} else {
|
||||
this.tokens = [...this.tokens, token];
|
||||
}
|
||||
await this.persist();
|
||||
return token;
|
||||
}
|
||||
|
||||
const token: AppToken = {
|
||||
id: existing!.id,
|
||||
name,
|
||||
imageRelPath,
|
||||
sha256,
|
||||
};
|
||||
this.tokens = this.tokens.map((t) => (t.id === token.id ? token : t));
|
||||
await this.persist();
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Импорт токена из внешнего файла (storyline zip) с заданным id или дедупом по sha256. */
|
||||
async importFromFile(input: {
|
||||
preferredId: TokenId;
|
||||
name: string;
|
||||
absFilePath: string;
|
||||
sha256?: string;
|
||||
}): Promise<{ token: AppToken; remappedFrom: TokenId }> {
|
||||
await this.ensureLoaded();
|
||||
let buf = await fs.readFile(input.absFilePath);
|
||||
const sha256 = input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const existingByHash = this.findBySha256(sha256);
|
||||
if (existingByHash) {
|
||||
return { token: existingByHash, remappedFrom: input.preferredId };
|
||||
}
|
||||
const existingById = this.getById(input.preferredId);
|
||||
const id = existingById ? randomTokenId() : input.preferredId;
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
const ext = path.extname(input.absFilePath) || '.png';
|
||||
const fileName = `${id}_${safeFileBase(input.name)}${ext.toLowerCase()}`;
|
||||
const imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
|
||||
const abs = path.join(this.rootDir, imageRelPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, buf);
|
||||
const token: AppToken = {
|
||||
id,
|
||||
name: input.name.trim() || 'Token',
|
||||
imageRelPath,
|
||||
sha256: crypto.createHash('sha256').update(buf).digest('hex'),
|
||||
};
|
||||
this.tokens = [...this.tokens, token];
|
||||
await this.persist();
|
||||
return { token, remappedFrom: input.preferredId };
|
||||
}
|
||||
|
||||
async delete(id: TokenId): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return;
|
||||
this.tokens = this.tokens.filter((t) => t.id !== id);
|
||||
await this.persist();
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Упаковать выбранные токены в каталог экспорта (`app-tokens/`). */
|
||||
async packForExport(tokenIds: string[], exportRoot: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const idSet = new Set(tokenIds);
|
||||
const selected = this.tokens.filter((t) => idSet.has(t.id));
|
||||
if (selected.length === 0) return;
|
||||
const outDir = path.join(exportRoot, 'app-tokens');
|
||||
const filesOut = path.join(outDir, 'files');
|
||||
await fs.mkdir(filesOut, { recursive: true });
|
||||
const packed: AppToken[] = [];
|
||||
for (const t of selected) {
|
||||
const src = path.join(this.rootDir, t.imageRelPath);
|
||||
const base = path.basename(t.imageRelPath);
|
||||
const destRel = path.join('files', base).replace(/\\/gu, '/');
|
||||
await fs.copyFile(src, path.join(outDir, destRel));
|
||||
packed.push({ ...t, imageRelPath: destRel });
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(outDir, 'tokens.json'),
|
||||
`${JSON.stringify({ tokens: packed }, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт из `sourceCache/app-tokens/`.
|
||||
* @returns map oldTokenId → newTokenId
|
||||
*/
|
||||
async importFromExportDir(sourceCache: string): Promise<Map<string, string>> {
|
||||
await this.ensureLoaded();
|
||||
const remap = new Map<string, string>();
|
||||
const manifestPath = path.join(sourceCache, 'app-tokens', 'tokens.json');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(manifestPath, 'utf8');
|
||||
} catch {
|
||||
return remap;
|
||||
}
|
||||
let parsed: TokensManifest;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as TokensManifest;
|
||||
} catch {
|
||||
return remap;
|
||||
}
|
||||
const list = Array.isArray(parsed.tokens) ? parsed.tokens : [];
|
||||
for (const t of list) {
|
||||
if (!t?.id || !t.imageRelPath) continue;
|
||||
const abs = path.join(sourceCache, 'app-tokens', t.imageRelPath);
|
||||
const { token, remappedFrom } = await this.importFromFile({
|
||||
preferredId: asTokenId(t.id),
|
||||
name: typeof t.name === 'string' ? t.name : 'Token',
|
||||
absFilePath: abs,
|
||||
...(typeof t.sha256 === 'string' ? { sha256: t.sha256 } : {}),
|
||||
});
|
||||
remap.set(remappedFrom, token.id);
|
||||
}
|
||||
return remap;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
const payload: TokensManifest = { tokens: this.tokens };
|
||||
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { app, dialog } from 'electron';
|
||||
import { app, BrowserWindow, dialog } from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
|
||||
import { appDisplayNameForLocale } from '../../shared/appBranding';
|
||||
import {
|
||||
ipcChannels,
|
||||
type UpdaterCheckResponse,
|
||||
type UpdaterDownloadResponse,
|
||||
type UpdaterProgressEvent,
|
||||
} from '../../shared/ipc/contracts';
|
||||
import type { IpcRegisterHandler } from '../ipc/router';
|
||||
import { addLicenseChangeListener } from '../license/licenseService';
|
||||
@@ -13,13 +15,63 @@ import type { LicenseService } from '../license/licenseService';
|
||||
const STARTUP_CHECK_DELAY_MS = 12_000;
|
||||
/** Не дёргать сервер чаще (смена лицензии / повторные emit). */
|
||||
const RE_CHECK_COOLDOWN_MS = 30_000;
|
||||
/** Ручная загрузка из модалки — не зависать бесконечно на «Загрузка…». */
|
||||
const MANUAL_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, code: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(code)), ms);
|
||||
promise.then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
(e: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* На macOS Squirrel.Mac может уже получить update-downloaded к моменту quitAndInstall.
|
||||
* При autoInstallOnAppQuit=true MacUpdater не вызывает checkForUpdates повторно и зависает.
|
||||
*/
|
||||
function quitAndInstallForPlatform(): void {
|
||||
if (process.platform === 'darwin') {
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
}
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
|
||||
function formatUpdaterError(e: unknown): string {
|
||||
const raw = e instanceof Error ? e.message : String(e);
|
||||
if (raw === 'UPDATE_DOWNLOAD_TIMEOUT') {
|
||||
return 'Превышено время ожидания загрузки обновления';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
let lastCheckAt = 0;
|
||||
/** Ручная установка: не показывать второй диалог из `update-downloaded`. */
|
||||
let suppressAutoInstallDialog = false;
|
||||
/** Версия, уже скачанная Squirrel/electron-updater (в т.ч. фоном). */
|
||||
let downloadedUpdateVersion: string | null = null;
|
||||
|
||||
type RegisterFn = IpcRegisterHandler;
|
||||
|
||||
function emitUpdaterProgress(ev: UpdaterProgressEvent): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.updater.progress, ev);
|
||||
} catch {
|
||||
/* окно закрылось */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLicensedForUpdates(licenseService: LicenseService): boolean {
|
||||
const snap = licenseService.getStatusSync();
|
||||
return snap.active;
|
||||
@@ -31,6 +83,7 @@ function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: bo
|
||||
const now = Date.now();
|
||||
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
|
||||
lastCheckAt = now;
|
||||
emitUpdaterProgress({ phase: 'checking' });
|
||||
void autoUpdater.checkForUpdates().catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -41,42 +94,132 @@ async function runManualUpdaterCheck(licenseService: LicenseService): Promise<Up
|
||||
if (!isLicensedForUpdates(licenseService)) {
|
||||
return { outcome: 'no_license' };
|
||||
}
|
||||
const prevAutoDownload = autoUpdater.autoDownload;
|
||||
autoUpdater.autoDownload = false;
|
||||
emitUpdaterProgress({ phase: 'checking' });
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
if (result && result.isUpdateAvailable && result.updateInfo.version) {
|
||||
emitUpdaterProgress({ phase: 'available', version: result.updateInfo.version });
|
||||
return { outcome: 'available', version: result.updateInfo.version };
|
||||
}
|
||||
emitUpdaterProgress({ phase: 'not-available', version: app.getVersion() });
|
||||
return { outcome: 'current', currentVersion: app.getVersion() };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
emitUpdaterProgress({ phase: 'error', message });
|
||||
return { outcome: 'error', message };
|
||||
} finally {
|
||||
autoUpdater.autoDownload = prevAutoDownload;
|
||||
}
|
||||
}
|
||||
|
||||
async function runManualDownloadAndRestart(): Promise<UpdaterDownloadResponse> {
|
||||
function isUpdateAlreadyDownloaded(targetVersion: string): boolean {
|
||||
return downloadedUpdateVersion !== null && downloadedUpdateVersion === targetVersion;
|
||||
}
|
||||
|
||||
async function runManualDownloadAndRestart(targetVersion: string): Promise<UpdaterDownloadResponse> {
|
||||
if (!app.isPackaged) {
|
||||
return { ok: false, message: 'NOT_PACKAGED' };
|
||||
}
|
||||
const prevAutoInstallOnAppQuit = autoUpdater.autoInstallOnAppQuit;
|
||||
try {
|
||||
suppressAutoInstallDialog = true;
|
||||
await autoUpdater.downloadUpdate();
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
if (process.platform === 'darwin') {
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
if (!isUpdateAlreadyDownloaded(targetVersion)) {
|
||||
emitUpdaterProgress({ phase: 'downloading', version: targetVersion, percent: 0 });
|
||||
await withTimeout(
|
||||
autoUpdater.downloadUpdate(),
|
||||
MANUAL_DOWNLOAD_TIMEOUT_MS,
|
||||
'UPDATE_DOWNLOAD_TIMEOUT',
|
||||
);
|
||||
}
|
||||
|
||||
emitUpdaterProgress({ phase: 'installing', version: targetVersion });
|
||||
quitAndInstallForPlatform();
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
suppressAutoInstallDialog = false;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const message = formatUpdaterError(e);
|
||||
emitUpdaterProgress({ phase: 'error', message });
|
||||
if (process.platform === 'darwin') {
|
||||
autoUpdater.autoInstallOnAppQuit = prevAutoInstallOnAppQuit;
|
||||
}
|
||||
return { ok: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
|
||||
register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService));
|
||||
register(ipcChannels.updater.downloadAndRestart, () => runManualDownloadAndRestart());
|
||||
register(ipcChannels.updater.downloadAndRestart, (req: { version: string }) =>
|
||||
runManualDownloadAndRestart(req.version),
|
||||
);
|
||||
}
|
||||
|
||||
function wireAutoUpdaterEvents(licenseService: LicenseService): void {
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
emitUpdaterProgress({ phase: 'checking' });
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
emitUpdaterProgress({ phase: 'available', version: info.version });
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
emitUpdaterProgress({ phase: 'not-available', version: info.version });
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
const percent =
|
||||
typeof progress.percent === 'number' && Number.isFinite(progress.percent)
|
||||
? Math.round(progress.percent)
|
||||
: undefined;
|
||||
const ev: UpdaterProgressEvent = { phase: 'downloading' };
|
||||
if (percent !== undefined) ev.percent = percent;
|
||||
emitUpdaterProgress(ev);
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
downloadedUpdateVersion = info.version;
|
||||
emitUpdaterProgress({ phase: 'downloading', version: info.version, percent: 100 });
|
||||
if (suppressAutoInstallDialog) {
|
||||
suppressAutoInstallDialog = false;
|
||||
return;
|
||||
}
|
||||
void dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: appDisplayNameForLocale(app.getLocale()),
|
||||
message: `Доступна новая версия ${info.version}. Установить и перезапустить?`,
|
||||
buttons: ['Перезапустить сейчас', 'Позже'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.response === 0) {
|
||||
emitUpdaterProgress({ phase: 'installing', version: info.version });
|
||||
quitAndInstallForPlatform();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (e) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
emitUpdaterProgress({ phase: 'error', message });
|
||||
});
|
||||
|
||||
addLicenseChangeListener(() => {
|
||||
maybeCheckForUpdates(licenseService, false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка обновлений: только упакованное приложение, только при активной лицензии.
|
||||
* Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки).
|
||||
* Дифференциальное скачивание (HTTP Range / blockmap) по умолчанию **выключено**: за nginx у Gitea raw часто **400** на multi-Range, updater всё равно уходит в полный файл. Включить снова: **`DND_UPDATE_ENABLE_DIFFERENTIAL=1`** (имеет смысл только если на сервере починили Range).
|
||||
*/
|
||||
export function installAutoUpdater(licenseService: LicenseService, register: RegisterFn): void {
|
||||
registerUpdaterHandlers(register, licenseService);
|
||||
@@ -89,37 +232,17 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url });
|
||||
}
|
||||
|
||||
const enableDiff = process.env.DND_UPDATE_ENABLE_DIFFERENTIAL?.trim().toLowerCase();
|
||||
autoUpdater.disableDifferentialDownload = !(
|
||||
enableDiff === '1' ||
|
||||
enableDiff === 'true' ||
|
||||
enableDiff === 'yes'
|
||||
);
|
||||
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
if (suppressAutoInstallDialog) {
|
||||
suppressAutoInstallDialog = false;
|
||||
return;
|
||||
}
|
||||
void dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'DNDGamePlayer',
|
||||
message: `Доступна новая версия ${info.version}. Установить и перезапустить?`,
|
||||
buttons: ['Перезапустить сейчас', 'Позже'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.response === 0) {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('error', () => {
|
||||
/* без console: в production main минифицируется с drop console */
|
||||
});
|
||||
|
||||
addLicenseChangeListener(() => {
|
||||
maybeCheckForUpdates(licenseService, false);
|
||||
});
|
||||
wireAutoUpdaterEvents(licenseService);
|
||||
|
||||
setTimeout(() => {
|
||||
maybeCheckForUpdates(licenseService, true);
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
|
||||
import { appDisplayNameForLocale, windowChromeTitle } from '../../shared/appBranding';
|
||||
import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
||||
@@ -63,6 +64,7 @@ export function createBootWindow(): BrowserWindow {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
win.setTitle(windowChromeTitle('boot', app.getLocale()));
|
||||
|
||||
bootSplashRef = win;
|
||||
win.once('closed', () => {
|
||||
@@ -93,7 +95,7 @@ export function setBootWindowStatus(win: BrowserWindow, text: string): void {
|
||||
|
||||
export function applyBootWindowBranding(win: BrowserWindow): void {
|
||||
if (win.isDestroyed()) return;
|
||||
const name = app.getName();
|
||||
const name = appDisplayNameForLocale(app.getLocale());
|
||||
const version = getAppSemanticVersion();
|
||||
const versionLabel = version.trim().length > 0 ? `v${version.trim()}` : '';
|
||||
void win.webContents.executeJavaScript(
|
||||
|
||||
@@ -35,6 +35,40 @@ void test('createWindows: пульт поверх экрана просмотр
|
||||
assert.ok(src.includes("createWindow('control'"));
|
||||
});
|
||||
|
||||
void test('createWindows: окно описания сцены закрывается с multi-window и отдельно', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openSceneDescriptionWindow'));
|
||||
assert.ok(src.includes('closeSceneDescriptionWindow'));
|
||||
assert.ok(src.includes("createWindow('sceneDescription'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeSceneDescriptionWindow/);
|
||||
assert.match(src, /kind !== 'presentation'[\s\S]*closeSceneDescriptionWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно материалов закрывается с multi-window', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openMaterialsWindow'));
|
||||
assert.ok(src.includes('closeMaterialsWindow'));
|
||||
assert.ok(src.includes("createWindow('materials'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openNpcsWindow'));
|
||||
assert.ok(src.includes('closeNpcsWindow'));
|
||||
assert.ok(src.includes('openNpcsEditorWindow'));
|
||||
assert.ok(src.includes('warmNpcsEditorWindow'));
|
||||
assert.ok(src.includes("createWindow('npcs'"));
|
||||
assert.ok(src.includes("createWindow('npcsEditor'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.match(src, /kind === 'control'/);
|
||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('loadFile'));
|
||||
@@ -46,3 +80,10 @@ void test('createWindows: показ окна — не только ready-to-sho
|
||||
assert.ok(src.includes('ensureWindowBecomesVisible'));
|
||||
assert.ok(src.includes('did-finish-load'));
|
||||
});
|
||||
|
||||
void test('createWindows: логи окон не валят main через EPIPE', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('safeConsoleError'));
|
||||
assert.ok(src.includes('errorCode === -3'));
|
||||
assert.ok(src.includes('isMainFrame'));
|
||||
});
|
||||
|
||||
@@ -2,16 +2,59 @@ import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, screen } from 'electron';
|
||||
|
||||
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
|
||||
import { safeConsoleError } from '../safeConsole';
|
||||
|
||||
import { getBootSplashWindow } from './bootWindow';
|
||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
||||
|
||||
type WindowKind = 'editor' | 'presentation' | 'control';
|
||||
export type WindowKind =
|
||||
| 'editor'
|
||||
| 'presentation'
|
||||
| 'control'
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'npcs';
|
||||
|
||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||||
'presentation',
|
||||
'control',
|
||||
'materials',
|
||||
'npcs',
|
||||
'npcsEditor',
|
||||
'sceneEditor',
|
||||
] as const;
|
||||
|
||||
const windows = new Map<WindowKind, BrowserWindow>();
|
||||
|
||||
/** Язык заголовков окон (из редактора); иначе `app.getLocale()`. */
|
||||
let chromeLocaleTagOverride: string | null = null;
|
||||
|
||||
function resolveChromeLocaleTag(): string {
|
||||
return chromeLocaleTagOverride ?? app.getLocale();
|
||||
}
|
||||
|
||||
let appQuitting = false;
|
||||
/** Защита от каскада close(control) ↔ close(presentation). */
|
||||
let closingPlaySession = false;
|
||||
let pendingSceneDescriptionHtml = '';
|
||||
|
||||
/** Окно материалов — только колонка списка. */
|
||||
const MATERIALS_WINDOW_WIDTH = 300;
|
||||
const MATERIALS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||
|
||||
/** Пульт НПС: деталь слева + список справа. */
|
||||
const NPCS_WINDOW_WIDTH = 720;
|
||||
const NPCS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
|
||||
function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
@@ -26,11 +69,111 @@ function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function getPresentationContentSize(): { width: number; height: number } | null {
|
||||
const pres = windows.get('presentation');
|
||||
if (!pres || pres.isDestroyed()) return null;
|
||||
const [width, height] = pres.getContentSize();
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function broadcastPresentationContentSize(): void {
|
||||
const size = getPresentationContentSize();
|
||||
if (!size) return;
|
||||
for (const w of BrowserWindow.getAllWindows()) {
|
||||
if (w.isDestroyed() || w.webContents.isDestroyed()) continue;
|
||||
try {
|
||||
w.webContents.send(ipcChannels.windows.presentationContentSizeChanged, size);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindPresentationContentSizeTracking(win: BrowserWindow): void {
|
||||
const emit = () => broadcastPresentationContentSize();
|
||||
win.on('resize', emit);
|
||||
win.on('enter-full-screen', emit);
|
||||
win.on('leave-full-screen', emit);
|
||||
win.webContents.once('did-finish-load', emit);
|
||||
}
|
||||
|
||||
function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.windows.sceneDescriptionContent, { html });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
|
||||
export function markAppQuitting(): void {
|
||||
appQuitting = true;
|
||||
}
|
||||
|
||||
/** Точечная рассылка в известные окна приложения (без splash / чужих BrowserWindow). */
|
||||
export function sendToAppWindows(
|
||||
channel: string,
|
||||
payload: unknown,
|
||||
kinds: readonly WindowKind[] = SESSION_STATE_WINDOW_KINDS,
|
||||
): void {
|
||||
for (const kind of kinds) {
|
||||
const win = windows.get(kind);
|
||||
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) continue;
|
||||
try {
|
||||
win.webContents.send(channel, payload);
|
||||
} catch {
|
||||
/* окно могло закрыться между проверкой и send */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
||||
if (win.isDestroyed()) return;
|
||||
win.setTitle(windowChromeTitle(kind as AppWindowKind, resolveChromeLocaleTag()));
|
||||
}
|
||||
|
||||
export function syncAllWindowChromeTitles(localeTag: string): void {
|
||||
chromeLocaleTagOverride = localeTag.trim() || null;
|
||||
for (const [kind, win] of windows.entries()) {
|
||||
applyWindowChromeTitle(win, kind);
|
||||
}
|
||||
}
|
||||
|
||||
function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
||||
const apply = () => applyWindowChromeTitle(win, kind);
|
||||
apply();
|
||||
win.webContents.on('page-title-updated', (event) => {
|
||||
event.preventDefault();
|
||||
apply();
|
||||
});
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
apply();
|
||||
});
|
||||
}
|
||||
|
||||
/** Закрыть окна сессии, кроме редактора и его дочерних окон. */
|
||||
function closePlaySessionAuxiliaryWindows(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
}
|
||||
|
||||
function closePresentationWindow(): void {
|
||||
const pres = windows.get('presentation');
|
||||
if (pres && !pres.isDestroyed()) {
|
||||
pres.close();
|
||||
}
|
||||
}
|
||||
|
||||
function closeControlWindow(): void {
|
||||
const ctrl = windows.get('control');
|
||||
if (ctrl && !ctrl.isDestroyed()) {
|
||||
ctrl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function quitAppFromEditorClose(): void {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
@@ -50,6 +193,27 @@ function getRendererHtmlPath(kind: WindowKind): string {
|
||||
return path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
|
||||
}
|
||||
|
||||
function pageNameForKind(kind: WindowKind): string {
|
||||
switch (kind) {
|
||||
case 'editor':
|
||||
return 'editor.html';
|
||||
case 'presentation':
|
||||
return 'presentation.html';
|
||||
case 'control':
|
||||
return 'control.html';
|
||||
case 'sceneDescription':
|
||||
return 'sceneDescription.html';
|
||||
case 'materials':
|
||||
return 'materials.html';
|
||||
case 'npcsEditor':
|
||||
return 'npcsEditor.html';
|
||||
case 'sceneEditor':
|
||||
return 'sceneEditor.html';
|
||||
case 'npcs':
|
||||
return 'npcs.html';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран;
|
||||
* `loadFile` корректно открывает HTML из asar и на Windows, и на macOS.
|
||||
@@ -57,9 +221,7 @@ function getRendererHtmlPath(kind: WindowKind): string {
|
||||
function loadWindowPage(win: BrowserWindow, kind: WindowKind): void {
|
||||
const dev = process.env.VITE_DEV_SERVER_URL;
|
||||
if (dev) {
|
||||
const page =
|
||||
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
|
||||
void win.loadURL(new URL(page, dev).toString());
|
||||
void win.loadURL(new URL(pageNameForKind(kind), dev).toString());
|
||||
return;
|
||||
}
|
||||
void win.loadFile(getRendererHtmlPath(kind));
|
||||
@@ -84,7 +246,7 @@ export function applyDockIconIfNeeded(): void {
|
||||
type CreateWindowOpts = {
|
||||
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
||||
parent?: BrowserWindow;
|
||||
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
|
||||
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
|
||||
deferVisibility?: boolean;
|
||||
};
|
||||
|
||||
@@ -111,12 +273,65 @@ function ensureWindowBecomesVisible(win: BrowserWindow): void {
|
||||
});
|
||||
}
|
||||
|
||||
function windowSizeForKind(kind: WindowKind): { width: number; height: number } {
|
||||
if (kind === 'editor') return { width: 1280, height: 800 };
|
||||
if (kind === 'control') return { width: 1200, height: 800 };
|
||||
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
||||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||||
return { width: 1280, height: 800 };
|
||||
}
|
||||
|
||||
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||||
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
|
||||
const deferShow = opts?.deferVisibility === true;
|
||||
const icon = loadBrandingWindowIcon();
|
||||
const size = windowSizeForKind(kind);
|
||||
const win = new BrowserWindow({
|
||||
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
|
||||
height: 800,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
autoHideMenuBar: true,
|
||||
...(kind === 'sceneDescription'
|
||||
? {
|
||||
minWidth: 520,
|
||||
minHeight: 420,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'materials'
|
||||
? {
|
||||
width: MATERIALS_WINDOW_WIDTH,
|
||||
height: MATERIALS_WINDOW_HEIGHT,
|
||||
minWidth: 260,
|
||||
maxWidth: 360,
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcsEditor'
|
||||
? {
|
||||
width: NPCS_EDITOR_WINDOW_WIDTH,
|
||||
height: NPCS_EDITOR_WINDOW_HEIGHT,
|
||||
minWidth: 1100,
|
||||
minHeight: 640,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'sceneEditor'
|
||||
? {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcs'
|
||||
? {
|
||||
width: NPCS_WINDOW_WIDTH,
|
||||
height: NPCS_WINDOW_HEIGHT,
|
||||
minWidth: 560,
|
||||
maxWidth: 900,
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
show: false,
|
||||
backgroundColor: '#09090B',
|
||||
...(icon ? { icon } : {}),
|
||||
@@ -140,17 +355,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
}
|
||||
}
|
||||
|
||||
bindWindowChromeTitle(win, kind);
|
||||
if (
|
||||
kind === 'sceneDescription' ||
|
||||
kind === 'materials' ||
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'sceneEditor' ||
|
||||
kind === 'npcs'
|
||||
) {
|
||||
win.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
||||
console.error(`[preload-error] ${preloadPath}:`, error);
|
||||
safeConsoleError(`[preload-error] ${preloadPath}:`, error);
|
||||
});
|
||||
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
||||
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
|
||||
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
|
||||
if (errorCode === -3) return;
|
||||
if (!isMainFrame) return;
|
||||
safeConsoleError(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
|
||||
});
|
||||
win.webContents.on('render-process-gone', (_event, details) => {
|
||||
console.error('[render-process-gone]', details.reason, details.exitCode);
|
||||
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
|
||||
});
|
||||
|
||||
if (!deferEditor) {
|
||||
if (!deferShow) {
|
||||
ensureWindowBecomesVisible(win);
|
||||
}
|
||||
loadWindowPage(win, kind);
|
||||
@@ -161,10 +390,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
quitAppFromEditorClose();
|
||||
});
|
||||
}
|
||||
if (kind === 'control') {
|
||||
win.on('close', () => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
closePresentationWindow();
|
||||
});
|
||||
}
|
||||
if (kind === 'presentation') {
|
||||
bindPresentationContentSizeTracking(win);
|
||||
win.on('close', () => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
closeControlWindow();
|
||||
});
|
||||
}
|
||||
win.on('closed', () => windows.delete(kind));
|
||||
win.on('closed', () => {
|
||||
if (kind !== 'presentation' && kind !== 'control') return;
|
||||
const open = windows.has('presentation') || windows.has('control');
|
||||
if (!open) {
|
||||
closingPlaySession = false;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
}
|
||||
broadcastMultiWindowStateChanged(open);
|
||||
});
|
||||
windows.set(kind, win);
|
||||
@@ -244,19 +494,260 @@ export function openMultiWindow() {
|
||||
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
|
||||
}
|
||||
broadcastMultiWindowStateChanged(true);
|
||||
broadcastPresentationContentSize();
|
||||
}
|
||||
|
||||
export function closeMultiWindow(): void {
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
const pres = windows.get('presentation');
|
||||
const ctrl = windows.get('control');
|
||||
if (pres) pres.close();
|
||||
if (ctrl) ctrl.close();
|
||||
if (pres && !pres.isDestroyed()) pres.close();
|
||||
if (ctrl && !ctrl.isDestroyed()) ctrl.close();
|
||||
if (!windows.has('presentation') && !windows.has('control')) {
|
||||
closingPlaySession = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isMultiWindowOpen(): boolean {
|
||||
return windows.has('presentation') || windows.has('control');
|
||||
}
|
||||
|
||||
export function closeSceneDescriptionWindow(): void {
|
||||
const win = windows.get('sceneDescription');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeMaterialsWindow(): void {
|
||||
const win = windows.get('materials');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsEditorWindow(): void {
|
||||
const win = windows.get('npcsEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeSceneEditorWindow(): void {
|
||||
const win = windows.get('sceneEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsWindow(): void {
|
||||
const win = windows.get('npcs');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function getSceneDescriptionContent(): string {
|
||||
return pendingSceneDescriptionHtml;
|
||||
}
|
||||
|
||||
/** Одно окно описания: переиспользовать существующее или создать новое. */
|
||||
export function openSceneDescriptionWindow(html: string): void {
|
||||
pendingSceneDescriptionHtml = html;
|
||||
const existing = windows.get('sceneDescription');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
sendSceneDescriptionContent(existing, html);
|
||||
return;
|
||||
}
|
||||
|
||||
// Держим поверх пульта/презентации, иначе окно уходит под полноэкранный экран просмотра.
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('sceneDescription', 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 + (dw - width) / 2),
|
||||
y: Math.round(y + (dh - height) / 2),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
sendSceneDescriptionContent(win, pendingSceneDescriptionHtml);
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Полоса материалов: фиксированная ширина плитки, переиспользование окна. */
|
||||
export function openMaterialsWindow(): void {
|
||||
const existing = windows.get('materials');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
const b = existing.getBounds();
|
||||
existing.setBounds({
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
width: MATERIALS_WINDOW_WIDTH,
|
||||
height: MATERIALS_WINDOW_HEIGHT,
|
||||
});
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('materials', 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 - 24)),
|
||||
y: Math.round(y + (dh - height) / 2),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||
const parent = windows.get('editor');
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
|
||||
export function warmNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) return;
|
||||
const parent = windows.get('editor');
|
||||
createWindow('npcsEditor', {
|
||||
...(parent ? { parent } : {}),
|
||||
deferVisibility: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||
export function openNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
positionNpcsEditorWindow(existing);
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', {
|
||||
...(parent ? { parent } : {}),
|
||||
deferVisibility: true,
|
||||
});
|
||||
positionNpcsEditorWindow(win);
|
||||
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор сцены: расстановка ловушек и легенды материалов. */
|
||||
export function openSceneEditorWindow(): void {
|
||||
const existing = windows.get('sceneEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('sceneEditor', 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 openNpcsWindow(): void {
|
||||
const existing = windows.get('npcs');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
const b = existing.getBounds();
|
||||
existing.setBounds({
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)),
|
||||
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
|
||||
});
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('npcs', 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 - 24)),
|
||||
y: Math.round(y + (dh - height) / 2),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function togglePresentationFullscreen(): boolean {
|
||||
const pres = windows.get('presentation');
|
||||
if (!pres) return false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { contextBridge } from 'electron';
|
||||
import { contextBridge, webUtils } from 'electron';
|
||||
|
||||
import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts';
|
||||
|
||||
@@ -10,9 +10,14 @@ export type DndApi = {
|
||||
payload: IpcInvokeMap[K]['req'],
|
||||
) => Promise<IpcInvokeMap[K]['res']>;
|
||||
on: <K extends keyof IpcEventMap>(channel: K, listener: (payload: IpcEventMap[K]) => void) => () => void;
|
||||
getPathForFile: (file: File) => string;
|
||||
};
|
||||
|
||||
const api: DndApi = { invoke, on };
|
||||
const api: DndApi = {
|
||||
invoke,
|
||||
on,
|
||||
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('dnd', api);
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<img class="logo" src="./app-window-icon.png" width="72" height="72" alt="" />
|
||||
<h1 class="title" data-boot-title>DNDGamePlayer</h1>
|
||||
<h1 class="title" data-boot-title>TTRPG Player</h1>
|
||||
<p class="subtitle">редактор и проигрыватель</p>
|
||||
<p class="version" data-boot-version></p>
|
||||
<p class="status" id="boot-status">Запуск…</p>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>DnD Player — Control</title>
|
||||
<title>TTRPG - Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.remote {
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-height: calc(100vh - 32px);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.remoteTitle {
|
||||
@@ -79,13 +85,114 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bookIcon {
|
||||
display: block;
|
||||
color: var(--text-muted-on-dark);
|
||||
}
|
||||
|
||||
.modalBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal-backdrop);
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: var(--color-scrim);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modalDialog {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.descriptionViewDialog {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 48px);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-weight: 900;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.modalClose {
|
||||
border: none;
|
||||
background: var(--panel2);
|
||||
color: var(--text2);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.descriptionViewBody {
|
||||
min-height: 360px;
|
||||
max-height: min(560px, calc(100vh - 200px));
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.descriptionViewProse {
|
||||
color: var(--text0);
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.descriptionViewEmpty {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.radiusRow {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 44px;
|
||||
grid-template-columns: 120px 1fr 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.effectsSoundRow {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.radiusLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
@@ -164,6 +271,7 @@
|
||||
|
||||
.historyTitle {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.emptyStory {
|
||||
@@ -195,9 +303,45 @@
|
||||
|
||||
.previewActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.npcTokenScale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.snapToGrid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.snapToGridLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npcTokenScaleLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npcTokenScaleRange {
|
||||
width: min(140px, 22vw);
|
||||
accent-color: var(--color-accent, #c9a227);
|
||||
}
|
||||
|
||||
.videoHint {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
@@ -249,7 +393,7 @@
|
||||
|
||||
.branchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -260,6 +404,8 @@
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.branchCardHeader {
|
||||
@@ -276,6 +422,63 @@
|
||||
|
||||
.branchName {
|
||||
font-weight: 900;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branchCardReturn {
|
||||
border-color: rgba(0, 120, 212, 0.45);
|
||||
}
|
||||
|
||||
.sideStoryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sideStoryTile {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.sideStoryTile:hover .sideStoryPreview {
|
||||
border-color: rgba(0, 120, 212, 0.55);
|
||||
}
|
||||
|
||||
.sideStoryPreview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--scene-tile-radius);
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--stroke);
|
||||
background: #0c0c0e;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sideStoryVideo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sideStoryPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0c0c0e;
|
||||
}
|
||||
|
||||
.sideStoryTitle {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.musicHeader {
|
||||
@@ -308,10 +511,51 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audioControls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
align-items: stretch;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.audioTransport {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.audioVolumeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audioVolumeIcon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audioVolumeIcon svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.audioVolume {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
accent-color: var(--accent-fill-solid);
|
||||
}
|
||||
|
||||
.scrubFill {
|
||||
@@ -371,3 +615,43 @@
|
||||
.audioScrubDefault {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: 41;
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background: var(--color-surface-menu, #1a1e28);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ctxItem {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text1, #e8eaef);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctxItem:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
+1689
-441
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './ControlApp.module.css';
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '0:00';
|
||||
const s = Math.floor(sec);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function clampAudioGain(v: number): number {
|
||||
if (!Number.isFinite(v)) return 1;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
function VolumeSpeakerIcon({ gain }: { gain: number }) {
|
||||
if (gain <= 0.001) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (gain < 0.5) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export type ControlAudioCardProps = {
|
||||
assetId: string;
|
||||
name: string;
|
||||
autoplay: boolean;
|
||||
loop: boolean;
|
||||
statusLabel: string;
|
||||
statusDetail?: string;
|
||||
extraBadge?: React.ReactNode;
|
||||
audioEl: HTMLAudioElement | null;
|
||||
initialGain: number;
|
||||
gainMap: Map<string, number>;
|
||||
playTitle: string;
|
||||
playLabel: string;
|
||||
pauseLabel: string;
|
||||
stopLabel: string;
|
||||
volumeLabel: string;
|
||||
modeAutoLabel: string;
|
||||
modeManualLabel: string;
|
||||
loopLabel: string;
|
||||
onceLabel: string;
|
||||
scrubSeekLabel: string;
|
||||
durationUnknownLabel: string;
|
||||
/** Редкий bump родителя (play/pause/error) — не для scrub. */
|
||||
onStatusChange: () => void;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Карточка трека: scrub/time обновляются локально (RAF → DOM), без ре-рендера всего ControlApp.
|
||||
*/
|
||||
export function ControlAudioCard({
|
||||
assetId,
|
||||
name,
|
||||
autoplay,
|
||||
loop,
|
||||
statusLabel,
|
||||
statusDetail,
|
||||
extraBadge,
|
||||
audioEl,
|
||||
initialGain,
|
||||
gainMap,
|
||||
playTitle,
|
||||
playLabel,
|
||||
pauseLabel,
|
||||
stopLabel,
|
||||
volumeLabel,
|
||||
modeAutoLabel,
|
||||
modeManualLabel,
|
||||
loopLabel,
|
||||
onceLabel,
|
||||
scrubSeekLabel,
|
||||
durationUnknownLabel,
|
||||
onStatusChange,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStop,
|
||||
}: ControlAudioCardProps) {
|
||||
const scrubRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrubFillRef = useRef<HTMLDivElement | null>(null);
|
||||
const curTimeRef = useRef<HTMLDivElement | null>(null);
|
||||
const durTimeRef = useRef<HTMLDivElement | null>(null);
|
||||
const [gainUi, setGainUi] = useState(() => clampAudioGain(initialGain));
|
||||
const onStatusChangeRef = useRef(onStatusChange);
|
||||
onStatusChangeRef.current = onStatusChange;
|
||||
|
||||
useEffect(() => {
|
||||
setGainUi(clampAudioGain(gainMap.get(assetId) ?? initialGain));
|
||||
}, [assetId, gainMap, initialGain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioEl) return;
|
||||
let raf = 0;
|
||||
|
||||
const paint = (): void => {
|
||||
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
|
||||
const cur = audioEl.currentTime && Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0;
|
||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||
if (scrubFillRef.current) {
|
||||
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||
}
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
|
||||
if (durTimeRef.current) durTimeRef.current.textContent = dur ? formatTime(dur) : '—:—';
|
||||
if (scrubRef.current) {
|
||||
scrubRef.current.setAttribute('aria-valuemin', '0');
|
||||
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
|
||||
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
|
||||
scrubRef.current.title = dur > 0 ? scrubSeekLabel : durationUnknownLabel;
|
||||
scrubRef.current.classList.toggle(styles.audioScrubPointer ?? 'audioScrubPointer', dur > 0);
|
||||
scrubRef.current.classList.toggle(styles.audioScrubDefault ?? 'audioScrubDefault', dur <= 0);
|
||||
}
|
||||
};
|
||||
|
||||
const stopLoop = (): void => {
|
||||
if (raf !== 0) {
|
||||
window.cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const loopPaint = (): void => {
|
||||
paint();
|
||||
if (!audioEl.paused) {
|
||||
raf = window.requestAnimationFrame(loopPaint);
|
||||
} else {
|
||||
raf = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const startLoop = (): void => {
|
||||
stopLoop();
|
||||
raf = window.requestAnimationFrame(loopPaint);
|
||||
};
|
||||
|
||||
const onPlayEv = (): void => {
|
||||
startLoop();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
const onPauseEv = (): void => {
|
||||
stopLoop();
|
||||
paint();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
const onEndedEv = (): void => {
|
||||
stopLoop();
|
||||
paint();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
const onMetaEv = (): void => {
|
||||
paint();
|
||||
onStatusChangeRef.current();
|
||||
};
|
||||
|
||||
audioEl.addEventListener('play', onPlayEv);
|
||||
audioEl.addEventListener('pause', onPauseEv);
|
||||
audioEl.addEventListener('ended', onEndedEv);
|
||||
audioEl.addEventListener('canplay', onMetaEv);
|
||||
audioEl.addEventListener('error', onMetaEv);
|
||||
paint();
|
||||
if (!audioEl.paused) startLoop();
|
||||
|
||||
return () => {
|
||||
stopLoop();
|
||||
audioEl.removeEventListener('play', onPlayEv);
|
||||
audioEl.removeEventListener('pause', onPauseEv);
|
||||
audioEl.removeEventListener('ended', onEndedEv);
|
||||
audioEl.removeEventListener('canplay', onMetaEv);
|
||||
audioEl.removeEventListener('error', onMetaEv);
|
||||
};
|
||||
}, [audioEl, durationUnknownLabel, scrubSeekLabel]);
|
||||
|
||||
const seekByClientX = (clientX: number): void => {
|
||||
if (!audioEl || !scrubRef.current) return;
|
||||
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
|
||||
if (!dur) return;
|
||||
const rect = scrubRef.current.getBoundingClientRect();
|
||||
const next = (clientX - rect.left) / Math.max(1, rect.width);
|
||||
audioEl.currentTime = Math.max(0, Math.min(dur, next * dur));
|
||||
const cur = audioEl.currentTime;
|
||||
const pct = Math.max(0, Math.min(1, cur / dur));
|
||||
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
|
||||
};
|
||||
|
||||
const applyGain = (v: number): void => {
|
||||
const g = clampAudioGain(v);
|
||||
gainMap.set(assetId, g);
|
||||
if (audioEl) {
|
||||
try {
|
||||
audioEl.volume = g;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setGainUi(g);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.audioCard}>
|
||||
<div className={styles.audioMeta}>
|
||||
<div className={styles.audioName}>{name}</div>
|
||||
<div className={styles.audioBadges}>
|
||||
<div>{autoplay ? modeAutoLabel : modeManualLabel}</div>
|
||||
<div>{loop ? loopLabel : onceLabel}</div>
|
||||
<div title={statusDetail}>{statusLabel}</div>
|
||||
{extraBadge}
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div
|
||||
ref={scrubRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
className={[styles.audioScrub, styles.audioScrubDefault].join(' ')}
|
||||
onKeyDown={(e) => {
|
||||
if (!audioEl) return;
|
||||
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
|
||||
if (!dur) return;
|
||||
if (e.key === 'ArrowLeft') audioEl.currentTime = Math.max(0, audioEl.currentTime - 5);
|
||||
if (e.key === 'ArrowRight') audioEl.currentTime = Math.min(dur, audioEl.currentTime + 5);
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(audioEl.currentTime);
|
||||
const pct = Math.max(0, Math.min(1, audioEl.currentTime / dur));
|
||||
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||
}}
|
||||
onClick={(e) => seekByClientX(e.clientX)}
|
||||
>
|
||||
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
|
||||
</div>
|
||||
<div className={styles.timeRow}>
|
||||
<div ref={curTimeRef}>0:00</div>
|
||||
<div ref={durTimeRef}>—:—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.audioControls}>
|
||||
<div className={styles.audioTransport}>
|
||||
<Button variant="primary" title={playTitle} ariaLabel={playLabel} onClick={onPlay}>
|
||||
▶
|
||||
</Button>
|
||||
<Button title={pauseLabel} ariaLabel={pauseLabel} onClick={onPause}>
|
||||
❚❚
|
||||
</Button>
|
||||
<Button
|
||||
title={stopLabel}
|
||||
ariaLabel={stopLabel}
|
||||
onClick={() => {
|
||||
onStop();
|
||||
if (scrubFillRef.current) scrubFillRef.current.style.width = '0%';
|
||||
if (curTimeRef.current) curTimeRef.current.textContent = '0:00';
|
||||
}}
|
||||
>
|
||||
■
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.audioVolumeRow}>
|
||||
<span className={styles.audioVolumeIcon} aria-hidden>
|
||||
<VolumeSpeakerIcon gain={gainUi} />
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={gainUi}
|
||||
disabled={!audioEl}
|
||||
className={styles.audioVolume}
|
||||
aria-label={volumeLabel}
|
||||
title={`${volumeLabel}: ${String(Math.round(gainUi * 100))}%`}
|
||||
onChange={(e) => applyGain(Number(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,8 @@
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
/* Above brush / traps layers so transport stays usable on video scenes. */
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.scrub {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneViewCamera } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
|
||||
@@ -12,6 +14,7 @@ import styles from './ControlScenePreview.module.css';
|
||||
type Props = {
|
||||
session: SessionState | null;
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
viewCamera?: SceneViewCamera | null;
|
||||
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
|
||||
};
|
||||
|
||||
@@ -23,7 +26,7 @@ function fmt(sec: number): string {
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
|
||||
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
|
||||
const { t } = useEditorI18n();
|
||||
const [vp, video] = useVideoPlaybackState();
|
||||
const scene =
|
||||
@@ -33,6 +36,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
const isVideo = scene?.previewAssetType === 'video';
|
||||
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
||||
const autostart = scene?.previewVideoAutostart ?? false;
|
||||
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const dur = useMemo(
|
||||
@@ -59,14 +63,18 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
if (!assetId) return;
|
||||
// `target.set` bumps revision and resets anchors; avoid firing on every render.
|
||||
if (vp?.targetAssetId === assetId) return;
|
||||
const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
|
||||
const prev = lastTargetRef.current;
|
||||
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
|
||||
return;
|
||||
}
|
||||
lastTargetRef.current = { sceneKey, assetId, autostart };
|
||||
void video.dispatch({
|
||||
kind: 'target.set',
|
||||
assetId,
|
||||
autostart,
|
||||
});
|
||||
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
|
||||
}, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
@@ -91,21 +99,27 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{url && scene?.previewAssetType === 'image' ? (
|
||||
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} />
|
||||
<RotatedImage
|
||||
url={url}
|
||||
rotationDeg={rot}
|
||||
mode="contain"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={onContentRectChange}
|
||||
/>
|
||||
) : url && isVideo ? (
|
||||
<video
|
||||
ref={(el) => {
|
||||
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el;
|
||||
}}
|
||||
className={styles.video}
|
||||
src={url}
|
||||
<ContainedVideo
|
||||
url={url}
|
||||
videoRef={videoRef}
|
||||
playsInline
|
||||
loop={Boolean(scene?.settings?.loopVideo)}
|
||||
preload="auto"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={onContentRectChange}
|
||||
onTimeUpdate={() => setTick((x) => x + 1)}
|
||||
onLoadedMetadata={() => setTick((x) => x + 1)}
|
||||
>
|
||||
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
|
||||
</video>
|
||||
</ContainedVideo>
|
||||
) : (
|
||||
<div className={styles.placeholder} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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));
|
||||
|
||||
/** Регресс: RAF в ControlApp бампил оба audio-tick на каждом кадре → полный ре-рендер пульта. */
|
||||
void test('ControlApp: нет per-frame RAF setState для аудио scrub', () => {
|
||||
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
|
||||
|
||||
assert.doesNotMatch(app, /\banyPlaying\b/);
|
||||
// Старый паттерн: RAF tick → оба set*AudioStateTick.
|
||||
assert.doesNotMatch(
|
||||
app,
|
||||
/const tick = \(\) => \{\s*setSceneAudioStateTick/,
|
||||
'корневой RAF-тик аудио удалён',
|
||||
);
|
||||
assert.doesNotMatch(app, /requestAnimationFrame\s*\(\s*tick\s*\)/);
|
||||
|
||||
assert.ok(app.includes('ControlAudioCard'));
|
||||
assert.ok(card.includes('requestAnimationFrame'), 'scrub крутится локально в карточке');
|
||||
assert.ok(card.includes('scrubFillRef'), 'прогресс пишется в DOM, не через setState корня');
|
||||
assert.ok(card.includes('setGainUi'), 'громкость обновляет только карточку');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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));
|
||||
|
||||
/** Регресс: scheduleDraftRepaint бампил draftFxTick → полный ре-рендер ControlApp на кадр штриха. */
|
||||
void test('ControlApp: draft кисти без корневого draftFxTick', () => {
|
||||
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
const pixi = fs.readFileSync(
|
||||
path.join(here, '../shared/effects/PxiEffectsOverlay.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.doesNotMatch(app, /\bdraftFxTick\b/);
|
||||
assert.doesNotMatch(app, /\bsetDraftFxTick\b/);
|
||||
assert.doesNotMatch(app, /\bfxMergedState\b/);
|
||||
|
||||
assert.ok(app.includes('scheduleDraftRepaint'));
|
||||
assert.ok(app.includes('pushDraftToPixi'));
|
||||
assert.ok(app.includes('effectsOverlayRef'));
|
||||
assert.match(app, /scheduleDraftRepaint[\s\S]*?pushDraftToPixi\(\)/);
|
||||
assert.doesNotMatch(
|
||||
app,
|
||||
/scheduleDraftRepaint[\s\S]*?setDraftFxTick/,
|
||||
'RAF draft не трогает React state корня',
|
||||
);
|
||||
|
||||
assert.ok(pixi.includes('PixiEffectsOverlayHandle'));
|
||||
assert.ok(pixi.includes('setDraft'));
|
||||
assert.ok(pixi.includes('mergeDraftState'));
|
||||
assert.ok(pixi.includes('forwardRef'));
|
||||
});
|
||||
@@ -18,6 +18,7 @@ void test('ControlApp: звук молнии (public/molniya.mp3)', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes('molniya.mp3'));
|
||||
assert.ok(src.includes('playLightningEffectSound'));
|
||||
assert.ok(src.includes('LIGHTNING_EFFECT_MS'));
|
||||
});
|
||||
|
||||
void test('ControlApp: звук заморозки (public/zamorozka.mp3)', () => {
|
||||
@@ -45,26 +46,88 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', (
|
||||
assert.ok(sfxSrc.includes('playbackRate'));
|
||||
});
|
||||
|
||||
void test('ControlApp: активация ловушки «яд» спавнит poisonCloud как на пульте эффектов', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes("trap?.type === 'poison'"));
|
||||
assert.ok(src.includes("type: 'poisonCloud'"));
|
||||
assert.ok(src.includes('trap_pc_'));
|
||||
assert.ok(src.includes('playPoisonCloudEffectSound(poisonLifeMs)'));
|
||||
});
|
||||
|
||||
void test('ControlApp: эффект «взрыв» + ловушка используют explosion webm/mp3', () => {
|
||||
const appSrc = readControlApp();
|
||||
const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8');
|
||||
assert.ok(appSrc.includes("title={t('control.explosion')}"));
|
||||
assert.ok(appSrc.includes("selectEffectTool('explosion')"));
|
||||
assert.ok(appSrc.includes("type: 'explosion'"));
|
||||
assert.ok(appSrc.includes('getExplosionEffectLifeMs'));
|
||||
assert.ok(appSrc.includes('playExplosionEffectSound'));
|
||||
assert.ok(appSrc.includes("trap?.type === 'explosion'"));
|
||||
assert.ok(appSrc.includes('trap_ex_'));
|
||||
assert.ok(sfxSrc.includes('explosion.mp3'));
|
||||
assert.ok(sfxSrc.includes('aerial-debris-smoke.webm'));
|
||||
assert.ok(appSrc.includes('ExplosionVideoOverlay'));
|
||||
const videoOverlaySrc = fs.readFileSync(
|
||||
path.join(here, '..', 'shared', 'effects', 'ExplosionVideoOverlay.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(videoOverlaySrc.includes('explosionEffectVideoUrl'));
|
||||
});
|
||||
|
||||
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes("t('control.instruments')"));
|
||||
assert.ok(src.includes("t('control.descriptionTool')"));
|
||||
assert.ok(src.includes("t('control.descriptionMissing')"));
|
||||
assert.ok(src.includes('openSceneDescription'));
|
||||
assert.ok(!src.includes('SceneDescriptionViewModal'));
|
||||
assert.ok(src.includes("t('control.effects')"));
|
||||
assert.ok(src.includes("t('control.tools')"));
|
||||
assert.ok(src.includes("t('control.fieldEffects')"));
|
||||
assert.ok(src.includes("t('control.actionEffects')"));
|
||||
assert.ok(src.includes("t('control.darknessControl')"));
|
||||
assert.ok(src.includes("t('control.explorerBrush')"));
|
||||
assert.ok(src.includes("t('control.closerBrush')"));
|
||||
assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
|
||||
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
|
||||
assert.ok(src.includes('SceneDarknessOverlay'));
|
||||
assert.ok(src.includes('useSceneDarknessState'));
|
||||
assert.ok(src.includes("t('control.sunbeam')"));
|
||||
assert.ok(src.includes("t('control.lightning')"));
|
||||
assert.ok(src.includes("title={t('control.water')}"));
|
||||
assert.ok(src.includes("title={t('control.fire')}"));
|
||||
assert.ok(src.includes("title={t('control.darkness')}"));
|
||||
assert.ok(src.includes("title={t('control.poisonCloud')}"));
|
||||
assert.ok(src.includes("title={t('control.fog')}"));
|
||||
assert.ok(src.includes("ariaLabel={t('control.fog')}"));
|
||||
assert.ok(src.includes('selectEffectTool'), 'повторный клик снимает инструмент');
|
||||
assert.ok(src.includes("tool.tool === next ? 'none' : next"));
|
||||
assert.ok(src.includes('iconOnly'));
|
||||
assert.ok(src.includes("title={t('control.clearEffects')}"));
|
||||
assert.ok(src.includes("ariaLabel={t('control.clearEffects')}"));
|
||||
assert.ok(src.includes('#e5484d'));
|
||||
const instruments = src.indexOf("t('control.instruments')");
|
||||
const fx = src.indexOf("t('control.effects')");
|
||||
const story = src.indexOf("t('control.storyLine')");
|
||||
assert.ok(
|
||||
instruments !== -1 && fx !== -1 && instruments < fx,
|
||||
'Блок инструментов должен быть выше эффектов',
|
||||
);
|
||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
||||
});
|
||||
|
||||
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
assert.ok(src.includes('useTokenGridSnapSession'));
|
||||
assert.ok(src.includes('snapNormToGridCell'));
|
||||
assert.ok(src.includes('data-testid="token-grid-snap"'));
|
||||
assert.ok(src.includes("t('control.snapTokensToGrid')"));
|
||||
assert.ok(src.includes('snapAllTokensToGrid'));
|
||||
assert.ok(src.includes('snapNorm={snapNormActive}'));
|
||||
assert.ok(css.includes('.snapToGrid'));
|
||||
});
|
||||
|
||||
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
@@ -76,6 +139,32 @@ void test('ControlApp: сюжетная линия — колонка сверх
|
||||
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||
});
|
||||
|
||||
void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(!src.includes('suppressNextHistoryPushRef'));
|
||||
assert.ok(!src.includes('arr.includes(cur)'));
|
||||
assert.ok(src.includes('historyRef.current = [...arr, cur]'));
|
||||
});
|
||||
|
||||
void test('ControlApp: текущая сцена в истории — последнее вхождение', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes('history.lastIndexOf(currentGraphNodeId)'));
|
||||
assert.ok(src.includes('idx === currentHistoryIdx'));
|
||||
});
|
||||
|
||||
void test('ControlApp: сюжетная линия не меняет граф и сцены проекта', () => {
|
||||
const src = readControlApp();
|
||||
const story = src.indexOf("t('control.storyLine')");
|
||||
assert.ok(story !== -1);
|
||||
const tail = src.slice(story);
|
||||
assert.ok(!tail.includes('addSceneGraphNode'));
|
||||
assert.ok(!tail.includes('removeSceneGraphNode'));
|
||||
assert.ok(!tail.includes('addSceneGraphEdge'));
|
||||
assert.ok(!tail.includes('removeSceneGraphEdge'));
|
||||
assert.ok(!tail.includes('updateProject'));
|
||||
assert.ok(tail.includes('ipcChannels.project.setCurrentGraphNode'));
|
||||
});
|
||||
|
||||
void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
@@ -108,6 +197,55 @@ void test('ControlApp: музыка разделена на сцену и кам
|
||||
assert.match(src, /pause campaign\./i);
|
||||
});
|
||||
|
||||
void test('ControlApp: у каждой аудиозаписи есть регулятор громкости под транспортом', () => {
|
||||
const src = readControlApp();
|
||||
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
|
||||
const css = readControlAppCss();
|
||||
assert.ok(src.includes('ControlAudioCard'));
|
||||
assert.ok(src.includes("t('control.volume')"));
|
||||
assert.ok(src.includes('sceneAudioGainRef'));
|
||||
assert.ok(src.includes('campaignAudioGainRef'));
|
||||
assert.ok(src.includes('applyAudioGain'));
|
||||
assert.ok(card.includes('VolumeSpeakerIcon'));
|
||||
assert.ok(card.includes('styles.audioVolume'));
|
||||
assert.ok(card.includes('styles.audioVolumeRow'));
|
||||
assert.match(css, /\.audioControls[\s\S]*?flex-direction:\s*column/);
|
||||
assert.match(css, /\.audioVolumeRow\b/);
|
||||
assert.match(css, /\.audioVolumeIcon\b/);
|
||||
assert.match(css, /\.audioVolume\b/);
|
||||
});
|
||||
|
||||
void test('ControlApp: весь контент скроллится в окне, отступы сверху и снизу равны', () => {
|
||||
const css = readControlAppCss();
|
||||
assert.match(css, /\.page\s*\{[^}]*padding:\s*16px/s);
|
||||
assert.match(css, /\.page\s*\{[^}]*overflow:\s*auto/s);
|
||||
assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s);
|
||||
});
|
||||
|
||||
void test('ControlApp: вода/дождь гасят огонь; ambient огня/дождя + слайдер «Звук эффектов»', () => {
|
||||
const src = readControlApp();
|
||||
const fireSfx = fs.readFileSync(path.join(here, 'fireAmbientSfx.ts'), 'utf8');
|
||||
const rainSfx = fs.readFileSync(path.join(here, 'rainAmbientSfx.ts'), 'utf8');
|
||||
const i18n = fs.readFileSync(path.join(here, '../editor/i18n/editorMessages.ts'), 'utf8');
|
||||
|
||||
assert.ok(src.includes('extinguishFireAlong'));
|
||||
assert.ok(src.includes("types: ['fire']"));
|
||||
assert.ok(src.includes("hitMode: 'brush'"));
|
||||
assert.ok(src.includes('effectsSoundRow'));
|
||||
assert.ok(src.includes('setFireAmbientActive'));
|
||||
assert.ok(src.includes('setRainAmbientActive'));
|
||||
assert.ok(src.includes('hasFireOnScene'));
|
||||
assert.ok(src.includes('hasRainOnScene'));
|
||||
assert.ok(src.includes("t('control.effectsSound')"));
|
||||
assert.ok(fireSfx.includes('fire-ambient.mp3'));
|
||||
assert.ok(rainSfx.includes('rain-ambient.mp3'));
|
||||
assert.ok(i18n.includes("'control.effectsSound': 'Звук эффектов'"));
|
||||
|
||||
const radius = src.indexOf("t('control.brushRadius')");
|
||||
const effectsSound = src.indexOf("t('control.effectsSound')");
|
||||
assert.ok(radius !== -1 && effectsSound !== -1 && radius < effectsSound);
|
||||
});
|
||||
|
||||
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
|
||||
const src = readControlApp();
|
||||
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Общая громкость звуков эффектов (огонь ambient + one-shot). 0…1. */
|
||||
|
||||
let effectsSfxGain = 0.75;
|
||||
|
||||
export function clampEffectsSfxGain(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0.75;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
export function getEffectsSfxGain(): number {
|
||||
return effectsSfxGain;
|
||||
}
|
||||
|
||||
export function setEffectsSfxGain(v: number): number {
|
||||
effectsSfxGain = clampEffectsSfxGain(v);
|
||||
return effectsSfxGain;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Звук и длительность эффекта «Взрыв» (`public/explosion.mp3` + WebM с альфой). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const EXPLOSION_SFX_VOLUME = 0.95;
|
||||
|
||||
/** Запас, если метаданные не прочитались (длина webm ~3.97 с). */
|
||||
const DEFAULT_EXPLOSION_LIFE_MS = 4200;
|
||||
|
||||
export function explosionEffectSoundUrl(): string {
|
||||
return new URL('explosion.mp3', window.location.href).href;
|
||||
}
|
||||
|
||||
/** VP8/VP9 WebM с `alpha_mode=1` — рендер через HTML `<video>`, не Pixi. */
|
||||
export function explosionEffectVideoUrl(): string {
|
||||
return new URL('vfx/explosion/aerial-debris-smoke.webm', window.location.href).href;
|
||||
}
|
||||
|
||||
let cachedExplosionSfxDurationMs: number | null = null;
|
||||
|
||||
/** Длительность трека в мс (кэш после первого чтения метаданных). */
|
||||
export function getExplosionSfxDurationMs(): Promise<number> {
|
||||
if (cachedExplosionSfxDurationMs !== null) {
|
||||
return Promise.resolve(cachedExplosionSfxDurationMs);
|
||||
}
|
||||
const url = explosionEffectSoundUrl();
|
||||
return new Promise((resolve) => {
|
||||
const a = new Audio();
|
||||
const done = (ms: number): void => {
|
||||
cachedExplosionSfxDurationMs = ms;
|
||||
a.removeAttribute('src');
|
||||
resolve(ms);
|
||||
};
|
||||
a.addEventListener('loadedmetadata', () => {
|
||||
const d = a.duration;
|
||||
done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_EXPLOSION_LIFE_MS);
|
||||
});
|
||||
a.addEventListener('error', () => done(DEFAULT_EXPLOSION_LIFE_MS));
|
||||
a.src = url;
|
||||
a.load();
|
||||
});
|
||||
}
|
||||
|
||||
/** Длительность визуала ≈ max(звук, webm), с разумными пределами. */
|
||||
export async function getExplosionEffectLifeMs(): Promise<number> {
|
||||
const sfxMs = await getExplosionSfxDurationMs();
|
||||
const raw = Math.max(sfxMs, DEFAULT_EXPLOSION_LIFE_MS);
|
||||
return Math.min(60_000, Math.max(600, raw));
|
||||
}
|
||||
|
||||
export async function playExplosionEffectSound(lifeMs: number): Promise<void> {
|
||||
try {
|
||||
const rawMs = await getExplosionSfxDurationMs();
|
||||
const target = Math.max(200, lifeMs);
|
||||
const rate = Math.max(0.25, Math.min(4, rawMs / target));
|
||||
const el = new Audio(explosionEffectSoundUrl());
|
||||
el.volume = Math.max(0, Math.min(1, EXPLOSION_SFX_VOLUME * getEffectsSfxGain()));
|
||||
el.playbackRate = rate;
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Фоновый звук горения при наличии огня на сцене (`public/fire-ambient.mp3`). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const FIRE_AMBIENT_BASE_VOLUME = 0.72;
|
||||
|
||||
export function fireAmbientSoundUrl(): string {
|
||||
return new URL('fire-ambient.mp3', window.location.href).href;
|
||||
}
|
||||
|
||||
let ambientEl: HTMLAudioElement | null = null;
|
||||
|
||||
function ensureAmbientEl(): HTMLAudioElement {
|
||||
if (ambientEl) return ambientEl;
|
||||
const el = new Audio(fireAmbientSoundUrl());
|
||||
el.loop = true;
|
||||
el.preload = 'auto';
|
||||
ambientEl = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
function applyVolume(el: HTMLAudioElement): void {
|
||||
el.volume = Math.max(0, Math.min(1, FIRE_AMBIENT_BASE_VOLUME * getEffectsSfxGain()));
|
||||
}
|
||||
|
||||
/** Включить/выключить loop горения. */
|
||||
export function setFireAmbientActive(active: boolean): void {
|
||||
try {
|
||||
const el = ensureAmbientEl();
|
||||
applyVolume(el);
|
||||
if (active) {
|
||||
if (el.paused) void el.play().catch(() => undefined);
|
||||
} else if (!el.paused) {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Обновить громкость, если ambient уже играет. */
|
||||
export function syncFireAmbientVolume(): void {
|
||||
if (!ambientEl) return;
|
||||
try {
|
||||
applyVolume(ambientEl);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
/** Звук и длительность эффекта «Заморозка» (`public/zamorozka.mp3`). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const DEFAULT_FREEZE_LIFE_MS = 820;
|
||||
|
||||
const FREEZE_SFX_BASE_VOLUME = 0.88;
|
||||
/** На 25% тише базовой громкости эффекта. */
|
||||
/** На 25% тише базовой громкости эффекта (до множителя «Звук эффектов»). */
|
||||
export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75;
|
||||
|
||||
export function freezeEffectSoundUrl(): string {
|
||||
@@ -44,7 +46,7 @@ export async function getFreezeEffectLifeMs(): Promise<number> {
|
||||
export function playFreezeEffectSound(): void {
|
||||
try {
|
||||
const el = new Audio(freezeEffectSoundUrl());
|
||||
el.volume = FREEZE_SFX_VOLUME;
|
||||
el.volume = Math.max(0, Math.min(1, FREEZE_SFX_VOLUME * getEffectsSfxGain()));
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { ControlApp } from './ControlApp';
|
||||
|
||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<ControlApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Пульт">
|
||||
<EditorI18nProvider>
|
||||
<ControlApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const POISON_CLOUD_SFX_VOLUME = 0.92;
|
||||
|
||||
/** Запас, если метаданные не прочитались. */
|
||||
@@ -50,7 +52,7 @@ export async function playPoisonCloudEffectSound(lifeMs: number): Promise<void>
|
||||
const target = Math.max(200, lifeMs);
|
||||
const rate = Math.max(0.25, Math.min(4, rawMs / target));
|
||||
const el = new Audio(poisonCloudEffectSoundUrl());
|
||||
el.volume = POISON_CLOUD_SFX_VOLUME;
|
||||
el.volume = Math.max(0, Math.min(1, POISON_CLOUD_SFX_VOLUME * getEffectsSfxGain()));
|
||||
el.playbackRate = rate;
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Фоновый звук дождя при наличии дождя на сцене (`public/rain-ambient.mp3`). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const RAIN_AMBIENT_BASE_VOLUME = 0.7;
|
||||
|
||||
export function rainAmbientSoundUrl(): string {
|
||||
return new URL('rain-ambient.mp3', window.location.href).href;
|
||||
}
|
||||
|
||||
let ambientEl: HTMLAudioElement | null = null;
|
||||
|
||||
function ensureAmbientEl(): HTMLAudioElement {
|
||||
if (ambientEl) return ambientEl;
|
||||
const el = new Audio(rainAmbientSoundUrl());
|
||||
el.loop = true;
|
||||
el.preload = 'auto';
|
||||
ambientEl = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
function applyVolume(el: HTMLAudioElement): void {
|
||||
el.volume = Math.max(0, Math.min(1, RAIN_AMBIENT_BASE_VOLUME * getEffectsSfxGain()));
|
||||
}
|
||||
|
||||
/** Включить/выключить loop дождя. */
|
||||
export function setRainAmbientActive(active: boolean): void {
|
||||
try {
|
||||
const el = ensureAmbientEl();
|
||||
applyVolume(el);
|
||||
if (active) {
|
||||
if (el.paused) void el.play().catch(() => undefined);
|
||||
} else if (!el.paused) {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Обновить громкость, если ambient уже играет. */
|
||||
export function syncRainAmbientVolume(): void {
|
||||
if (!ambientEl) return;
|
||||
try {
|
||||
applyVolume(ambientEl);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Звук эффекта «Луч света» (`public/luch_sveta.mp3`). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const SUNBEAM_SFX_VOLUME = 0.88;
|
||||
/** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */
|
||||
export const SUNBEAM_PLAYBACK_RATE = 1.5;
|
||||
@@ -47,7 +49,7 @@ export async function getSunbeamEffectLifeMs(): Promise<number> {
|
||||
export function playSunbeamEffectSound(): void {
|
||||
try {
|
||||
const el = new Audio(sunbeamEffectSoundUrl());
|
||||
el.volume = SUNBEAM_SFX_VOLUME;
|
||||
el.volume = Math.max(0, Math.min(1, SUNBEAM_SFX_VOLUME * getEffectsSfxGain()));
|
||||
el.playbackRate = SUNBEAM_PLAYBACK_RATE;
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>DnD Player — Editor</title>
|
||||
<title>TTRPG - Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -59,15 +59,89 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.splitRun {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-fill-solid);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.splitRun:disabled,
|
||||
.splitRun[aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.splitRunMain,
|
||||
.splitRunChevron {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.splitRunMain {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.splitRunMain:hover:not(:disabled),
|
||||
.splitRunChevron:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent-fill-solid) 72%, white);
|
||||
}
|
||||
|
||||
.splitRunMain:active:not(:disabled),
|
||||
.splitRunChevron:active:not(:disabled),
|
||||
.splitRunChevronOpen {
|
||||
background: color-mix(in srgb, var(--accent-fill-solid) 78%, black);
|
||||
}
|
||||
|
||||
.splitRunDivider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.splitRunChevron {
|
||||
width: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.splitRunChevronSvg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editorSidebar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--stroke);
|
||||
background: var(--editor-column-bg);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.editorSidebarDragOver {
|
||||
border-right-color: var(--accent);
|
||||
background: var(--accent-fill-soft);
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.editorGraphHost {
|
||||
@@ -94,25 +168,62 @@
|
||||
.gridTools {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.gridTools > * {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gridTools button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spacer14 {
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.spacer18 {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.gamePropsButtons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gamePropsButtons > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gamePropsButtons button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebarScroll {
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.sceneListGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
justify-items: stretch;
|
||||
@@ -131,7 +242,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
/* Выше модалок (20001), чтобы прогресс импорта не уходил под диалог выбора. */
|
||||
z-index: 30000;
|
||||
}
|
||||
|
||||
.editorLockOverlay {
|
||||
@@ -169,15 +281,19 @@
|
||||
.progressModal {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
padding: 16px 16px 20px;
|
||||
background: rgba(25, 28, 38, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.progressTitle {
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
@@ -186,6 +302,7 @@
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
@@ -195,11 +312,13 @@
|
||||
}
|
||||
|
||||
.progressMeta {
|
||||
margin-top: 10px;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
opacity: 0.9;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inspectorTitle {
|
||||
@@ -208,6 +327,18 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.projectNameLabel {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspectorScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -219,6 +350,11 @@
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.noticeMessage {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.fileMenu {
|
||||
position: fixed;
|
||||
min-width: 220px;
|
||||
@@ -243,6 +379,17 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.fileMenuItemDanger {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.fileMenuSubHost {
|
||||
position: relative;
|
||||
}
|
||||
@@ -289,6 +436,10 @@
|
||||
transform: translate(-50%, -50%);
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
@@ -296,6 +447,7 @@
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
@@ -325,6 +477,17 @@
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldGroup {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldGroupSpaced {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
@@ -338,17 +501,6 @@
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.selectInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
color: var(--text0);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.rowFlex {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -358,8 +510,13 @@
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.licenseBlockTitle {
|
||||
@@ -393,6 +550,153 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.instructionsDialog {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(920px, calc(100vw - 32px));
|
||||
max-height: min(86vh, 720px);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.instructionsLayout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 220px;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
max-height: min(62vh, 560px);
|
||||
}
|
||||
|
||||
.instructionsContent {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
.instructionsContentTitle {
|
||||
font-weight: 800;
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.instructionsParagraph {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.instructionsParagraph:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.instructionsInlineLink {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent, #a78bfa);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.instructionsInlineLink:hover {
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.instructionsNav {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--panel2);
|
||||
}
|
||||
|
||||
.instructionsNavItem {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.instructionsNavItem:hover {
|
||||
background: var(--scene-list-hover-bg);
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.instructionsNavItemActive {
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
color: var(--text0);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.aboutBody {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aboutAppName {
|
||||
font-weight: 900;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.aboutTagline {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.aboutParagraph {
|
||||
margin: 0;
|
||||
color: var(--text1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.aboutMetaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 14px;
|
||||
align-items: baseline;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.aboutLink {
|
||||
color: var(--color-accent, #a78bfa);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.aboutLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fileSuffix {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
@@ -414,6 +718,23 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.projectPickerForm > * {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.projectPickerForm > *:has(button),
|
||||
.projectPickerForm > span {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.projectPickerForm button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spacer6 {
|
||||
height: 6px;
|
||||
}
|
||||
@@ -452,6 +773,16 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.projectCardBodyOpening {
|
||||
cursor: wait;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.projectCardBodyDisabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.projectCardMenuBtn {
|
||||
flex-shrink: 0;
|
||||
margin: -4px -4px 0 0;
|
||||
@@ -490,6 +821,19 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.labelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.labelRow .labelSm {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.spacer8 {
|
||||
height: 8px;
|
||||
}
|
||||
@@ -505,6 +849,75 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.descriptionEmpty {
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--stroke-2);
|
||||
background: var(--color-overlay-dark-2);
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.descriptionPreview {
|
||||
max-height: 4.6em;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
color: var(--text1);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.descriptionPreview :global(p),
|
||||
.descriptionPreview :global(h2),
|
||||
.descriptionPreview :global(h3),
|
||||
.descriptionPreview :global(ul),
|
||||
.descriptionPreview :global(ol),
|
||||
.descriptionPreview :global(blockquote),
|
||||
.descriptionPreview :global(pre) {
|
||||
margin: 0 0 0.35em;
|
||||
}
|
||||
|
||||
.descriptionPreview :global(p:last-child),
|
||||
.descriptionPreview :global(h2:last-child),
|
||||
.descriptionPreview :global(h3:last-child),
|
||||
.descriptionPreview :global(ul:last-child),
|
||||
.descriptionPreview :global(ol:last-child),
|
||||
.descriptionPreview :global(blockquote:last-child),
|
||||
.descriptionPreview :global(pre:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.descriptionPreview :global(h2),
|
||||
.descriptionPreview :global(h3) {
|
||||
font-size: 1em;
|
||||
font-weight: 800;
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.descriptionPreview :global(ul),
|
||||
.descriptionPreview :global(ol) {
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.descriptionPreview :global(strong),
|
||||
.descriptionPreview :global(b) {
|
||||
font-weight: 800;
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.descriptionPreview :global(a) {
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
@@ -522,6 +935,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.previewBoxDragOver {
|
||||
border-color: var(--accent);
|
||||
border-style: dashed;
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.previewFill {
|
||||
@@ -586,6 +1008,33 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actionsRowHalf {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowVideoChecks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -615,6 +1064,47 @@
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.audioDropEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 96px;
|
||||
text-align: center;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.audioDropEmpty > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.audioDropDragOver {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.dropHintOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
border-radius: inherit;
|
||||
background: var(--accent-fill-soft-2);
|
||||
color: var(--accent);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.audioList {
|
||||
@@ -687,6 +1177,7 @@
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sceneCard:not(.sceneCardActive):hover {
|
||||
@@ -698,6 +1189,36 @@
|
||||
background: var(--scene-list-selected-bg);
|
||||
}
|
||||
|
||||
.sceneCardDragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.sceneCardDropBefore,
|
||||
.sceneCardDropAfter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sceneCardDropBefore::before,
|
||||
.sceneCardDropAfter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sceneCardDropBefore::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sceneCardDropAfter::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.sceneThumb {
|
||||
height: 92px;
|
||||
position: relative;
|
||||
@@ -712,6 +1233,13 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sceneThumbInner img,
|
||||
.sceneThumbInner video,
|
||||
.sceneThumbVideo {
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sceneThumbVideo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -737,21 +1265,23 @@
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sceneCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badgeCurrent {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sceneMenuBtn {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--panel2);
|
||||
border-radius: var(--radius-xs);
|
||||
@@ -765,6 +1295,11 @@
|
||||
|
||||
.sceneCardTitle {
|
||||
font-weight: 750;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menuBackdrop {
|
||||
@@ -802,3 +1337,77 @@
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modalDialogWide {
|
||||
width: 640px;
|
||||
}
|
||||
|
||||
.storylineChecklist {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.storylineCheck {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.storylineCheck input {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.storylineCheckDisabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.conflictList {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conflictRow {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.conflictRowTitle {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.conflictRowOptions {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.importFileRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reportList {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
+1542
-370
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Button, Select } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
|
||||
export type FoundryImportSourceSelection =
|
||||
| { kind: 'folder'; sourcePath: string }
|
||||
| { kind: 'archive'; sourcePath: string };
|
||||
|
||||
type FoundryImportModalProps = {
|
||||
open: boolean;
|
||||
pickSource: (
|
||||
mode: 'folder' | 'archive',
|
||||
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
|
||||
onClose: () => void;
|
||||
onImport: (selection: FoundryImportSourceSelection) => Promise<void>;
|
||||
};
|
||||
|
||||
export function FoundryImportModal({ open, pickSource, onClose, onImport }: FoundryImportModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [mode, setMode] = useState<'folder' | 'archive'>('folder');
|
||||
const [picked, setPicked] = useState<{ path: string; name: string } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode('folder');
|
||||
setPicked(null);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !submitting) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open, submitting]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!submitting) onClose();
|
||||
}}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('foundryImport.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!submitting) onClose();
|
||||
}}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.muted}>{t('foundryImport.hint')}</div>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('foundryImport.sourceType')}</div>
|
||||
<Select
|
||||
value={mode}
|
||||
disabled={submitting}
|
||||
ariaLabel={t('foundryImport.sourceType')}
|
||||
options={[
|
||||
{ value: 'folder', label: t('foundryImport.folder') },
|
||||
{ value: 'archive', label: t('foundryImport.archive') },
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setMode(next as 'folder' | 'archive');
|
||||
setPicked(null);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('foundryImport.source')}</div>
|
||||
<div className={styles.importFileRow}>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setError(null);
|
||||
const res = await pickSource(mode);
|
||||
if (res.canceled) return;
|
||||
const name = res.sourcePath.split(/[/\\]/).pop() ?? res.sourcePath;
|
||||
setPicked({ path: res.sourcePath, name });
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{mode === 'folder' ? t('foundryImport.chooseFolder') : t('foundryImport.chooseArchive')}
|
||||
</Button>
|
||||
<span className={styles.muted}>{picked ? picked.name : t('foundryImport.noSourceSelected')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!picked || submitting}
|
||||
onClick={() => {
|
||||
if (!picked) return;
|
||||
void (async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onImport({
|
||||
kind: mode,
|
||||
sourcePath: picked.path,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('foundryImport.import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.dialog {
|
||||
width: min(440px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.teamDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 10px 8px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
|
||||
import { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
|
||||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import launchStyles from './LaunchPlayersModal.module.css';
|
||||
|
||||
export { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
|
||||
|
||||
export function LaunchPlayersModal({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (playerIds: string[]) => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const { players, teams } = useAppPlayers();
|
||||
const [selectedPlayers, setSelectedPlayers] = useState<Set<string>>(() => new Set());
|
||||
const [selectedTeams, setSelectedTeams] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedPlayers(new Set());
|
||||
setSelectedTeams(new Set());
|
||||
}, [open]);
|
||||
|
||||
const resolvedIds = useMemo(
|
||||
() => resolveLaunchPlayerIds(players, selectedPlayers, selectedTeams),
|
||||
[players, selectedPlayers, selectedTeams],
|
||||
);
|
||||
|
||||
const standalonePlayers = useMemo(
|
||||
() =>
|
||||
players.filter((p) => !(p.teamId && selectedTeams.has(String(p.teamId)))),
|
||||
[players, selectedTeams],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const togglePlayer = (id: PlayerId) => {
|
||||
setSelectedPlayers((prev) => {
|
||||
const next = new Set(prev);
|
||||
const key = String(id);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTeam = (id: PlayerTeamId) => {
|
||||
const key = String(id);
|
||||
setSelectedTeams((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
// Участники выбранной команды не показываются в «Игроки» — снимаем их индивидуальный выбор.
|
||||
setSelectedPlayers((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set(prev);
|
||||
let changed = false;
|
||||
for (const p of players) {
|
||||
if (String(p.teamId) !== key) continue;
|
||||
if (next.delete(String(p.id))) changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
|
||||
<div
|
||||
className={[styles.modalDialog, launchStyles.dialog].join(' ')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="launch-players-modal"
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('top.runWithPlayersTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className={launchStyles.hint}>{t('top.runWithPlayersHint')}</p>
|
||||
{teams.length > 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersTeams')}</div>
|
||||
<div className={launchStyles.list}>
|
||||
{teams.map((team: AppPlayerTeam) => {
|
||||
const count = players.filter((p) => p.teamId === team.id).length;
|
||||
return (
|
||||
<label key={team.id} className={launchStyles.row}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTeams.has(String(team.id))}
|
||||
onChange={() => toggleTeam(team.id)}
|
||||
data-testid={`launch-team-${team.id}`}
|
||||
/>
|
||||
<span className={launchStyles.teamDot} style={{ background: team.color }} />
|
||||
<span>
|
||||
{team.name} ({count})
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{standalonePlayers.length > 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersPlayers')}</div>
|
||||
<div className={launchStyles.list}>
|
||||
{standalonePlayers.map((player: AppPlayer) => (
|
||||
<label key={player.id} className={launchStyles.row}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPlayers.has(String(player.id))}
|
||||
onChange={() => togglePlayer(player.id)}
|
||||
data-testid={`launch-player-${player.id}`}
|
||||
/>
|
||||
<span>{player.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : players.length === 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.empty}>{t('top.runWithPlayersEmpty')}</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={resolvedIds.length === 0}
|
||||
data-testid="launch-players-confirm"
|
||||
onClick={() => onConfirm(resolvedIds)}
|
||||
>
|
||||
{t('top.runWithPlayersConfirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
.legendBlock {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legendBlockLarge {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legendHeadRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.legendHeadRow .row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.map {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 280px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--stroke, #333);
|
||||
background: #0a0b0e;
|
||||
}
|
||||
|
||||
.mapLarge {
|
||||
max-height: none;
|
||||
height: min(48vh, 440px);
|
||||
min-height: 260px;
|
||||
flex: 0 0 auto;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.mapIdle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.mapEmpty {
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mapImg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: inherit;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #1e3a5f;
|
||||
border: 2px solid #f5c542;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mapHint {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 3;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mapHintZoom {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 3;
|
||||
font-size: 11px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.itemRow {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.itemRowActive {
|
||||
border-color: color-mix(in srgb, var(--color-accent, #c9a227) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--color-accent, #c9a227) 12%, transparent);
|
||||
}
|
||||
|
||||
.num {
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.itemDrag {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { MaterialLegend, MaterialLegendItem, MaterialLegendMarker } from '../../shared/types';
|
||||
import {
|
||||
asMaterialLegendItemId,
|
||||
asMaterialLegendMarkerId,
|
||||
EMPTY_MATERIAL_LEGEND,
|
||||
hostPointToLegendImageUv,
|
||||
legendImageUvToHostPoint,
|
||||
nextLegendNumber,
|
||||
} from '../../shared/types/materialLegend';
|
||||
import {
|
||||
DEFAULT_SCENE_VIEW_CAMERA,
|
||||
sceneViewPanBy,
|
||||
sceneViewZoomAt,
|
||||
type SceneViewCamera,
|
||||
} from '../../shared/types/sceneView';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
|
||||
import styles from './MaterialLegendEditor.module.css';
|
||||
|
||||
type Props = {
|
||||
legend: MaterialLegend | undefined;
|
||||
previewUrl: string | null;
|
||||
rotationDeg?: 0 | 90 | 180 | 270;
|
||||
/** Крупная карта (окно «Материалы»). */
|
||||
largeMap?: boolean;
|
||||
onChange: (legend: MaterialLegend) => void;
|
||||
onRotate?: () => void;
|
||||
rotateLabel?: string;
|
||||
};
|
||||
|
||||
type DragMode =
|
||||
| { kind: 'pan'; lastX: number; lastY: number }
|
||||
| { kind: 'marker'; id: string }
|
||||
| null;
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 180;
|
||||
|
||||
function rid(prefix: string): string {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function cloneLegend(legend: MaterialLegend | undefined): MaterialLegend {
|
||||
const base = legend ?? EMPTY_MATERIAL_LEGEND;
|
||||
return {
|
||||
enabled: base.enabled,
|
||||
items: base.items.map((it) => ({ ...it })),
|
||||
markers: base.markers.map((m) => ({ ...m })),
|
||||
};
|
||||
}
|
||||
|
||||
export function MaterialLegendEditor({
|
||||
legend,
|
||||
previewUrl,
|
||||
rotationDeg = 0,
|
||||
largeMap = false,
|
||||
onChange,
|
||||
onRotate,
|
||||
rotateLabel = 'Повернуть',
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend));
|
||||
const [activeItemId, setActiveItemId] = useState<string | null>(null);
|
||||
const [view, setView] = useState<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragMode>(null);
|
||||
const skipMapClickRef = useRef(false);
|
||||
const spaceDownRef = useRef(false);
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const saveTimerRef = useRef(0);
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
const flushPersist = () => {
|
||||
if (saveTimerRef.current) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = 0;
|
||||
}
|
||||
if (!dirtyRef.current) return;
|
||||
dirtyRef.current = false;
|
||||
onChangeRef.current(draftRef.current);
|
||||
};
|
||||
|
||||
const schedulePersist = () => {
|
||||
dirtyRef.current = true;
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveTimerRef.current = 0;
|
||||
dirtyRef.current = false;
|
||||
onChangeRef.current(draftRef.current);
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const applyDraft = (next: MaterialLegend) => {
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
schedulePersist();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
if (dirtyRef.current) onChangeRef.current(draftRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
flushPersist();
|
||||
setDraft(cloneLegend(legend));
|
||||
dirtyRef.current = false;
|
||||
setActiveItemId(null);
|
||||
setView(DEFAULT_SCENE_VIEW_CAMERA);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when switching material image
|
||||
}, [previewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeItemId && !draft.items.some((i) => i.id === activeItemId)) {
|
||||
setActiveItemId(null);
|
||||
}
|
||||
}, [activeItemId, draft.items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!largeMap) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') spaceDownRef.current = true;
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') spaceDownRef.current = false;
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, [largeMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!largeMap) return;
|
||||
const host = mapRef.current;
|
||||
if (!host) return;
|
||||
const nativeWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const cr = contentRect;
|
||||
if (!cr) {
|
||||
setView((v) => {
|
||||
const nextScale = Math.max(1, Math.min(8, v.scale * factor));
|
||||
if (nextScale <= 1.001) return { ...DEFAULT_SCENE_VIEW_CAMERA };
|
||||
return { ...v, scale: nextScale };
|
||||
});
|
||||
return;
|
||||
}
|
||||
const r = host.getBoundingClientRect();
|
||||
setView((v) => {
|
||||
const containW = cr.w / Math.max(1e-6, v.scale);
|
||||
const containH = cr.h / Math.max(1e-6, v.scale);
|
||||
return sceneViewZoomAt(v, {
|
||||
hostW: r.width,
|
||||
hostH: r.height,
|
||||
containW,
|
||||
containH,
|
||||
hostX: e.clientX - r.left,
|
||||
hostY: e.clientY - r.top,
|
||||
factor,
|
||||
});
|
||||
});
|
||||
};
|
||||
host.addEventListener('wheel', nativeWheel, { passive: false });
|
||||
return () => host.removeEventListener('wheel', nativeWheel);
|
||||
}, [contentRect, largeMap, previewUrl]);
|
||||
|
||||
const activeItem = useMemo(
|
||||
() => (activeItemId ? (draft.items.find((i) => i.id === activeItemId) ?? null) : null),
|
||||
[activeItemId, draft.items],
|
||||
);
|
||||
|
||||
const setEnabled = (enabled: boolean) => {
|
||||
applyDraft({ ...draftRef.current, enabled });
|
||||
};
|
||||
|
||||
const updateItems = (items: MaterialLegendItem[]) => {
|
||||
applyDraft({ ...draftRef.current, enabled: true, items });
|
||||
};
|
||||
|
||||
const updateMarkers = (markers: MaterialLegendMarker[]) => {
|
||||
applyDraft({ ...draftRef.current, enabled: true, markers });
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const cur = draftRef.current;
|
||||
const number = nextLegendNumber(cur.items);
|
||||
const item: MaterialLegendItem = {
|
||||
id: asMaterialLegendItemId(rid('li')),
|
||||
number,
|
||||
text: '',
|
||||
};
|
||||
updateItems([...cur.items, item]);
|
||||
setActiveItemId(item.id);
|
||||
};
|
||||
|
||||
const placeMarker = (nx: number, ny: number, number: number) => {
|
||||
updateMarkers([
|
||||
...draftRef.current.markers,
|
||||
{
|
||||
id: asMaterialLegendMarkerId(rid('lm')),
|
||||
number,
|
||||
nx,
|
||||
ny,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const toNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
|
||||
const el = mapRef.current;
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
// UV неповёрнутого изображения — тот же space, что у MaterialOverlay на пульте/презентации.
|
||||
if (largeMap && contentRect && contentRect.w > 1 && contentRect.h > 1) {
|
||||
return hostPointToLegendImageUv(
|
||||
clientX - r.left,
|
||||
clientY - r.top,
|
||||
contentRect,
|
||||
rotationDeg,
|
||||
);
|
||||
}
|
||||
const img = el.querySelector('img');
|
||||
if (!img) return null;
|
||||
const ir = img.getBoundingClientRect();
|
||||
if (ir.width < 1 || ir.height < 1) return null;
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (clientX - ir.left) / ir.width)),
|
||||
y: Math.max(0, Math.min(1, (clientY - ir.top) / ir.height)),
|
||||
};
|
||||
};
|
||||
|
||||
const onMapPointerDown = (e: React.PointerEvent) => {
|
||||
if (!largeMap) return;
|
||||
if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
|
||||
skipMapClickRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onMapPointerMove = (e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
if (d.kind === 'pan') {
|
||||
const cr = contentRect;
|
||||
if (!cr) return;
|
||||
const dx = e.clientX - d.lastX;
|
||||
const dy = e.clientY - d.lastY;
|
||||
d.lastX = e.clientX;
|
||||
d.lastY = e.clientY;
|
||||
setView((v) => {
|
||||
const containW = cr.w / Math.max(1e-6, v.scale);
|
||||
const containH = cr.h / Math.max(1e-6, v.scale);
|
||||
return sceneViewPanBy(v, { containW, containH, dx, dy });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'marker') {
|
||||
skipMapClickRef.current = true;
|
||||
const p = toNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
const nextMarkers = draftRef.current.markers.map((x) =>
|
||||
x.id === d.id ? { ...x, nx: p.x, ny: p.y } : x,
|
||||
);
|
||||
const next = { ...draftRef.current, enabled: true, markers: nextMarkers };
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
// persist только на pointerup
|
||||
}
|
||||
};
|
||||
|
||||
const onMapPointerUp = () => {
|
||||
const d = dragRef.current;
|
||||
if (d?.kind === 'marker') {
|
||||
schedulePersist();
|
||||
}
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
const onMapClick = (e: React.MouseEvent) => {
|
||||
if (skipMapClickRef.current) {
|
||||
skipMapClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (dragRef.current) return;
|
||||
if (!draft.enabled || !activeItem) return;
|
||||
const p = toNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
placeMarker(p.x, p.y, activeItem.number);
|
||||
};
|
||||
|
||||
const renderMarkers = () => {
|
||||
if (!draft.enabled) return null;
|
||||
return draft.markers.map((m) => {
|
||||
const style =
|
||||
largeMap && contentRect
|
||||
? (() => {
|
||||
const p = legendImageUvToHostPoint(m.nx, m.ny, contentRect, rotationDeg);
|
||||
return { left: p.x, top: p.y };
|
||||
})()
|
||||
: {
|
||||
left: `${String(m.nx * 100)}%`,
|
||||
top: `${String(m.ny * 100)}%`,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={styles.marker}
|
||||
style={style}
|
||||
onPointerDown={(e) => {
|
||||
if (!draft.enabled) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
dragRef.current = { kind: 'marker', id: m.id };
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
}}
|
||||
onPointerMove={onMapPointerMove}
|
||||
onPointerUp={(e) => {
|
||||
e.stopPropagation();
|
||||
if (dragRef.current?.kind === 'marker') skipMapClickRef.current = true;
|
||||
onMapPointerUp();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
skipMapClickRef.current = true;
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateMarkers(draftRef.current.markers.filter((x) => x.id !== m.id));
|
||||
}}
|
||||
title="Перетащите, чтобы переместить. ПКМ — удалить"
|
||||
>
|
||||
{m.number}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const mapBlock = previewUrl ? (
|
||||
<div
|
||||
ref={mapRef}
|
||||
className={[
|
||||
styles.map,
|
||||
largeMap ? styles.mapLarge : '',
|
||||
!draft.enabled ? styles.mapIdle : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onDragOver={(e) => {
|
||||
if (!draft.enabled) return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!draft.enabled) return;
|
||||
e.preventDefault();
|
||||
const num = Number(e.dataTransfer.getData('application/x-legend-number'));
|
||||
const p = toNorm(e.clientX, e.clientY);
|
||||
if (!p || !Number.isFinite(num) || num < 1) return;
|
||||
placeMarker(p.x, p.y, Math.round(num));
|
||||
}}
|
||||
onClick={onMapClick}
|
||||
onPointerDown={onMapPointerDown}
|
||||
onPointerMove={onMapPointerMove}
|
||||
onPointerUp={onMapPointerUp}
|
||||
onPointerCancel={onMapPointerUp}
|
||||
>
|
||||
{largeMap ? (
|
||||
<RotatedImage
|
||||
url={previewUrl}
|
||||
rotationDeg={rotationDeg}
|
||||
mode="contain"
|
||||
viewCamera={view}
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
) : (
|
||||
<img className={styles.mapImg} src={previewUrl} alt="" draggable={false} />
|
||||
)}
|
||||
{renderMarkers()}
|
||||
{draft.enabled && !activeItem ? (
|
||||
<div className={styles.mapHint}>Выберите строку легенды, чтобы ставить метки</div>
|
||||
) : null}
|
||||
{largeMap ? (
|
||||
<div className={styles.mapHintZoom}>Колесо — зум · СКМ / Space+ЛКМ — пан</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className={[styles.map, largeMap ? styles.mapLarge : '', styles.mapIdle].filter(Boolean).join(' ')}>
|
||||
<div className={styles.mapEmpty}>Нет изображения</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}>
|
||||
{largeMap ? mapBlock : null}
|
||||
|
||||
<div className={styles.legendHeadRow}>
|
||||
{onRotate ? (
|
||||
<Button onClick={onRotate}>{rotateLabel}</Button>
|
||||
) : null}
|
||||
<label className={styles.row}>
|
||||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>Легенда</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.enabled ? (
|
||||
<>
|
||||
<div className={styles.row}>
|
||||
<Button onClick={addItem}>Добавить пункт</Button>
|
||||
<span className={styles.hint}>
|
||||
Активная строка подсвечена — клик по картинке ставит метку; клик по метке — перемещение
|
||||
</span>
|
||||
</div>
|
||||
{draft.items.map((item) => {
|
||||
const active = item.id === activeItemId;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={[styles.itemRow, active ? styles.itemRowActive : ''].filter(Boolean).join(' ')}
|
||||
draggable
|
||||
onClick={() => setActiveItemId(item.id)}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-legend-number', String(item.number));
|
||||
setActiveItemId(item.id);
|
||||
}}
|
||||
>
|
||||
<div className={styles.num}>{item.number}</div>
|
||||
<Input
|
||||
value={item.text}
|
||||
onChange={(text) => {
|
||||
updateItems(
|
||||
draftRef.current.items.map((it) => (it.id === item.id ? { ...it, text } : it)),
|
||||
);
|
||||
}}
|
||||
placeholder="Описание…"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.itemDrag}
|
||||
title="Удалить"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const cur = draftRef.current;
|
||||
applyDraft({
|
||||
...cur,
|
||||
enabled: true,
|
||||
items: cur.items.filter((it) => it.id !== item.id),
|
||||
markers: cur.markers.filter((m) => m.number !== item.number),
|
||||
});
|
||||
if (activeItemId === item.id) setActiveItemId(null);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!largeMap ? mapBlock : null}
|
||||
</>
|
||||
) : largeMap ? null : (
|
||||
mapBlock
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { MaterialLegendEditor } from './MaterialLegendEditor';
|
||||
import matStyles from './MaterialsModals.module.css';
|
||||
|
||||
const DND_MATERIAL_ID_MIME = 'application/x-dnd-material-id';
|
||||
|
||||
export type MaterialsBrowserProps = {
|
||||
materials: ProjectMaterial[];
|
||||
/** editor: CRUD + ⋮; runtime: показ на сцене, без меню */
|
||||
mode: 'editor' | 'runtime';
|
||||
selectedId: MaterialId | null;
|
||||
onSelect: (id: MaterialId | null) => void;
|
||||
activeMaterialIds?: readonly MaterialId[];
|
||||
onAdd?: () => void;
|
||||
onEdit?: (material: ProjectMaterial) => void;
|
||||
onDelete?: (materialId: MaterialId) => Promise<void>;
|
||||
onReorder?: (materialIds: MaterialId[]) => Promise<void>;
|
||||
onRotate?: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
|
||||
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<void>;
|
||||
onTileActivate?: (materialId: MaterialId) => void;
|
||||
toolbar?: React.ReactNode;
|
||||
className?: string | undefined;
|
||||
/** Растянуть тело на всю высоту (окно Electron). */
|
||||
fillHeight?: boolean;
|
||||
/** Только колонка списка (без большого превью). */
|
||||
listOnly?: boolean;
|
||||
};
|
||||
|
||||
export function MaterialsBrowser({
|
||||
materials,
|
||||
mode,
|
||||
selectedId,
|
||||
onSelect,
|
||||
activeMaterialIds = [],
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReorder,
|
||||
onRotate,
|
||||
onLegendChange,
|
||||
onTileActivate,
|
||||
toolbar,
|
||||
className,
|
||||
fillHeight = false,
|
||||
listOnly = false,
|
||||
}: MaterialsBrowserProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [query, setQuery] = useState('');
|
||||
const [menuFor, setMenuFor] = useState<MaterialId | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [dragId, setDragId] = useState<MaterialId | null>(null);
|
||||
const [dropPlace, setDropPlace] = useState<{ id: MaterialId; place: 'before' | 'after' } | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<ProjectMaterial | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuFor) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const tgt = e.target as HTMLElement | null;
|
||||
if (!tgt) return;
|
||||
if (tgt.closest('[data-material-menu-root="1"]')) return;
|
||||
setMenuFor(null);
|
||||
setMenuPos(null);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [menuFor]);
|
||||
|
||||
const activeSet = useMemo(() => new Set(activeMaterialIds), [activeMaterialIds]);
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return materials;
|
||||
return materials.filter((m) => m.name.toLowerCase().includes(q));
|
||||
}, [materials, query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId && filtered.some((m) => m.id === selectedId)) return;
|
||||
const next = filtered[0]?.id ?? null;
|
||||
if (next !== selectedId) onSelect(next);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- sync selection to filtered list
|
||||
}, [filtered, selectedId]);
|
||||
|
||||
const selected = materials.find((m) => m.id === selectedId) ?? null;
|
||||
const selectedUrl = useAssetUrl(selected?.assetId ?? null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
matStyles.browserRoot,
|
||||
fillHeight ? matStyles.browserRootFill : '',
|
||||
listOnly ? matStyles.browserRootListOnly : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{toolbar ? <div className={matStyles.browserToolbar}>{toolbar}</div> : null}
|
||||
<div
|
||||
className={[
|
||||
matStyles.managerBody,
|
||||
fillHeight ? matStyles.managerBodyFill : '',
|
||||
listOnly ? matStyles.managerBodyListOnly : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={matStyles.side}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('materials.search')} />
|
||||
{mode === 'editor' && onAdd ? (
|
||||
<div className={matStyles.sideAddBtn}>
|
||||
<Button variant="primary" onClick={onAdd}>
|
||||
{t('materials.add')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={matStyles.list}>
|
||||
{filtered.map((m) => (
|
||||
<MaterialTile
|
||||
key={m.id}
|
||||
material={m}
|
||||
selected={m.id === selectedId}
|
||||
active={activeSet.has(m.id)}
|
||||
showMenu={mode === 'editor'}
|
||||
dragging={dragId === m.id}
|
||||
dropPlace={dropPlace?.id === m.id ? dropPlace.place : null}
|
||||
reorderEnabled={mode === 'editor' && Boolean(onReorder)}
|
||||
onSelect={() => {
|
||||
onSelect(m.id);
|
||||
if (mode === 'runtime' && onTileActivate) onTileActivate(m.id);
|
||||
}}
|
||||
onMenu={(e) => {
|
||||
if (mode !== 'editor') return;
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const menuW = 180;
|
||||
const menuH = 88;
|
||||
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
|
||||
const top =
|
||||
r.bottom + 8 + menuH > window.innerHeight - 8
|
||||
? Math.max(8, r.top - menuH - 8)
|
||||
: r.bottom + 8;
|
||||
setMenuPos({ left, top });
|
||||
setMenuFor((cur) => (cur === m.id ? null : m.id));
|
||||
}}
|
||||
onDragStart={() => setDragId(m.id)}
|
||||
onDragEnd={() => {
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
}}
|
||||
onDragOver={(place) => {
|
||||
if (!dragId || dragId === m.id) {
|
||||
setDropPlace(null);
|
||||
return;
|
||||
}
|
||||
setDropPlace({ id: m.id, place });
|
||||
}}
|
||||
onDropReorder={async () => {
|
||||
if (!onReorder || !dragId || !dropPlace || dragId === dropPlace.id) return;
|
||||
const ids = materials.map((x) => x.id);
|
||||
const from = ids.indexOf(dragId);
|
||||
if (from < 0) return;
|
||||
ids.splice(from, 1);
|
||||
let to = ids.indexOf(dropPlace.id);
|
||||
if (to < 0) return;
|
||||
if (dropPlace.place === 'after') to += 1;
|
||||
ids.splice(to, 0, dragId);
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
await onReorder(ids);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{materials.length === 0 ? <div className={styles.muted}>{t('materials.empty')}</div> : null}
|
||||
{materials.length > 0 && filtered.length === 0 ? (
|
||||
<div className={styles.muted}>{t('materials.searchEmpty')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!listOnly ? (
|
||||
<div
|
||||
className={[
|
||||
matStyles.previewColumn,
|
||||
onLegendChange ? matStyles.previewColumnLegend : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{selected && selectedUrl && onLegendChange ? (
|
||||
<div className={matStyles.previewLegendScroll}>
|
||||
<MaterialLegendEditor
|
||||
key={selected.id}
|
||||
largeMap
|
||||
legend={selected.legend}
|
||||
previewUrl={selectedUrl}
|
||||
rotationDeg={selected.rotationDeg ?? 0}
|
||||
rotateLabel={t('scene.rotate')}
|
||||
onRotate={
|
||||
onRotate
|
||||
? () => {
|
||||
const cur = selected.rotationDeg ?? 0;
|
||||
const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
onRotate(selected.id, next);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={(next) => {
|
||||
void onLegendChange(selected.id, next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={matStyles.previewPane}>
|
||||
{selected && selectedUrl ? (
|
||||
<div className={matStyles.previewLargeHost}>
|
||||
<RotatedImage
|
||||
url={selectedUrl}
|
||||
rotationDeg={selected.rotationDeg ?? 0}
|
||||
mode="contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={matStyles.previewEmpty}>{t('materials.addPrompt')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selected && onRotate && !onLegendChange ? (
|
||||
<div className={matStyles.previewActions}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const cur = selected.rotationDeg ?? 0;
|
||||
const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
onRotate(selected.id, next);
|
||||
}}
|
||||
>
|
||||
{t('scene.rotate')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mode === 'editor' && menuFor && menuPos
|
||||
? createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
data-material-menu-root="1"
|
||||
className={styles.fileMenu}
|
||||
style={{ left: menuPos.left, top: menuPos.top }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
onClick={() => {
|
||||
const mat = materials.find((x) => x.id === menuFor);
|
||||
setMenuFor(null);
|
||||
setMenuPos(null);
|
||||
if (mat && onEdit) onEdit(mat);
|
||||
}}
|
||||
>
|
||||
{t('materials.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItemDanger}
|
||||
onClick={() => {
|
||||
const mat = materials.find((x) => x.id === menuFor);
|
||||
setMenuFor(null);
|
||||
setMenuPos(null);
|
||||
if (mat) setPendingDelete(mat);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{pendingDelete
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => setPendingDelete(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('materials.deleteTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => setPendingDelete(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.muted}>
|
||||
{t('materials.deleteConfirm', { name: pendingDelete.name })}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDelete(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDelete.id;
|
||||
setPendingDelete(null);
|
||||
if (onDelete) void onDelete(id);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MaterialTile({
|
||||
material,
|
||||
selected,
|
||||
active,
|
||||
showMenu,
|
||||
dragging,
|
||||
dropPlace,
|
||||
reorderEnabled,
|
||||
onSelect,
|
||||
onMenu,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDropReorder,
|
||||
}: {
|
||||
material: ProjectMaterial;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
showMenu: boolean;
|
||||
dragging: boolean;
|
||||
dropPlace: 'before' | 'after' | null;
|
||||
reorderEnabled: boolean;
|
||||
onSelect: () => void;
|
||||
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (place: 'before' | 'after') => void;
|
||||
onDropReorder: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const url = useAssetUrl(material.assetId);
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
matStyles.tile,
|
||||
selected ? matStyles.tileSelected : '',
|
||||
active ? matStyles.tileActive : '',
|
||||
dragging ? matStyles.tileDragging : '',
|
||||
dropPlace === 'before' ? matStyles.tileDropBefore : '',
|
||||
dropPlace === 'after' ? matStyles.tileDropAfter : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable={reorderEnabled}
|
||||
onDragStart={(e) => {
|
||||
if (!reorderEnabled) return;
|
||||
e.dataTransfer.setData(DND_MATERIAL_ID_MIME, material.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
onDragStart();
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => {
|
||||
if (!reorderEnabled) return;
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const place = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
|
||||
onDragOver(place);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!reorderEnabled) return;
|
||||
e.preventDefault();
|
||||
onDropReorder();
|
||||
}}
|
||||
>
|
||||
<button type="button" className={matStyles.tileBody} onClick={onSelect}>
|
||||
{url ? (
|
||||
<div className={matStyles.tileImg}>
|
||||
<RotatedImage url={url} rotationDeg={material.rotationDeg ?? 0} mode="contain" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={matStyles.tileImgEmpty} />
|
||||
)}
|
||||
<div className={matStyles.tileName}>{material.name}</div>
|
||||
</button>
|
||||
{showMenu ? (
|
||||
<button
|
||||
type="button"
|
||||
className={matStyles.tileMenu}
|
||||
data-material-menu-root="1"
|
||||
aria-label={t('materials.tileMenu')}
|
||||
onClick={onMenu}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
.managerDialog {
|
||||
width: min(1100px, calc(100vw - 48px));
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.browserRoot {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.browserRootFill {
|
||||
height: 100%;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.browserRootListOnly {
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.browserToolbar {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browserToolbarRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browserToolbarRow > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.browserToolbarRow > * > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browserToolbarZoomRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browserToolbarFullBtn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browserToolbarFullBtn > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browserToolbarHint {
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
padding: 0 2px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.managerBody {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 14px;
|
||||
/* ~3 плитки по высоте + поиск/кнопка; дальше скролл в списке */
|
||||
min-height: 560px;
|
||||
max-height: min(84vh, 820px);
|
||||
}
|
||||
|
||||
.managerBodyFill {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.managerBodyListOnly {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sideAddBtn {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sideAddBtn > * {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sideAddBtn button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 12px;
|
||||
background: var(--color-overlay-dark-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tileSelected {
|
||||
border-color: var(--color-accent, #a78bfa);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent, #a78bfa) 45%, transparent);
|
||||
}
|
||||
|
||||
.tileActive {
|
||||
outline: 1px solid color-mix(in srgb, var(--color-accent, #c9a227) 70%, transparent);
|
||||
}
|
||||
|
||||
.tileDragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tileDropBefore::before,
|
||||
.tileDropAfter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
background: var(--color-accent, #a78bfa);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tileDropBefore::before {
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.tileDropAfter::after {
|
||||
bottom: -1px;
|
||||
}
|
||||
|
||||
.tileBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.tileImg,
|
||||
.tileImgEmpty {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 8px;
|
||||
background: var(--color-overlay-dark-4);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tileName {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tileMenu {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.tileMenu:hover {
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.previewColumn {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.previewColumnLegend {
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.previewLegendScroll {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
width: 100%;
|
||||
/* высота ≈ 3 плитки списка */
|
||||
height: 520px;
|
||||
min-height: 520px;
|
||||
max-height: 520px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 14px;
|
||||
background: var(--color-overlay-dark-3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.managerBodyFill .previewPane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.previewLargeHost {
|
||||
position: absolute;
|
||||
inset: 16px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.previewEmpty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.previewActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.imageDrop {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--stroke-2);
|
||||
background: var(--color-overlay-dark-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.imageDropEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 112px;
|
||||
text-align: center;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.imageDropEmpty > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.imageDropOver {
|
||||
border-color: var(--color-accent, #a78bfa);
|
||||
}
|
||||
|
||||
.previewThumb {
|
||||
width: 100%;
|
||||
max-height: 160px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
background: var(--color-overlay-dark-4);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from './fileDrop';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { MaterialsBrowser } from './MaterialsBrowser';
|
||||
import matStyles from './MaterialsModals.module.css';
|
||||
|
||||
function normalizeName(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
type MaterialEditModalProps = {
|
||||
open: boolean;
|
||||
initial: ProjectMaterial | null;
|
||||
existingNames: string[];
|
||||
onClose: () => void;
|
||||
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
onSave: (input: { name: string; filePath?: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
export function MaterialEditModal({
|
||||
open,
|
||||
initial,
|
||||
existingNames,
|
||||
onClose,
|
||||
onPickImage,
|
||||
onSave,
|
||||
}: MaterialEditModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [name, setName] = useState('');
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const existingUrl = useAssetUrl(initial?.assetId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? '');
|
||||
setFilePath(null);
|
||||
setLocalPreviewUrl(null);
|
||||
setSaving(false);
|
||||
setSaveProgress(null);
|
||||
setError(null);
|
||||
}, [initial, open]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (localPreviewUrl?.startsWith('blob:')) URL.revokeObjectURL(localPreviewUrl);
|
||||
};
|
||||
}, [localPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return api.on(ipcChannels.project.materialUpsertProgress, (evt) => {
|
||||
setSaveProgress({
|
||||
percent: Math.max(0, Math.min(100, Math.round(evt.percent))),
|
||||
detail: evt.detail?.trim() || t('materials.savingWait'),
|
||||
});
|
||||
});
|
||||
}, [api, open, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !saving) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open, saving]);
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||
setFilePath(path);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return previewUrl;
|
||||
});
|
||||
};
|
||||
|
||||
const drop = useFileDropZone({
|
||||
onDropPaths: (paths) => {
|
||||
const picked = pickFirstMaterialImagePath(paths);
|
||||
if (!picked) return;
|
||||
setPreviewFromPathAndUrl(picked, '');
|
||||
},
|
||||
filterPaths: filterMaterialImagePaths,
|
||||
});
|
||||
|
||||
const trimmed = name.trim();
|
||||
const nameOk = trimmed.length >= 1;
|
||||
const nameDup =
|
||||
nameOk &&
|
||||
existingNames.some(
|
||||
(n) => normalizeName(n) === normalizeName(trimmed) && normalizeName(n) !== normalizeName(initial?.name ?? ''),
|
||||
);
|
||||
const hasImage = Boolean(filePath) || Boolean(initial?.assetId);
|
||||
const canSave = nameOk && !nameDup && hasImage && !saving;
|
||||
const previewSrc = localPreviewUrl || existingUrl;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const progressPercent = saveProgress?.percent ?? (saving ? 0 : 0);
|
||||
const progressDetail = saveProgress?.detail ?? t('materials.savingWait');
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!saving) onClose();
|
||||
}}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>
|
||||
{initial ? t('materials.editTitle') : t('materials.addTitle')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!saving) onClose();
|
||||
}}
|
||||
className={styles.modalClose}
|
||||
disabled={saving}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('materials.name')}</div>
|
||||
<Input value={name} onChange={setName} placeholder={t('materials.namePlaceholder')} />
|
||||
{!nameOk ? <div className={styles.fieldError}>{t('materials.nameRequired')}</div> : null}
|
||||
{nameDup ? <div className={styles.fieldError}>{t('materials.nameDup')}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('materials.image')}</div>
|
||||
<div
|
||||
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
|
||||
onDragEnter={drop.onDragEnter}
|
||||
onDragLeave={drop.onDragLeave}
|
||||
onDragOver={drop.onDragOver}
|
||||
onDrop={(e) => {
|
||||
drop.onDrop(e);
|
||||
const entries = getDroppedFileEntries(e);
|
||||
const files = e.dataTransfer?.files;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i]!;
|
||||
if (!pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files?.[i];
|
||||
if (file) {
|
||||
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
|
||||
return;
|
||||
}
|
||||
setPreviewFromPathAndUrl(entry.path, '');
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('materials.dropHint')}</div> : null}
|
||||
{previewSrc ? (
|
||||
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
|
||||
) : (
|
||||
<div className={matStyles.imageDropEmpty}>
|
||||
<div className={styles.muted}>{t('materials.imageEmpty')}</div>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const picked = await onPickImage();
|
||||
if (!picked) return;
|
||||
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('materials.chooseImage')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{previewSrc ? (
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const picked = await onPickImage();
|
||||
if (!picked) return;
|
||||
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('materials.chooseImage')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{!hasImage ? <div className={styles.fieldError}>{t('materials.imageRequired')}</div> : null}
|
||||
</div>
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
if (!canSave) return;
|
||||
void (async () => {
|
||||
flushSync(() => {
|
||||
setSaving(true);
|
||||
setSaveProgress({ percent: 0, detail: t('materials.savingWait') });
|
||||
setError(null);
|
||||
});
|
||||
try {
|
||||
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSaveProgress(null);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{saving ? (
|
||||
<div
|
||||
className={styles.progressOverlay}
|
||||
role="dialog"
|
||||
aria-label={t('materials.savingProgress')}
|
||||
aria-busy
|
||||
>
|
||||
<div className={styles.progressModal}>
|
||||
<div className={styles.progressTitle}>{t('materials.savingTitle')}</div>
|
||||
<div className={styles.previewSpinner} aria-hidden />
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${String(Math.max(0, Math.min(100, progressPercent)))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.progressMeta}>
|
||||
<div>{progressDetail}</div>
|
||||
<div>{progressPercent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type MaterialsManagerModalProps = {
|
||||
open: boolean;
|
||||
materials: ProjectMaterial[];
|
||||
onClose: () => void;
|
||||
onAdd: () => void;
|
||||
onEdit: (material: ProjectMaterial) => void;
|
||||
onDelete: (materialId: MaterialId) => Promise<void>;
|
||||
onReorder: (materialIds: MaterialId[]) => Promise<void>;
|
||||
onRotate: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
|
||||
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<void>;
|
||||
};
|
||||
|
||||
export function MaterialsManagerModal({
|
||||
open,
|
||||
materials,
|
||||
onClose,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReorder,
|
||||
onRotate,
|
||||
onLegendChange,
|
||||
}: MaterialsManagerModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [selectedId, setSelectedId] = useState<MaterialId | null>(null);
|
||||
const onSelect = useCallback((id: MaterialId | null) => setSelectedId(id), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedId(materials[0]?.id ?? null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
|
||||
<div role="dialog" aria-modal="true" className={[styles.modalDialog, matStyles.managerDialog].join(' ')}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('materials.managerTitle')}</div>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<MaterialsBrowser
|
||||
mode="editor"
|
||||
materials={materials}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onReorder={onReorder}
|
||||
onRotate={onRotate}
|
||||
{...(onLegendChange ? { onLegendChange } : {})}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
.dialog {
|
||||
width: min(1080px, calc(100vw - 48px));
|
||||
max-width: 1080px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 330px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 560px;
|
||||
max-height: min(78vh, 760px);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.editor {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebarActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebarActions > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebarActions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.teamForm {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 10px;
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.teamFormField {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.teamFormField input[type='color'] {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.teamFormActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.teamFormActions > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.teamFormActions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.team {
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 10px;
|
||||
background: var(--color-overlay-dark-2);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.teamHeader {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 34px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.teamDot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.teamName {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.teamMenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.teamMenu summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: 4px 7px;
|
||||
}
|
||||
|
||||
.teamMenuPopup {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 130px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: var(--bg1, #1a1d24);
|
||||
box-shadow: 0 10px 24px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.teamMenuPopup button {
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.teamMenuPopup button:hover {
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.teamBody {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.playerRow {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.playerRow:hover,
|
||||
.playerRowSelected {
|
||||
border-color: var(--color-accent, #c9a227);
|
||||
background: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.playerRow > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropTarget {
|
||||
padding: 8px;
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 12px;
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.editorDropOver {
|
||||
border-color: var(--color-accent, #c9a227);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: min(420px, 100%);
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field input[type='color'] {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
|
||||
import {
|
||||
DEFAULT_PLAYER_IMAGE_OFFSET,
|
||||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||||
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from './fileDrop';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import playerStyles from './PlayersModals.module.css';
|
||||
|
||||
const PLAYER_DND_MIME = 'application/x-dnd-player-id';
|
||||
|
||||
function PlayerRow({
|
||||
player,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const url = usePlayerImageUrl(player.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`player-row-${player.id}`}
|
||||
className={[playerStyles.playerRow, selected ? playerStyles.playerRowSelected : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(PLAYER_DND_MIME, player.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={player.name}
|
||||
imageUrl={url}
|
||||
ringColor={player.ringColor}
|
||||
imageOffset={player.imageOffset}
|
||||
imageScale={player.imageScale}
|
||||
sizePx={48}
|
||||
hideName
|
||||
/>
|
||||
<span>{player.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamSection({
|
||||
team,
|
||||
players,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onAssign,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
team: AppPlayerTeam | null;
|
||||
players: AppPlayer[];
|
||||
selectedId: PlayerId | null;
|
||||
onSelect: (id: PlayerId) => void;
|
||||
onAssign: (id: PlayerId, teamId: PlayerTeamId | null) => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
return (
|
||||
<section
|
||||
className={playerStyles.team}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes(PLAYER_DND_MIME)) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData(PLAYER_DND_MIME);
|
||||
if (id) onAssign(id as PlayerId, team?.id ?? null);
|
||||
}}
|
||||
>
|
||||
<div className={playerStyles.teamHeader}>
|
||||
{team ? <span className={playerStyles.teamDot} style={{ background: team.color }} /> : null}
|
||||
<span className={playerStyles.teamName}>{team?.name ?? t('players.ungrouped')}</span>
|
||||
{team ? (
|
||||
<details className={playerStyles.teamMenu}>
|
||||
<summary aria-label={t('players.teamMenu')}>⋮</summary>
|
||||
<div className={playerStyles.teamMenuPopup}>
|
||||
<button type="button" onClick={onEdit}>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button type="button" onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={playerStyles.teamBody}>
|
||||
{players.map((player) => (
|
||||
<PlayerRow
|
||||
key={player.id}
|
||||
player={player}
|
||||
selected={player.id === selectedId}
|
||||
onSelect={() => onSelect(player.id)}
|
||||
/>
|
||||
))}
|
||||
{players.length === 0 ? <div className={playerStyles.dropTarget}>{t('players.dropHere')}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const { players, teams } = useAppPlayers();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<PlayerId | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [ringColor, setRingColor] = useState(DEFAULT_PLAYER_RING_COLOR);
|
||||
const [imageOffset, setImageOffset] = useState(DEFAULT_PLAYER_IMAGE_OFFSET);
|
||||
const [imageScale, setImageScale] = useState(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [progress, setProgress] = useState({ percent: 0, detail: '' });
|
||||
/** Electron не поддерживает window.prompt — форма команды в сайдбаре. */
|
||||
const [teamForm, setTeamForm] = useState<{
|
||||
id: PlayerTeamId | null;
|
||||
name: string;
|
||||
color: string;
|
||||
} | null>(null);
|
||||
const [pendingDeletePlayer, setPendingDeletePlayer] = useState<AppPlayer | null>(null);
|
||||
const [pendingDeleteTeam, setPendingDeleteTeam] = useState<AppPlayerTeam | null>(null);
|
||||
const appearanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selected = players.find((p) => p.id === selectedId) ?? null;
|
||||
const selectedUrl = usePlayerImageUrl(selected?.id);
|
||||
|
||||
const resetModalState = useCallback(() => {
|
||||
if (appearanceTimerRef.current) {
|
||||
clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = null;
|
||||
}
|
||||
setQuery('');
|
||||
setSelectedId(null);
|
||||
setName('');
|
||||
setRingColor(DEFAULT_PLAYER_RING_COLOR);
|
||||
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
|
||||
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setAdding(false);
|
||||
setSaving(false);
|
||||
setProgress({ percent: 0, detail: '' });
|
||||
setTeamForm(null);
|
||||
setPendingDeletePlayer(null);
|
||||
setPendingDeleteTeam(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (saving) return;
|
||||
resetModalState();
|
||||
onClose();
|
||||
}, [onClose, resetModalState, saving]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedId((current) =>
|
||||
current && players.some((p) => p.id === current) ? current : (players[0]?.id ?? null),
|
||||
);
|
||||
}, [open, players]);
|
||||
|
||||
// Синхронизировать форму только при смене выбранного игрока — не при каждом
|
||||
// players.stateChanged (иначе сбрасывается несохранённый zoom/pan).
|
||||
useEffect(() => {
|
||||
if (adding) return;
|
||||
if (!selectedId) return;
|
||||
const p = players.find((item) => item.id === selectedId);
|
||||
if (!p) return;
|
||||
setName(p.name);
|
||||
setRingColor(p.ringColor);
|
||||
setImageOffset(p.imageOffset);
|
||||
setImageScale(p.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- только selectedId / adding
|
||||
}, [adding, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return api.on(ipcChannels.players.upsertProgress, (event) => {
|
||||
setProgress({
|
||||
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
|
||||
detail: event.detail?.trim() ?? t('players.savingWait'),
|
||||
});
|
||||
});
|
||||
}, [api, open, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || saving) return;
|
||||
if (pendingDeletePlayer) {
|
||||
setPendingDeletePlayer(null);
|
||||
return;
|
||||
}
|
||||
if (pendingDeleteTeam) {
|
||||
setPendingDeleteTeam(null);
|
||||
return;
|
||||
}
|
||||
if (teamForm) {
|
||||
setTeamForm(null);
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [handleClose, open, pendingDeletePlayer, pendingDeleteTeam, saving, teamForm]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? players.filter((p) => p.name.toLowerCase().includes(q)) : players;
|
||||
}, [players, query]);
|
||||
|
||||
const beginAdd = () => {
|
||||
setAdding(true);
|
||||
setSelectedId(null);
|
||||
setName('');
|
||||
setRingColor(DEFAULT_PLAYER_RING_COLOR);
|
||||
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
|
||||
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl(null);
|
||||
};
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, url: string) => {
|
||||
setFilePath(path);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return url || null;
|
||||
});
|
||||
};
|
||||
|
||||
const pickImage = async () => {
|
||||
const result = await api.invoke(ipcChannels.players.pickImage, {});
|
||||
if (result.canceled) return;
|
||||
setPreviewFromPathAndUrl(result.filePath, result.previewDataUrl);
|
||||
};
|
||||
|
||||
const applyDroppedImage = (path: string, previewFromFile: string) => {
|
||||
if (!adding && !selected) beginAdd();
|
||||
setPreviewFromPathAndUrl(path, previewFromFile);
|
||||
};
|
||||
|
||||
const drop = useFileDropZone({
|
||||
disabled: saving,
|
||||
filterPaths: filterMaterialImagePaths,
|
||||
onDropPaths: (paths) => {
|
||||
const picked = pickFirstMaterialImagePath(paths);
|
||||
if (!picked) return;
|
||||
applyDroppedImage(picked, '');
|
||||
},
|
||||
});
|
||||
|
||||
const appearanceLatestRef = useRef({
|
||||
selected,
|
||||
adding,
|
||||
saving,
|
||||
ringColor,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
});
|
||||
appearanceLatestRef.current = { selected, adding, saving, ringColor, imageOffset, imageScale };
|
||||
|
||||
const persistAppearance = (patch: {
|
||||
ringColor?: string;
|
||||
imageOffset?: typeof imageOffset;
|
||||
imageScale?: number;
|
||||
}) => {
|
||||
const snap = appearanceLatestRef.current;
|
||||
if (!snap.selected || snap.adding || snap.saving) return;
|
||||
const payload = {
|
||||
id: snap.selected.id,
|
||||
teamId: snap.selected.teamId,
|
||||
name: snap.selected.name,
|
||||
ringColor: patch.ringColor ?? snap.ringColor,
|
||||
imageOffset: patch.imageOffset ?? snap.imageOffset,
|
||||
imageScale: patch.imageScale ?? snap.imageScale,
|
||||
};
|
||||
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = setTimeout(() => {
|
||||
appearanceTimerRef.current = null;
|
||||
void api.invoke(ipcChannels.players.upsert, payload);
|
||||
}, 180);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || (selected === null && filePath === null)) return;
|
||||
if (appearanceTimerRef.current) {
|
||||
clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = null;
|
||||
}
|
||||
flushSync(() => {
|
||||
setSaving(true);
|
||||
setProgress({ percent: 0, detail: t('players.savingWait') });
|
||||
});
|
||||
try {
|
||||
const result = await api.invoke(ipcChannels.players.upsert, {
|
||||
...(selected ? { id: selected.id, teamId: selected.teamId } : {}),
|
||||
name: trimmed,
|
||||
...(filePath ? { filePath } : {}),
|
||||
ringColor,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
});
|
||||
setAdding(false);
|
||||
setSelectedId(result.player.id);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
const activeUrl = previewUrl ?? selectedUrl;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={styles.modalBackdrop} aria-hidden />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="players-modal"
|
||||
className={[styles.modalDialog, playerStyles.dialog].join(' ')}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.managerTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={handleClose}
|
||||
className={styles.modalClose}
|
||||
disabled={saving}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={playerStyles.body}>
|
||||
<aside className={playerStyles.sidebar}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('players.search')} />
|
||||
<div className={playerStyles.sidebarActions}>
|
||||
<Button variant="primary" data-testid="players-add" onClick={beginAdd}>
|
||||
{t('players.add')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="players-add-team"
|
||||
onClick={() =>
|
||||
setTeamForm({
|
||||
id: null,
|
||||
name: '',
|
||||
color: DEFAULT_PLAYER_RING_COLOR,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('players.addTeam')}
|
||||
</Button>
|
||||
</div>
|
||||
{teamForm ? (
|
||||
<div className={playerStyles.teamForm} data-testid="players-team-form">
|
||||
<label className={playerStyles.teamFormField}>
|
||||
<span>{t('players.teamName')}</span>
|
||||
<Input
|
||||
value={teamForm.name}
|
||||
onChange={(v) => setTeamForm((prev) => (prev ? { ...prev, name: v } : prev))}
|
||||
placeholder={t('players.teamName')}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className={playerStyles.teamFormField}>
|
||||
<span>{t('players.teamColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={teamForm.color}
|
||||
onChange={(e) =>
|
||||
setTeamForm((prev) =>
|
||||
prev ? { ...prev, color: e.currentTarget.value } : prev,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className={playerStyles.teamFormActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!teamForm.name.trim()}
|
||||
onClick={() => {
|
||||
const trimmed = teamForm.name.trim();
|
||||
if (!trimmed) return;
|
||||
void api
|
||||
.invoke(ipcChannels.players.upsertTeam, {
|
||||
...(teamForm.id ? { id: teamForm.id } : {}),
|
||||
name: trimmed,
|
||||
color: teamForm.color,
|
||||
})
|
||||
.then(() => setTeamForm(null));
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button onClick={() => setTeamForm(null)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={playerStyles.list}>
|
||||
{teams.map((team) => (
|
||||
<TeamSection
|
||||
key={team.id}
|
||||
team={team}
|
||||
players={filtered.filter((p) => p.teamId === team.id)}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
setAdding(false);
|
||||
setSelectedId(id);
|
||||
}}
|
||||
onAssign={(playerId, teamId) =>
|
||||
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
|
||||
}
|
||||
onEdit={() =>
|
||||
setTeamForm({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
color: team.color,
|
||||
})
|
||||
}
|
||||
onDelete={() => setPendingDeleteTeam(team)}
|
||||
/>
|
||||
))}
|
||||
<TeamSection
|
||||
team={null}
|
||||
players={filtered.filter((p) => p.teamId === null)}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
setAdding(false);
|
||||
setSelectedId(id);
|
||||
}}
|
||||
onAssign={(playerId, teamId) =>
|
||||
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<main
|
||||
className={[playerStyles.editor, drop.dragOver ? playerStyles.editorDropOver : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onDragEnter={drop.onDragEnter}
|
||||
onDragLeave={drop.onDragLeave}
|
||||
onDragOver={drop.onDragOver}
|
||||
onDrop={(e) => {
|
||||
drop.onDrop(e);
|
||||
const entries = getDroppedFileEntries(e);
|
||||
const files = e.dataTransfer?.files;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i]!;
|
||||
if (!pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files?.[i];
|
||||
applyDroppedImage(entry.path, file ? URL.createObjectURL(file) : '');
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('players.dropHint')}</div> : null}
|
||||
{adding || selected ? (
|
||||
<>
|
||||
<PlayerTokenView
|
||||
data-testid="player-token-preview"
|
||||
name={name}
|
||||
imageUrl={activeUrl}
|
||||
ringColor={ringColor}
|
||||
imageOffset={imageOffset}
|
||||
imageScale={imageScale}
|
||||
panEnabled
|
||||
onImageOffsetChange={(next) => {
|
||||
setImageOffset(next);
|
||||
persistAppearance({ imageOffset: next });
|
||||
}}
|
||||
onImageScaleChange={(next) => {
|
||||
setImageScale(next);
|
||||
persistAppearance({ imageScale: next });
|
||||
}}
|
||||
sizePx={220}
|
||||
/>
|
||||
<label className={playerStyles.field}>
|
||||
<span>{t('players.name')}</span>
|
||||
<Input value={name} onChange={setName} placeholder={t('players.namePlaceholder')} />
|
||||
</label>
|
||||
<label className={playerStyles.field}>
|
||||
<span>{t('players.ringColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={ringColor}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setRingColor(next);
|
||||
persistAppearance({ ringColor: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className={playerStyles.actions}>
|
||||
<Button data-testid="players-choose-image" onClick={() => void pickImage()}>
|
||||
{t('players.chooseImage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
data-testid="players-save"
|
||||
disabled={!name.trim() || (!selected && !filePath) || saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
{selected ? (
|
||||
<Button onClick={() => setPendingDeletePlayer(selected)}>{t('common.delete')}</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('players.selectPrompt')}</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{pendingDeletePlayer
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => setPendingDeletePlayer(null)}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="players-delete-confirm"
|
||||
className={styles.modalDialog}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.deleteTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => setPendingDeletePlayer(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.muted}>
|
||||
{t('players.deleteConfirm', { name: pendingDeletePlayer.name })}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeletePlayer(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeletePlayer.id;
|
||||
setPendingDeletePlayer(null);
|
||||
void api.invoke(ipcChannels.players.delete, { id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{pendingDeleteTeam
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => setPendingDeleteTeam(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.deleteTeamTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => setPendingDeleteTeam(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.muted}>
|
||||
{t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeleteTeam(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeleteTeam.id;
|
||||
setPendingDeleteTeam(null);
|
||||
void api.invoke(ipcChannels.players.deleteTeam, { id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{saving ? (
|
||||
<div className={styles.progressOverlay} role="dialog" aria-busy data-testid="players-save-progress">
|
||||
<div className={styles.progressModal}>
|
||||
<div className={styles.progressTitle}>{t('players.savingTitle')}</div>
|
||||
<div className={styles.previewSpinner} aria-hidden />
|
||||
<div className={styles.progressBar}>
|
||||
<div className={styles.progressFill} style={{ width: `${String(progress.percent)}%` }} />
|
||||
</div>
|
||||
<div className={styles.progressMeta}>
|
||||
<div>{progress.detail}</div>
|
||||
<div>{progress.percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
.dialog {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 48px);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.editorShell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 360px;
|
||||
max-height: min(560px, calc(100vh - 200px));
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
background: var(--color-panel-2);
|
||||
}
|
||||
|
||||
.toolbarGroup {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbarSep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 2px 4px;
|
||||
background: var(--stroke-2);
|
||||
}
|
||||
|
||||
.toolBtn {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--text1);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.toolBtn:hover:not(:disabled) {
|
||||
background: var(--panel);
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.toolBtnActive {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-fill-soft);
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.toolBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolIcon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editorContent {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.editorContent :global(.tiptap) {
|
||||
min-height: 280px;
|
||||
outline: none;
|
||||
color: var(--text0);
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.editorContent :global(.tiptap p.is-editor-empty:first-child::before) {
|
||||
color: var(--text2);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Shared rich-text look (editor + inspector preview) */
|
||||
.prose :global(p) {
|
||||
margin: 0 0 0.65em;
|
||||
}
|
||||
|
||||
.prose :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose :global(h2),
|
||||
.prose :global(h3) {
|
||||
margin: 0.85em 0 0.4em;
|
||||
font-weight: 800;
|
||||
color: var(--text0);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prose :global(h2) {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.prose :global(h3) {
|
||||
font-size: 1.08em;
|
||||
}
|
||||
|
||||
.prose :global(ul),
|
||||
.prose :global(ol) {
|
||||
margin: 0 0 0.65em;
|
||||
padding-left: 1.35em;
|
||||
}
|
||||
|
||||
.prose :global(li) {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
|
||||
.prose :global(strong) {
|
||||
font-weight: 800;
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.prose :global(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prose :global(u) {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose :global(a) {
|
||||
color: var(--accent2);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose :global(blockquote) {
|
||||
margin: 0 0 0.65em;
|
||||
padding: 0.35em 0 0.35em 0.85em;
|
||||
border-left: 3px solid var(--accent-border);
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.prose :global(code) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: var(--color-overlay-dark-4);
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.prose :global(pre) {
|
||||
margin: 0 0 0.65em;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prose :global(pre code) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { normalizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
||||
import modalStyles from './SceneDescriptionModal.module.css';
|
||||
|
||||
type SceneDescriptionModalProps = {
|
||||
initialHtml: string;
|
||||
onClose: () => void;
|
||||
onSave: (html: string) => void;
|
||||
};
|
||||
|
||||
function ToolbarIcon({ path, size = 14 }: { path: string; size?: number }) {
|
||||
return (
|
||||
<svg className={modalStyles.toolIcon} viewBox="0 0 24 24" width={size} height={size} aria-hidden>
|
||||
<path fill="currentColor" d={path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolButton({
|
||||
active = false,
|
||||
disabled = false,
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
className={[modalStyles.toolBtn, active ? modalStyles.toolBtnActive : ''].filter(Boolean).join(' ')}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDescriptionModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: t('scene.descriptionPlaceholder'),
|
||||
}),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: initialHtml || '',
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: [modalStyles.prose, 'tiptap'].join(' '),
|
||||
'aria-label': t('scene.descriptionModalTitle'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const toolbarState = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: ed }) => ({
|
||||
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||||
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||||
underline: Boolean(ed && !ed.isDestroyed && ed.isActive('underline')),
|
||||
bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
|
||||
orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
|
||||
h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
|
||||
h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
|
||||
blockquote: Boolean(ed && !ed.isDestroyed && ed.isActive('blockquote')),
|
||||
}),
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
let raw = '';
|
||||
try {
|
||||
raw = editor.getHTML();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
onSave(normalizeSceneDescriptionHtml(raw));
|
||||
};
|
||||
|
||||
if (!editor || editor.isDestroyed) {
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={[styles.modalDialog, modalStyles.dialog].join(' ')}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('scene.descriptionModalTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={modalStyles.editorShell} />
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={[styles.modalDialog, modalStyles.dialog].join(' ')}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('scene.descriptionModalTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.editorShell}>
|
||||
<div className={modalStyles.toolbar} role="toolbar" aria-label={t('scene.descriptionToolbar')}>
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionBold')}
|
||||
active={toolbarState?.bold ?? false}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionItalic')}
|
||||
active={toolbarState?.italic ?? false}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<em>I</em>
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionUnderline')}
|
||||
active={toolbarState?.underline ?? false}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<span style={{ textDecoration: 'underline' }}>U</span>
|
||||
</ToolButton>
|
||||
</div>
|
||||
<div className={modalStyles.toolbarSep} />
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionHeading2')}
|
||||
active={toolbarState?.h2 ?? false}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
H2
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionHeading3')}
|
||||
active={toolbarState?.h3 ?? false}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
H3
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionQuote')}
|
||||
active={toolbarState?.blockquote ?? false}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
|
||||
</ToolButton>
|
||||
</div>
|
||||
<div className={modalStyles.toolbarSep} />
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionBulletList')}
|
||||
active={toolbarState?.bulletList ?? false}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<ToolbarIcon path="M4 6h2v2H4V6zm0 5h2v2H4v-2zm0 5h2v2H4v-2zm4-10h12v2H8V6zm0 5h12v2H8v-2zm0 5h12v2H8v-2z" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionOrderedList')}
|
||||
active={toolbarState?.orderedList ?? false}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
|
||||
</ToolButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={modalStyles.editorContent}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
collectSceneIdsForSelections,
|
||||
findNpcNameConflicts,
|
||||
findSceneTitleConflicts,
|
||||
listExportedNpcsFromBundle,
|
||||
storylineSelectionKey,
|
||||
type NpcImportResolution,
|
||||
type NpcNameConflict,
|
||||
type SceneImportResolution,
|
||||
type SceneTitleConflict,
|
||||
type StorylineImportMergeReport,
|
||||
type StorylineLabels,
|
||||
type StorylineListItem,
|
||||
type StorylineSelection,
|
||||
} from '../../shared/graph/storylineExportImport';
|
||||
import type { Project, ProjectId, SceneId } from '../../shared/types';
|
||||
import { Button, Select } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
|
||||
export type ExportNpcOption = { id: string; name: string };
|
||||
|
||||
type ExportProjectModalProps = {
|
||||
open: boolean;
|
||||
projects: { id: ProjectId; name: string; fileName: string }[];
|
||||
initialProjectId: ProjectId | null;
|
||||
storylineLabels: StorylineLabels;
|
||||
loadStorylines: (
|
||||
projectId: ProjectId,
|
||||
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
|
||||
onClose: () => void;
|
||||
onExport: (
|
||||
projectId: ProjectId,
|
||||
selections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ExportProjectModal({
|
||||
open,
|
||||
projects,
|
||||
initialProjectId,
|
||||
storylineLabels: _storylineLabels,
|
||||
loadStorylines,
|
||||
onClose,
|
||||
onExport,
|
||||
}: ExportProjectModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
|
||||
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
||||
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
||||
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
|
||||
const [loadingStorylines, setLoadingStorylines] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setProjectId(initialProjectId);
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setStep('storylines');
|
||||
setNpcs([]);
|
||||
}, [initialProjectId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) {
|
||||
setStorylines([]);
|
||||
setNpcs([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingStorylines(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await loadStorylines(projectId);
|
||||
if (cancelled) return;
|
||||
const list = Array.isArray(res?.storylines) ? res.storylines : [];
|
||||
const npcList = Array.isArray(res?.npcs) ? res.npcs : [];
|
||||
setStorylines(list);
|
||||
setNpcs(npcList);
|
||||
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
|
||||
setSelectedNpcIds(new Set(npcList.map((n) => n.id)));
|
||||
setStep('storylines');
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setStorylines([]);
|
||||
setNpcs([]);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingStorylines(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadStorylines, open, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (step === 'npcs') setStep('storylines');
|
||||
else onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open, step]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canContinueStorylines =
|
||||
projectId !== null &&
|
||||
projects.some((p) => p.id === projectId) &&
|
||||
selectedKeys.size > 0 &&
|
||||
!loadingStorylines;
|
||||
|
||||
const toggleKey = (key: string, disabled?: boolean) => {
|
||||
if (disabled) return;
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleNpc = (id: string) => {
|
||||
setSelectedNpcIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedSelections = storylines
|
||||
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
|
||||
.map((item) => item.selection);
|
||||
|
||||
const runExport = (npcIds: string[]) => {
|
||||
if (!projectId || !canContinueStorylines) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onExport(projectId, selectedSelections, npcIds);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const goNextFromStorylines = () => {
|
||||
if (!canContinueStorylines) return;
|
||||
if (npcs.length === 0) {
|
||||
runExport([]);
|
||||
return;
|
||||
}
|
||||
setStep('npcs');
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>
|
||||
{step === 'storylines' ? t('export.title') : t('export.npcsTitle')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 'storylines' ? (
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
||||
<Select
|
||||
value={projectId ?? ''}
|
||||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||||
disabled={projects.length === 0 || saving}
|
||||
ariaLabel={t('export.project')}
|
||||
options={projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{loadingStorylines ? (
|
||||
<div className={styles.muted}>{t('storyline.loading')}</div>
|
||||
) : storylines.length === 0 ? (
|
||||
<div className={styles.muted}>{t('storyline.empty')}</div>
|
||||
) : (
|
||||
<div className={styles.storylineChecklist}>
|
||||
{storylines.map((item) => {
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const checked = selectedKeys.has(key);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled || saving}
|
||||
onChange={() => toggleKey(key, disabled)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{disabled && item.disabledReason === 'main_exists' ? (
|
||||
<span className={styles.muted}> — {t('storyline.mainExistsHint')}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.muted}>{t('export.hint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
||||
<div className={styles.storylineChecklist}>
|
||||
{npcs.map((n) => (
|
||||
<label key={n.id} className={styles.storylineCheck}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNpcIds.has(n.id)}
|
||||
disabled={saving}
|
||||
onChange={() => toggleNpc(n.id)}
|
||||
/>
|
||||
<span>{n.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
disabled={saving || npcs.length === 0}
|
||||
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
|
||||
>
|
||||
{t('export.selectAllNpcs')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
{step === 'npcs' ? (
|
||||
<Button onClick={() => setStep('storylines')} disabled={saving}>
|
||||
{t('export.back')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{step === 'storylines' ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canContinueStorylines || saving}
|
||||
onClick={goNextFromStorylines}
|
||||
>
|
||||
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={saving}
|
||||
onClick={() => runExport([...selectedNpcIds])}
|
||||
>
|
||||
{t('export.saveAs')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export type ImportPeekResult = {
|
||||
kind: 'file' | 'project';
|
||||
filePath?: string;
|
||||
sourceProjectId?: ProjectId;
|
||||
projectName: string;
|
||||
storylines: StorylineListItem[];
|
||||
sourceProject: Project;
|
||||
};
|
||||
|
||||
export type ImportSourceSelection =
|
||||
| { kind: 'file'; filePath: string; fileName: string }
|
||||
| { kind: 'project'; sourceProjectId: ProjectId };
|
||||
|
||||
type ImportSourceModalProps = {
|
||||
open: boolean;
|
||||
/** false — только импорт из файла (полный импорт проекта). */
|
||||
canImportFromProject: boolean;
|
||||
projects: { id: ProjectId; name: string; fileName: string }[];
|
||||
currentProjectId: ProjectId | null;
|
||||
pickFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
|
||||
onClose: () => void;
|
||||
onContinue: (selection: ImportSourceSelection) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ImportSourceModal({
|
||||
open,
|
||||
canImportFromProject,
|
||||
projects,
|
||||
currentProjectId,
|
||||
pickFile,
|
||||
onClose,
|
||||
onContinue,
|
||||
}: ImportSourceModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [importKind, setImportKind] = useState<'project' | 'file'>('file');
|
||||
const [sourceProjectId, setSourceProjectId] = useState<ProjectId | null>(null);
|
||||
const [pickedFile, setPickedFile] = useState<{ path: string; name: string } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const availableProjects = useMemo(
|
||||
() => projects.filter((p) => p.id !== currentProjectId),
|
||||
[currentProjectId, projects],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setImportKind(canImportFromProject && availableProjects.length > 0 ? 'project' : 'file');
|
||||
setSourceProjectId(availableProjects[0]?.id ?? null);
|
||||
setPickedFile(null);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [availableProjects, canImportFromProject, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canContinue =
|
||||
!submitting &&
|
||||
(importKind === 'project' ? canImportFromProject && sourceProjectId !== null : pickedFile !== null);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importSource.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
{canImportFromProject ? (
|
||||
<div className={styles.fieldGroup}>
|
||||
<div className={styles.fieldLabel}>{t('importSource.type')}</div>
|
||||
<Select
|
||||
value={importKind}
|
||||
onChange={(next) => setImportKind(next as 'project' | 'file')}
|
||||
disabled={submitting}
|
||||
ariaLabel={t('importSource.type')}
|
||||
options={[
|
||||
{ value: 'project', label: t('importSource.fromProject') },
|
||||
{ value: 'file', label: t('importSource.fromFile') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
|
||||
)}
|
||||
|
||||
{importKind === 'project' && canImportFromProject ? (
|
||||
<div className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : ''].filter(Boolean).join(' ')}>
|
||||
<div className={styles.fieldLabel}>{t('importSource.project')}</div>
|
||||
<Select
|
||||
value={sourceProjectId ?? ''}
|
||||
onChange={(next) => setSourceProjectId((next as ProjectId) || null)}
|
||||
disabled={availableProjects.length === 0 || submitting}
|
||||
ariaLabel={t('importSource.project')}
|
||||
options={availableProjects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
{availableProjects.length === 0 ? (
|
||||
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={styles.fieldLabel}>{t('importSource.file')}</div>
|
||||
<div className={styles.importFileRow}>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setError(null);
|
||||
const res = await pickFile();
|
||||
if (res.canceled) return;
|
||||
const name = res.filePath.split(/[/\\]/).pop() ?? res.filePath;
|
||||
setPickedFile({ path: res.filePath, name });
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('importSource.chooseFile')}
|
||||
</Button>
|
||||
<span className={styles.muted}>
|
||||
{pickedFile ? pickedFile.name : t('importSource.noFileSelected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canContinue}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (importKind === 'project' && canImportFromProject && sourceProjectId) {
|
||||
await onContinue({ kind: 'project', sourceProjectId });
|
||||
} else if (pickedFile) {
|
||||
await onContinue({ kind: 'file', filePath: pickedFile.path, fileName: pickedFile.name });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('importStoryline.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type ImportStorylinesModalProps = {
|
||||
open: boolean;
|
||||
sourceName: string;
|
||||
storylines: StorylineListItem[];
|
||||
onClose: () => void;
|
||||
onContinue: (selections: StorylineSelection[]) => void;
|
||||
};
|
||||
|
||||
export function ImportStorylinesModal({
|
||||
open,
|
||||
sourceName,
|
||||
storylines,
|
||||
onClose,
|
||||
onContinue,
|
||||
}: ImportStorylinesModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedKeys(new Set());
|
||||
}, [open, sourceName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const toggleKey = (key: string, disabled?: boolean) => {
|
||||
if (disabled) return;
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedSelections = storylines
|
||||
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
|
||||
.map((item) => item.selection);
|
||||
|
||||
const canContinue = selectedKeys.size > 0;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('importStoryline.source')}</div>
|
||||
<div>{sourceName}</div>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{storylines.length === 0 ? (
|
||||
<div className={styles.muted}>{t('storyline.empty')}</div>
|
||||
) : (
|
||||
<div className={styles.storylineChecklist}>
|
||||
{storylines.map((item) => {
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.has(key)}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleKey(key, disabled)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{disabled && item.disabledReason === 'main_exists' ? (
|
||||
<span className={styles.muted}> — {t('storyline.mainExistsHint')}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" disabled={!canContinue} onClick={() => onContinue(selectedSelections)}>
|
||||
{t('importStoryline.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type SceneConflictModalProps = {
|
||||
open: boolean;
|
||||
conflicts: SceneTitleConflict[];
|
||||
onClose: () => void;
|
||||
onConfirm: (resolutions: SceneImportResolution[]) => void;
|
||||
};
|
||||
|
||||
export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: SceneConflictModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [choices, setChoices] = useState<Record<string, 'create' | SceneId>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const init: Record<string, 'create' | SceneId> = {};
|
||||
for (const c of conflicts) {
|
||||
init[c.sourceSceneId] = c.matches[0]?.sceneId ?? 'create';
|
||||
}
|
||||
setChoices(init);
|
||||
}, [conflicts, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={`${styles.modalDialog} ${styles.modalDialogWide}`}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.conflictsTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={styles.muted}>{t('importStoryline.conflictsHint')}</p>
|
||||
|
||||
<div className={styles.conflictList}>
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.sourceSceneId} className={styles.conflictRow}>
|
||||
<div className={styles.conflictTitle}>{c.sourceTitle}</div>
|
||||
<Select
|
||||
value={choices[c.sourceSceneId] ?? 'create'}
|
||||
onChange={(v) => {
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
|
||||
}));
|
||||
}}
|
||||
ariaLabel={c.sourceTitle}
|
||||
options={[
|
||||
{ value: 'create', label: t('importStoryline.createNewScene') },
|
||||
...c.matches.map((m) => ({
|
||||
value: m.sceneId,
|
||||
label: t('importStoryline.useExistingScene', { title: m.title }),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const resolutions: SceneImportResolution[] = conflicts.map((c) => {
|
||||
const choice = choices[c.sourceSceneId] ?? 'create';
|
||||
if (choice === 'create') return { sourceSceneId: c.sourceSceneId, mode: 'create' };
|
||||
return { sourceSceneId: c.sourceSceneId, mode: 'use', targetSceneId: choice };
|
||||
});
|
||||
onConfirm(resolutions);
|
||||
}}
|
||||
>
|
||||
{t('importStoryline.import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type NpcConflictModalProps = {
|
||||
open: boolean;
|
||||
conflicts: NpcNameConflict[];
|
||||
onClose: () => void;
|
||||
onConfirm: (resolutions: NpcImportResolution[]) => void;
|
||||
};
|
||||
|
||||
function defaultNpcConflictChoices(conflicts: NpcNameConflict[]): Record<string, 'create' | string> {
|
||||
const init: Record<string, 'create' | string> = {};
|
||||
for (const c of conflicts) {
|
||||
init[c.sourceNpcId] = c.matches[0]?.npcId ?? 'create';
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
export function NpcConflictModal({ open, conflicts, onClose, onConfirm }: NpcConflictModalProps) {
|
||||
if (!open) return null;
|
||||
const remountKey = conflicts.map((c) => c.sourceNpcId).join('|');
|
||||
return (
|
||||
<NpcConflictModalBody
|
||||
key={remountKey}
|
||||
conflicts={conflicts}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NpcConflictModalBody({
|
||||
conflicts,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
conflicts: NpcNameConflict[];
|
||||
onClose: () => void;
|
||||
onConfirm: (resolutions: NpcImportResolution[]) => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const [choices, setChoices] = useState(() => defaultNpcConflictChoices(conflicts));
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={[styles.modalDialog, styles.modalDialogWide].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.npcConflictsTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={styles.muted}>{t('importStoryline.npcConflictsHint')}</p>
|
||||
|
||||
<div className={styles.conflictList}>
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.sourceNpcId} className={styles.conflictRow}>
|
||||
<div className={styles.conflictTitle}>{c.sourceName}</div>
|
||||
<Select
|
||||
value={choices[c.sourceNpcId] ?? 'create'}
|
||||
onChange={(v) => {
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[c.sourceNpcId]: v === 'create' ? 'create' : v,
|
||||
}));
|
||||
}}
|
||||
ariaLabel={c.sourceName}
|
||||
options={[
|
||||
{ value: 'create', label: t('importStoryline.createNewNpc') },
|
||||
...c.matches.map((m) => ({
|
||||
value: m.npcId,
|
||||
label: t('importStoryline.useExistingNpc', { name: m.name }),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const resolutions: NpcImportResolution[] = conflicts.map((c) => {
|
||||
const choice = choices[c.sourceNpcId] ?? 'create';
|
||||
if (choice === 'create') return { sourceNpcId: c.sourceNpcId, mode: 'create' };
|
||||
return { sourceNpcId: c.sourceNpcId, mode: 'use', targetNpcId: choice };
|
||||
});
|
||||
onConfirm(resolutions);
|
||||
}}
|
||||
>
|
||||
{t('importStoryline.import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type ImportReportModalProps = {
|
||||
open: boolean;
|
||||
report: StorylineImportMergeReport | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ImportReportModal({ open, report, onClose }: ImportReportModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open || !report) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.reportTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className={styles.reportList}>
|
||||
<li>{t('importStoryline.reportLines', { count: report.storylinesImported })}</li>
|
||||
<li>{t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}</li>
|
||||
<li>{t('importStoryline.reportScenesReused', { count: report.scenesReused })}</li>
|
||||
<li>{t('importStoryline.reportNpcsCreated', { count: report.npcsCreated })}</li>
|
||||
<li>{t('importStoryline.reportNpcsReused', { count: report.npcsReused })}</li>
|
||||
<li>{t('importStoryline.reportNodes', { count: report.graphNodesAdded })}</li>
|
||||
<li>{t('importStoryline.reportEdges', { count: report.edgesAdded })}</li>
|
||||
<li>{t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}</li>
|
||||
<li>{t('importStoryline.reportAssetsReused', { count: report.assetsReused })}</li>
|
||||
{report.renamedSideTitles.length > 0 ? (
|
||||
<li>{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSceneResolutionsForImport(
|
||||
targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
conflicts: SceneTitleConflict[],
|
||||
userResolutions: SceneImportResolution[],
|
||||
): SceneImportResolution[] {
|
||||
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
|
||||
const conflictIds = new Set(conflicts.map((c) => c.sourceSceneId));
|
||||
const bySource = new Map(userResolutions.map((r) => [r.sourceSceneId, r]));
|
||||
const out: SceneImportResolution[] = [];
|
||||
for (const sid of sceneIds) {
|
||||
if (conflictIds.has(sid)) {
|
||||
const r = bySource.get(sid);
|
||||
if (r) out.push(r);
|
||||
continue;
|
||||
}
|
||||
out.push({ sourceSceneId: sid, mode: 'create' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeImportConflicts(
|
||||
targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
): SceneTitleConflict[] {
|
||||
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
|
||||
return findSceneTitleConflicts(targetProject, sourceProject, sceneIds);
|
||||
}
|
||||
|
||||
export function buildNpcResolutionsForImport(
|
||||
_targetProject: Project,
|
||||
sourceProject: Project,
|
||||
conflicts: NpcNameConflict[],
|
||||
userResolutions: NpcImportResolution[],
|
||||
): NpcImportResolution[] {
|
||||
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
|
||||
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
|
||||
const out: NpcImportResolution[] = [];
|
||||
for (const n of exported) {
|
||||
if (conflictIds.has(n.id)) {
|
||||
const r = bySource.get(n.id);
|
||||
if (r) out.push(r);
|
||||
continue;
|
||||
}
|
||||
out.push({ sourceNpcId: n.id, mode: 'create' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeNpcImportConflicts(
|
||||
targetProject: Project,
|
||||
sourceProject: Project,
|
||||
): NpcNameConflict[] {
|
||||
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||
return findNpcNameConflicts(
|
||||
targetProject,
|
||||
sourceProject,
|
||||
exported.map((n) => n.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function useStorylineLabels(): StorylineLabels {
|
||||
const { t } = useEditorI18n();
|
||||
return useMemo(
|
||||
() => ({
|
||||
main: t('storyline.main'),
|
||||
untitled: t('graph.untitled'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
|
||||
import { Button } from '../../shared/ui/controls';
|
||||
import styles from '../EditorApp.module.css';
|
||||
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../help/helpLinkify';
|
||||
import {
|
||||
HELP_SECTION_IDS,
|
||||
helpSectionBodyKey,
|
||||
helpSectionTitleKey,
|
||||
type HelpSectionId,
|
||||
} from '../help/helpSections';
|
||||
import { useEditorI18n } from '../i18n/EditorI18nContext';
|
||||
|
||||
type AppAboutModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
appVersion: string | null;
|
||||
};
|
||||
|
||||
export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps) {
|
||||
const { t, locale } = useEditorI18n();
|
||||
const appName = locale === 'ru' ? APP_DISPLAY_NAME_RU : APP_DISPLAY_NAME_EN;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('app.about.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.aboutBody}>
|
||||
<div className={styles.aboutAppName}>{appName}</div>
|
||||
<div className={styles.aboutTagline}>{t('app.about.tagline')}</div>
|
||||
<p className={styles.aboutParagraph}>{t('app.about.description')}</p>
|
||||
<div className={styles.aboutMetaGrid}>
|
||||
<div className={styles.fieldLabel}>{t('app.about.versionLabel')}</div>
|
||||
<div>{appVersion ?? '—'}</div>
|
||||
<div className={styles.fieldLabel}>{t('app.about.developerLabel')}</div>
|
||||
<div>{t('app.about.developer')}</div>
|
||||
<div className={styles.fieldLabel}>{t('app.about.supportLabel')}</div>
|
||||
<div>
|
||||
<a className={styles.aboutLink} href={`mailto:${t('app.about.supportEmail')}`}>
|
||||
{t('app.about.supportEmail')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.fieldLabel}>{t('app.about.websiteLabel')}</div>
|
||||
<div>
|
||||
<a
|
||||
className={styles.aboutLink}
|
||||
href={t('app.about.websiteUrl')}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('app.about.websiteUrl')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type InstructionsModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialSection?: HelpSectionId;
|
||||
};
|
||||
|
||||
function InstructionsModalBody({
|
||||
initialSection,
|
||||
onClose,
|
||||
}: {
|
||||
initialSection: HelpSectionId;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0 });
|
||||
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
||||
activeNav?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeId]);
|
||||
|
||||
const linkCatalog = useMemo(
|
||||
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
|
||||
[t],
|
||||
);
|
||||
|
||||
const body = t(helpSectionBodyKey(activeId));
|
||||
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
||||
|
||||
const goToSection = (id: HelpSectionId) => {
|
||||
setActiveId(id);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.instructionsDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('help.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.instructionsLayout}>
|
||||
<div ref={contentRef} className={styles.instructionsContent}>
|
||||
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} className={styles.instructionsParagraph}>
|
||||
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
|
||||
part.type === 'text' ? (
|
||||
<React.Fragment key={j}>{part.value}</React.Fragment>
|
||||
) : (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
className={styles.instructionsInlineLink}
|
||||
onClick={() => goToSection(part.id)}
|
||||
>
|
||||
{part.value}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}>
|
||||
{HELP_SECTION_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={[
|
||||
styles.instructionsNavItem,
|
||||
id === activeId ? styles.instructionsNavItemActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={id === activeId ? 'true' : undefined}
|
||||
onClick={() => goToSection(id)}
|
||||
>
|
||||
{t(helpSectionTitleKey(id))}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) {
|
||||
if (!open) return null;
|
||||
const section = initialSection ?? 'overview';
|
||||
return <InstructionsModalBody key={section} initialSection={section} onClose={onClose} />;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
isPreviewMediaPath,
|
||||
partitionSceneMediaDrops,
|
||||
sceneTitleFromMediaPath,
|
||||
} from './fileDrop';
|
||||
|
||||
void test('sceneTitleFromMediaPath strips extension and path', () => {
|
||||
assert.equal(sceneTitleFromMediaPath('C:\\media\\Forest Gate.png'), 'Forest Gate');
|
||||
assert.equal(sceneTitleFromMediaPath('/tmp/battle.mp4'), 'battle');
|
||||
assert.equal(sceneTitleFromMediaPath('onlyname'), 'onlyname');
|
||||
});
|
||||
|
||||
void test('isPreviewMediaPath accepts images and videos', () => {
|
||||
assert.equal(isPreviewMediaPath('a.png'), true);
|
||||
assert.equal(isPreviewMediaPath('a.JPG'), true);
|
||||
assert.equal(isPreviewMediaPath('a.webm'), true);
|
||||
assert.equal(isPreviewMediaPath('a.mp3'), false);
|
||||
assert.equal(isPreviewMediaPath('a.pdf'), false);
|
||||
});
|
||||
|
||||
void test('partitionSceneMediaDrops keeps valid and rejects others', () => {
|
||||
const { accepted, rejected } = partitionSceneMediaDrops([
|
||||
{ path: 'D:\\a\\one.jpg', name: 'one.jpg' },
|
||||
{ path: 'D:\\a\\two.mp3', name: 'two.mp3' },
|
||||
{ path: '', name: 'ghost.png' },
|
||||
{ path: 'D:\\a\\three.mov', name: 'three.mov' },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
accepted.map((x) => x.name),
|
||||
['one.jpg', 'three.mov'],
|
||||
);
|
||||
assert.deepEqual(rejected, [
|
||||
{ name: 'two.mp3', reason: 'unsupported' },
|
||||
{ name: 'ghost.png', reason: 'no_path' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
|
||||
const MATERIAL_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
const PREVIEW_EXTENSIONS = new Set([
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.webp',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.mp4',
|
||||
'.webm',
|
||||
'.mov',
|
||||
]);
|
||||
|
||||
function fileExtension(filePath: string): string {
|
||||
const dot = filePath.lastIndexOf('.');
|
||||
return dot >= 0 ? filePath.slice(dot).toLowerCase() : '';
|
||||
}
|
||||
|
||||
export type DroppedFileEntry = { path: string; name: string };
|
||||
|
||||
export type SceneMediaDropRejectReason = 'unsupported' | 'no_path';
|
||||
|
||||
export type SceneMediaDropRejected = { name: string; reason: SceneMediaDropRejectReason };
|
||||
|
||||
export function getDroppedFilePaths(e: DragEvent): string[] {
|
||||
return getDroppedFileEntries(e)
|
||||
.map((entry) => entry.path)
|
||||
.filter((path) => path.length > 0);
|
||||
}
|
||||
|
||||
export function getDroppedFileEntries(e: DragEvent): DroppedFileEntry[] {
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files?.length) return [];
|
||||
const getPathForFile = getDndApi().getPathForFile;
|
||||
return Array.from(files).map((file) => ({
|
||||
path: getPathForFile(file),
|
||||
name: file.name || 'file',
|
||||
}));
|
||||
}
|
||||
|
||||
export function filterAudioFilePaths(paths: string[]): string[] {
|
||||
return paths.filter((path) => AUDIO_EXTENSIONS.has(fileExtension(path)));
|
||||
}
|
||||
|
||||
export function filterMaterialImagePaths(paths: string[]): string[] {
|
||||
return paths.filter((path) => MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path)));
|
||||
}
|
||||
|
||||
export function pickFirstMaterialImagePath(paths: string[]): string | null {
|
||||
for (const path of paths) {
|
||||
if (MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path))) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pickFirstPreviewFilePath(paths: string[]): string | null {
|
||||
for (const path of paths) {
|
||||
if (PREVIEW_EXTENSIONS.has(fileExtension(path))) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPreviewMediaPath(filePath: string): boolean {
|
||||
return PREVIEW_EXTENSIONS.has(fileExtension(filePath));
|
||||
}
|
||||
|
||||
export function sceneTitleFromMediaPath(filePath: string): string {
|
||||
const base = filePath.split(/[/\\]/).pop() ?? filePath;
|
||||
const dot = base.lastIndexOf('.');
|
||||
const title = (dot > 0 ? base.slice(0, dot) : base).trim();
|
||||
return title.length > 0 ? title : 'Новая сцена';
|
||||
}
|
||||
|
||||
export function partitionSceneMediaDrops(entries: DroppedFileEntry[]): {
|
||||
accepted: DroppedFileEntry[];
|
||||
rejected: SceneMediaDropRejected[];
|
||||
} {
|
||||
const accepted: DroppedFileEntry[] = [];
|
||||
const rejected: SceneMediaDropRejected[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.path) {
|
||||
rejected.push({ name: entry.name, reason: 'no_path' });
|
||||
continue;
|
||||
}
|
||||
if (!isPreviewMediaPath(entry.path)) {
|
||||
rejected.push({ name: entry.name, reason: 'unsupported' });
|
||||
continue;
|
||||
}
|
||||
accepted.push(entry);
|
||||
}
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
function dragHasFiles(e: DragEvent): boolean {
|
||||
const types = Array.from(e.dataTransfer?.types ?? []);
|
||||
// Внутренний drag карточки сцены (в т.ч. с превью-картинкой) не должен считаться файловым.
|
||||
if (types.some((t) => t === 'application/x-dnd-scene-id')) return false;
|
||||
return types.includes('Files');
|
||||
}
|
||||
|
||||
type UseFileDropZoneOptions = {
|
||||
disabled?: boolean;
|
||||
/** Синхронная блокировка (например, идёт reorder списка сцен). */
|
||||
isBlocked?: () => boolean;
|
||||
onDropPaths?: (paths: string[]) => void;
|
||||
onDropEntries?: (entries: DroppedFileEntry[]) => void;
|
||||
filterPaths?: (paths: string[]) => string[];
|
||||
};
|
||||
|
||||
export function useFileDropZone({
|
||||
disabled = false,
|
||||
isBlocked,
|
||||
onDropPaths,
|
||||
onDropEntries,
|
||||
filterPaths,
|
||||
}: UseFileDropZoneOptions) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const depthRef = useRef(0);
|
||||
|
||||
const blocked = useCallback(() => disabled || Boolean(isBlocked?.()), [disabled, isBlocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blocked()) return;
|
||||
depthRef.current = 0;
|
||||
setDragOver(false);
|
||||
}, [blocked]);
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (blocked() || !dragHasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
depthRef.current += 1;
|
||||
setDragOver(true);
|
||||
},
|
||||
[blocked],
|
||||
);
|
||||
|
||||
const onDragLeave = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (blocked()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
depthRef.current = Math.max(0, depthRef.current - 1);
|
||||
if (depthRef.current === 0) setDragOver(false);
|
||||
},
|
||||
[blocked],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (blocked() || !dragHasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
},
|
||||
[blocked],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (blocked() || !dragHasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
depthRef.current = 0;
|
||||
setDragOver(false);
|
||||
if (onDropEntries) {
|
||||
const entries = getDroppedFileEntries(e);
|
||||
if (entries.length === 0) return;
|
||||
onDropEntries(entries);
|
||||
return;
|
||||
}
|
||||
const raw = getDroppedFilePaths(e);
|
||||
const paths = filterPaths ? filterPaths(raw) : raw;
|
||||
if (paths.length === 0) return;
|
||||
onDropPaths?.(paths);
|
||||
},
|
||||
[blocked, filterPaths, onDropEntries, onDropPaths],
|
||||
);
|
||||
|
||||
return { dragOver, onDragEnter, onDragLeave, onDragOver, onDrop };
|
||||
}
|
||||
@@ -8,6 +8,12 @@
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.handleSide {
|
||||
background: var(--side-story-handle);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -26,6 +32,13 @@
|
||||
0 25px 50px -12px rgba(167, 139, 250, 0.12);
|
||||
}
|
||||
|
||||
.cardActiveSide {
|
||||
border-color: rgba(0, 120, 212, 0.95);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(0, 120, 212, 0.35),
|
||||
0 25px 50px -12px rgba(0, 120, 212, 0.12);
|
||||
}
|
||||
|
||||
.previewShell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -65,6 +78,21 @@
|
||||
box-shadow: var(--shadow-start-badge);
|
||||
}
|
||||
|
||||
.badgeSideStory {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--side-story-fill-solid);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: var(--shadow-side-story-badge);
|
||||
}
|
||||
|
||||
.cornerBadges {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
@@ -119,6 +147,7 @@
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.musicParams {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
|
||||
import { createPortal } from 'react-dom';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
ConnectionMode,
|
||||
Handle,
|
||||
MarkerType,
|
||||
Panel,
|
||||
@@ -19,8 +20,15 @@ import ReactFlow, {
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
|
||||
import {
|
||||
canSetSideStoryStart,
|
||||
isNodeInSideStoryline,
|
||||
isSideStoryEdge,
|
||||
} from '../../../shared/graph/sceneGraphLineage';
|
||||
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
|
||||
import { RotatedImage } from '../../shared/RotatedImage';
|
||||
import { EllipsisText } from '../../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
|
||||
import { useAssetUrl } from '../../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './SceneGraph.module.css';
|
||||
@@ -53,6 +61,7 @@ const SCENE_CARD_H = 248;
|
||||
/** UI strings for the scene graph (passed from editor i18n). */
|
||||
export type SceneGraphUiStrings = {
|
||||
badgeStart: string;
|
||||
badgeSideStory: string;
|
||||
untitled: string;
|
||||
videoBadge: string;
|
||||
audioBadge: string;
|
||||
@@ -67,11 +76,15 @@ export type SceneGraphUiStrings = {
|
||||
closeMenu: string;
|
||||
startScene: string;
|
||||
unsetStartScene: string;
|
||||
sideStoryStartScene: string;
|
||||
unsetSideStoryStartScene: string;
|
||||
runFromScene: string;
|
||||
delete: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
||||
badgeStart: 'НАЧАЛО',
|
||||
badgeSideStory: 'ПОБОЧНАЯ',
|
||||
untitled: 'Без названия',
|
||||
videoBadge: 'Видео',
|
||||
audioBadge: 'Аудио',
|
||||
@@ -86,6 +99,9 @@ const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
||||
closeMenu: 'Закрыть меню',
|
||||
startScene: 'Начальная сцена',
|
||||
unsetStartScene: 'Снять метку «Начальная сцена»',
|
||||
sideStoryStartScene: 'Начальная сцена побочной линии',
|
||||
unsetSideStoryStartScene: 'Снять метку «Начальная сцена побочной линии»',
|
||||
runFromScene: 'Запустить с этой сцены',
|
||||
delete: 'Удалить',
|
||||
};
|
||||
|
||||
@@ -95,15 +111,18 @@ export type SceneGraphProps = {
|
||||
sceneGraphNodes: SceneGraphNode[];
|
||||
sceneGraphEdges: SceneGraphEdge[];
|
||||
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
|
||||
currentSceneId: SceneId | null;
|
||||
/** Выделенная карточка на графе (одна нода, не все копии сцены). */
|
||||
selectedGraphNodeId: GraphNodeId | null;
|
||||
graphUi?: SceneGraphUiStrings;
|
||||
onCurrentSceneChange: (id: SceneId) => void;
|
||||
onGraphNodeSelect: (graphNodeId: GraphNodeId, sceneId: SceneId) => void;
|
||||
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
|
||||
onDisconnect: (edgeId: string) => void;
|
||||
onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void;
|
||||
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
|
||||
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
|
||||
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
|
||||
onSetGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => void;
|
||||
onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void;
|
||||
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
|
||||
};
|
||||
|
||||
@@ -117,6 +136,8 @@ type SceneCardData = {
|
||||
previewVideoAutostart: boolean;
|
||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||
isStartScene: boolean;
|
||||
isSideStoryStart: boolean;
|
||||
isSideStoryNode: boolean;
|
||||
hasSceneAudio: boolean;
|
||||
previewIsVideo: boolean;
|
||||
hasAnyAudioLoop: boolean;
|
||||
@@ -176,15 +197,24 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
const ui = useContext(GraphUiContext);
|
||||
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
|
||||
const previewUrl = useAssetUrl(data.previewAssetId);
|
||||
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
|
||||
const cardClass = [
|
||||
styles.card,
|
||||
data.active ? (data.isSideStoryNode ? styles.cardActiveSide : styles.cardActive) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const handleClass = data.isSideStoryNode ? styles.handleSide : styles.handle;
|
||||
const showCornerVideo = data.previewIsVideo;
|
||||
const showCornerAudio = data.hasSceneAudio;
|
||||
return (
|
||||
<div className={styles.nodeWrap}>
|
||||
<Handle type="target" position={Position.Top} className={styles.handle} />
|
||||
<Handle type="target" position={Position.Top} className={handleClass} />
|
||||
<div className={cardClass}>
|
||||
<div className={styles.previewShell}>
|
||||
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
|
||||
{data.isSideStoryStart ? (
|
||||
<div className={styles.badgeSideStory}>{ui.badgeSideStory}</div>
|
||||
) : null}
|
||||
{thumbUrl ? (
|
||||
<div className={styles.previewFill}>
|
||||
{data.previewRotationDeg === 0 ? (
|
||||
@@ -265,7 +295,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.nodeBody}>
|
||||
<div className={styles.title}>{data.title || ui.untitled}</div>
|
||||
<EllipsisText text={data.title || ui.untitled} className={[styles.title, ellipsisStyles.root].join(' ')} />
|
||||
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParams}>
|
||||
{data.hasAnyAudioLoop ? (
|
||||
@@ -300,7 +330,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className={styles.handle} />
|
||||
<Handle type="source" position={Position.Bottom} className={handleClass} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -350,39 +380,66 @@ function SceneGraphCanvas({
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
sceneCardById,
|
||||
currentSceneId,
|
||||
selectedGraphNodeId,
|
||||
graphUi,
|
||||
onCurrentSceneChange,
|
||||
onGraphNodeSelect,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onNodePositionCommit,
|
||||
onRemoveGraphNodes,
|
||||
onRemoveGraphNode,
|
||||
onSetGraphNodeStart,
|
||||
onSetGraphNodeSideStoryStart,
|
||||
onRunFromGraphNode,
|
||||
onDropSceneFromList,
|
||||
}: SceneGraphProps) {
|
||||
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
|
||||
const [edgeMenu, setEdgeMenu] = useState<{ x: number; y: number; edgeId: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
if (!menu && !edgeMenu) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMenu(null);
|
||||
if (e.key === 'Escape') {
|
||||
setMenu(null);
|
||||
setEdgeMenu(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [menu]);
|
||||
}, [edgeMenu, menu]);
|
||||
|
||||
const menuNodeIsStart = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene);
|
||||
}, [menu, sceneGraphNodes]);
|
||||
|
||||
const menuNodeIsSideStoryStart = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isSideStoryStart);
|
||||
}, [menu, sceneGraphNodes]);
|
||||
|
||||
const menuCanSetSideStoryStart = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return canSetSideStoryStart(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
|
||||
}, [menu, sceneGraphEdges, sceneGraphNodes]);
|
||||
|
||||
const menuNodeIsSideBranch = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
|
||||
}, [menu, sceneGraphEdges, sceneGraphNodes]);
|
||||
|
||||
const sideStoryEdgeStroke = 'rgba(0,120,212,0.95)';
|
||||
const sideStoryEdgeStrokeDim = 'rgba(0,120,212,0.55)';
|
||||
const mainEdgeStroke = 'rgba(167,139,250,0.95)';
|
||||
const mainEdgeStrokeDim = 'rgba(167,139,250,0.55)';
|
||||
|
||||
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
|
||||
return sceneGraphNodes.map((gn) => {
|
||||
const c = sceneCardById[gn.sceneId];
|
||||
const active = gn.sceneId === currentSceneId;
|
||||
const active = selectedGraphNodeId === gn.id;
|
||||
const isSideStoryNode = isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, gn.id);
|
||||
const audios = c?.audios ?? [];
|
||||
return {
|
||||
id: gn.id,
|
||||
@@ -398,6 +455,8 @@ function SceneGraphCanvas({
|
||||
previewVideoAutostart: c?.previewVideoAutostart ?? false,
|
||||
previewRotationDeg: c?.previewRotationDeg ?? 0,
|
||||
isStartScene: gn.isStartScene,
|
||||
isSideStoryStart: gn.isSideStoryStart,
|
||||
isSideStoryNode,
|
||||
hasSceneAudio: audios.length >= 1,
|
||||
previewIsVideo: c?.previewAssetType === 'video',
|
||||
hasAnyAudioLoop: audios.some((a) => a.loop),
|
||||
@@ -408,39 +467,46 @@ function SceneGraphCanvas({
|
||||
style: { padding: 0, background: 'transparent', border: 'none' },
|
||||
};
|
||||
});
|
||||
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
|
||||
}, [sceneCardById, sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
|
||||
|
||||
const desiredEdges = useMemo<Edge[]>(() => {
|
||||
const selectedGraphNodeIds = new Set<GraphNodeId>();
|
||||
if (currentSceneId) {
|
||||
for (const gn of sceneGraphNodes) {
|
||||
if (gn.sceneId === currentSceneId) selectedGraphNodeIds.add(gn.id);
|
||||
}
|
||||
}
|
||||
const hasSelection = selectedGraphNodeIds.size > 0;
|
||||
return sceneGraphEdges.map((e) => ({
|
||||
...(hasSelection
|
||||
? {
|
||||
style:
|
||||
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
|
||||
? { stroke: 'rgba(167,139,250,0.95)', strokeWidth: 3 }
|
||||
: { stroke: 'rgba(255,255,255,0.10)', strokeWidth: 2 },
|
||||
markerEnd:
|
||||
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
|
||||
? { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.95)', strokeWidth: 2 }
|
||||
: { type: MarkerType.ArrowClosed, color: 'rgba(255,255,255,0.18)', strokeWidth: 2 },
|
||||
}
|
||||
: {
|
||||
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 },
|
||||
}),
|
||||
id: e.id,
|
||||
source: e.sourceGraphNodeId,
|
||||
target: e.targetGraphNodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
}));
|
||||
}, [currentSceneId, sceneGraphEdges, sceneGraphNodes]);
|
||||
const hasSelection = selectedGraphNodeId != null;
|
||||
return sceneGraphEdges.map((e) => {
|
||||
const isSide = isSideStoryEdge(sceneGraphNodes, sceneGraphEdges, e);
|
||||
const strokeActive = isSide ? sideStoryEdgeStroke : mainEdgeStroke;
|
||||
const strokeIdle = isSide ? sideStoryEdgeStrokeDim : mainEdgeStrokeDim;
|
||||
const strokeDim = 'rgba(255,255,255,0.10)';
|
||||
const markerDim = 'rgba(255,255,255,0.18)';
|
||||
const touchesSelection =
|
||||
selectedGraphNodeId != null &&
|
||||
(e.sourceGraphNodeId === selectedGraphNodeId || e.targetGraphNodeId === selectedGraphNodeId);
|
||||
return {
|
||||
...(hasSelection
|
||||
? {
|
||||
style: touchesSelection
|
||||
? { stroke: strokeActive, strokeWidth: 3 }
|
||||
: { stroke: strokeDim, strokeWidth: 2 },
|
||||
markerEnd: touchesSelection
|
||||
? { type: MarkerType.ArrowClosed, color: strokeActive, strokeWidth: 2 }
|
||||
: { type: MarkerType.ArrowClosed, color: markerDim, strokeWidth: 2 },
|
||||
}
|
||||
: {
|
||||
style: { stroke: strokeIdle, strokeWidth: 2 },
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isSide ? 'rgba(0,120,212,0.85)' : 'rgba(167,139,250,0.85)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
}),
|
||||
id: e.id,
|
||||
source: e.sourceGraphNodeId,
|
||||
target: e.targetGraphNodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
selectable: false,
|
||||
};
|
||||
});
|
||||
}, [sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
@@ -485,12 +551,22 @@ function SceneGraphCanvas({
|
||||
if (!menu) return null;
|
||||
const pad = 8;
|
||||
const mw = 220;
|
||||
const mh = 120;
|
||||
const mh = 210;
|
||||
const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad));
|
||||
const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad));
|
||||
return { x, y };
|
||||
}, [menu]);
|
||||
|
||||
const edgeMenuPosition = useMemo(() => {
|
||||
if (!edgeMenu) return null;
|
||||
const pad = 8;
|
||||
const mw = 200;
|
||||
const mh = 48;
|
||||
const x = Math.max(pad, Math.min(edgeMenu.x, window.innerWidth - mw - pad));
|
||||
const y = Math.max(pad, Math.min(edgeMenu.y, window.innerHeight - mh - pad));
|
||||
return { x, y };
|
||||
}, [edgeMenu]);
|
||||
|
||||
return (
|
||||
<GraphUiContext.Provider value={ui}>
|
||||
<div className={styles.canvasWrap}>
|
||||
@@ -504,33 +580,35 @@ function SceneGraphCanvas({
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
isValidConnection={isValidConnection}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
onConnect={onConnectInternal}
|
||||
onEdgesDelete={(eds) => {
|
||||
for (const ed of eds) {
|
||||
onDisconnect(ed.id);
|
||||
}
|
||||
}}
|
||||
onEdgeClick={(_, edge) => {
|
||||
onDisconnect(edge.id);
|
||||
onEdgeContextMenu={(e, edge) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
setEdgeMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
|
||||
}}
|
||||
onNodesDelete={(nds) => {
|
||||
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
|
||||
}}
|
||||
onNodeClick={(_, node) => {
|
||||
setMenu(null);
|
||||
setEdgeMenu(null);
|
||||
const d = node.data as SceneCardData;
|
||||
onCurrentSceneChange(d.sceneId);
|
||||
onGraphNodeSelect(node.id as GraphNodeId, d.sceneId);
|
||||
}}
|
||||
onNodeContextMenu={(e, node) => {
|
||||
e.preventDefault();
|
||||
setEdgeMenu(null);
|
||||
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
setMenu(null);
|
||||
setEdgeMenu(null);
|
||||
}}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
setEdgeMenu(null);
|
||||
}}
|
||||
onInit={(instance) => {
|
||||
instance.fitView({ padding: 0.25 });
|
||||
@@ -539,6 +617,7 @@ function SceneGraphCanvas({
|
||||
onDrop={onDrop}
|
||||
panOnScroll
|
||||
selectionOnDrag={false}
|
||||
minZoom={0.1}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
@@ -579,6 +658,33 @@ function SceneGraphCanvas({
|
||||
>
|
||||
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
|
||||
</button>
|
||||
{menuNodeIsSideStoryStart || menuCanSetSideStoryStart ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
onClick={() => {
|
||||
onSetGraphNodeSideStoryStart(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{menuNodeIsSideStoryStart ? ui.unsetSideStoryStartScene : ui.sideStoryStartScene}
|
||||
</button>
|
||||
) : null}
|
||||
{!menuNodeIsSideBranch ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
disabled={!onRunFromGraphNode}
|
||||
onClick={() => {
|
||||
onRunFromGraphNode?.(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{ui.runFromScene}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -595,6 +701,41 @@ function SceneGraphCanvas({
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{edgeMenu && edgeMenuPosition
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ui.closeMenu}
|
||||
className={styles.menuBackdrop}
|
||||
onClick={() => setEdgeMenu(null)}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: edgeMenuPosition.x, top: edgeMenuPosition.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setEdgeMenu(null);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItemDanger}
|
||||
onClick={() => {
|
||||
onDisconnect(edgeMenu.edgeId);
|
||||
setEdgeMenu(null);
|
||||
}}
|
||||
>
|
||||
{ui.delete}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</GraphUiContext.Provider>
|
||||
);
|
||||
|
||||
@@ -19,8 +19,12 @@ function minimalProject(overrides: Partial<Project>): Project {
|
||||
schemaVersion: 1 as unknown as Project['meta']['schemaVersion'],
|
||||
},
|
||||
scenes: {},
|
||||
sceneListOrder: [],
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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));
|
||||
|
||||
function readSceneGraph(): string {
|
||||
return fs.readFileSync(path.join(here, 'SceneGraph.tsx'), 'utf8');
|
||||
}
|
||||
|
||||
void test('SceneGraph: контекстное меню узла — «Запустить с этой сцены»', () => {
|
||||
const src = readSceneGraph();
|
||||
assert.ok(src.includes('runFromScene'));
|
||||
assert.ok(src.includes('onRunFromGraphNode'));
|
||||
assert.ok(src.includes('onRunFromGraphNode?.(menu.graphNodeId)'));
|
||||
});
|
||||
|
||||
void test('SceneGraph: побочная линия — меню старта и скрытие Run', () => {
|
||||
const src = readSceneGraph();
|
||||
assert.ok(src.includes('sideStoryStartScene'));
|
||||
assert.ok(src.includes('onSetGraphNodeSideStoryStart'));
|
||||
assert.ok(src.includes('menuNodeIsSideBranch'));
|
||||
assert.ok(src.includes('badgeSideStory'));
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { HelpSectionId } from './helpSections';
|
||||
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
|
||||
|
||||
const RU_TITLES: Record<HelpSectionId, string> = {
|
||||
overview: 'Обзор приложения',
|
||||
license: 'Лицензия и первый запуск',
|
||||
projects: 'Проекты',
|
||||
scenes: 'Сцены',
|
||||
graph: 'Граф сцен',
|
||||
sideStorylines: 'Побочные сюжетные линии',
|
||||
sceneProps: 'Свойства сцены',
|
||||
sceneEditor: 'Редактор сцены',
|
||||
grid: 'Генератор сетки',
|
||||
traps: 'Ловушки',
|
||||
tokens: 'Неигровые токены',
|
||||
campaignAudio: 'Аудио игры',
|
||||
materials: 'Материалы',
|
||||
players: 'Игроки',
|
||||
npcs: 'НПС',
|
||||
session: 'Запуск сессии',
|
||||
controlPanel: 'Пульт управления',
|
||||
transitions: 'Переходы между сценами',
|
||||
music: 'Музыка на пульте',
|
||||
effects: 'Эффекты поля и действий',
|
||||
presentation: 'Экран презентации',
|
||||
importExport: 'Импорт и экспорт',
|
||||
settings: 'Настройки, язык и обновления',
|
||||
};
|
||||
|
||||
void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => {
|
||||
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
|
||||
const text = 'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
|
||||
const ranges = findHelpLinkRanges(text, catalog);
|
||||
assert.deepEqual(
|
||||
ranges.map((r) => r.id),
|
||||
['traps', 'effects', 'grid', 'tokens'],
|
||||
);
|
||||
});
|
||||
|
||||
void test('findHelpLinkRanges: длинный title предпочитается короткому алиасу', () => {
|
||||
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
|
||||
const text = 'Откройте «Редактор сцены» и «Генератор сетки».';
|
||||
const ranges = findHelpLinkRanges(text, catalog);
|
||||
assert.equal(ranges.length, 2);
|
||||
assert.equal(ranges[0]?.id, 'sceneEditor');
|
||||
assert.equal(ranges[1]?.id, 'grid');
|
||||
});
|
||||
|
||||
void test('splitHelpTextWithLinks: EN see Scene editor / Traps', () => {
|
||||
const getTitle = (id: HelpSectionId): string => {
|
||||
const map: Partial<Record<HelpSectionId, string>> = {
|
||||
sceneEditor: 'Scene editor',
|
||||
traps: 'Traps',
|
||||
tokens: 'Non-player tokens',
|
||||
grid: 'Grid generator',
|
||||
effects: 'Field and action effects',
|
||||
controlPanel: 'Control panel',
|
||||
};
|
||||
return map[id] ?? id;
|
||||
};
|
||||
const catalog = buildHelpLinkCatalog(getTitle);
|
||||
const parts = splitHelpTextWithLinks(
|
||||
'For details, see Grid generator, Traps, and Non-player tokens. Also see Effects.',
|
||||
catalog,
|
||||
);
|
||||
const links = parts.filter((p) => p.type === 'link');
|
||||
assert.deepEqual(
|
||||
links.map((p) => (p.type === 'link' ? p.id : null)),
|
||||
['grid', 'traps', 'tokens', 'effects'],
|
||||
);
|
||||
});
|
||||
|
||||
void test('findHelpLinkRanges: полное «Пульт управления» не режется до «Пульт»', () => {
|
||||
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
|
||||
const ranges = findHelpLinkRanges('см. «Пульт управления»', catalog);
|
||||
assert.equal(ranges.length, 1);
|
||||
assert.equal(ranges[0]?.id, 'controlPanel');
|
||||
assert.equal(ranges[0]?.text, '«Пульт управления»');
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { HELP_SECTION_IDS, type HelpSectionId } from './helpSections';
|
||||
|
||||
/**
|
||||
* Короткие имена разделов в перекрёстных ссылках
|
||||
* (когда в тексте не полное title, напр. «Эффекты» вместо «Эффекты поля и действий»).
|
||||
*/
|
||||
export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly string[]>> = {
|
||||
effects: ['Эффекты', 'Effects'],
|
||||
presentation: ['Презентация', 'Presentation'],
|
||||
grid: ['Сетка', 'Grid'],
|
||||
controlPanel: ['Пульт'],
|
||||
players: ['Игроки', 'Players'],
|
||||
};
|
||||
|
||||
export type HelpLinkCatalogEntry = {
|
||||
id: HelpSectionId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type HelpLinkRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
id: HelpSectionId;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Каталог подписей → id, длинные первыми. */
|
||||
export function buildHelpLinkCatalog(getTitle: (id: HelpSectionId) => string): HelpLinkCatalogEntry[] {
|
||||
const byLabel = new Map<string, HelpSectionId>();
|
||||
for (const id of HELP_SECTION_IDS) {
|
||||
const title = getTitle(id).trim();
|
||||
if (title) byLabel.set(title, id);
|
||||
for (const alias of HELP_SECTION_LINK_ALIASES[id] ?? []) {
|
||||
const a = alias.trim();
|
||||
if (a) byLabel.set(a, id);
|
||||
}
|
||||
}
|
||||
return [...byLabel.entries()]
|
||||
.map(([label, id]) => ({ id, label }))
|
||||
.sort((a, b) => b.label.length - a.label.length || a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
function isBoundaryChar(ch: string | undefined): boolean {
|
||||
if (ch === undefined) return true;
|
||||
if (/^[\p{L}\p{N}]$/u.test(ch)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectMatches(text: string, label: string, id: HelpSectionId): HelpLinkRange[] {
|
||||
const out: HelpLinkRange[] = [];
|
||||
if (!label) return out;
|
||||
|
||||
const quoted = new RegExp(`«${escapeRegExp(label)}»`, 'g');
|
||||
for (const m of text.matchAll(quoted)) {
|
||||
if (m.index === undefined) continue;
|
||||
out.push({ start: m.index, end: m.index + m[0].length, id, text: m[0] });
|
||||
}
|
||||
|
||||
const bare = new RegExp(escapeRegExp(label), 'g');
|
||||
for (const m of text.matchAll(bare)) {
|
||||
if (m.index === undefined) continue;
|
||||
const start = m.index;
|
||||
const end = start + m[0].length;
|
||||
const before = text[start - 1];
|
||||
const after = text[end];
|
||||
if (!isBoundaryChar(before) || !isBoundaryChar(after)) continue;
|
||||
if (before === '«' && after === '»') continue;
|
||||
out.push({ start, end, id, text: m[0] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function findHelpLinkRanges(text: string, catalog: readonly HelpLinkCatalogEntry[]): HelpLinkRange[] {
|
||||
const candidates: HelpLinkRange[] = [];
|
||||
for (const entry of catalog) {
|
||||
candidates.push(...collectMatches(text, entry.label, entry.id));
|
||||
}
|
||||
candidates.sort(
|
||||
(a, b) => a.start - b.start || b.end - b.start - (a.end - a.start),
|
||||
);
|
||||
|
||||
const selected: HelpLinkRange[] = [];
|
||||
let cursor = 0;
|
||||
for (const range of candidates) {
|
||||
if (range.start < cursor) continue;
|
||||
selected.push(range);
|
||||
cursor = range.end;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function splitHelpTextWithLinks(
|
||||
text: string,
|
||||
catalog: readonly HelpLinkCatalogEntry[],
|
||||
): Array<{ type: 'text'; value: string } | { type: 'link'; id: HelpSectionId; value: string }> {
|
||||
const ranges = findHelpLinkRanges(text, catalog);
|
||||
if (ranges.length === 0) return [{ type: 'text', value: text }];
|
||||
const parts: Array<{ type: 'text'; value: string } | { type: 'link'; id: HelpSectionId; value: string }> =
|
||||
[];
|
||||
let cursor = 0;
|
||||
for (const range of ranges) {
|
||||
if (range.start > cursor) {
|
||||
parts.push({ type: 'text', value: text.slice(cursor, range.start) });
|
||||
}
|
||||
parts.push({ type: 'link', id: range.id, value: range.text });
|
||||
cursor = range.end;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
parts.push({ type: 'text', value: text.slice(cursor) });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user