Compare commits
95 Commits
v1.0.3
...
10cbb3b256
| Author | SHA1 | Date | |
|---|---|---|---|
| 10cbb3b256 | |||
| c46ff34393 | |||
| 7a25b18268 | |||
| d4dc4e7f3c | |||
| 2979d06f1c | |||
| 61446dacfc | |||
| 41b112159f | |||
| a3a03eb9e3 | |||
| e08f5ef550 | |||
| 04c75cd725 | |||
| e687303c57 | |||
| c7bf7cf449 | |||
| a2b418b78a | |||
| f270812219 | |||
| bdeb64e356 | |||
| 4aa0f257d5 | |||
| 9d82e74272 | |||
| fed5674468 | |||
| 02d73ddf81 | |||
| d3b1c4660d | |||
| eb127c11c2 | |||
| 2c0e6fbf09 | |||
| a797162f16 | |||
| d9fbecf5a7 | |||
| 32a5479086 | |||
| c1c332364c | |||
| 0cab7ce7ca | |||
| 1829191410 | |||
| f4c0ac1438 | |||
| 37ba855faf | |||
| 61875be857 | |||
| 195d4be086 | |||
| 8d73f7744e | |||
| e731ddb1c0 | |||
| cfa3959fb3 | |||
| 35a6e979eb | |||
| b7e6ff6915 | |||
| c8ab9dd567 | |||
| de9190959c | |||
| 10bb7013e6 | |||
| 83a326b0b9 | |||
| 089da3cae0 | |||
| f5240d1623 | |||
| 4631a1bece | |||
| d54e9ed02d | |||
| ca0cf9c0ce | |||
| 657e3c862e | |||
| 97c77a025a | |||
| 42b067615d | |||
| 0c45d58359 | |||
| 1eed2b38f8 | |||
| 60ef837e1c | |||
| 374e041321 | |||
| 3acb89c80d | |||
| 8eb88399a3 | |||
| a96e1f8465 | |||
| 80103a00e7 | |||
| b017155eaf | |||
| 5706355c5f | |||
| 428fa09224 | |||
| 1cda87fe13 | |||
| 10de99bb06 | |||
| 0ae3c39333 | |||
| d07dcae626 | |||
| dd0dd646f6 | |||
| 8ec830cdb5 | |||
| 02b3131f19 | |||
| cfa067519d | |||
| 4e5d320c36 | |||
| 411ac634f4 | |||
| ece48fe53d | |||
| 744ead383d | |||
| 9f82a541fc | |||
| 963a1f0790 | |||
| 394b42e845 | |||
| 6204359330 | |||
| 7c858ba633 | |||
| 2c03921d23 | |||
| 285a1a9667 | |||
| 0e14180044 | |||
| cd3ba5fe07 | |||
| 26f8a81631 | |||
| 2dc7015f53 | |||
| 0eadfdce30 | |||
| 7e7827224d | |||
| 8fa8467db7 | |||
| af4c2616f2 | |||
| 3877a6f2a6 | |||
| 1c6f06b278 | |||
| 064592d4d4 | |||
| e923de350d | |||
| 07641be2d2 | |||
| 8bc2e5bd49 | |||
| 2037144a5c | |||
| 1840227be6 |
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -1,131 +0,0 @@
|
||||
# Сборка по тегу v* и выкладка в публичный репо (ветка updates).
|
||||
#
|
||||
# Метки runs-on = labels твоих act_runner (Админка Gitea → Действия → Раннеры).
|
||||
# Не используем windows-latest/macos-latest — это только GitHub-hosted.
|
||||
# По умолчанию: одна Linux-сборка Win+NSIS (Wine). macOS — когда будет раннер (см. комментарий в build-macos).
|
||||
#
|
||||
# Один 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
|
||||
|
||||
# Не используем `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"
|
||||
|
||||
- run: npm run build
|
||||
|
||||
- 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}"
|
||||
|
||||
- name: Каталог артефактов для feed (_win)
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p _win
|
||||
shopt -s nullglob || true
|
||||
for f in release/*; do
|
||||
[[ -f "$f" ]] || continue
|
||||
base=$(basename "$f")
|
||||
case "$base" in
|
||||
*.yml|*.yaml|*.exe|*.blockmap|*.zip) cp -v "$f" _win/ ;;
|
||||
esac
|
||||
done
|
||||
ls -la _win
|
||||
|
||||
- name: Пустой каталог mac (пока нет сборки mac в CI)
|
||||
run: mkdir -p _mac
|
||||
|
||||
- name: Push в публичный репозиторий updates
|
||||
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 }}/_win
|
||||
ARTIFACT_MAC: ${{ github.workspace }}/_mac
|
||||
GIT_COMMIT_TAG: ${{ github.ref_name }}
|
||||
run: node scripts/sync-update-feed.mjs
|
||||
|
||||
# Когда появится macOS-раннер: отдельный job build-macos, копирование eb-mac в _mac перед sync
|
||||
# или расширить шаг «Каталог артефактов»; сейчас всё в одном job `release`.
|
||||
#
|
||||
# build-macos:
|
||||
# runs-on: macos-14
|
||||
# steps: ...
|
||||
@@ -3,6 +3,11 @@ import test from 'node:test';
|
||||
|
||||
import { EffectsStore } from './effectsStore';
|
||||
|
||||
void test('defaultTool: при старте инструмент не выбран', () => {
|
||||
const store = new EffectsStore();
|
||||
assert.equal(store.getState().tool.tool, 'none');
|
||||
});
|
||||
|
||||
void test('pruneExpired: лёд не удаляется по времени', () => {
|
||||
const store = new EffectsStore();
|
||||
const createdAtMs = Date.now() - 365 * 24 * 60 * 60 * 1000;
|
||||
@@ -64,7 +69,7 @@ void test('pruneExpired: луч света удаляется после lifetim
|
||||
assert.equal(store.getState().instances.length, 0);
|
||||
});
|
||||
|
||||
void test('pruneExpired: облако яда удаляется после lifetime', () => {
|
||||
void test('pruneExpired: яд удаляется после lifetime', () => {
|
||||
const store = new EffectsStore();
|
||||
store.dispatch({
|
||||
kind: 'instance.add',
|
||||
@@ -82,3 +87,22 @@ void test('pruneExpired: облако яда удаляется после lifet
|
||||
assert.equal(store.pruneExpired(), true);
|
||||
assert.equal(store.getState().instances.length, 0);
|
||||
});
|
||||
|
||||
void test('pruneExpired: взрыв удаляется после lifetime', () => {
|
||||
const store = new EffectsStore();
|
||||
store.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: 'ex_test',
|
||||
type: 'explosion',
|
||||
seed: 1,
|
||||
createdAtMs: Date.now() - 20_000,
|
||||
at: { x: 0.5, y: 0.5 },
|
||||
radiusN: 0.08,
|
||||
intensity: 1,
|
||||
lifetimeMs: 4200,
|
||||
},
|
||||
});
|
||||
assert.equal(store.pruneExpired(), true);
|
||||
assert.equal(store.getState().instances.length, 0);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
|
||||
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
|
||||
|
||||
function nowMs(): number {
|
||||
@@ -7,7 +8,12 @@ function nowMs(): number {
|
||||
}
|
||||
|
||||
function defaultTool(): EffectToolState {
|
||||
return { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
|
||||
return { tool: 'none', radiusN: 0.08, intensity: 0.6 };
|
||||
}
|
||||
|
||||
/** Сброс выбранного инструмента (при старте сессии). */
|
||||
export function effectsDefaultTool(): EffectToolState {
|
||||
return defaultTool();
|
||||
}
|
||||
|
||||
export class EffectsStore {
|
||||
@@ -35,7 +41,7 @@ export class EffectsStore {
|
||||
|
||||
dispatch(event: EffectsEvent): EffectsState {
|
||||
const s = this.state;
|
||||
const next: EffectsState = applyEvent(s, event);
|
||||
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
|
||||
this.state = next;
|
||||
return next;
|
||||
}
|
||||
@@ -46,14 +52,20 @@ export class EffectsStore {
|
||||
const before = this.state.instances.length;
|
||||
const kept = this.state.instances.filter((i) => {
|
||||
// Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI).
|
||||
if (i.type === 'ice') return true;
|
||||
if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud') {
|
||||
if (i.type === 'ice' || i.type === 'shadow') return true;
|
||||
if (
|
||||
i.type === 'lightning' ||
|
||||
i.type === 'sunbeam' ||
|
||||
i.type === 'poisonCloud' ||
|
||||
i.type === 'explosion' ||
|
||||
i.type === 'darkness'
|
||||
) {
|
||||
return now - i.createdAtMs < i.lifetimeMs;
|
||||
}
|
||||
if (i.type === 'scorch') {
|
||||
return now - i.createdAtMs < i.lifetimeMs;
|
||||
}
|
||||
if (i.type === 'fog' || i.type === 'water') {
|
||||
if (i.type === 'fog' || i.type === 'fire' || i.type === 'rain' || i.type === 'water') {
|
||||
if (i.lifetimeMs === null) return true;
|
||||
return now - i.createdAtMs < i.lifetimeMs;
|
||||
}
|
||||
@@ -74,7 +86,11 @@ export class EffectsStore {
|
||||
}
|
||||
}
|
||||
|
||||
function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
|
||||
function applyEvent(
|
||||
state: EffectsState,
|
||||
event: EffectsEvent,
|
||||
makeId: (prefix: string) => string,
|
||||
): EffectsState {
|
||||
const bump = (patch: Omit<EffectsState, 'revision' | 'serverNowMs'>): EffectsState => ({
|
||||
...patch,
|
||||
revision: state.revision + 1,
|
||||
@@ -89,6 +105,18 @@ function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
|
||||
return bump({ ...state, instances: [...state.instances, event.instance] });
|
||||
case 'instance.remove':
|
||||
return bump({ ...state, instances: state.instances.filter((i) => i.id !== event.id) });
|
||||
case 'field.erase':
|
||||
return bump({
|
||||
...state,
|
||||
instances: applyFieldEffectEraserStroke(
|
||||
state.instances,
|
||||
event.points,
|
||||
event.radiusN,
|
||||
makeId,
|
||||
event.types,
|
||||
event.hitMode ?? 'inclusive',
|
||||
),
|
||||
});
|
||||
default: {
|
||||
// Exhaustiveness
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SceneDarknessStore } from './sceneDarknessStore';
|
||||
|
||||
void test('SceneDarknessStore: сохраняет штрихи при переключении сцен', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 's1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
},
|
||||
});
|
||||
|
||||
store.switchScene('scene_b', true);
|
||||
assert.equal(store.getState().strokes.length, 0);
|
||||
|
||||
store.switchScene('scene_a', true);
|
||||
assert.equal(store.getState().strokes.length, 1);
|
||||
assert.equal(store.getState().strokes[0]?.id, 's1');
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: resetSession очищает кэш', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 's1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.2, y: 0.2, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
},
|
||||
});
|
||||
store.resetSession();
|
||||
store.switchScene('scene_a', true);
|
||||
assert.equal(store.getState().strokes.length, 0);
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: draft синхронизируется и сбрасывается при commit', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'draft.set',
|
||||
draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05, mode: 'cover' },
|
||||
});
|
||||
assert.ok(store.getState().draft);
|
||||
assert.equal(store.getState().draft?.mode, 'cover');
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 's1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.1, y: 0.1, tMs: 1 }],
|
||||
radiusN: 0.05,
|
||||
mode: 'cover',
|
||||
},
|
||||
});
|
||||
assert.equal(store.getState().draft, null);
|
||||
assert.equal(store.getState().strokes.length, 1);
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'open1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
mode: 'reveal',
|
||||
},
|
||||
});
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'close1',
|
||||
seed: 2,
|
||||
createdAtMs: 200,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
|
||||
radiusN: 0.08,
|
||||
mode: 'cover',
|
||||
},
|
||||
});
|
||||
assert.equal(store.getState().strokes.length, 2);
|
||||
assert.equal(store.getState().strokes[1]?.mode, 'cover');
|
||||
store.switchScene('scene_b', true);
|
||||
store.switchScene('scene_a', true);
|
||||
assert.equal(store.getState().strokes.length, 2);
|
||||
assert.equal(store.getState().strokes[1]?.mode, 'cover');
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { SceneDarknessEvent, SceneDarknessRevealStroke, SceneDarknessState } from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneDarknessState {
|
||||
return {
|
||||
revision: 1,
|
||||
enabled: false,
|
||||
cacheKey: null,
|
||||
strokes: [],
|
||||
draft: null,
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneDarknessStore {
|
||||
private state: SceneDarknessState = emptyState();
|
||||
/** Кэш раскрытых областей по ключу (graphNodeId или sceneId) на время сессии показа. */
|
||||
private cache = new Map<string, SceneDarknessRevealStroke[]>();
|
||||
private currentKey: string | null = null;
|
||||
|
||||
getState(): SceneDarknessState {
|
||||
return { ...this.state, strokes: [...this.state.strokes] };
|
||||
}
|
||||
|
||||
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
|
||||
resetSession(): void {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = emptyState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены.
|
||||
*/
|
||||
switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState {
|
||||
if (this.currentKey !== null) {
|
||||
this.cache.set(this.currentKey, [...this.state.strokes]);
|
||||
}
|
||||
this.currentKey = cacheKey;
|
||||
const strokes =
|
||||
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : [];
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
enabled,
|
||||
cacheKey,
|
||||
strokes,
|
||||
draft: null,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
dispatch(event: SceneDarknessEvent): SceneDarknessState {
|
||||
if (!this.state.enabled) return this.getState();
|
||||
switch (event.kind) {
|
||||
case 'draft.set':
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
draft: event.draft,
|
||||
};
|
||||
return this.getState();
|
||||
case 'stroke.add': {
|
||||
const strokes = [...this.state.strokes, event.stroke];
|
||||
if (this.currentKey) {
|
||||
this.cache.set(this.currentKey, strokes);
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
strokes,
|
||||
draft: null,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
default: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _x: never = event;
|
||||
return this.getState();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ClassicLevel } from 'classic-level';
|
||||
|
||||
import type {
|
||||
FoundryActorDoc,
|
||||
FoundryAdventureDoc,
|
||||
FoundryFolderDoc,
|
||||
FoundryJournalDoc,
|
||||
FoundryLoadedDocuments,
|
||||
FoundryPlaylistDoc,
|
||||
FoundrySceneDoc,
|
||||
} from '../../shared/foundry/foundryTypes';
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function asDocArray<T>(docs: unknown[]): T[] {
|
||||
return docs.filter((d) => isRecord(d) && typeof d._id === 'string' && typeof d.name === 'string') as T[];
|
||||
}
|
||||
|
||||
/** NeDB: по строке JSON на линию (игнор пустых / битых). */
|
||||
export async function readNedbDocuments(filePath: string): Promise<Record<string, unknown>[]> {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (const line of raw.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (isRecord(parsed) && typeof parsed._id === 'string') out.push(parsed);
|
||||
} catch {
|
||||
// skip broken line
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isLevelDbDir(entries: { name: string; isFile(): boolean; isDirectory(): boolean }[]): boolean {
|
||||
const names = new Set(entries.map((e) => e.name));
|
||||
return (
|
||||
names.has('CURRENT') ||
|
||||
names.has('LOG') ||
|
||||
[...names].some((n) => n.endsWith('.ldb') || n.startsWith('MANIFEST-'))
|
||||
);
|
||||
}
|
||||
|
||||
/** Читает primary documents из LevelDB-папки Foundry (ключи `!collection!id`). */
|
||||
export async function readLevelDbDocuments(dirPath: string): Promise<Record<string, unknown>[]> {
|
||||
const db = new ClassicLevel(dirPath, {
|
||||
keyEncoding: 'utf8',
|
||||
valueEncoding: 'json',
|
||||
createIfMissing: false,
|
||||
});
|
||||
try {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for await (const [key, value] of db.iterator()) {
|
||||
if (typeof key !== 'string') continue;
|
||||
const parts = key.split('!');
|
||||
// "", collection, id
|
||||
if (parts.length < 3) continue;
|
||||
const collection = parts[1] ?? '';
|
||||
if (!collection || collection.includes('.')) continue; // embedded
|
||||
if (!isRecord(value)) continue;
|
||||
if (typeof value._id !== 'string') continue;
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
await db.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readCollectionDocs(baseDir: string, collection: string): Promise<Record<string, unknown>[]> {
|
||||
const candidates = [
|
||||
path.join(baseDir, collection),
|
||||
path.join(baseDir, `${collection}.db`),
|
||||
path.join(baseDir, 'data', collection),
|
||||
path.join(baseDir, 'data', `${collection}.db`),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!(await pathExists(candidate))) continue;
|
||||
const st = await fs.stat(candidate);
|
||||
if (st.isFile() && candidate.endsWith('.db')) {
|
||||
return readNedbDocuments(candidate);
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
const entries = await fs.readdir(candidate, { withFileTypes: true });
|
||||
if (isLevelDbDir(entries)) {
|
||||
return readLevelDbDocuments(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function readPackDocuments(
|
||||
packAbsPath: string,
|
||||
documentType: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (!(await pathExists(packAbsPath))) return [];
|
||||
const st = await fs.stat(packAbsPath);
|
||||
if (st.isFile() && packAbsPath.endsWith('.db')) {
|
||||
return readNedbDocuments(packAbsPath);
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
const entries = await fs.readdir(packAbsPath, { withFileTypes: true });
|
||||
if (isLevelDbDir(entries)) {
|
||||
return readLevelDbDocuments(packAbsPath);
|
||||
}
|
||||
// Иногда path указывает на папку с .db внутри.
|
||||
const nestedDb = path.join(packAbsPath, `${path.basename(packAbsPath)}.db`);
|
||||
if (await pathExists(nestedDb)) return readNedbDocuments(nestedDb);
|
||||
}
|
||||
void documentType;
|
||||
return [];
|
||||
}
|
||||
|
||||
function mergeById<T extends { _id: string }>(lists: T[][]): T[] {
|
||||
const map = new Map<string, T>();
|
||||
for (const list of lists) {
|
||||
for (const doc of list) {
|
||||
if (!map.has(doc._id)) map.set(doc._id, doc);
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
function flattenAdventureDocs(adventures: FoundryAdventureDoc[]): {
|
||||
scenes: FoundrySceneDoc[];
|
||||
actors: FoundryActorDoc[];
|
||||
playlists: FoundryPlaylistDoc[];
|
||||
journals: FoundryJournalDoc[];
|
||||
folders: FoundryFolderDoc[];
|
||||
} {
|
||||
const scenes: FoundrySceneDoc[] = [];
|
||||
const actors: FoundryActorDoc[] = [];
|
||||
const playlists: FoundryPlaylistDoc[] = [];
|
||||
const journals: FoundryJournalDoc[] = [];
|
||||
const folders: FoundryFolderDoc[] = [];
|
||||
for (const adv of adventures) {
|
||||
if (Array.isArray(adv.scenes)) scenes.push(...asDocArray<FoundrySceneDoc>(adv.scenes));
|
||||
if (Array.isArray(adv.actors)) actors.push(...asDocArray<FoundryActorDoc>(adv.actors));
|
||||
if (Array.isArray(adv.playlists)) playlists.push(...asDocArray<FoundryPlaylistDoc>(adv.playlists));
|
||||
if (Array.isArray(adv.journal)) journals.push(...asDocArray<FoundryJournalDoc>(adv.journal));
|
||||
if (Array.isArray(adv.folders)) {
|
||||
for (const f of adv.folders) {
|
||||
if (f && typeof f === 'object' && typeof (f as FoundryFolderDoc)._id === 'string') {
|
||||
folders.push(f as FoundryFolderDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { scenes, actors, playlists, journals, folders };
|
||||
}
|
||||
|
||||
function asFolderArray(docs: unknown[]): FoundryFolderDoc[] {
|
||||
return docs.filter(
|
||||
(d): d is FoundryFolderDoc =>
|
||||
Boolean(d) &&
|
||||
typeof d === 'object' &&
|
||||
typeof (d as FoundryFolderDoc)._id === 'string' &&
|
||||
typeof (d as FoundryFolderDoc).name === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadWorldDocuments(rootDir: string): Promise<FoundryLoadedDocuments> {
|
||||
const [scenesRaw, actorsRaw, playlistsRaw, journalsRaw, adventuresRaw, foldersRaw] =
|
||||
await Promise.all([
|
||||
readCollectionDocs(rootDir, 'scenes'),
|
||||
readCollectionDocs(rootDir, 'actors'),
|
||||
readCollectionDocs(rootDir, 'playlists'),
|
||||
readCollectionDocs(rootDir, 'journal'),
|
||||
readCollectionDocs(rootDir, 'adventures'),
|
||||
readCollectionDocs(rootDir, 'folders'),
|
||||
]);
|
||||
|
||||
const adventures = asDocArray<FoundryAdventureDoc>(adventuresRaw);
|
||||
const embedded = flattenAdventureDocs(adventures);
|
||||
|
||||
return {
|
||||
scenes: mergeById([asDocArray<FoundrySceneDoc>(scenesRaw), embedded.scenes]),
|
||||
actors: mergeById([asDocArray<FoundryActorDoc>(actorsRaw), embedded.actors]),
|
||||
playlists: mergeById([asDocArray<FoundryPlaylistDoc>(playlistsRaw), embedded.playlists]),
|
||||
journals: mergeById([asDocArray<FoundryJournalDoc>(journalsRaw), embedded.journals]),
|
||||
adventures,
|
||||
folders: mergeById([asFolderArray(foldersRaw), embedded.folders]),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadModuleDocuments(
|
||||
rootDir: string,
|
||||
packs: { path: string; type: string }[],
|
||||
): Promise<FoundryLoadedDocuments> {
|
||||
const scenes: FoundrySceneDoc[][] = [];
|
||||
const actors: FoundryActorDoc[][] = [];
|
||||
const playlists: FoundryPlaylistDoc[][] = [];
|
||||
const journals: FoundryJournalDoc[][] = [];
|
||||
const adventures: FoundryAdventureDoc[][] = [];
|
||||
|
||||
for (const pack of packs) {
|
||||
const abs = path.isAbsolute(pack.path) ? pack.path : path.join(rootDir, pack.path);
|
||||
// path в module.json иногда с `./` и иногда ещё со старым `.db`
|
||||
const variants = [abs, abs.endsWith('.db') ? abs : `${abs}.db`, abs.replace(/\.db$/u, '')];
|
||||
let docs: Record<string, unknown>[] = [];
|
||||
for (const v of variants) {
|
||||
docs = await readPackDocuments(v, pack.type);
|
||||
if (docs.length > 0) break;
|
||||
}
|
||||
|
||||
const type = pack.type;
|
||||
if (type === 'Scene') scenes.push(asDocArray<FoundrySceneDoc>(docs));
|
||||
else if (type === 'Actor') actors.push(asDocArray<FoundryActorDoc>(docs));
|
||||
else if (type === 'Playlist') playlists.push(asDocArray<FoundryPlaylistDoc>(docs));
|
||||
else if (type === 'JournalEntry') journals.push(asDocArray<FoundryJournalDoc>(docs));
|
||||
else if (type === 'Adventure') adventures.push(asDocArray<FoundryAdventureDoc>(docs));
|
||||
}
|
||||
|
||||
const advMerged = mergeById(adventures);
|
||||
const embedded = flattenAdventureDocs(advMerged);
|
||||
|
||||
return {
|
||||
scenes: mergeById([...scenes, embedded.scenes]),
|
||||
actors: mergeById([...actors, embedded.actors]),
|
||||
playlists: mergeById([...playlists, embedded.playlists]),
|
||||
journals: mergeById([...journals, embedded.journals]),
|
||||
adventures: advMerged,
|
||||
folders: mergeById([embedded.folders]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { decodeFoundryAssetPath } from '../../shared/foundry/foundryPaths';
|
||||
import type { FoundryPackageManifest, FoundryPackRef } from '../../shared/foundry/foundryTypes';
|
||||
import { isSupportedFoundryPackage } from '../../shared/foundry/foundryVersion';
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<unknown> {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw) as unknown;
|
||||
}
|
||||
|
||||
function parsePacks(raw: unknown): FoundryPackRef[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: FoundryPackRef[] = [];
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue;
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const label = typeof item.label === 'string' ? item.label : name;
|
||||
const packPath = typeof item.path === 'string' ? item.path.replace(/^\.\//u, '') : '';
|
||||
const type =
|
||||
typeof item.type === 'string' ? item.type : typeof item.entity === 'string' ? item.entity : '';
|
||||
if (!packPath || !type) continue;
|
||||
out.push({ name: name || path.basename(packPath), label: label || name, path: packPath, type });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function titleFromManifest(data: Record<string, unknown>, fallbackId: string): string {
|
||||
if (typeof data.title === 'string' && data.title.trim()) return data.title.trim();
|
||||
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
function idFromManifest(data: Record<string, unknown>, dirName: string): string {
|
||||
if (typeof data.id === 'string' && data.id.trim()) return data.id.trim();
|
||||
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
|
||||
return dirName;
|
||||
}
|
||||
|
||||
async function tryParseManifest(dir: string): Promise<FoundryPackageManifest | null> {
|
||||
const worldPath = path.join(dir, 'world.json');
|
||||
const modulePath = path.join(dir, 'module.json');
|
||||
|
||||
try {
|
||||
await fs.access(worldPath);
|
||||
const data = await readJsonFile(worldPath);
|
||||
if (!isRecord(data)) return null;
|
||||
const id = idFromManifest(data, path.basename(dir));
|
||||
const manifest: FoundryPackageManifest = {
|
||||
kind: 'world',
|
||||
id,
|
||||
title: titleFromManifest(data, id),
|
||||
rootDir: dir,
|
||||
packs: [],
|
||||
};
|
||||
const compatibility = parseCompatibility(data.compatibility);
|
||||
if (compatibility) manifest.compatibility = compatibility;
|
||||
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
|
||||
return manifest;
|
||||
} catch {
|
||||
// not a world
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(modulePath);
|
||||
const data = await readJsonFile(modulePath);
|
||||
if (!isRecord(data)) return null;
|
||||
const id = idFromManifest(data, path.basename(dir));
|
||||
const manifest: FoundryPackageManifest = {
|
||||
kind: 'module',
|
||||
id,
|
||||
title: titleFromManifest(data, id),
|
||||
rootDir: dir,
|
||||
packs: parsePacks(data.packs),
|
||||
};
|
||||
const compatibility = parseCompatibility(data.compatibility);
|
||||
if (compatibility) manifest.compatibility = compatibility;
|
||||
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
|
||||
return manifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCompatibility(raw: unknown): FoundryPackageManifest['compatibility'] | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const out: NonNullable<FoundryPackageManifest['compatibility']> = {};
|
||||
if (typeof raw.minimum === 'string') out.minimum = raw.minimum;
|
||||
if (typeof raw.verified === 'string') out.verified = raw.verified;
|
||||
if (typeof raw.maximum === 'string') out.maximum = raw.maximum;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ищет world.json / module.json в папке и на 1–2 уровня глубже. */
|
||||
export async function detectFoundryPackage(rootPath: string): Promise<FoundryPackageManifest> {
|
||||
const abs = path.resolve(rootPath);
|
||||
const direct = await tryParseManifest(abs);
|
||||
if (direct) {
|
||||
const ver = isSupportedFoundryPackage(direct);
|
||||
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
|
||||
return direct;
|
||||
}
|
||||
|
||||
// Архив мог содержать один корневой каталог.
|
||||
const entries = await fs.readdir(abs, { withFileTypes: true });
|
||||
const dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(abs, e.name));
|
||||
|
||||
for (const dir of dirs) {
|
||||
const found = await tryParseManifest(dir);
|
||||
if (found) {
|
||||
const ver = isSupportedFoundryPackage(found);
|
||||
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// Data/worlds/<id> или Data/modules/<id>
|
||||
for (const mid of ['worlds', 'modules']) {
|
||||
const midDir = path.join(abs, mid);
|
||||
try {
|
||||
const st = await fs.stat(midDir);
|
||||
if (!st.isDirectory()) continue;
|
||||
const children = await fs.readdir(midDir, { withFileTypes: true });
|
||||
for (const child of children.filter((c) => c.isDirectory())) {
|
||||
const found = await tryParseManifest(path.join(midDir, child.name));
|
||||
if (found) {
|
||||
const ver = isSupportedFoundryPackage(found);
|
||||
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
|
||||
return found;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Data как корень выбранной папки
|
||||
const dataDir = path.join(abs, 'Data');
|
||||
try {
|
||||
const st = await fs.stat(dataDir);
|
||||
if (st.isDirectory()) {
|
||||
return await detectFoundryPackage(dataDir);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Не похоже на пакет Foundry VTT: не найдены world.json или module.json. Выберите папку мира/модуля или архив с ними.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Резолвит путь ассета Foundry относительно корня пакета / Data.
|
||||
* foundryPath — как в документах: `worlds/id/...`, `modules/id/...` или относительный.
|
||||
* Поддерживает URL-encoding (`The%20Withered%20Grove%20(day).webp`).
|
||||
*/
|
||||
export async function resolveFoundryAssetPath(
|
||||
manifest: FoundryPackageManifest,
|
||||
foundryPath: string,
|
||||
): Promise<string | null> {
|
||||
const cleaned = decodeFoundryAssetPath(foundryPath);
|
||||
if (!cleaned) return null;
|
||||
if (/^https?:\/\//iu.test(cleaned)) return null;
|
||||
|
||||
const packagePrefix = manifest.kind === 'world' ? `worlds/${manifest.id}/` : `modules/${manifest.id}/`;
|
||||
|
||||
const rawNormalized = foundryPath.trim().replace(/\\/gu, '/');
|
||||
const pathVariants = cleaned === rawNormalized ? [cleaned] : [cleaned, rawNormalized];
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const variant of pathVariants) {
|
||||
const v = variant.replace(/^\/+/u, '');
|
||||
if (!v) continue;
|
||||
if (v.toLowerCase().startsWith(packagePrefix.toLowerCase())) {
|
||||
candidates.push(path.join(manifest.rootDir, v.slice(packagePrefix.length)));
|
||||
}
|
||||
candidates.push(path.join(manifest.rootDir, v));
|
||||
|
||||
const dataRootGuesses = [
|
||||
path.resolve(manifest.rootDir, '..', '..'),
|
||||
path.resolve(manifest.rootDir, '..'),
|
||||
];
|
||||
for (const dataRoot of dataRootGuesses) {
|
||||
candidates.push(path.join(dataRoot, v));
|
||||
}
|
||||
|
||||
const base = path.basename(v);
|
||||
if (base && base !== v) {
|
||||
candidates.push(path.join(manifest.rootDir, base));
|
||||
candidates.push(path.join(manifest.rootDir, 'assets', base));
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const st = await fs.stat(c);
|
||||
if (st.isFile()) return c;
|
||||
} catch {
|
||||
// next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
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: [],
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
+867
-93
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,10 @@ function channelRequiresLicense(channel: string): boolean {
|
||||
if (channel.startsWith('license.')) return false;
|
||||
if (channel.startsWith('app.')) return false;
|
||||
if (channel === ipcChannels.windows.closeMultiWindow) return false;
|
||||
if (channel === ipcChannels.windows.closeSceneDescription) return false;
|
||||
if (channel === ipcChannels.windows.closeMaterials) return false;
|
||||
if (channel === ipcChannels.windows.closeNpcs) return false;
|
||||
if (channel === ipcChannels.windows.closeNpcsEditor) return false;
|
||||
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
|
||||
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
|
||||
if (channel === ipcChannels.project.list) return false;
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { resolveMachineFingerprint } from './machineFingerprint';
|
||||
import { deviceIdPath } from './paths';
|
||||
|
||||
export function getOrCreateDeviceId(userData: string): string {
|
||||
const p = deviceIdPath(userData);
|
||||
/** Старый per-user UUID из userData/device.id (до привязки к железу). */
|
||||
export function readLegacyDeviceId(userData: string): string | null {
|
||||
try {
|
||||
const existing = fs.readFileSync(p, 'utf8').trim();
|
||||
const existing = fs.readFileSync(deviceIdPath(userData), 'utf8').trim();
|
||||
if (existing.length >= 8) return existing;
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
const id = randomUUID();
|
||||
fs.mkdirSync(userData, { recursive: true });
|
||||
fs.writeFileSync(p, `${id}\n`, 'utf8');
|
||||
return id;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearLegacyDeviceId(userData: string): void {
|
||||
try {
|
||||
fs.unlinkSync(deviceIdPath(userData));
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Идентификатор устройства для лицензии: отпечаток физической машины.
|
||||
* Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux).
|
||||
* `userData` — путь для дискового кэша fingerprint (без повторного reg/wmic).
|
||||
*/
|
||||
export function getOrCreateDeviceId(userData?: string): string {
|
||||
return resolveMachineFingerprint(userData ? { userData } : {});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { BrowserWindow, safeStorage } from 'electron';
|
||||
@@ -6,11 +7,11 @@ import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||
import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
|
||||
import { isDndProductKey } from '../../shared/license/productKey';
|
||||
import { isProductKey } from '../../shared/license/productKey';
|
||||
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
|
||||
|
||||
import { getOrCreateDeviceId } from './deviceId';
|
||||
import { licenseEncryptedPath, preferencesPath } from './paths';
|
||||
import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
|
||||
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
|
||||
import { verifyLicenseToken } from './verifyLicenseToken';
|
||||
|
||||
type Preferences = {
|
||||
@@ -19,6 +20,8 @@ type Preferences = {
|
||||
|
||||
type LicenseChangeListener = () => void;
|
||||
|
||||
const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii');
|
||||
|
||||
const licenseChangeListeners = new Set<LicenseChangeListener>();
|
||||
|
||||
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
|
||||
@@ -63,35 +66,121 @@ function emitLicenseStatusChanged(): void {
|
||||
export class LicenseService {
|
||||
private readonly userData: string;
|
||||
private readonly deviceId: string;
|
||||
/** UUID из старого userData/device.id — только для совместимости до повторной активации. */
|
||||
private legacyDeviceId: string | null;
|
||||
private lastRemoteRevokeCheckMs = 0;
|
||||
private lastRemoteRevoked = false;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.userData = userData;
|
||||
this.deviceId = getOrCreateDeviceId(userData);
|
||||
const legacy = readLegacyDeviceId(userData);
|
||||
this.legacyDeviceId = legacy && legacy !== this.deviceId ? legacy : null;
|
||||
}
|
||||
|
||||
private verifyOpts(nowSec: number): {
|
||||
nowSec: number;
|
||||
deviceId: string;
|
||||
alsoAcceptDeviceIds?: readonly string[];
|
||||
} {
|
||||
return {
|
||||
nowSec,
|
||||
deviceId: this.deviceId,
|
||||
...(this.legacyDeviceId ? { alsoAcceptDeviceIds: [this.legacyDeviceId] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private isSkipLicense(): boolean {
|
||||
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
|
||||
}
|
||||
|
||||
private readSealedToken(): string | null {
|
||||
const p = licenseEncryptedPath(this.userData);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе');
|
||||
}
|
||||
/** Только для окружений без OS keychain (WSL и т.п.); слабее safeStorage — см. licensing-spec. */
|
||||
private isInsecureFileStorageAllowed(): boolean {
|
||||
const v = process.env.DND_LICENSE_INSECURE_FILE_STORAGE?.trim().toLowerCase();
|
||||
return v === '1' || v === 'true' || v === 'yes';
|
||||
}
|
||||
|
||||
private deriveFallbackKey(): Buffer {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update('TTRPGPlayer.license.fallback.v1\0', 'utf8')
|
||||
.update(this.deviceId, 'utf8')
|
||||
.digest();
|
||||
}
|
||||
|
||||
private readFallbackSealedToken(): string {
|
||||
const p = licenseFallbackSealedPath(this.userData);
|
||||
const buf = fs.readFileSync(p);
|
||||
return safeStorage.decryptString(buf);
|
||||
if (buf.length < FALLBACK_MAGIC.length + 12 + 16 + 1) {
|
||||
throw new Error('license.fallback: файл повреждён или слишком короткий');
|
||||
}
|
||||
if (!buf.subarray(0, FALLBACK_MAGIC.length).equals(FALLBACK_MAGIC)) {
|
||||
throw new Error('license.fallback: неверный формат');
|
||||
}
|
||||
const iv = buf.subarray(FALLBACK_MAGIC.length, FALLBACK_MAGIC.length + 12);
|
||||
const tag = buf.subarray(FALLBACK_MAGIC.length + 12, FALLBACK_MAGIC.length + 12 + 16);
|
||||
const data = buf.subarray(FALLBACK_MAGIC.length + 12 + 16);
|
||||
const key = this.deriveFallbackKey();
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
private writeFallbackSealedToken(token: string): void {
|
||||
const key = this.deriveFallbackKey();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const enc = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
const payload = Buffer.concat([FALLBACK_MAGIC, iv, tag, enc]);
|
||||
fs.writeFileSync(licenseFallbackSealedPath(this.userData), payload, { mode: 0o600 });
|
||||
}
|
||||
|
||||
private readSealedToken(): string | null {
|
||||
const sealedPath = licenseEncryptedPath(this.userData);
|
||||
const fallbackPath = licenseFallbackSealedPath(this.userData);
|
||||
|
||||
if (fs.existsSync(sealedPath)) {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error(
|
||||
'safeStorage недоступен: есть license.sealed, но расшифровать нельзя (часто перенос профиля или WSL без keyring). Удалите файл лицензии в настройках приложения или используйте DND_LICENSE_INSECURE_FILE_STORAGE=1 и активируйте заново.',
|
||||
);
|
||||
}
|
||||
const buf = fs.readFileSync(sealedPath);
|
||||
return safeStorage.decryptString(buf);
|
||||
}
|
||||
|
||||
if (fs.existsSync(fallbackPath)) {
|
||||
return this.readFallbackSealedToken();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private writeSealedToken(token: string): void {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе');
|
||||
}
|
||||
fs.mkdirSync(this.userData, { recursive: true });
|
||||
const enc = safeStorage.encryptString(token);
|
||||
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
const enc = safeStorage.encryptString(token);
|
||||
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
|
||||
try {
|
||||
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.isInsecureFileStorageAllowed()) {
|
||||
this.writeFallbackSealedToken(token);
|
||||
try {
|
||||
fs.unlinkSync(licenseEncryptedPath(this.userData));
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'safeStorage недоступен: нельзя сохранить лицензию на этой системе (типично WSL без gnome-keyring). Запустите с переменной DND_LICENSE_INSECURE_FILE_STORAGE=1 — токен будет сохранён в зашифрованном файле (слабее OS-хранилища); либо настройте Secret Service / gnome-keyring.',
|
||||
);
|
||||
}
|
||||
|
||||
private clearSealedTokenFile(): void {
|
||||
@@ -100,22 +189,34 @@ export class LicenseService {
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
|
||||
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
|
||||
private resolveLicenseActivateBaseUrl(): string {
|
||||
const raw = process.env.DND_LICENSE_STATUS_URL?.trim();
|
||||
if (raw) return raw.endsWith('/') ? raw : `${raw}/`;
|
||||
return 'https://license.mailib.ru/';
|
||||
return 'https://license.ttrpgplayer.ru/';
|
||||
}
|
||||
|
||||
private async activateWithProductKey(productKey: string): Promise<string> {
|
||||
const base = this.resolveLicenseActivateBaseUrl();
|
||||
const url = new URL('v1/activate', base);
|
||||
const body: { productKey: string; deviceId: string; retireDeviceId?: string } = {
|
||||
productKey: productKey.trim(),
|
||||
deviceId: this.deviceId,
|
||||
};
|
||||
if (this.legacyDeviceId) {
|
||||
body.retireDeviceId = this.legacyDeviceId;
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ productKey: productKey.trim(), deviceId: this.deviceId }),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const text = await res.text();
|
||||
@@ -133,6 +234,10 @@ export class LicenseService {
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
|
||||
}
|
||||
if (this.legacyDeviceId) {
|
||||
clearLegacyDeviceId(this.userData);
|
||||
this.legacyDeviceId = null;
|
||||
}
|
||||
return normalizeLicenseTokenInput(token);
|
||||
}
|
||||
|
||||
@@ -200,7 +305,7 @@ export class LicenseService {
|
||||
};
|
||||
}
|
||||
|
||||
const v = verifyLicenseToken(token, { nowSec, deviceId: this.deviceId });
|
||||
const v = verifyLicenseToken(token, this.verifyOpts(nowSec));
|
||||
if (!v.ok) {
|
||||
return {
|
||||
active: false,
|
||||
@@ -250,10 +355,7 @@ export class LicenseService {
|
||||
if (!base.active || !base.summary) return base;
|
||||
const token = this.readSealedToken();
|
||||
if (!token?.trim()) return base;
|
||||
const v = verifyLicenseToken(token, {
|
||||
nowSec: Math.floor(Date.now() / 1000),
|
||||
deviceId: this.deviceId,
|
||||
});
|
||||
const v = verifyLicenseToken(token, this.verifyOpts(Math.floor(Date.now() / 1000)));
|
||||
if (!v.ok) return this.getStatusSync();
|
||||
void this.maybeRefreshRemoteRevocation(v.payload);
|
||||
return this.getStatusSync();
|
||||
@@ -264,11 +366,11 @@ export class LicenseService {
|
||||
return this.getStatusSync();
|
||||
}
|
||||
let trimmed = normalizeLicenseTokenInput(token);
|
||||
if (isDndProductKey(trimmed)) {
|
||||
if (isProductKey(trimmed)) {
|
||||
trimmed = await this.activateWithProductKey(trimmed);
|
||||
}
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
|
||||
const v = verifyLicenseToken(trimmed, this.verifyOpts(nowSec));
|
||||
if (!v.ok) {
|
||||
throw new Error(`LICENSE_INVALID:${v.reason}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
clearMachineFingerprintMemoryCache,
|
||||
hashMachineRawId,
|
||||
machineWideIdPath,
|
||||
parseMacIOPlatformUUID,
|
||||
parseWindowsMachineGuid,
|
||||
parseWmicUuid,
|
||||
resolveMachineFingerprint,
|
||||
} from './machineFingerprint';
|
||||
import { machineFingerprintCachePath } from './paths';
|
||||
|
||||
void test('hashMachineRawId: стабилен и не зависит от регистра GUID', () => {
|
||||
const a = hashMachineRawId('win32', 'ABCDEF00-1111-2222-3333-444455556666');
|
||||
const b = hashMachineRawId('win32', 'abcdef00-1111-2222-3333-444455556666');
|
||||
assert.equal(a, b);
|
||||
assert.equal(a.length, 64);
|
||||
assert.notEqual(a, hashMachineRawId('linux', 'abcdef00-1111-2222-3333-444455556666'));
|
||||
});
|
||||
|
||||
void test('parseWindowsMachineGuid', () => {
|
||||
const out = `
|
||||
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
|
||||
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
|
||||
`;
|
||||
assert.equal(parseWindowsMachineGuid(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||
assert.equal(parseWindowsMachineGuid('nope'), null);
|
||||
});
|
||||
|
||||
void test('parseMacIOPlatformUUID', () => {
|
||||
const out = `
|
||||
+-o IOPlatformExpertDevice <class IOPlatformExpertDevice, id 0x1000001ea, registered, matched>
|
||||
{
|
||||
"IOPlatformUUID" = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
|
||||
}
|
||||
`;
|
||||
assert.equal(parseMacIOPlatformUUID(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||
});
|
||||
|
||||
void test('parseWmicUuid', () => {
|
||||
assert.equal(parseWmicUuid('UUID\nA1B2C3D4-E5F6-7890-ABCD-EF1234567890\n'), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||
assert.equal(parseWmicUuid('UUID\n00000000-0000-0000-0000-000000000000\n'), null);
|
||||
});
|
||||
|
||||
void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID', () => {
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const id = resolveMachineFingerprint({
|
||||
platform: 'linux',
|
||||
env: { DND_LICENSE_DEVICE_ID: 'override-device-id-12345' },
|
||||
});
|
||||
assert.equal(id, 'override-device-id-12345');
|
||||
});
|
||||
|
||||
void test('resolveMachineFingerprint: Windows MachineGuid → одинаковый hash', () => {
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const exec = () =>
|
||||
`
|
||||
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
|
||||
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
|
||||
`;
|
||||
const a = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
execFileSync: exec as never,
|
||||
});
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const b = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
execFileSync: exec as never,
|
||||
});
|
||||
assert.equal(a, b);
|
||||
assert.equal(a, hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890'));
|
||||
});
|
||||
|
||||
void test('resolveMachineFingerprint: Linux /etc/machine-id', () => {
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const id = resolveMachineFingerprint({
|
||||
platform: 'linux',
|
||||
env: {},
|
||||
readFileSync: (p) => {
|
||||
if (p === '/etc/machine-id') return '0123456789abcdef0123456789abcdef\n';
|
||||
throw new Error('enoent');
|
||||
},
|
||||
});
|
||||
assert.equal(id, hashMachineRawId('linux', '0123456789abcdef0123456789abcdef'));
|
||||
});
|
||||
|
||||
void test('resolveMachineFingerprint: fallback в machine-wide путь', () => {
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-'));
|
||||
const env = { PROGRAMDATA: tmp };
|
||||
const p = machineWideIdPath('win32', env);
|
||||
const id1 = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env,
|
||||
execFileSync: () => {
|
||||
throw new Error('no reg');
|
||||
},
|
||||
});
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const id2 = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env,
|
||||
execFileSync: () => {
|
||||
throw new Error('no reg');
|
||||
},
|
||||
});
|
||||
assert.equal(id1, id2);
|
||||
assert.ok(fs.existsSync(p));
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
void test('resolveMachineFingerprint: disk cache — без повторного exec', () => {
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-cache-'));
|
||||
const expected = hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
|
||||
let execCalls = 0;
|
||||
const exec = () => {
|
||||
execCalls += 1;
|
||||
return `
|
||||
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
|
||||
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
|
||||
`;
|
||||
};
|
||||
const a = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
userData: tmp,
|
||||
execFileSync: exec as never,
|
||||
});
|
||||
assert.equal(a, expected);
|
||||
assert.equal(execCalls, 1);
|
||||
assert.ok(fs.existsSync(machineFingerprintCachePath(tmp)));
|
||||
|
||||
clearMachineFingerprintMemoryCache();
|
||||
const b = resolveMachineFingerprint({
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
userData: tmp,
|
||||
execFileSync: exec as never,
|
||||
});
|
||||
assert.equal(b, expected);
|
||||
assert.equal(execCalls, 1, 'второй вызов читает disk cache, без reg/wmic');
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { machineFingerprintCachePath } from './paths';
|
||||
|
||||
type ExecFile = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: { encoding: 'utf8'; windowsHide?: boolean; timeout?: number },
|
||||
) => string;
|
||||
|
||||
export type MachineFingerprintDeps = {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Electron userData — для дискового кэша hashed fingerprint. */
|
||||
userData?: string;
|
||||
execFileSync?: ExecFile;
|
||||
readFileSync?: (p: string, encoding: 'utf8') => string;
|
||||
existsSync?: (p: string) => boolean;
|
||||
mkdirSync?: (p: string, opts: { recursive: boolean }) => void;
|
||||
writeFileSync?: (p: string, data: string, opts?: { mode?: number }) => void;
|
||||
};
|
||||
|
||||
/** Process-level кэш: повторные вызовы в том же процессе без I/O. */
|
||||
let memoryFingerprint: string | null = null;
|
||||
|
||||
/** Только для тестов. */
|
||||
export function clearMachineFingerprintMemoryCache(): void {
|
||||
memoryFingerprint = null;
|
||||
}
|
||||
|
||||
function isPlausiblyFingerprint(id: string): boolean {
|
||||
return id.length >= 8 && id.length <= 128 && !/\s/.test(id);
|
||||
}
|
||||
|
||||
const HASH_PREFIX = 'TTRPGPlayer.machine.v1\0';
|
||||
|
||||
/** Стабильный opaque id из сырого машинного идентификатора ОС. */
|
||||
export function hashMachineRawId(platform: string, rawId: string): string {
|
||||
return createHash('sha256')
|
||||
.update(HASH_PREFIX, 'utf8')
|
||||
.update(platform, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(rawId.trim().toLowerCase(), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function parseWindowsMachineGuid(regOutput: string): string | null {
|
||||
const m = /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{8,})/.exec(regOutput);
|
||||
const id = m?.[1]?.trim();
|
||||
return id && id.length >= 8 ? id : null;
|
||||
}
|
||||
|
||||
export function parseMacIOPlatformUUID(ioregOutput: string): string | null {
|
||||
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(ioregOutput);
|
||||
const id = m?.[1]?.trim();
|
||||
return id && id.length >= 8 ? id : null;
|
||||
}
|
||||
|
||||
export function parseWmicUuid(wmicOutput: string): string | null {
|
||||
const lines = wmicOutput
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
for (const line of lines) {
|
||||
if (/^uuid$/i.test(line)) continue;
|
||||
if (/^[0-9a-fA-F-]{8,}$/.test(line) && !/^0+-?0+-?0+-?0+-?0+$/.test(line)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryExec(exec: ExecFile, file: string, args: readonly string[]): string | null {
|
||||
try {
|
||||
return exec(file, args, { encoding: 'utf8', windowsHide: true, timeout: 8_000 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readWindowsRawId(exec: ExecFile): string | null {
|
||||
const regOut = tryExec(exec, 'reg', [
|
||||
'query',
|
||||
'HKLM\\SOFTWARE\\Microsoft\\Cryptography',
|
||||
'/v',
|
||||
'MachineGuid',
|
||||
]);
|
||||
if (regOut) {
|
||||
const guid = parseWindowsMachineGuid(regOut);
|
||||
if (guid) return guid;
|
||||
}
|
||||
const wmicOut = tryExec(exec, 'wmic', ['csproduct', 'get', 'uuid']);
|
||||
if (wmicOut) {
|
||||
const uuid = parseWmicUuid(wmicOut);
|
||||
if (uuid) return uuid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDarwinRawId(exec: ExecFile): string | null {
|
||||
const out = tryExec(exec, 'ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
|
||||
if (!out) return null;
|
||||
return parseMacIOPlatformUUID(out);
|
||||
}
|
||||
|
||||
function readLinuxRawId(readFile: (p: string, encoding: 'utf8') => string): string | null {
|
||||
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
||||
try {
|
||||
const id = readFile(p, 'utf8').trim();
|
||||
if (id.length >= 8 && !/^0+$/.test(id)) return id;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
try {
|
||||
const id = readFile('/sys/class/dmi/id/product_uuid', 'utf8').trim();
|
||||
if (id.length >= 8 && !/^0+-?0+-?0+-?0+-?0+$/i.test(id)) return id;
|
||||
} catch {
|
||||
/* optional, often root-only */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Каталог на уровне машины (не профиль пользователя), для редкого fallback UUID. */
|
||||
export function machineWideIdPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {
|
||||
if (platform === 'win32') {
|
||||
const base = env.PROGRAMDATA?.trim() || path.join(env.SystemDrive || 'C:', 'ProgramData');
|
||||
return path.join(base, 'TTRPGPlayer', 'machine.id');
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
return path.join('/Library/Application Support', 'TTRPGPlayer', 'machine.id');
|
||||
}
|
||||
return path.join('/var/lib', 'ttrpg-player', 'machine.id');
|
||||
}
|
||||
|
||||
function readOrCreateMachineWideFallback(
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv,
|
||||
deps: Required<
|
||||
Pick<MachineFingerprintDeps, 'existsSync' | 'readFileSync' | 'mkdirSync' | 'writeFileSync'>
|
||||
>,
|
||||
): string | null {
|
||||
const p = machineWideIdPath(platform, env);
|
||||
try {
|
||||
if (deps.existsSync(p)) {
|
||||
const existing = deps.readFileSync(p, 'utf8').trim();
|
||||
if (existing.length >= 8) return existing;
|
||||
}
|
||||
} catch {
|
||||
/* create below */
|
||||
}
|
||||
const id = createHash('sha256')
|
||||
.update(`TTRPGPlayer.machine.fallback\0${platform}\0${os.hostname()}\0${Date.now()}\0${Math.random()}`, 'utf8')
|
||||
.digest('hex');
|
||||
try {
|
||||
deps.mkdirSync(path.dirname(p), { recursive: true });
|
||||
deps.writeFileSync(p, `${id}\n`, { mode: 0o644 });
|
||||
return id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readDiskFingerprintCache(
|
||||
cacheFile: string,
|
||||
readFile: (p: string, encoding: 'utf8') => string,
|
||||
existsSync: (p: string) => boolean,
|
||||
): string | null {
|
||||
try {
|
||||
if (!existsSync(cacheFile)) return null;
|
||||
const cached = readFile(cacheFile, 'utf8').trim();
|
||||
return isPlausiblyFingerprint(cached) ? cached : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDiskFingerprintCache(
|
||||
cacheFile: string,
|
||||
fingerprint: string,
|
||||
mkdirSync: (p: string, opts: { recursive: boolean }) => void,
|
||||
writeFileSync: (p: string, data: string, opts?: { mode?: number }) => void,
|
||||
): void {
|
||||
try {
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
writeFileSync(cacheFile, `${fingerprint}\n`, { mode: 0o644 });
|
||||
} catch {
|
||||
/* кэш необязателен */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК).
|
||||
* Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id.
|
||||
* Кэш: память процесса → userData/machine.fingerprint → sync probe ОС только при miss.
|
||||
*/
|
||||
export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): string {
|
||||
const platform = deps.platform ?? process.platform;
|
||||
const env = deps.env ?? process.env;
|
||||
const exec = deps.execFileSync ?? (execFileSync as ExecFile);
|
||||
const readFile = deps.readFileSync ?? ((p, enc) => fs.readFileSync(p, enc));
|
||||
const existsSync = deps.existsSync ?? ((p) => fs.existsSync(p));
|
||||
const mkdirSync = deps.mkdirSync ?? ((p, opts) => {
|
||||
fs.mkdirSync(p, opts);
|
||||
});
|
||||
const writeFileSync = deps.writeFileSync ?? ((p, data, opts) => {
|
||||
fs.writeFileSync(p, data, opts);
|
||||
});
|
||||
|
||||
const override = env.DND_LICENSE_DEVICE_ID?.trim();
|
||||
if (override && override.length >= 8) {
|
||||
memoryFingerprint = override;
|
||||
return override;
|
||||
}
|
||||
|
||||
if (memoryFingerprint && isPlausiblyFingerprint(memoryFingerprint)) {
|
||||
return memoryFingerprint;
|
||||
}
|
||||
|
||||
const userData = deps.userData?.trim();
|
||||
const cacheFile = userData ? machineFingerprintCachePath(userData) : null;
|
||||
if (cacheFile) {
|
||||
const fromDisk = readDiskFingerprintCache(cacheFile, readFile, existsSync);
|
||||
if (fromDisk) {
|
||||
memoryFingerprint = fromDisk;
|
||||
return fromDisk;
|
||||
}
|
||||
}
|
||||
|
||||
let raw: string | null = null;
|
||||
if (platform === 'win32') raw = readWindowsRawId(exec);
|
||||
else if (platform === 'darwin') raw = readDarwinRawId(exec);
|
||||
else if (platform === 'linux') raw = readLinuxRawId(readFile);
|
||||
|
||||
if (!raw) {
|
||||
raw = readOrCreateMachineWideFallback(platform, env, {
|
||||
existsSync,
|
||||
readFileSync: readFile,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
});
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(
|
||||
'LICENSE_MACHINE_ID_UNAVAILABLE: не удалось получить идентификатор физической машины',
|
||||
);
|
||||
}
|
||||
|
||||
const fingerprint = hashMachineRawId(platform, raw);
|
||||
memoryFingerprint = fingerprint;
|
||||
if (cacheFile) {
|
||||
writeDiskFingerprintCache(cacheFile, fingerprint, mkdirSync, writeFileSync);
|
||||
}
|
||||
return fingerprint;
|
||||
}
|
||||
@@ -4,10 +4,21 @@ export function licenseEncryptedPath(userData: string): string {
|
||||
return path.join(userData, 'license.sealed');
|
||||
}
|
||||
|
||||
/** Fallback, если нет OS keychain (WSL без gnome-keyring и т.п.); только при DND_LICENSE_INSECURE_FILE_STORAGE=1. */
|
||||
export function licenseFallbackSealedPath(userData: string): string {
|
||||
return path.join(userData, 'license.sealed.fallback');
|
||||
}
|
||||
|
||||
/** Устаревший per-user UUID; актуальный deviceId — fingerprint машины (см. machineFingerprint.ts). */
|
||||
export function deviceIdPath(userData: string): string {
|
||||
return path.join(userData, 'device.id');
|
||||
}
|
||||
|
||||
/** Кэш hashed machine fingerprint — без повторного reg/wmic/ioreg на каждом старте. */
|
||||
export function machineFingerprintCachePath(userData: string): string {
|
||||
return path.join(userData, 'machine.fingerprint');
|
||||
}
|
||||
|
||||
export function preferencesPath(userData: string): string {
|
||||
return path.join(userData, 'preferences.json');
|
||||
}
|
||||
|
||||
@@ -55,6 +55,30 @@ void test('verifyLicenseToken: неверное устройство', () => {
|
||||
assert.equal(bad.reason, 'wrong_device');
|
||||
});
|
||||
|
||||
void test('verifyLicenseToken: принимает legacy deviceId при миграции', () => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
const payload = {
|
||||
v: 1 as const,
|
||||
sub: 'lic_legacy',
|
||||
pid: 'dnd_player',
|
||||
iat: 100,
|
||||
exp: 2_000_000_000,
|
||||
did: 'legacy-uuid-from-userdata',
|
||||
};
|
||||
const body = canonicalJson(payload);
|
||||
const sig = sign(null, Buffer.from(body, 'utf8'), privateKey);
|
||||
const token = joinSignedLicenseToken(body, new Uint8Array(sig.buffer, sig.byteOffset, sig.byteLength));
|
||||
|
||||
const ok = verifyLicenseToken(token, {
|
||||
nowSec: 1_700_000_000,
|
||||
deviceId: 'machine-fingerprint-hash',
|
||||
alsoAcceptDeviceIds: ['legacy-uuid-from-userdata'],
|
||||
publicKeyOverrideSpkiDerB64: pubB64,
|
||||
});
|
||||
if (!ok.ok) assert.fail(`expected ok, got ${ok.reason}`);
|
||||
});
|
||||
|
||||
void test('verifyLicenseToken: токен с переносами строк после копирования', () => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
|
||||
@@ -23,7 +23,13 @@ function getBundledPublicKey() {
|
||||
|
||||
export function verifyLicenseToken(
|
||||
token: string,
|
||||
opts: { nowSec: number; deviceId: string; publicKeyOverrideSpkiDerB64?: string },
|
||||
opts: {
|
||||
nowSec: number;
|
||||
deviceId: string;
|
||||
/** Старые deviceId (например UUID из userData) — принимаются до повторной активации. */
|
||||
alsoAcceptDeviceIds?: readonly string[];
|
||||
publicKeyOverrideSpkiDerB64?: string;
|
||||
},
|
||||
): LicenseVerifyResult {
|
||||
const parts = splitSignedLicenseToken(token);
|
||||
if (!parts) return { ok: false, reason: 'malformed' };
|
||||
@@ -53,8 +59,11 @@ export function verifyLicenseToken(
|
||||
return { ok: false, reason: 'not_yet_valid' };
|
||||
}
|
||||
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
|
||||
if (payload.did !== null && payload.did !== opts.deviceId) {
|
||||
return { ok: false, reason: 'wrong_device' };
|
||||
if (payload.did !== null) {
|
||||
const accepted = new Set<string>([opts.deviceId, ...(opts.alsoAcceptDeviceIds ?? [])]);
|
||||
if (!accepted.has(payload.did)) {
|
||||
return { ok: false, reason: 'wrong_device' };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, payload };
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
clampMaterialsLayout,
|
||||
DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
defaultLegendLayoutForMaterial,
|
||||
type MaterialId,
|
||||
type MaterialsOverlayEvent,
|
||||
type MaterialsOverlayLayout,
|
||||
type MaterialsOverlayState,
|
||||
type MaterialsZoomTool,
|
||||
zoomMaterialsLayoutAt,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): MaterialsOverlayState {
|
||||
return {
|
||||
revision: 1,
|
||||
activeMaterialIds: [],
|
||||
layouts: {},
|
||||
legendLayouts: {},
|
||||
focusMaterialId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
}
|
||||
|
||||
function initialLayout(rotationDeg?: number): MaterialsOverlayLayout {
|
||||
return clampMaterialsLayout({
|
||||
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
rotationDeg: rotationDeg ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
function layoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
|
||||
return state.layouts[materialId] ?? { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT };
|
||||
}
|
||||
|
||||
function legendLayoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
|
||||
return state.legendLayouts[materialId] ?? defaultLegendLayoutForMaterial();
|
||||
}
|
||||
|
||||
export class MaterialsOverlayStore {
|
||||
private state: MaterialsOverlayState = emptyState();
|
||||
|
||||
getState(): MaterialsOverlayState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
clear(): MaterialsOverlayState {
|
||||
if (this.state.activeMaterialIds.length === 0 && this.state.zoomTool === null) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds: [],
|
||||
layouts: {},
|
||||
legendLayouts: {},
|
||||
focusMaterialId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: MaterialsOverlayEvent): MaterialsOverlayState {
|
||||
switch (event.kind) {
|
||||
case 'hide':
|
||||
return this.clear();
|
||||
case 'show': {
|
||||
if (this.state.activeMaterialIds.includes(event.materialId)) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusMaterialId: event.materialId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: initialLayout(event.rotationDeg),
|
||||
},
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: defaultLegendLayoutForMaterial(),
|
||||
},
|
||||
focusMaterialId: event.materialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'toggle': {
|
||||
if (this.state.activeMaterialIds.includes(event.materialId)) {
|
||||
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => id !== event.materialId);
|
||||
const layouts = { ...this.state.layouts };
|
||||
const legendLayouts = { ...this.state.legendLayouts };
|
||||
delete layouts[event.materialId];
|
||||
delete legendLayouts[event.materialId];
|
||||
const focusMaterialId =
|
||||
this.state.focusMaterialId === event.materialId
|
||||
? (activeMaterialIds[activeMaterialIds.length - 1] ?? null)
|
||||
: this.state.focusMaterialId;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds,
|
||||
layouts,
|
||||
legendLayouts,
|
||||
focusMaterialId,
|
||||
zoomTool: activeMaterialIds.length === 0 ? null : this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: initialLayout(event.rotationDeg),
|
||||
},
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: defaultLegendLayoutForMaterial(),
|
||||
},
|
||||
focusMaterialId: event.materialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'layout.set': {
|
||||
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusMaterialId: event.materialId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: clampMaterialsLayout({
|
||||
...layoutFor(this.state, event.materialId),
|
||||
...event.layout,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'legendLayout.set': {
|
||||
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusMaterialId: event.materialId,
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: clampMaterialsLayout({
|
||||
...legendLayoutFor(this.state, event.materialId),
|
||||
...event.layout,
|
||||
rotationDeg: 0,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomTool.set': {
|
||||
const tool: MaterialsZoomTool = event.tool;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
zoomTool: tool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomAt': {
|
||||
if (this.state.activeMaterialIds.length === 0 || !this.state.zoomTool) return this.state;
|
||||
const targetId =
|
||||
(event.materialId && this.state.activeMaterialIds.includes(event.materialId)
|
||||
? event.materialId
|
||||
: null) ??
|
||||
this.state.focusMaterialId ??
|
||||
this.state.activeMaterialIds[this.state.activeMaterialIds.length - 1] ??
|
||||
null;
|
||||
if (!targetId) return this.state;
|
||||
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
|
||||
const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt(
|
||||
layoutFor(this.state, targetId),
|
||||
event.nx,
|
||||
event.ny,
|
||||
factor,
|
||||
);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusMaterialId: targetId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[targetId]: layout,
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default:
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
ensureMaterialStillExists(materialIds: ReadonlySet<MaterialId>): MaterialsOverlayState {
|
||||
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => materialIds.has(id));
|
||||
if (activeMaterialIds.length === this.state.activeMaterialIds.length) return this.state;
|
||||
if (activeMaterialIds.length === 0) return this.clear();
|
||||
const layouts: Record<string, MaterialsOverlayLayout> = {};
|
||||
const legendLayouts: Record<string, MaterialsOverlayLayout> = {};
|
||||
for (const id of activeMaterialIds) {
|
||||
const layout = this.state.layouts[id];
|
||||
if (layout) layouts[id] = layout;
|
||||
const legend = this.state.legendLayouts[id];
|
||||
if (legend) legendLayouts[id] = legend;
|
||||
}
|
||||
const focusMaterialId =
|
||||
this.state.focusMaterialId && activeMaterialIds.includes(this.state.focusMaterialId)
|
||||
? this.state.focusMaterialId
|
||||
: (activeMaterialIds[activeMaterialIds.length - 1] ?? null);
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds,
|
||||
layouts,
|
||||
legendLayouts,
|
||||
focusMaterialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
clampNpcsLayout,
|
||||
DEFAULT_NPCS_OVERLAY_LAYOUT,
|
||||
type NpcId,
|
||||
type NpcsOverlayEvent,
|
||||
type NpcsOverlayLayout,
|
||||
type NpcsOverlayState,
|
||||
type NpcsZoomTool,
|
||||
zoomNpcsLayoutAt,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): NpcsOverlayState {
|
||||
return {
|
||||
revision: 1,
|
||||
activeNpcIds: [],
|
||||
layouts: {},
|
||||
focusNpcId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
}
|
||||
|
||||
function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
|
||||
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
|
||||
}
|
||||
|
||||
export class NpcsOverlayStore {
|
||||
private state: NpcsOverlayState = emptyState();
|
||||
|
||||
getState(): NpcsOverlayState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
clear(): NpcsOverlayState {
|
||||
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds: [],
|
||||
layouts: {},
|
||||
focusNpcId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
|
||||
switch (event.kind) {
|
||||
case 'hide':
|
||||
return this.clear();
|
||||
case 'show': {
|
||||
if (this.state.activeNpcIds.includes(event.npcId)) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusNpcId: event.npcId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
},
|
||||
focusNpcId: event.npcId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'toggle': {
|
||||
if (this.state.activeNpcIds.includes(event.npcId)) {
|
||||
const activeNpcIds = this.state.activeNpcIds.filter((id) => id !== event.npcId);
|
||||
const layouts = { ...this.state.layouts };
|
||||
delete layouts[event.npcId];
|
||||
const focusNpcId =
|
||||
this.state.focusNpcId === event.npcId
|
||||
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
|
||||
: this.state.focusNpcId;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds,
|
||||
layouts,
|
||||
focusNpcId,
|
||||
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||
},
|
||||
focusNpcId: event.npcId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'layout.set': {
|
||||
if (!this.state.activeNpcIds.includes(event.npcId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusNpcId: event.npcId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.npcId]: clampNpcsLayout({
|
||||
...layoutFor(this.state, event.npcId),
|
||||
...event.layout,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomTool.set': {
|
||||
const tool: NpcsZoomTool = event.tool;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
zoomTool: tool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomAt': {
|
||||
if (this.state.activeNpcIds.length === 0 || !this.state.zoomTool) return this.state;
|
||||
const targetId =
|
||||
(event.npcId && this.state.activeNpcIds.includes(event.npcId) ? event.npcId : null) ??
|
||||
this.state.focusNpcId ??
|
||||
this.state.activeNpcIds[this.state.activeNpcIds.length - 1] ??
|
||||
null;
|
||||
if (!targetId) return this.state;
|
||||
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
|
||||
const layout: NpcsOverlayLayout = zoomNpcsLayoutAt(
|
||||
layoutFor(this.state, targetId),
|
||||
event.nx,
|
||||
event.ny,
|
||||
factor,
|
||||
);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusNpcId: targetId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[targetId]: layout,
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default:
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
|
||||
const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
|
||||
if (activeNpcIds.length === this.state.activeNpcIds.length) return this.state;
|
||||
if (activeNpcIds.length === 0) return this.clear();
|
||||
const layouts: Record<string, NpcsOverlayLayout> = {};
|
||||
for (const id of activeNpcIds) {
|
||||
const layout = this.state.layouts[id];
|
||||
if (layout) layouts[id] = layout;
|
||||
}
|
||||
const focusNpcId =
|
||||
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
|
||||
? this.state.focusNpcId
|
||||
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeNpcIds,
|
||||
layouts,
|
||||
focusNpcId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,11 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
|
||||
},
|
||||
},
|
||||
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
|
||||
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
|
||||
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
|
||||
} as unknown as Project;
|
||||
const s = collectReferencedAssetIds(p);
|
||||
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'th', 'v1'].sort());
|
||||
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'mat1', 'npc1', 'pr', 'th', 'v1'].sort());
|
||||
});
|
||||
|
||||
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
|
||||
@@ -65,12 +67,14 @@ void test('reconcileAssetFiles: снимает осиротевшие assets и
|
||||
...base,
|
||||
scenes: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
assets: { orphan: asset } as Project['assets'],
|
||||
};
|
||||
const next: Project = {
|
||||
...base,
|
||||
scenes: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
assets: { orphan: asset } as Project['assets'],
|
||||
};
|
||||
|
||||
@@ -115,7 +119,7 @@ void test('reconcileAssetFiles: удаляет файл при исключен
|
||||
} as unknown as Project;
|
||||
|
||||
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
|
||||
const next: Project = { ...base, campaignAudios: [], assets: {} as Project['assets'] };
|
||||
const next: Project = { ...base, campaignAudios: [], materials: [], assets: {} as Project['assets'] };
|
||||
|
||||
const out = await reconcileAssetFiles(prev, next, tmp);
|
||||
assert.deepEqual(out.assets, {});
|
||||
|
||||
@@ -14,6 +14,8 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
|
||||
for (const au of sc.media.audios) refs.add(au.assetId);
|
||||
}
|
||||
for (const au of p.campaignAudios) refs.add(au.assetId);
|
||||
for (const m of p.materials ?? []) refs.add(m.assetId);
|
||||
for (const n of p.npcs ?? []) refs.add(n.avatarAssetId);
|
||||
return refs;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PROJECT_ZIP_EXTENSION,
|
||||
PROJECT_ZIP_EXTENSION_LEGACY,
|
||||
isProjectZipFileName,
|
||||
} from '../../shared/project/projectZipExtension';
|
||||
|
||||
import { readProjectJsonFromZip } from './yauzlProjectZip';
|
||||
|
||||
/**
|
||||
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.dnd.zip.tmp`).
|
||||
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp`).
|
||||
* Нельзя сначала удалять `finalPath`: при сбое rename после rm проект теряется (Windows/антивирус).
|
||||
*/
|
||||
export async function replaceFileAtomic(completedSrc: string, finalPath: string): Promise<void> {
|
||||
@@ -50,8 +56,10 @@ export async function replaceFileAtomic(completedSrc: string, finalPath: string)
|
||||
await fs.unlink(backupPath).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Если сохранение оборвалось, остаётся только `*.dnd.zip.tmp` — восстанавливаем в `*.dnd.zip`. */
|
||||
export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void> {
|
||||
const ZIP_TMP_SUFFIXES = [`${PROJECT_ZIP_EXTENSION}.tmp`, `${PROJECT_ZIP_EXTENSION_LEGACY}.tmp`] as const;
|
||||
|
||||
/** Если сохранение оборвалось, остаётся `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp` — восстанавливаем финальный архив. */
|
||||
export async function recoverOrphanProjectZipTmpInRoot(root: string): Promise<void> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(root);
|
||||
@@ -59,10 +67,11 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
|
||||
return;
|
||||
}
|
||||
for (const name of names) {
|
||||
if (!name.endsWith('.dnd.zip.tmp')) continue;
|
||||
const matchedSuffix = ZIP_TMP_SUFFIXES.find((s) => name.endsWith(s));
|
||||
if (!matchedSuffix) continue;
|
||||
const tmpPath = path.join(root, name);
|
||||
const finalName = name.slice(0, -'.tmp'.length);
|
||||
if (!finalName.endsWith('.dnd.zip')) continue;
|
||||
if (!isProjectZipFileName(finalName)) continue;
|
||||
const finalPath = path.join(root, finalName);
|
||||
try {
|
||||
await fs.access(finalPath);
|
||||
@@ -80,3 +89,6 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Используйте {@link recoverOrphanProjectZipTmpInRoot}. */
|
||||
export const recoverOrphanDndZipTmpInRoot = recoverOrphanProjectZipTmpInRoot;
|
||||
|
||||
@@ -17,7 +17,14 @@ export function getProjectsCacheRootDir(): string {
|
||||
export function getLegacyProjectsRootDirs(): string[] {
|
||||
const cur = getProjectsRootDir();
|
||||
const parent = path.dirname(app.getPath('userData'));
|
||||
const siblingNames = ['DnD Player', 'dnd-player', 'DNDGamePlayer', 'dnd_player'];
|
||||
const siblingNames = [
|
||||
'TTRPG Player',
|
||||
'TTRPGPlayer',
|
||||
'DnD Player',
|
||||
'dnd-player',
|
||||
'DNDGamePlayer',
|
||||
'dnd_player',
|
||||
];
|
||||
const out: string[] = [];
|
||||
for (const n of siblingNames) {
|
||||
const p = path.join(parent, n, 'projects');
|
||||
|
||||
@@ -13,7 +13,7 @@ import { readProjectJsonFromZip } from './yauzlProjectZip';
|
||||
|
||||
void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-read-'));
|
||||
const zipPath = path.join(tmp, 'test.dnd.zip');
|
||||
const zipPath = path.join(tmp, 'test.ttrpg.zip');
|
||||
const minimal = {
|
||||
id: 'p1',
|
||||
meta: {
|
||||
@@ -54,7 +54,7 @@ void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', as
|
||||
|
||||
void test('readProjectJsonFromZip: campaignAudios round-trips inside project.json', async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-campaign-'));
|
||||
const zipPath = path.join(tmp, 'test.dnd.zip');
|
||||
const zipPath = path.join(tmp, 'test.ttrpg.zip');
|
||||
const assetId = 'audio_asset_1';
|
||||
const minimal = {
|
||||
id: 'p1',
|
||||
|
||||
@@ -28,9 +28,32 @@ void test('zipStore: openProjectById flushes pending saveNow before cache reset'
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
// When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first.
|
||||
assert.match(src, /async openProjectById/);
|
||||
assert.match(src, /enqueueOpenProject/);
|
||||
assert.match(src, /openProjectByIdInner/);
|
||||
assert.match(src, /if \(this\.openProject\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
|
||||
assert.match(src, /await this\.drainSavePipeline\(\)/);
|
||||
assert.match(src, /await fs\.rm\(cacheDir, \{ recursive: true, force: true \}\)/);
|
||||
assert.match(src, /await unzipToDir\(zipPath, cacheDir\)/);
|
||||
assert.match(src, /await unzipToDir\(zipPath, cacheDir/);
|
||||
});
|
||||
|
||||
void test('zipStore: openProjectById skips re-unzip when project is already open', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*return this\.openProject\.project;\s*\}/);
|
||||
});
|
||||
|
||||
void test('zipStore: pack and open operations are serialized', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /private packChain: Promise<void>/);
|
||||
assert.match(src, /private projectSwitchChain: Promise<void>/);
|
||||
assert.match(src, /enqueuePack/);
|
||||
assert.match(src, /enqueueOpenProject/);
|
||||
assert.match(src, /enqueueProjectSwitch/);
|
||||
});
|
||||
|
||||
void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/);
|
||||
assert.match(src, /Открытие проекта отменено/);
|
||||
});
|
||||
|
||||
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
|
||||
@@ -58,7 +81,7 @@ void test('atomicReplace: replaceFileAtomic must not rm destination before succe
|
||||
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
|
||||
const i = src.indexOf('export async function replaceFileAtomic');
|
||||
assert.ok(i >= 0);
|
||||
const j = src.indexOf('export async function recoverOrphanDndZipTmpInRoot', i);
|
||||
const j = src.indexOf('export async function recoverOrphanProjectZipTmpInRoot', i);
|
||||
assert.ok(j > i);
|
||||
const block = src.slice(i, j);
|
||||
assert.match(block, /rename\(finalPath, backupPath\)/);
|
||||
|
||||
+1424
-133
File diff suppressed because it is too large
Load Diff
@@ -2,72 +2,100 @@ import fs from 'node:fs/promises';
|
||||
|
||||
import { session } from 'electron';
|
||||
|
||||
import { asAssetId } from '../../shared/types/ids';
|
||||
import { asAssetId, asTokenId } from '../../shared/types/ids';
|
||||
import type { ZipProjectStore } from '../project/zipStore';
|
||||
import type { TokensStore } from '../tokens/tokensStore';
|
||||
|
||||
type ReadInfo = { absPath: string; mime: string };
|
||||
|
||||
async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
|
||||
try {
|
||||
const stat = await fs.stat(info.absPath);
|
||||
const total = stat.size;
|
||||
const range = request.headers.get('range') ?? request.headers.get('Range');
|
||||
|
||||
if (range) {
|
||||
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
|
||||
if (m) {
|
||||
const start = Number(m[1]);
|
||||
const endRaw = m[2] ? Number(m[2]) : total - 1;
|
||||
const end = Math.min(endRaw, total - 1);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
'Content-Range': `bytes */${String(total)}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
const len = end - start + 1;
|
||||
const fh = await fs.open(info.absPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(len);
|
||||
const { bytesRead } = await fh.read(buf, 0, len, start);
|
||||
if (bytesRead <= 0) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
'Content-Range': `bytes */${String(total)}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead));
|
||||
const actualEnd = start + bytesRead - 1;
|
||||
return new Response(body, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': info.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
|
||||
'Content-Length': String(body.length),
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buf = await fs.readFile(info.absPath);
|
||||
return new Response(buf, {
|
||||
headers: {
|
||||
'Content-Type': info.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обслуживает `dnd://asset?...` — без этого `<img src="file://...">` в рендерере часто ломается.
|
||||
* Обслуживает `dnd://asset?...` и `dnd://token?...`.
|
||||
*/
|
||||
export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
|
||||
export function registerDndAssetProtocol(
|
||||
projectStore: ZipProjectStore,
|
||||
tokensStore: TokensStore,
|
||||
): void {
|
||||
session.defaultSession.protocol.handle('dnd', async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.hostname !== 'asset') {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
const id = url.searchParams.get('id');
|
||||
if (!id) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
const info = projectStore.getAssetReadInfo(asAssetId(id));
|
||||
let info: ReadInfo | null = null;
|
||||
if (url.hostname === 'asset') {
|
||||
info = projectStore.getAssetReadInfo(asAssetId(id));
|
||||
} else if (url.hostname === 'token') {
|
||||
info = tokensStore.getImageReadInfo(asTokenId(id));
|
||||
}
|
||||
if (!info) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const stat = await fs.stat(info.absPath);
|
||||
const total = stat.size;
|
||||
const range = request.headers.get('range') ?? request.headers.get('Range');
|
||||
|
||||
if (range) {
|
||||
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
|
||||
if (m) {
|
||||
const start = Number(m[1]);
|
||||
const endRaw = m[2] ? Number(m[2]) : total - 1;
|
||||
const end = Math.min(endRaw, total - 1);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
|
||||
return new Response(null, { status: 416 });
|
||||
}
|
||||
const len = end - start + 1;
|
||||
const fh = await fs.open(info.absPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(len);
|
||||
await fh.read(buf, 0, len, start);
|
||||
return new Response(buf, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Type': info.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`,
|
||||
'Content-Length': String(len),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buf = await fs.readFile(info.absPath);
|
||||
return new Response(buf, {
|
||||
headers: {
|
||||
'Content-Type': info.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': String(buf.length),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
return serveFile(info, request);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
void test('main: EPIPE guards ставятся до app.whenReady', () => {
|
||||
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
|
||||
const safe = fs.readFileSync(path.join(here, 'safeConsole.ts'), 'utf8');
|
||||
assert.ok(index.includes('installStdoutEpipeGuards'));
|
||||
assert.ok(index.indexOf('installStdoutEpipeGuards()') < index.indexOf('app.requestSingleInstanceLock'));
|
||||
assert.ok(safe.includes('EPIPE'));
|
||||
assert.ok(safe.includes('safeConsoleError'));
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* В Electron (особенно после рестарта в dev) stdout/stderr часто уже закрыты.
|
||||
* Обычный `console.error` тогда даёт EPIPE и валит main process диалогом Uncaught Exception.
|
||||
*/
|
||||
|
||||
function isBrokenPipe(err: unknown): boolean {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED';
|
||||
}
|
||||
|
||||
export function installStdoutEpipeGuards(): void {
|
||||
for (const stream of [process.stdout, process.stderr]) {
|
||||
stream?.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (isBrokenPipe(err)) return;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function safeConsoleError(...args: unknown[]): void {
|
||||
try {
|
||||
console.error(...args);
|
||||
} catch (err) {
|
||||
if (isBrokenPipe(err)) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SceneTrapsStore } from './sceneTrapsStore';
|
||||
|
||||
void test('SceneTrapsStore: сохраняет runtime при переключении сцен внутри сессии', () => {
|
||||
const store = new SceneTrapsStore();
|
||||
store.switchScene('scene_a', ['t1']);
|
||||
store.dispatch({ kind: 'activate', trapId: 't1' });
|
||||
assert.equal(store.getState().byId.t1?.status, 'active');
|
||||
assert.equal(store.getState().byId.t1?.revealed, true);
|
||||
|
||||
store.switchScene('scene_b', ['t2']);
|
||||
assert.equal(store.getState().byId.t2?.status, 'inactive');
|
||||
|
||||
store.switchScene('scene_a', ['t1']);
|
||||
assert.equal(store.getState().byId.t1?.status, 'active');
|
||||
assert.equal(store.getState().byId.t1?.revealed, true);
|
||||
});
|
||||
|
||||
void test('SceneTrapsStore: resetSession сбрасывает кэш к дефолту (кнопка «Запустить»)', () => {
|
||||
const store = new SceneTrapsStore();
|
||||
store.switchScene('scene_a', ['t1', 't2']);
|
||||
store.dispatch({ kind: 'reveal', trapId: 't1' });
|
||||
store.dispatch({ kind: 'activate', trapId: 't2' });
|
||||
assert.equal(store.getState().byId.t1?.revealed, true);
|
||||
assert.equal(store.getState().byId.t2?.status, 'active');
|
||||
assert.ok(store.getState().lastActivation);
|
||||
|
||||
store.resetSession();
|
||||
store.switchScene('scene_a', ['t1', 't2']);
|
||||
|
||||
assert.equal(store.getState().byId.t1?.status, 'inactive');
|
||||
assert.equal(store.getState().byId.t1?.revealed, false);
|
||||
assert.equal(store.getState().byId.t2?.status, 'inactive');
|
||||
assert.equal(store.getState().byId.t2?.revealed, false);
|
||||
assert.equal(store.getState().lastActivation, null);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
defaultTrapRuntime,
|
||||
type SceneTrapsEvent,
|
||||
type SceneTrapsState,
|
||||
type SceneTrapRuntime,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneTrapsState {
|
||||
return {
|
||||
revision: 1,
|
||||
cacheKey: null,
|
||||
byId: {},
|
||||
lastActivation: null,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneById(byId: Record<string, SceneTrapRuntime>): Record<string, SceneTrapRuntime> {
|
||||
const out: Record<string, SceneTrapRuntime> = {};
|
||||
for (const [k, v] of Object.entries(byId)) {
|
||||
out[k] = { ...v };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class SceneTrapsStore {
|
||||
private state: SceneTrapsState = emptyState();
|
||||
/** Кэш runtime по ключу сцены/ноды на время сессии показа. */
|
||||
private cache = new Map<string, Record<string, SceneTrapRuntime>>();
|
||||
private currentKey: string | null = null;
|
||||
private activationToken = 0;
|
||||
|
||||
getState(): SceneTrapsState {
|
||||
return {
|
||||
...this.state,
|
||||
byId: cloneById(this.state.byId),
|
||||
lastActivation: this.state.lastActivation ? { ...this.state.lastActivation } : null,
|
||||
};
|
||||
}
|
||||
|
||||
resetSession(): void {
|
||||
this.cache.clear();
|
||||
this.currentKey = null;
|
||||
this.state = emptyState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключение сцены: сохраняем runtime в кэш и подгружаем/инициализируем для новых trapIds.
|
||||
*/
|
||||
switchScene(cacheKey: string | null, trapIds: readonly string[]): SceneTrapsState {
|
||||
if (this.currentKey !== null) {
|
||||
this.cache.set(this.currentKey, cloneById(this.state.byId));
|
||||
}
|
||||
this.currentKey = cacheKey;
|
||||
const cached = cacheKey ? this.cache.get(cacheKey) : undefined;
|
||||
const byId: Record<string, SceneTrapRuntime> = {};
|
||||
for (const id of trapIds) {
|
||||
byId[id] = cached?.[id] ? { ...cached[id]! } : defaultTrapRuntime();
|
||||
}
|
||||
if (cacheKey) {
|
||||
this.cache.set(cacheKey, cloneById(byId));
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
cacheKey,
|
||||
byId,
|
||||
lastActivation: null,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
dispatch(event: SceneTrapsEvent): SceneTrapsState {
|
||||
switch (event.kind) {
|
||||
case 'syncTrapIds': {
|
||||
const byId: Record<string, SceneTrapRuntime> = {};
|
||||
for (const id of event.trapIds) {
|
||||
byId[id] = this.state.byId[id] ? { ...this.state.byId[id]! } : defaultTrapRuntime();
|
||||
}
|
||||
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byId,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
case 'reveal': {
|
||||
const cur = this.state.byId[event.trapId];
|
||||
if (!cur) return this.getState();
|
||||
const byId = cloneById(this.state.byId);
|
||||
byId[event.trapId] = { ...cur, revealed: true };
|
||||
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byId,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
case 'activate': {
|
||||
const cur = this.state.byId[event.trapId];
|
||||
if (!cur) return this.getState();
|
||||
const byId = cloneById(this.state.byId);
|
||||
byId[event.trapId] = { status: 'active', revealed: true };
|
||||
this.activationToken += 1;
|
||||
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byId,
|
||||
lastActivation: { trapId: event.trapId, token: this.activationToken },
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
case 'disarm': {
|
||||
const cur = this.state.byId[event.trapId];
|
||||
if (!cur) return this.getState();
|
||||
const byId = cloneById(this.state.byId);
|
||||
byId[event.trapId] = { status: 'disarmed', revealed: true };
|
||||
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byId,
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
default: {
|
||||
const _x: never = event;
|
||||
void _x;
|
||||
return this.getState();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_SCENE_VIEW_CAMERA,
|
||||
sceneViewPanBy,
|
||||
sceneViewZoomAt,
|
||||
} from '../../shared/types/sceneView';
|
||||
|
||||
import { SceneViewStore } from './sceneViewStore';
|
||||
|
||||
describe('SceneViewStore', () => {
|
||||
it('resets to default camera', () => {
|
||||
const store = new SceneViewStore();
|
||||
store.dispatch({ kind: 'set', camera: { scale: 2, ox: 0.2, oy: 0.8 } });
|
||||
const next = store.dispatch({ kind: 'reset' });
|
||||
assert.equal(next.scale, 1);
|
||||
assert.equal(next.ox, 0.5);
|
||||
assert.equal(next.oy, 0.5);
|
||||
});
|
||||
|
||||
it('clamps scale and origin', () => {
|
||||
const store = new SceneViewStore();
|
||||
const next = store.dispatch({ kind: 'set', camera: { scale: 99, ox: -1, oy: 2 } });
|
||||
assert.equal(next.scale, 8);
|
||||
assert.equal(next.ox, 0);
|
||||
assert.equal(next.oy, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sceneViewZoomAt / panBy', () => {
|
||||
it('zooms toward cursor and keeps that content point under cursor', () => {
|
||||
const hostW = 1000;
|
||||
const hostH = 500;
|
||||
const containW = 800;
|
||||
const containH = 400;
|
||||
const hostX = 700;
|
||||
const hostY = 250;
|
||||
const next = sceneViewZoomAt(DEFAULT_SCENE_VIEW_CAMERA, {
|
||||
hostW,
|
||||
hostH,
|
||||
containW,
|
||||
containH,
|
||||
hostX,
|
||||
hostY,
|
||||
factor: 2,
|
||||
});
|
||||
assert.ok(next.scale > 1.5);
|
||||
const displayW = containW * next.scale;
|
||||
const displayH = containH * next.scale;
|
||||
const left = hostW / 2 - next.ox * displayW;
|
||||
const top = hostH / 2 - next.oy * displayH;
|
||||
const ix = (hostX - left) / displayW;
|
||||
const iy = (hostY - top) / displayH;
|
||||
// At scale=1 contain is centered; cursor was at content x=(700-100)/800=0.75
|
||||
assert.ok(Math.abs(ix - 0.75) < 1e-6);
|
||||
assert.ok(Math.abs(iy - 0.5) < 1e-6);
|
||||
});
|
||||
|
||||
it('pans in host pixels', () => {
|
||||
const cam = { scale: 2, ox: 0.5, oy: 0.5 };
|
||||
const next = sceneViewPanBy(cam, { containW: 400, containH: 200, dx: 80, dy: 0 });
|
||||
// displayW=800; dx=80 → ox decreases by 0.1
|
||||
assert.ok(Math.abs(next.ox - 0.4) < 1e-6);
|
||||
assert.equal(next.oy, 0.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
clampSceneViewCamera,
|
||||
DEFAULT_SCENE_VIEW_CAMERA,
|
||||
type SceneViewCamera,
|
||||
type SceneViewEvent,
|
||||
type SceneViewState,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneViewState {
|
||||
return {
|
||||
revision: 1,
|
||||
...DEFAULT_SCENE_VIEW_CAMERA,
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneViewStore {
|
||||
private state: SceneViewState = emptyState();
|
||||
|
||||
getState(): SceneViewState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): SceneViewState {
|
||||
if (
|
||||
this.state.scale === 1 &&
|
||||
this.state.ox === 0.5 &&
|
||||
this.state.oy === 0.5
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
...DEFAULT_SCENE_VIEW_CAMERA,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneViewEvent): SceneViewState {
|
||||
switch (event.kind) {
|
||||
case 'reset':
|
||||
return this.reset();
|
||||
case 'set': {
|
||||
const camera: SceneViewCamera = clampSceneViewCamera(event.camera);
|
||||
if (
|
||||
camera.scale === this.state.scale &&
|
||||
camera.ox === this.state.ox &&
|
||||
camera.oy === this.state.oy
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
...camera,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Фаза 6 (срез): layout-only мутации не должны слать полный Project во все окна.
|
||||
* Редактор НПС применяет позицию из ответа invoke локально.
|
||||
*/
|
||||
void test('session IPC: нет emitSessionState на graph/NPC position hot-path', () => {
|
||||
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
|
||||
const createWindows = fs.readFileSync(path.join(here, 'windows/createWindows.ts'), 'utf8');
|
||||
const npcsEditor = fs.readFileSync(
|
||||
path.join(here, '../renderer/npcs/NpcsEditorApp.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.ok(index.includes('sendToAppWindows(ipcChannels.session.stateChanged'));
|
||||
assert.ok(createWindows.includes('SESSION_STATE_WINDOW_KINDS'));
|
||||
assert.ok(createWindows.includes('sendToAppWindows'));
|
||||
const kindsBlock = /SESSION_STATE_WINDOW_KINDS: readonly WindowKind\[\] = \[([\s\S]*?)\] as const/.exec(
|
||||
createWindows,
|
||||
);
|
||||
assert.ok(kindsBlock, 'SESSION_STATE_WINDOW_KINDS объявлен');
|
||||
assert.doesNotMatch(kindsBlock[1] ?? '', /'editor'/, 'editor не получает session.stateChanged');
|
||||
assert.match(kindsBlock[1] ?? '', /'presentation'/);
|
||||
assert.match(kindsBlock[1] ?? '', /'control'/);
|
||||
|
||||
// Handlers больше не вызывают emitSessionState сразу после layout-update.
|
||||
const npcPosHandler =
|
||||
/registerHandler\(ipcChannels\.project\.updateNpcPosition,\s*async\s*\(\{ npcId, x, y \}\) => \{([\s\S]*?)\}\);/.exec(
|
||||
index,
|
||||
);
|
||||
const graphPosHandler =
|
||||
/registerHandler\(ipcChannels\.project\.updateSceneGraphNodePosition,\s*async\s*\(\{ nodeId, x, y \}\) => \{([\s\S]*?)\}\);/.exec(
|
||||
index,
|
||||
);
|
||||
assert.ok(npcPosHandler, 'updateNpcPosition handler');
|
||||
assert.ok(graphPosHandler, 'updateSceneGraphNodePosition handler');
|
||||
assert.doesNotMatch(npcPosHandler[1] ?? '', /emitSessionState/);
|
||||
assert.doesNotMatch(graphPosHandler[1] ?? '', /emitSessionState/);
|
||||
assert.match(npcPosHandler[1] ?? '', /return \{ project \}/);
|
||||
assert.match(graphPosHandler[1] ?? '', /return \{ project \}/);
|
||||
|
||||
assert.ok(npcsEditor.includes('updateNpcPosition'));
|
||||
assert.match(
|
||||
npcsEditor,
|
||||
/onNodePositionCommit[\s\S]*?setSession\([\s\S]*?npcs: prev\.project\.npcs\.map/,
|
||||
);
|
||||
assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneTokensSessionState {
|
||||
return {
|
||||
revision: 1,
|
||||
byPlacementId: {},
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneTokensSessionStore {
|
||||
private state: SceneTokensSessionState = emptyState();
|
||||
|
||||
getState(): SceneTokensSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): SceneTokensSessionState {
|
||||
if (Object.keys(this.state.byPlacementId).length === 0) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'move': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: { nx, ny },
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
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,
|
||||
sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined,
|
||||
});
|
||||
remap.set(remappedFrom, token.id);
|
||||
}
|
||||
return remap;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
const payload: TokensManifest = { tokens: this.tokens };
|
||||
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { app, dialog } from 'electron';
|
||||
import { app, BrowserWindow, dialog } from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
|
||||
import { appDisplayNameForLocale } from '../../shared/appBranding';
|
||||
import {
|
||||
ipcChannels,
|
||||
type UpdaterCheckResponse,
|
||||
type UpdaterDownloadResponse,
|
||||
type UpdaterProgressEvent,
|
||||
} from '../../shared/ipc/contracts';
|
||||
import type { IpcRegisterHandler } from '../ipc/router';
|
||||
import { addLicenseChangeListener } from '../license/licenseService';
|
||||
@@ -13,13 +15,63 @@ import type { LicenseService } from '../license/licenseService';
|
||||
const STARTUP_CHECK_DELAY_MS = 12_000;
|
||||
/** Не дёргать сервер чаще (смена лицензии / повторные emit). */
|
||||
const RE_CHECK_COOLDOWN_MS = 30_000;
|
||||
/** Ручная загрузка из модалки — не зависать бесконечно на «Загрузка…». */
|
||||
const MANUAL_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, code: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(code)), ms);
|
||||
promise.then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
(e: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* На macOS Squirrel.Mac может уже получить update-downloaded к моменту quitAndInstall.
|
||||
* При autoInstallOnAppQuit=true MacUpdater не вызывает checkForUpdates повторно и зависает.
|
||||
*/
|
||||
function quitAndInstallForPlatform(): void {
|
||||
if (process.platform === 'darwin') {
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
}
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
|
||||
function formatUpdaterError(e: unknown): string {
|
||||
const raw = e instanceof Error ? e.message : String(e);
|
||||
if (raw === 'UPDATE_DOWNLOAD_TIMEOUT') {
|
||||
return 'Превышено время ожидания загрузки обновления';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
let lastCheckAt = 0;
|
||||
/** Ручная установка: не показывать второй диалог из `update-downloaded`. */
|
||||
let suppressAutoInstallDialog = false;
|
||||
/** Версия, уже скачанная Squirrel/electron-updater (в т.ч. фоном). */
|
||||
let downloadedUpdateVersion: string | null = null;
|
||||
|
||||
type RegisterFn = IpcRegisterHandler;
|
||||
|
||||
function emitUpdaterProgress(ev: UpdaterProgressEvent): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.updater.progress, ev);
|
||||
} catch {
|
||||
/* окно закрылось */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLicensedForUpdates(licenseService: LicenseService): boolean {
|
||||
const snap = licenseService.getStatusSync();
|
||||
return snap.active;
|
||||
@@ -31,6 +83,7 @@ function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: bo
|
||||
const now = Date.now();
|
||||
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
|
||||
lastCheckAt = now;
|
||||
emitUpdaterProgress({ phase: 'checking' });
|
||||
void autoUpdater.checkForUpdates().catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -41,42 +94,132 @@ async function runManualUpdaterCheck(licenseService: LicenseService): Promise<Up
|
||||
if (!isLicensedForUpdates(licenseService)) {
|
||||
return { outcome: 'no_license' };
|
||||
}
|
||||
const prevAutoDownload = autoUpdater.autoDownload;
|
||||
autoUpdater.autoDownload = false;
|
||||
emitUpdaterProgress({ phase: 'checking' });
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
if (result && result.isUpdateAvailable && result.updateInfo.version) {
|
||||
emitUpdaterProgress({ phase: 'available', version: result.updateInfo.version });
|
||||
return { outcome: 'available', version: result.updateInfo.version };
|
||||
}
|
||||
emitUpdaterProgress({ phase: 'not-available', version: app.getVersion() });
|
||||
return { outcome: 'current', currentVersion: app.getVersion() };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
emitUpdaterProgress({ phase: 'error', message });
|
||||
return { outcome: 'error', message };
|
||||
} finally {
|
||||
autoUpdater.autoDownload = prevAutoDownload;
|
||||
}
|
||||
}
|
||||
|
||||
async function runManualDownloadAndRestart(): Promise<UpdaterDownloadResponse> {
|
||||
function isUpdateAlreadyDownloaded(targetVersion: string): boolean {
|
||||
return downloadedUpdateVersion !== null && downloadedUpdateVersion === targetVersion;
|
||||
}
|
||||
|
||||
async function runManualDownloadAndRestart(targetVersion: string): Promise<UpdaterDownloadResponse> {
|
||||
if (!app.isPackaged) {
|
||||
return { ok: false, message: 'NOT_PACKAGED' };
|
||||
}
|
||||
const prevAutoInstallOnAppQuit = autoUpdater.autoInstallOnAppQuit;
|
||||
try {
|
||||
suppressAutoInstallDialog = true;
|
||||
await autoUpdater.downloadUpdate();
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
if (process.platform === 'darwin') {
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
if (!isUpdateAlreadyDownloaded(targetVersion)) {
|
||||
emitUpdaterProgress({ phase: 'downloading', version: targetVersion, percent: 0 });
|
||||
await withTimeout(
|
||||
autoUpdater.downloadUpdate(),
|
||||
MANUAL_DOWNLOAD_TIMEOUT_MS,
|
||||
'UPDATE_DOWNLOAD_TIMEOUT',
|
||||
);
|
||||
}
|
||||
|
||||
emitUpdaterProgress({ phase: 'installing', version: targetVersion });
|
||||
quitAndInstallForPlatform();
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
suppressAutoInstallDialog = false;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const message = formatUpdaterError(e);
|
||||
emitUpdaterProgress({ phase: 'error', message });
|
||||
if (process.platform === 'darwin') {
|
||||
autoUpdater.autoInstallOnAppQuit = prevAutoInstallOnAppQuit;
|
||||
}
|
||||
return { ok: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
|
||||
register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService));
|
||||
register(ipcChannels.updater.downloadAndRestart, () => runManualDownloadAndRestart());
|
||||
register(ipcChannels.updater.downloadAndRestart, (req: { version: string }) =>
|
||||
runManualDownloadAndRestart(req.version),
|
||||
);
|
||||
}
|
||||
|
||||
function wireAutoUpdaterEvents(licenseService: LicenseService): void {
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
emitUpdaterProgress({ phase: 'checking' });
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
emitUpdaterProgress({ phase: 'available', version: info.version });
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
emitUpdaterProgress({ phase: 'not-available', version: info.version });
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
const percent =
|
||||
typeof progress.percent === 'number' && Number.isFinite(progress.percent)
|
||||
? Math.round(progress.percent)
|
||||
: undefined;
|
||||
const ev: UpdaterProgressEvent = { phase: 'downloading' };
|
||||
if (percent !== undefined) ev.percent = percent;
|
||||
emitUpdaterProgress(ev);
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
downloadedUpdateVersion = info.version;
|
||||
emitUpdaterProgress({ phase: 'downloading', version: info.version, percent: 100 });
|
||||
if (suppressAutoInstallDialog) {
|
||||
suppressAutoInstallDialog = false;
|
||||
return;
|
||||
}
|
||||
void dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: appDisplayNameForLocale(app.getLocale()),
|
||||
message: `Доступна новая версия ${info.version}. Установить и перезапустить?`,
|
||||
buttons: ['Перезапустить сейчас', 'Позже'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.response === 0) {
|
||||
emitUpdaterProgress({ phase: 'installing', version: info.version });
|
||||
quitAndInstallForPlatform();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (e) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
emitUpdaterProgress({ phase: 'error', message });
|
||||
});
|
||||
|
||||
addLicenseChangeListener(() => {
|
||||
maybeCheckForUpdates(licenseService, false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка обновлений: только упакованное приложение, только при активной лицензии.
|
||||
* Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки).
|
||||
* Дифференциальное скачивание (HTTP Range / blockmap) по умолчанию **выключено**: за nginx у Gitea raw часто **400** на multi-Range, updater всё равно уходит в полный файл. Включить снова: **`DND_UPDATE_ENABLE_DIFFERENTIAL=1`** (имеет смысл только если на сервере починили Range).
|
||||
*/
|
||||
export function installAutoUpdater(licenseService: LicenseService, register: RegisterFn): void {
|
||||
registerUpdaterHandlers(register, licenseService);
|
||||
@@ -89,37 +232,17 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url });
|
||||
}
|
||||
|
||||
const enableDiff = process.env.DND_UPDATE_ENABLE_DIFFERENTIAL?.trim().toLowerCase();
|
||||
autoUpdater.disableDifferentialDownload = !(
|
||||
enableDiff === '1' ||
|
||||
enableDiff === 'true' ||
|
||||
enableDiff === 'yes'
|
||||
);
|
||||
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
if (suppressAutoInstallDialog) {
|
||||
suppressAutoInstallDialog = false;
|
||||
return;
|
||||
}
|
||||
void dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'DNDGamePlayer',
|
||||
message: `Доступна новая версия ${info.version}. Установить и перезапустить?`,
|
||||
buttons: ['Перезапустить сейчас', 'Позже'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.response === 0) {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('error', () => {
|
||||
/* без console: в production main минифицируется с drop console */
|
||||
});
|
||||
|
||||
addLicenseChangeListener(() => {
|
||||
maybeCheckForUpdates(licenseService, false);
|
||||
});
|
||||
wireAutoUpdaterEvents(licenseService);
|
||||
|
||||
setTimeout(() => {
|
||||
maybeCheckForUpdates(licenseService, true);
|
||||
|
||||
@@ -2,8 +2,11 @@ import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
|
||||
import { appDisplayNameForLocale, windowChromeTitle } from '../../shared/appBranding';
|
||||
import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
||||
|
||||
let bootSplashRef: BrowserWindow | null = null;
|
||||
|
||||
export function getBootSplashWindow(): BrowserWindow | null {
|
||||
@@ -37,6 +40,7 @@ function bootWebPreferences(): Electron.WebPreferences {
|
||||
* Показывать после `waitForBootWindowReady`.
|
||||
*/
|
||||
export function createBootWindow(): BrowserWindow {
|
||||
const icon = loadBrandingWindowIcon();
|
||||
const win = new BrowserWindow({
|
||||
width: 440,
|
||||
height: 420,
|
||||
@@ -50,8 +54,17 @@ export function createBootWindow(): BrowserWindow {
|
||||
transparent: false,
|
||||
backgroundColor: '#09090B',
|
||||
roundedCorners: true,
|
||||
...(icon ? { icon } : {}),
|
||||
webPreferences: bootWebPreferences(),
|
||||
});
|
||||
if (icon) {
|
||||
try {
|
||||
win.setIcon(icon);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
win.setTitle(windowChromeTitle('boot', app.getLocale()));
|
||||
|
||||
bootSplashRef = win;
|
||||
win.once('closed', () => {
|
||||
@@ -82,7 +95,7 @@ export function setBootWindowStatus(win: BrowserWindow, text: string): void {
|
||||
|
||||
export function applyBootWindowBranding(win: BrowserWindow): void {
|
||||
if (win.isDestroyed()) return;
|
||||
const name = app.getName();
|
||||
const name = appDisplayNameForLocale(app.getLocale());
|
||||
const version = getAppSemanticVersion();
|
||||
const versionLabel = version.trim().length > 0 ? `v${version.trim()}` : '';
|
||||
void win.webContents.executeJavaScript(
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, nativeImage } from 'electron';
|
||||
|
||||
let resolved = false;
|
||||
let cached: Electron.NativeImage | undefined;
|
||||
|
||||
/** ICO рядом с exe (вне asar): надёжно для `nativeImage` / панели задач на Windows. */
|
||||
function getPackagedBrandingIcoPath(): string | undefined {
|
||||
if (!app.isPackaged) return undefined;
|
||||
const p = path.join(process.resourcesPath, 'branding', 'icon.ico');
|
||||
try {
|
||||
if (fs.existsSync(p)) return p;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function brandingPngPaths(): string[] {
|
||||
const root = app.getAppPath();
|
||||
const relPack = path.join('dist', 'renderer', 'app-pack-icon.png');
|
||||
const relWindow = path.join('dist', 'renderer', 'app-window-icon.png');
|
||||
const paths: string[] = [];
|
||||
if (app.isPackaged) {
|
||||
const unpacked = path.join(process.resourcesPath, 'app.asar.unpacked');
|
||||
paths.push(path.join(unpacked, relPack), path.join(unpacked, relWindow));
|
||||
}
|
||||
paths.push(
|
||||
path.join(root, relPack),
|
||||
path.join(root, relWindow),
|
||||
path.join(root, 'build', 'icon.png'),
|
||||
path.join(root, 'app', 'renderer', 'public', 'app-window-icon.png'),
|
||||
);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function tryLoadImageFile(filePath: string): Electron.NativeImage | undefined {
|
||||
try {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
const fromBuf = nativeImage.createFromBuffer(buf);
|
||||
if (!fromBuf.isEmpty()) return fromBuf;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const fromPath = nativeImage.createFromPath(filePath);
|
||||
if (!fromPath.isEmpty()) return fromPath;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tryLoadSvgFile(filePath: string): Electron.NativeImage | undefined {
|
||||
try {
|
||||
const fromPath = nativeImage.createFromPath(filePath);
|
||||
if (!fromPath.isEmpty()) return fromPath;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tryDarwinSvgPaths(): Electron.NativeImage | undefined {
|
||||
if (process.platform !== 'darwin') return undefined;
|
||||
const root = app.getAppPath();
|
||||
for (const p of [
|
||||
path.join(root, 'dist', 'renderer', 'app-logo.svg'),
|
||||
path.join(root, 'app', 'renderer', 'public', 'app-logo.svg'),
|
||||
]) {
|
||||
if (!fs.existsSync(p)) continue;
|
||||
const img = tryLoadSvgFile(p);
|
||||
if (img) return img;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Иконка окна / дока. Сначала ICO из `extraResources` (реальный путь на диске), затем PNG
|
||||
* через буфер — `createFromPath` к файлам внутри `app.asar` на Windows часто даёт пустой `NativeImage`.
|
||||
*/
|
||||
export function loadBrandingWindowIcon(): Electron.NativeImage | undefined {
|
||||
if (resolved) return cached;
|
||||
resolved = true;
|
||||
|
||||
const ico = getPackagedBrandingIcoPath();
|
||||
if (ico) {
|
||||
const img = tryLoadImageFile(ico);
|
||||
if (img) {
|
||||
cached = img;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of brandingPngPaths()) {
|
||||
if (!fs.existsSync(p)) continue;
|
||||
const img = tryLoadImageFile(p);
|
||||
if (img) {
|
||||
cached = img;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const svgIcon = tryDarwinSvgPaths();
|
||||
if (svgIcon) {
|
||||
cached = svgIcon;
|
||||
return cached;
|
||||
}
|
||||
|
||||
cached = undefined;
|
||||
return undefined;
|
||||
}
|
||||
@@ -22,10 +22,11 @@ void test('createWindows: закрытие редактора завершает
|
||||
|
||||
void test('createWindows: иконка окна (pack PNG, затем window PNG; SVG только вне win32)', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('resolveWindowIconPath'));
|
||||
assert.ok(src.includes('app-pack-icon.png'));
|
||||
assert.ok(src.includes('app-window-icon.png'));
|
||||
assert.ok(src.includes('app-logo.svg'));
|
||||
assert.ok(src.includes('loadBrandingWindowIcon'));
|
||||
const branding = fs.readFileSync(path.join(here, 'brandingIcon.ts'), 'utf8');
|
||||
assert.ok(branding.includes('app-pack-icon.png'));
|
||||
assert.ok(branding.includes('app-window-icon.png'));
|
||||
assert.ok(branding.includes('tryDarwinSvgPaths'));
|
||||
});
|
||||
|
||||
void test('createWindows: пульт поверх экрана просмотра (дочернее окно)', () => {
|
||||
@@ -34,6 +35,40 @@ void test('createWindows: пульт поверх экрана просмотр
|
||||
assert.ok(src.includes("createWindow('control'"));
|
||||
});
|
||||
|
||||
void test('createWindows: окно описания сцены закрывается с multi-window и отдельно', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openSceneDescriptionWindow'));
|
||||
assert.ok(src.includes('closeSceneDescriptionWindow'));
|
||||
assert.ok(src.includes("createWindow('sceneDescription'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeSceneDescriptionWindow/);
|
||||
assert.match(src, /kind !== 'presentation'[\s\S]*closeSceneDescriptionWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно материалов закрывается с multi-window', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openMaterialsWindow'));
|
||||
assert.ok(src.includes('closeMaterialsWindow'));
|
||||
assert.ok(src.includes("createWindow('materials'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно НПС закрывается с multi-window', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openNpcsWindow'));
|
||||
assert.ok(src.includes('closeNpcsWindow'));
|
||||
assert.ok(src.includes('openNpcsEditorWindow'));
|
||||
assert.ok(src.includes('warmNpcsEditorWindow'));
|
||||
assert.ok(src.includes("createWindow('npcs'"));
|
||||
assert.ok(src.includes("createWindow('npcsEditor'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.match(src, /kind === 'control'/);
|
||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('loadFile'));
|
||||
@@ -45,3 +80,10 @@ void test('createWindows: показ окна — не только ready-to-sho
|
||||
assert.ok(src.includes('ensureWindowBecomesVisible'));
|
||||
assert.ok(src.includes('did-finish-load'));
|
||||
});
|
||||
|
||||
void test('createWindows: логи окон не валят main через EPIPE', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('safeConsoleError'));
|
||||
assert.ok(src.includes('errorCode === -3'));
|
||||
assert.ok(src.includes('isMainFrame'));
|
||||
});
|
||||
|
||||
@@ -1,17 +1,60 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, nativeImage, screen } from 'electron';
|
||||
import { app, BrowserWindow, screen } from 'electron';
|
||||
|
||||
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
|
||||
import { getBootSplashWindow } from './bootWindow';
|
||||
import { safeConsoleError } from '../safeConsole';
|
||||
|
||||
type WindowKind = 'editor' | 'presentation' | 'control';
|
||||
import { getBootSplashWindow } from './bootWindow';
|
||||
import { loadBrandingWindowIcon } from './brandingIcon';
|
||||
|
||||
export type WindowKind =
|
||||
| 'editor'
|
||||
| 'presentation'
|
||||
| 'control'
|
||||
| 'sceneDescription'
|
||||
| 'materials'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'npcs';
|
||||
|
||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||||
'presentation',
|
||||
'control',
|
||||
'materials',
|
||||
'npcs',
|
||||
'npcsEditor',
|
||||
'sceneEditor',
|
||||
] as const;
|
||||
|
||||
const windows = new Map<WindowKind, BrowserWindow>();
|
||||
|
||||
/** Язык заголовков окон (из редактора); иначе `app.getLocale()`. */
|
||||
let chromeLocaleTagOverride: string | null = null;
|
||||
|
||||
function resolveChromeLocaleTag(): string {
|
||||
return chromeLocaleTagOverride ?? app.getLocale();
|
||||
}
|
||||
|
||||
let appQuitting = false;
|
||||
/** Защита от каскада close(control) ↔ close(presentation). */
|
||||
let closingPlaySession = false;
|
||||
let pendingSceneDescriptionHtml = '';
|
||||
|
||||
/** Окно материалов — только колонка списка. */
|
||||
const MATERIALS_WINDOW_WIDTH = 300;
|
||||
const MATERIALS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Редактор НПС — как основной редактор, шире инспектор. */
|
||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||
|
||||
/** Пульт НПС: деталь слева + список справа. */
|
||||
const NPCS_WINDOW_WIDTH = 720;
|
||||
const NPCS_WINDOW_HEIGHT = 720;
|
||||
|
||||
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
|
||||
function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
@@ -26,11 +69,111 @@ function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function getPresentationContentSize(): { width: number; height: number } | null {
|
||||
const pres = windows.get('presentation');
|
||||
if (!pres || pres.isDestroyed()) return null;
|
||||
const [width, height] = pres.getContentSize();
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function broadcastPresentationContentSize(): void {
|
||||
const size = getPresentationContentSize();
|
||||
if (!size) return;
|
||||
for (const w of BrowserWindow.getAllWindows()) {
|
||||
if (w.isDestroyed() || w.webContents.isDestroyed()) continue;
|
||||
try {
|
||||
w.webContents.send(ipcChannels.windows.presentationContentSizeChanged, size);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindPresentationContentSizeTracking(win: BrowserWindow): void {
|
||||
const emit = () => broadcastPresentationContentSize();
|
||||
win.on('resize', emit);
|
||||
win.on('enter-full-screen', emit);
|
||||
win.on('leave-full-screen', emit);
|
||||
win.webContents.once('did-finish-load', emit);
|
||||
}
|
||||
|
||||
function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.windows.sceneDescriptionContent, { html });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
|
||||
export function markAppQuitting(): void {
|
||||
appQuitting = true;
|
||||
}
|
||||
|
||||
/** Точечная рассылка в известные окна приложения (без splash / чужих BrowserWindow). */
|
||||
export function sendToAppWindows(
|
||||
channel: string,
|
||||
payload: unknown,
|
||||
kinds: readonly WindowKind[] = SESSION_STATE_WINDOW_KINDS,
|
||||
): void {
|
||||
for (const kind of kinds) {
|
||||
const win = windows.get(kind);
|
||||
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) continue;
|
||||
try {
|
||||
win.webContents.send(channel, payload);
|
||||
} catch {
|
||||
/* окно могло закрыться между проверкой и send */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
||||
if (win.isDestroyed()) return;
|
||||
win.setTitle(windowChromeTitle(kind as AppWindowKind, resolveChromeLocaleTag()));
|
||||
}
|
||||
|
||||
export function syncAllWindowChromeTitles(localeTag: string): void {
|
||||
chromeLocaleTagOverride = localeTag.trim() || null;
|
||||
for (const [kind, win] of windows.entries()) {
|
||||
applyWindowChromeTitle(win, kind);
|
||||
}
|
||||
}
|
||||
|
||||
function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
||||
const apply = () => applyWindowChromeTitle(win, kind);
|
||||
apply();
|
||||
win.webContents.on('page-title-updated', (event) => {
|
||||
event.preventDefault();
|
||||
apply();
|
||||
});
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
apply();
|
||||
});
|
||||
}
|
||||
|
||||
/** Закрыть окна сессии, кроме редактора и его дочерних окон. */
|
||||
function closePlaySessionAuxiliaryWindows(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
}
|
||||
|
||||
function closePresentationWindow(): void {
|
||||
const pres = windows.get('presentation');
|
||||
if (pres && !pres.isDestroyed()) {
|
||||
pres.close();
|
||||
}
|
||||
}
|
||||
|
||||
function closeControlWindow(): void {
|
||||
const ctrl = windows.get('control');
|
||||
if (ctrl && !ctrl.isDestroyed()) {
|
||||
ctrl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function quitAppFromEditorClose(): void {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
@@ -50,6 +193,27 @@ function getRendererHtmlPath(kind: WindowKind): string {
|
||||
return path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
|
||||
}
|
||||
|
||||
function pageNameForKind(kind: WindowKind): string {
|
||||
switch (kind) {
|
||||
case 'editor':
|
||||
return 'editor.html';
|
||||
case 'presentation':
|
||||
return 'presentation.html';
|
||||
case 'control':
|
||||
return 'control.html';
|
||||
case 'sceneDescription':
|
||||
return 'sceneDescription.html';
|
||||
case 'materials':
|
||||
return 'materials.html';
|
||||
case 'npcsEditor':
|
||||
return 'npcsEditor.html';
|
||||
case 'sceneEditor':
|
||||
return 'sceneEditor.html';
|
||||
case 'npcs':
|
||||
return 'npcs.html';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран;
|
||||
* `loadFile` корректно открывает HTML из asar и на Windows, и на macOS.
|
||||
@@ -57,9 +221,7 @@ function getRendererHtmlPath(kind: WindowKind): string {
|
||||
function loadWindowPage(win: BrowserWindow, kind: WindowKind): void {
|
||||
const dev = process.env.VITE_DEV_SERVER_URL;
|
||||
if (dev) {
|
||||
const page =
|
||||
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
|
||||
void win.loadURL(new URL(page, dev).toString());
|
||||
void win.loadURL(new URL(pageNameForKind(kind), dev).toString());
|
||||
return;
|
||||
}
|
||||
void win.loadFile(getRendererHtmlPath(kind));
|
||||
@@ -69,89 +231,22 @@ function getPreloadPath(): string {
|
||||
return path.join(app.getAppPath(), 'dist', 'preload', 'index.cjs');
|
||||
}
|
||||
|
||||
/**
|
||||
* PNG для иконки окна / дока: тот же растр, что electron-builder берёт из `build/icon.png`
|
||||
* (копия в dist после сборки), затем окно 256px, затем dev-пути. SVG не используем для
|
||||
* nativeImage на Windows — иначе пустая картинка и дефолтная иконка Electron вместо exe.
|
||||
*/
|
||||
function resolveBrandingPngPaths(): string[] {
|
||||
const root = app.getAppPath();
|
||||
return [
|
||||
path.join(root, 'dist', 'renderer', 'app-pack-icon.png'),
|
||||
path.join(root, 'dist', 'renderer', 'app-window-icon.png'),
|
||||
path.join(root, 'build', 'icon.png'),
|
||||
path.join(root, 'app', 'renderer', 'public', 'app-window-icon.png'),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveWindowIconPath(): string | undefined {
|
||||
for (const p of resolveBrandingPngPaths()) {
|
||||
try {
|
||||
if (fs.existsSync(p)) return p;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const root = app.getAppPath();
|
||||
const svgFallback = [
|
||||
path.join(root, 'dist', 'renderer', 'app-logo.svg'),
|
||||
path.join(root, 'app', 'renderer', 'public', 'app-logo.svg'),
|
||||
];
|
||||
for (const p of svgFallback) {
|
||||
try {
|
||||
if (fs.existsSync(p)) return p;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveWindowIcon(): Electron.NativeImage | undefined {
|
||||
const tryPath = (filePath: string): Electron.NativeImage | undefined => {
|
||||
try {
|
||||
const img = nativeImage.createFromPath(filePath);
|
||||
if (!img.isEmpty()) return img;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (process.platform === 'win32' || process.platform === 'linux') {
|
||||
for (const p of resolveBrandingPngPaths()) {
|
||||
if (!fs.existsSync(p)) continue;
|
||||
const img = tryPath(p);
|
||||
if (img) return img;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const p = resolveWindowIconPath();
|
||||
if (!p) return undefined;
|
||||
return tryPath(p);
|
||||
}
|
||||
|
||||
/** macOS: в Dock показываем тот же PNG, что и у упакованного приложения на Windows (иконка exe). */
|
||||
/** macOS: в Dock — тот же растр, что и у окон (ICO/PNG из brandingIcon). */
|
||||
export function applyDockIconIfNeeded(): void {
|
||||
if (process.platform !== 'darwin' || !app.dock) return;
|
||||
for (const p of resolveBrandingPngPaths()) {
|
||||
if (!fs.existsSync(p)) continue;
|
||||
try {
|
||||
const img = nativeImage.createFromPath(p);
|
||||
if (img.isEmpty()) continue;
|
||||
app.dock.setIcon(img);
|
||||
return;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
const icon = loadBrandingWindowIcon();
|
||||
if (!icon || icon.isEmpty()) return;
|
||||
try {
|
||||
app.dock.setIcon(icon);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
type CreateWindowOpts = {
|
||||
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
||||
parent?: BrowserWindow;
|
||||
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
|
||||
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
|
||||
deferVisibility?: boolean;
|
||||
};
|
||||
|
||||
@@ -178,12 +273,65 @@ function ensureWindowBecomesVisible(win: BrowserWindow): void {
|
||||
});
|
||||
}
|
||||
|
||||
function windowSizeForKind(kind: WindowKind): { width: number; height: number } {
|
||||
if (kind === 'editor') return { width: 1280, height: 800 };
|
||||
if (kind === 'control') return { width: 1200, height: 800 };
|
||||
if (kind === 'sceneDescription') return { width: 720, height: 640 };
|
||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
||||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||||
return { width: 1280, height: 800 };
|
||||
}
|
||||
|
||||
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||||
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
|
||||
const icon = resolveWindowIcon();
|
||||
const deferShow = opts?.deferVisibility === true;
|
||||
const icon = loadBrandingWindowIcon();
|
||||
const size = windowSizeForKind(kind);
|
||||
const win = new BrowserWindow({
|
||||
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
|
||||
height: 800,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
autoHideMenuBar: true,
|
||||
...(kind === 'sceneDescription'
|
||||
? {
|
||||
minWidth: 520,
|
||||
minHeight: 420,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'materials'
|
||||
? {
|
||||
width: MATERIALS_WINDOW_WIDTH,
|
||||
height: MATERIALS_WINDOW_HEIGHT,
|
||||
minWidth: 260,
|
||||
maxWidth: 360,
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcsEditor'
|
||||
? {
|
||||
width: NPCS_EDITOR_WINDOW_WIDTH,
|
||||
height: NPCS_EDITOR_WINDOW_HEIGHT,
|
||||
minWidth: 1100,
|
||||
minHeight: 640,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'sceneEditor'
|
||||
? {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcs'
|
||||
? {
|
||||
width: NPCS_WINDOW_WIDTH,
|
||||
height: NPCS_WINDOW_HEIGHT,
|
||||
minWidth: 560,
|
||||
maxWidth: 900,
|
||||
minHeight: 480,
|
||||
}
|
||||
: {}),
|
||||
show: false,
|
||||
backgroundColor: '#09090B',
|
||||
...(icon ? { icon } : {}),
|
||||
@@ -199,18 +347,39 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
|
||||
},
|
||||
});
|
||||
if (icon) {
|
||||
try {
|
||||
win.setIcon(icon);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
bindWindowChromeTitle(win, kind);
|
||||
if (
|
||||
kind === 'sceneDescription' ||
|
||||
kind === 'materials' ||
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'sceneEditor' ||
|
||||
kind === 'npcs'
|
||||
) {
|
||||
win.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
win.webContents.on('preload-error', (_event, preloadPath, error) => {
|
||||
console.error(`[preload-error] ${preloadPath}:`, error);
|
||||
safeConsoleError(`[preload-error] ${preloadPath}:`, error);
|
||||
});
|
||||
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
||||
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
|
||||
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
|
||||
if (errorCode === -3) return;
|
||||
if (!isMainFrame) return;
|
||||
safeConsoleError(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
|
||||
});
|
||||
win.webContents.on('render-process-gone', (_event, details) => {
|
||||
console.error('[render-process-gone]', details.reason, details.exitCode);
|
||||
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
|
||||
});
|
||||
|
||||
if (!deferEditor) {
|
||||
if (!deferShow) {
|
||||
ensureWindowBecomesVisible(win);
|
||||
}
|
||||
loadWindowPage(win, kind);
|
||||
@@ -221,10 +390,31 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
quitAppFromEditorClose();
|
||||
});
|
||||
}
|
||||
if (kind === 'control') {
|
||||
win.on('close', () => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
closePresentationWindow();
|
||||
});
|
||||
}
|
||||
if (kind === 'presentation') {
|
||||
bindPresentationContentSizeTracking(win);
|
||||
win.on('close', () => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
closeControlWindow();
|
||||
});
|
||||
}
|
||||
win.on('closed', () => windows.delete(kind));
|
||||
win.on('closed', () => {
|
||||
if (kind !== 'presentation' && kind !== 'control') return;
|
||||
const open = windows.has('presentation') || windows.has('control');
|
||||
if (!open) {
|
||||
closingPlaySession = false;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
}
|
||||
broadcastMultiWindowStateChanged(open);
|
||||
});
|
||||
windows.set(kind, win);
|
||||
@@ -304,19 +494,260 @@ export function openMultiWindow() {
|
||||
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
|
||||
}
|
||||
broadcastMultiWindowStateChanged(true);
|
||||
broadcastPresentationContentSize();
|
||||
}
|
||||
|
||||
export function closeMultiWindow(): void {
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
const pres = windows.get('presentation');
|
||||
const ctrl = windows.get('control');
|
||||
if (pres) pres.close();
|
||||
if (ctrl) ctrl.close();
|
||||
if (pres && !pres.isDestroyed()) pres.close();
|
||||
if (ctrl && !ctrl.isDestroyed()) ctrl.close();
|
||||
if (!windows.has('presentation') && !windows.has('control')) {
|
||||
closingPlaySession = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isMultiWindowOpen(): boolean {
|
||||
return windows.has('presentation') || windows.has('control');
|
||||
}
|
||||
|
||||
export function closeSceneDescriptionWindow(): void {
|
||||
const win = windows.get('sceneDescription');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeMaterialsWindow(): void {
|
||||
const win = windows.get('materials');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsEditorWindow(): void {
|
||||
const win = windows.get('npcsEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeSceneEditorWindow(): void {
|
||||
const win = windows.get('sceneEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeNpcsWindow(): void {
|
||||
const win = windows.get('npcs');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function getSceneDescriptionContent(): string {
|
||||
return pendingSceneDescriptionHtml;
|
||||
}
|
||||
|
||||
/** Одно окно описания: переиспользовать существующее или создать новое. */
|
||||
export function openSceneDescriptionWindow(html: string): void {
|
||||
pendingSceneDescriptionHtml = html;
|
||||
const existing = windows.get('sceneDescription');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
sendSceneDescriptionContent(existing, html);
|
||||
return;
|
||||
}
|
||||
|
||||
// Держим поверх пульта/презентации, иначе окно уходит под полноэкранный экран просмотра.
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('sceneDescription', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + (dw - width) / 2),
|
||||
y: Math.round(y + (dh - height) / 2),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
sendSceneDescriptionContent(win, pendingSceneDescriptionHtml);
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Полоса материалов: фиксированная ширина плитки, переиспользование окна. */
|
||||
export function openMaterialsWindow(): void {
|
||||
const existing = windows.get('materials');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
const b = existing.getBounds();
|
||||
existing.setBounds({
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
width: MATERIALS_WINDOW_WIDTH,
|
||||
height: MATERIALS_WINDOW_HEIGHT,
|
||||
});
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('materials', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, dw - width - 24)),
|
||||
y: Math.round(y + (dh - height) / 2),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||
const parent = windows.get('editor');
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
|
||||
export function warmNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) return;
|
||||
const parent = windows.get('editor');
|
||||
createWindow('npcsEditor', {
|
||||
...(parent ? { parent } : {}),
|
||||
deferVisibility: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||
export function openNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
positionNpcsEditorWindow(existing);
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', {
|
||||
...(parent ? { parent } : {}),
|
||||
deferVisibility: true,
|
||||
});
|
||||
positionNpcsEditorWindow(win);
|
||||
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор сцены: расстановка ловушек и легенды материалов. */
|
||||
export function openSceneEditorWindow(): void {
|
||||
const existing = windows.get('sceneEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('sceneEditor', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, (dw - width) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Пульт НПС: список + описание выбранного; оверлей аватара на сцене. */
|
||||
export function openNpcsWindow(): void {
|
||||
const existing = windows.get('npcs');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
const b = existing.getBounds();
|
||||
existing.setBounds({
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)),
|
||||
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
|
||||
});
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('control') ?? windows.get('presentation');
|
||||
const win = createWindow('npcs', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
win.setBounds({
|
||||
x: Math.round(x + Math.max(0, dw - width - 24)),
|
||||
y: Math.round(y + (dh - height) / 2),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function togglePresentationFullscreen(): boolean {
|
||||
const pres = windows.get('presentation');
|
||||
if (!pres) return false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { contextBridge } from 'electron';
|
||||
import { contextBridge, webUtils } from 'electron';
|
||||
|
||||
import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts';
|
||||
|
||||
@@ -10,9 +10,14 @@ export type DndApi = {
|
||||
payload: IpcInvokeMap[K]['req'],
|
||||
) => Promise<IpcInvokeMap[K]['res']>;
|
||||
on: <K extends keyof IpcEventMap>(channel: K, listener: (payload: IpcEventMap[K]) => void) => () => void;
|
||||
getPathForFile: (file: File) => string;
|
||||
};
|
||||
|
||||
const api: DndApi = { invoke, on };
|
||||
const api: DndApi = {
|
||||
invoke,
|
||||
on,
|
||||
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('dnd', api);
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<img class="logo" src="./app-window-icon.png" width="72" height="72" alt="" />
|
||||
<h1 class="title" data-boot-title>DNDGamePlayer</h1>
|
||||
<h1 class="title" data-boot-title>TTRPG Player</h1>
|
||||
<p class="subtitle">редактор и проигрыватель</p>
|
||||
<p class="version" data-boot-version></p>
|
||||
<p class="status" id="boot-status">Запуск…</p>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>DnD Player — Control</title>
|
||||
<title>TTRPG - Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.remote {
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-height: calc(100vh - 32px);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.remoteTitle {
|
||||
@@ -79,13 +85,114 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bookIcon {
|
||||
display: block;
|
||||
color: var(--text-muted-on-dark);
|
||||
}
|
||||
|
||||
.modalBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal-backdrop);
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: var(--color-scrim);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modalDialog {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.descriptionViewDialog {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 48px);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-weight: 900;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.modalClose {
|
||||
border: none;
|
||||
background: var(--panel2);
|
||||
color: var(--text2);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.descriptionViewBody {
|
||||
min-height: 360px;
|
||||
max-height: min(560px, calc(100vh - 200px));
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.descriptionViewProse {
|
||||
color: var(--text0);
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.descriptionViewEmpty {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.radiusRow {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 44px;
|
||||
grid-template-columns: 120px 1fr 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.effectsSoundRow {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.radiusLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
@@ -164,6 +271,7 @@
|
||||
|
||||
.historyTitle {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.emptyStory {
|
||||
@@ -249,7 +357,7 @@
|
||||
|
||||
.branchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -260,6 +368,8 @@
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.branchCardHeader {
|
||||
@@ -276,6 +386,63 @@
|
||||
|
||||
.branchName {
|
||||
font-weight: 900;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branchCardReturn {
|
||||
border-color: rgba(0, 120, 212, 0.45);
|
||||
}
|
||||
|
||||
.sideStoryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sideStoryTile {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.sideStoryTile:hover .sideStoryPreview {
|
||||
border-color: rgba(0, 120, 212, 0.55);
|
||||
}
|
||||
|
||||
.sideStoryPreview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--scene-tile-radius);
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--stroke);
|
||||
background: #0c0c0e;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sideStoryVideo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sideStoryPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0c0c0e;
|
||||
}
|
||||
|
||||
.sideStoryTitle {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.musicHeader {
|
||||
@@ -308,10 +475,51 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audioControls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
align-items: stretch;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.audioTransport {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.audioVolumeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audioVolumeIcon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audioVolumeIcon svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.audioVolume {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
accent-color: var(--accent-fill-solid);
|
||||
}
|
||||
|
||||
.scrubFill {
|
||||
|
||||
+1322
-429
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 type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneViewCamera } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
@@ -12,6 +13,7 @@ import styles from './ControlScenePreview.module.css';
|
||||
type Props = {
|
||||
session: SessionState | null;
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
viewCamera?: SceneViewCamera | null;
|
||||
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
|
||||
};
|
||||
|
||||
@@ -23,7 +25,7 @@ function fmt(sec: number): string {
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
|
||||
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
|
||||
const { t } = useEditorI18n();
|
||||
const [vp, video] = useVideoPlaybackState();
|
||||
const scene =
|
||||
@@ -33,6 +35,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
const isVideo = scene?.previewAssetType === 'video';
|
||||
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
||||
const autostart = scene?.previewVideoAutostart ?? false;
|
||||
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const dur = useMemo(
|
||||
@@ -59,14 +62,18 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
if (!assetId) return;
|
||||
// `target.set` bumps revision and resets anchors; avoid firing on every render.
|
||||
if (vp?.targetAssetId === assetId) return;
|
||||
const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
|
||||
const prev = lastTargetRef.current;
|
||||
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
|
||||
return;
|
||||
}
|
||||
lastTargetRef.current = { sceneKey, assetId, autostart };
|
||||
void video.dispatch({
|
||||
kind: 'target.set',
|
||||
assetId,
|
||||
autostart,
|
||||
});
|
||||
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
|
||||
}, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
@@ -91,7 +98,13 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{url && scene?.previewAssetType === 'image' ? (
|
||||
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} />
|
||||
<RotatedImage
|
||||
url={url}
|
||||
rotationDeg={rot}
|
||||
mode="contain"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={onContentRectChange}
|
||||
/>
|
||||
) : url && isVideo ? (
|
||||
<video
|
||||
ref={(el) => {
|
||||
@@ -100,6 +113,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
|
||||
className={styles.video}
|
||||
src={url}
|
||||
playsInline
|
||||
loop={Boolean(scene?.settings?.loopVideo)}
|
||||
preload="auto"
|
||||
onTimeUpdate={() => 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();
|
||||
assert.ok(src.includes('molniya.mp3'));
|
||||
assert.ok(src.includes('playLightningEffectSound'));
|
||||
assert.ok(src.includes('LIGHTNING_EFFECT_MS'));
|
||||
});
|
||||
|
||||
void test('ControlApp: звук заморозки (public/zamorozka.mp3)', () => {
|
||||
@@ -45,23 +46,73 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', (
|
||||
assert.ok(sfxSrc.includes('playbackRate'));
|
||||
});
|
||||
|
||||
void test('ControlApp: активация ловушки «яд» спавнит poisonCloud как на пульте эффектов', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes("trap?.type === 'poison'"));
|
||||
assert.ok(src.includes("type: 'poisonCloud'"));
|
||||
assert.ok(src.includes('trap_pc_'));
|
||||
assert.ok(src.includes('playPoisonCloudEffectSound(poisonLifeMs)'));
|
||||
});
|
||||
|
||||
void test('ControlApp: эффект «взрыв» + ловушка используют explosion webm/mp3', () => {
|
||||
const appSrc = readControlApp();
|
||||
const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8');
|
||||
assert.ok(appSrc.includes("title={t('control.explosion')}"));
|
||||
assert.ok(appSrc.includes("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', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes("t('control.instruments')"));
|
||||
assert.ok(src.includes("t('control.descriptionTool')"));
|
||||
assert.ok(src.includes("t('control.descriptionMissing')"));
|
||||
assert.ok(src.includes('openSceneDescription'));
|
||||
assert.ok(!src.includes('SceneDescriptionViewModal'));
|
||||
assert.ok(src.includes("t('control.effects')"));
|
||||
assert.ok(src.includes("t('control.tools')"));
|
||||
assert.ok(src.includes("t('control.fieldEffects')"));
|
||||
assert.ok(src.includes("t('control.actionEffects')"));
|
||||
assert.ok(src.includes("t('control.darknessControl')"));
|
||||
assert.ok(src.includes("t('control.explorerBrush')"));
|
||||
assert.ok(src.includes("t('control.closerBrush')"));
|
||||
assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
|
||||
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
|
||||
assert.ok(src.includes('SceneDarknessOverlay'));
|
||||
assert.ok(src.includes('useSceneDarknessState'));
|
||||
assert.ok(src.includes("t('control.sunbeam')"));
|
||||
assert.ok(src.includes("t('control.lightning')"));
|
||||
assert.ok(src.includes("title={t('control.water')}"));
|
||||
assert.ok(src.includes("title={t('control.fire')}"));
|
||||
assert.ok(src.includes("title={t('control.darkness')}"));
|
||||
assert.ok(src.includes("title={t('control.poisonCloud')}"));
|
||||
assert.ok(src.includes("title={t('control.fog')}"));
|
||||
assert.ok(src.includes("ariaLabel={t('control.fog')}"));
|
||||
assert.ok(src.includes('selectEffectTool'), 'повторный клик снимает инструмент');
|
||||
assert.ok(src.includes("tool.tool === next ? 'none' : next"));
|
||||
assert.ok(src.includes('iconOnly'));
|
||||
assert.ok(src.includes("title={t('control.clearEffects')}"));
|
||||
assert.ok(src.includes("ariaLabel={t('control.clearEffects')}"));
|
||||
assert.ok(src.includes('#e5484d'));
|
||||
const instruments = src.indexOf("t('control.instruments')");
|
||||
const fx = src.indexOf("t('control.effects')");
|
||||
const story = src.indexOf("t('control.storyLine')");
|
||||
assert.ok(
|
||||
instruments !== -1 && fx !== -1 && instruments < fx,
|
||||
'Блок инструментов должен быть выше эффектов',
|
||||
);
|
||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
||||
});
|
||||
|
||||
@@ -76,6 +127,32 @@ void test('ControlApp: сюжетная линия — колонка сверх
|
||||
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||
});
|
||||
|
||||
void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(!src.includes('suppressNextHistoryPushRef'));
|
||||
assert.ok(!src.includes('arr.includes(cur)'));
|
||||
assert.ok(src.includes('historyRef.current = [...arr, cur]'));
|
||||
});
|
||||
|
||||
void test('ControlApp: текущая сцена в истории — последнее вхождение', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes('history.lastIndexOf(currentGraphNodeId)'));
|
||||
assert.ok(src.includes('idx === currentHistoryIdx'));
|
||||
});
|
||||
|
||||
void test('ControlApp: сюжетная линия не меняет граф и сцены проекта', () => {
|
||||
const src = readControlApp();
|
||||
const story = src.indexOf("t('control.storyLine')");
|
||||
assert.ok(story !== -1);
|
||||
const tail = src.slice(story);
|
||||
assert.ok(!tail.includes('addSceneGraphNode'));
|
||||
assert.ok(!tail.includes('removeSceneGraphNode'));
|
||||
assert.ok(!tail.includes('addSceneGraphEdge'));
|
||||
assert.ok(!tail.includes('removeSceneGraphEdge'));
|
||||
assert.ok(!tail.includes('updateProject'));
|
||||
assert.ok(tail.includes('ipcChannels.project.setCurrentGraphNode'));
|
||||
});
|
||||
|
||||
void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
@@ -108,6 +185,55 @@ void test('ControlApp: музыка разделена на сцену и кам
|
||||
assert.match(src, /pause campaign\./i);
|
||||
});
|
||||
|
||||
void test('ControlApp: у каждой аудиозаписи есть регулятор громкости под транспортом', () => {
|
||||
const src = readControlApp();
|
||||
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
|
||||
const css = readControlAppCss();
|
||||
assert.ok(src.includes('ControlAudioCard'));
|
||||
assert.ok(src.includes("t('control.volume')"));
|
||||
assert.ok(src.includes('sceneAudioGainRef'));
|
||||
assert.ok(src.includes('campaignAudioGainRef'));
|
||||
assert.ok(src.includes('applyAudioGain'));
|
||||
assert.ok(card.includes('VolumeSpeakerIcon'));
|
||||
assert.ok(card.includes('styles.audioVolume'));
|
||||
assert.ok(card.includes('styles.audioVolumeRow'));
|
||||
assert.match(css, /\.audioControls[\s\S]*?flex-direction:\s*column/);
|
||||
assert.match(css, /\.audioVolumeRow\b/);
|
||||
assert.match(css, /\.audioVolumeIcon\b/);
|
||||
assert.match(css, /\.audioVolume\b/);
|
||||
});
|
||||
|
||||
void test('ControlApp: весь контент скроллится в окне, отступы сверху и снизу равны', () => {
|
||||
const css = readControlAppCss();
|
||||
assert.match(css, /\.page\s*\{[^}]*padding:\s*16px/s);
|
||||
assert.match(css, /\.page\s*\{[^}]*overflow:\s*auto/s);
|
||||
assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s);
|
||||
});
|
||||
|
||||
void test('ControlApp: вода/дождь гасят огонь; ambient огня/дождя + слайдер «Звук эффектов»', () => {
|
||||
const src = readControlApp();
|
||||
const fireSfx = fs.readFileSync(path.join(here, 'fireAmbientSfx.ts'), 'utf8');
|
||||
const rainSfx = fs.readFileSync(path.join(here, 'rainAmbientSfx.ts'), 'utf8');
|
||||
const i18n = fs.readFileSync(path.join(here, '../editor/i18n/editorMessages.ts'), 'utf8');
|
||||
|
||||
assert.ok(src.includes('extinguishFireAlong'));
|
||||
assert.ok(src.includes("types: ['fire']"));
|
||||
assert.ok(src.includes("hitMode: 'brush'"));
|
||||
assert.ok(src.includes('effectsSoundRow'));
|
||||
assert.ok(src.includes('setFireAmbientActive'));
|
||||
assert.ok(src.includes('setRainAmbientActive'));
|
||||
assert.ok(src.includes('hasFireOnScene'));
|
||||
assert.ok(src.includes('hasRainOnScene'));
|
||||
assert.ok(src.includes("t('control.effectsSound')"));
|
||||
assert.ok(fireSfx.includes('fire-ambient.mp3'));
|
||||
assert.ok(rainSfx.includes('rain-ambient.mp3'));
|
||||
assert.ok(i18n.includes("'control.effectsSound': 'Звук эффектов'"));
|
||||
|
||||
const radius = src.indexOf("t('control.brushRadius')");
|
||||
const effectsSound = src.indexOf("t('control.effectsSound')");
|
||||
assert.ok(radius !== -1 && effectsSound !== -1 && radius < effectsSound);
|
||||
});
|
||||
|
||||
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
|
||||
const src = readControlApp();
|
||||
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Общая громкость звуков эффектов (огонь ambient + one-shot). 0…1. */
|
||||
|
||||
let effectsSfxGain = 0.75;
|
||||
|
||||
export function clampEffectsSfxGain(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0.75;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
export function getEffectsSfxGain(): number {
|
||||
return effectsSfxGain;
|
||||
}
|
||||
|
||||
export function setEffectsSfxGain(v: number): number {
|
||||
effectsSfxGain = clampEffectsSfxGain(v);
|
||||
return effectsSfxGain;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Звук и длительность эффекта «Взрыв» (`public/explosion.mp3` + WebM с альфой). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const EXPLOSION_SFX_VOLUME = 0.95;
|
||||
|
||||
/** Запас, если метаданные не прочитались (длина webm ~3.97 с). */
|
||||
const DEFAULT_EXPLOSION_LIFE_MS = 4200;
|
||||
|
||||
export function explosionEffectSoundUrl(): string {
|
||||
return new URL('explosion.mp3', window.location.href).href;
|
||||
}
|
||||
|
||||
/** VP8/VP9 WebM с `alpha_mode=1` — рендер через HTML `<video>`, не Pixi. */
|
||||
export function explosionEffectVideoUrl(): string {
|
||||
return new URL('vfx/explosion/aerial-debris-smoke.webm', window.location.href).href;
|
||||
}
|
||||
|
||||
let cachedExplosionSfxDurationMs: number | null = null;
|
||||
|
||||
/** Длительность трека в мс (кэш после первого чтения метаданных). */
|
||||
export function getExplosionSfxDurationMs(): Promise<number> {
|
||||
if (cachedExplosionSfxDurationMs !== null) {
|
||||
return Promise.resolve(cachedExplosionSfxDurationMs);
|
||||
}
|
||||
const url = explosionEffectSoundUrl();
|
||||
return new Promise((resolve) => {
|
||||
const a = new Audio();
|
||||
const done = (ms: number): void => {
|
||||
cachedExplosionSfxDurationMs = ms;
|
||||
a.removeAttribute('src');
|
||||
resolve(ms);
|
||||
};
|
||||
a.addEventListener('loadedmetadata', () => {
|
||||
const d = a.duration;
|
||||
done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_EXPLOSION_LIFE_MS);
|
||||
});
|
||||
a.addEventListener('error', () => done(DEFAULT_EXPLOSION_LIFE_MS));
|
||||
a.src = url;
|
||||
a.load();
|
||||
});
|
||||
}
|
||||
|
||||
/** Длительность визуала ≈ max(звук, webm), с разумными пределами. */
|
||||
export async function getExplosionEffectLifeMs(): Promise<number> {
|
||||
const sfxMs = await getExplosionSfxDurationMs();
|
||||
const raw = Math.max(sfxMs, DEFAULT_EXPLOSION_LIFE_MS);
|
||||
return Math.min(60_000, Math.max(600, raw));
|
||||
}
|
||||
|
||||
export async function playExplosionEffectSound(lifeMs: number): Promise<void> {
|
||||
try {
|
||||
const rawMs = await getExplosionSfxDurationMs();
|
||||
const target = Math.max(200, lifeMs);
|
||||
const rate = Math.max(0.25, Math.min(4, rawMs / target));
|
||||
const el = new Audio(explosionEffectSoundUrl());
|
||||
el.volume = Math.max(0, Math.min(1, EXPLOSION_SFX_VOLUME * getEffectsSfxGain()));
|
||||
el.playbackRate = rate;
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Фоновый звук горения при наличии огня на сцене (`public/fire-ambient.mp3`). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const FIRE_AMBIENT_BASE_VOLUME = 0.72;
|
||||
|
||||
export function fireAmbientSoundUrl(): string {
|
||||
return new URL('fire-ambient.mp3', window.location.href).href;
|
||||
}
|
||||
|
||||
let ambientEl: HTMLAudioElement | null = null;
|
||||
|
||||
function ensureAmbientEl(): HTMLAudioElement {
|
||||
if (ambientEl) return ambientEl;
|
||||
const el = new Audio(fireAmbientSoundUrl());
|
||||
el.loop = true;
|
||||
el.preload = 'auto';
|
||||
ambientEl = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
function applyVolume(el: HTMLAudioElement): void {
|
||||
el.volume = Math.max(0, Math.min(1, FIRE_AMBIENT_BASE_VOLUME * getEffectsSfxGain()));
|
||||
}
|
||||
|
||||
/** Включить/выключить loop горения. */
|
||||
export function setFireAmbientActive(active: boolean): void {
|
||||
try {
|
||||
const el = ensureAmbientEl();
|
||||
applyVolume(el);
|
||||
if (active) {
|
||||
if (el.paused) void el.play().catch(() => undefined);
|
||||
} else if (!el.paused) {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Обновить громкость, если ambient уже играет. */
|
||||
export function syncFireAmbientVolume(): void {
|
||||
if (!ambientEl) return;
|
||||
try {
|
||||
applyVolume(ambientEl);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
/** Звук и длительность эффекта «Заморозка» (`public/zamorozka.mp3`). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const DEFAULT_FREEZE_LIFE_MS = 820;
|
||||
|
||||
const FREEZE_SFX_BASE_VOLUME = 0.88;
|
||||
/** На 25% тише базовой громкости эффекта. */
|
||||
/** На 25% тише базовой громкости эффекта (до множителя «Звук эффектов»). */
|
||||
export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75;
|
||||
|
||||
export function freezeEffectSoundUrl(): string {
|
||||
@@ -44,7 +46,7 @@ export async function getFreezeEffectLifeMs(): Promise<number> {
|
||||
export function playFreezeEffectSound(): void {
|
||||
try {
|
||||
const el = new Audio(freezeEffectSoundUrl());
|
||||
el.volume = FREEZE_SFX_VOLUME;
|
||||
el.volume = Math.max(0, Math.min(1, FREEZE_SFX_VOLUME * getEffectsSfxGain()));
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { ControlApp } from './ControlApp';
|
||||
|
||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<ControlApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Пульт">
|
||||
<EditorI18nProvider>
|
||||
<ControlApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const POISON_CLOUD_SFX_VOLUME = 0.92;
|
||||
|
||||
/** Запас, если метаданные не прочитались. */
|
||||
@@ -50,7 +52,7 @@ export async function playPoisonCloudEffectSound(lifeMs: number): Promise<void>
|
||||
const target = Math.max(200, lifeMs);
|
||||
const rate = Math.max(0.25, Math.min(4, rawMs / target));
|
||||
const el = new Audio(poisonCloudEffectSoundUrl());
|
||||
el.volume = POISON_CLOUD_SFX_VOLUME;
|
||||
el.volume = Math.max(0, Math.min(1, POISON_CLOUD_SFX_VOLUME * getEffectsSfxGain()));
|
||||
el.playbackRate = rate;
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Фоновый звук дождя при наличии дождя на сцене (`public/rain-ambient.mp3`). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const RAIN_AMBIENT_BASE_VOLUME = 0.7;
|
||||
|
||||
export function rainAmbientSoundUrl(): string {
|
||||
return new URL('rain-ambient.mp3', window.location.href).href;
|
||||
}
|
||||
|
||||
let ambientEl: HTMLAudioElement | null = null;
|
||||
|
||||
function ensureAmbientEl(): HTMLAudioElement {
|
||||
if (ambientEl) return ambientEl;
|
||||
const el = new Audio(rainAmbientSoundUrl());
|
||||
el.loop = true;
|
||||
el.preload = 'auto';
|
||||
ambientEl = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
function applyVolume(el: HTMLAudioElement): void {
|
||||
el.volume = Math.max(0, Math.min(1, RAIN_AMBIENT_BASE_VOLUME * getEffectsSfxGain()));
|
||||
}
|
||||
|
||||
/** Включить/выключить loop дождя. */
|
||||
export function setRainAmbientActive(active: boolean): void {
|
||||
try {
|
||||
const el = ensureAmbientEl();
|
||||
applyVolume(el);
|
||||
if (active) {
|
||||
if (el.paused) void el.play().catch(() => undefined);
|
||||
} else if (!el.paused) {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Обновить громкость, если ambient уже играет. */
|
||||
export function syncRainAmbientVolume(): void {
|
||||
if (!ambientEl) return;
|
||||
try {
|
||||
applyVolume(ambientEl);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Звук эффекта «Луч света» (`public/luch_sveta.mp3`). */
|
||||
|
||||
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||
|
||||
const SUNBEAM_SFX_VOLUME = 0.88;
|
||||
/** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */
|
||||
export const SUNBEAM_PLAYBACK_RATE = 1.5;
|
||||
@@ -47,7 +49,7 @@ export async function getSunbeamEffectLifeMs(): Promise<number> {
|
||||
export function playSunbeamEffectSound(): void {
|
||||
try {
|
||||
const el = new Audio(sunbeamEffectSoundUrl());
|
||||
el.volume = SUNBEAM_SFX_VOLUME;
|
||||
el.volume = Math.max(0, Math.min(1, SUNBEAM_SFX_VOLUME * getEffectsSfxGain()));
|
||||
el.playbackRate = SUNBEAM_PLAYBACK_RATE;
|
||||
void el.play().catch(() => undefined);
|
||||
} catch {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>DnD Player — Editor</title>
|
||||
<title>TTRPG - Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -62,12 +62,24 @@
|
||||
.editorSidebar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--stroke);
|
||||
background: var(--editor-column-bg);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.editorSidebarDragOver {
|
||||
border-right-color: var(--accent);
|
||||
background: var(--accent-fill-soft);
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.editorGraphHost {
|
||||
@@ -94,25 +106,62 @@
|
||||
.gridTools {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.gridTools > * {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gridTools button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spacer14 {
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.spacer18 {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.gamePropsButtons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gamePropsButtons > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gamePropsButtons button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebarScroll {
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.sceneListGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
justify-items: stretch;
|
||||
@@ -131,7 +180,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
/* Выше модалок (20001), чтобы прогресс импорта не уходил под диалог выбора. */
|
||||
z-index: 30000;
|
||||
}
|
||||
|
||||
.editorLockOverlay {
|
||||
@@ -169,15 +219,19 @@
|
||||
.progressModal {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
padding: 16px 16px 20px;
|
||||
background: rgba(25, 28, 38, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.progressTitle {
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
@@ -186,6 +240,7 @@
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
@@ -195,11 +250,13 @@
|
||||
}
|
||||
|
||||
.progressMeta {
|
||||
margin-top: 10px;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
opacity: 0.9;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inspectorTitle {
|
||||
@@ -208,6 +265,18 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.projectNameLabel {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspectorScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -219,6 +288,11 @@
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.noticeMessage {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.fileMenu {
|
||||
position: fixed;
|
||||
min-width: 220px;
|
||||
@@ -243,6 +317,17 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.fileMenuItemDanger {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.fileMenuSubHost {
|
||||
position: relative;
|
||||
}
|
||||
@@ -289,6 +374,10 @@
|
||||
transform: translate(-50%, -50%);
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
@@ -296,6 +385,7 @@
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
@@ -325,6 +415,17 @@
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldGroup {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldGroupSpaced {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
@@ -338,17 +439,6 @@
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.selectInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
color: var(--text0);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.rowFlex {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -358,8 +448,13 @@
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.licenseBlockTitle {
|
||||
@@ -393,6 +488,153 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.instructionsDialog {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(920px, calc(100vw - 32px));
|
||||
max-height: min(86vh, 720px);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.instructionsLayout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 220px;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
max-height: min(62vh, 560px);
|
||||
}
|
||||
|
||||
.instructionsContent {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
.instructionsContentTitle {
|
||||
font-weight: 800;
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.instructionsParagraph {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.instructionsParagraph:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.instructionsInlineLink {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent, #a78bfa);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.instructionsInlineLink:hover {
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.instructionsNav {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--panel2);
|
||||
}
|
||||
|
||||
.instructionsNavItem {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.instructionsNavItem:hover {
|
||||
background: var(--scene-list-hover-bg);
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.instructionsNavItemActive {
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
color: var(--text0);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.aboutBody {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aboutAppName {
|
||||
font-weight: 900;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.aboutTagline {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.aboutParagraph {
|
||||
margin: 0;
|
||||
color: var(--text1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.aboutMetaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 14px;
|
||||
align-items: baseline;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.aboutLink {
|
||||
color: var(--color-accent, #a78bfa);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.aboutLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fileSuffix {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
@@ -414,6 +656,23 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.projectPickerForm > * {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.projectPickerForm > *:has(button),
|
||||
.projectPickerForm > span {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.projectPickerForm button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spacer6 {
|
||||
height: 6px;
|
||||
}
|
||||
@@ -452,6 +711,16 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.projectCardBodyOpening {
|
||||
cursor: wait;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.projectCardBodyDisabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.projectCardMenuBtn {
|
||||
flex-shrink: 0;
|
||||
margin: -4px -4px 0 0;
|
||||
@@ -490,6 +759,19 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.labelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.labelRow .labelSm {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.spacer8 {
|
||||
height: 8px;
|
||||
}
|
||||
@@ -505,6 +787,75 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.descriptionEmpty {
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--stroke-2);
|
||||
background: var(--color-overlay-dark-2);
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.descriptionPreview {
|
||||
max-height: 4.6em;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
color: var(--text1);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.descriptionPreview :global(p),
|
||||
.descriptionPreview :global(h2),
|
||||
.descriptionPreview :global(h3),
|
||||
.descriptionPreview :global(ul),
|
||||
.descriptionPreview :global(ol),
|
||||
.descriptionPreview :global(blockquote),
|
||||
.descriptionPreview :global(pre) {
|
||||
margin: 0 0 0.35em;
|
||||
}
|
||||
|
||||
.descriptionPreview :global(p:last-child),
|
||||
.descriptionPreview :global(h2:last-child),
|
||||
.descriptionPreview :global(h3:last-child),
|
||||
.descriptionPreview :global(ul:last-child),
|
||||
.descriptionPreview :global(ol:last-child),
|
||||
.descriptionPreview :global(blockquote:last-child),
|
||||
.descriptionPreview :global(pre:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.descriptionPreview :global(h2),
|
||||
.descriptionPreview :global(h3) {
|
||||
font-size: 1em;
|
||||
font-weight: 800;
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.descriptionPreview :global(ul),
|
||||
.descriptionPreview :global(ol) {
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.descriptionPreview :global(strong),
|
||||
.descriptionPreview :global(b) {
|
||||
font-weight: 800;
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.descriptionPreview :global(a) {
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
@@ -522,6 +873,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.previewBoxDragOver {
|
||||
border-color: var(--accent);
|
||||
border-style: dashed;
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.previewFill {
|
||||
@@ -586,6 +946,33 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actionsRowHalf {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowVideoChecks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -615,6 +1002,47 @@
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.audioDropEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 96px;
|
||||
text-align: center;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.audioDropEmpty > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.audioDropDragOver {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.dropHintOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
border-radius: inherit;
|
||||
background: var(--accent-fill-soft-2);
|
||||
color: var(--accent);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.audioList {
|
||||
@@ -687,6 +1115,7 @@
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sceneCard:not(.sceneCardActive):hover {
|
||||
@@ -698,6 +1127,36 @@
|
||||
background: var(--scene-list-selected-bg);
|
||||
}
|
||||
|
||||
.sceneCardDragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.sceneCardDropBefore,
|
||||
.sceneCardDropAfter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sceneCardDropBefore::before,
|
||||
.sceneCardDropAfter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sceneCardDropBefore::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sceneCardDropAfter::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.sceneThumb {
|
||||
height: 92px;
|
||||
position: relative;
|
||||
@@ -712,6 +1171,13 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sceneThumbInner img,
|
||||
.sceneThumbInner video,
|
||||
.sceneThumbVideo {
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sceneThumbVideo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -737,21 +1203,23 @@
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sceneCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badgeCurrent {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sceneMenuBtn {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--panel2);
|
||||
border-radius: var(--radius-xs);
|
||||
@@ -765,6 +1233,11 @@
|
||||
|
||||
.sceneCardTitle {
|
||||
font-weight: 750;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menuBackdrop {
|
||||
@@ -802,3 +1275,77 @@
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modalDialogWide {
|
||||
width: 640px;
|
||||
}
|
||||
|
||||
.storylineChecklist {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.storylineCheck {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.storylineCheck input {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.storylineCheckDisabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.conflictList {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conflictRow {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.conflictRowTitle {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.conflictRowOptions {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.importFileRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reportList {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
+1427
-370
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Button, Select } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
|
||||
export type FoundryImportSourceSelection =
|
||||
| { kind: 'folder'; sourcePath: string }
|
||||
| { kind: 'archive'; sourcePath: string };
|
||||
|
||||
type FoundryImportModalProps = {
|
||||
open: boolean;
|
||||
pickSource: (
|
||||
mode: 'folder' | 'archive',
|
||||
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
|
||||
onClose: () => void;
|
||||
onImport: (selection: FoundryImportSourceSelection) => Promise<void>;
|
||||
};
|
||||
|
||||
export function FoundryImportModal({ open, pickSource, onClose, onImport }: FoundryImportModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [mode, setMode] = useState<'folder' | 'archive'>('folder');
|
||||
const [picked, setPicked] = useState<{ path: string; name: string } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode('folder');
|
||||
setPicked(null);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !submitting) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open, submitting]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!submitting) onClose();
|
||||
}}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('foundryImport.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!submitting) onClose();
|
||||
}}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.muted}>{t('foundryImport.hint')}</div>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('foundryImport.sourceType')}</div>
|
||||
<Select
|
||||
value={mode}
|
||||
disabled={submitting}
|
||||
ariaLabel={t('foundryImport.sourceType')}
|
||||
options={[
|
||||
{ value: 'folder', label: t('foundryImport.folder') },
|
||||
{ value: 'archive', label: t('foundryImport.archive') },
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setMode(next as 'folder' | 'archive');
|
||||
setPicked(null);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('foundryImport.source')}</div>
|
||||
<div className={styles.importFileRow}>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setError(null);
|
||||
const res = await pickSource(mode);
|
||||
if (res.canceled) return;
|
||||
const name = res.sourcePath.split(/[/\\]/).pop() ?? res.sourcePath;
|
||||
setPicked({ path: res.sourcePath, name });
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{mode === 'folder' ? t('foundryImport.chooseFolder') : t('foundryImport.chooseArchive')}
|
||||
</Button>
|
||||
<span className={styles.muted}>{picked ? picked.name : t('foundryImport.noSourceSelected')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!picked || submitting}
|
||||
onClick={() => {
|
||||
if (!picked) return;
|
||||
void (async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onImport({
|
||||
kind: mode,
|
||||
sourcePath: picked.path,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('foundryImport.import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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,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}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionItalic')}
|
||||
active={toolbarState.italic}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<em>I</em>
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionUnderline')}
|
||||
active={toolbarState.underline}
|
||||
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}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
H2
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionHeading3')}
|
||||
active={toolbarState.h3}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
H3
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionQuote')}
|
||||
active={toolbarState.blockquote}
|
||||
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}
|
||||
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}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
|
||||
</ToolButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={modalStyles.editorContent}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
collectSceneIdsForSelections,
|
||||
findNpcNameConflicts,
|
||||
findSceneTitleConflicts,
|
||||
listExportedNpcsFromBundle,
|
||||
storylineSelectionKey,
|
||||
type NpcImportResolution,
|
||||
type NpcNameConflict,
|
||||
type SceneImportResolution,
|
||||
type SceneTitleConflict,
|
||||
type StorylineImportMergeReport,
|
||||
type StorylineLabels,
|
||||
type StorylineListItem,
|
||||
type StorylineSelection,
|
||||
} from '../../shared/graph/storylineExportImport';
|
||||
import type { Project, ProjectId, SceneId } from '../../shared/types';
|
||||
import { Button, Select } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
|
||||
export type ExportNpcOption = { id: string; name: string };
|
||||
|
||||
type ExportProjectModalProps = {
|
||||
open: boolean;
|
||||
projects: { id: ProjectId; name: string; fileName: string }[];
|
||||
initialProjectId: ProjectId | null;
|
||||
storylineLabels: StorylineLabels;
|
||||
loadStorylines: (
|
||||
projectId: ProjectId,
|
||||
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
|
||||
onClose: () => void;
|
||||
onExport: (
|
||||
projectId: ProjectId,
|
||||
selections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ExportProjectModal({
|
||||
open,
|
||||
projects,
|
||||
initialProjectId,
|
||||
storylineLabels: _storylineLabels,
|
||||
loadStorylines,
|
||||
onClose,
|
||||
onExport,
|
||||
}: ExportProjectModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
|
||||
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
||||
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
||||
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
|
||||
const [loadingStorylines, setLoadingStorylines] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setProjectId(initialProjectId);
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setStep('storylines');
|
||||
setNpcs([]);
|
||||
}, [initialProjectId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) {
|
||||
setStorylines([]);
|
||||
setNpcs([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingStorylines(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await loadStorylines(projectId);
|
||||
if (cancelled) return;
|
||||
const list = Array.isArray(res?.storylines) ? res.storylines : [];
|
||||
const npcList = Array.isArray(res?.npcs) ? res.npcs : [];
|
||||
setStorylines(list);
|
||||
setNpcs(npcList);
|
||||
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
|
||||
setSelectedNpcIds(new Set(npcList.map((n) => n.id)));
|
||||
setStep('storylines');
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setStorylines([]);
|
||||
setNpcs([]);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingStorylines(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadStorylines, open, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (step === 'npcs') setStep('storylines');
|
||||
else onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open, step]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canContinueStorylines =
|
||||
projectId !== null &&
|
||||
projects.some((p) => p.id === projectId) &&
|
||||
selectedKeys.size > 0 &&
|
||||
!loadingStorylines;
|
||||
|
||||
const toggleKey = (key: string, disabled?: boolean) => {
|
||||
if (disabled) return;
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleNpc = (id: string) => {
|
||||
setSelectedNpcIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedSelections = storylines
|
||||
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
|
||||
.map((item) => item.selection);
|
||||
|
||||
const runExport = (npcIds: string[]) => {
|
||||
if (!projectId || !canContinueStorylines) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onExport(projectId, selectedSelections, npcIds);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const goNextFromStorylines = () => {
|
||||
if (!canContinueStorylines) return;
|
||||
if (npcs.length === 0) {
|
||||
runExport([]);
|
||||
return;
|
||||
}
|
||||
setStep('npcs');
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>
|
||||
{step === 'storylines' ? t('export.title') : t('export.npcsTitle')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 'storylines' ? (
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
||||
<Select
|
||||
value={projectId ?? ''}
|
||||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||||
disabled={projects.length === 0 || saving}
|
||||
ariaLabel={t('export.project')}
|
||||
options={projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{loadingStorylines ? (
|
||||
<div className={styles.muted}>{t('storyline.loading')}</div>
|
||||
) : storylines.length === 0 ? (
|
||||
<div className={styles.muted}>{t('storyline.empty')}</div>
|
||||
) : (
|
||||
<div className={styles.storylineChecklist}>
|
||||
{storylines.map((item) => {
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const checked = selectedKeys.has(key);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled || saving}
|
||||
onChange={() => toggleKey(key, disabled)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{disabled && item.disabledReason === 'main_exists' ? (
|
||||
<span className={styles.muted}> — {t('storyline.mainExistsHint')}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.muted}>{t('export.hint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
||||
<div className={styles.storylineChecklist}>
|
||||
{npcs.map((n) => (
|
||||
<label key={n.id} className={styles.storylineCheck}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNpcIds.has(n.id)}
|
||||
disabled={saving}
|
||||
onChange={() => toggleNpc(n.id)}
|
||||
/>
|
||||
<span>{n.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
disabled={saving || npcs.length === 0}
|
||||
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
|
||||
>
|
||||
{t('export.selectAllNpcs')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
{step === 'npcs' ? (
|
||||
<Button onClick={() => setStep('storylines')} disabled={saving}>
|
||||
{t('export.back')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{step === 'storylines' ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canContinueStorylines || saving}
|
||||
onClick={goNextFromStorylines}
|
||||
>
|
||||
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={saving}
|
||||
onClick={() => runExport([...selectedNpcIds])}
|
||||
>
|
||||
{t('export.saveAs')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export type ImportPeekResult = {
|
||||
kind: 'file' | 'project';
|
||||
filePath?: string;
|
||||
sourceProjectId?: ProjectId;
|
||||
projectName: string;
|
||||
storylines: StorylineListItem[];
|
||||
sourceProject: Project;
|
||||
};
|
||||
|
||||
export type ImportSourceSelection =
|
||||
| { kind: 'file'; filePath: string; fileName: string }
|
||||
| { kind: 'project'; sourceProjectId: ProjectId };
|
||||
|
||||
type ImportSourceModalProps = {
|
||||
open: boolean;
|
||||
/** false — только импорт из файла (полный импорт проекта). */
|
||||
canImportFromProject: boolean;
|
||||
projects: { id: ProjectId; name: string; fileName: string }[];
|
||||
currentProjectId: ProjectId | null;
|
||||
pickFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
|
||||
onClose: () => void;
|
||||
onContinue: (selection: ImportSourceSelection) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ImportSourceModal({
|
||||
open,
|
||||
canImportFromProject,
|
||||
projects,
|
||||
currentProjectId,
|
||||
pickFile,
|
||||
onClose,
|
||||
onContinue,
|
||||
}: ImportSourceModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [importKind, setImportKind] = useState<'project' | 'file'>('file');
|
||||
const [sourceProjectId, setSourceProjectId] = useState<ProjectId | null>(null);
|
||||
const [pickedFile, setPickedFile] = useState<{ path: string; name: string } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const availableProjects = useMemo(
|
||||
() => projects.filter((p) => p.id !== currentProjectId),
|
||||
[currentProjectId, projects],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setImportKind(canImportFromProject && availableProjects.length > 0 ? 'project' : 'file');
|
||||
setSourceProjectId(availableProjects[0]?.id ?? null);
|
||||
setPickedFile(null);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [availableProjects, canImportFromProject, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canContinue =
|
||||
!submitting &&
|
||||
(importKind === 'project' ? canImportFromProject && sourceProjectId !== null : pickedFile !== null);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importSource.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
{canImportFromProject ? (
|
||||
<div className={styles.fieldGroup}>
|
||||
<div className={styles.fieldLabel}>{t('importSource.type')}</div>
|
||||
<Select
|
||||
value={importKind}
|
||||
onChange={(next) => setImportKind(next as 'project' | 'file')}
|
||||
disabled={submitting}
|
||||
ariaLabel={t('importSource.type')}
|
||||
options={[
|
||||
{ value: 'project', label: t('importSource.fromProject') },
|
||||
{ value: 'file', label: t('importSource.fromFile') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
|
||||
)}
|
||||
|
||||
{importKind === 'project' && canImportFromProject ? (
|
||||
<div className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : ''].filter(Boolean).join(' ')}>
|
||||
<div className={styles.fieldLabel}>{t('importSource.project')}</div>
|
||||
<Select
|
||||
value={sourceProjectId ?? ''}
|
||||
onChange={(next) => setSourceProjectId((next as ProjectId) || null)}
|
||||
disabled={availableProjects.length === 0 || submitting}
|
||||
ariaLabel={t('importSource.project')}
|
||||
options={availableProjects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
{availableProjects.length === 0 ? (
|
||||
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={styles.fieldLabel}>{t('importSource.file')}</div>
|
||||
<div className={styles.importFileRow}>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setError(null);
|
||||
const res = await pickFile();
|
||||
if (res.canceled) return;
|
||||
const name = res.filePath.split(/[/\\]/).pop() ?? res.filePath;
|
||||
setPickedFile({ path: res.filePath, name });
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('importSource.chooseFile')}
|
||||
</Button>
|
||||
<span className={styles.muted}>
|
||||
{pickedFile ? pickedFile.name : t('importSource.noFileSelected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canContinue}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (importKind === 'project' && canImportFromProject && sourceProjectId) {
|
||||
await onContinue({ kind: 'project', sourceProjectId });
|
||||
} else if (pickedFile) {
|
||||
await onContinue({ kind: 'file', filePath: pickedFile.path, fileName: pickedFile.name });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('importStoryline.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type ImportStorylinesModalProps = {
|
||||
open: boolean;
|
||||
sourceName: string;
|
||||
storylines: StorylineListItem[];
|
||||
onClose: () => void;
|
||||
onContinue: (selections: StorylineSelection[]) => void;
|
||||
};
|
||||
|
||||
export function ImportStorylinesModal({
|
||||
open,
|
||||
sourceName,
|
||||
storylines,
|
||||
onClose,
|
||||
onContinue,
|
||||
}: ImportStorylinesModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedKeys(new Set());
|
||||
}, [open, sourceName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const toggleKey = (key: string, disabled?: boolean) => {
|
||||
if (disabled) return;
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedSelections = storylines
|
||||
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
|
||||
.map((item) => item.selection);
|
||||
|
||||
const canContinue = selectedKeys.size > 0;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('importStoryline.source')}</div>
|
||||
<div>{sourceName}</div>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{storylines.length === 0 ? (
|
||||
<div className={styles.muted}>{t('storyline.empty')}</div>
|
||||
) : (
|
||||
<div className={styles.storylineChecklist}>
|
||||
{storylines.map((item) => {
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.has(key)}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleKey(key, disabled)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{disabled && item.disabledReason === 'main_exists' ? (
|
||||
<span className={styles.muted}> — {t('storyline.mainExistsHint')}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" disabled={!canContinue} onClick={() => onContinue(selectedSelections)}>
|
||||
{t('importStoryline.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type SceneConflictModalProps = {
|
||||
open: boolean;
|
||||
conflicts: SceneTitleConflict[];
|
||||
onClose: () => void;
|
||||
onConfirm: (resolutions: SceneImportResolution[]) => void;
|
||||
};
|
||||
|
||||
export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: SceneConflictModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [choices, setChoices] = useState<Record<string, 'create' | SceneId>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const init: Record<string, 'create' | SceneId> = {};
|
||||
for (const c of conflicts) {
|
||||
init[c.sourceSceneId] = c.matches[0]?.sceneId ?? 'create';
|
||||
}
|
||||
setChoices(init);
|
||||
}, [conflicts, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={`${styles.modalDialog} ${styles.modalDialogWide}`}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.conflictsTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={styles.muted}>{t('importStoryline.conflictsHint')}</p>
|
||||
|
||||
<div className={styles.conflictList}>
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.sourceSceneId} className={styles.conflictRow}>
|
||||
<div className={styles.conflictTitle}>{c.sourceTitle}</div>
|
||||
<Select
|
||||
value={choices[c.sourceSceneId] ?? 'create'}
|
||||
onChange={(v) => {
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
|
||||
}));
|
||||
}}
|
||||
ariaLabel={c.sourceTitle}
|
||||
options={[
|
||||
{ value: 'create', label: t('importStoryline.createNewScene') },
|
||||
...c.matches.map((m) => ({
|
||||
value: m.sceneId,
|
||||
label: t('importStoryline.useExistingScene', { title: m.title }),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const resolutions: SceneImportResolution[] = conflicts.map((c) => {
|
||||
const choice = choices[c.sourceSceneId] ?? 'create';
|
||||
if (choice === 'create') return { sourceSceneId: c.sourceSceneId, mode: 'create' };
|
||||
return { sourceSceneId: c.sourceSceneId, mode: 'use', targetSceneId: choice };
|
||||
});
|
||||
onConfirm(resolutions);
|
||||
}}
|
||||
>
|
||||
{t('importStoryline.import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type NpcConflictModalProps = {
|
||||
open: boolean;
|
||||
conflicts: NpcNameConflict[];
|
||||
onClose: () => void;
|
||||
onConfirm: (resolutions: NpcImportResolution[]) => void;
|
||||
};
|
||||
|
||||
function defaultNpcConflictChoices(conflicts: NpcNameConflict[]): Record<string, 'create' | string> {
|
||||
const init: Record<string, 'create' | string> = {};
|
||||
for (const c of conflicts) {
|
||||
init[c.sourceNpcId] = c.matches[0]?.npcId ?? 'create';
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
export function NpcConflictModal({ open, conflicts, onClose, onConfirm }: NpcConflictModalProps) {
|
||||
if (!open) return null;
|
||||
const remountKey = conflicts.map((c) => c.sourceNpcId).join('|');
|
||||
return (
|
||||
<NpcConflictModalBody
|
||||
key={remountKey}
|
||||
conflicts={conflicts}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NpcConflictModalBody({
|
||||
conflicts,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
conflicts: NpcNameConflict[];
|
||||
onClose: () => void;
|
||||
onConfirm: (resolutions: NpcImportResolution[]) => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const [choices, setChoices] = useState(() => defaultNpcConflictChoices(conflicts));
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={[styles.modalDialog, styles.modalDialogWide].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.npcConflictsTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={styles.muted}>{t('importStoryline.npcConflictsHint')}</p>
|
||||
|
||||
<div className={styles.conflictList}>
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.sourceNpcId} className={styles.conflictRow}>
|
||||
<div className={styles.conflictTitle}>{c.sourceName}</div>
|
||||
<Select
|
||||
value={choices[c.sourceNpcId] ?? 'create'}
|
||||
onChange={(v) => {
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[c.sourceNpcId]: v === 'create' ? 'create' : v,
|
||||
}));
|
||||
}}
|
||||
ariaLabel={c.sourceName}
|
||||
options={[
|
||||
{ value: 'create', label: t('importStoryline.createNewNpc') },
|
||||
...c.matches.map((m) => ({
|
||||
value: m.npcId,
|
||||
label: t('importStoryline.useExistingNpc', { name: m.name }),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const resolutions: NpcImportResolution[] = conflicts.map((c) => {
|
||||
const choice = choices[c.sourceNpcId] ?? 'create';
|
||||
if (choice === 'create') return { sourceNpcId: c.sourceNpcId, mode: 'create' };
|
||||
return { sourceNpcId: c.sourceNpcId, mode: 'use', targetNpcId: choice };
|
||||
});
|
||||
onConfirm(resolutions);
|
||||
}}
|
||||
>
|
||||
{t('importStoryline.import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type ImportReportModalProps = {
|
||||
open: boolean;
|
||||
report: StorylineImportMergeReport | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ImportReportModal({ open, report, onClose }: ImportReportModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open || !report) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.reportTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className={styles.reportList}>
|
||||
<li>{t('importStoryline.reportLines', { count: report.storylinesImported })}</li>
|
||||
<li>{t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}</li>
|
||||
<li>{t('importStoryline.reportScenesReused', { count: report.scenesReused })}</li>
|
||||
<li>{t('importStoryline.reportNpcsCreated', { count: report.npcsCreated })}</li>
|
||||
<li>{t('importStoryline.reportNpcsReused', { count: report.npcsReused })}</li>
|
||||
<li>{t('importStoryline.reportNodes', { count: report.graphNodesAdded })}</li>
|
||||
<li>{t('importStoryline.reportEdges', { count: report.edgesAdded })}</li>
|
||||
<li>{t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}</li>
|
||||
<li>{t('importStoryline.reportAssetsReused', { count: report.assetsReused })}</li>
|
||||
{report.renamedSideTitles.length > 0 ? (
|
||||
<li>{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSceneResolutionsForImport(
|
||||
targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
conflicts: SceneTitleConflict[],
|
||||
userResolutions: SceneImportResolution[],
|
||||
): SceneImportResolution[] {
|
||||
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
|
||||
const conflictIds = new Set(conflicts.map((c) => c.sourceSceneId));
|
||||
const bySource = new Map(userResolutions.map((r) => [r.sourceSceneId, r]));
|
||||
const out: SceneImportResolution[] = [];
|
||||
for (const sid of sceneIds) {
|
||||
if (conflictIds.has(sid)) {
|
||||
const r = bySource.get(sid);
|
||||
if (r) out.push(r);
|
||||
continue;
|
||||
}
|
||||
out.push({ sourceSceneId: sid, mode: 'create' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeImportConflicts(
|
||||
targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
): SceneTitleConflict[] {
|
||||
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
|
||||
return findSceneTitleConflicts(targetProject, sourceProject, sceneIds);
|
||||
}
|
||||
|
||||
export function buildNpcResolutionsForImport(
|
||||
_targetProject: Project,
|
||||
sourceProject: Project,
|
||||
conflicts: NpcNameConflict[],
|
||||
userResolutions: NpcImportResolution[],
|
||||
): NpcImportResolution[] {
|
||||
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
|
||||
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
|
||||
const out: NpcImportResolution[] = [];
|
||||
for (const n of exported) {
|
||||
if (conflictIds.has(n.id)) {
|
||||
const r = bySource.get(n.id);
|
||||
if (r) out.push(r);
|
||||
continue;
|
||||
}
|
||||
out.push({ sourceNpcId: n.id, mode: 'create' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeNpcImportConflicts(
|
||||
targetProject: Project,
|
||||
sourceProject: Project,
|
||||
): NpcNameConflict[] {
|
||||
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||
return findNpcNameConflicts(
|
||||
targetProject,
|
||||
sourceProject,
|
||||
exported.map((n) => n.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function useStorylineLabels(): StorylineLabels {
|
||||
const { t } = useEditorI18n();
|
||||
return useMemo(
|
||||
() => ({
|
||||
main: t('storyline.main'),
|
||||
untitled: t('graph.untitled'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
|
||||
import { Button } from '../../shared/ui/controls';
|
||||
import styles from '../EditorApp.module.css';
|
||||
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../help/helpLinkify';
|
||||
import {
|
||||
HELP_SECTION_IDS,
|
||||
helpSectionBodyKey,
|
||||
helpSectionTitleKey,
|
||||
type HelpSectionId,
|
||||
} from '../help/helpSections';
|
||||
import { useEditorI18n } from '../i18n/EditorI18nContext';
|
||||
|
||||
type AppAboutModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
appVersion: string | null;
|
||||
};
|
||||
|
||||
export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps) {
|
||||
const { t, locale } = useEditorI18n();
|
||||
const appName = locale === 'ru' ? APP_DISPLAY_NAME_RU : APP_DISPLAY_NAME_EN;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('app.about.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.aboutBody}>
|
||||
<div className={styles.aboutAppName}>{appName}</div>
|
||||
<div className={styles.aboutTagline}>{t('app.about.tagline')}</div>
|
||||
<p className={styles.aboutParagraph}>{t('app.about.description')}</p>
|
||||
<div className={styles.aboutMetaGrid}>
|
||||
<div className={styles.fieldLabel}>{t('app.about.versionLabel')}</div>
|
||||
<div>{appVersion ?? '—'}</div>
|
||||
<div className={styles.fieldLabel}>{t('app.about.developerLabel')}</div>
|
||||
<div>{t('app.about.developer')}</div>
|
||||
<div className={styles.fieldLabel}>{t('app.about.supportLabel')}</div>
|
||||
<div>
|
||||
<a className={styles.aboutLink} href={`mailto:${t('app.about.supportEmail')}`}>
|
||||
{t('app.about.supportEmail')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.fieldLabel}>{t('app.about.websiteLabel')}</div>
|
||||
<div>
|
||||
<a
|
||||
className={styles.aboutLink}
|
||||
href={t('app.about.websiteUrl')}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('app.about.websiteUrl')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type InstructionsModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialSection?: HelpSectionId;
|
||||
};
|
||||
|
||||
function InstructionsModalBody({
|
||||
initialSection,
|
||||
onClose,
|
||||
}: {
|
||||
initialSection: HelpSectionId;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0 });
|
||||
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
||||
activeNav?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeId]);
|
||||
|
||||
const linkCatalog = useMemo(
|
||||
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
|
||||
[t],
|
||||
);
|
||||
|
||||
const body = t(helpSectionBodyKey(activeId));
|
||||
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
||||
|
||||
const goToSection = (id: HelpSectionId) => {
|
||||
setActiveId(id);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.instructionsDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('help.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.instructionsLayout}>
|
||||
<div ref={contentRef} className={styles.instructionsContent}>
|
||||
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} className={styles.instructionsParagraph}>
|
||||
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
|
||||
part.type === 'text' ? (
|
||||
<React.Fragment key={j}>{part.value}</React.Fragment>
|
||||
) : (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
className={styles.instructionsInlineLink}
|
||||
onClick={() => goToSection(part.id)}
|
||||
>
|
||||
{part.value}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}>
|
||||
{HELP_SECTION_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={[
|
||||
styles.instructionsNavItem,
|
||||
id === activeId ? styles.instructionsNavItemActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={id === activeId ? 'true' : undefined}
|
||||
onClick={() => goToSection(id)}
|
||||
>
|
||||
{t(helpSectionTitleKey(id))}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) {
|
||||
if (!open) return null;
|
||||
const section = initialSection ?? 'overview';
|
||||
return <InstructionsModalBody key={section} initialSection={section} onClose={onClose} />;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
isPreviewMediaPath,
|
||||
partitionSceneMediaDrops,
|
||||
sceneTitleFromMediaPath,
|
||||
} from './fileDrop';
|
||||
|
||||
void test('sceneTitleFromMediaPath strips extension and path', () => {
|
||||
assert.equal(sceneTitleFromMediaPath('C:\\media\\Forest Gate.png'), 'Forest Gate');
|
||||
assert.equal(sceneTitleFromMediaPath('/tmp/battle.mp4'), 'battle');
|
||||
assert.equal(sceneTitleFromMediaPath('onlyname'), 'onlyname');
|
||||
});
|
||||
|
||||
void test('isPreviewMediaPath accepts images and videos', () => {
|
||||
assert.equal(isPreviewMediaPath('a.png'), true);
|
||||
assert.equal(isPreviewMediaPath('a.JPG'), true);
|
||||
assert.equal(isPreviewMediaPath('a.webm'), true);
|
||||
assert.equal(isPreviewMediaPath('a.mp3'), false);
|
||||
assert.equal(isPreviewMediaPath('a.pdf'), false);
|
||||
});
|
||||
|
||||
void test('partitionSceneMediaDrops keeps valid and rejects others', () => {
|
||||
const { accepted, rejected } = partitionSceneMediaDrops([
|
||||
{ path: 'D:\\a\\one.jpg', name: 'one.jpg' },
|
||||
{ path: 'D:\\a\\two.mp3', name: 'two.mp3' },
|
||||
{ path: '', name: 'ghost.png' },
|
||||
{ path: 'D:\\a\\three.mov', name: 'three.mov' },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
accepted.map((x) => x.name),
|
||||
['one.jpg', 'three.mov'],
|
||||
);
|
||||
assert.deepEqual(rejected, [
|
||||
{ name: 'two.mp3', reason: 'unsupported' },
|
||||
{ name: 'ghost.png', reason: 'no_path' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
|
||||
const MATERIAL_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
const PREVIEW_EXTENSIONS = new Set([
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.webp',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.mp4',
|
||||
'.webm',
|
||||
'.mov',
|
||||
]);
|
||||
|
||||
function fileExtension(filePath: string): string {
|
||||
const dot = filePath.lastIndexOf('.');
|
||||
return dot >= 0 ? filePath.slice(dot).toLowerCase() : '';
|
||||
}
|
||||
|
||||
export type DroppedFileEntry = { path: string; name: string };
|
||||
|
||||
export type SceneMediaDropRejectReason = 'unsupported' | 'no_path';
|
||||
|
||||
export type SceneMediaDropRejected = { name: string; reason: SceneMediaDropRejectReason };
|
||||
|
||||
export function getDroppedFilePaths(e: DragEvent): string[] {
|
||||
return getDroppedFileEntries(e)
|
||||
.map((entry) => entry.path)
|
||||
.filter((path) => path.length > 0);
|
||||
}
|
||||
|
||||
export function getDroppedFileEntries(e: DragEvent): DroppedFileEntry[] {
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files?.length) return [];
|
||||
const getPathForFile = getDndApi().getPathForFile;
|
||||
return Array.from(files).map((file) => ({
|
||||
path: getPathForFile(file),
|
||||
name: file.name || 'file',
|
||||
}));
|
||||
}
|
||||
|
||||
export function filterAudioFilePaths(paths: string[]): string[] {
|
||||
return paths.filter((path) => AUDIO_EXTENSIONS.has(fileExtension(path)));
|
||||
}
|
||||
|
||||
export function filterMaterialImagePaths(paths: string[]): string[] {
|
||||
return paths.filter((path) => MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path)));
|
||||
}
|
||||
|
||||
export function pickFirstMaterialImagePath(paths: string[]): string | null {
|
||||
for (const path of paths) {
|
||||
if (MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path))) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pickFirstPreviewFilePath(paths: string[]): string | null {
|
||||
for (const path of paths) {
|
||||
if (PREVIEW_EXTENSIONS.has(fileExtension(path))) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPreviewMediaPath(filePath: string): boolean {
|
||||
return PREVIEW_EXTENSIONS.has(fileExtension(filePath));
|
||||
}
|
||||
|
||||
export function sceneTitleFromMediaPath(filePath: string): string {
|
||||
const base = filePath.split(/[/\\]/).pop() ?? filePath;
|
||||
const dot = base.lastIndexOf('.');
|
||||
const title = (dot > 0 ? base.slice(0, dot) : base).trim();
|
||||
return title.length > 0 ? title : 'Новая сцена';
|
||||
}
|
||||
|
||||
export function partitionSceneMediaDrops(entries: DroppedFileEntry[]): {
|
||||
accepted: DroppedFileEntry[];
|
||||
rejected: SceneMediaDropRejected[];
|
||||
} {
|
||||
const accepted: DroppedFileEntry[] = [];
|
||||
const rejected: SceneMediaDropRejected[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.path) {
|
||||
rejected.push({ name: entry.name, reason: 'no_path' });
|
||||
continue;
|
||||
}
|
||||
if (!isPreviewMediaPath(entry.path)) {
|
||||
rejected.push({ name: entry.name, reason: 'unsupported' });
|
||||
continue;
|
||||
}
|
||||
accepted.push(entry);
|
||||
}
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
function dragHasFiles(e: DragEvent): boolean {
|
||||
const types = Array.from(e.dataTransfer?.types ?? []);
|
||||
// Внутренний drag карточки сцены (в т.ч. с превью-картинкой) не должен считаться файловым.
|
||||
if (types.some((t) => t === 'application/x-dnd-scene-id')) return false;
|
||||
return types.includes('Files');
|
||||
}
|
||||
|
||||
type UseFileDropZoneOptions = {
|
||||
disabled?: boolean;
|
||||
/** Синхронная блокировка (например, идёт reorder списка сцен). */
|
||||
isBlocked?: () => boolean;
|
||||
onDropPaths?: (paths: string[]) => void;
|
||||
onDropEntries?: (entries: DroppedFileEntry[]) => void;
|
||||
filterPaths?: (paths: string[]) => string[];
|
||||
};
|
||||
|
||||
export function useFileDropZone({
|
||||
disabled = false,
|
||||
isBlocked,
|
||||
onDropPaths,
|
||||
onDropEntries,
|
||||
filterPaths,
|
||||
}: UseFileDropZoneOptions) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const depthRef = useRef(0);
|
||||
|
||||
const blocked = useCallback(() => disabled || Boolean(isBlocked?.()), [disabled, isBlocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blocked()) return;
|
||||
depthRef.current = 0;
|
||||
setDragOver(false);
|
||||
}, [blocked]);
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (blocked() || !dragHasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
depthRef.current += 1;
|
||||
setDragOver(true);
|
||||
},
|
||||
[blocked],
|
||||
);
|
||||
|
||||
const onDragLeave = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (blocked()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
depthRef.current = Math.max(0, depthRef.current - 1);
|
||||
if (depthRef.current === 0) setDragOver(false);
|
||||
},
|
||||
[blocked],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (blocked() || !dragHasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
},
|
||||
[blocked],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (blocked() || !dragHasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
depthRef.current = 0;
|
||||
setDragOver(false);
|
||||
if (onDropEntries) {
|
||||
const entries = getDroppedFileEntries(e);
|
||||
if (entries.length === 0) return;
|
||||
onDropEntries(entries);
|
||||
return;
|
||||
}
|
||||
const raw = getDroppedFilePaths(e);
|
||||
const paths = filterPaths ? filterPaths(raw) : raw;
|
||||
if (paths.length === 0) return;
|
||||
onDropPaths?.(paths);
|
||||
},
|
||||
[blocked, filterPaths, onDropEntries, onDropPaths],
|
||||
);
|
||||
|
||||
return { dragOver, onDragEnter, onDragLeave, onDragOver, onDrop };
|
||||
}
|
||||
@@ -8,6 +8,12 @@
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.handleSide {
|
||||
background: var(--side-story-handle);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -26,6 +32,13 @@
|
||||
0 25px 50px -12px rgba(167, 139, 250, 0.12);
|
||||
}
|
||||
|
||||
.cardActiveSide {
|
||||
border-color: rgba(0, 120, 212, 0.95);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(0, 120, 212, 0.35),
|
||||
0 25px 50px -12px rgba(0, 120, 212, 0.12);
|
||||
}
|
||||
|
||||
.previewShell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -65,6 +78,21 @@
|
||||
box-shadow: var(--shadow-start-badge);
|
||||
}
|
||||
|
||||
.badgeSideStory {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--side-story-fill-solid);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: var(--shadow-side-story-badge);
|
||||
}
|
||||
|
||||
.cornerBadges {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
@@ -119,6 +147,7 @@
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.musicParams {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
|
||||
import { createPortal } from 'react-dom';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
ConnectionMode,
|
||||
Handle,
|
||||
MarkerType,
|
||||
Panel,
|
||||
@@ -19,8 +20,15 @@ import ReactFlow, {
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
|
||||
import {
|
||||
canSetSideStoryStart,
|
||||
isNodeInSideStoryline,
|
||||
isSideStoryEdge,
|
||||
} from '../../../shared/graph/sceneGraphLineage';
|
||||
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
|
||||
import { RotatedImage } from '../../shared/RotatedImage';
|
||||
import { EllipsisText } from '../../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
|
||||
import { useAssetUrl } from '../../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './SceneGraph.module.css';
|
||||
@@ -53,6 +61,7 @@ const SCENE_CARD_H = 248;
|
||||
/** UI strings for the scene graph (passed from editor i18n). */
|
||||
export type SceneGraphUiStrings = {
|
||||
badgeStart: string;
|
||||
badgeSideStory: string;
|
||||
untitled: string;
|
||||
videoBadge: string;
|
||||
audioBadge: string;
|
||||
@@ -67,11 +76,15 @@ export type SceneGraphUiStrings = {
|
||||
closeMenu: string;
|
||||
startScene: string;
|
||||
unsetStartScene: string;
|
||||
sideStoryStartScene: string;
|
||||
unsetSideStoryStartScene: string;
|
||||
runFromScene: string;
|
||||
delete: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
||||
badgeStart: 'НАЧАЛО',
|
||||
badgeSideStory: 'ПОБОЧНАЯ',
|
||||
untitled: 'Без названия',
|
||||
videoBadge: 'Видео',
|
||||
audioBadge: 'Аудио',
|
||||
@@ -86,6 +99,9 @@ const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
||||
closeMenu: 'Закрыть меню',
|
||||
startScene: 'Начальная сцена',
|
||||
unsetStartScene: 'Снять метку «Начальная сцена»',
|
||||
sideStoryStartScene: 'Начальная сцена побочной линии',
|
||||
unsetSideStoryStartScene: 'Снять метку «Начальная сцена побочной линии»',
|
||||
runFromScene: 'Запустить с этой сцены',
|
||||
delete: 'Удалить',
|
||||
};
|
||||
|
||||
@@ -95,15 +111,18 @@ export type SceneGraphProps = {
|
||||
sceneGraphNodes: SceneGraphNode[];
|
||||
sceneGraphEdges: SceneGraphEdge[];
|
||||
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
|
||||
currentSceneId: SceneId | null;
|
||||
/** Выделенная карточка на графе (одна нода, не все копии сцены). */
|
||||
selectedGraphNodeId: GraphNodeId | null;
|
||||
graphUi?: SceneGraphUiStrings;
|
||||
onCurrentSceneChange: (id: SceneId) => void;
|
||||
onGraphNodeSelect: (graphNodeId: GraphNodeId, sceneId: SceneId) => void;
|
||||
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
|
||||
onDisconnect: (edgeId: string) => void;
|
||||
onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void;
|
||||
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
|
||||
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
|
||||
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
|
||||
onSetGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => void;
|
||||
onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void;
|
||||
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
|
||||
};
|
||||
|
||||
@@ -117,6 +136,8 @@ type SceneCardData = {
|
||||
previewVideoAutostart: boolean;
|
||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||
isStartScene: boolean;
|
||||
isSideStoryStart: boolean;
|
||||
isSideStoryNode: boolean;
|
||||
hasSceneAudio: boolean;
|
||||
previewIsVideo: boolean;
|
||||
hasAnyAudioLoop: boolean;
|
||||
@@ -176,15 +197,24 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
const ui = useContext(GraphUiContext);
|
||||
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
|
||||
const previewUrl = useAssetUrl(data.previewAssetId);
|
||||
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
|
||||
const cardClass = [
|
||||
styles.card,
|
||||
data.active ? (data.isSideStoryNode ? styles.cardActiveSide : styles.cardActive) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const handleClass = data.isSideStoryNode ? styles.handleSide : styles.handle;
|
||||
const showCornerVideo = data.previewIsVideo;
|
||||
const showCornerAudio = data.hasSceneAudio;
|
||||
return (
|
||||
<div className={styles.nodeWrap}>
|
||||
<Handle type="target" position={Position.Top} className={styles.handle} />
|
||||
<Handle type="target" position={Position.Top} className={handleClass} />
|
||||
<div className={cardClass}>
|
||||
<div className={styles.previewShell}>
|
||||
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
|
||||
{data.isSideStoryStart ? (
|
||||
<div className={styles.badgeSideStory}>{ui.badgeSideStory}</div>
|
||||
) : null}
|
||||
{thumbUrl ? (
|
||||
<div className={styles.previewFill}>
|
||||
{data.previewRotationDeg === 0 ? (
|
||||
@@ -265,7 +295,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.nodeBody}>
|
||||
<div className={styles.title}>{data.title || ui.untitled}</div>
|
||||
<EllipsisText text={data.title || ui.untitled} className={[styles.title, ellipsisStyles.root].join(' ')} />
|
||||
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParams}>
|
||||
{data.hasAnyAudioLoop ? (
|
||||
@@ -300,7 +330,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className={styles.handle} />
|
||||
<Handle type="source" position={Position.Bottom} className={handleClass} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -350,39 +380,66 @@ function SceneGraphCanvas({
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
sceneCardById,
|
||||
currentSceneId,
|
||||
selectedGraphNodeId,
|
||||
graphUi,
|
||||
onCurrentSceneChange,
|
||||
onGraphNodeSelect,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onNodePositionCommit,
|
||||
onRemoveGraphNodes,
|
||||
onRemoveGraphNode,
|
||||
onSetGraphNodeStart,
|
||||
onSetGraphNodeSideStoryStart,
|
||||
onRunFromGraphNode,
|
||||
onDropSceneFromList,
|
||||
}: SceneGraphProps) {
|
||||
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
|
||||
const [edgeMenu, setEdgeMenu] = useState<{ x: number; y: number; edgeId: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
if (!menu && !edgeMenu) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMenu(null);
|
||||
if (e.key === 'Escape') {
|
||||
setMenu(null);
|
||||
setEdgeMenu(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [menu]);
|
||||
}, [edgeMenu, menu]);
|
||||
|
||||
const menuNodeIsStart = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene);
|
||||
}, [menu, sceneGraphNodes]);
|
||||
|
||||
const menuNodeIsSideStoryStart = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isSideStoryStart);
|
||||
}, [menu, sceneGraphNodes]);
|
||||
|
||||
const menuCanSetSideStoryStart = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return canSetSideStoryStart(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
|
||||
}, [menu, sceneGraphEdges, sceneGraphNodes]);
|
||||
|
||||
const menuNodeIsSideBranch = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
|
||||
}, [menu, sceneGraphEdges, sceneGraphNodes]);
|
||||
|
||||
const sideStoryEdgeStroke = 'rgba(0,120,212,0.95)';
|
||||
const sideStoryEdgeStrokeDim = 'rgba(0,120,212,0.55)';
|
||||
const mainEdgeStroke = 'rgba(167,139,250,0.95)';
|
||||
const mainEdgeStrokeDim = 'rgba(167,139,250,0.55)';
|
||||
|
||||
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
|
||||
return sceneGraphNodes.map((gn) => {
|
||||
const c = sceneCardById[gn.sceneId];
|
||||
const active = gn.sceneId === currentSceneId;
|
||||
const active = selectedGraphNodeId === gn.id;
|
||||
const isSideStoryNode = isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, gn.id);
|
||||
const audios = c?.audios ?? [];
|
||||
return {
|
||||
id: gn.id,
|
||||
@@ -398,6 +455,8 @@ function SceneGraphCanvas({
|
||||
previewVideoAutostart: c?.previewVideoAutostart ?? false,
|
||||
previewRotationDeg: c?.previewRotationDeg ?? 0,
|
||||
isStartScene: gn.isStartScene,
|
||||
isSideStoryStart: gn.isSideStoryStart,
|
||||
isSideStoryNode,
|
||||
hasSceneAudio: audios.length >= 1,
|
||||
previewIsVideo: c?.previewAssetType === 'video',
|
||||
hasAnyAudioLoop: audios.some((a) => a.loop),
|
||||
@@ -408,39 +467,46 @@ function SceneGraphCanvas({
|
||||
style: { padding: 0, background: 'transparent', border: 'none' },
|
||||
};
|
||||
});
|
||||
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
|
||||
}, [sceneCardById, sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
|
||||
|
||||
const desiredEdges = useMemo<Edge[]>(() => {
|
||||
const selectedGraphNodeIds = new Set<GraphNodeId>();
|
||||
if (currentSceneId) {
|
||||
for (const gn of sceneGraphNodes) {
|
||||
if (gn.sceneId === currentSceneId) selectedGraphNodeIds.add(gn.id);
|
||||
}
|
||||
}
|
||||
const hasSelection = selectedGraphNodeIds.size > 0;
|
||||
return sceneGraphEdges.map((e) => ({
|
||||
...(hasSelection
|
||||
? {
|
||||
style:
|
||||
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
|
||||
? { stroke: 'rgba(167,139,250,0.95)', strokeWidth: 3 }
|
||||
: { stroke: 'rgba(255,255,255,0.10)', strokeWidth: 2 },
|
||||
markerEnd:
|
||||
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
|
||||
? { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.95)', strokeWidth: 2 }
|
||||
: { type: MarkerType.ArrowClosed, color: 'rgba(255,255,255,0.18)', strokeWidth: 2 },
|
||||
}
|
||||
: {
|
||||
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 },
|
||||
}),
|
||||
id: e.id,
|
||||
source: e.sourceGraphNodeId,
|
||||
target: e.targetGraphNodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
}));
|
||||
}, [currentSceneId, sceneGraphEdges, sceneGraphNodes]);
|
||||
const hasSelection = selectedGraphNodeId != null;
|
||||
return sceneGraphEdges.map((e) => {
|
||||
const isSide = isSideStoryEdge(sceneGraphNodes, sceneGraphEdges, e);
|
||||
const strokeActive = isSide ? sideStoryEdgeStroke : mainEdgeStroke;
|
||||
const strokeIdle = isSide ? sideStoryEdgeStrokeDim : mainEdgeStrokeDim;
|
||||
const strokeDim = 'rgba(255,255,255,0.10)';
|
||||
const markerDim = 'rgba(255,255,255,0.18)';
|
||||
const touchesSelection =
|
||||
selectedGraphNodeId != null &&
|
||||
(e.sourceGraphNodeId === selectedGraphNodeId || e.targetGraphNodeId === selectedGraphNodeId);
|
||||
return {
|
||||
...(hasSelection
|
||||
? {
|
||||
style: touchesSelection
|
||||
? { stroke: strokeActive, strokeWidth: 3 }
|
||||
: { stroke: strokeDim, strokeWidth: 2 },
|
||||
markerEnd: touchesSelection
|
||||
? { type: MarkerType.ArrowClosed, color: strokeActive, strokeWidth: 2 }
|
||||
: { type: MarkerType.ArrowClosed, color: markerDim, strokeWidth: 2 },
|
||||
}
|
||||
: {
|
||||
style: { stroke: strokeIdle, strokeWidth: 2 },
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isSide ? 'rgba(0,120,212,0.85)' : 'rgba(167,139,250,0.85)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
}),
|
||||
id: e.id,
|
||||
source: e.sourceGraphNodeId,
|
||||
target: e.targetGraphNodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
selectable: false,
|
||||
};
|
||||
});
|
||||
}, [sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
@@ -485,12 +551,22 @@ function SceneGraphCanvas({
|
||||
if (!menu) return null;
|
||||
const pad = 8;
|
||||
const mw = 220;
|
||||
const mh = 120;
|
||||
const mh = 210;
|
||||
const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad));
|
||||
const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad));
|
||||
return { x, y };
|
||||
}, [menu]);
|
||||
|
||||
const edgeMenuPosition = useMemo(() => {
|
||||
if (!edgeMenu) return null;
|
||||
const pad = 8;
|
||||
const mw = 200;
|
||||
const mh = 48;
|
||||
const x = Math.max(pad, Math.min(edgeMenu.x, window.innerWidth - mw - pad));
|
||||
const y = Math.max(pad, Math.min(edgeMenu.y, window.innerHeight - mh - pad));
|
||||
return { x, y };
|
||||
}, [edgeMenu]);
|
||||
|
||||
return (
|
||||
<GraphUiContext.Provider value={ui}>
|
||||
<div className={styles.canvasWrap}>
|
||||
@@ -504,33 +580,35 @@ function SceneGraphCanvas({
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
isValidConnection={isValidConnection}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
onConnect={onConnectInternal}
|
||||
onEdgesDelete={(eds) => {
|
||||
for (const ed of eds) {
|
||||
onDisconnect(ed.id);
|
||||
}
|
||||
}}
|
||||
onEdgeClick={(_, edge) => {
|
||||
onDisconnect(edge.id);
|
||||
onEdgeContextMenu={(e, edge) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
setEdgeMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
|
||||
}}
|
||||
onNodesDelete={(nds) => {
|
||||
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
|
||||
}}
|
||||
onNodeClick={(_, node) => {
|
||||
setMenu(null);
|
||||
setEdgeMenu(null);
|
||||
const d = node.data as SceneCardData;
|
||||
onCurrentSceneChange(d.sceneId);
|
||||
onGraphNodeSelect(node.id as GraphNodeId, d.sceneId);
|
||||
}}
|
||||
onNodeContextMenu={(e, node) => {
|
||||
e.preventDefault();
|
||||
setEdgeMenu(null);
|
||||
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
setMenu(null);
|
||||
setEdgeMenu(null);
|
||||
}}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
setEdgeMenu(null);
|
||||
}}
|
||||
onInit={(instance) => {
|
||||
instance.fitView({ padding: 0.25 });
|
||||
@@ -539,6 +617,7 @@ function SceneGraphCanvas({
|
||||
onDrop={onDrop}
|
||||
panOnScroll
|
||||
selectionOnDrag={false}
|
||||
minZoom={0.1}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
@@ -579,6 +658,33 @@ function SceneGraphCanvas({
|
||||
>
|
||||
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
|
||||
</button>
|
||||
{menuNodeIsSideStoryStart || menuCanSetSideStoryStart ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
onClick={() => {
|
||||
onSetGraphNodeSideStoryStart(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{menuNodeIsSideStoryStart ? ui.unsetSideStoryStartScene : ui.sideStoryStartScene}
|
||||
</button>
|
||||
) : null}
|
||||
{!menuNodeIsSideBranch ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
disabled={!onRunFromGraphNode}
|
||||
onClick={() => {
|
||||
onRunFromGraphNode?.(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{ui.runFromScene}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -595,6 +701,41 @@ function SceneGraphCanvas({
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{edgeMenu && edgeMenuPosition
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ui.closeMenu}
|
||||
className={styles.menuBackdrop}
|
||||
onClick={() => setEdgeMenu(null)}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: edgeMenuPosition.x, top: edgeMenuPosition.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setEdgeMenu(null);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItemDanger}
|
||||
onClick={() => {
|
||||
onDisconnect(edgeMenu.edgeId);
|
||||
setEdgeMenu(null);
|
||||
}}
|
||||
>
|
||||
{ui.delete}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</GraphUiContext.Provider>
|
||||
);
|
||||
|
||||
@@ -19,8 +19,12 @@ function minimalProject(overrides: Partial<Project>): Project {
|
||||
schemaVersion: 1 as unknown as Project['meta']['schemaVersion'],
|
||||
},
|
||||
scenes: {},
|
||||
sceneListOrder: [],
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function readSceneGraph(): string {
|
||||
return fs.readFileSync(path.join(here, 'SceneGraph.tsx'), 'utf8');
|
||||
}
|
||||
|
||||
void test('SceneGraph: контекстное меню узла — «Запустить с этой сцены»', () => {
|
||||
const src = readSceneGraph();
|
||||
assert.ok(src.includes('runFromScene'));
|
||||
assert.ok(src.includes('onRunFromGraphNode'));
|
||||
assert.ok(src.includes('onRunFromGraphNode?.(menu.graphNodeId)'));
|
||||
});
|
||||
|
||||
void test('SceneGraph: побочная линия — меню старта и скрытие Run', () => {
|
||||
const src = readSceneGraph();
|
||||
assert.ok(src.includes('sideStoryStartScene'));
|
||||
assert.ok(src.includes('onSetGraphNodeSideStoryStart'));
|
||||
assert.ok(src.includes('menuNodeIsSideBranch'));
|
||||
assert.ok(src.includes('badgeSideStory'));
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { HelpSectionId } from './helpSections';
|
||||
import { buildHelpLinkCatalog, findHelpLinkRanges, splitHelpTextWithLinks } from './helpLinkify';
|
||||
|
||||
const RU_TITLES: Record<HelpSectionId, string> = {
|
||||
overview: 'Обзор приложения',
|
||||
license: 'Лицензия и первый запуск',
|
||||
projects: 'Проекты',
|
||||
scenes: 'Сцены',
|
||||
graph: 'Граф сцен',
|
||||
sideStorylines: 'Побочные сюжетные линии',
|
||||
sceneProps: 'Свойства сцены',
|
||||
sceneEditor: 'Редактор сцены',
|
||||
grid: 'Генератор сетки',
|
||||
traps: 'Ловушки',
|
||||
tokens: 'Неигровые токены',
|
||||
campaignAudio: 'Аудио игры',
|
||||
materials: 'Материалы',
|
||||
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,115 @@
|
||||
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: ['Пульт'],
|
||||
};
|
||||
|
||||
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,35 @@
|
||||
/** Порядок разделов в окне «Инструкция». */
|
||||
export const HELP_SECTION_IDS = [
|
||||
'overview',
|
||||
'license',
|
||||
'projects',
|
||||
'scenes',
|
||||
'graph',
|
||||
'sideStorylines',
|
||||
'sceneProps',
|
||||
'sceneEditor',
|
||||
'grid',
|
||||
'traps',
|
||||
'tokens',
|
||||
'campaignAudio',
|
||||
'materials',
|
||||
'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 {
|
||||
EDITOR_LOCALE_STORAGE_KEY,
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
translateEditorMessage,
|
||||
type EditorLocale,
|
||||
} from './editorMessages';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
|
||||
type EditorI18nContextValue = {
|
||||
locale: EditorLocale;
|
||||
@@ -36,6 +38,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(
|
||||
(key: string, vars?: Record<string, string | number>) => translateEditorMessage(locale, key, vars),
|
||||
[locale],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
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', () => {
|
||||
assert.equal(inferEditorLocaleFromSystem(['en-GB', 'ru-RU']), 'en');
|
||||
@@ -24,7 +26,45 @@ void test('normalizeEditorLocale: trims stored en/ru', () => {
|
||||
assert.equal(normalizeEditorLocale('ru '), 'ru');
|
||||
});
|
||||
|
||||
void test('normalizeEditorLocale: blank or invalid defers to infer (explicit list)', () => {
|
||||
assert.equal(normalizeEditorLocale(''), inferEditorLocaleFromSystem([]));
|
||||
assert.equal(normalizeEditorLocale('xx'), inferEditorLocaleFromSystem([]));
|
||||
void test('normalizeEditorLocale: blank or invalid defers to system infer', () => {
|
||||
const inferred = 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(), '');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,12 +54,16 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'common.close': 'Закрыть',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.save': 'Сохранить',
|
||||
'common.saving': 'Сохранение…',
|
||||
'common.edit': 'Редактировать',
|
||||
'common.understood': 'Понятно',
|
||||
'common.message': 'Сообщение',
|
||||
'common.error': 'Ошибка',
|
||||
'common.delete': 'Удалить',
|
||||
'common.closeMenu': 'Закрыть меню',
|
||||
|
||||
'app.brandTitle': 'НРИ Плеер',
|
||||
|
||||
'notice.campaignAudioEmpty': 'Аудио не добавлено. Проверьте формат файла.',
|
||||
|
||||
'license.checkingTitle': 'Проверка лицензии…',
|
||||
@@ -69,7 +73,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'Укажите ключ в меню «Настройки» → «Указать ключ». До активации доступно только меню «Настройки».',
|
||||
'license.tokenTitle': 'Указать ключ',
|
||||
'license.tokenKey': 'КЛЮЧ',
|
||||
'license.tokenPlaceholder': 'Продуктовый ключ DND-...',
|
||||
'license.tokenPlaceholder': 'Продуктовый ключ TTRPG-... или DND-...',
|
||||
'license.tokenSaving': 'Сохранение…',
|
||||
'license.eulaTitle': 'Лицензионное соглашение',
|
||||
'license.eulaReject': 'Не принимаю',
|
||||
@@ -109,8 +113,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'top.backToProjects': 'К списку проектов',
|
||||
'top.appVersion': 'Версия приложения',
|
||||
'top.run': 'Запустить',
|
||||
'top.launching': 'Запуск…',
|
||||
'top.afterLicense': 'Доступно после активации лицензии',
|
||||
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
|
||||
'top.runHelpAria': 'Как разблокировать кнопку «Запустить»',
|
||||
|
||||
'menu.enterKey': 'Указать ключ',
|
||||
'menu.aboutLicense': 'О лицензии',
|
||||
@@ -118,6 +124,114 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'menu.language': 'Язык',
|
||||
'menu.langRu': 'Русский',
|
||||
'menu.langEn': 'English',
|
||||
'menu.aboutProgram': 'О программе',
|
||||
'menu.instructions': 'Инструкция',
|
||||
|
||||
'top.aboutApp': 'О приложении',
|
||||
|
||||
'app.about.title': 'О программе',
|
||||
'app.about.tagline': 'Редактор кампаний и пульт мастера для настольных ролевых игр',
|
||||
'app.about.description':
|
||||
'TTRPG Player (НРИ Плеер) — настольное приложение для мастера: вы собираете кампанию как граф сцен с картами, музыкой и ветвлениями, а во время сессии управляете атмосферой с пульта. Игроки видят только презентацию на втором экране — без панелей редактора.',
|
||||
'app.about.versionLabel': 'ВЕРСИЯ',
|
||||
'app.about.developerLabel': 'РАЗРАБОТЧИК',
|
||||
'app.about.developer':
|
||||
'Независимая разработка. По вопросам лицензии, покупки и поддержки — электронная почта ниже.',
|
||||
'app.about.supportLabel': 'ПОДДЕРЖКА',
|
||||
'app.about.supportEmail': 'player.ttrpg@gmail.com',
|
||||
'app.about.websiteLabel': 'САЙТ И ОБНОВЛЕНИЯ',
|
||||
'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
|
||||
|
||||
'help.title': 'Инструкция',
|
||||
'help.navAria': 'Разделы инструкции',
|
||||
|
||||
'help.section.overview.title': 'Обзор приложения',
|
||||
'help.section.overview.body':
|
||||
'При запуске вы попадаете в Редактор — здесь готовите кампанию: сцены, картинки, музыку и связи между эпизодами. Когда придёт время играть, нажмите «Запустить» — откроются «Презентация» для игроков и «Пульт управления» для вас.\n\nВ редакторе слева — список сцен, по центру — карта связей, справа — настройки игры и выбранной сцены.\n\nПока идёт показ, редактор временно нельзя менять — это нормально. Закройте презентацию и пульт, чтобы снова редактировать кампанию.\n\nИнтернет нужен только для активации лицензии и проверки обновлений. Все проекты и файлы хранятся на вашем компьютере.',
|
||||
|
||||
'help.section.license.title': 'Лицензия и первый запуск',
|
||||
'help.section.license.body':
|
||||
'Перед работой с проектами один раз активируйте лицензию.\n\n1) Откройте «Настройки» → «Указать ключ».\n\n2) Если появится лицензионное соглашение — прочитайте и примите его.\n\n3) Вставьте ключ из письма (формат TTRPG-… или старый DND-…) и нажмите «Сохранить».\n\nДо активации доступны только настройки. После успешного сохранения ключа откроются проекты, сцены и запуск сессии.\n\nСтатус, срок действия и привязка к компьютеру — в «Настройки» → «О лицензии». Ключ привязан к этому ПК; на другом компьютере понадобится отдельная активация по условиям покупки.',
|
||||
|
||||
'help.section.projects.title': 'Проекты',
|
||||
'help.section.projects.body':
|
||||
'Проект — вся кампания целиком: сцены, медиа и связи между ними.\n\nСоздать новую кампанию:\n\n1) На начальном экране введите название в поле слева.\n\n2) Нажмите «Создать проект».\n\nОткрыть существующую — кликните по названию в списке. Вернуться к списку: «Проект» → «Начальный экран» или клик по названию приложения в шапке.\n\nПеренести кампанию на другой компьютер:\n\n1) «Проект» → «Экспорт» — сохраните копию как .ttrpg.zip.\n\n2) На другом ПК — «Проект» → «Импорт» и выберите этот файл.\n\nПереименовать открытый проект: «Файл» → «Переименовать проект» (минимум 3 символа; в имени файла нельзя использовать <>:"/\\|?*).\n\nУдалить проект с диска:\n\n1) На карточке проекта нажмите «⋮».\n\n2) В меню выберите «Удалить».\n\n3) Подтвердите удаление в диалоге.\n\nПосле этого файл проекта и кэш будут стёрты без восстановления. Если кампания может понадобиться снова — сначала сделайте экспорт.',
|
||||
|
||||
'help.section.scenes.title': 'Сцены',
|
||||
'help.section.scenes.body':
|
||||
'Сцена — отдельный эпизод: локация, кадр истории, диалог. У неё есть название, картинка или видео для игроков, описание для мастера и своя музыка.\n\nДобавить сцену:\n\n1) В левой колонке нажмите «+ Новая сцена».\n\n2) Задайте название и настройте свойства справа (см. «Свойства сцены»).\n\n«Поиск сцен…» помогает быстро найти нужную. Клик по карточке выделяет сцену — она же подсветится на карте связей.\n\nУдалить: правый клик по карточке в списке → «Удалить». Сцена исчезнет из списка и с карты вместе со связями.\n\nПеретащите сцену из списка на карту — так она появится как узел (подробнее в «Граф сцен»).',
|
||||
|
||||
'help.section.graph.title': 'Граф сцен',
|
||||
'help.section.graph.body':
|
||||
'Карта в центре показывает, как эпизоды связаны. Каждый прямоугольник — место на схеме; одна сцена может встретиться несколько раз (например, игроки снова возвращаются в таверну).\n\nДобавить сцену на карту:\n\n1) Возьмите сцену в левом списке.\n\n2) Перетащите на свободное место на карте.\n\nСделать переход между сценами:\n\n1) Наведите на нижнюю точку первой карточки.\n\n2) Потяните линию к верхней точке второй и отпустите — появится стрелка.\n\nИз одной сцены может выходить несколько стрелок — так вы делаете ветвление сюжета. Вторую стрелку к той же паре сцен провести нельзя.\n\nУдалить стрелку: правый клик по линии → «Удалить». Обычный клик по линии ничего не делает.\n\nС чего начинается игра: правый клик по карточке → «Начальная сцена» (появится метка «НАЧАЛО»), затем «Запустить» в шапке. Можно начать с любой карточки основного сюжета: ПКМ → «Запустить с этой сцены» — откроются презентация и пульт с выбранного места (метку «НАЧАЛО» ставить не обязательно). Для карточек побочных линий этот пункт недоступен.\n\nУбрать карточку с карты, не удаляя сцену из списка: ПКМ → «Удалить».\n\nМасштаб — кнопки внизу или колёсико мыши. «Показать всё» вместит всю схему на экран.',
|
||||
|
||||
'help.section.sideStorylines.title': 'Побочные сюжетные линии',
|
||||
'help.section.sideStorylines.body':
|
||||
'Побочная линия — отдельная ветка сюжета, не связанная с основным сюжетом. Она нужна для ответвлений, флешбэков, побочных квестов и сцен «вне основного пути».\n\nСоздать побочную линию в редакторе:\n\n1) Добавьте сцены на карту и соедините их стрелками в отдельной группе — она не должна касаться основного сюжета (фиолетовые связи) и других побочных линий.\n\n2) Правый клик по стартовой карточке побочной ветки → «Начальная сцена побочной линии». Появится синяя метка «ПОБОЧНАЯ».\n\n3) В свойствах сцены задайте «Название побочной линии» — оно будет видно на пульте.\n\nПункт «Начальная сцена побочной линии» скрыт, если карточка уже связана с основным сюжетом (где есть фиолетовое «НАЧАЛО») или с другой побочной линией (где есть синее «ПОБОЧНАЯ»).\n\nСвязи внутри побочной линии и выделение её карточек — синего цвета (#0078d4). Между основным и побочным сюжетом, а также между разными побочными линиями, стрелки провести нельзя.\n\nСнять метку: ПКМ → «Снять метку «Начальная сцена побочной линии»». Название очистится, плитка исчезнет с пульта.\n\nУдалить стартовую карточку: если есть следующая сцена по стрелке — метка переносится на неё; если нет — вся побочная линия удаляется с карты.\n\nВо время игры на пульте под блоком «Музыка» появляется «Побочные сюжетные линии» — плитки с превью и названием. Клик переносит партию на первую сцену линии. Программа запоминает, с какой сцены основного сюжета вы ушли.\n\nПока идёт побочная линия, в «Варианты ветвления» первой опцией всегда «Вернуться в основной сюжет» — возврат на запомненную сцену. История «Сюжетная линия» продолжает записывать все шаги, включая побочную ветку.\n\nЗапустить побочную линию из редактора нельзя — только с пульта во время сессии.',
|
||||
|
||||
'help.section.sceneProps.title': 'Свойства сцены',
|
||||
'help.section.sceneProps.body':
|
||||
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «Сюжетная линия».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки можно включить «Затемнить сцену»: при показе игроки сначала увидят карту в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Для картинки доступна кнопка «Редактор сцены» — сетка, ловушки и неигровые токены на карте (см. «Редактор сцены», «Генератор сетки», «Ловушки», «Неигровые токены»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков. На видео-сценах эффекты кистью и редактор сцены недоступны.\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
|
||||
|
||||
'help.section.sceneEditor.title': 'Редактор сцены',
|
||||
'help.section.sceneEditor.body':
|
||||
'«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки и неигровые токены. Доступен только для сцен с изображением (не с видео).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку, если её ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены» и «Ловушки»; справа — карта сцены.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».',
|
||||
|
||||
'help.section.grid.title': 'Генератор сетки',
|
||||
'help.section.grid.body':
|
||||
'Генератор сетки накладывает на картинку сцены боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» для сцены с изображением (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nНастройки сетки хранятся в проекте вместе со сценой. На видео-сценах генератор недоступен — только на картинках. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.',
|
||||
|
||||
'help.section.traps.title': 'Ловушки',
|
||||
'help.section.traps.body':
|
||||
'Ловушки — маркеры на карте сцены для скрытых угроз и сюрпризов. Расстановка хранится в проекте вместе со сценой; во время игры вы решаете, когда их показать игрокам.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Ловушки» слева.\n\n3) Перетащите тип из палитры на нужное место карты.\n\n4) Перетащите маркер, чтобы сдвинуть его; потяните за уголок выделенного маркера — изменить размер.\n\n5) Delete / Backspace — убрать выделенную ловушку. «Очистить сцену» снимает все маркеры сразу.\n\nТипы: Мимик, Взрыв, Яд, Пропасть, Стрела, Лазер и Метка (универсальный маркер без особого эффекта).\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» видны все ловушки текущей сцены. Пока они скрыты от игроков, маркеры у вас слегка приглушены.\n\n2) На презентации маркеры появляются только после проявления или срабатывания.\n\n3) Правый клик по маркеру на пульте:\n• «Проявить» — показать игрокам без срабатывания.\n• «Активировать» — проявить и запустить эффект: у Мимика, Пропасти, Стрелы и Лазера — анимация и звук; у Яда и Взрыва — как соответствующие эффекты с пульта (облако яда / взрыв); у Метки — короткая вспышка.\n• «Обезвредить» — показать как обезвреженную.\n\nСостояние ловушек (проявлены / сработали / обезврежены) сбрасывается при новом запуске сессии. Сами маркеры на карте остаются.',
|
||||
|
||||
'help.section.tokens.title': 'Неигровые токены',
|
||||
'help.section.tokens.body':
|
||||
'Неигровые токены — картинки существ, предметов и маркеров, которые вы ставите на карту сцены. Библиотека токенов хранится в приложении на этом компьютере (не внутри файла проекта). На сцене сохраняется только расстановка: какой токен, где стоит, размер и поворот.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой (см. «Редактор сцены»).\n\n2) Раскройте «Неигровые токены».\n\n3) «Добавить» — задайте уникальное название и изображение (кнопка выбора или перетаскивание файла).\n\n4) В поиске можно быстро найти токен по имени.\n\n5) Меню «⋮» у плитки — «Изменить» или «Удалить» (с подтверждением). Удаление из пула также убирает этот токен с текущей сцены.\n\n6) Перетащите плитку на карту, чтобы поставить токен. Выделите маркер: перетаскивание — сдвиг, уголок — размер, ручка поворота — угол. Delete / Backspace или ПКМ по маркеру — убрать с карты. «Очистить сцену» снимает все токены и ловушки со сцены.\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» токены видны сразу (в отличие от ловушек их не нужно проявлять).\n\n2) Перетаскивайте токены левой кнопкой — новая позиция запоминается до конца текущей сессии, в том числе если вы возвращаетесь к сцене через «Сюжетную линию». При новом «Запустить» позиции снова берутся из редактора.\n\n3) На экране презентации токены только отображаются: клики и перетаскивание для игроков недоступны.\n\nПри экспорте и импорте сюжетных линий нужные файлы токенов упаковываются вместе с линией, чтобы на другом компьютере расстановка не «теряла» картинки.',
|
||||
|
||||
'help.section.campaignAudio.title': 'Аудио игры',
|
||||
'help.section.campaignAudio.body':
|
||||
'«Аудио игры» в блоке «Свойства игры» — музыка всей кампании: тема, фон, атмосфера. Она не привязана к одной сцене.\n\n1) Нажмите «Загрузить» и выберите файлы.\n\n2) Для каждого трека отметьте «Авто» и «Цикл» по желанию.\n\n3) Удалить трек — иконка корзины.\n\nНа пульте музыка сцены важнее общей: пока играет трек сцены, кампанийная музыка приглушается. Когда у сцены нет своего звука или вы переключитесь вручную — общая музыка снова может играть.',
|
||||
|
||||
'help.section.materials.title': 'Материалы',
|
||||
'help.section.materials.body':
|
||||
'Материалы — изображения кампании (карты, записки, чертежи), которые можно показать игрокам поверх сцены во время игры. Они общие для проекта и не привязаны к одной сцене.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «Материалы».\n\n2) «Добавить» — укажите уникальное название и изображение (PNG, JPG или WebP): кнопка выбора или перетаскивание файла.\n\n3) В списке можно искать, менять порядок перетаскиванием, править или удалять через меню «⋮» (перед удалением будет подтверждение).\n\n4) Под большим превью — «Повернуть»: поворот на 90° (учитывается и в плитке, и при показе на экране).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» нажмите кнопку материалов (иконка карты сокровищ) — откроется отдельное окно со списком.\n\n2) Клик по плитке показывает материал поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его.\n\n3) На предпросмотре пульта материал можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне материалов лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по материалу в предпросмотре пульта.\n\nПри смене сцены показ материала сбрасывается. Описание сцены и эффекты поля с материалами не связаны.',
|
||||
|
||||
'help.section.npcs.title': 'НПС',
|
||||
'help.section.npcs.body':
|
||||
'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются).\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи. Связь однонаправленная (А → Б и Б → А — разные). Несколько связей в одном направлении рисуются параллельными дугами. Клик по связи или её подписи выбирает исходного персонажа и подсвечивает его исходящие связи.\n\n5) Справа — карточка выбранного персонажа: аватар, имя, описание (форматированный текст) и список «Отношения» — только исходящие связи («название» + имя цели).\n\n6) Правый клик по связи на графе — «Редактировать» название или «Удалить» (с подтверждением).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» рядом с материалами нажмите кнопку НПС (цветная иконка человека) — откроется отдельное окно.\n\n2) Справа — список персонажей; клик по плитке показывает аватар поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его. Слева — описание и исходящие отношения выбранного персонажа (их видите только вы).\n\n3) На предпросмотре пульта аватар можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне НПС лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по аватару в предпросмотре пульта.\n\nПри смене сцены показ НПС сбрасывается. Игроки на презентации видят только аватар.',
|
||||
|
||||
'help.section.session.title': 'Запуск сессии',
|
||||
'help.section.session.body':
|
||||
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить демонстрацию» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
|
||||
|
||||
'help.section.controlPanel.title': 'Пульт управления',
|
||||
'help.section.controlPanel.body':
|
||||
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы». Кнопка НПС (цветная иконка человека) открывает окно персонажей — см. раздел «НПС».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. На сценах с картинкой здесь же рисуют эффекты — они сразу появляются на большом экране. Если на сцене расставлены ловушки, они видны на предпросмотре: правый клик по маркеру — проявить, активировать или обезвредить (см. «Ловушки»). Неигровые токены тоже видны на предпросмотре и их можно двигать до конца сессии (см. «Неигровые токены»).\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
|
||||
|
||||
'help.section.transitions.title': 'Переходы между сценами',
|
||||
'help.section.transitions.body':
|
||||
'Куда можно пойти дальше — видно на пульте в «Варианты ветвления». Это исходящие стрелки с текущей карточки на карте.\n\n1) Посмотрите карточки «ОПЦИЯ 1», «ОПЦИЯ 2» — там названия целевых сцен.\n\n2) Нажмите «Переключить» у нужного варианта.\n\n3) На презентации сменятся картинка и музыка, в сюжетной линии появится новый шаг.\n\nЕсли вариантов нет (конец ветки) — «Нет вариантов перехода». Нажмите «Завершить показ», чтобы закрыть окна.\n\nВарианты появляются только там, где вы провели стрелки на карте в редакторе.',
|
||||
|
||||
'help.section.music.title': 'Музыка на пульте',
|
||||
'help.section.music.body':
|
||||
'Блок «Музыка» повторяет треки из редактора — отдельно «Музыка сцены» и «Музыка игры».\n\n▶ — воспроизвести, ⏸ — пауза, ⏹ — остановить. «Авто»/«Ручн.» и «Цикл»/«Один раз» показывают, как трек настроен в редакторе.\n\nКлик по полоске прогресса — перемотка (если известна длина). Стрелки ← → на полоске (когда она в фокусе) — на 5 секунд назад или вперёд.\n\nЕсли музыка не стартует сама — один раз нажмите ▶: после вашего действия звук обычно разрешается. Не играет файл — проверьте формат (MP3, WAV и т.п.).',
|
||||
|
||||
'help.section.effects.title': 'Эффекты поля и действий',
|
||||
'help.section.effects.body':
|
||||
'Эффекты работают на сценах с картинкой, не на видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте.\n• Эффекты действий (молния, луч света, заморозка, тьма, облако яда, взрыв) — короткий клик или штрих; у некоторых есть звук.\n\nЕсли у сцены в свойствах включено «Затемнить сцену», появится блок «Управление затемнением» с «Кистью Открытия» 🔦 и «Кистью Закрытия» ⬛. «Кисть Открытия» снимает тьму на обоих экранах сразу; «Кисть Закрытия» снова накрывает уже открытые участки. У игроков нераскрытое остаётся чёрным, у вас на пульте — полузатемнённым. Состояние сохраняется, пока идёт показ и вы снова попадаете на ту же карточку сцены на карте. Это не то же самое, что эффект «Тьма» 🌑 в блоке действий.\n\nЛастик 🧹 — для эффектов поля (туман, дождь, огонь, вода) водите кистью, как «Кистью Открытия» для затемнения: стирается только пройденный участок. Эффекты действий (молния, луч и т.д.) убираются целиком при клике или проведении по ним. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.',
|
||||
|
||||
'help.section.presentation.title': 'Экран презентации',
|
||||
'help.section.presentation.body':
|
||||
'«Презентация» — то, что видят игроки: картинка сцены (с учётом поворота из редактора) или видео по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
|
||||
|
||||
'help.section.importExport.title': 'Импорт и экспорт',
|
||||
'help.section.importExport.body':
|
||||
'Проект хранится как файл .ttrpg.zip — внутри уже все картинки, звуки и настройки.\n\nРезервная копия или перенос на другой ПК:\n\n1) «Проект» → «Экспорт».\n\n2) Выберите проект и куда сохранить.\n\n3) Скопируйте .ttrpg.zip на флешку, в облако или на другой компьютер.\n\nЗагрузить бэкап:\n\n1) «Проект» → «Импорт».\n\n2) Выберите .ttrpg.zip.\n\n3) Проект появится в списке на начальном экране.\n\nПри больших архивах показывается прогресс. Картинки и музыку отдельно переносить не нужно — всё внутри архива.',
|
||||
|
||||
'help.section.settings.title': 'Настройки, язык и обновления',
|
||||
'help.section.settings.body':
|
||||
'«Настройки» → «Указать ключ» — ввести или сменить лицензию. «О лицензии» — проверить статус и срок.\n\n«Проверить обновления» (в установленной версии при активной лицензии) — если есть новая версия, можно скачать и перезапустить программу.\n\n«Язык» → «Русский» или «English» — меняет язык интерфейса. Выбор сохраняется между запусками.\n\nНомер версии — в шапке справа от меню. «О приложении» → «О программе» — о продукте и контактах поддержки.',
|
||||
|
||||
'updates.dialogTitle': 'Обновления',
|
||||
'updates.checking': 'Проверка наличия обновлений…',
|
||||
@@ -128,18 +242,47 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'updates.noLicense': 'Нужна активная лицензия.',
|
||||
'updates.download': 'Обновить',
|
||||
'updates.downloading': 'Загрузка…',
|
||||
'updates.stageLine': 'Этап: {stage}',
|
||||
'updates.stage.checking': 'проверка обновлений',
|
||||
'updates.stage.available': 'доступна версия {version}',
|
||||
'updates.stage.not-available': 'актуальная версия',
|
||||
'updates.stage.downloading': 'загрузка{percent}',
|
||||
'updates.stage.installing': 'установка и перезапуск',
|
||||
'updates.stage.error': 'ошибка',
|
||||
'updates.stagePercent': ' ({percent}%)',
|
||||
|
||||
'projectMenu.home': 'Начальный экран',
|
||||
'projectMenu.import': 'Импорт',
|
||||
'projectMenu.importFoundry': 'Импорт из Foundry',
|
||||
'projectMenu.export': 'Экспорт',
|
||||
'projectMenu.noProjects': 'Нет сохранённых проектов',
|
||||
|
||||
'foundryImport.title': 'Импорт из Foundry',
|
||||
'foundryImport.hint':
|
||||
'Выберите папку или архив мира (.world) либо модуля Foundry VTT (версии 11+). Будет создан новый проект.',
|
||||
'foundryImport.sourceType': 'ТИП ИСТОЧНИКА',
|
||||
'foundryImport.folder': 'Папка',
|
||||
'foundryImport.archive': 'Архив (.zip / .fvtt)',
|
||||
'foundryImport.source': 'ИСТОЧНИК',
|
||||
'foundryImport.chooseFolder': 'Выбрать папку',
|
||||
'foundryImport.chooseArchive': 'Выбрать архив',
|
||||
'foundryImport.noSourceSelected': 'Не выбрано',
|
||||
'foundryImport.import': 'Импортировать',
|
||||
|
||||
'fileMenu.rename': 'Переименовать проект',
|
||||
|
||||
'scenes.search': 'Поиск сцен…',
|
||||
'scenes.new': '+ Новая сцена',
|
||||
'scenes.dropHint': 'Перетащите изображения или видео',
|
||||
'scenes.batchTitle': 'Создание сцен',
|
||||
'scenes.batchProgress': 'Сцена {current} из {total}',
|
||||
'scenes.dropSkippedTitle': 'Часть файлов не добавлена',
|
||||
'scenes.dropSkippedIntro': 'Эти файлы пропущены:',
|
||||
'scenes.dropSkippedUnsupported': 'неподдерживаемый формат',
|
||||
'scenes.dropSkippedNoPath': 'не удалось получить путь к файлу',
|
||||
'scenes.inspectorGame': 'Свойства игры',
|
||||
'scenes.inspectorScene': 'Свойства сцены',
|
||||
'scenes.projectLabel': 'Проект: {name}',
|
||||
'scenes.selectHint': 'Выберите сцену слева, чтобы редактировать её свойства.',
|
||||
'scenes.openProjectHint': 'Откройте проект, чтобы редактировать кампанию и сцены.',
|
||||
|
||||
@@ -156,23 +299,78 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'export.title': 'Экспорт проекта',
|
||||
'export.project': 'ПРОЕКТ',
|
||||
'export.hint':
|
||||
'Далее откроется окно сохранения: укажите имя и папку для файла .dnd.zip — будет создана копия архива проекта.',
|
||||
'Выберите сюжетные линии для экспорта. В архив попадут выбранные линии, их сцены и материалы. Если в проекте есть НПС, на следующем шаге можно отметить, кого включить.',
|
||||
'export.npcsTitle': 'Экспорт НПС',
|
||||
'export.npcsHint':
|
||||
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
|
||||
'export.selectAllNpcs': 'Отметить всех',
|
||||
'export.next': 'Далее',
|
||||
'export.back': 'Назад',
|
||||
'export.exporting': 'Экспорт…',
|
||||
'export.saveAs': 'Сохранить как…',
|
||||
|
||||
'storyline.section': 'СЮЖЕТНАЯ ЛИНИЯ',
|
||||
'storyline.main': 'Основная линия',
|
||||
'storyline.loading': 'Загрузка линий…',
|
||||
'storyline.empty': 'В проекте нет сюжетных линий с метками «НАЧАЛО» или «ПОБОЧНАЯ».',
|
||||
'storyline.mainExistsHint': 'в проекте уже есть основная линия',
|
||||
|
||||
'importSource.title': 'Импорт',
|
||||
'importSource.type': 'ТИП ИМПОРТА',
|
||||
'importSource.fromProject': 'Из проекта',
|
||||
'importSource.fromFile': 'Из файла',
|
||||
'importSource.project': 'ПРОЕКТ',
|
||||
'importSource.file': 'ФАЙЛ',
|
||||
'importSource.chooseFile': 'Выбрать файл',
|
||||
'importSource.noFileSelected': 'Файл не выбран',
|
||||
'importSource.noOtherProjects': 'Нет других проектов для импорта.',
|
||||
'importSource.fileOnlyHint': 'Выберите файл проекта (.ttrpg.zip) для полного импорта.',
|
||||
|
||||
'importStoryline.title': 'Импорт сюжетных линий',
|
||||
'importStoryline.source': 'ИСТОЧНИК',
|
||||
'importStoryline.continue': 'Далее',
|
||||
'importStoryline.import': 'Импортировать',
|
||||
'importStoryline.conflictsTitle': 'Совпадение названий сцен',
|
||||
'importStoryline.conflictsHint':
|
||||
'В импортируемых линиях есть сцены с такими же названиями, как в текущем проекте. Выберите действие для каждой.',
|
||||
'importStoryline.createNewScene': 'Создать новую сцену',
|
||||
'importStoryline.useExistingScene': 'Использовать «{title}»',
|
||||
'importStoryline.reportTitle': 'Импорт завершён',
|
||||
'importStoryline.reportLines': 'Импортировано линий: {count}',
|
||||
'importStoryline.reportScenesCreated': 'Создано новых сцен: {count}',
|
||||
'importStoryline.reportScenesReused': 'Использовано существующих сцен: {count}',
|
||||
'importStoryline.reportNpcsCreated': 'Создано новых НПС: {count}',
|
||||
'importStoryline.reportNpcsReused': 'Использовано существующих НПС: {count}',
|
||||
'importStoryline.reportNodes': 'Добавлено карточек на граф: {count}',
|
||||
'importStoryline.reportEdges': 'Добавлено связей: {count}',
|
||||
'importStoryline.reportAssetsCopied': 'Скопировано файлов материалов: {count}',
|
||||
'importStoryline.reportAssetsReused': 'Повторно использовано материалов: {count}',
|
||||
'importStoryline.reportRenamedSides': 'Переименованы побочные линии: {names}',
|
||||
'importStoryline.npcConflictsTitle': 'Совпадение имён НПС',
|
||||
'importStoryline.npcConflictsHint':
|
||||
'В импортируемых линиях есть НПС с такими же именами, как в текущем проекте. Выберите действие для каждого.',
|
||||
'importStoryline.createNewNpc': 'Создать нового НПС',
|
||||
'importStoryline.useExistingNpc': 'Использовать «{name}»',
|
||||
|
||||
'confirmDelete.title': 'Удаление проекта',
|
||||
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
|
||||
'confirmDelete.failedTitle': 'Не удалось удалить',
|
||||
|
||||
'confirmDeleteScene.title': 'Удаление сцены',
|
||||
'confirmDeleteScene.body': 'Удалить сцену «{name}»? Её нельзя будет восстановить.',
|
||||
|
||||
'picker.title': 'Проекты',
|
||||
'picker.newPlaceholder': 'Название нового проекта…',
|
||||
'picker.create': 'Создать проект',
|
||||
'picker.search': 'Поиск кампаний…',
|
||||
'picker.searchEmpty': 'Ничего не найдено.',
|
||||
'picker.existing': 'СУЩЕСТВУЮЩИЕ',
|
||||
'picker.lockedHint':
|
||||
'Открытие и создание — после активации лицензии. Список показывает файлы в папке приложения.',
|
||||
'picker.empty': 'Пока нет проектов.',
|
||||
'picker.projectMenu': 'Меню проекта',
|
||||
'picker.openDisabled': 'Открытие проекта — после активации лицензии',
|
||||
'picker.opening': 'Открытие…',
|
||||
'picker.defaultName': 'Моя кампания',
|
||||
|
||||
'campaign.label': 'АУДИО ИГРЫ',
|
||||
@@ -181,16 +379,141 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'campaign.loop': 'Цикл',
|
||||
'campaign.removeTitle': 'Убрать из кампании',
|
||||
'campaign.upload': 'Загрузить',
|
||||
'drop.hintAudio': 'Перетащите аудиофайлы сюда',
|
||||
'drop.hintPreview': 'Перетащите изображение или видео',
|
||||
|
||||
'materials.open': 'Материалы',
|
||||
'materials.managerTitle': 'Материалы',
|
||||
'materials.add': 'Добавить',
|
||||
'materials.addTitle': 'Новый материал',
|
||||
'materials.editTitle': 'Изменить материал',
|
||||
'materials.savingTitle': 'Сохранение материала',
|
||||
'materials.savingWait': 'Подождите…',
|
||||
'materials.savingProgress': 'Прогресс сохранения материала',
|
||||
'materials.edit': 'Изменить',
|
||||
'materials.search': 'Поиск материалов…',
|
||||
'materials.searchEmpty': 'Ничего не найдено.',
|
||||
'materials.empty': 'Материалов пока нет.',
|
||||
'materials.addPrompt': 'Добавьте материал',
|
||||
'materials.name': 'НАЗВАНИЕ',
|
||||
'materials.namePlaceholder': 'Название материала…',
|
||||
'materials.nameRequired': 'Укажите название.',
|
||||
'materials.nameDup': 'Материал с таким названием уже есть.',
|
||||
'materials.image': 'ИЗОБРАЖЕНИЕ',
|
||||
'materials.imageEmpty': 'Изображение не выбрано',
|
||||
'materials.imageRequired': 'Выберите изображение.',
|
||||
'materials.chooseImage': 'Выбрать изображение',
|
||||
'materials.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'materials.tileMenu': 'Меню материала',
|
||||
'materials.windowEmpty': 'Добавьте материалы в редакторе.',
|
||||
'materials.closeOverlay': 'Закрыть материалы',
|
||||
'materials.rotateOverlay': 'Повернуть',
|
||||
'materials.deleteTitle': 'Удаление материала',
|
||||
'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?',
|
||||
'materials.zoomIn': 'Увеличить',
|
||||
'materials.zoomOut': 'Уменьшить',
|
||||
'materials.zoomInHint': 'Кликните по материалу в предпросмотре пульта, чтобы увеличить.',
|
||||
'materials.zoomOutHint': 'Кликните по материалу в предпросмотре пульта, чтобы уменьшить.',
|
||||
'materials.zoomIdleHint': 'Выберите лупу, затем кликните по материалу в предпросмотре пульта.',
|
||||
|
||||
'npcs.open': 'НПС',
|
||||
'npcs.editorTitle': 'НПС',
|
||||
'npcs.add': 'Добавить',
|
||||
'npcs.addTitle': 'Новый НПС',
|
||||
'npcs.editTitle': 'Изменить НПС',
|
||||
'npcs.savingTitle': 'Сохранение НПС',
|
||||
'npcs.savingWait': 'Подождите…',
|
||||
'npcs.savingProgress': 'Прогресс сохранения НПС',
|
||||
'npcs.graphLoading': 'Загрузка графа…',
|
||||
'npcs.edit': 'Изменить',
|
||||
'npcs.tileMenu': 'Меню НПС',
|
||||
'npcs.search': 'Поиск НПС…',
|
||||
'npcs.searchEmpty': 'Ничего не найдено.',
|
||||
'npcs.empty': 'НПС пока нет.',
|
||||
'npcs.selectPrompt': 'Выберите НПС в списке или на графе.',
|
||||
'npcs.name': 'ИМЯ',
|
||||
'npcs.namePlaceholder': 'Имя персонажа…',
|
||||
'npcs.nameRequired': 'Укажите имя.',
|
||||
'npcs.nameDup': 'НПС с таким именем уже есть.',
|
||||
'npcs.avatar': 'АВАТАР',
|
||||
'npcs.avatarEmpty': 'Аватар не выбран',
|
||||
'npcs.avatarRequired': 'Выберите аватар.',
|
||||
'npcs.chooseAvatar': 'Выбрать аватар',
|
||||
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'npcs.description': 'ОПИСАНИЕ',
|
||||
'npcs.descriptionPlaceholder': 'Описание персонажа…',
|
||||
'npcs.descriptionEmpty': 'Описание отсутствует',
|
||||
'npcs.relations': 'Отношения',
|
||||
'npcs.untitled': 'Без имени',
|
||||
'npcs.deleteTitle': 'Удаление НПС',
|
||||
'npcs.deleteConfirm': 'Вы уверены, что хотите удалить НПС «{name}»? Все связи с ним будут удалены.',
|
||||
'npcs.relationCreateTitle': 'Название связи',
|
||||
'npcs.relationEditTitle': 'Название связи',
|
||||
'npcs.relationLabel': 'НАЗВАНИЕ',
|
||||
'npcs.relationLabelPlaceholder': 'Например: друзья, враги…',
|
||||
'npcs.relationLabelRequired': 'Укажите название связи.',
|
||||
'npcs.relationEdit': 'Редактировать',
|
||||
'npcs.relationDelete': 'Удалить',
|
||||
'npcs.relationDeleteTitle': 'Удаление связи',
|
||||
'npcs.relationDeleteConfirm': 'Удалить связь «{name}»?',
|
||||
'npcs.graphZoomBar': 'Масштаб графа',
|
||||
'npcs.graphZoomIn': 'Увеличить',
|
||||
'npcs.graphZoomOut': 'Уменьшить',
|
||||
'npcs.graphFitAll': 'Показать всё',
|
||||
'npcs.windowEmpty': 'Добавьте НПС в редакторе.',
|
||||
'npcs.selectToShow': 'Выберите персонажа в списке — он появится на экране.',
|
||||
'npcs.closeOverlay': 'Закрыть всех',
|
||||
'npcs.rotateOverlay': 'Повернуть',
|
||||
'npcs.zoomIn': 'Увеличить',
|
||||
'npcs.zoomOut': 'Уменьшить',
|
||||
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
|
||||
'npcs.zoomOutHint': 'Кликните по аватару в предпросмотре пульта, чтобы уменьшить.',
|
||||
'npcs.zoomIdleHint': 'Выберите лупу, затем кликните по аватару в предпросмотре пульта.',
|
||||
'npcs.ungrouped': 'Без группы',
|
||||
'npcs.addGroup': 'Новая группа',
|
||||
'npcs.editGroup': 'Изменить группу',
|
||||
'npcs.deleteGroup': 'Удалить группу',
|
||||
'npcs.groupName': 'Название группы',
|
||||
'npcs.groupColor': 'Цвет',
|
||||
'npcs.groupNameRequired': 'Укажите название группы.',
|
||||
'npcs.groupNameDup': 'Группа с таким названием уже есть.',
|
||||
'npcs.deleteGroupTitle': 'Удаление группы',
|
||||
'npcs.deleteGroupConfirm':
|
||||
'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.',
|
||||
'npcs.addSubgroup': 'Добавить подгруппу',
|
||||
'npcs.group': 'ГРУППА',
|
||||
'npcs.graphFilterAll': 'Все',
|
||||
'npcs.graphFilterUngrouped': 'Без группы',
|
||||
'npcs.graphFilter': 'Фильтр графа',
|
||||
|
||||
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
|
||||
'scene.description': 'ОПИСАНИЕ',
|
||||
'scene.descriptionEmpty': 'описание отсутствует',
|
||||
'scene.descriptionModalTitle': 'Описание сцены',
|
||||
'scene.descriptionPlaceholder': 'Введите описание сцены…',
|
||||
'scene.descriptionToolbar': 'Форматирование',
|
||||
'scene.descriptionBold': 'Жирный',
|
||||
'scene.descriptionItalic': 'Курсив',
|
||||
'scene.descriptionUnderline': 'Подчёркнутый',
|
||||
'scene.descriptionHeading2': 'Заголовок',
|
||||
'scene.descriptionHeading3': 'Подзаголовок',
|
||||
'scene.descriptionQuote': 'Цитата',
|
||||
'scene.descriptionBulletList': 'Маркированный список',
|
||||
'scene.descriptionOrderedList': 'Нумерованный список',
|
||||
'scene.descriptionLink': 'Ссылка',
|
||||
'scene.descriptionLinkPrompt': 'URL ссылки',
|
||||
'scene.preview': 'ПРЕВЬЮ СЦЕНЫ',
|
||||
'scene.previewHint': 'Файл изображения (PNG, JPG, WebP, GIF и т.д.).',
|
||||
'scene.previewEmpty': 'Превью не задано',
|
||||
'scene.previewBusy': 'Загрузка и оптимизация изображения…',
|
||||
'scene.previewBusySelecting': 'Выберите файл…',
|
||||
'scene.previewOptimizing': 'Превью уже доступно. Оптимизируем в фоне…',
|
||||
'scene.previewReady': 'Превью готово',
|
||||
'scene.previewFailed': 'Превью добавлено, но оптимизация не удалась',
|
||||
'scene.change': 'Изменить',
|
||||
'scene.clear': 'Очистить',
|
||||
'scene.autostart': 'Автостарт',
|
||||
'scene.darkenScene': 'Затемнить сцену',
|
||||
'scene.rotate': 'Повернуть',
|
||||
'scene.audio': 'АУДИО СЦЕНЫ',
|
||||
'scene.removeTitle': 'Убрать из сцены',
|
||||
@@ -202,6 +525,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'sceneCard.menu': 'Меню сцены',
|
||||
|
||||
'graph.badgeStart': 'НАЧАЛО',
|
||||
'graph.badgeSideStory': 'ПОБОЧНАЯ',
|
||||
'graph.untitled': 'Без названия',
|
||||
'graph.videoBadge': 'Видео',
|
||||
'graph.audioBadge': 'Аудио',
|
||||
@@ -215,10 +539,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'graph.fitAll': 'Показать всё',
|
||||
'graph.startScene': 'Начальная сцена',
|
||||
'graph.unsetStartScene': 'Снять метку «Начальная сцена»',
|
||||
'graph.sideStoryStartScene': 'Начальная сцена побочной линии',
|
||||
'graph.unsetSideStoryStartScene': 'Снять метку «Начальная сцена побочной линии»',
|
||||
'graph.runFromScene': 'Запустить с этой сцены',
|
||||
|
||||
'scene.sideStoryLineTitle': 'Название побочной линии',
|
||||
|
||||
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
|
||||
'control.instruments': 'ИНСТРУМЕНТЫ',
|
||||
'control.descriptionTool': 'Описание',
|
||||
'control.materialsTool': 'Материалы',
|
||||
'control.npcsTool': 'НПС',
|
||||
'control.descriptionMissing': 'Описание отсутствует',
|
||||
'control.effects': 'ЭФФЕКТЫ',
|
||||
'control.tools': 'Инструменты',
|
||||
'control.tools': 'Очистка',
|
||||
'control.fieldEffects': 'Эффекты поля',
|
||||
'control.actionEffects': 'Эффекты действий',
|
||||
'control.eraser': 'Ластик',
|
||||
@@ -227,11 +561,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.rain': 'Дождь',
|
||||
'control.fire': 'Огонь',
|
||||
'control.water': 'Вода',
|
||||
'control.darkness': 'Тьма',
|
||||
'control.darknessControl': 'Управление затемнением',
|
||||
'control.explorerBrush': 'Кисть Открытия',
|
||||
'control.closerBrush': 'Кисть Закрытия',
|
||||
'control.lightning': 'Молния',
|
||||
'control.sunbeam': 'Луч света',
|
||||
'control.freeze': 'Заморозка',
|
||||
'control.poisonCloud': 'Облако яда',
|
||||
'control.explosion': 'Взрыв',
|
||||
'control.brushRadius': 'Радиус кисти',
|
||||
'control.effectsSound': 'Звук эффектов',
|
||||
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
|
||||
'control.gotoScene': 'Перейти к этой сцене',
|
||||
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
|
||||
@@ -242,6 +582,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.videoBrushHint':
|
||||
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
|
||||
'control.branches': 'Варианты ветвления',
|
||||
'control.returnToMainStory': 'Вернуться в основной сюжет',
|
||||
'control.sideStoryLines': 'Побочные сюжетные линии',
|
||||
'control.option': 'ОПЦИЯ {n}',
|
||||
'control.unnamed': 'Без названия',
|
||||
'control.switchScene': 'Переключить',
|
||||
@@ -277,17 +619,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.transportPlay': 'Воспроизведение',
|
||||
'control.transportPause': 'Пауза',
|
||||
'control.transportStop': 'Стоп',
|
||||
'control.volume': 'Громкость',
|
||||
},
|
||||
en: {
|
||||
'common.close': 'Close',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.save': 'Save',
|
||||
'common.saving': 'Saving…',
|
||||
'common.edit': 'Edit',
|
||||
'common.understood': 'OK',
|
||||
'common.message': 'Message',
|
||||
'common.error': 'Error',
|
||||
'common.delete': 'Delete',
|
||||
'common.closeMenu': 'Close menu',
|
||||
|
||||
'app.brandTitle': 'TTRPG Player',
|
||||
|
||||
'notice.campaignAudioEmpty': 'No audio was added. Check the file format.',
|
||||
|
||||
'license.checkingTitle': 'Checking license…',
|
||||
@@ -297,7 +644,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'Enter your key via Settings → Enter license key. Until activation, only Settings is available.',
|
||||
'license.tokenTitle': 'Enter license key',
|
||||
'license.tokenKey': 'KEY',
|
||||
'license.tokenPlaceholder': 'DND product key…',
|
||||
'license.tokenPlaceholder': 'TTRPG- or DND- product key…',
|
||||
'license.tokenSaving': 'Saving…',
|
||||
'license.eulaTitle': 'End User License Agreement',
|
||||
'license.eulaReject': 'Decline',
|
||||
@@ -336,8 +683,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'top.backToProjects': 'Back to projects',
|
||||
'top.appVersion': 'App version',
|
||||
'top.run': 'Run',
|
||||
'top.launching': 'Starting…',
|
||||
'top.afterLicense': 'Available after license activation',
|
||||
'top.setStartScene': 'Set a start scene on the graph (right‑click a node)',
|
||||
'top.runHelpAria': 'How to enable the Run button',
|
||||
|
||||
'menu.enterKey': 'Enter license key',
|
||||
'menu.aboutLicense': 'About license',
|
||||
@@ -345,6 +694,114 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'menu.language': 'Language',
|
||||
'menu.langRu': 'Русский',
|
||||
'menu.langEn': 'English',
|
||||
'menu.aboutProgram': 'About',
|
||||
'menu.instructions': 'Instructions',
|
||||
|
||||
'top.aboutApp': 'About',
|
||||
|
||||
'app.about.title': 'About',
|
||||
'app.about.tagline': 'Campaign editor and GM control panel for tabletop RPGs',
|
||||
'app.about.description':
|
||||
'TTRPG Player is a desktop app for game masters: build your campaign as a scene graph with maps, music and branching paths, then run the session from the control panel. Players see only the presentation on a second screen — no editor panels.',
|
||||
'app.about.versionLabel': 'VERSION',
|
||||
'app.about.developerLabel': 'DEVELOPER',
|
||||
'app.about.developer':
|
||||
'Independent development. For licensing, purchase and support questions, use the email below.',
|
||||
'app.about.supportLabel': 'SUPPORT',
|
||||
'app.about.supportEmail': 'player.ttrpg@gmail.com',
|
||||
'app.about.websiteLabel': 'WEBSITE & UPDATES',
|
||||
'app.about.websiteUrl': 'https://ttrpgplayer.ru/',
|
||||
|
||||
'help.title': 'Instructions',
|
||||
'help.navAria': 'Instruction sections',
|
||||
|
||||
'help.section.overview.title': 'App overview',
|
||||
'help.section.overview.body':
|
||||
'When you launch the app, you land in the Editor — where you prepare your campaign: scenes, images, music, and how episodes connect. When it is time to play, click Run. That opens Presentation for your players and the Control panel for you.\n\nIn the editor, the left column lists scenes, the center shows the story map, and the right column holds game and scene settings.\n\nWhile a session is running, the editor is temporarily locked — that is normal. Close presentation and the control panel to edit again.\n\nInternet is only needed for license activation and update checks. All projects and files stay on your computer.',
|
||||
|
||||
'help.section.license.title': 'License and first launch',
|
||||
'help.section.license.body':
|
||||
'Before you can work with projects, activate your license once.\n\n1) Open Settings → Enter license key.\n\n2) If you see the license agreement, read and accept it.\n\n3) Paste the key from your email (TTRPG-… or legacy DND-…) and click Save.\n\nUntil activation succeeds, only Settings is available. After that, projects, scenes, and running a session unlock.\n\nStatus, expiry, and device binding are under Settings → About license. The key is tied to this PC; another computer may need a separate activation per your purchase terms.',
|
||||
|
||||
'help.section.projects.title': 'Projects',
|
||||
'help.section.projects.body':
|
||||
'A project is your whole campaign: scenes, media, and connections between them.\n\nCreate a new campaign:\n\n1) On the home screen, type a name in the field on the left.\n\n2) Click Create project.\n\nOpen an existing one — click its name in the list. Return to the list: Project → Home, or click the app title in the header.\n\nMove a campaign to another computer:\n\n1) Project → Export — save a copy as .ttrpg.zip.\n\n2) On the other PC — Project → Import and pick that file.\n\nRename an open project: File → Rename project (at least 3 characters; file names cannot contain <>:"/\\|?*).\n\nTo delete a project from disk:\n\n1) On the project card, click ⋮.\n\n2) In the menu, choose Delete.\n\n3) Confirm deletion in the dialog.\n\nThe project file and cache are then removed permanently. If you might need the campaign again, export a backup first.',
|
||||
|
||||
'help.section.scenes.title': 'Scenes',
|
||||
'help.section.scenes.body':
|
||||
'A scene is one episode: a location, story beat, or dialogue. It has a title, an image or video for players, notes for the GM, and its own music.\n\nAdd a scene:\n\n1) In the left column, click + New scene.\n\n2) Set the title and adjust properties on the right (see Scene properties).\n\nSearch scenes… helps you find one quickly. Click a card to select it — it will also highlight on the story map.\n\nDelete: right-click a list card → Delete. The scene disappears from the list and map, including all links.\n\nDrag a scene from the list onto the map to place it as a node (see Scene graph).',
|
||||
|
||||
'help.section.graph.title': 'Scene graph',
|
||||
'help.section.graph.body':
|
||||
'The map in the center shows how episodes connect. Each box is a spot on your story; the same scene can appear more than once (for example, players return to the tavern).\n\nPlace a scene on the map:\n\n1) Grab a scene in the left list.\n\n2) Drag it onto empty space on the map.\n\nConnect two scenes:\n\n1) Point at the bottom dot on the first card.\n\n2) Drag a line to the top dot on the second and release — an arrow appears.\n\nOne scene can have several outgoing arrows — that is how you branch the story. You cannot draw a second arrow between the same pair.\n\nRemove an arrow: right-click the line → Delete. A normal click on the line does nothing.\n\nSet where the game starts: right-click a card → Start scene (a START badge appears), then click Run in the header. You can also start from any main-story card: right-click → Start from this scene — presentation and the control panel open at that spot (no START badge required). Side-story cards do not offer this menu item.\n\nRemove a card from the map without deleting the scene from the list: right-click → Delete.\n\nZoom with the buttons at the bottom or the mouse wheel. Fit view shows the whole map.',
|
||||
|
||||
'help.section.sideStorylines.title': 'Side storylines',
|
||||
'help.section.sideStorylines.body':
|
||||
'A side storyline is a separate branch not connected to the main plot — for detours, flashbacks, side quests, and scenes off the main path.\n\nCreate one in the editor:\n\n1) Place scenes on the map and link them in an isolated group — it must not touch the main story (purple links) or other side storylines.\n\n2) Right-click the starting card → Side storyline start scene. A blue SIDE badge appears.\n\n3) In scene properties, set Side storyline title — it appears on the control panel.\n\nThe menu item is hidden if the card is already linked to the main story (purple START anywhere in the group) or another side storyline (blue SIDE in the group).\n\nLinks inside a side storyline and card selection use blue (#0078d4). You cannot link main to side, or one side storyline to another.\n\nClear the mark: right-click → Clear side storyline start mark. The title is cleared and the tile disappears from the control panel.\n\nDeleting the start card: if there is a next scene along an arrow, the mark moves to it; otherwise the whole side storyline is removed from the map.\n\nDuring play, Side storylines appears under Music on the control panel — tiles with preview and title. Clicking jumps to the first scene. The app remembers which main-story scene you left from.\n\nWhile in a side storyline, Branch options always lists Return to main story first — back to the remembered scene. Storyline history keeps recording all steps, including inside side branches.\n\nYou cannot launch a side storyline from the editor — only from the control panel during a session.',
|
||||
|
||||
'help.section.sceneProps.title': 'Scene properties',
|
||||
'help.section.sceneProps.body':
|
||||
'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the Storyline list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\n5) For images, Scene editor opens the battle grid, traps, and non-player tokens on the map (see Scene editor, Grid generator, Traps, and Non-player tokens).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects and the scene editor are not available on video scenes.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
|
||||
|
||||
'help.section.sceneEditor.title': 'Scene editor',
|
||||
'help.section.sceneEditor.body':
|
||||
'Scene editor is a separate window for preparing the map: battle grid, traps, and non-player tokens. It is available only for image scenes (not video).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left are the Grid, Non-player tokens, and Traps accordions; on the right is the scene map.\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nUnder the accordions, Clear scene removes every trap and token from the current map (it does not delete tokens from the app library).\n\nFor details, see Grid generator, Traps, and Non-player tokens.',
|
||||
|
||||
'help.section.grid.title': 'Grid generator',
|
||||
'help.section.grid.body':
|
||||
'The grid generator overlays a battle grid on the scene image — square or hexagonal. It helps track cells in combat and is visible both on your control panel and on the players’ presentation.\n\nSet it up:\n\n1) Open Scene editor for an image scene (see Scene editor).\n\n2) Expand the Grid accordion on the left.\n\n3) Enable Overlay grid — lines appear over the map.\n\n4) Type — Square or Hexagonal.\n\n5) Color — line tint (pick contrast that suits the map).\n\n6) Size — cell size slider: higher values mean larger cells.\n\nWhile the grid is off, type, color, and size stay disabled, but the saved values return when you turn it back on.\n\nGrid settings are stored with the scene in the project. The generator is not available on video scenes — only on images. The grid draws under trap and token markers and does not block placing them.',
|
||||
|
||||
'help.section.traps.title': 'Traps',
|
||||
'help.section.traps.body':
|
||||
'Traps are markers on the scene map for hidden threats and surprises. Placement is stored with the scene in the project; during play you decide when players see them.\n\nIn the scene editor:\n\n1) Open Scene editor for an image scene (see Scene editor).\n\n2) Expand the Traps accordion on the left.\n\n3) Drag a type from the palette onto the map.\n\n4) Drag a marker to move it; drag the corner handle of the selected marker to resize.\n\n5) Delete / Backspace removes the selected trap. Clear scene removes all markers at once.\n\nTypes: Mimic, Explosion, Poison, Pit, Arrow, Laser, and Marker (a generic marker without a special effect).\n\nDuring a session:\n\n1) On the control panel Screen preview you see every trap on the current scene. While still hidden from players, markers look slightly muted on your side.\n\n2) On presentation, markers appear only after reveal or activation.\n\n3) Right-click a marker on the control panel:\n• Reveal — show it to players without triggering.\n• Activate — reveal and play the effect: Mimic, Pit, Arrow, and Laser play animation and sound; Poison and Explosion use the matching control-panel effects (poison cloud / explosion); Marker shows a short flash.\n• Disarm — show it as disarmed.\n\nTrap runtime state (revealed / triggered / disarmed) resets when you start a new session. Markers placed on the map remain.',
|
||||
|
||||
'help.section.tokens.title': 'Non-player tokens',
|
||||
'help.section.tokens.body':
|
||||
'Non-player tokens are images of creatures, props, and markers you place on the scene map. The token library lives in the app on this computer (not inside the project file). The scene only stores placements: which token, where it stands, size, and rotation.\n\nIn the scene editor:\n\n1) Open Scene editor for an image scene (see Scene editor).\n\n2) Expand Non-player tokens.\n\n3) Add — enter a unique name and an image (choose file or drop one).\n\n4) Use search to find a token by name.\n\n5) The ⋮ menu on a tile opens Edit or Delete (with confirmation). Deleting from the library also removes that token from the current scene.\n\n6) Drag a tile onto the map to place it. Select a marker: drag to move, corner handle to resize, rotate handle to turn. Delete / Backspace or right-click the marker removes it from the map. Clear scene removes all tokens and traps from the scene.\n\nDuring a session:\n\n1) On the control panel Screen preview, tokens are visible right away (unlike traps, they do not need revealing).\n\n2) Drag tokens with the left button — the new position is kept until the current session ends, including when you return to the scene via Storyline. A new Run resets positions to what you set in the editor.\n\n3) On the presentation screen tokens are display-only: players cannot click or drag them.\n\nWhen you export or import storylines, the needed token files are packed with the line so placements keep their images on another computer.',
|
||||
|
||||
'help.section.campaignAudio.title': 'Game audio',
|
||||
'help.section.campaignAudio.body':
|
||||
'Game audio under Game properties is music for the whole campaign: theme, ambience, background. It is not tied to one scene.\n\n1) Click Upload and choose files.\n\n2) Set Auto and Loop per track as you like.\n\n3) Remove a track with the trash icon.\n\nOn the control panel, scene music comes first: while a scene track plays, campaign music pauses. When the scene has no track or you take manual control, campaign music can play again.',
|
||||
|
||||
'help.section.materials.title': 'Materials',
|
||||
'help.section.materials.body':
|
||||
'Materials are campaign images (maps, notes, sketches) you can show players on top of the scene during play. They belong to the project and are not tied to one scene.\n\nIn the editor:\n\n1) Under Game properties, click Materials.\n\n2) Add — enter a unique name and an image (PNG, JPG, or WebP) via Choose image or by dropping a file.\n\n3) In the list you can search, reorder by drag-and-drop, and edit or delete via the ⋮ menu (delete asks for confirmation).\n\n4) Under the large preview, Rotate turns the image by 90° (applied in the tile and when shown on screen).\n\nDuring a session:\n\n1) On the control panel under Tools, click the materials button (treasure-map icon) to open a separate window with the list.\n\n2) Click a tile to show the material over the scene on the control preview and presentation; click the same tile again to hide it.\n\n3) On the control preview you can drag the material and resize it from the corners; the × button closes the overlay.\n\n4) In the materials window, the + / − magnifiers are zoom tools: pick one, then click the material on the control preview.\n\nChanging scenes clears the material overlay. Scene description and field effects are separate from materials.',
|
||||
|
||||
'help.section.npcs.title': 'NPCs',
|
||||
'help.section.npcs.body':
|
||||
'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project.\n\nIn the editor:\n\n1) Under Game properties, click NPCs — a separate character editor window opens.\n\n2) Add — enter a unique name and a required avatar (PNG, JPG, or WebP) via Choose avatar or by dropping a file. You can fill in the description right away if you want.\n\n3) Left: character list with search and drag reorder; the ⋮ menu is delete only (with confirmation; all relations involving that character are removed too).\n\n4) Center: relationship graph — drag an arrow from one character to another and enter a required relation name. Relations are one-way (A → B and B → A are different). Multiple relations in the same direction are drawn as parallel curves. Click a relation or its label to select the source character and highlight their outgoing links.\n\n5) Right: the selected character’s card — avatar, name, description (rich text), and a Relations list of outgoing links only (“name” + target name).\n\n6) Right-click a relation on the graph to Edit the name or Delete (with confirmation).\n\nDuring a session:\n\n1) On the control panel under Tools, next to materials, click the NPCs button (colored person icon) to open a separate window.\n\n2) Right: character list; click a tile to show the avatar over the scene on the control preview and presentation; click the same tile again to hide it. Left: description and outgoing relations for the selected character (visible only to you).\n\n3) On the control preview you can drag the avatar and resize it from the corners; the × button closes the overlay.\n\n4) In the NPCs window, the + / − magnifiers are zoom tools: pick one, then click the avatar on the control preview.\n\nChanging scenes clears the NPC overlay. Players on presentation see only the avatar.',
|
||||
|
||||
'help.section.session.title': 'Starting a session',
|
||||
'help.section.session.body':
|
||||
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Stop presentation or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
|
||||
|
||||
'help.section.controlPanel.title': 'Control panel',
|
||||
'help.section.controlPanel.body':
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The NPCs button (colored person icon) opens the characters window — see the NPCs section.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player tokens are also visible on the preview and can be moved until the session ends (see Non-player tokens).\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
|
||||
|
||||
'help.section.transitions.title': 'Scene transitions',
|
||||
'help.section.transitions.body':
|
||||
'Where you can go next appears under Branch options on the control panel. These are outgoing arrows from the current card on the map.\n\n1) Read the OPTION 1, OPTION 2 cards — they show target scene names.\n\n2) Click Switch on the choice you want.\n\n3) Presentation updates image and music; storyline adds a new step.\n\nIf there are no options (end of a branch), you will see No transitions available. Click End presentation to close the show windows.\n\nOptions only exist where you drew arrows on the map in the editor.',
|
||||
|
||||
'help.section.music.title': 'Music on the control panel',
|
||||
'help.section.music.body':
|
||||
'The Music section mirrors what you set in the editor — Scene music and Game music separately.\n\n▶ play, ⏸ pause, ⏹ stop. Auto/Manual and Loop/Once show how each track was configured in the editor.\n\nClick the progress bar to seek (when duration is known). ← → on the bar (when focused) skip 5 seconds back or forward.\n\nIf music does not start on its own, press ▶ once — after your click, sound is usually allowed. If a file still will not play, check the format (MP3, WAV, etc.).',
|
||||
|
||||
'help.section.effects.title': 'Field and action effects',
|
||||
'help.section.effects.body':
|
||||
'Effects work on image scenes, not video. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map.\n• Action effects (lightning, sunbeam, freeze, darkness, poison cloud, explosion) — click or short stroke; some include sound.\n\nIf Darken scene is enabled in scene properties, a Darkness control section appears with the Opening brush 🔦 and Closing brush ⬛. Opening brush clears darkness on both screens at once; Closing brush covers revealed areas again. Unrevealed areas stay fully black for players and half-dark on your preview. The state is remembered while the show runs and you return to the same graph card. This is not the same as the Darkness 🌑 action effect.\n\nEraser 🧹 — for field effects (fog, rain, fire, water), brush like the Opening brush for darkness: only the stroke area is erased. Action effects (lightning, sunbeam, etc.) are removed whole when you click or drag over them. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.',
|
||||
|
||||
'help.section.presentation.title': 'Presentation screen',
|
||||
'help.section.presentation.body':
|
||||
'Presentation is what players see: the scene image (with rotation from the editor) or video according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
|
||||
|
||||
'help.section.importExport.title': 'Import and export',
|
||||
'help.section.importExport.body':
|
||||
'A project is stored as a .ttrpg.zip file — images, sounds, and settings are already inside.\n\nBackup or move to another PC:\n\n1) Project → Export.\n\n2) Choose the project and where to save.\n\n3) Copy the .ttrpg.zip to a USB drive, cloud, or another computer.\n\nLoad a backup:\n\n1) Project → Import.\n\n2) Select the .ttrpg.zip.\n\n3) The project appears on the home screen.\n\nLarge archives show a progress bar. You do not need to move images and music separately — everything is in the archive.',
|
||||
|
||||
'help.section.settings.title': 'Settings, language and updates',
|
||||
'help.section.settings.body':
|
||||
'Settings → Enter license key — add or change your license. About license — check status and expiry.\n\nCheck for updates (installed app with active license) — if a new version exists, you can download and restart.\n\nLanguage → Русский or English changes the interface. Your choice is saved between launches.\n\nThe version number is in the header, right of the menus. About → About opens product info and support contacts.',
|
||||
|
||||
'updates.dialogTitle': 'Updates',
|
||||
'updates.checking': 'Checking for updates…',
|
||||
@@ -355,18 +812,47 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'updates.noLicense': 'An active license is required.',
|
||||
'updates.download': 'Update',
|
||||
'updates.downloading': 'Downloading…',
|
||||
'updates.stageLine': 'Stage: {stage}',
|
||||
'updates.stage.checking': 'checking for updates',
|
||||
'updates.stage.available': 'version {version} available',
|
||||
'updates.stage.not-available': 'up to date',
|
||||
'updates.stage.downloading': 'downloading{percent}',
|
||||
'updates.stage.installing': 'installing and restarting',
|
||||
'updates.stage.error': 'error',
|
||||
'updates.stagePercent': ' ({percent}%)',
|
||||
|
||||
'projectMenu.home': 'Home',
|
||||
'projectMenu.import': 'Import',
|
||||
'projectMenu.importFoundry': 'Import from Foundry',
|
||||
'projectMenu.export': 'Export',
|
||||
'projectMenu.noProjects': 'No saved projects',
|
||||
|
||||
'foundryImport.title': 'Import from Foundry',
|
||||
'foundryImport.hint':
|
||||
'Choose a Foundry VTT world or module folder or archive (version 11+). A new project will be created.',
|
||||
'foundryImport.sourceType': 'SOURCE TYPE',
|
||||
'foundryImport.folder': 'Folder',
|
||||
'foundryImport.archive': 'Archive (.zip / .fvtt)',
|
||||
'foundryImport.source': 'SOURCE',
|
||||
'foundryImport.chooseFolder': 'Choose folder',
|
||||
'foundryImport.chooseArchive': 'Choose archive',
|
||||
'foundryImport.noSourceSelected': 'Nothing selected',
|
||||
'foundryImport.import': 'Import',
|
||||
|
||||
'fileMenu.rename': 'Rename project',
|
||||
|
||||
'scenes.search': 'Search scenes…',
|
||||
'scenes.new': '+ New scene',
|
||||
'scenes.dropHint': 'Drop images or videos',
|
||||
'scenes.batchTitle': 'Creating scenes',
|
||||
'scenes.batchProgress': 'Scene {current} of {total}',
|
||||
'scenes.dropSkippedTitle': 'Some files were not added',
|
||||
'scenes.dropSkippedIntro': 'These files were skipped:',
|
||||
'scenes.dropSkippedUnsupported': 'unsupported format',
|
||||
'scenes.dropSkippedNoPath': 'could not resolve file path',
|
||||
'scenes.inspectorGame': 'Game properties',
|
||||
'scenes.inspectorScene': 'Scene properties',
|
||||
'scenes.projectLabel': 'Project: {name}',
|
||||
'scenes.selectHint': 'Select a scene on the left to edit its properties.',
|
||||
'scenes.openProjectHint': 'Open a project to edit the campaign and scenes.',
|
||||
|
||||
@@ -383,24 +869,79 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'export.title': 'Export project',
|
||||
'export.project': 'PROJECT',
|
||||
'export.hint':
|
||||
'A save dialog will open: choose a name and folder for the .dnd.zip file — a copy of the project archive will be created.',
|
||||
'Select storylines to export. The archive will include the chosen lines, their scenes, and assets. If the project has NPCs, the next step lets you choose which ones to include.',
|
||||
'export.npcsTitle': 'Export NPCs',
|
||||
'export.npcsHint':
|
||||
'Select NPCs to export. Relations between selected NPCs and their groups are included.',
|
||||
'export.selectAllNpcs': 'Select all',
|
||||
'export.next': 'Next',
|
||||
'export.back': 'Back',
|
||||
'export.exporting': 'Exporting…',
|
||||
'export.saveAs': 'Save as…',
|
||||
|
||||
'storyline.section': 'STORYLINE',
|
||||
'storyline.main': 'Main storyline',
|
||||
'storyline.loading': 'Loading storylines…',
|
||||
'storyline.empty': 'This project has no storylines marked with START or SIDE badges.',
|
||||
'storyline.mainExistsHint': 'main storyline already exists in this project',
|
||||
|
||||
'importSource.title': 'Import',
|
||||
'importSource.type': 'IMPORT TYPE',
|
||||
'importSource.fromProject': 'From project',
|
||||
'importSource.fromFile': 'From file',
|
||||
'importSource.project': 'PROJECT',
|
||||
'importSource.file': 'FILE',
|
||||
'importSource.chooseFile': 'Choose file',
|
||||
'importSource.noFileSelected': 'No file selected',
|
||||
'importSource.noOtherProjects': 'No other projects available to import from.',
|
||||
'importSource.fileOnlyHint': 'Choose a project file (.ttrpg.zip) for a full import.',
|
||||
|
||||
'importStoryline.title': 'Import storylines',
|
||||
'importStoryline.source': 'SOURCE',
|
||||
'importStoryline.continue': 'Continue',
|
||||
'importStoryline.import': 'Import',
|
||||
'importStoryline.conflictsTitle': 'Duplicate scene titles',
|
||||
'importStoryline.conflictsHint':
|
||||
'Imported storylines contain scenes with the same titles as in the current project. Choose what to do for each.',
|
||||
'importStoryline.createNewScene': 'Create new scene',
|
||||
'importStoryline.useExistingScene': 'Use existing «{title}»',
|
||||
'importStoryline.reportTitle': 'Import complete',
|
||||
'importStoryline.reportLines': 'Storylines imported: {count}',
|
||||
'importStoryline.reportScenesCreated': 'New scenes created: {count}',
|
||||
'importStoryline.reportScenesReused': 'Existing scenes reused: {count}',
|
||||
'importStoryline.reportNpcsCreated': 'New NPCs created: {count}',
|
||||
'importStoryline.reportNpcsReused': 'Existing NPCs reused: {count}',
|
||||
'importStoryline.reportNodes': 'Graph cards added: {count}',
|
||||
'importStoryline.reportEdges': 'Connections added: {count}',
|
||||
'importStoryline.reportAssetsCopied': 'Asset files copied: {count}',
|
||||
'importStoryline.reportAssetsReused': 'Assets reused: {count}',
|
||||
'importStoryline.reportRenamedSides': 'Renamed side storylines: {names}',
|
||||
'importStoryline.npcConflictsTitle': 'Duplicate NPC names',
|
||||
'importStoryline.npcConflictsHint':
|
||||
'Imported storylines contain NPCs with the same names as in the current project. Choose what to do for each.',
|
||||
'importStoryline.createNewNpc': 'Create new NPC',
|
||||
'importStoryline.useExistingNpc': 'Use existing «{name}»',
|
||||
|
||||
'confirmDelete.title': 'Delete project',
|
||||
'confirmDelete.body':
|
||||
'Permanently delete project “{name}”? The file and cache will be removed from disk.',
|
||||
'confirmDelete.failedTitle': 'Could not delete',
|
||||
|
||||
'confirmDeleteScene.title': 'Delete scene',
|
||||
'confirmDeleteScene.body': 'Delete scene “{name}”? This cannot be undone.',
|
||||
|
||||
'picker.title': 'Projects',
|
||||
'picker.newPlaceholder': 'New project name…',
|
||||
'picker.create': 'Create project',
|
||||
'picker.search': 'Search campaigns…',
|
||||
'picker.searchEmpty': 'No matches.',
|
||||
'picker.existing': 'EXISTING',
|
||||
'picker.lockedHint':
|
||||
'Opening and creating projects require an active license. The list still shows files in the app folder.',
|
||||
'picker.empty': 'No projects yet.',
|
||||
'picker.projectMenu': 'Project menu',
|
||||
'picker.openDisabled': 'Open project — after license activation',
|
||||
'picker.opening': 'Opening…',
|
||||
'picker.defaultName': 'My campaign',
|
||||
|
||||
'campaign.label': 'GAME AUDIO',
|
||||
@@ -409,18 +950,144 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'campaign.loop': 'Loop',
|
||||
'campaign.removeTitle': 'Remove from campaign',
|
||||
'campaign.upload': 'Upload',
|
||||
'drop.hintAudio': 'Drop audio files here',
|
||||
'drop.hintPreview': 'Drop an image or video',
|
||||
|
||||
'materials.open': 'Materials',
|
||||
'materials.managerTitle': 'Materials',
|
||||
'materials.add': 'Add',
|
||||
'materials.addTitle': 'New material',
|
||||
'materials.editTitle': 'Edit material',
|
||||
'materials.savingTitle': 'Saving material',
|
||||
'materials.savingWait': 'Please wait…',
|
||||
'materials.savingProgress': 'Material save progress',
|
||||
'materials.edit': 'Edit',
|
||||
'materials.search': 'Search materials…',
|
||||
'materials.searchEmpty': 'No matches.',
|
||||
'materials.empty': 'No materials yet.',
|
||||
'materials.addPrompt': 'Add a material',
|
||||
'materials.name': 'NAME',
|
||||
'materials.namePlaceholder': 'Material name…',
|
||||
'materials.nameRequired': 'Name is required.',
|
||||
'materials.nameDup': 'A material with this name already exists.',
|
||||
'materials.image': 'IMAGE',
|
||||
'materials.imageEmpty': 'No image selected',
|
||||
'materials.imageRequired': 'Choose an image.',
|
||||
'materials.chooseImage': 'Choose image',
|
||||
'materials.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'materials.tileMenu': 'Material menu',
|
||||
'materials.windowEmpty': 'Add materials in the editor.',
|
||||
'materials.closeOverlay': 'Close materials',
|
||||
'materials.rotateOverlay': 'Rotate',
|
||||
'materials.deleteTitle': 'Delete material',
|
||||
'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?',
|
||||
'materials.zoomIn': 'Zoom in',
|
||||
'materials.zoomOut': 'Zoom out',
|
||||
'materials.zoomInHint': 'Click the material on the control preview to zoom in.',
|
||||
'materials.zoomOutHint': 'Click the material on the control preview to zoom out.',
|
||||
'materials.zoomIdleHint': 'Pick a magnifier, then click the material on the control preview.',
|
||||
|
||||
'npcs.open': 'NPCs',
|
||||
'npcs.editorTitle': 'NPCs',
|
||||
'npcs.add': 'Add',
|
||||
'npcs.addTitle': 'New NPC',
|
||||
'npcs.editTitle': 'Edit NPC',
|
||||
'npcs.savingTitle': 'Saving NPC',
|
||||
'npcs.savingWait': 'Please wait…',
|
||||
'npcs.savingProgress': 'NPC save progress',
|
||||
'npcs.graphLoading': 'Loading graph…',
|
||||
'npcs.edit': 'Edit',
|
||||
'npcs.tileMenu': 'NPC menu',
|
||||
'npcs.search': 'Search NPCs…',
|
||||
'npcs.searchEmpty': 'No matches.',
|
||||
'npcs.empty': 'No NPCs yet.',
|
||||
'npcs.selectPrompt': 'Select an NPC in the list or on the graph.',
|
||||
'npcs.name': 'NAME',
|
||||
'npcs.namePlaceholder': 'Character name…',
|
||||
'npcs.nameRequired': 'Name is required.',
|
||||
'npcs.nameDup': 'An NPC with this name already exists.',
|
||||
'npcs.avatar': 'AVATAR',
|
||||
'npcs.avatarEmpty': 'No avatar selected',
|
||||
'npcs.avatarRequired': 'Choose an avatar.',
|
||||
'npcs.chooseAvatar': 'Choose avatar',
|
||||
'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'npcs.description': 'DESCRIPTION',
|
||||
'npcs.descriptionPlaceholder': 'Character description…',
|
||||
'npcs.descriptionEmpty': 'No description',
|
||||
'npcs.relations': 'Relations',
|
||||
'npcs.untitled': 'Untitled',
|
||||
'npcs.deleteTitle': 'Delete NPC',
|
||||
'npcs.deleteConfirm': 'Are you sure you want to delete NPC “{name}”? All of their relations will be removed.',
|
||||
'npcs.relationCreateTitle': 'Relation name',
|
||||
'npcs.relationEditTitle': 'Relation name',
|
||||
'npcs.relationLabel': 'NAME',
|
||||
'npcs.relationLabelPlaceholder': 'e.g. friends, rivals…',
|
||||
'npcs.relationLabelRequired': 'Relation name is required.',
|
||||
'npcs.relationEdit': 'Edit',
|
||||
'npcs.relationDelete': 'Delete',
|
||||
'npcs.relationDeleteTitle': 'Delete relation',
|
||||
'npcs.relationDeleteConfirm': 'Delete relation “{name}”?',
|
||||
'npcs.graphZoomBar': 'Graph zoom',
|
||||
'npcs.graphZoomIn': 'Zoom in',
|
||||
'npcs.graphZoomOut': 'Zoom out',
|
||||
'npcs.graphFitAll': 'Fit view',
|
||||
'npcs.windowEmpty': 'Add NPCs in the editor.',
|
||||
'npcs.selectToShow': 'Select a character in the list — they will appear on screen.',
|
||||
'npcs.closeOverlay': 'Close all',
|
||||
'npcs.rotateOverlay': 'Rotate',
|
||||
'npcs.zoomIn': 'Zoom in',
|
||||
'npcs.zoomOut': 'Zoom out',
|
||||
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.',
|
||||
'npcs.zoomOutHint': 'Click the avatar on the control preview to zoom out.',
|
||||
'npcs.zoomIdleHint': 'Pick a magnifier, then click the avatar on the control preview.',
|
||||
'npcs.ungrouped': 'Ungrouped',
|
||||
'npcs.addGroup': 'New group',
|
||||
'npcs.editGroup': 'Edit group',
|
||||
'npcs.deleteGroup': 'Delete group',
|
||||
'npcs.groupName': 'Group name',
|
||||
'npcs.groupColor': 'Color',
|
||||
'npcs.groupNameRequired': 'Group name is required.',
|
||||
'npcs.groupNameDup': 'A group with this name already exists.',
|
||||
'npcs.deleteGroupTitle': 'Delete group',
|
||||
'npcs.deleteGroupConfirm':
|
||||
'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.',
|
||||
'npcs.addSubgroup': 'Add subgroup',
|
||||
'npcs.group': 'GROUP',
|
||||
'npcs.graphFilterAll': 'All',
|
||||
'npcs.graphFilterUngrouped': 'Ungrouped',
|
||||
'npcs.graphFilter': 'Graph filter',
|
||||
|
||||
'scene.title': 'SCENE TITLE',
|
||||
'scene.description': 'DESCRIPTION',
|
||||
'scene.descriptionEmpty': 'no description',
|
||||
'scene.descriptionModalTitle': 'Scene description',
|
||||
'scene.descriptionPlaceholder': 'Enter scene description…',
|
||||
'scene.descriptionToolbar': 'Formatting',
|
||||
'scene.descriptionBold': 'Bold',
|
||||
'scene.descriptionItalic': 'Italic',
|
||||
'scene.descriptionUnderline': 'Underline',
|
||||
'scene.descriptionHeading2': 'Heading',
|
||||
'scene.descriptionHeading3': 'Subheading',
|
||||
'scene.descriptionQuote': 'Quote',
|
||||
'scene.descriptionBulletList': 'Bullet list',
|
||||
'scene.descriptionOrderedList': 'Numbered list',
|
||||
'scene.descriptionLink': 'Link',
|
||||
'scene.descriptionLinkPrompt': 'Link URL',
|
||||
'scene.preview': 'SCENE PREVIEW',
|
||||
'scene.previewHint': 'Image file (PNG, JPG, WebP, GIF, etc.).',
|
||||
'scene.previewEmpty': 'No preview',
|
||||
'scene.previewBusy': 'Loading and optimizing image…',
|
||||
'scene.previewBusySelecting': 'Choose a file…',
|
||||
'scene.previewOptimizing': 'Preview is ready. Optimizing in the background…',
|
||||
'scene.previewReady': 'Preview is ready',
|
||||
'scene.previewFailed': 'Preview was added, but optimization failed',
|
||||
'scene.change': 'Change',
|
||||
'scene.clear': 'Clear',
|
||||
'scene.autostart': 'Autostart',
|
||||
'scene.darkenScene': 'Darken scene',
|
||||
'scene.rotate': 'Rotate',
|
||||
'scene.audio': 'SCENE AUDIO',
|
||||
'scene.sideStoryLineTitle': 'Side storyline title',
|
||||
'scene.removeTitle': 'Remove from scene',
|
||||
'scene.branching': 'BRANCHING',
|
||||
'scene.branchingHint':
|
||||
@@ -430,6 +1097,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'sceneCard.menu': 'Scene menu',
|
||||
|
||||
'graph.badgeStart': 'START',
|
||||
'graph.badgeSideStory': 'SIDE',
|
||||
'graph.untitled': 'Untitled',
|
||||
'graph.videoBadge': 'Video',
|
||||
'graph.audioBadge': 'Audio',
|
||||
@@ -443,10 +1111,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'graph.fitAll': 'Fit view',
|
||||
'graph.startScene': 'Start scene',
|
||||
'graph.unsetStartScene': 'Clear start scene mark',
|
||||
'graph.sideStoryStartScene': 'Side storyline start scene',
|
||||
'graph.unsetSideStoryStartScene': 'Clear side storyline start mark',
|
||||
'graph.runFromScene': 'Start from this scene',
|
||||
|
||||
'control.remoteTitle': 'CONTROL PANEL',
|
||||
'control.instruments': 'TOOLS',
|
||||
'control.descriptionTool': 'Description',
|
||||
'control.materialsTool': 'Materials',
|
||||
'control.npcsTool': 'NPCs',
|
||||
'control.descriptionMissing': 'No description',
|
||||
'control.effects': 'EFFECTS',
|
||||
'control.tools': 'Tools',
|
||||
'control.tools': 'Cleanup',
|
||||
'control.fieldEffects': 'Field effects',
|
||||
'control.actionEffects': 'Action effects',
|
||||
'control.eraser': 'Eraser',
|
||||
@@ -455,11 +1131,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.rain': 'Rain',
|
||||
'control.fire': 'Fire',
|
||||
'control.water': 'Water',
|
||||
'control.darkness': 'Darkness',
|
||||
'control.darknessControl': 'Darkness control',
|
||||
'control.explorerBrush': 'Opening brush',
|
||||
'control.closerBrush': 'Closing brush',
|
||||
'control.lightning': 'Lightning',
|
||||
'control.sunbeam': 'Sunbeam',
|
||||
'control.freeze': 'Freeze',
|
||||
'control.poisonCloud': 'Poison cloud',
|
||||
'control.explosion': 'Explosion',
|
||||
'control.brushRadius': 'Brush radius',
|
||||
'control.effectsSound': 'Effects sound',
|
||||
'control.storyLine': 'STORYLINE',
|
||||
'control.gotoScene': 'Go to this scene',
|
||||
'control.currentSceneBadge': 'CURRENT SCENE',
|
||||
@@ -470,6 +1152,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.videoBrushHint':
|
||||
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
|
||||
'control.branches': 'Branch options',
|
||||
'control.returnToMainStory': 'Return to main story',
|
||||
'control.sideStoryLines': 'Side storylines',
|
||||
'control.option': 'OPTION {n}',
|
||||
'control.unnamed': 'Untitled',
|
||||
'control.switchScene': 'Switch',
|
||||
@@ -504,6 +1188,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.transportPlay': 'Play',
|
||||
'control.transportPause': 'Pause',
|
||||
'control.transportStop': 'Stop',
|
||||
'control.volume': 'Volume',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
import { EditorApp } from './EditorApp';
|
||||
import { EditorI18nProvider } from './i18n/EditorI18nContext';
|
||||
|
||||
@@ -12,8 +13,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<EditorApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Редактор">
|
||||
<EditorI18nProvider>
|
||||
<EditorApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
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/);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const routerSrc = fs.readFileSync(path.join(here, '..', '..', '..', 'main', 'ipc', 'router.ts'), 'utf8');
|
||||
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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
|
||||
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
NpcImportResolution,
|
||||
SceneImportResolution,
|
||||
StorylineImportMergeReport,
|
||||
StorylineLabels,
|
||||
StorylineListItem,
|
||||
StorylineSelection,
|
||||
} from '../../../shared/graph/storylineExportImport';
|
||||
import type {
|
||||
AssetId,
|
||||
GraphNodeId,
|
||||
MaterialId,
|
||||
Project,
|
||||
ProjectId,
|
||||
Scene,
|
||||
SceneId,
|
||||
} from '../../../shared/types';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
|
||||
|
||||
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
|
||||
|
||||
@@ -10,7 +27,14 @@ type State = {
|
||||
projects: ProjectSummary[];
|
||||
project: Project | null;
|
||||
selectedSceneId: SceneId | null;
|
||||
openingProjectId: ProjectId | null;
|
||||
creatingScene: boolean;
|
||||
sceneBatchImport: { current: number; total: number; fileName: string } | null;
|
||||
zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null;
|
||||
scenePreviewImports: Record<
|
||||
SceneId,
|
||||
{ assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string }
|
||||
>;
|
||||
};
|
||||
|
||||
type Actions = {
|
||||
@@ -19,9 +43,26 @@ type Actions = {
|
||||
openProject: (id: ProjectId) => Promise<void>;
|
||||
closeProject: () => Promise<void>;
|
||||
createScene: () => Promise<void>;
|
||||
createScenesFromMediaPaths: (
|
||||
items: { filePath: string; title: string }[],
|
||||
) => Promise<{ created: number; lastSceneId: SceneId | null }>;
|
||||
selectScene: (id: SceneId) => Promise<void>;
|
||||
importCampaignAudio: () => Promise<void>;
|
||||
importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
|
||||
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
|
||||
upsertMaterial: (input: {
|
||||
materialId?: MaterialId;
|
||||
name: string;
|
||||
filePath?: string;
|
||||
}) => Promise<void>;
|
||||
deleteMaterial: (materialId: MaterialId) => Promise<void>;
|
||||
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
|
||||
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
|
||||
setMaterialLegend: (
|
||||
materialId: MaterialId,
|
||||
legend: import('../../../shared/types').MaterialLegend | null,
|
||||
) => Promise<void>;
|
||||
pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
updateScene: (
|
||||
sceneId: SceneId,
|
||||
patch: {
|
||||
@@ -32,6 +73,7 @@ type Actions = {
|
||||
previewAssetType?: 'image' | 'video' | null;
|
||||
previewVideoAutostart?: boolean;
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
layout?: { x: number; y: number };
|
||||
@@ -39,7 +81,12 @@ type Actions = {
|
||||
) => Promise<void>;
|
||||
updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>;
|
||||
importMediaToScene: (sceneId: SceneId) => Promise<void>;
|
||||
importScenePreview: (sceneId: SceneId) => Promise<void>;
|
||||
importMediaToSceneFromPaths: (sceneId: SceneId, filePaths: string[]) => Promise<void>;
|
||||
importScenePreview: (sceneId: SceneId) => Promise<{ assetId: AssetId | null; background: boolean }>;
|
||||
importScenePreviewFromPath: (
|
||||
sceneId: SceneId,
|
||||
filePath: string,
|
||||
) => Promise<{ assetId: AssetId | null; background: boolean }>;
|
||||
clearScenePreview: (sceneId: SceneId) => Promise<void>;
|
||||
updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>;
|
||||
addSceneGraphNode: (sceneId: SceneId, x: number, y: number) => Promise<void>;
|
||||
@@ -47,10 +94,70 @@ type Actions = {
|
||||
addSceneGraphEdge: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => Promise<void>;
|
||||
removeSceneGraphEdge: (edgeId: string) => Promise<void>;
|
||||
setSceneGraphNodeStart: (graphNodeId: GraphNodeId | null) => Promise<void>;
|
||||
setSceneGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => Promise<void>;
|
||||
updateSideStoryLineTitle: (graphNodeId: GraphNodeId, title: string) => Promise<void>;
|
||||
deleteScene: (sceneId: SceneId) => Promise<void>;
|
||||
setSceneListOrder: (sceneListOrder: SceneId[]) => Promise<void>;
|
||||
renameProject: (name: string, fileBaseName: string) => Promise<void>;
|
||||
importProject: () => Promise<void>;
|
||||
exportProject: (projectId: ProjectId) => Promise<void>;
|
||||
pickFoundrySource: (
|
||||
mode: 'folder' | 'archive',
|
||||
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
|
||||
importFoundryProject: (sourcePath: string) => Promise<void>;
|
||||
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
|
||||
| { canceled: true }
|
||||
| {
|
||||
canceled: false;
|
||||
filePath: string;
|
||||
projectName: string;
|
||||
storylines: StorylineListItem[];
|
||||
sourceProject: Project;
|
||||
}
|
||||
>;
|
||||
pickImportZipFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
|
||||
peekImportZipPath: (
|
||||
filePath: string,
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
) => Promise<{
|
||||
filePath: string;
|
||||
projectName: string;
|
||||
storylines: StorylineListItem[];
|
||||
sourceProject: Project;
|
||||
}>;
|
||||
peekImportFromProject: (
|
||||
sourceProjectId: ProjectId,
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
) => Promise<{
|
||||
sourceProjectId: ProjectId;
|
||||
projectName: string;
|
||||
storylines: StorylineListItem[];
|
||||
sourceProject: Project;
|
||||
}>;
|
||||
mergeImportZip: (
|
||||
filePath: string,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
mergeImportFromProject: (
|
||||
sourceProjectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
importProjectFromPath: (filePath: string) => Promise<void>;
|
||||
getProjectStorylines: (
|
||||
projectId: ProjectId,
|
||||
labels: StorylineLabels,
|
||||
) => Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }>;
|
||||
exportProject: (
|
||||
projectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
labels: StorylineLabels,
|
||||
) => Promise<void>;
|
||||
deleteProject: (projectId: ProjectId) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -74,9 +181,18 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
projects: [],
|
||||
project: null,
|
||||
selectedSceneId: null,
|
||||
openingProjectId: null,
|
||||
creatingScene: false,
|
||||
sceneBatchImport: null,
|
||||
zipProgress: null,
|
||||
scenePreviewImports: {} as Record<
|
||||
SceneId,
|
||||
{ assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string }
|
||||
>,
|
||||
});
|
||||
const projectRef = useRef<Project | null>(null);
|
||||
const openInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const createSceneInFlightRef = useRef<Promise<void> | null>(null);
|
||||
/** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */
|
||||
const projectDataEpochRef = useRef(0);
|
||||
useEffect(() => {
|
||||
@@ -95,8 +211,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
...(e.detail ? { detail: e.detail } : null),
|
||||
},
|
||||
}));
|
||||
if (e.stage === 'done' || e.percent >= 100) {
|
||||
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
|
||||
if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) {
|
||||
const delay = e.stage === 'error' ? 0 : 450;
|
||||
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
|
||||
}
|
||||
});
|
||||
const offExport = api.on(ipcChannels.project.exportZipProgress, (evt) => {
|
||||
@@ -110,13 +227,45 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
...(e.detail ? { detail: e.detail } : null),
|
||||
},
|
||||
}));
|
||||
if (e.stage === 'done' || e.percent >= 100) {
|
||||
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
|
||||
if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) {
|
||||
const delay = e.stage === 'error' ? 0 : 450;
|
||||
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
|
||||
}
|
||||
});
|
||||
const offPreview = api.on(ipcChannels.project.scenePreviewImportProgress, (evt) => {
|
||||
setState((s) => {
|
||||
const nextImports = { ...s.scenePreviewImports };
|
||||
nextImports[evt.sceneId] = {
|
||||
assetId: evt.assetId,
|
||||
phase: evt.phase,
|
||||
...(evt.message ? { message: evt.message } : null),
|
||||
};
|
||||
return {
|
||||
...s,
|
||||
...(evt.project ? { project: evt.project } : null),
|
||||
scenePreviewImports: nextImports,
|
||||
};
|
||||
});
|
||||
if (evt.phase === 'done' || evt.phase === 'error') {
|
||||
setTimeout(
|
||||
() => {
|
||||
setState((s) => {
|
||||
const cur = s.scenePreviewImports[evt.sceneId];
|
||||
if (cur?.assetId !== evt.assetId || cur.phase !== evt.phase) return s;
|
||||
const nextImports = Object.fromEntries(
|
||||
Object.entries(s.scenePreviewImports).filter(([sceneId]) => sceneId !== evt.sceneId),
|
||||
) as State['scenePreviewImports'];
|
||||
return { ...s, scenePreviewImports: nextImports };
|
||||
});
|
||||
},
|
||||
evt.phase === 'done' ? 1000 : 4000,
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
offImport();
|
||||
offExport();
|
||||
offPreview();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
@@ -135,50 +284,200 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
|
||||
const openProject = async (id: ProjectId) => {
|
||||
projectDataEpochRef.current += 1;
|
||||
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
|
||||
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId }));
|
||||
const epoch = projectDataEpochRef.current;
|
||||
openInFlightRef.current = null;
|
||||
// URL ассетов зависят от открытого проекта — сбрасываем renderer-кэш.
|
||||
invalidateAssetUrlCache();
|
||||
|
||||
const job = (async () => {
|
||||
// При открытии не выбираем сцену: список/граф/инспектор без выделения.
|
||||
setState((s) => ({ ...s, openingProjectId: id, selectedSceneId: null }));
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
|
||||
if (projectDataEpochRef.current !== epoch) {
|
||||
setState((s) => ({ ...s, openingProjectId: null }));
|
||||
return;
|
||||
}
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: null,
|
||||
openingProjectId: null,
|
||||
}));
|
||||
} catch {
|
||||
if (projectDataEpochRef.current === epoch) {
|
||||
setState((s) => ({ ...s, openingProjectId: null }));
|
||||
}
|
||||
}
|
||||
})();
|
||||
openInFlightRef.current = job;
|
||||
void job.finally(() => {
|
||||
if (openInFlightRef.current === job) openInFlightRef.current = null;
|
||||
});
|
||||
return job;
|
||||
};
|
||||
|
||||
const closeProject = async () => {
|
||||
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
|
||||
await refreshProjects();
|
||||
projectDataEpochRef.current += 1;
|
||||
openInFlightRef.current = null;
|
||||
invalidateAssetUrlCache();
|
||||
try {
|
||||
await api.invoke(ipcChannels.project.close, {});
|
||||
} finally {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: null,
|
||||
selectedSceneId: null,
|
||||
openingProjectId: null,
|
||||
zipProgress: null,
|
||||
}));
|
||||
await refreshProjects();
|
||||
}
|
||||
};
|
||||
|
||||
const createScene = async () => {
|
||||
if (createSceneInFlightRef.current) return createSceneInFlightRef.current;
|
||||
const p = projectRef.current;
|
||||
if (!p) return;
|
||||
const sceneId = randomId('scene') as SceneId;
|
||||
const scene: Scene = {
|
||||
id: sceneId,
|
||||
title: `Новая сцена`,
|
||||
description: '',
|
||||
previewAssetId: null,
|
||||
previewThumbAssetId: null,
|
||||
previewAssetType: null,
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||
connections: [],
|
||||
layout: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
await api.invoke(ipcChannels.project.updateScene, {
|
||||
sceneId,
|
||||
patch: {
|
||||
title: scene.title,
|
||||
description: scene.description,
|
||||
media: scene.media,
|
||||
settings: scene.settings,
|
||||
layout: scene.layout,
|
||||
previewAssetId: scene.previewAssetId,
|
||||
previewAssetType: scene.previewAssetType,
|
||||
previewVideoAutostart: scene.previewVideoAutostart,
|
||||
},
|
||||
const job = (async () => {
|
||||
setState((s) => ({ ...s, creatingScene: true }));
|
||||
try {
|
||||
const sceneId = randomId('scene') as SceneId;
|
||||
const scene: Scene = {
|
||||
id: sceneId,
|
||||
title: `Новая сцена`,
|
||||
description: '',
|
||||
previewAssetId: null,
|
||||
previewThumbAssetId: null,
|
||||
previewAssetType: null,
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||
connections: [],
|
||||
layout: { x: 0, y: 0 },
|
||||
};
|
||||
await api.invoke(ipcChannels.project.updateScene, {
|
||||
sceneId,
|
||||
patch: {
|
||||
title: scene.title,
|
||||
description: scene.description,
|
||||
media: scene.media,
|
||||
settings: scene.settings,
|
||||
layout: scene.layout,
|
||||
previewAssetId: scene.previewAssetId,
|
||||
previewAssetType: scene.previewAssetType,
|
||||
previewVideoAutostart: scene.previewVideoAutostart,
|
||||
},
|
||||
});
|
||||
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId });
|
||||
const res = await api.invoke(ipcChannels.project.get, {});
|
||||
setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId }));
|
||||
} finally {
|
||||
setState((s) => ({ ...s, creatingScene: false }));
|
||||
}
|
||||
})();
|
||||
createSceneInFlightRef.current = job;
|
||||
void job.finally(() => {
|
||||
if (createSceneInFlightRef.current === job) createSceneInFlightRef.current = null;
|
||||
});
|
||||
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId });
|
||||
const res = await api.invoke(ipcChannels.project.get, {});
|
||||
setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId }));
|
||||
return job;
|
||||
};
|
||||
|
||||
const createScenesFromMediaPaths = async (
|
||||
items: { filePath: string; title: string }[],
|
||||
): Promise<{ created: number; lastSceneId: SceneId | null }> => {
|
||||
if (items.length === 0) return { created: 0, lastSceneId: null };
|
||||
if (createSceneInFlightRef.current) {
|
||||
await createSceneInFlightRef.current;
|
||||
}
|
||||
const p = projectRef.current;
|
||||
if (!p) return { created: 0, lastSceneId: null };
|
||||
|
||||
let created = 0;
|
||||
let lastSceneId: SceneId | null = null;
|
||||
const job = (async () => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
creatingScene: true,
|
||||
sceneBatchImport: { current: 0, total: items.length, fileName: items[0]?.title ?? '' },
|
||||
}));
|
||||
try {
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i]!;
|
||||
setState((s) => ({
|
||||
...s,
|
||||
sceneBatchImport: {
|
||||
current: i + 1,
|
||||
total: items.length,
|
||||
fileName: item.title,
|
||||
},
|
||||
}));
|
||||
const sceneId = randomId('scene') as SceneId;
|
||||
await api.invoke(ipcChannels.project.updateScene, {
|
||||
sceneId,
|
||||
patch: {
|
||||
title: item.title,
|
||||
description: '',
|
||||
media: { videos: [], audios: [] },
|
||||
settings: {
|
||||
autoplayVideo: false,
|
||||
autoplayAudio: true,
|
||||
loopVideo: true,
|
||||
loopAudio: true,
|
||||
},
|
||||
layout: { x: 0, y: 0 },
|
||||
previewAssetId: null,
|
||||
previewAssetType: null,
|
||||
previewVideoAutostart: false,
|
||||
},
|
||||
});
|
||||
const previewRes = await api.invoke(ipcChannels.project.importScenePreview, {
|
||||
sceneId,
|
||||
filePath: item.filePath,
|
||||
});
|
||||
setState((s) => {
|
||||
const nextImports = { ...s.scenePreviewImports };
|
||||
if (previewRes.assetId !== null && previewRes.background) {
|
||||
nextImports[sceneId] = { assetId: previewRes.assetId, phase: 'queued' };
|
||||
}
|
||||
return {
|
||||
...s,
|
||||
project: previewRes.project,
|
||||
scenePreviewImports: nextImports,
|
||||
};
|
||||
});
|
||||
created += 1;
|
||||
lastSceneId = sceneId;
|
||||
}
|
||||
if (lastSceneId) {
|
||||
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: lastSceneId });
|
||||
const res = await api.invoke(ipcChannels.project.get, {});
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: lastSceneId,
|
||||
}));
|
||||
}
|
||||
await refreshProjects();
|
||||
} finally {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
creatingScene: false,
|
||||
sceneBatchImport: null,
|
||||
}));
|
||||
}
|
||||
})();
|
||||
createSceneInFlightRef.current = job;
|
||||
void job.finally(() => {
|
||||
if (createSceneInFlightRef.current === job) createSceneInFlightRef.current = null;
|
||||
});
|
||||
await job;
|
||||
return { created, lastSceneId };
|
||||
};
|
||||
|
||||
const selectScene = async (id: SceneId) => {
|
||||
@@ -196,12 +495,69 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const importCampaignAudioFromPaths = async (filePaths: string[]) => {
|
||||
if (filePaths.length === 0) return;
|
||||
const res = await api.invoke(ipcChannels.project.importCampaignAudio, { filePaths });
|
||||
if (res.imported.length === 0) return;
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const updateCampaignAudios = async (next: Project['campaignAudios']) => {
|
||||
const res = await api.invoke(ipcChannels.project.updateCampaignAudios, { audios: next });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const upsertMaterial = async (input: {
|
||||
materialId?: MaterialId;
|
||||
name: string;
|
||||
filePath?: string;
|
||||
}) => {
|
||||
const res = await api.invoke(ipcChannels.project.upsertMaterial, input);
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const deleteMaterial = async (materialId: MaterialId) => {
|
||||
const res = await api.invoke(ipcChannels.project.deleteMaterial, { materialId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const setMaterialsOrder = async (materialIds: MaterialId[]) => {
|
||||
const res = await api.invoke(ipcChannels.project.setMaterialsOrder, { materialIds });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const setMaterialRotation = async (
|
||||
materialId: MaterialId,
|
||||
rotationDeg: 0 | 90 | 180 | 270,
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.setMaterialRotation, {
|
||||
materialId,
|
||||
rotationDeg,
|
||||
});
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const setMaterialLegend = async (
|
||||
materialId: MaterialId,
|
||||
legend: import('../../../shared/types').MaterialLegend | null,
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.setMaterialLegend, { materialId, legend });
|
||||
// Список проектов не меняется — не дергаем refreshProjects на каждое обновление легенды.
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const pickMaterialImage = async () => {
|
||||
const res = await api.invoke(ipcChannels.project.pickMaterialImage, {});
|
||||
if (res.canceled) return null;
|
||||
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
|
||||
};
|
||||
|
||||
const updateScene = async (
|
||||
sceneId: SceneId,
|
||||
patch: {
|
||||
@@ -212,6 +568,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
previewAssetType?: 'image' | 'video' | null;
|
||||
previewVideoAutostart?: boolean;
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
traps?: import('../../../shared/types').SceneTrap[];
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
layout?: { x: number; y: number };
|
||||
@@ -237,6 +595,10 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
...(patch.previewRotationDeg !== undefined
|
||||
? { previewRotationDeg: patch.previewRotationDeg }
|
||||
: null),
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
||||
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
|
||||
...(patch.grid !== undefined ? { grid: patch.grid } : null),
|
||||
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
||||
@@ -268,10 +630,44 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const importMediaToSceneFromPaths = async (sceneId: SceneId, filePaths: string[]) => {
|
||||
if (filePaths.length === 0) return;
|
||||
const res = await api.invoke(ipcChannels.project.importMedia, { sceneId, filePaths });
|
||||
if (res.imported.length === 0) return;
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const importScenePreview = async (sceneId: SceneId) => {
|
||||
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
if (res.assetId !== null && res.background) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
scenePreviewImports: {
|
||||
...s.scenePreviewImports,
|
||||
[sceneId]: { assetId: res.assetId, phase: 'queued' },
|
||||
},
|
||||
}));
|
||||
}
|
||||
await refreshProjects();
|
||||
return { assetId: res.assetId, background: res.background };
|
||||
};
|
||||
|
||||
const importScenePreviewFromPath = async (sceneId: SceneId, filePath: string) => {
|
||||
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId, filePath });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
if (res.assetId !== null && res.background) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
scenePreviewImports: {
|
||||
...s.scenePreviewImports,
|
||||
[sceneId]: { assetId: res.assetId, phase: 'queued' },
|
||||
},
|
||||
}));
|
||||
}
|
||||
await refreshProjects();
|
||||
return { assetId: res.assetId, background: res.background };
|
||||
};
|
||||
|
||||
const clearScenePreview = async (sceneId: SceneId) => {
|
||||
@@ -324,6 +720,16 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const setSceneGraphNodeSideStoryStart = async (graphNodeId: GraphNodeId) => {
|
||||
const res = await api.invoke(ipcChannels.project.setSceneGraphNodeSideStoryStart, { graphNodeId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const updateSideStoryLineTitle = async (graphNodeId: GraphNodeId, title: string) => {
|
||||
const res = await api.invoke(ipcChannels.project.updateSideStoryLineTitle, { graphNodeId, title });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const deleteScene = async (sceneId: SceneId) => {
|
||||
const res = await api.invoke(ipcChannels.project.deleteScene, { sceneId });
|
||||
setState((s) => ({
|
||||
@@ -334,6 +740,16 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const setSceneListOrder = async (sceneListOrder: SceneId[]) => {
|
||||
setState((s) => {
|
||||
const p = s.project;
|
||||
if (!p) return s;
|
||||
return { ...s, project: { ...p, sceneListOrder } };
|
||||
});
|
||||
const res = await api.invoke(ipcChannels.project.setSceneListOrder, { sceneListOrder });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const renameProject = async (name: string, fileBaseName: string) => {
|
||||
const res = await api.invoke(ipcChannels.project.rename, { name, fileBaseName });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
@@ -341,19 +757,145 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
};
|
||||
|
||||
const importProject = async () => {
|
||||
const res = await api.invoke(ipcChannels.project.importZip, {});
|
||||
if (res.canceled) return;
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.importZip, {});
|
||||
if (res.canceled) return;
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId,
|
||||
}));
|
||||
await refreshProjects();
|
||||
} finally {
|
||||
setState((s) => ({ ...s, zipProgress: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const importProjectFromPath = async (filePath: string) => {
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.importZipFromPath, { filePath });
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId,
|
||||
}));
|
||||
await refreshProjects();
|
||||
} finally {
|
||||
setState((s) => ({ ...s, zipProgress: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const pickFoundrySource = async (mode: 'folder' | 'archive') => {
|
||||
return api.invoke(ipcChannels.project.pickFoundrySource, { mode });
|
||||
};
|
||||
|
||||
const importFoundryProject = async (sourcePath: string) => {
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.importFoundry, { sourcePath });
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId,
|
||||
}));
|
||||
await refreshProjects();
|
||||
} finally {
|
||||
setState((s) => ({ ...s, zipProgress: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const peekImportZip = async (labels: StorylineLabels, targetHasMainStart: boolean) => {
|
||||
return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart });
|
||||
};
|
||||
|
||||
const pickImportZipFile = async () => {
|
||||
return api.invoke(ipcChannels.project.pickImportZipFile, {});
|
||||
};
|
||||
|
||||
const peekImportZipPath = async (
|
||||
filePath: string,
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
) => {
|
||||
return api.invoke(ipcChannels.project.peekImportZipPath, { filePath, labels, targetHasMainStart });
|
||||
};
|
||||
|
||||
const peekImportFromProject = async (
|
||||
sourceProjectId: ProjectId,
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
) => {
|
||||
return api.invoke(ipcChannels.project.peekImportFromProject, {
|
||||
sourceProjectId,
|
||||
labels,
|
||||
targetHasMainStart,
|
||||
});
|
||||
};
|
||||
|
||||
const mergeImportZip = async (
|
||||
filePath: string,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.mergeImportZip, {
|
||||
filePath,
|
||||
storylineSelections,
|
||||
sceneResolutions,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
});
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId,
|
||||
selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
|
||||
}));
|
||||
await refreshProjects();
|
||||
return res;
|
||||
};
|
||||
|
||||
const exportProject = async (projectId: ProjectId) => {
|
||||
const res = await api.invoke(ipcChannels.project.exportZip, { projectId });
|
||||
if (res.canceled) return;
|
||||
const mergeImportFromProject = async (
|
||||
sourceProjectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.mergeImportFromProject, {
|
||||
sourceProjectId,
|
||||
storylineSelections,
|
||||
sceneResolutions,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
});
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
|
||||
}));
|
||||
return res;
|
||||
};
|
||||
|
||||
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
|
||||
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
|
||||
return {
|
||||
storylines: Array.isArray(res?.storylines) ? res.storylines : [],
|
||||
npcs: Array.isArray(res?.npcs) ? res.npcs : [],
|
||||
};
|
||||
};
|
||||
|
||||
const exportProject = async (
|
||||
projectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
labels: StorylineLabels,
|
||||
) => {
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.exportZip, {
|
||||
projectId,
|
||||
storylineSelections,
|
||||
npcIds,
|
||||
labels,
|
||||
});
|
||||
if (res.canceled) return;
|
||||
} finally {
|
||||
setState((s) => ({ ...s, zipProgress: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProject = async (projectId: ProjectId) => {
|
||||
@@ -375,13 +917,23 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
openProject,
|
||||
closeProject,
|
||||
createScene,
|
||||
createScenesFromMediaPaths,
|
||||
selectScene,
|
||||
importCampaignAudio,
|
||||
importCampaignAudioFromPaths,
|
||||
updateCampaignAudios,
|
||||
upsertMaterial,
|
||||
deleteMaterial,
|
||||
setMaterialsOrder,
|
||||
setMaterialRotation,
|
||||
setMaterialLegend,
|
||||
pickMaterialImage,
|
||||
updateScene,
|
||||
updateConnections,
|
||||
importMediaToScene,
|
||||
importMediaToSceneFromPaths,
|
||||
importScenePreview,
|
||||
importScenePreviewFromPath,
|
||||
clearScenePreview,
|
||||
updateSceneGraphNodePosition,
|
||||
addSceneGraphNode,
|
||||
@@ -389,9 +941,22 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
addSceneGraphEdge,
|
||||
removeSceneGraphEdge,
|
||||
setSceneGraphNodeStart,
|
||||
setSceneGraphNodeSideStoryStart,
|
||||
updateSideStoryLineTitle,
|
||||
deleteScene,
|
||||
setSceneListOrder,
|
||||
renameProject,
|
||||
importProject,
|
||||
importProjectFromPath,
|
||||
pickFoundrySource,
|
||||
importFoundryProject,
|
||||
peekImportZip,
|
||||
pickImportZipFile,
|
||||
peekImportZipPath,
|
||||
peekImportFromProject,
|
||||
mergeImportZip,
|
||||
mergeImportFromProject,
|
||||
getProjectStorylines,
|
||||
exportProject,
|
||||
deleteProject,
|
||||
};
|
||||
@@ -418,7 +983,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project?.currentSceneId ?? null,
|
||||
// Восстановление открытого проекта — без автовыбора сцены в редакторе.
|
||||
selectedSceneId: null,
|
||||
}));
|
||||
} catch {
|
||||
if (projectDataEpochRef.current !== epoch) return;
|
||||
|
||||
@@ -2,23 +2,102 @@
|
||||
export const EULA_RU_MARKDOWN = `
|
||||
# Лицензионное соглашение с конечным пользователем (EULA)
|
||||
|
||||
Используя DNDGamePlayer («Программу»), вы соглашаетесь с условиями ниже.
|
||||
Настоящее соглашение регулирует использование программы «НРИ Плеер» («Программа»).
|
||||
|
||||
Правообладатель: Фонтош И.С., физическое лицо.
|
||||
|
||||
Используя Программу, устанавливая её, активируя лицензионный ключ или нажимая кнопку принятия условий, пользователь подтверждает, что прочитал и принимает настоящее соглашение.
|
||||
|
||||
## 1. Предоставление прав
|
||||
Правообладатель предоставляет вам неисключическую, непередаваемую лицензию на использование Программы в пределах приобретённой лицензии (активации).
|
||||
|
||||
Правообладатель предоставляет пользователю простую (неисключительную), непередаваемую, ограниченную лицензию на установку и использование Программы в личных некоммерческих целях, связанных с подготовкой и проведением настольных ролевых игр.
|
||||
|
||||
Исключительные права на Программу не передаются пользователю. Все права, прямо не предоставленные настоящим соглашением, сохраняются за правообладателем.
|
||||
|
||||
## 2. Активация, срок, устройства
|
||||
Доступ к функциям может требовать онлайн- или офлайн-активации с помощью ключа. Лицензия может быть ограничена сроком действия и числом устройств. Подробности отображаются в разделе «О лицензии».
|
||||
|
||||
## 3. Отзыв
|
||||
Правообладатель вправе отозвать лицензию при нарушении условий или по иным основаниям, предусмотренным офертой. После отзыва Программа может ограничить доступ к функциям без обновления установленного у вас клиента (проверка статуса при наличии сети).
|
||||
Доступ к функциям Программы может предоставляться на основании лицензионного ключа, пробного периода или иного разрешения правообладателя.
|
||||
|
||||
## 4. Отказ от гарантий
|
||||
Программа поставляется «как есть». По максимуму, допускаемому применимым правом, исключаются гарантии любого рода.
|
||||
Доступные варианты лицензии:
|
||||
|
||||
## 5. Ограничение ответственности
|
||||
Ответственность ограничивается суммой, уплаченной за лицензию, если иное не установлено императивным правом.
|
||||
- Базовый ключ: доступ на 1 год, до 3 устройств.
|
||||
- Ключ мастера: доступ на 2 года, до 5 устройств.
|
||||
- Легенда НРИ: доступ на 99 лет, до 15 устройств.
|
||||
- Пробный доступ: предоставляется бесплатно на условиях, указанных правообладателем.
|
||||
|
||||
## 6. Применимое право
|
||||
Применимое право и разрешение споров — в соответствии с документами, сопровождающими вашу покупку, либо по выбору правообладателя, если отдельные документы не согласованы.
|
||||
Срок доступа и число устройств определяются выданным лицензионным ключом и отображаются в разделе «О лицензии».
|
||||
|
||||
## 3. Поддержка проекта
|
||||
|
||||
Пользователь может добровольно поддержать проект. Такая поддержка является добровольным взносом в развитие Программы и не является обязательным условием заключения договора купли-продажи.
|
||||
|
||||
В знак благодарности за поддержку проекта правообладатель может вручную предоставить пользователю лицензионный ключ соответствующего уровня доступа. Условия предоставления ключей публикуются правообладателем или сообщаются пользователю отдельно.
|
||||
|
||||
## 4. Активация и проверка лицензии
|
||||
|
||||
Для активации Программа отправляет на сервер лицензирования лицензионный ключ и технический идентификатор физической машины (deviceId). Для проверки отзыва лицензии Программа может отправлять идентификатор лицензии (sub).
|
||||
|
||||
Программа не отправляет на сервер имя пользователя, адрес электронной почты, содержимое проектов, сцены, изображения, музыку, заметки, кампании или иные пользовательские материалы.
|
||||
|
||||
Лицензионный токен хранится локально на устройстве пользователя с использованием защищённого хранилища операционной системы, если оно доступно.
|
||||
|
||||
## 5. Ограничения
|
||||
|
||||
Пользователь не вправе:
|
||||
|
||||
- передавать, продавать, сдавать в аренду или публиковать лицензионный ключ;
|
||||
- обходить активацию, технические ограничения или механизмы защиты;
|
||||
- модифицировать, декомпилировать, дизассемблировать или иным образом пытаться получить исходный код Программы, кроме случаев, прямо разрешённых законом;
|
||||
- распространять копии Программы от имени правообладателя без разрешения;
|
||||
- использовать Программу способом, нарушающим законодательство РФ или права третьих лиц.
|
||||
|
||||
## 6. Пользовательские материалы
|
||||
|
||||
Проекты, сцены, изображения, музыка, тексты и иные материалы, загружаемые пользователем в Программу, остаются у пользователя. Правообладатель не получает прав на такие материалы и не имеет доступа к ним через механизм лицензирования.
|
||||
|
||||
Пользователь самостоятельно отвечает за наличие прав на материалы, которые он использует в своих проектах.
|
||||
|
||||
## 7. Обновления
|
||||
|
||||
Правообладатель может выпускать обновления Программы. Все обновления Программы предоставляются бесплатно, если правообладатель отдельно не объявит иные условия для будущих продуктов или отдельных редакций.
|
||||
|
||||
Правообладатель не обязан выпускать обновления в определённые сроки.
|
||||
|
||||
## 8. Отзыв и ограничение доступа
|
||||
|
||||
Правообладатель вправе отозвать или заблокировать лицензию при нарушении настоящего соглашения, передаче ключа третьим лицам, попытке обхода защиты, злоупотреблении числом устройств или ином недобросовестном использовании.
|
||||
|
||||
После отзыва лицензии Программа может ограничить доступ к функциям при наличии сетевого подключения и успешной проверке статуса лицензии.
|
||||
|
||||
## 9. Отказ от гарантий
|
||||
|
||||
Программа предоставляется «как есть». Правообладатель не гарантирует, что Программа будет работать без ошибок, соответствовать всем ожиданиям пользователя или быть совместимой с любым оборудованием и программным окружением.
|
||||
|
||||
Настоящий раздел применяется в пределах, допускаемых законодательством РФ.
|
||||
|
||||
## 10. Ограничение ответственности
|
||||
|
||||
Правообладатель не несёт ответственности за потерю пользовательских данных, невозможность проведения игры, косвенные убытки, упущенную выгоду или последствия использования пользовательских материалов, если иное прямо не установлено обязательными нормами законодательства РФ.
|
||||
|
||||
Ответственность правообладателя в любом случае ограничивается суммой фактически полученной от пользователя поддержки за соответствующий лицензионный ключ, если иное не установлено законом.
|
||||
|
||||
## 11. Персональные данные и конфиденциальность
|
||||
|
||||
Программа не запрашивает и не передаёт персональные данные пользователя при активации лицензии. Для работы лицензирования используются только технические данные: лицензионный ключ, deviceId, идентификатор лицензии и технические данные сетевого соединения, которые могут автоматически обрабатываться сервером.
|
||||
|
||||
Если в будущем Программа или связанные с ней сервисы начнут собирать персональные данные, их обработка будет регулироваться отдельной политикой конфиденциальности.
|
||||
|
||||
## 12. Применимое право и споры
|
||||
|
||||
К настоящему соглашению применяется право Российской Федерации.
|
||||
|
||||
Стороны стремятся решать споры путём переговоров. Если спор не урегулирован, он рассматривается в соответствии с законодательством РФ.
|
||||
|
||||
Если пользователь является потребителем, его права, предоставленные императивными нормами законодательства о защите прав потребителей, не ограничиваются настоящим соглашением.
|
||||
|
||||
## 13. Изменение соглашения
|
||||
|
||||
Правообладатель вправе изменять настоящее соглашение. При существенном изменении условий Программа может запросить повторное принятие новой версии соглашения.
|
||||
|
||||
Продолжение использования Программы после принятия новой версии означает согласие пользователя с обновлёнными условиями.
|
||||
`.trim();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>TTRPG</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/materials/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
:global(html),
|
||||
:global(body),
|
||||
:global(#root) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
background: var(--bg0);
|
||||
color: var(--text0);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.browser {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { MaterialId } from '../../shared/types';
|
||||
import { MaterialsBrowser } from '../editor/MaterialsBrowser';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './MaterialsApp.module.css';
|
||||
|
||||
function ZoomInIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path
|
||||
d="M10.5 7.8v5.4M7.8 10.5h5.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomOutIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MaterialsApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [overlay, overlayApi] = useMaterialsOverlayState();
|
||||
const [selectedId, setSelectedId] = useState<MaterialId | null>(null);
|
||||
const onSelect = useCallback((id: MaterialId | null) => setSelectedId(id), []);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
||||
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
|
||||
const mats = project?.materials ?? [];
|
||||
setSelectedId(mats[0]?.id ?? null);
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if ((overlay?.activeMaterialIds?.length ?? 0) > 0) {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
return;
|
||||
}
|
||||
if (overlay?.zoomTool) {
|
||||
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [overlay?.activeMaterialIds, overlay?.zoomTool, overlayApi]);
|
||||
|
||||
const materials = session?.project?.materials ?? [];
|
||||
const activeIds = overlay?.activeMaterialIds ?? [];
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<MaterialsBrowser
|
||||
mode="runtime"
|
||||
fillHeight
|
||||
listOnly
|
||||
className={styles.browser}
|
||||
materials={materials}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
activeMaterialIds={activeIds}
|
||||
onTileActivate={(id) => {
|
||||
const mat = materials.find((m) => m.id === id);
|
||||
void overlayApi.dispatch({
|
||||
kind: 'toggle',
|
||||
materialId: id,
|
||||
rotationDeg: mat?.rotationDeg ?? 0,
|
||||
});
|
||||
}}
|
||||
toolbar={
|
||||
<>
|
||||
<div className={matStyles.browserToolbarZoomRow}>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('materials.zoomIn')}
|
||||
ariaLabel={t('materials.zoomIn')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({
|
||||
kind: 'zoomTool.set',
|
||||
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('materials.zoomOut')}
|
||||
ariaLabel={t('materials.zoomOut')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({
|
||||
kind: 'zoomTool.set',
|
||||
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{activeIds.length > 0 ? (
|
||||
<div className={matStyles.browserToolbarFullBtn}>
|
||||
<Button
|
||||
title={t('materials.closeOverlay')}
|
||||
ariaLabel={t('materials.closeOverlay')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
}}
|
||||
>
|
||||
{t('materials.closeOverlay')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={matStyles.browserToolbarHint}>
|
||||
{zoomTool === 'zoomIn'
|
||||
? t('materials.zoomInHint')
|
||||
: zoomTool === 'zoomOut'
|
||||
? t('materials.zoomOutHint')
|
||||
: t('materials.zoomIdleHint')}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { MaterialsApp } from './MaterialsApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Материалы">
|
||||
<EditorI18nProvider>
|
||||
<MaterialsApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>TTRPG</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/npcs/npcsMain.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,163 @@
|
||||
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 { normalizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import modalStyles from '../editor/SceneDescriptionModal.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
|
||||
import styles from './NpcsEditorApp.module.css';
|
||||
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
|
||||
|
||||
type NpcDescriptionFieldProps = {
|
||||
html: string;
|
||||
onCommit: (html: string) => void;
|
||||
};
|
||||
|
||||
function ToolButton({
|
||||
active = false,
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-pressed={active}
|
||||
className={[modalStyles.toolBtn, active ? modalStyles.toolBtnActive : ''].filter(Boolean).join(' ')}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps) {
|
||||
const { t } = useEditorI18n();
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: t('npcs.descriptionPlaceholder'),
|
||||
}),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: html || '',
|
||||
// StrictMode + true даёт destroy/recreate с null schema → падение getHTML (чёрный экран окна НПС).
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: [modalStyles.prose, 'tiptap'].join(' '),
|
||||
'aria-label': t('npcs.description'),
|
||||
},
|
||||
},
|
||||
onBlur: ({ editor: ed }) => {
|
||||
const raw = readTipTapHtmlSafe(ed);
|
||||
if (raw == null) return;
|
||||
onCommit(normalizeSceneDescriptionHtml(raw));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
const raw = readTipTapHtmlSafe(editor);
|
||||
if (raw == null) return;
|
||||
const current = normalizeSceneDescriptionHtml(raw);
|
||||
const next = normalizeSceneDescriptionHtml(html);
|
||||
if (current !== next) {
|
||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||
}
|
||||
}, [editor, html]);
|
||||
|
||||
const toolbarState = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: ed }) => ({
|
||||
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||||
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||||
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 })),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!editor || editor.isDestroyed) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.descShell}>
|
||||
<div className={modalStyles.toolbar}>
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState?.bold ?? false}
|
||||
title={t('scene.descriptionBold')}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
B
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState?.italic ?? false}
|
||||
title={t('scene.descriptionItalic')}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
I
|
||||
</ToolButton>
|
||||
</div>
|
||||
<div className={modalStyles.toolbarSep} />
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState?.h2 ?? false}
|
||||
title={t('scene.descriptionHeading2')}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
H2
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState?.h3 ?? false}
|
||||
title={t('scene.descriptionHeading3')}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
H3
|
||||
</ToolButton>
|
||||
</div>
|
||||
<div className={modalStyles.toolbarSep} />
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState?.bulletList ?? false}
|
||||
title={t('scene.descriptionBulletList')}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
•
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState?.orderedList ?? false}
|
||||
title={t('scene.descriptionOrderedList')}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
1.
|
||||
</ToolButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.descContent}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
|
||||
import type { NpcGroupId, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from '../editor/fileDrop';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
function normalizeName(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function flattenGroupOptions(
|
||||
nodes: ReturnType<typeof buildNpcGroupForest>['roots'],
|
||||
depth = 0,
|
||||
): { id: NpcGroupId; label: string }[] {
|
||||
const out: { id: NpcGroupId; label: string }[] = [];
|
||||
for (const node of nodes) {
|
||||
const prefix = depth > 0 ? ' '.repeat(depth) : '';
|
||||
out.push({ id: node.group.id, label: `${prefix}${node.group.name}` });
|
||||
out.push(...flattenGroupOptions(node.children, depth + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type NpcEditModalProps = {
|
||||
open: boolean;
|
||||
initial: ProjectNpc | null;
|
||||
existingNames: string[];
|
||||
npcGroups: ProjectNpcGroup[];
|
||||
onClose: () => void;
|
||||
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
onSave: (input: {
|
||||
name: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
export function NpcEditModal({
|
||||
open,
|
||||
initial,
|
||||
existingNames,
|
||||
npcGroups,
|
||||
onClose,
|
||||
onPickImage,
|
||||
onSave,
|
||||
}: NpcEditModalProps) {
|
||||
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 [groupId, setGroupId] = useState<NpcGroupId | ''>('');
|
||||
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?.avatarAssetId ?? null);
|
||||
|
||||
const groupOptions = useMemo(() => {
|
||||
const { roots } = buildNpcGroupForest(npcGroups, []);
|
||||
return flattenGroupOptions(roots);
|
||||
}, [npcGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? '');
|
||||
setFilePath(null);
|
||||
setLocalPreviewUrl(null);
|
||||
setGroupId(initial?.groupId ?? '');
|
||||
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.npcUpsertProgress, (evt) => {
|
||||
setSaveProgress({
|
||||
percent: Math.max(0, Math.min(100, Math.round(evt.percent))),
|
||||
detail: evt.detail?.trim() || t('npcs.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?.avatarAssetId);
|
||||
const canSave = nameOk && !nameDup && hasImage && !saving;
|
||||
const previewSrc = localPreviewUrl ?? existingUrl;
|
||||
const progressPercent = saveProgress?.percent ?? (saving ? 0 : 0);
|
||||
const progressDetail = saveProgress?.detail ?? t('npcs.savingWait');
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!saving) onClose();
|
||||
}}
|
||||
className={editorStyles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editTitle') : t('npcs.addTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!saving) onClose();
|
||||
}}
|
||||
className={editorStyles.modalClose}
|
||||
disabled={saving}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.name')}</div>
|
||||
<Input value={name} onChange={setName} placeholder={t('npcs.namePlaceholder')} />
|
||||
{!nameOk ? <div className={editorStyles.fieldError}>{t('npcs.nameRequired')}</div> : null}
|
||||
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null}
|
||||
</div>
|
||||
|
||||
{!initial && groupOptions.length > 0 ? (
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.group')}</div>
|
||||
<Select
|
||||
value={groupId}
|
||||
ariaLabel={t('npcs.group')}
|
||||
onChange={(next) => setGroupId(next as NpcGroupId | '')}
|
||||
options={[
|
||||
{ value: '', label: t('npcs.ungrouped') },
|
||||
...groupOptions.map((g) => ({ value: g.id, label: g.label })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.avatar')}</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 (!entry || !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={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div> : null}
|
||||
{previewSrc ? (
|
||||
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
|
||||
) : (
|
||||
<div className={matStyles.imageDropEmpty}>
|
||||
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const picked = await onPickImage();
|
||||
if (!picked) return;
|
||||
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('npcs.chooseAvatar')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{previewSrc ? (
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const picked = await onPickImage();
|
||||
if (!picked) return;
|
||||
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('npcs.chooseAvatar')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
|
||||
</div>
|
||||
|
||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={editorStyles.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('npcs.savingWait') });
|
||||
setError(null);
|
||||
});
|
||||
try {
|
||||
await onSave({
|
||||
name: trimmed,
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(!initial ? { groupId: groupId || null } : {}),
|
||||
});
|
||||
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={editorStyles.progressOverlay}
|
||||
role="dialog"
|
||||
aria-label={t('npcs.savingProgress')}
|
||||
aria-busy
|
||||
>
|
||||
<div className={editorStyles.progressModal}>
|
||||
<div className={editorStyles.progressTitle}>{t('npcs.savingTitle')}</div>
|
||||
<div className={editorStyles.previewSpinner} aria-hidden />
|
||||
<div className={editorStyles.progressBar}>
|
||||
<div
|
||||
className={editorStyles.progressFill}
|
||||
style={{ width: `${String(Math.max(0, Math.min(100, progressPercent)))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={editorStyles.progressMeta}>
|
||||
<div>{progressDetail}</div>
|
||||
<div>{progressPercent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
.wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #0c0c0f;
|
||||
}
|
||||
|
||||
.zoomBar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.zoomBtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: #18181b;
|
||||
color: var(--text1);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.zoomBtn:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.node {
|
||||
width: 120px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
border-left-width: 3px;
|
||||
border-left-color: var(--npc-group-color, var(--stroke));
|
||||
}
|
||||
|
||||
.nodeActive {
|
||||
border-color: #60a5fa;
|
||||
border-left-color: var(--npc-group-color, #60a5fa);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--npc-group-color, #60a5fa),
|
||||
0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.nodeDimmed {
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--text1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.handle {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
background: #71717a !important;
|
||||
border: 2px solid #18181b !important;
|
||||
}
|
||||
|
||||
.handle:hover {
|
||||
background: #60a5fa !important;
|
||||
}
|
||||
|
||||
.edgeLabel {
|
||||
pointer-events: all;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
border: 1px solid var(--stroke);
|
||||
color: var(--text1);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%);
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.edgeLabelActive {
|
||||
border-color: #60a5fa;
|
||||
color: #93c5fd;
|
||||
box-shadow: 0 0 0 1px #60a5fa;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.menuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 79;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
min-width: 160px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: #18181b;
|
||||
border: 1px solid var(--stroke);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text1);
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.menuItemDanger:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 160px;
|
||||
width: auto;
|
||||
min-height: 30px;
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
color: var(--text1);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user