Compare commits
89 Commits
v1.0.12
...
f75444a5dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
e2e/fixtures/sample.png
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import { EffectsStore } from './effectsStore';
|
import { EffectsStore } from './effectsStore';
|
||||||
|
|
||||||
|
void test('defaultTool: при старте инструмент не выбран', () => {
|
||||||
|
const store = new EffectsStore();
|
||||||
|
assert.equal(store.getState().tool.tool, 'none');
|
||||||
|
});
|
||||||
|
|
||||||
void test('pruneExpired: лёд не удаляется по времени', () => {
|
void test('pruneExpired: лёд не удаляется по времени', () => {
|
||||||
const store = new EffectsStore();
|
const store = new EffectsStore();
|
||||||
const createdAtMs = Date.now() - 365 * 24 * 60 * 60 * 1000;
|
const createdAtMs = Date.now() - 365 * 24 * 60 * 60 * 1000;
|
||||||
@@ -64,7 +69,7 @@ void test('pruneExpired: луч света удаляется после lifetim
|
|||||||
assert.equal(store.getState().instances.length, 0);
|
assert.equal(store.getState().instances.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('pruneExpired: облако яда удаляется после lifetime', () => {
|
void test('pruneExpired: яд удаляется после lifetime', () => {
|
||||||
const store = new EffectsStore();
|
const store = new EffectsStore();
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
kind: 'instance.add',
|
kind: 'instance.add',
|
||||||
@@ -82,3 +87,22 @@ void test('pruneExpired: облако яда удаляется после lifet
|
|||||||
assert.equal(store.pruneExpired(), true);
|
assert.equal(store.pruneExpired(), true);
|
||||||
assert.equal(store.getState().instances.length, 0);
|
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 crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
|
||||||
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
|
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
|
||||||
|
|
||||||
function nowMs(): number {
|
function nowMs(): number {
|
||||||
@@ -7,7 +8,12 @@ function nowMs(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function defaultTool(): EffectToolState {
|
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 {
|
export class EffectsStore {
|
||||||
@@ -35,7 +41,7 @@ export class EffectsStore {
|
|||||||
|
|
||||||
dispatch(event: EffectsEvent): EffectsState {
|
dispatch(event: EffectsEvent): EffectsState {
|
||||||
const s = this.state;
|
const s = this.state;
|
||||||
const next: EffectsState = applyEvent(s, event);
|
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
|
||||||
this.state = next;
|
this.state = next;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
@@ -46,14 +52,20 @@ export class EffectsStore {
|
|||||||
const before = this.state.instances.length;
|
const before = this.state.instances.length;
|
||||||
const kept = this.state.instances.filter((i) => {
|
const kept = this.state.instances.filter((i) => {
|
||||||
// Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI).
|
// Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI).
|
||||||
if (i.type === 'ice') return true;
|
if (i.type === 'ice' || i.type === 'shadow') return true;
|
||||||
if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud') {
|
if (
|
||||||
|
i.type === 'lightning' ||
|
||||||
|
i.type === 'sunbeam' ||
|
||||||
|
i.type === 'poisonCloud' ||
|
||||||
|
i.type === 'explosion' ||
|
||||||
|
i.type === 'darkness'
|
||||||
|
) {
|
||||||
return now - i.createdAtMs < i.lifetimeMs;
|
return now - i.createdAtMs < i.lifetimeMs;
|
||||||
}
|
}
|
||||||
if (i.type === 'scorch') {
|
if (i.type === 'scorch') {
|
||||||
return now - i.createdAtMs < i.lifetimeMs;
|
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;
|
if (i.lifetimeMs === null) return true;
|
||||||
return now - i.createdAtMs < i.lifetimeMs;
|
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 => ({
|
const bump = (patch: Omit<EffectsState, 'revision' | 'serverNowMs'>): EffectsState => ({
|
||||||
...patch,
|
...patch,
|
||||||
revision: state.revision + 1,
|
revision: state.revision + 1,
|
||||||
@@ -89,6 +105,18 @@ function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
|
|||||||
return bump({ ...state, instances: [...state.instances, event.instance] });
|
return bump({ ...state, instances: [...state.instances, event.instance] });
|
||||||
case 'instance.remove':
|
case 'instance.remove':
|
||||||
return bump({ ...state, instances: state.instances.filter((i) => i.id !== event.id) });
|
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: {
|
default: {
|
||||||
// Exhaustiveness
|
// Exhaustiveness
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+993
-61
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('license.')) return false;
|
||||||
if (channel.startsWith('app.')) return false;
|
if (channel.startsWith('app.')) return false;
|
||||||
if (channel === ipcChannels.windows.closeMultiWindow) return false;
|
if (channel === ipcChannels.windows.closeMultiWindow) return false;
|
||||||
|
if (channel === ipcChannels.windows.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;
|
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
|
||||||
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
|
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
|
||||||
if (channel === ipcChannels.project.list) return false;
|
if (channel === ipcChannels.project.list) return false;
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import { resolveMachineFingerprint } from './machineFingerprint';
|
||||||
import { deviceIdPath } from './paths';
|
import { deviceIdPath } from './paths';
|
||||||
|
|
||||||
export function getOrCreateDeviceId(userData: string): string {
|
/** Старый per-user UUID из userData/device.id (до привязки к железу). */
|
||||||
const p = deviceIdPath(userData);
|
export function readLegacyDeviceId(userData: string): string | null {
|
||||||
try {
|
try {
|
||||||
const existing = fs.readFileSync(p, 'utf8').trim();
|
const existing = fs.readFileSync(deviceIdPath(userData), 'utf8').trim();
|
||||||
if (existing.length >= 8) return existing;
|
if (existing.length >= 8) return existing;
|
||||||
} catch {
|
} catch {
|
||||||
/* empty */
|
/* empty */
|
||||||
}
|
}
|
||||||
const id = randomUUID();
|
return null;
|
||||||
fs.mkdirSync(userData, { recursive: true });
|
}
|
||||||
fs.writeFileSync(p, `${id}\n`, 'utf8');
|
|
||||||
return id;
|
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 fs from 'node:fs';
|
||||||
|
|
||||||
import { BrowserWindow, safeStorage } from 'electron';
|
import { BrowserWindow, safeStorage } from 'electron';
|
||||||
@@ -6,11 +7,11 @@ import { ipcChannels } from '../../shared/ipc/contracts';
|
|||||||
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||||
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||||
import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
|
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 { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
|
||||||
|
|
||||||
import { getOrCreateDeviceId } from './deviceId';
|
import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
|
||||||
import { licenseEncryptedPath, preferencesPath } from './paths';
|
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
|
||||||
import { verifyLicenseToken } from './verifyLicenseToken';
|
import { verifyLicenseToken } from './verifyLicenseToken';
|
||||||
|
|
||||||
type Preferences = {
|
type Preferences = {
|
||||||
@@ -19,6 +20,8 @@ type Preferences = {
|
|||||||
|
|
||||||
type LicenseChangeListener = () => void;
|
type LicenseChangeListener = () => void;
|
||||||
|
|
||||||
|
const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii');
|
||||||
|
|
||||||
const licenseChangeListeners = new Set<LicenseChangeListener>();
|
const licenseChangeListeners = new Set<LicenseChangeListener>();
|
||||||
|
|
||||||
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
|
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
|
||||||
@@ -63,35 +66,121 @@ function emitLicenseStatusChanged(): void {
|
|||||||
export class LicenseService {
|
export class LicenseService {
|
||||||
private readonly userData: string;
|
private readonly userData: string;
|
||||||
private readonly deviceId: string;
|
private readonly deviceId: string;
|
||||||
|
/** UUID из старого userData/device.id — только для совместимости до повторной активации. */
|
||||||
|
private legacyDeviceId: string | null;
|
||||||
private lastRemoteRevokeCheckMs = 0;
|
private lastRemoteRevokeCheckMs = 0;
|
||||||
private lastRemoteRevoked = false;
|
private lastRemoteRevoked = false;
|
||||||
|
|
||||||
constructor(userData: string) {
|
constructor(userData: string) {
|
||||||
this.userData = userData;
|
this.userData = userData;
|
||||||
this.deviceId = getOrCreateDeviceId(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 {
|
private isSkipLicense(): boolean {
|
||||||
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
|
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
private readSealedToken(): string | null {
|
/** Только для окружений без OS keychain (WSL и т.п.); слабее safeStorage — см. licensing-spec. */
|
||||||
const p = licenseEncryptedPath(this.userData);
|
private isInsecureFileStorageAllowed(): boolean {
|
||||||
if (!fs.existsSync(p)) return null;
|
const v = process.env.DND_LICENSE_INSECURE_FILE_STORAGE?.trim().toLowerCase();
|
||||||
if (!safeStorage.isEncryptionAvailable()) {
|
return v === '1' || v === 'true' || v === 'yes';
|
||||||
throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
const buf = fs.readFileSync(p);
|
||||||
|
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);
|
return safeStorage.decryptString(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeSealedToken(token: string): void {
|
if (fs.existsSync(fallbackPath)) {
|
||||||
if (!safeStorage.isEncryptionAvailable()) {
|
return this.readFallbackSealedToken();
|
||||||
throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeSealedToken(token: string): void {
|
||||||
fs.mkdirSync(this.userData, { recursive: true });
|
fs.mkdirSync(this.userData, { recursive: true });
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
const enc = safeStorage.encryptString(token);
|
const enc = safeStorage.encryptString(token);
|
||||||
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
|
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 {
|
private clearSealedTokenFile(): void {
|
||||||
@@ -100,22 +189,34 @@ export class LicenseService {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ok */
|
/* ok */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
|
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
|
||||||
private resolveLicenseActivateBaseUrl(): string {
|
private resolveLicenseActivateBaseUrl(): string {
|
||||||
const raw = process.env.DND_LICENSE_STATUS_URL?.trim();
|
const raw = process.env.DND_LICENSE_STATUS_URL?.trim();
|
||||||
if (raw) return raw.endsWith('/') ? raw : `${raw}/`;
|
if (raw) return raw.endsWith('/') ? raw : `${raw}/`;
|
||||||
return 'https://license.mailib.ru/';
|
return 'https://license.ttrpgplayer.ru/';
|
||||||
}
|
}
|
||||||
|
|
||||||
private async activateWithProductKey(productKey: string): Promise<string> {
|
private async activateWithProductKey(productKey: string): Promise<string> {
|
||||||
const base = this.resolveLicenseActivateBaseUrl();
|
const base = this.resolveLicenseActivateBaseUrl();
|
||||||
const url = new URL('v1/activate', base);
|
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, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
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),
|
signal: AbortSignal.timeout(20_000),
|
||||||
});
|
});
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -133,6 +234,10 @@ export class LicenseService {
|
|||||||
if (!token || typeof token !== 'string') {
|
if (!token || typeof token !== 'string') {
|
||||||
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
|
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
|
||||||
}
|
}
|
||||||
|
if (this.legacyDeviceId) {
|
||||||
|
clearLegacyDeviceId(this.userData);
|
||||||
|
this.legacyDeviceId = null;
|
||||||
|
}
|
||||||
return normalizeLicenseTokenInput(token);
|
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) {
|
if (!v.ok) {
|
||||||
return {
|
return {
|
||||||
active: false,
|
active: false,
|
||||||
@@ -250,10 +355,7 @@ export class LicenseService {
|
|||||||
if (!base.active || !base.summary) return base;
|
if (!base.active || !base.summary) return base;
|
||||||
const token = this.readSealedToken();
|
const token = this.readSealedToken();
|
||||||
if (!token?.trim()) return base;
|
if (!token?.trim()) return base;
|
||||||
const v = verifyLicenseToken(token, {
|
const v = verifyLicenseToken(token, this.verifyOpts(Math.floor(Date.now() / 1000)));
|
||||||
nowSec: Math.floor(Date.now() / 1000),
|
|
||||||
deviceId: this.deviceId,
|
|
||||||
});
|
|
||||||
if (!v.ok) return this.getStatusSync();
|
if (!v.ok) return this.getStatusSync();
|
||||||
void this.maybeRefreshRemoteRevocation(v.payload);
|
void this.maybeRefreshRemoteRevocation(v.payload);
|
||||||
return this.getStatusSync();
|
return this.getStatusSync();
|
||||||
@@ -264,11 +366,11 @@ export class LicenseService {
|
|||||||
return this.getStatusSync();
|
return this.getStatusSync();
|
||||||
}
|
}
|
||||||
let trimmed = normalizeLicenseTokenInput(token);
|
let trimmed = normalizeLicenseTokenInput(token);
|
||||||
if (isDndProductKey(trimmed)) {
|
if (isProductKey(trimmed)) {
|
||||||
trimmed = await this.activateWithProductKey(trimmed);
|
trimmed = await this.activateWithProductKey(trimmed);
|
||||||
}
|
}
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
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) {
|
if (!v.ok) {
|
||||||
throw new Error(`LICENSE_INVALID:${v.reason}`);
|
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');
|
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 {
|
export function deviceIdPath(userData: string): string {
|
||||||
return path.join(userData, 'device.id');
|
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 {
|
export function preferencesPath(userData: string): string {
|
||||||
return path.join(userData, 'preferences.json');
|
return path.join(userData, 'preferences.json');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,30 @@ void test('verifyLicenseToken: неверное устройство', () => {
|
|||||||
assert.equal(bad.reason, 'wrong_device');
|
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: токен с переносами строк после копирования', () => {
|
void test('verifyLicenseToken: токен с переносами строк после копирования', () => {
|
||||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||||
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ function getBundledPublicKey() {
|
|||||||
|
|
||||||
export function verifyLicenseToken(
|
export function verifyLicenseToken(
|
||||||
token: string,
|
token: string,
|
||||||
opts: { nowSec: number; deviceId: string; publicKeyOverrideSpkiDerB64?: string },
|
opts: {
|
||||||
|
nowSec: number;
|
||||||
|
deviceId: string;
|
||||||
|
/** Старые deviceId (например UUID из userData) — принимаются до повторной активации. */
|
||||||
|
alsoAcceptDeviceIds?: readonly string[];
|
||||||
|
publicKeyOverrideSpkiDerB64?: string;
|
||||||
|
},
|
||||||
): LicenseVerifyResult {
|
): LicenseVerifyResult {
|
||||||
const parts = splitSignedLicenseToken(token);
|
const parts = splitSignedLicenseToken(token);
|
||||||
if (!parts) return { ok: false, reason: 'malformed' };
|
if (!parts) return { ok: false, reason: 'malformed' };
|
||||||
@@ -53,9 +59,12 @@ export function verifyLicenseToken(
|
|||||||
return { ok: false, reason: 'not_yet_valid' };
|
return { ok: false, reason: 'not_yet_valid' };
|
||||||
}
|
}
|
||||||
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
|
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
|
||||||
if (payload.did !== null && payload.did !== opts.deviceId) {
|
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: false, reason: 'wrong_device' };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, payload };
|
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 }],
|
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;
|
} as unknown as Project;
|
||||||
const s = collectReferencedAssetIds(p);
|
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 () => {
|
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
|
||||||
@@ -65,12 +67,14 @@ void test('reconcileAssetFiles: снимает осиротевшие assets и
|
|||||||
...base,
|
...base,
|
||||||
scenes: {},
|
scenes: {},
|
||||||
campaignAudios: [],
|
campaignAudios: [],
|
||||||
|
materials: [],
|
||||||
assets: { orphan: asset } as Project['assets'],
|
assets: { orphan: asset } as Project['assets'],
|
||||||
};
|
};
|
||||||
const next: Project = {
|
const next: Project = {
|
||||||
...base,
|
...base,
|
||||||
scenes: {},
|
scenes: {},
|
||||||
campaignAudios: [],
|
campaignAudios: [],
|
||||||
|
materials: [],
|
||||||
assets: { orphan: asset } as Project['assets'],
|
assets: { orphan: asset } as Project['assets'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,7 +119,7 @@ void test('reconcileAssetFiles: удаляет файл при исключен
|
|||||||
} as unknown as Project;
|
} as unknown as Project;
|
||||||
|
|
||||||
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
|
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);
|
const out = await reconcileAssetFiles(prev, next, tmp);
|
||||||
assert.deepEqual(out.assets, {});
|
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 sc.media.audios) refs.add(au.assetId);
|
||||||
}
|
}
|
||||||
for (const au of p.campaignAudios) 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;
|
return refs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import crypto from 'node:crypto';
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PROJECT_ZIP_EXTENSION,
|
||||||
|
PROJECT_ZIP_EXTENSION_LEGACY,
|
||||||
|
isProjectZipFileName,
|
||||||
|
} from '../../shared/project/projectZipExtension';
|
||||||
|
|
||||||
import { readProjectJsonFromZip } from './yauzlProjectZip';
|
import { readProjectJsonFromZip } from './yauzlProjectZip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.dnd.zip.tmp`).
|
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp`).
|
||||||
* Нельзя сначала удалять `finalPath`: при сбое rename после rm проект теряется (Windows/антивирус).
|
* Нельзя сначала удалять `finalPath`: при сбое rename после rm проект теряется (Windows/антивирус).
|
||||||
*/
|
*/
|
||||||
export async function replaceFileAtomic(completedSrc: string, finalPath: string): Promise<void> {
|
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);
|
await fs.unlink(backupPath).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Если сохранение оборвалось, остаётся только `*.dnd.zip.tmp` — восстанавливаем в `*.dnd.zip`. */
|
const ZIP_TMP_SUFFIXES = [`${PROJECT_ZIP_EXTENSION}.tmp`, `${PROJECT_ZIP_EXTENSION_LEGACY}.tmp`] as const;
|
||||||
export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void> {
|
|
||||||
|
/** Если сохранение оборвалось, остаётся `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp` — восстанавливаем финальный архив. */
|
||||||
|
export async function recoverOrphanProjectZipTmpInRoot(root: string): Promise<void> {
|
||||||
let names: string[];
|
let names: string[];
|
||||||
try {
|
try {
|
||||||
names = await fs.readdir(root);
|
names = await fs.readdir(root);
|
||||||
@@ -59,10 +67,11 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const name of names) {
|
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 tmpPath = path.join(root, name);
|
||||||
const finalName = name.slice(0, -'.tmp'.length);
|
const finalName = name.slice(0, -'.tmp'.length);
|
||||||
if (!finalName.endsWith('.dnd.zip')) continue;
|
if (!isProjectZipFileName(finalName)) continue;
|
||||||
const finalPath = path.join(root, finalName);
|
const finalPath = path.join(root, finalName);
|
||||||
try {
|
try {
|
||||||
await fs.access(finalPath);
|
await fs.access(finalPath);
|
||||||
@@ -80,3 +89,6 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Используйте {@link recoverOrphanProjectZipTmpInRoot}. */
|
||||||
|
export const recoverOrphanDndZipTmpInRoot = recoverOrphanProjectZipTmpInRoot;
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ export function getProjectsCacheRootDir(): string {
|
|||||||
export function getLegacyProjectsRootDirs(): string[] {
|
export function getLegacyProjectsRootDirs(): string[] {
|
||||||
const cur = getProjectsRootDir();
|
const cur = getProjectsRootDir();
|
||||||
const parent = path.dirname(app.getPath('userData'));
|
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[] = [];
|
const out: string[] = [];
|
||||||
for (const n of siblingNames) {
|
for (const n of siblingNames) {
|
||||||
const p = path.join(parent, n, 'projects');
|
const p = path.join(parent, n, 'projects');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { readProjectJsonFromZip } from './yauzlProjectZip';
|
|||||||
|
|
||||||
void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', async () => {
|
void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', async () => {
|
||||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-read-'));
|
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 = {
|
const minimal = {
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
meta: {
|
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 () => {
|
void test('readProjectJsonFromZip: campaignAudios round-trips inside project.json', async () => {
|
||||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-campaign-'));
|
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 assetId = 'audio_asset_1';
|
||||||
const minimal = {
|
const minimal = {
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
|
|||||||
@@ -28,9 +28,32 @@ void test('zipStore: openProjectById flushes pending saveNow before cache reset'
|
|||||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
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.
|
// When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first.
|
||||||
assert.match(src, /async openProjectById/);
|
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, /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 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: 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', () => {
|
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
|
||||||
@@ -58,7 +81,7 @@ void test('atomicReplace: replaceFileAtomic must not rm destination before succe
|
|||||||
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
||||||
const i = src.indexOf('export async function replaceFileAtomic');
|
const i = src.indexOf('export async function replaceFileAtomic');
|
||||||
assert.ok(i >= 0);
|
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);
|
assert.ok(j > i);
|
||||||
const block = src.slice(i, j);
|
const block = src.slice(i, j);
|
||||||
assert.match(block, /rename\(finalPath, backupPath\)/);
|
assert.match(block, /rename\(finalPath, backupPath\)/);
|
||||||
|
|||||||
+1516
-135
File diff suppressed because it is too large
Load Diff
@@ -2,26 +2,14 @@ import fs from 'node:fs/promises';
|
|||||||
|
|
||||||
import { session } from 'electron';
|
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 { ZipProjectStore } from '../project/zipStore';
|
||||||
|
import type { TokensStore } from '../tokens/tokensStore';
|
||||||
|
|
||||||
/**
|
type ReadInfo = { absPath: string; mime: string };
|
||||||
* Обслуживает `dnd://asset?...` — без этого `<img src="file://...">` в рендерере часто ломается.
|
|
||||||
*/
|
async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
|
||||||
export function registerDndAssetProtocol(projectStore: ZipProjectStore): 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));
|
|
||||||
if (!info) {
|
|
||||||
return new Response(null, { status: 404 });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(info.absPath);
|
const stat = await fs.stat(info.absPath);
|
||||||
const total = stat.size;
|
const total = stat.size;
|
||||||
@@ -33,22 +21,39 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
|
|||||||
const start = Number(m[1]);
|
const start = Number(m[1]);
|
||||||
const endRaw = m[2] ? Number(m[2]) : total - 1;
|
const endRaw = m[2] ? Number(m[2]) : total - 1;
|
||||||
const end = Math.min(endRaw, total - 1);
|
const end = Math.min(endRaw, total - 1);
|
||||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
|
||||||
return new Response(null, { status: 416 });
|
return new Response(null, {
|
||||||
|
status: 416,
|
||||||
|
headers: {
|
||||||
|
'Content-Range': `bytes */${String(total)}`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const len = end - start + 1;
|
const len = end - start + 1;
|
||||||
const fh = await fs.open(info.absPath, 'r');
|
const fh = await fs.open(info.absPath, 'r');
|
||||||
try {
|
try {
|
||||||
const buf = Buffer.alloc(len);
|
const buf = Buffer.alloc(len);
|
||||||
await fh.read(buf, 0, len, start);
|
const { bytesRead } = await fh.read(buf, 0, len, start);
|
||||||
return new Response(buf, {
|
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,
|
status: 206,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': info.mime,
|
'Content-Type': info.mime,
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`,
|
'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
|
||||||
'Content-Length': String(len),
|
'Content-Length': String(body.length),
|
||||||
'Cache-Control': 'public, max-age=300',
|
'Cache-Control': 'no-store',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,12 +67,39 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': info.mime,
|
'Content-Type': info.mime,
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
'Content-Length': String(buf.length),
|
'Cache-Control': 'no-store',
|
||||||
'Cache-Control': 'public, max-age=300',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, { status: 404 });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`.
|
||||||
|
*/
|
||||||
|
export function registerDndAssetProtocol(
|
||||||
|
projectStore: ZipProjectStore,
|
||||||
|
tokensStore: TokensStore,
|
||||||
|
playersStore: PlayersStore,
|
||||||
|
): void {
|
||||||
|
session.defaultSession.protocol.handle('dnd', async (request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const id = url.searchParams.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
if (!info) {
|
||||||
|
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,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 { autoUpdater } from 'electron-updater';
|
||||||
|
|
||||||
|
import { appDisplayNameForLocale } from '../../shared/appBranding';
|
||||||
import {
|
import {
|
||||||
ipcChannels,
|
ipcChannels,
|
||||||
type UpdaterCheckResponse,
|
type UpdaterCheckResponse,
|
||||||
type UpdaterDownloadResponse,
|
type UpdaterDownloadResponse,
|
||||||
|
type UpdaterProgressEvent,
|
||||||
} from '../../shared/ipc/contracts';
|
} from '../../shared/ipc/contracts';
|
||||||
import type { IpcRegisterHandler } from '../ipc/router';
|
import type { IpcRegisterHandler } from '../ipc/router';
|
||||||
import { addLicenseChangeListener } from '../license/licenseService';
|
import { addLicenseChangeListener } from '../license/licenseService';
|
||||||
@@ -13,13 +15,63 @@ import type { LicenseService } from '../license/licenseService';
|
|||||||
const STARTUP_CHECK_DELAY_MS = 12_000;
|
const STARTUP_CHECK_DELAY_MS = 12_000;
|
||||||
/** Не дёргать сервер чаще (смена лицензии / повторные emit). */
|
/** Не дёргать сервер чаще (смена лицензии / повторные emit). */
|
||||||
const RE_CHECK_COOLDOWN_MS = 30_000;
|
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;
|
let lastCheckAt = 0;
|
||||||
/** Ручная установка: не показывать второй диалог из `update-downloaded`. */
|
/** Ручная установка: не показывать второй диалог из `update-downloaded`. */
|
||||||
let suppressAutoInstallDialog = false;
|
let suppressAutoInstallDialog = false;
|
||||||
|
/** Версия, уже скачанная Squirrel/electron-updater (в т.ч. фоном). */
|
||||||
|
let downloadedUpdateVersion: string | null = null;
|
||||||
|
|
||||||
type RegisterFn = IpcRegisterHandler;
|
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 {
|
function isLicensedForUpdates(licenseService: LicenseService): boolean {
|
||||||
const snap = licenseService.getStatusSync();
|
const snap = licenseService.getStatusSync();
|
||||||
return snap.active;
|
return snap.active;
|
||||||
@@ -31,6 +83,7 @@ function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: bo
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
|
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
|
||||||
lastCheckAt = now;
|
lastCheckAt = now;
|
||||||
|
emitUpdaterProgress({ phase: 'checking' });
|
||||||
void autoUpdater.checkForUpdates().catch(() => undefined);
|
void autoUpdater.checkForUpdates().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,42 +94,132 @@ async function runManualUpdaterCheck(licenseService: LicenseService): Promise<Up
|
|||||||
if (!isLicensedForUpdates(licenseService)) {
|
if (!isLicensedForUpdates(licenseService)) {
|
||||||
return { outcome: 'no_license' };
|
return { outcome: 'no_license' };
|
||||||
}
|
}
|
||||||
|
const prevAutoDownload = autoUpdater.autoDownload;
|
||||||
|
autoUpdater.autoDownload = false;
|
||||||
|
emitUpdaterProgress({ phase: 'checking' });
|
||||||
try {
|
try {
|
||||||
const result = await autoUpdater.checkForUpdates();
|
const result = await autoUpdater.checkForUpdates();
|
||||||
if (result && result.isUpdateAvailable && result.updateInfo.version) {
|
if (result && result.isUpdateAvailable && result.updateInfo.version) {
|
||||||
|
emitUpdaterProgress({ phase: 'available', version: result.updateInfo.version });
|
||||||
return { outcome: '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() };
|
return { outcome: 'current', currentVersion: app.getVersion() };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
emitUpdaterProgress({ phase: 'error', message });
|
||||||
return { outcome: '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) {
|
if (!app.isPackaged) {
|
||||||
return { ok: false, message: 'NOT_PACKAGED' };
|
return { ok: false, message: 'NOT_PACKAGED' };
|
||||||
}
|
}
|
||||||
|
const prevAutoInstallOnAppQuit = autoUpdater.autoInstallOnAppQuit;
|
||||||
try {
|
try {
|
||||||
suppressAutoInstallDialog = true;
|
suppressAutoInstallDialog = true;
|
||||||
await autoUpdater.downloadUpdate();
|
if (process.platform === 'darwin') {
|
||||||
autoUpdater.quitAndInstall(false, true);
|
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 };
|
return { ok: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
suppressAutoInstallDialog = false;
|
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 };
|
return { ok: false, message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
|
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
|
||||||
register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService));
|
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` внутри установки).
|
* Канал и 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 {
|
export function installAutoUpdater(licenseService: LicenseService, register: RegisterFn): void {
|
||||||
registerUpdaterHandlers(register, licenseService);
|
registerUpdaterHandlers(register, licenseService);
|
||||||
@@ -89,37 +232,17 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg
|
|||||||
autoUpdater.setFeedURL({ provider: 'generic', url });
|
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.autoDownload = true;
|
||||||
autoUpdater.autoInstallOnAppQuit = true;
|
autoUpdater.autoInstallOnAppQuit = true;
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', (info) => {
|
wireAutoUpdaterEvents(licenseService);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
maybeCheckForUpdates(licenseService, true);
|
maybeCheckForUpdates(licenseService, true);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import { appDisplayNameForLocale, windowChromeTitle } from '../../shared/appBranding';
|
||||||
import { getAppSemanticVersion } from '../versionInfo';
|
import { getAppSemanticVersion } from '../versionInfo';
|
||||||
|
|
||||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
import { loadBrandingWindowIcon } from './brandingIcon';
|
||||||
@@ -63,6 +64,7 @@ export function createBootWindow(): BrowserWindow {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
win.setTitle(windowChromeTitle('boot', app.getLocale()));
|
||||||
|
|
||||||
bootSplashRef = win;
|
bootSplashRef = win;
|
||||||
win.once('closed', () => {
|
win.once('closed', () => {
|
||||||
@@ -93,7 +95,7 @@ export function setBootWindowStatus(win: BrowserWindow, text: string): void {
|
|||||||
|
|
||||||
export function applyBootWindowBranding(win: BrowserWindow): void {
|
export function applyBootWindowBranding(win: BrowserWindow): void {
|
||||||
if (win.isDestroyed()) return;
|
if (win.isDestroyed()) return;
|
||||||
const name = app.getName();
|
const name = appDisplayNameForLocale(app.getLocale());
|
||||||
const version = getAppSemanticVersion();
|
const version = getAppSemanticVersion();
|
||||||
const versionLabel = version.trim().length > 0 ? `v${version.trim()}` : '';
|
const versionLabel = version.trim().length > 0 ? `v${version.trim()}` : '';
|
||||||
void win.webContents.executeJavaScript(
|
void win.webContents.executeJavaScript(
|
||||||
|
|||||||
@@ -35,6 +35,40 @@ void test('createWindows: пульт поверх экрана просмотр
|
|||||||
assert.ok(src.includes("createWindow('control'"));
|
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://)', () => {
|
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
||||||
const src = readCreateWindows();
|
const src = readCreateWindows();
|
||||||
assert.ok(src.includes('loadFile'));
|
assert.ok(src.includes('loadFile'));
|
||||||
@@ -46,3 +80,10 @@ void test('createWindows: показ окна — не только ready-to-sho
|
|||||||
assert.ok(src.includes('ensureWindowBecomesVisible'));
|
assert.ok(src.includes('ensureWindowBecomesVisible'));
|
||||||
assert.ok(src.includes('did-finish-load'));
|
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 { app, BrowserWindow, screen } from 'electron';
|
||||||
|
|
||||||
|
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
|
||||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
|
|
||||||
|
import { safeConsoleError } from '../safeConsole';
|
||||||
|
|
||||||
import { getBootSplashWindow } from './bootWindow';
|
import { getBootSplashWindow } from './bootWindow';
|
||||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
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>();
|
const windows = new Map<WindowKind, BrowserWindow>();
|
||||||
|
|
||||||
|
/** Язык заголовков окон (из редактора); иначе `app.getLocale()`. */
|
||||||
|
let chromeLocaleTagOverride: string | null = null;
|
||||||
|
|
||||||
|
function resolveChromeLocaleTag(): string {
|
||||||
|
return chromeLocaleTagOverride ?? app.getLocale();
|
||||||
|
}
|
||||||
|
|
||||||
let appQuitting = false;
|
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 {
|
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 {
|
export function markAppQuitting(): void {
|
||||||
appQuitting = true;
|
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 {
|
function quitAppFromEditorClose(): void {
|
||||||
markAppQuitting();
|
markAppQuitting();
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -50,6 +193,27 @@ function getRendererHtmlPath(kind: WindowKind): string {
|
|||||||
return path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
|
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 иногда даёт чёрный экран;
|
* В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран;
|
||||||
* `loadFile` корректно открывает HTML из asar и на Windows, и на macOS.
|
* `loadFile` корректно открывает HTML из asar и на Windows, и на macOS.
|
||||||
@@ -57,9 +221,7 @@ function getRendererHtmlPath(kind: WindowKind): string {
|
|||||||
function loadWindowPage(win: BrowserWindow, kind: WindowKind): void {
|
function loadWindowPage(win: BrowserWindow, kind: WindowKind): void {
|
||||||
const dev = process.env.VITE_DEV_SERVER_URL;
|
const dev = process.env.VITE_DEV_SERVER_URL;
|
||||||
if (dev) {
|
if (dev) {
|
||||||
const page =
|
void win.loadURL(new URL(pageNameForKind(kind), dev).toString());
|
||||||
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
|
|
||||||
void win.loadURL(new URL(page, dev).toString());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void win.loadFile(getRendererHtmlPath(kind));
|
void win.loadFile(getRendererHtmlPath(kind));
|
||||||
@@ -84,7 +246,7 @@ export function applyDockIconIfNeeded(): void {
|
|||||||
type CreateWindowOpts = {
|
type CreateWindowOpts = {
|
||||||
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
||||||
parent?: BrowserWindow;
|
parent?: BrowserWindow;
|
||||||
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
|
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
|
||||||
deferVisibility?: boolean;
|
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 {
|
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||||||
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
|
const deferShow = opts?.deferVisibility === true;
|
||||||
const icon = loadBrandingWindowIcon();
|
const icon = loadBrandingWindowIcon();
|
||||||
|
const size = windowSizeForKind(kind);
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
|
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,
|
height: 800,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 600,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(kind === 'npcs'
|
||||||
|
? {
|
||||||
|
width: NPCS_WINDOW_WIDTH,
|
||||||
|
height: NPCS_WINDOW_HEIGHT,
|
||||||
|
minWidth: 560,
|
||||||
|
maxWidth: 900,
|
||||||
|
minHeight: 480,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: '#09090B',
|
backgroundColor: '#09090B',
|
||||||
...(icon ? { icon } : {}),
|
...(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) => {
|
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) => {
|
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||||
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
|
// -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) => {
|
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);
|
ensureWindowBecomesVisible(win);
|
||||||
}
|
}
|
||||||
loadWindowPage(win, kind);
|
loadWindowPage(win, kind);
|
||||||
@@ -161,10 +390,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
quitAppFromEditorClose();
|
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', () => windows.delete(kind));
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
if (kind !== 'presentation' && kind !== 'control') return;
|
if (kind !== 'presentation' && kind !== 'control') return;
|
||||||
const open = windows.has('presentation') || windows.has('control');
|
const open = windows.has('presentation') || windows.has('control');
|
||||||
|
if (!open) {
|
||||||
|
closingPlaySession = false;
|
||||||
|
closePlaySessionAuxiliaryWindows();
|
||||||
|
}
|
||||||
broadcastMultiWindowStateChanged(open);
|
broadcastMultiWindowStateChanged(open);
|
||||||
});
|
});
|
||||||
windows.set(kind, win);
|
windows.set(kind, win);
|
||||||
@@ -244,19 +494,260 @@ export function openMultiWindow() {
|
|||||||
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
|
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
|
||||||
}
|
}
|
||||||
broadcastMultiWindowStateChanged(true);
|
broadcastMultiWindowStateChanged(true);
|
||||||
|
broadcastPresentationContentSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeMultiWindow(): void {
|
export function closeMultiWindow(): void {
|
||||||
|
closingPlaySession = true;
|
||||||
|
closePlaySessionAuxiliaryWindows();
|
||||||
const pres = windows.get('presentation');
|
const pres = windows.get('presentation');
|
||||||
const ctrl = windows.get('control');
|
const ctrl = windows.get('control');
|
||||||
if (pres) pres.close();
|
if (pres && !pres.isDestroyed()) pres.close();
|
||||||
if (ctrl) ctrl.close();
|
if (ctrl && !ctrl.isDestroyed()) ctrl.close();
|
||||||
|
if (!windows.has('presentation') && !windows.has('control')) {
|
||||||
|
closingPlaySession = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMultiWindowOpen(): boolean {
|
export function isMultiWindowOpen(): boolean {
|
||||||
return windows.has('presentation') || windows.has('control');
|
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 {
|
export function togglePresentationFullscreen(): boolean {
|
||||||
const pres = windows.get('presentation');
|
const pres = windows.get('presentation');
|
||||||
if (!pres) return false;
|
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';
|
import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts';
|
||||||
|
|
||||||
@@ -10,9 +10,14 @@ export type DndApi = {
|
|||||||
payload: IpcInvokeMap[K]['req'],
|
payload: IpcInvokeMap[K]['req'],
|
||||||
) => Promise<IpcInvokeMap[K]['res']>;
|
) => Promise<IpcInvokeMap[K]['res']>;
|
||||||
on: <K extends keyof IpcEventMap>(channel: K, listener: (payload: IpcEventMap[K]) => void) => () => void;
|
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);
|
contextBridge.exposeInMainWorld('dnd', api);
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img class="logo" src="./app-window-icon.png" width="72" height="72" alt="" />
|
<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="subtitle">редактор и проигрыватель</p>
|
||||||
<p class="version" data-boot-version></p>
|
<p class="version" data-boot-version></p>
|
||||||
<p class="status" id="boot-status">Запуск…</p>
|
<p class="status" id="boot-status">Запуск…</p>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
<title>DnD Player — Control</title>
|
<title>TTRPG - Control</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,14 +4,20 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px 1fr;
|
grid-template-columns: 280px 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remote {
|
.remote {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: calc(100vh - 32px);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remoteTitle {
|
.remoteTitle {
|
||||||
@@ -79,13 +85,114 @@
|
|||||||
justify-content: center;
|
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 {
|
.radiusRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 100px 1fr 44px;
|
grid-template-columns: 120px 1fr 44px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.effectsSoundRow {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.radiusLabel {
|
.radiusLabel {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -164,6 +271,7 @@
|
|||||||
|
|
||||||
.historyTitle {
|
.historyTitle {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyStory {
|
.emptyStory {
|
||||||
@@ -195,9 +303,29 @@
|
|||||||
|
|
||||||
.previewActions {
|
.previewActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.npcTokenScale {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.videoHint {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -249,7 +377,7 @@
|
|||||||
|
|
||||||
.branchGrid {
|
.branchGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +388,8 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.branchCardHeader {
|
.branchCardHeader {
|
||||||
@@ -276,6 +406,63 @@
|
|||||||
|
|
||||||
.branchName {
|
.branchName {
|
||||||
font-weight: 900;
|
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 {
|
.musicHeader {
|
||||||
@@ -308,10 +495,51 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audioControls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
.audioTransport {
|
.audioTransport {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-shrink: 0;
|
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 {
|
.scrubFill {
|
||||||
@@ -371,3 +599,43 @@
|
|||||||
.audioScrubDefault {
|
.audioScrubDefault {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ctxMenuBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctxMenu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 41;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--stroke, #2a2f3a);
|
||||||
|
background: var(--color-surface-menu, #1a1e28);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctxItem {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text1, #e8eaef);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctxItem:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|||||||
+1447
-384
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||||
import type { SessionState } from '../../shared/ipc/contracts';
|
import type { SessionState } from '../../shared/ipc/contracts';
|
||||||
|
import type { SceneViewCamera } from '../../shared/types';
|
||||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
import { RotatedImage } from '../shared/RotatedImage';
|
import { RotatedImage } from '../shared/RotatedImage';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
@@ -12,6 +13,7 @@ import styles from './ControlScenePreview.module.css';
|
|||||||
type Props = {
|
type Props = {
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||||
|
viewCamera?: SceneViewCamera | null;
|
||||||
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
|
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ function fmt(sec: number): string {
|
|||||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
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 { t } = useEditorI18n();
|
||||||
const [vp, video] = useVideoPlaybackState();
|
const [vp, video] = useVideoPlaybackState();
|
||||||
const scene =
|
const scene =
|
||||||
@@ -33,6 +35,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
|||||||
const isVideo = scene?.previewAssetType === 'video';
|
const isVideo = scene?.previewAssetType === 'video';
|
||||||
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
||||||
const autostart = scene?.previewVideoAutostart ?? false;
|
const autostart = scene?.previewVideoAutostart ?? false;
|
||||||
|
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
|
||||||
|
|
||||||
const [tick, setTick] = useState(0);
|
const [tick, setTick] = useState(0);
|
||||||
const dur = useMemo(
|
const dur = useMemo(
|
||||||
@@ -59,14 +62,18 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVideo) return;
|
if (!isVideo) return;
|
||||||
if (!assetId) return;
|
if (!assetId) return;
|
||||||
// `target.set` bumps revision and resets anchors; avoid firing on every render.
|
const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
|
||||||
if (vp?.targetAssetId === assetId) return;
|
const prev = lastTargetRef.current;
|
||||||
|
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTargetRef.current = { sceneKey, assetId, autostart };
|
||||||
void video.dispatch({
|
void video.dispatch({
|
||||||
kind: 'target.set',
|
kind: 'target.set',
|
||||||
assetId,
|
assetId,
|
||||||
autostart,
|
autostart,
|
||||||
});
|
});
|
||||||
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
|
}, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
@@ -91,7 +98,13 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
|||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
{url && scene?.previewAssetType === 'image' ? (
|
{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 ? (
|
) : url && isVideo ? (
|
||||||
<video
|
<video
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -100,6 +113,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
|||||||
className={styles.video}
|
className={styles.video}
|
||||||
src={url}
|
src={url}
|
||||||
playsInline
|
playsInline
|
||||||
|
loop={Boolean(scene?.settings?.loopVideo)}
|
||||||
preload="auto"
|
preload="auto"
|
||||||
onTimeUpdate={() => setTick((x) => x + 1)}
|
onTimeUpdate={() => setTick((x) => x + 1)}
|
||||||
onLoadedMetadata={() => setTick((x) => x + 1)}
|
onLoadedMetadata={() => setTick((x) => x + 1)}
|
||||||
|
|||||||
@@ -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();
|
const src = readControlApp();
|
||||||
assert.ok(src.includes('molniya.mp3'));
|
assert.ok(src.includes('molniya.mp3'));
|
||||||
assert.ok(src.includes('playLightningEffectSound'));
|
assert.ok(src.includes('playLightningEffectSound'));
|
||||||
|
assert.ok(src.includes('LIGHTNING_EFFECT_MS'));
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('ControlApp: звук заморозки (public/zamorozka.mp3)', () => {
|
void test('ControlApp: звук заморозки (public/zamorozka.mp3)', () => {
|
||||||
@@ -45,23 +46,73 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', (
|
|||||||
assert.ok(sfxSrc.includes('playbackRate'));
|
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("tool: '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', () => {
|
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
|
||||||
const src = readControlApp();
|
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.effects')"));
|
||||||
assert.ok(src.includes("t('control.tools')"));
|
assert.ok(src.includes("t('control.tools')"));
|
||||||
assert.ok(src.includes("t('control.fieldEffects')"));
|
assert.ok(src.includes("t('control.fieldEffects')"));
|
||||||
assert.ok(src.includes("t('control.actionEffects')"));
|
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.sunbeam')"));
|
||||||
|
assert.ok(src.includes("t('control.lightning')"));
|
||||||
assert.ok(src.includes("title={t('control.water')}"));
|
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.poisonCloud')}"));
|
||||||
assert.ok(src.includes("title={t('control.fog')}"));
|
assert.ok(src.includes("title={t('control.fog')}"));
|
||||||
assert.ok(src.includes("ariaLabel={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('iconOnly'));
|
||||||
assert.ok(src.includes("title={t('control.clearEffects')}"));
|
assert.ok(src.includes("title={t('control.clearEffects')}"));
|
||||||
assert.ok(src.includes("ariaLabel={t('control.clearEffects')}"));
|
assert.ok(src.includes("ariaLabel={t('control.clearEffects')}"));
|
||||||
assert.ok(src.includes('#e5484d'));
|
assert.ok(src.includes('#e5484d'));
|
||||||
|
const instruments = src.indexOf("t('control.instruments')");
|
||||||
const fx = src.indexOf("t('control.effects')");
|
const fx = src.indexOf("t('control.effects')");
|
||||||
const story = src.indexOf("t('control.storyLine')");
|
const story = src.indexOf("t('control.storyLine')");
|
||||||
|
assert.ok(
|
||||||
|
instruments !== -1 && fx !== -1 && instruments < fx,
|
||||||
|
'Блок инструментов должен быть выше эффектов',
|
||||||
|
);
|
||||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,6 +127,32 @@ void test('ControlApp: сюжетная линия — колонка сверх
|
|||||||
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => {
|
||||||
|
const src = readControlApp();
|
||||||
|
assert.ok(!src.includes('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)', () => {
|
void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => {
|
||||||
const src = readControlApp();
|
const src = readControlApp();
|
||||||
const css = readControlAppCss();
|
const css = readControlAppCss();
|
||||||
@@ -108,6 +185,55 @@ void test('ControlApp: музыка разделена на сцену и кам
|
|||||||
assert.match(src, /pause campaign\./i);
|
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', () => {
|
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
|
||||||
const src = readControlApp();
|
const src = readControlApp();
|
||||||
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
|
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`). */
|
/** Звук и длительность эффекта «Заморозка» (`public/zamorozka.mp3`). */
|
||||||
|
|
||||||
|
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||||
|
|
||||||
const DEFAULT_FREEZE_LIFE_MS = 820;
|
const DEFAULT_FREEZE_LIFE_MS = 820;
|
||||||
|
|
||||||
const FREEZE_SFX_BASE_VOLUME = 0.88;
|
const FREEZE_SFX_BASE_VOLUME = 0.88;
|
||||||
/** На 25% тише базовой громкости эффекта. */
|
/** На 25% тише базовой громкости эффекта (до множителя «Звук эффектов»). */
|
||||||
export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75;
|
export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75;
|
||||||
|
|
||||||
export function freezeEffectSoundUrl(): string {
|
export function freezeEffectSoundUrl(): string {
|
||||||
@@ -44,7 +46,7 @@ export async function getFreezeEffectLifeMs(): Promise<number> {
|
|||||||
export function playFreezeEffectSound(): void {
|
export function playFreezeEffectSound(): void {
|
||||||
try {
|
try {
|
||||||
const el = new Audio(freezeEffectSoundUrl());
|
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);
|
void el.play().catch(() => undefined);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { ControlApp } from './ControlApp';
|
import { ControlApp } from './ControlApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Пульт">
|
||||||
<EditorI18nProvider>
|
<EditorI18nProvider>
|
||||||
<ControlApp />
|
<ControlApp />
|
||||||
</EditorI18nProvider>
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */
|
/** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */
|
||||||
|
|
||||||
|
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||||
|
|
||||||
const POISON_CLOUD_SFX_VOLUME = 0.92;
|
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 target = Math.max(200, lifeMs);
|
||||||
const rate = Math.max(0.25, Math.min(4, rawMs / target));
|
const rate = Math.max(0.25, Math.min(4, rawMs / target));
|
||||||
const el = new Audio(poisonCloudEffectSoundUrl());
|
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;
|
el.playbackRate = rate;
|
||||||
void el.play().catch(() => undefined);
|
void el.play().catch(() => undefined);
|
||||||
} catch {
|
} 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`). */
|
/** Звук эффекта «Луч света» (`public/luch_sveta.mp3`). */
|
||||||
|
|
||||||
|
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||||
|
|
||||||
const SUNBEAM_SFX_VOLUME = 0.88;
|
const SUNBEAM_SFX_VOLUME = 0.88;
|
||||||
/** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */
|
/** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */
|
||||||
export const SUNBEAM_PLAYBACK_RATE = 1.5;
|
export const SUNBEAM_PLAYBACK_RATE = 1.5;
|
||||||
@@ -47,7 +49,7 @@ export async function getSunbeamEffectLifeMs(): Promise<number> {
|
|||||||
export function playSunbeamEffectSound(): void {
|
export function playSunbeamEffectSound(): void {
|
||||||
try {
|
try {
|
||||||
const el = new Audio(sunbeamEffectSoundUrl());
|
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;
|
el.playbackRate = SUNBEAM_PLAYBACK_RATE;
|
||||||
void el.play().catch(() => undefined);
|
void el.play().catch(() => undefined);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||||
<title>DnD Player — Editor</title>
|
<title>TTRPG - Editor</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -59,15 +59,89 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.splitRun {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
|
background: var(--accent-fill-solid);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitRun:disabled,
|
||||||
|
.splitRun[aria-disabled='true'] {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitRunMain,
|
||||||
|
.splitRunChevron {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitRunMain {
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitRunMain:hover:not(:disabled),
|
||||||
|
.splitRunChevron:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--accent-fill-solid) 72%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitRunMain:active:not(:disabled),
|
||||||
|
.splitRunChevron:active:not(:disabled),
|
||||||
|
.splitRunChevronOpen {
|
||||||
|
background: color-mix(in srgb, var(--accent-fill-solid) 78%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitRunDivider {
|
||||||
|
width: 1px;
|
||||||
|
align-self: stretch;
|
||||||
|
background: rgba(0, 0, 0, 0.28);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitRunChevron {
|
||||||
|
width: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitRunChevronSvg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.editorSidebar {
|
.editorSidebar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: auto 1fr;
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-right: 1px solid var(--stroke);
|
border-right: 1px solid var(--stroke);
|
||||||
background: var(--editor-column-bg);
|
background: var(--editor-column-bg);
|
||||||
overflow: hidden;
|
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 {
|
.editorGraphHost {
|
||||||
@@ -94,25 +168,62 @@
|
|||||||
.gridTools {
|
.gridTools {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
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 {
|
.spacer14 {
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer18 {
|
.spacer18 {
|
||||||
height: 18px;
|
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 {
|
.sidebarScroll {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sceneListGrid {
|
.sceneListGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
justify-items: stretch;
|
justify-items: stretch;
|
||||||
@@ -131,7 +242,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 10000;
|
/* Выше модалок (20001), чтобы прогресс импорта не уходил под диалог выбора. */
|
||||||
|
z-index: 30000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorLockOverlay {
|
.editorLockOverlay {
|
||||||
@@ -169,15 +281,19 @@
|
|||||||
.progressModal {
|
.progressModal {
|
||||||
width: min(520px, calc(100vw - 32px));
|
width: min(520px, calc(100vw - 32px));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 14px;
|
padding: 16px 16px 20px;
|
||||||
background: rgba(25, 28, 38, 0.92);
|
background: rgba(25, 28, 38, 0.92);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressTitle {
|
.progressTitle {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 10px;
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressBar {
|
.progressBar {
|
||||||
@@ -186,6 +302,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressFill {
|
.progressFill {
|
||||||
@@ -195,11 +312,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progressMeta {
|
.progressMeta {
|
||||||
margin-top: 10px;
|
margin-top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inspectorTitle {
|
.inspectorTitle {
|
||||||
@@ -208,6 +327,18 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.inspectorScroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -219,6 +350,11 @@
|
|||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noticeMessage {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.fileMenu {
|
.fileMenu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
@@ -243,6 +379,17 @@
|
|||||||
font: inherit;
|
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 {
|
.fileMenuSubHost {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -289,6 +436,10 @@
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
width: 520px;
|
width: 520px;
|
||||||
max-width: calc(100vw - 32px);
|
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-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
background: var(--color-surface-elevated);
|
background: var(--color-surface-elevated);
|
||||||
@@ -296,6 +447,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalHeader {
|
.modalHeader {
|
||||||
@@ -325,6 +477,17 @@
|
|||||||
.fieldGrid {
|
.fieldGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldGroup {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldGroupSpaced {
|
||||||
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldLabel {
|
.fieldLabel {
|
||||||
@@ -338,17 +501,6 @@
|
|||||||
font-size: var(--text-xs);
|
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 {
|
.rowFlex {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -358,8 +510,13 @@
|
|||||||
.modalFooter {
|
.modalFooter {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.licenseBlockTitle {
|
.licenseBlockTitle {
|
||||||
@@ -393,6 +550,153 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.instructionsDialog {
|
||||||
|
position: fixed;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(920px, calc(100vw - 32px));
|
||||||
|
max-height: min(86vh, 720px);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsLayout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 220px;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: min(62vh, 560px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsContent {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--bg0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsContentTitle {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: var(--text-md);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsParagraph {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsParagraph:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsInlineLink {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent, #a78bfa);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsInlineLink:hover {
|
||||||
|
color: var(--text0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsNav {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--panel2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsNavItem {
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsNavItem:hover {
|
||||||
|
background: var(--scene-list-hover-bg);
|
||||||
|
color: var(--text0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructionsNavItemActive {
|
||||||
|
background: rgba(167, 139, 250, 0.18);
|
||||||
|
color: var(--text0);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aboutBody {
|
||||||
|
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 {
|
.fileSuffix {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -414,6 +718,23 @@
|
|||||||
gap: 10px;
|
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 {
|
.spacer6 {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
@@ -452,6 +773,16 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.projectCardBodyOpening {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projectCardBodyDisabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.projectCardMenuBtn {
|
.projectCardMenuBtn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: -4px -4px 0 0;
|
margin: -4px -4px 0 0;
|
||||||
@@ -490,6 +821,19 @@
|
|||||||
font-weight: 700;
|
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 {
|
.spacer8 {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
@@ -505,6 +849,75 @@
|
|||||||
outline: none;
|
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 {
|
.hint {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -522,6 +935,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
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 {
|
.previewFill {
|
||||||
@@ -586,6 +1008,33 @@
|
|||||||
flex-wrap: wrap;
|
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 {
|
.checkboxLabel {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -615,6 +1064,47 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
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 {
|
.audioList {
|
||||||
@@ -687,6 +1177,7 @@
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sceneCard:not(.sceneCardActive):hover {
|
.sceneCard:not(.sceneCardActive):hover {
|
||||||
@@ -698,6 +1189,36 @@
|
|||||||
background: var(--scene-list-selected-bg);
|
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 {
|
.sceneThumb {
|
||||||
height: 92px;
|
height: 92px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -712,6 +1233,13 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sceneThumbInner img,
|
||||||
|
.sceneThumbInner video,
|
||||||
|
.sceneThumbVideo {
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sceneThumbVideo {
|
.sceneThumbVideo {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -737,21 +1265,23 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sceneCardHeader {
|
.sceneCardHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badgeCurrent {
|
.badgeCurrent {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--accent2);
|
color: var(--accent2);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sceneMenuBtn {
|
.sceneMenuBtn {
|
||||||
margin-left: auto;
|
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--panel2);
|
background: var(--panel2);
|
||||||
border-radius: var(--radius-xs);
|
border-radius: var(--radius-xs);
|
||||||
@@ -765,6 +1295,11 @@
|
|||||||
|
|
||||||
.sceneCardTitle {
|
.sceneCardTitle {
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuBackdrop {
|
.menuBackdrop {
|
||||||
@@ -802,3 +1337,77 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+1358
-211
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;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.handleSide {
|
||||||
|
background: var(--side-story-handle);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -26,6 +32,13 @@
|
|||||||
0 25px 50px -12px rgba(167, 139, 250, 0.12);
|
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 {
|
.previewShell {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -65,6 +78,21 @@
|
|||||||
box-shadow: var(--shadow-start-badge);
|
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 {
|
.cornerBadges {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
@@ -119,6 +147,7 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.musicParams {
|
.musicParams {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Background,
|
Background,
|
||||||
|
ConnectionMode,
|
||||||
Handle,
|
Handle,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
Panel,
|
Panel,
|
||||||
@@ -19,8 +20,15 @@ import ReactFlow, {
|
|||||||
import 'reactflow/dist/style.css';
|
import 'reactflow/dist/style.css';
|
||||||
|
|
||||||
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
|
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 type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
|
||||||
import { RotatedImage } from '../../shared/RotatedImage';
|
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 { useAssetUrl } from '../../shared/useAssetImageUrl';
|
||||||
|
|
||||||
import styles from './SceneGraph.module.css';
|
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). */
|
/** UI strings for the scene graph (passed from editor i18n). */
|
||||||
export type SceneGraphUiStrings = {
|
export type SceneGraphUiStrings = {
|
||||||
badgeStart: string;
|
badgeStart: string;
|
||||||
|
badgeSideStory: string;
|
||||||
untitled: string;
|
untitled: string;
|
||||||
videoBadge: string;
|
videoBadge: string;
|
||||||
audioBadge: string;
|
audioBadge: string;
|
||||||
@@ -67,11 +76,15 @@ export type SceneGraphUiStrings = {
|
|||||||
closeMenu: string;
|
closeMenu: string;
|
||||||
startScene: string;
|
startScene: string;
|
||||||
unsetStartScene: string;
|
unsetStartScene: string;
|
||||||
|
sideStoryStartScene: string;
|
||||||
|
unsetSideStoryStartScene: string;
|
||||||
|
runFromScene: string;
|
||||||
delete: string;
|
delete: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
||||||
badgeStart: 'НАЧАЛО',
|
badgeStart: 'НАЧАЛО',
|
||||||
|
badgeSideStory: 'ПОБОЧНАЯ',
|
||||||
untitled: 'Без названия',
|
untitled: 'Без названия',
|
||||||
videoBadge: 'Видео',
|
videoBadge: 'Видео',
|
||||||
audioBadge: 'Аудио',
|
audioBadge: 'Аудио',
|
||||||
@@ -86,6 +99,9 @@ const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
|||||||
closeMenu: 'Закрыть меню',
|
closeMenu: 'Закрыть меню',
|
||||||
startScene: 'Начальная сцена',
|
startScene: 'Начальная сцена',
|
||||||
unsetStartScene: 'Снять метку «Начальная сцена»',
|
unsetStartScene: 'Снять метку «Начальная сцена»',
|
||||||
|
sideStoryStartScene: 'Начальная сцена побочной линии',
|
||||||
|
unsetSideStoryStartScene: 'Снять метку «Начальная сцена побочной линии»',
|
||||||
|
runFromScene: 'Запустить с этой сцены',
|
||||||
delete: 'Удалить',
|
delete: 'Удалить',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,15 +111,18 @@ export type SceneGraphProps = {
|
|||||||
sceneGraphNodes: SceneGraphNode[];
|
sceneGraphNodes: SceneGraphNode[];
|
||||||
sceneGraphEdges: SceneGraphEdge[];
|
sceneGraphEdges: SceneGraphEdge[];
|
||||||
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
|
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
|
||||||
currentSceneId: SceneId | null;
|
/** Выделенная карточка на графе (одна нода, не все копии сцены). */
|
||||||
|
selectedGraphNodeId: GraphNodeId | null;
|
||||||
graphUi?: SceneGraphUiStrings;
|
graphUi?: SceneGraphUiStrings;
|
||||||
onCurrentSceneChange: (id: SceneId) => void;
|
onGraphNodeSelect: (graphNodeId: GraphNodeId, sceneId: SceneId) => void;
|
||||||
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
|
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
|
||||||
onDisconnect: (edgeId: string) => void;
|
onDisconnect: (edgeId: string) => void;
|
||||||
onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void;
|
onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void;
|
||||||
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
|
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
|
||||||
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
|
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
|
||||||
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
|
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
|
||||||
|
onSetGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => void;
|
||||||
|
onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void;
|
||||||
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
|
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,6 +136,8 @@ type SceneCardData = {
|
|||||||
previewVideoAutostart: boolean;
|
previewVideoAutostart: boolean;
|
||||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||||
isStartScene: boolean;
|
isStartScene: boolean;
|
||||||
|
isSideStoryStart: boolean;
|
||||||
|
isSideStoryNode: boolean;
|
||||||
hasSceneAudio: boolean;
|
hasSceneAudio: boolean;
|
||||||
previewIsVideo: boolean;
|
previewIsVideo: boolean;
|
||||||
hasAnyAudioLoop: boolean;
|
hasAnyAudioLoop: boolean;
|
||||||
@@ -176,15 +197,24 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
|||||||
const ui = useContext(GraphUiContext);
|
const ui = useContext(GraphUiContext);
|
||||||
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
|
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
|
||||||
const previewUrl = useAssetUrl(data.previewAssetId);
|
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 showCornerVideo = data.previewIsVideo;
|
||||||
const showCornerAudio = data.hasSceneAudio;
|
const showCornerAudio = data.hasSceneAudio;
|
||||||
return (
|
return (
|
||||||
<div className={styles.nodeWrap}>
|
<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={cardClass}>
|
||||||
<div className={styles.previewShell}>
|
<div className={styles.previewShell}>
|
||||||
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
|
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
|
||||||
|
{data.isSideStoryStart ? (
|
||||||
|
<div className={styles.badgeSideStory}>{ui.badgeSideStory}</div>
|
||||||
|
) : null}
|
||||||
{thumbUrl ? (
|
{thumbUrl ? (
|
||||||
<div className={styles.previewFill}>
|
<div className={styles.previewFill}>
|
||||||
{data.previewRotationDeg === 0 ? (
|
{data.previewRotationDeg === 0 ? (
|
||||||
@@ -265,7 +295,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.nodeBody}>
|
<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 ? (
|
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||||||
<div className={styles.musicParams}>
|
<div className={styles.musicParams}>
|
||||||
{data.hasAnyAudioLoop ? (
|
{data.hasAnyAudioLoop ? (
|
||||||
@@ -300,7 +330,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Handle type="source" position={Position.Bottom} className={styles.handle} />
|
<Handle type="source" position={Position.Bottom} className={handleClass} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -350,39 +380,66 @@ function SceneGraphCanvas({
|
|||||||
sceneGraphNodes,
|
sceneGraphNodes,
|
||||||
sceneGraphEdges,
|
sceneGraphEdges,
|
||||||
sceneCardById,
|
sceneCardById,
|
||||||
currentSceneId,
|
selectedGraphNodeId,
|
||||||
graphUi,
|
graphUi,
|
||||||
onCurrentSceneChange,
|
onGraphNodeSelect,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
onNodePositionCommit,
|
onNodePositionCommit,
|
||||||
onRemoveGraphNodes,
|
onRemoveGraphNodes,
|
||||||
onRemoveGraphNode,
|
onRemoveGraphNode,
|
||||||
onSetGraphNodeStart,
|
onSetGraphNodeStart,
|
||||||
|
onSetGraphNodeSideStoryStart,
|
||||||
|
onRunFromGraphNode,
|
||||||
onDropSceneFromList,
|
onDropSceneFromList,
|
||||||
}: SceneGraphProps) {
|
}: SceneGraphProps) {
|
||||||
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
|
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
|
||||||
const { screenToFlowPosition } = useReactFlow();
|
const { screenToFlowPosition } = useReactFlow();
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu && !edgeMenu) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setMenu(null);
|
if (e.key === 'Escape') {
|
||||||
|
setMenu(null);
|
||||||
|
setEdgeMenu(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [menu]);
|
}, [edgeMenu, menu]);
|
||||||
|
|
||||||
const menuNodeIsStart = useMemo(() => {
|
const menuNodeIsStart = useMemo(() => {
|
||||||
if (!menu) return false;
|
if (!menu) return false;
|
||||||
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene);
|
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene);
|
||||||
}, [menu, sceneGraphNodes]);
|
}, [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>[]>(() => {
|
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
|
||||||
return sceneGraphNodes.map((gn) => {
|
return sceneGraphNodes.map((gn) => {
|
||||||
const c = sceneCardById[gn.sceneId];
|
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 ?? [];
|
const audios = c?.audios ?? [];
|
||||||
return {
|
return {
|
||||||
id: gn.id,
|
id: gn.id,
|
||||||
@@ -398,6 +455,8 @@ function SceneGraphCanvas({
|
|||||||
previewVideoAutostart: c?.previewVideoAutostart ?? false,
|
previewVideoAutostart: c?.previewVideoAutostart ?? false,
|
||||||
previewRotationDeg: c?.previewRotationDeg ?? 0,
|
previewRotationDeg: c?.previewRotationDeg ?? 0,
|
||||||
isStartScene: gn.isStartScene,
|
isStartScene: gn.isStartScene,
|
||||||
|
isSideStoryStart: gn.isSideStoryStart,
|
||||||
|
isSideStoryNode,
|
||||||
hasSceneAudio: audios.length >= 1,
|
hasSceneAudio: audios.length >= 1,
|
||||||
previewIsVideo: c?.previewAssetType === 'video',
|
previewIsVideo: c?.previewAssetType === 'video',
|
||||||
hasAnyAudioLoop: audios.some((a) => a.loop),
|
hasAnyAudioLoop: audios.some((a) => a.loop),
|
||||||
@@ -408,39 +467,46 @@ function SceneGraphCanvas({
|
|||||||
style: { padding: 0, background: 'transparent', border: 'none' },
|
style: { padding: 0, background: 'transparent', border: 'none' },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
|
}, [sceneCardById, sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
|
||||||
|
|
||||||
const desiredEdges = useMemo<Edge[]>(() => {
|
const desiredEdges = useMemo<Edge[]>(() => {
|
||||||
const selectedGraphNodeIds = new Set<GraphNodeId>();
|
const hasSelection = selectedGraphNodeId != null;
|
||||||
if (currentSceneId) {
|
return sceneGraphEdges.map((e) => {
|
||||||
for (const gn of sceneGraphNodes) {
|
const isSide = isSideStoryEdge(sceneGraphNodes, sceneGraphEdges, e);
|
||||||
if (gn.sceneId === currentSceneId) selectedGraphNodeIds.add(gn.id);
|
const strokeActive = isSide ? sideStoryEdgeStroke : mainEdgeStroke;
|
||||||
}
|
const strokeIdle = isSide ? sideStoryEdgeStrokeDim : mainEdgeStrokeDim;
|
||||||
}
|
const strokeDim = 'rgba(255,255,255,0.10)';
|
||||||
const hasSelection = selectedGraphNodeIds.size > 0;
|
const markerDim = 'rgba(255,255,255,0.18)';
|
||||||
return sceneGraphEdges.map((e) => ({
|
const touchesSelection =
|
||||||
|
selectedGraphNodeId != null &&
|
||||||
|
(e.sourceGraphNodeId === selectedGraphNodeId || e.targetGraphNodeId === selectedGraphNodeId);
|
||||||
|
return {
|
||||||
...(hasSelection
|
...(hasSelection
|
||||||
? {
|
? {
|
||||||
style:
|
style: touchesSelection
|
||||||
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
|
? { stroke: strokeActive, strokeWidth: 3 }
|
||||||
? { stroke: 'rgba(167,139,250,0.95)', strokeWidth: 3 }
|
: { stroke: strokeDim, strokeWidth: 2 },
|
||||||
: { stroke: 'rgba(255,255,255,0.10)', strokeWidth: 2 },
|
markerEnd: touchesSelection
|
||||||
markerEnd:
|
? { type: MarkerType.ArrowClosed, color: strokeActive, strokeWidth: 2 }
|
||||||
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
|
: { type: MarkerType.ArrowClosed, color: markerDim, strokeWidth: 2 },
|
||||||
? { 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 },
|
style: { stroke: strokeIdle, strokeWidth: 2 },
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', 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,
|
id: e.id,
|
||||||
source: e.sourceGraphNodeId,
|
source: e.sourceGraphNodeId,
|
||||||
target: e.targetGraphNodeId,
|
target: e.targetGraphNodeId,
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: false,
|
animated: false,
|
||||||
}));
|
selectable: false,
|
||||||
}, [currentSceneId, sceneGraphEdges, sceneGraphNodes]);
|
};
|
||||||
|
});
|
||||||
|
}, [sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
@@ -485,12 +551,22 @@ function SceneGraphCanvas({
|
|||||||
if (!menu) return null;
|
if (!menu) return null;
|
||||||
const pad = 8;
|
const pad = 8;
|
||||||
const mw = 220;
|
const mw = 220;
|
||||||
const mh = 120;
|
const mh = 210;
|
||||||
const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad));
|
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));
|
const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad));
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}, [menu]);
|
}, [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 (
|
return (
|
||||||
<GraphUiContext.Provider value={ui}>
|
<GraphUiContext.Provider value={ui}>
|
||||||
<div className={styles.canvasWrap}>
|
<div className={styles.canvasWrap}>
|
||||||
@@ -504,33 +580,35 @@ function SceneGraphCanvas({
|
|||||||
}}
|
}}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
|
connectionMode={ConnectionMode.Loose}
|
||||||
onConnect={onConnectInternal}
|
onConnect={onConnectInternal}
|
||||||
onEdgesDelete={(eds) => {
|
onEdgeContextMenu={(e, edge) => {
|
||||||
for (const ed of eds) {
|
e.preventDefault();
|
||||||
onDisconnect(ed.id);
|
setMenu(null);
|
||||||
}
|
setEdgeMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
|
||||||
}}
|
|
||||||
onEdgeClick={(_, edge) => {
|
|
||||||
onDisconnect(edge.id);
|
|
||||||
}}
|
}}
|
||||||
onNodesDelete={(nds) => {
|
onNodesDelete={(nds) => {
|
||||||
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
|
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
|
||||||
}}
|
}}
|
||||||
onNodeClick={(_, node) => {
|
onNodeClick={(_, node) => {
|
||||||
setMenu(null);
|
setMenu(null);
|
||||||
|
setEdgeMenu(null);
|
||||||
const d = node.data as SceneCardData;
|
const d = node.data as SceneCardData;
|
||||||
onCurrentSceneChange(d.sceneId);
|
onGraphNodeSelect(node.id as GraphNodeId, d.sceneId);
|
||||||
}}
|
}}
|
||||||
onNodeContextMenu={(e, node) => {
|
onNodeContextMenu={(e, node) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setEdgeMenu(null);
|
||||||
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
|
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
|
||||||
}}
|
}}
|
||||||
onPaneClick={() => {
|
onPaneClick={() => {
|
||||||
setMenu(null);
|
setMenu(null);
|
||||||
|
setEdgeMenu(null);
|
||||||
}}
|
}}
|
||||||
onPaneContextMenu={(e) => {
|
onPaneContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMenu(null);
|
setMenu(null);
|
||||||
|
setEdgeMenu(null);
|
||||||
}}
|
}}
|
||||||
onInit={(instance) => {
|
onInit={(instance) => {
|
||||||
instance.fitView({ padding: 0.25 });
|
instance.fitView({ padding: 0.25 });
|
||||||
@@ -539,6 +617,7 @@ function SceneGraphCanvas({
|
|||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
panOnScroll
|
panOnScroll
|
||||||
selectionOnDrag={false}
|
selectionOnDrag={false}
|
||||||
|
minZoom={0.1}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
>
|
>
|
||||||
@@ -579,6 +658,33 @@ function SceneGraphCanvas({
|
|||||||
>
|
>
|
||||||
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
|
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -595,6 +701,41 @@ function SceneGraphCanvas({
|
|||||||
document.body,
|
document.body,
|
||||||
)
|
)
|
||||||
: null}
|
: 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>
|
</div>
|
||||||
</GraphUiContext.Provider>
|
</GraphUiContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ function minimalProject(overrides: Partial<Project>): Project {
|
|||||||
schemaVersion: 1 as unknown as Project['meta']['schemaVersion'],
|
schemaVersion: 1 as unknown as Project['meta']['schemaVersion'],
|
||||||
},
|
},
|
||||||
scenes: {},
|
scenes: {},
|
||||||
|
sceneListOrder: [],
|
||||||
assets: {},
|
assets: {},
|
||||||
campaignAudios: [],
|
campaignAudios: [],
|
||||||
|
materials: [],
|
||||||
|
npcs: [],
|
||||||
|
npcRelations: [],
|
||||||
currentSceneId: null,
|
currentSceneId: null,
|
||||||
currentGraphNodeId: null,
|
currentGraphNodeId: null,
|
||||||
sceneGraphNodes: [],
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/** Порядок разделов в окне «Инструкция». */
|
||||||
|
export const HELP_SECTION_IDS = [
|
||||||
|
'overview',
|
||||||
|
'license',
|
||||||
|
'projects',
|
||||||
|
'scenes',
|
||||||
|
'graph',
|
||||||
|
'sideStorylines',
|
||||||
|
'sceneProps',
|
||||||
|
'sceneEditor',
|
||||||
|
'grid',
|
||||||
|
'traps',
|
||||||
|
'tokens',
|
||||||
|
'campaignAudio',
|
||||||
|
'materials',
|
||||||
|
'players',
|
||||||
|
'npcs',
|
||||||
|
'session',
|
||||||
|
'controlPanel',
|
||||||
|
'transitions',
|
||||||
|
'music',
|
||||||
|
'effects',
|
||||||
|
'presentation',
|
||||||
|
'importExport',
|
||||||
|
'settings',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type HelpSectionId = (typeof HELP_SECTION_IDS)[number];
|
||||||
|
|
||||||
|
export function helpSectionTitleKey(id: HelpSectionId): string {
|
||||||
|
return `help.section.${id}.title`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function helpSectionBodyKey(id: HelpSectionId): string {
|
||||||
|
return `help.section.${id}.body`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EDITOR_LOCALE_STORAGE_KEY,
|
EDITOR_LOCALE_STORAGE_KEY,
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
translateEditorMessage,
|
translateEditorMessage,
|
||||||
type EditorLocale,
|
type EditorLocale,
|
||||||
} from './editorMessages';
|
} from './editorMessages';
|
||||||
|
import { getDndApi } from '../../shared/dndApi';
|
||||||
|
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||||
|
|
||||||
type EditorI18nContextValue = {
|
type EditorI18nContextValue = {
|
||||||
locale: EditorLocale;
|
locale: EditorLocale;
|
||||||
@@ -36,6 +38,25 @@ export function EditorI18nProvider({ children }: { children: React.ReactNode })
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tag = locale === 'ru' ? 'ru-RU' : 'en-US';
|
||||||
|
try {
|
||||||
|
void getDndApi().invoke(ipcChannels.windows.syncChromeTitles, { localeTag: tag });
|
||||||
|
} catch {
|
||||||
|
// preload ещё не готов (редко при первом кадре)
|
||||||
|
}
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
// Другие окна Electron (пульт, материалы) подхватывают смену языка из редактора.
|
||||||
|
useEffect(() => {
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key !== EDITOR_LOCALE_STORAGE_KEY) return;
|
||||||
|
setLocaleState(normalizeEditorLocale(e.newValue));
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
return () => window.removeEventListener('storage', onStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const t = useCallback(
|
const t = useCallback(
|
||||||
(key: string, vars?: Record<string, string | number>) => translateEditorMessage(locale, key, vars),
|
(key: string, vars?: Record<string, string | number>) => translateEditorMessage(locale, key, vars),
|
||||||
[locale],
|
[locale],
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { inferEditorLocaleFromSystem, normalizeEditorLocale } from './editorMessages';
|
import { HELP_SECTION_IDS, helpSectionBodyKey, helpSectionTitleKey } from '../help/helpSections';
|
||||||
|
|
||||||
|
import { EDITOR_MESSAGES, inferEditorLocaleFromSystem, normalizeEditorLocale } from './editorMessages';
|
||||||
|
|
||||||
void test('inferEditorLocaleFromSystem: en-* wins when listed first', () => {
|
void test('inferEditorLocaleFromSystem: en-* wins when listed first', () => {
|
||||||
assert.equal(inferEditorLocaleFromSystem(['en-GB', 'ru-RU']), 'en');
|
assert.equal(inferEditorLocaleFromSystem(['en-GB', 'ru-RU']), 'en');
|
||||||
@@ -24,7 +26,45 @@ void test('normalizeEditorLocale: trims stored en/ru', () => {
|
|||||||
assert.equal(normalizeEditorLocale('ru '), 'ru');
|
assert.equal(normalizeEditorLocale('ru '), 'ru');
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('normalizeEditorLocale: blank or invalid defers to infer (explicit list)', () => {
|
void test('normalizeEditorLocale: blank or invalid defers to system infer', () => {
|
||||||
assert.equal(normalizeEditorLocale(''), inferEditorLocaleFromSystem([]));
|
const inferred = inferEditorLocaleFromSystem();
|
||||||
assert.equal(normalizeEditorLocale('xx'), inferEditorLocaleFromSystem([]));
|
assert.equal(normalizeEditorLocale(''), inferred);
|
||||||
|
assert.equal(normalizeEditorLocale('xx'), inferred);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('EDITOR_MESSAGES: ru and en have the same keys', () => {
|
||||||
|
const ruKeys = Object.keys(EDITOR_MESSAGES.ru).sort();
|
||||||
|
const enKeys = Object.keys(EDITOR_MESSAGES.en).sort();
|
||||||
|
assert.deepEqual(enKeys, ruKeys);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('EDITOR_MESSAGES: every help section has title and body in both locales', () => {
|
||||||
|
for (const id of HELP_SECTION_IDS) {
|
||||||
|
const title = helpSectionTitleKey(id);
|
||||||
|
const body = helpSectionBodyKey(id);
|
||||||
|
for (const locale of ['ru', 'en'] as const) {
|
||||||
|
assert.ok(EDITOR_MESSAGES[locale][title], `missing ${locale} ${title}`);
|
||||||
|
assert.ok(EDITOR_MESSAGES[locale][body], `missing ${locale} ${body}`);
|
||||||
|
assert.notEqual(EDITOR_MESSAGES[locale][title]!.trim(), '');
|
||||||
|
assert.notEqual(EDITOR_MESSAGES[locale][body]!.trim(), '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('EDITOR_MESSAGES: materials.* keys exist in both locales', () => {
|
||||||
|
const materialKeys = Object.keys(EDITOR_MESSAGES.ru).filter((k) => k.startsWith('materials.'));
|
||||||
|
assert.ok(materialKeys.length >= 20, `expected materials.* keys, got ${String(materialKeys.length)}`);
|
||||||
|
for (const key of materialKeys) {
|
||||||
|
assert.ok(EDITOR_MESSAGES.en[key], `missing en ${key}`);
|
||||||
|
assert.notEqual(EDITOR_MESSAGES.en[key]!.trim(), '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('EDITOR_MESSAGES: npcs.* keys exist in both locales', () => {
|
||||||
|
const npcKeys = Object.keys(EDITOR_MESSAGES.ru).filter((k) => k.startsWith('npcs.'));
|
||||||
|
assert.ok(npcKeys.length >= 20, `expected npcs.* keys, got ${String(npcKeys.length)}`);
|
||||||
|
for (const key of npcKeys) {
|
||||||
|
assert.ok(EDITOR_MESSAGES.en[key], `missing en ${key}`);
|
||||||
|
assert.notEqual(EDITOR_MESSAGES.en[key]!.trim(), '');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
import { EditorApp } from './EditorApp';
|
import { EditorApp } from './EditorApp';
|
||||||
import { EditorI18nProvider } from './i18n/EditorI18nContext';
|
import { EditorI18nProvider } from './i18n/EditorI18nContext';
|
||||||
|
|
||||||
@@ -12,8 +13,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<WindowErrorBoundary title="Редактор">
|
||||||
<EditorI18nProvider>
|
<EditorI18nProvider>
|
||||||
<EditorApp />
|
<EditorApp />
|
||||||
</EditorI18nProvider>
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { isSceneDescriptionEmpty, normalizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
||||||
|
|
||||||
|
void test('isSceneDescriptionEmpty treats blank and empty paragraphs as empty', () => {
|
||||||
|
assert.equal(isSceneDescriptionEmpty(''), true);
|
||||||
|
assert.equal(isSceneDescriptionEmpty(' '), true);
|
||||||
|
assert.equal(isSceneDescriptionEmpty('<p></p>'), true);
|
||||||
|
assert.equal(isSceneDescriptionEmpty('<p><br></p>'), true);
|
||||||
|
assert.equal(isSceneDescriptionEmpty('<p> </p>'), true);
|
||||||
|
assert.equal(isSceneDescriptionEmpty('<p>Hi</p>'), false);
|
||||||
|
assert.equal(isSceneDescriptionEmpty('plain'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('normalizeSceneDescriptionHtml clears empty markup', () => {
|
||||||
|
assert.equal(normalizeSceneDescriptionHtml('<p></p>'), '');
|
||||||
|
assert.equal(normalizeSceneDescriptionHtml('<p>Ok</p>'), '<p>Ok</p>');
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/** Detect empty TipTap / legacy plain description values. */
|
||||||
|
export function isSceneDescriptionEmpty(html: string | null | undefined): boolean {
|
||||||
|
if (html == null) return true;
|
||||||
|
const trimmed = html.trim();
|
||||||
|
if (trimmed === '') return true;
|
||||||
|
const text = trimmed
|
||||||
|
.replace(/<br\s*\/?>/gi, ' ')
|
||||||
|
.replace(/<\/(p|div|h[1-6]|li|blockquote)>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return text.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist empty editor as '' instead of empty paragraph markup. */
|
||||||
|
export function normalizeSceneDescriptionHtml(html: string): string {
|
||||||
|
return isSceneDescriptionEmpty(html) ? '' : html.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_TAGS = new Set([
|
||||||
|
'P',
|
||||||
|
'BR',
|
||||||
|
'STRONG',
|
||||||
|
'B',
|
||||||
|
'EM',
|
||||||
|
'I',
|
||||||
|
'U',
|
||||||
|
'S',
|
||||||
|
'H2',
|
||||||
|
'H3',
|
||||||
|
'UL',
|
||||||
|
'OL',
|
||||||
|
'LI',
|
||||||
|
'BLOCKQUOTE',
|
||||||
|
'A',
|
||||||
|
'CODE',
|
||||||
|
'SPAN',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Sanitize TipTap HTML for safe preview rendering. */
|
||||||
|
export function sanitizeSceneDescriptionHtml(html: string): string {
|
||||||
|
if (typeof document === 'undefined') return html;
|
||||||
|
if (isSceneDescriptionEmpty(html)) return '';
|
||||||
|
if (!/<[a-z][\s\S]*>/i.test(html)) {
|
||||||
|
const esc = document.createElement('div');
|
||||||
|
esc.textContent = html;
|
||||||
|
return esc.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = document.createElement('template');
|
||||||
|
template.innerHTML = html.trim();
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.appendChild(template.content.cloneNode(true));
|
||||||
|
|
||||||
|
const walk = (node: Node) => {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const tag = el.tagName;
|
||||||
|
if (!ALLOWED_TAGS.has(tag)) {
|
||||||
|
const parent = el.parentNode;
|
||||||
|
if (parent) {
|
||||||
|
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
||||||
|
parent.removeChild(el);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const attr of [...el.attributes]) {
|
||||||
|
const name = attr.name.toLowerCase();
|
||||||
|
if (tag === 'A' && (name === 'href' || name === 'target' || name === 'rel')) {
|
||||||
|
if (name === 'href') {
|
||||||
|
const href = attr.value.trim();
|
||||||
|
if (!/^(https?:|mailto:)/i.test(href)) {
|
||||||
|
el.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
el.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
if (tag === 'A') {
|
||||||
|
el.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
el.setAttribute('target', '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of [...node.childNodes]) walk(child);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk children only — never treat the container as a removable tag
|
||||||
|
// (that would unwrap it and leave container.innerHTML empty).
|
||||||
|
for (const child of [...container.childNodes]) walk(child);
|
||||||
|
return container.innerHTML;
|
||||||
|
}
|
||||||
@@ -17,12 +17,28 @@ void test('projectState: list/get after delete invalidates in-flight initial loa
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/const openProject = async[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?await api\.invoke/,
|
/const openProject = async[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?const epoch = projectDataEpochRef\.current[\s\S]+?openInFlightRef\.current = null[\s\S]+?if \(projectDataEpochRef\.current !== epoch\)/,
|
||||||
);
|
);
|
||||||
|
assert.match(src, /openInFlightRef\.current = null[\s\S]+?openingProjectId: null/);
|
||||||
assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/);
|
assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('projectState: createScene clears creatingScene and in-flight ref', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'projectState.ts'), 'utf8');
|
||||||
|
assert.match(src, /createSceneInFlightRef\.current = job;/);
|
||||||
|
assert.match(src, /creatingScene: false/);
|
||||||
|
assert.doesNotMatch(src, /createSceneInFlightRef\.current = job\.finally/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('ipc router: project.list does not require license', () => {
|
void test('ipc router: project.list does not require license', () => {
|
||||||
const routerSrc = fs.readFileSync(path.join(here, '..', '..', '..', 'main', 'ipc', 'router.ts'), 'utf8');
|
const routerSrc = fs.readFileSync(path.join(here, '..', '..', '..', 'main', 'ipc', 'router.ts'), 'utf8');
|
||||||
assert.match(routerSrc, /if \(channel === ipcChannels\.project\.list\) return false/);
|
assert.match(routerSrc, /if \(channel === ipcChannels\.project\.list\) return false/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('projectState: openProject не выбирает сцену (selectedSceneId: null)', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'projectState.ts'), 'utf8');
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/const openProject = async[\s\S]+?openingProjectId: id, selectedSceneId: null[\s\S]+?selectedSceneId: null,\s*openingProjectId: null/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user