feat(npcs): add groups, storyline bindings, and Foundry import

Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-20 13:19:22 +08:00
parent 37ba855faf
commit f4c0ac1438
41 changed files with 5210 additions and 418 deletions
+242
View File
@@ -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]),
};
}
+209
View File
@@ -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;
}
+654
View File
@@ -0,0 +1,654 @@
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 { noneBinding } from '../../shared/npcs/npcBinding';
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,
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,
binding: noneBinding(),
});
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;
}
}
+86 -7
View File
@@ -625,7 +625,9 @@ async function main() {
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`; const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl }; return { canceled: false as const, filePath, previewDataUrl };
}); });
registerHandler(ipcChannels.project.upsertNpc, async ({ npcId, name, description, filePath: pathFromDrop }) => { registerHandler(
ipcChannels.project.upsertNpc,
async ({ npcId, name, description, filePath: pathFromDrop, groupId, binding }) => {
let filePath = pathFromDrop; let filePath = pathFromDrop;
if (!filePath && !npcId) { if (!filePath && !npcId) {
const { canceled, filePaths } = await dialog.showOpenDialog({ const { canceled, filePaths } = await dialog.showOpenDialog({
@@ -647,20 +649,28 @@ async function main() {
name, name,
...(typeof description === 'string' ? { description } : {}), ...(typeof description === 'string' ? { description } : {}),
...(filePath ? { filePath } : {}), ...(filePath ? { filePath } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(binding !== undefined ? { binding } : {}),
}); });
syncNpcsOverlayWithProject(project); syncNpcsOverlayWithProject(project);
emitNpcsOverlayState(); emitNpcsOverlayState();
emitSessionState(); emitSessionState();
return { project }; return { project };
}); },
registerHandler(ipcChannels.project.updateNpcFields, async ({ npcId, name, description }) => { );
registerHandler(
ipcChannels.project.updateNpcFields,
async ({ npcId, name, description, groupId, binding }) => {
const project = await projectStore.updateNpcFields(npcId, { const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}), ...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}), ...(typeof description === 'string' ? { description } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(binding !== undefined ? { binding } : {}),
}); });
emitSessionState(); emitSessionState();
return { project }; return { project };
}); },
);
registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => { registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => {
const project = await projectStore.updateNpcPosition(npcId, x, y); const project = await projectStore.updateNpcPosition(npcId, x, y);
emitSessionState(); emitSessionState();
@@ -715,6 +725,31 @@ async function main() {
emitSessionState(); emitSessionState();
return { project }; return { project };
}); });
registerHandler(
ipcChannels.project.upsertNpcGroup,
async ({ groupId, name, color, parentId }) => {
const project = await projectStore.upsertNpcGroup({
...(groupId ? { groupId } : {}),
name,
...(typeof color === 'string' ? { color } : {}),
...(parentId !== undefined ? { parentId } : {}),
});
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => {
const project = await projectStore.deleteNpcGroup(groupId);
syncNpcsOverlayWithProject(project);
emitNpcsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setNpcGroupsOrder, async ({ groupIds }) => {
const project = await projectStore.setNpcGroupsOrder(groupIds);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => { registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
let filePath = pathFromDrop; let filePath = pathFromDrop;
if (!filePath) { if (!filePath) {
@@ -880,7 +915,9 @@ async function main() {
registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => { registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => {
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart); return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
}); });
registerHandler(ipcChannels.project.mergeImportZip, async ({ filePath, storylineSelections, sceneResolutions }) => { registerHandler(
ipcChannels.project.mergeImportZip,
async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' }); emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromExternalZip( const { project, report } = await projectStore.mergeStorylinesFromExternalZip(
filePath, filePath,
@@ -894,14 +931,16 @@ async function main() {
...(p.detail ? { detail: p.detail } : null), ...(p.detail ? { detail: p.detail } : null),
}); });
}, },
npcResolutions,
); );
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState(); emitSessionState();
return { project, report }; return { project, report };
}); },
);
registerHandler( registerHandler(
ipcChannels.project.mergeImportFromProject, ipcChannels.project.mergeImportFromProject,
async ({ sourceProjectId, storylineSelections, sceneResolutions }) => { async ({ sourceProjectId, storylineSelections, sceneResolutions, npcResolutions }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' }); emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
const { project, report } = await projectStore.mergeStorylinesFromProjectId( const { project, report } = await projectStore.mergeStorylinesFromProjectId(
sourceProjectId, sourceProjectId,
@@ -915,6 +954,7 @@ async function main() {
...(p.detail ? { detail: p.detail } : null), ...(p.detail ? { detail: p.detail } : null),
}); });
}, },
npcResolutions,
); );
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState(); emitSessionState();
@@ -935,6 +975,45 @@ async function main() {
emitSessionState(); emitSessionState();
return { project }; return { project };
}); });
registerHandler(ipcChannels.project.pickFoundrySource, async ({ mode }) => {
if (mode === 'folder') {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openDirectory'],
title: 'Папка мира или модуля Foundry VTT',
});
if (canceled || !filePaths[0]) return { canceled: true as const };
return { canceled: false as const, sourcePath: filePaths[0] };
}
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
title: 'Архив мира или модуля Foundry VTT',
filters: [
{ name: 'Foundry package', extensions: ['zip', 'fvtt'] },
{ name: 'All files', extensions: ['*'] },
],
});
if (canceled || !filePaths[0]) return { canceled: true as const };
return { canceled: false as const, sourcePath: filePaths[0] };
});
registerHandler(ipcChannels.project.importFoundry, async ({ sourcePath }) => {
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт Foundry…' });
try {
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy',
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
return { project };
} catch (e) {
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Ошибка' });
throw e;
}
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => { registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => {
const list = await projectStore.listProjects(); const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId); const entry = list.find((p) => p.id === projectId);
+283 -8
View File
@@ -20,6 +20,7 @@ import {
listImportableStorylines, listImportableStorylines,
mergeStorylinesIntoProject, mergeStorylinesIntoProject,
newExportBundleProjectId, newExportBundleProjectId,
type NpcImportResolution,
type SceneImportResolution, type SceneImportResolution,
type StorylineImportMergeReport, type StorylineImportMergeReport,
type StorylineLabels, type StorylineLabels,
@@ -35,9 +36,11 @@ import {
import type { import type {
MediaAsset, MediaAsset,
MediaAssetType, MediaAssetType,
NpcBinding,
Project, Project,
ProjectId, ProjectId,
ProjectNpc, ProjectNpc,
ProjectNpcGroup,
ProjectNpcRelation, ProjectNpcRelation,
Scene, Scene,
SceneGraphEdge, SceneGraphEdge,
@@ -45,15 +48,34 @@ import type {
SceneId, SceneId,
} from '../../shared/types'; } from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import type { AssetId, GraphNodeId, MaterialId, NpcId, NpcRelationId } from '../../shared/types/ids'; import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
import { import {
asAssetId, asAssetId,
asGraphNodeId, asGraphNodeId,
asMaterialId, asMaterialId,
asNpcGroupId,
asNpcId, asNpcId,
asNpcRelationId, asNpcRelationId,
asProjectId, asProjectId,
} from '../../shared/types/ids'; } from '../../shared/types/ids';
import {
clearNpcBindingsForDeletedScene,
clearNpcBindingsForRemovedStoryline,
noneBinding,
normalizeNpcBinding,
} from '../../shared/npcs/npcBinding';
import {
DEFAULT_NPC_GROUP_COLOR,
normalizeHexColor,
normalizeNpcGroups,
resolveNpcGroupId,
wouldCreateGroupCycle,
} from '../../shared/npcs/npcGroups';
import {
buildProjectFromFoundryDocuments,
loadFoundryDocumentsForImport,
type FoundryImportProgress,
} from '../foundry/foundryImport';
import { getAppSemanticVersion } from '../versionInfo'; import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune'; import { reconcileAssetFiles } from './assetPrune';
@@ -236,6 +258,7 @@ export class ZipProjectStore {
campaignAudios: [], campaignAudios: [],
materials: [], materials: [],
npcs: [], npcs: [],
npcGroups: [],
npcRelations: [], npcRelations: [],
currentSceneId: null, currentSceneId: null,
currentGraphNodeId: null, currentGraphNodeId: null,
@@ -660,9 +683,25 @@ export class ZipProjectStore {
currentSceneId = ids[0] ?? null; currentSceneId = ids[0] ?? null;
} }
const removedSideStarts = p.sceneGraphNodes.filter(
(n) => n.sceneId === sceneId && n.isSideStoryStart,
);
let npcs = clearNpcBindingsForDeletedScene(p.npcs ?? [], sceneId);
for (const side of removedSideStarts) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
kind: 'side',
startGraphNodeId: side.id,
});
}
const hadMainOnScene = p.sceneGraphNodes.some((n) => n.sceneId === sceneId && n.isStartScene);
if (hadMainOnScene) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
}
return { return {
...withGraph, ...withGraph,
scenes: nextScenes, scenes: nextScenes,
npcs,
sceneListOrder: removeFromSceneListOrder( sceneListOrder: removeFromSceneListOrder(
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder), reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
sceneId, sceneId,
@@ -724,8 +763,25 @@ export class ZipProjectStore {
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) { if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
throw new Error('Graph node not found'); throw new Error('Graph node not found');
} }
await this.updateProject((p) => ({ const prevMain = open.project.sceneGraphNodes.find((n) => n.isStartScene);
const clearingMain = graphNodeId === null || (prevMain && prevMain.id !== graphNodeId);
await this.updateProject((p) => {
let npcs = p.npcs ?? [];
if (clearingMain && prevMain) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
}
const demotedSides = p.sceneGraphNodes.filter(
(n) => n.isSideStoryStart && graphNodeId !== null && n.id === graphNodeId,
);
for (const side of demotedSides) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
kind: 'side',
startGraphNodeId: side.id,
});
}
return {
...p, ...p,
npcs,
sceneGraphNodes: p.sceneGraphNodes.map((n) => { sceneGraphNodes: p.sceneGraphNodes.map((n) => {
const isMain = graphNodeId !== null && n.id === graphNodeId; const isMain = graphNodeId !== null && n.id === graphNodeId;
if (isMain) { if (isMain) {
@@ -733,7 +789,8 @@ export class ZipProjectStore {
} }
return { ...n, isStartScene: false }; return { ...n, isStartScene: false };
}), }),
})); };
});
const latest = this.getOpenProject(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project'); if (!latest) throw new Error('No open project');
return latest; return latest;
@@ -754,8 +811,20 @@ export class ZipProjectStore {
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) { if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) {
return open.project; return open.project;
} }
await this.updateProject((p) => ({ await this.updateProject((p) => {
let npcs = p.npcs ?? [];
if (!enabling) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
kind: 'side',
startGraphNodeId: graphNodeId,
});
}
if (enabling && node.isStartScene) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
}
return {
...p, ...p,
npcs,
sceneGraphNodes: p.sceneGraphNodes.map((n) => { sceneGraphNodes: p.sceneGraphNodes.map((n) => {
if (n.id !== graphNodeId) return n; if (n.id !== graphNodeId) return n;
if (enabling) { if (enabling) {
@@ -763,7 +832,8 @@ export class ZipProjectStore {
} }
return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' }; return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' };
}), }),
})); };
});
const latest = this.getOpenProject(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project'); if (!latest) throw new Error('No open project');
return latest; return latest;
@@ -817,7 +887,23 @@ export class ZipProjectStore {
await this.updateProject((p) => { await this.updateProject((p) => {
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges }; const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) }; const npcs = (p.npcs ?? []).map((n) => {
if (
n.binding?.kind === 'storyline' &&
n.binding.storyline.kind === 'side' &&
n.binding.storyline.startGraphNodeId === nodeId
) {
return {
...n,
binding: {
kind: 'storyline' as const,
storyline: { kind: 'side' as const, startGraphNodeId: newStartId },
},
};
}
return n;
});
return { ...withGraph, npcs, scenes: applyConnectionSets(withGraph.scenes, out) };
}); });
const latest = this.getOpenProject(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project'); if (!latest) throw new Error('No open project');
@@ -1154,6 +1240,8 @@ export class ZipProjectStore {
name: string; name: string;
description?: string; description?: string;
filePath?: string; filePath?: string;
groupId?: NpcGroupId | null;
binding?: NpcBinding;
}): Promise<Project> { }): Promise<Project> {
const open = this.openProject; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
@@ -1199,6 +1287,13 @@ export class ZipProjectStore {
const assets = { ...p.assets }; const assets = { ...p.assets };
if (stagedAsset) assets[stagedAsset.id] = stagedAsset; if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
const resolveGroup = (raw: NpcGroupId | null | undefined, prev: NpcGroupId | null): NpcGroupId | null => {
if (raw === undefined) return prev;
if (raw === null) return null;
return groupIds.has(raw) ? raw : null;
};
if (editingId) { if (editingId) {
const idx = npcs.findIndex((n) => n.id === editingId); const idx = npcs.findIndex((n) => n.id === editingId);
if (idx < 0) throw new Error('NPC not found'); if (idx < 0) throw new Error('NPC not found');
@@ -1209,6 +1304,8 @@ export class ZipProjectStore {
avatarAssetId: nextAssetId ?? prev.avatarAssetId, avatarAssetId: nextAssetId ?? prev.avatarAssetId,
description: description:
typeof input.description === 'string' ? input.description : prev.description, typeof input.description === 'string' ? input.description : prev.description,
groupId: resolveGroup(input.groupId, prev.groupId),
binding: input.binding !== undefined ? input.binding : prev.binding,
}; };
} else { } else {
if (!nextAssetId) throw new Error('NPC avatar is required'); if (!nextAssetId) throw new Error('NPC avatar is required');
@@ -1220,6 +1317,8 @@ export class ZipProjectStore {
description: typeof input.description === 'string' ? input.description : '', description: typeof input.description === 'string' ? input.description : '',
x: 80 + (count % 4) * 220, x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200, y: 80 + Math.floor(count / 4) * 200,
groupId: resolveGroup(input.groupId, null),
binding: input.binding ?? noneBinding(),
}); });
} }
return { ...p, assets, npcs }; return { ...p, assets, npcs };
@@ -1232,7 +1331,12 @@ export class ZipProjectStore {
async updateNpcFields( async updateNpcFields(
npcId: NpcId, npcId: NpcId,
patch: { name?: string; description?: string }, patch: {
name?: string;
description?: string;
groupId?: NpcGroupId | null;
binding?: NpcBinding;
},
): Promise<Project> { ): Promise<Project> {
const open = this.openProject; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
@@ -1250,12 +1354,20 @@ export class ZipProjectStore {
} }
} }
await this.updateProject((p) => { await this.updateProject((p) => {
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
const npcs = (p.npcs ?? []).map((n) => { const npcs = (p.npcs ?? []).map((n) => {
if (n.id !== npcId) return n; if (n.id !== npcId) return n;
let groupId = n.groupId;
if (patch.groupId !== undefined) {
groupId =
patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
}
return { return {
...n, ...n,
...(name !== undefined ? { name } : {}), ...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}), ...(typeof patch.description === 'string' ? { description: patch.description } : {}),
groupId,
...(patch.binding !== undefined ? { binding: patch.binding } : {}),
}; };
}); });
return { ...p, npcs }; return { ...p, npcs };
@@ -1313,6 +1425,101 @@ export class ZipProjectStore {
return latest; return latest;
} }
async upsertNpcGroup(input: {
groupId?: NpcGroupId;
name: string;
color?: string;
parentId?: NpcGroupId | null;
}): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const name = input.name.trim();
if (name.length < 1) throw new Error('Group name is required');
const color = normalizeHexColor(input.color, DEFAULT_NPC_GROUP_COLOR);
await this.updateProject((p) => {
const groups = [...(p.npcGroups ?? [])];
const editingId = input.groupId ?? null;
const parentId =
input.parentId === undefined
? editingId
? (groups.find((g) => g.id === editingId)?.parentId ?? null)
: null
: input.parentId;
if (parentId && !groups.some((g) => g.id === parentId)) {
throw new Error('Parent group not found');
}
if (editingId && parentId && wouldCreateGroupCycle(groups, editingId, parentId)) {
throw new Error('Invalid group parent');
}
const nameKey = name.toLowerCase();
const siblingConflict = groups.some(
(g) =>
g.id !== editingId &&
g.parentId === parentId &&
g.name.trim().toLowerCase() === nameKey,
);
if (siblingConflict) throw new Error('Group name already exists');
if (editingId) {
const idx = groups.findIndex((g) => g.id === editingId);
if (idx < 0) throw new Error('Group not found');
groups[idx] = { ...groups[idx]!, name, color, parentId };
} else {
groups.push({
id: asNpcGroupId(`ng_${this.randomId()}`),
name,
color,
parentId,
});
}
return { ...p, npcGroups: groups };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteNpcGroup(groupId: NpcGroupId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const groups = p.npcGroups ?? [];
if (!groups.some((g) => g.id === groupId)) throw new Error('Group not found');
const parentOfDeleted = groups.find((g) => g.id === groupId)?.parentId ?? null;
const nextGroups = groups
.filter((g) => g.id !== groupId)
.map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
const npcs = (p.npcs ?? []).map((n) =>
n.groupId === groupId ? { ...n, groupId: null } : n,
);
return { ...p, npcGroups: nextGroups, npcs };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setNpcGroupsOrder(groupIds: NpcGroupId[]): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const byId = new Map((p.npcGroups ?? []).map((g) => [g.id, g]));
const next: ProjectNpcGroup[] = [];
for (const id of groupIds) {
const g = byId.get(id);
if (g) {
next.push(g);
byId.delete(id);
}
}
for (const g of byId.values()) next.push(g);
return { ...p, npcGroups: next };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async upsertNpcRelation(input: { async upsertNpcRelation(input: {
relationId?: NpcRelationId; relationId?: NpcRelationId;
sourceNpcId: NpcId; sourceNpcId: NpcId;
@@ -1583,6 +1790,50 @@ export class ZipProjectStore {
return opened; return opened;
} }
/**
* Импорт мира/модуля Foundry VTT (папка или .zip/.fvtt) в новый проект.
* Создаёт `.ttrpg.zip`, открывает проект и возвращает его.
*/
async importProjectFromFoundry(
sourcePath: string,
onProgress?: (p: FoundryImportProgress) => void,
): Promise<Project> {
await this.ensureRoots();
const loaded = await loadFoundryDocumentsForImport(sourcePath, onProgress);
try {
this.projectSession += 1;
const projectId = asProjectId(this.randomId());
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
await fs.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(path.join(cacheDir, 'assets'), { recursive: true });
const { project } = await buildProjectFromFoundryDocuments(
loaded.manifest,
loaded.docs,
cacheDir,
onProgress,
{ projectId },
);
const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(project.meta.fileBaseName));
const projectPath = path.join(cacheDir, 'project.json');
this.openProject = {
id: project.id,
zipPath,
cacheDir,
projectPath,
project,
};
await this.writeCacheProject(cacheDir, project);
onProgress?.({ stage: 'zip', percent: 90, detail: 'Сборка проекта…' });
await this.enqueuePack(cacheDir, zipPath);
onProgress?.({ stage: 'done', percent: 100, detail: 'Готово' });
return this.openProject.project;
} finally {
await loaded.cleanup();
}
}
/** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */ /** Копия файла проекта в указанный путь (полный путь к `.dnd.zip`). */
async exportProjectZipToPath( async exportProjectZipToPath(
projectId: ProjectId, projectId: ProjectId,
@@ -1809,6 +2060,7 @@ export class ZipProjectStore {
selections: StorylineSelection[], selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
): Promise<{ project: Project; report: StorylineImportMergeReport }> { ): Promise<{ project: Project; report: StorylineImportMergeReport }> {
if (!this.openProject) throw new Error('Нет открытого проекта'); if (!this.openProject) throw new Error('Нет открытого проекта');
const offsetX = computeGraphImportOffsetX(this.openProject.project); const offsetX = computeGraphImportOffsetX(this.openProject.project);
@@ -1817,7 +2069,10 @@ export class ZipProjectStore {
source, source,
selections, selections,
sceneResolutions, sceneResolutions,
{ graphOffsetX: offsetX }, {
graphOffsetX: offsetX,
...(npcResolutions ? { npcResolutions } : {}),
},
); );
const targetCache = this.openProject.cacheDir; const targetCache = this.openProject.cacheDir;
@@ -1851,6 +2106,7 @@ export class ZipProjectStore {
selections: StorylineSelection[], selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
): Promise<{ project: Project; report: StorylineImportMergeReport }> { ): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections); this.assertStorylineMergeAllowed(selections);
const snap = await this.loadProjectSnapshot(sourceProjectId); const snap = await this.loadProjectSnapshot(sourceProjectId);
@@ -1861,6 +2117,7 @@ export class ZipProjectStore {
selections, selections,
sceneResolutions, sceneResolutions,
onProgress, onProgress,
npcResolutions,
); );
} finally { } finally {
if (snap.ownsCache) { if (snap.ownsCache) {
@@ -1874,6 +2131,7 @@ export class ZipProjectStore {
selections: StorylineSelection[], selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
): Promise<{ project: Project; report: StorylineImportMergeReport }> { ): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections); this.assertStorylineMergeAllowed(selections);
const source = await this.readExternalProjectForImport(sourcePath); const source = await this.readExternalProjectForImport(sourcePath);
@@ -1887,6 +2145,7 @@ export class ZipProjectStore {
selections, selections,
sceneResolutions, sceneResolutions,
onProgress, onProgress,
npcResolutions,
); );
} finally { } finally {
await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined); await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined);
@@ -2095,6 +2354,13 @@ function normalizeProject(p: Project): Project {
(x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } => (x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } =>
Boolean(x), Boolean(x),
); );
const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups);
const groupIdSet = new Set(npcGroups.map((g) => g.id));
const sceneIdSet = new Set(Object.keys(scenes) as SceneId[]);
const sideStartIds = new Set(
sceneGraphNodes.filter((n) => n.isSideStoryStart).map((n) => n.id),
);
const hasMainStart = sceneGraphNodes.some((n) => n.isStartScene);
const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs; const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : []) const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
.map((n, index) => { .map((n, index) => {
@@ -2106,6 +2372,8 @@ function normalizeProject(p: Project): Project {
description?: string; description?: string;
x?: number; x?: number;
y?: number; y?: number;
groupId?: string | null;
binding?: unknown;
}; };
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null; if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim(); const name = obj.name.trim();
@@ -2120,6 +2388,12 @@ function normalizeProject(p: Project): Project {
description: typeof obj.description === 'string' ? obj.description : '', description: typeof obj.description === 'string' ? obj.description : '',
x, x,
y, y,
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
binding: normalizeNpcBinding(obj.binding, {
sceneIds: sceneIdSet,
sideStartIds,
hasMainStart,
}),
}; };
}) })
.filter((x): x is ProjectNpc => Boolean(x)); .filter((x): x is ProjectNpc => Boolean(x));
@@ -2171,6 +2445,7 @@ function normalizeProject(p: Project): Project {
campaignAudios, campaignAudios,
materials, materials,
npcs, npcs,
npcGroups,
npcRelations, npcRelations,
sceneGraphNodes, sceneGraphNodes,
sceneGraphEdges, sceneGraphEdges,
+8 -2
View File
@@ -153,7 +153,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 10000; /* Выше модалок (20001), чтобы прогресс импорта не уходил под диалог выбора. */
z-index: 30000;
} }
.editorLockOverlay { .editorLockOverlay {
@@ -380,9 +381,14 @@
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 8px 10px; padding: 8px 10px;
padding-right: 28px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
background: var(--bg0); background-color: var(--bg0);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
color: var(--text0); color: var(--text0);
font: inherit; font: inherit;
} }
+118 -19
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder'; import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
import type { import type {
NpcImportResolution,
SceneImportResolution, SceneImportResolution,
StorylineImportMergeReport, StorylineImportMergeReport,
StorylineSelection, StorylineSelection,
@@ -42,6 +43,7 @@ import {
sceneTitleFromMediaPath, sceneTitleFromMediaPath,
useFileDropZone, useFileDropZone,
} from './fileDrop'; } from './fileDrop';
import { FoundryImportModal } from './FoundryImportModal';
import { buildNextSceneCardById } from './graph/sceneCardById'; import { buildNextSceneCardById } from './graph/sceneCardById';
import { import {
DND_SCENE_ID_MIME, DND_SCENE_ID_MIME,
@@ -58,12 +60,15 @@ import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState'; import type { ProjectNoticeCode } from './state/projectState';
import { useProjectState } from './state/projectState'; import { useProjectState } from './state/projectState';
import { import {
buildNpcResolutionsForImport,
buildSceneResolutionsForImport, buildSceneResolutionsForImport,
computeImportConflicts, computeImportConflicts,
computeNpcImportConflicts,
ExportProjectModal, ExportProjectModal,
ImportReportModal, ImportReportModal,
ImportSourceModal, ImportSourceModal,
ImportStorylinesModal, ImportStorylinesModal,
NpcConflictModal,
SceneConflictModal, SceneConflictModal,
useStorylineLabels, useStorylineLabels,
type ImportPeekResult, type ImportPeekResult,
@@ -123,11 +128,17 @@ export function EditorApp() {
const [renameOpen, setRenameOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [importSourceOpen, setImportSourceOpen] = useState(false); const [importSourceOpen, setImportSourceOpen] = useState(false);
const [foundryImportOpen, setFoundryImportOpen] = useState(false);
const [importPeek, setImportPeek] = useState<ImportPeekResult | null>(null); const [importPeek, setImportPeek] = useState<ImportPeekResult | null>(null);
const [importStorylinesOpen, setImportStorylinesOpen] = useState(false); const [importStorylinesOpen, setImportStorylinesOpen] = useState(false);
const [importConflictsOpen, setImportConflictsOpen] = useState(false); const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]); const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
const [importNpcConflicts, setImportNpcConflicts] = useState<
ReturnType<typeof computeNpcImportConflicts>
>([]);
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]); const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false); const [importReportOpen, setImportReportOpen] = useState(false);
const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null); const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null);
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null); const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
@@ -499,22 +510,71 @@ export function EditorApp() {
[actions, storylineLabels], [actions, storylineLabels],
); );
const runStorylineMerge = useCallback( const clearImportFlow = useCallback(() => {
async (selections: StorylineSelection[], resolutions: SceneImportResolution[]) => {
if (!importPeek) return;
const { report } =
importPeek.kind === 'project' && importPeek.sourceProjectId
? await actions.mergeImportFromProject(importPeek.sourceProjectId, selections, resolutions)
: await actions.mergeImportZip(importPeek.filePath!, selections, resolutions);
setImportReport(report);
setImportReportOpen(true);
setImportPeek(null); setImportPeek(null);
setImportStorylinesOpen(false); setImportStorylinesOpen(false);
setImportConflictsOpen(false); setImportConflictsOpen(false);
setImportNpcConflictsOpen(false);
setPendingImportSelections([]); setPendingImportSelections([]);
setPendingSceneResolutions([]);
setImportConflicts([]); setImportConflicts([]);
setImportNpcConflicts([]);
}, []);
const runStorylineMerge = useCallback(
async (
selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[],
npcResolutions: NpcImportResolution[],
) => {
if (!importPeek) return;
const { report } =
importPeek.kind === 'project' && importPeek.sourceProjectId
? await actions.mergeImportFromProject(
importPeek.sourceProjectId,
selections,
sceneResolutions,
npcResolutions,
)
: await actions.mergeImportZip(
importPeek.filePath!,
selections,
sceneResolutions,
npcResolutions,
);
setImportReport(report);
setImportReportOpen(true);
clearImportFlow();
}, },
[actions, importPeek], [actions, clearImportFlow, importPeek],
);
const continueImportAfterScenes = useCallback(
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
if (!importPeek || !state.project) return;
const npcConflicts = computeNpcImportConflicts(
state.project,
importPeek.sourceProject,
selections,
);
setPendingImportSelections(selections);
setPendingSceneResolutions(sceneResolutions);
setImportConflictsOpen(false);
if (npcConflicts.length > 0) {
setImportNpcConflicts(npcConflicts);
setImportNpcConflictsOpen(true);
return;
}
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
selections,
[],
[],
);
void runStorylineMerge(selections, sceneResolutions, npcResolutions);
},
[importPeek, runStorylineMerge, state.project],
); );
const handleImportSourceContinue = useCallback( const handleImportSourceContinue = useCallback(
@@ -554,10 +614,16 @@ export function EditorApp() {
setImportSourceOpen(true); setImportSourceOpen(true);
}, []); }, []);
const handleImportFoundry = useCallback(() => {
setProjectMenuOpen(false);
setFoundryImportOpen(true);
}, []);
const goHome = useCallback(() => { const goHome = useCallback(() => {
setProjectMenuOpen(false); setProjectMenuOpen(false);
setExportModalOpen(false); setExportModalOpen(false);
setImportSourceOpen(false); setImportSourceOpen(false);
setFoundryImportOpen(false);
setImportStorylinesOpen(false); setImportStorylinesOpen(false);
setImportConflictsOpen(false); setImportConflictsOpen(false);
setImportReportOpen(false); setImportReportOpen(false);
@@ -1294,6 +1360,18 @@ export function EditorApp() {
> >
{t('projectMenu.import')} {t('projectMenu.import')}
</button> </button>
{!state.project ? (
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
handleImportFoundry();
}}
>
{t('projectMenu.importFoundry')}
</button>
) : null}
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
@@ -1373,6 +1451,16 @@ export function EditorApp() {
onClose={() => setImportSourceOpen(false)} onClose={() => setImportSourceOpen(false)}
onContinue={handleImportSourceContinue} onContinue={handleImportSourceContinue}
/> />
<FoundryImportModal
open={foundryImportOpen}
pickSource={actions.pickFoundrySource}
onClose={() => setFoundryImportOpen(false)}
onImport={async (selection) => {
// Сразу закрываем диалог, чтобы прогресс импорта не оказался под ним.
setFoundryImportOpen(false);
await actions.importFoundryProject(selection.sourcePath);
}}
/>
<ImportStorylinesModal <ImportStorylinesModal
open={importStorylinesOpen} open={importStorylinesOpen}
sourceName={importPeek?.projectName ?? ''} sourceName={importPeek?.projectName ?? ''}
@@ -1385,10 +1473,10 @@ export function EditorApp() {
if (!importPeek || !state.project) return; if (!importPeek || !state.project) return;
const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections); const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections);
setPendingImportSelections(selections); setPendingImportSelections(selections);
setImportStorylinesOpen(false);
if (conflicts.length > 0) { if (conflicts.length > 0) {
setImportConflicts(conflicts); setImportConflicts(conflicts);
setImportConflictsOpen(true); setImportConflictsOpen(true);
setImportStorylinesOpen(false);
return; return;
} }
const resolutions = buildSceneResolutionsForImport( const resolutions = buildSceneResolutionsForImport(
@@ -1398,18 +1486,13 @@ export function EditorApp() {
[], [],
[], [],
); );
void runStorylineMerge(selections, resolutions); continueImportAfterScenes(selections, resolutions);
}} }}
/> />
<SceneConflictModal <SceneConflictModal
open={importConflictsOpen} open={importConflictsOpen}
conflicts={importConflicts} conflicts={importConflicts}
onClose={() => { onClose={clearImportFlow}
setImportConflictsOpen(false);
setImportPeek(null);
setPendingImportSelections([]);
setImportConflicts([]);
}}
onConfirm={(userResolutions) => { onConfirm={(userResolutions) => {
if (!importPeek || !state.project) return; if (!importPeek || !state.project) return;
const resolutions = buildSceneResolutionsForImport( const resolutions = buildSceneResolutionsForImport(
@@ -1419,7 +1502,23 @@ export function EditorApp() {
importConflicts, importConflicts,
userResolutions, userResolutions,
); );
void runStorylineMerge(pendingImportSelections, resolutions); continueImportAfterScenes(pendingImportSelections, resolutions);
}}
/>
<NpcConflictModal
open={importNpcConflictsOpen}
conflicts={importNpcConflicts}
onClose={clearImportFlow}
onConfirm={(userNpcResolutions) => {
if (!importPeek || !state.project) return;
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
pendingImportSelections,
importNpcConflicts,
userNpcResolutions,
);
void runStorylineMerge(pendingImportSelections, pendingSceneResolutions, npcResolutions);
}} }}
/> />
<ImportReportModal <ImportReportModal
+145
View File
@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button } 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
className={styles.selectInput}
value={mode}
disabled={submitting}
onChange={(e) => {
setMode(e.target.value as 'folder' | 'archive');
setPicked(null);
setError(null);
}}
>
<option value="folder">{t('foundryImport.folder')}</option>
<option value="archive">{t('foundryImport.archive')}</option>
</select>
<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,
);
}
+229 -24
View File
@@ -3,8 +3,12 @@ import { createPortal } from 'react-dom';
import { import {
collectSceneIdsForSelections, collectSceneIdsForSelections,
filterNpcsForStorylineExport,
findNpcNameConflicts,
findSceneTitleConflicts, findSceneTitleConflicts,
storylineSelectionKey, storylineSelectionKey,
type NpcImportResolution,
type NpcNameConflict,
type SceneImportResolution, type SceneImportResolution,
type SceneTitleConflict, type SceneTitleConflict,
type StorylineImportMergeReport, type StorylineImportMergeReport,
@@ -14,8 +18,9 @@ import {
} from '../../shared/graph/storylineExportImport'; } from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types'; import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button } from '../shared/ui/controls'; import { Button } from '../shared/ui/controls';
import { useEditorI18n } from './i18n/EditorI18nContext';
import styles from './EditorApp.module.css'; import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
type ExportProjectModalProps = { type ExportProjectModalProps = {
open: boolean; open: boolean;
@@ -109,11 +114,21 @@ export function ExportProjectModal({
return createPortal( return createPortal(
<> <>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} /> <button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}> <div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('export.title')}</div> <div className={styles.modalTitle}>{t('export.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}> <button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
× ×
</button> </button>
</div> </div>
@@ -145,7 +160,10 @@ export function ExportProjectModal({
const checked = selectedKeys.has(key); const checked = selectedKeys.has(key);
const disabled = item.disabled === true; const disabled = item.disabled === true;
return ( return (
<label key={key} className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}> <label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
@@ -266,17 +284,25 @@ export function ImportSourceModal({
const canContinue = const canContinue =
!submitting && !submitting &&
(importKind === 'project' (importKind === 'project' ? canImportFromProject && sourceProjectId !== null : pickedFile !== null);
? canImportFromProject && sourceProjectId !== null
: pickedFile !== null);
return createPortal( return createPortal(
<> <>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} /> <button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}> <div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importSource.title')}</div> <div className={styles.modalTitle}>{t('importSource.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}> <button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
× ×
</button> </button>
</div> </div>
@@ -432,11 +458,21 @@ export function ImportStorylinesModal({
return createPortal( return createPortal(
<> <>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} /> <button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}> <div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.title')}</div> <div className={styles.modalTitle}>{t('importStoryline.title')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}> <button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
× ×
</button> </button>
</div> </div>
@@ -454,7 +490,10 @@ export function ImportStorylinesModal({
const key = storylineSelectionKey(item.selection); const key = storylineSelectionKey(item.selection);
const disabled = item.disabled === true; const disabled = item.disabled === true;
return ( return (
<label key={key} className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}> <label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input <input
type="checkbox" type="checkbox"
checked={selectedKeys.has(key)} checked={selectedKeys.has(key)}
@@ -474,11 +513,7 @@ export function ImportStorylinesModal({
<div className={styles.modalFooter}> <div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button> <Button onClick={onClose}>{t('common.cancel')}</Button>
<Button <Button variant="primary" disabled={!canContinue} onClick={() => onContinue(selectedSelections)}>
variant="primary"
disabled={!canContinue}
onClick={() => onContinue(selectedSelections)}
>
{t('importStoryline.continue')} {t('importStoryline.continue')}
</Button> </Button>
</div> </div>
@@ -512,11 +547,21 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen
return createPortal( return createPortal(
<> <>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} /> <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 role="dialog" aria-modal="true" className={`${styles.modalDialog} ${styles.modalDialogWide}`}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.conflictsTitle')}</div> <div className={styles.modalTitle}>{t('importStoryline.conflictsTitle')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}> <button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
× ×
</button> </button>
</div> </div>
@@ -571,6 +616,121 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen
); );
} }
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
className={styles.selectInput}
value={choices[c.sourceNpcId] ?? 'create'}
onChange={(e) => {
const v = e.target.value;
setChoices((prev) => ({
...prev,
[c.sourceNpcId]: v === 'create' ? 'create' : v,
}));
}}
>
<option value="create">{t('importStoryline.createNewNpc')}</option>
{c.matches.map((m) => (
<option key={m.npcId} value={m.npcId}>
{t('importStoryline.useExistingNpc', { name: m.name })}
</option>
))}
</select>
</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 = { type ImportReportModalProps = {
open: boolean; open: boolean;
report: StorylineImportMergeReport | null; report: StorylineImportMergeReport | null;
@@ -593,11 +753,21 @@ export function ImportReportModal({ open, report, onClose }: ImportReportModalPr
return createPortal( return createPortal(
<> <>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} /> <button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}> <div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.reportTitle')}</div> <div className={styles.modalTitle}>{t('importStoryline.reportTitle')}</div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}> <button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
× ×
</button> </button>
</div> </div>
@@ -606,14 +776,14 @@ export function ImportReportModal({ open, report, onClose }: ImportReportModalPr
<li>{t('importStoryline.reportLines', { count: report.storylinesImported })}</li> <li>{t('importStoryline.reportLines', { count: report.storylinesImported })}</li>
<li>{t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}</li> <li>{t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}</li>
<li>{t('importStoryline.reportScenesReused', { count: report.scenesReused })}</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.reportNodes', { count: report.graphNodesAdded })}</li>
<li>{t('importStoryline.reportEdges', { count: report.edgesAdded })}</li> <li>{t('importStoryline.reportEdges', { count: report.edgesAdded })}</li>
<li>{t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}</li> <li>{t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}</li>
<li>{t('importStoryline.reportAssetsReused', { count: report.assetsReused })}</li> <li>{t('importStoryline.reportAssetsReused', { count: report.assetsReused })}</li>
{report.renamedSideTitles.length > 0 ? ( {report.renamedSideTitles.length > 0 ? (
<li> <li>{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}</li>
{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}
</li>
) : null} ) : null}
</ul> </ul>
@@ -659,6 +829,41 @@ export function computeImportConflicts(
return findSceneTitleConflicts(targetProject, sourceProject, sceneIds); return findSceneTitleConflicts(targetProject, sourceProject, sceneIds);
} }
export function buildNpcResolutionsForImport(
_targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
conflicts: NpcNameConflict[],
userResolutions: NpcImportResolution[],
): NpcImportResolution[] {
const exported = filterNpcsForStorylineExport(sourceProject, selections);
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,
selections: StorylineSelection[],
): NpcNameConflict[] {
const exported = filterNpcsForStorylineExport(sourceProject, selections);
return findNpcNameConflicts(
targetProject,
sourceProject,
exported.map((n) => n.id),
);
}
export function useStorylineLabels(): StorylineLabels { export function useStorylineLabels(): StorylineLabels {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
return useMemo( return useMemo(
@@ -235,9 +235,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'projectMenu.home': 'Начальный экран', 'projectMenu.home': 'Начальный экран',
'projectMenu.import': 'Импорт', 'projectMenu.import': 'Импорт',
'projectMenu.importFoundry': 'Импорт из Foundry',
'projectMenu.export': 'Экспорт', 'projectMenu.export': 'Экспорт',
'projectMenu.noProjects': 'Нет сохранённых проектов', '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': 'Переименовать проект', 'fileMenu.rename': 'Переименовать проект',
'scenes.search': 'Поиск сцен…', 'scenes.search': 'Поиск сцен…',
@@ -301,11 +314,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'importStoryline.reportLines': 'Импортировано линий: {count}', 'importStoryline.reportLines': 'Импортировано линий: {count}',
'importStoryline.reportScenesCreated': 'Создано новых сцен: {count}', 'importStoryline.reportScenesCreated': 'Создано новых сцен: {count}',
'importStoryline.reportScenesReused': 'Использовано существующих сцен: {count}', 'importStoryline.reportScenesReused': 'Использовано существующих сцен: {count}',
'importStoryline.reportNpcsCreated': 'Создано новых НПС: {count}',
'importStoryline.reportNpcsReused': 'Использовано существующих НПС: {count}',
'importStoryline.reportNodes': 'Добавлено карточек на граф: {count}', 'importStoryline.reportNodes': 'Добавлено карточек на граф: {count}',
'importStoryline.reportEdges': 'Добавлено связей: {count}', 'importStoryline.reportEdges': 'Добавлено связей: {count}',
'importStoryline.reportAssetsCopied': 'Скопировано файлов материалов: {count}', 'importStoryline.reportAssetsCopied': 'Скопировано файлов материалов: {count}',
'importStoryline.reportAssetsReused': 'Повторно использовано материалов: {count}', 'importStoryline.reportAssetsReused': 'Повторно использовано материалов: {count}',
'importStoryline.reportRenamedSides': 'Переименованы побочные линии: {names}', 'importStoryline.reportRenamedSides': 'Переименованы побочные линии: {names}',
'importStoryline.npcConflictsTitle': 'Совпадение имён НПС',
'importStoryline.npcConflictsHint':
'В импортируемых линиях есть НПС с такими же именами, как в текущем проекте. Выберите действие для каждого.',
'importStoryline.createNewNpc': 'Создать нового НПС',
'importStoryline.useExistingNpc': 'Использовать «{name}»',
'confirmDelete.title': 'Удаление проекта', 'confirmDelete.title': 'Удаление проекта',
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.', 'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
@@ -411,6 +431,28 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.', 'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
'npcs.zoomOutHint': 'Кликните по аватару в предпросмотре пульта, чтобы уменьшить.', 'npcs.zoomOutHint': 'Кликните по аватару в предпросмотре пульта, чтобы уменьшить.',
'npcs.zoomIdleHint': 'Выберите лупу, затем кликните по аватару в предпросмотре пульта.', '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.bindingEnable': 'Привязать…',
'npcs.bindingKind': 'ТИП ПРИВЯЗКИ',
'npcs.bindingStoryline': 'Сюжетная линия',
'npcs.bindingScene': 'Сцена',
'npcs.bindingMain': 'Основная линия',
'npcs.bindingSelect': 'ОБЪЕКТ',
'npcs.graphFilterAll': 'Все',
'npcs.graphFilterUngrouped': 'Без группы',
'npcs.graphFilter': 'Фильтр графа',
'scene.title': 'НАЗВАНИЕ СЦЕНЫ', 'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
'scene.description': 'ОПИСАНИЕ', 'scene.description': 'ОПИСАНИЕ',
@@ -727,9 +769,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'projectMenu.home': 'Home', 'projectMenu.home': 'Home',
'projectMenu.import': 'Import', 'projectMenu.import': 'Import',
'projectMenu.importFoundry': 'Import from Foundry',
'projectMenu.export': 'Export', 'projectMenu.export': 'Export',
'projectMenu.noProjects': 'No saved projects', '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', 'fileMenu.rename': 'Rename project',
'scenes.search': 'Search scenes…', 'scenes.search': 'Search scenes…',
@@ -793,11 +848,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'importStoryline.reportLines': 'Storylines imported: {count}', 'importStoryline.reportLines': 'Storylines imported: {count}',
'importStoryline.reportScenesCreated': 'New scenes created: {count}', 'importStoryline.reportScenesCreated': 'New scenes created: {count}',
'importStoryline.reportScenesReused': 'Existing scenes reused: {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.reportNodes': 'Graph cards added: {count}',
'importStoryline.reportEdges': 'Connections added: {count}', 'importStoryline.reportEdges': 'Connections added: {count}',
'importStoryline.reportAssetsCopied': 'Asset files copied: {count}', 'importStoryline.reportAssetsCopied': 'Asset files copied: {count}',
'importStoryline.reportAssetsReused': 'Assets reused: {count}', 'importStoryline.reportAssetsReused': 'Assets reused: {count}',
'importStoryline.reportRenamedSides': 'Renamed side storylines: {names}', '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.title': 'Delete project',
'confirmDelete.body': 'confirmDelete.body':
@@ -904,6 +966,28 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.', '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.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.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.bindingEnable': 'Bind…',
'npcs.bindingKind': 'BINDING TYPE',
'npcs.bindingStoryline': 'Storyline',
'npcs.bindingScene': 'Scene',
'npcs.bindingMain': 'Main storyline',
'npcs.bindingSelect': 'TARGET',
'npcs.graphFilterAll': 'All',
'npcs.graphFilterUngrouped': 'Ungrouped',
'npcs.graphFilter': 'Graph filter',
'scene.title': 'SCENE TITLE', 'scene.title': 'SCENE TITLE',
'scene.description': 'DESCRIPTION', 'scene.description': 'DESCRIPTION',
+31
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts'; import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
import type { import type {
NpcImportResolution,
SceneImportResolution, SceneImportResolution,
StorylineImportMergeReport, StorylineImportMergeReport,
StorylineLabels, StorylineLabels,
@@ -94,6 +95,10 @@ type Actions = {
setSceneListOrder: (sceneListOrder: SceneId[]) => Promise<void>; setSceneListOrder: (sceneListOrder: SceneId[]) => Promise<void>;
renameProject: (name: string, fileBaseName: string) => Promise<void>; renameProject: (name: string, fileBaseName: string) => Promise<void>;
importProject: () => Promise<void>; importProject: () => Promise<void>;
pickFoundrySource: (
mode: 'folder' | 'archive',
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
importFoundryProject: (sourcePath: string) => Promise<void>;
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise< peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
| { canceled: true } | { canceled: true }
| { | {
@@ -129,11 +134,13 @@ type Actions = {
filePath: string, filePath: string,
storylineSelections: StorylineSelection[], storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>; ) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
mergeImportFromProject: ( mergeImportFromProject: (
sourceProjectId: ProjectId, sourceProjectId: ProjectId,
storylineSelections: StorylineSelection[], storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>; ) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
importProjectFromPath: (filePath: string) => Promise<void>; importProjectFromPath: (filePath: string) => Promise<void>;
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>; getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
@@ -749,6 +756,24 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
} }
}; };
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) => { const peekImportZip = async (labels: StorylineLabels, targetHasMainStart: boolean) => {
return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart }); return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart });
}; };
@@ -781,11 +806,13 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
filePath: string, filePath: string,
storylineSelections: StorylineSelection[], storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => { ) => {
const res = await api.invoke(ipcChannels.project.mergeImportZip, { const res = await api.invoke(ipcChannels.project.mergeImportZip, {
filePath, filePath,
storylineSelections, storylineSelections,
sceneResolutions, sceneResolutions,
...(npcResolutions ? { npcResolutions } : {}),
}); });
setState((s) => ({ setState((s) => ({
...s, ...s,
@@ -799,11 +826,13 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
sourceProjectId: ProjectId, sourceProjectId: ProjectId,
storylineSelections: StorylineSelection[], storylineSelections: StorylineSelection[],
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
npcResolutions?: NpcImportResolution[],
) => { ) => {
const res = await api.invoke(ipcChannels.project.mergeImportFromProject, { const res = await api.invoke(ipcChannels.project.mergeImportFromProject, {
sourceProjectId, sourceProjectId,
storylineSelections, storylineSelections,
sceneResolutions, sceneResolutions,
...(npcResolutions ? { npcResolutions } : {}),
}); });
setState((s) => ({ setState((s) => ({
...s, ...s,
@@ -884,6 +913,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
renameProject, renameProject,
importProject, importProject,
importProjectFromPath, importProjectFromPath,
pickFoundrySource,
importFoundryProject,
peekImportZip, peekImportZip,
pickImportZipFile, pickImportZipFile,
peekImportZipPath, peekImportZipPath,
+140
View File
@@ -0,0 +1,140 @@
import React, { useMemo } from 'react';
import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '../../shared/npcs/npcBinding';
import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import controlStyles from '../shared/ui/Controls.module.css';
type NpcBindingFieldsProps = {
project: Project;
binding: NpcBinding;
onChange: (binding: NpcBinding) => void;
};
function defaultBinding(project: Project): NpcBinding {
const opts = listStorylineOptionsForBinding(project);
if (opts.main) return { kind: 'storyline', storyline: { kind: 'main' } };
if (opts.sides[0]) {
return {
kind: 'storyline',
storyline: { kind: 'side', startGraphNodeId: opts.sides[0].startGraphNodeId },
};
}
const firstScene = Object.keys(project.scenes)[0] as SceneId | undefined;
if (firstScene) return { kind: 'scene', sceneId: firstScene };
return noneBinding();
}
export function NpcBindingFields({ project, binding, onChange }: NpcBindingFieldsProps) {
const { t } = useEditorI18n();
const enabled = !isNpcBindingNone(binding);
const storylineOpts = useMemo(() => listStorylineOptionsForBinding(project), [project]);
const sceneOptions = useMemo(
() =>
Object.entries(project.scenes)
.map(([id, scene]) => ({ id: id as SceneId, title: scene.title.trim() || id }))
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })),
[project.scenes],
);
const kind = binding.kind === 'none' ? 'storyline' : binding.kind;
const bindingTargetValue = useMemo(() => {
if (binding.kind === 'scene') return binding.sceneId;
if (binding.kind === 'storyline') {
if (binding.storyline.kind === 'main') return 'main';
return `side:${binding.storyline.startGraphNodeId}`;
}
return '';
}, [binding]);
return (
<div className={editorStyles.fieldGrid}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
onChange(e.target.checked ? defaultBinding(project) : noneBinding());
}}
/>
<span>{t('npcs.bindingEnable')}</span>
</label>
{enabled ? (
<>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
<select
className={controlStyles.input}
value={kind}
onChange={(e) => {
const nextKind = e.target.value;
if (nextKind === 'scene') {
const first = sceneOptions[0];
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
return;
}
if (storylineOpts.main) {
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
} else if (storylineOpts.sides[0]) {
onChange({
kind: 'storyline',
storyline: { kind: 'side', startGraphNodeId: storylineOpts.sides[0].startGraphNodeId },
});
} else {
onChange(noneBinding());
}
}}
>
<option value="storyline">{t('npcs.bindingStoryline')}</option>
<option value="scene">{t('npcs.bindingScene')}</option>
</select>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div>
<select
className={controlStyles.input}
value={bindingTargetValue}
onChange={(e) => {
const v = e.target.value;
if (kind === 'scene') {
onChange({ kind: 'scene', sceneId: v as SceneId });
return;
}
if (v === 'main') {
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
return;
}
if (v.startsWith('side:')) {
onChange({
kind: 'storyline',
storyline: {
kind: 'side',
startGraphNodeId: v.slice(5) as GraphNodeId,
},
});
}
}}
>
{kind === 'storyline' ? (
<>
{storylineOpts.main ? <option value="main">{t('npcs.bindingMain')}</option> : null}
{storylineOpts.sides.map((s) => (
<option key={s.startGraphNodeId} value={`side:${s.startGraphNodeId}`}>
{s.label}
</option>
))}
</>
) : (
sceneOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))
)}
</select>
</>
) : null}
</div>
);
}
+75 -17
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { ProjectNpc } from '../../shared/types'; import { noneBinding } from '../../shared/npcs/npcBinding';
import { Button, Input } from '../shared/ui/controls'; import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css'; import editorStyles from '../editor/EditorApp.module.css';
import { import {
filterMaterialImagePaths, filterMaterialImagePaths,
@@ -13,24 +13,51 @@ import {
} from '../editor/fileDrop'; } from '../editor/fileDrop';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import matStyles from '../editor/MaterialsModals.module.css'; import matStyles from '../editor/MaterialsModals.module.css';
import { Button, Input } from '../shared/ui/controls';
import controlStyles from '../shared/ui/Controls.module.css';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
function normalizeName(input: string): string { function normalizeName(input: string): string {
return input.trim().toLowerCase(); 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 = { type NpcEditModalProps = {
open: boolean; open: boolean;
initial: ProjectNpc | null; initial: ProjectNpc | null;
existingNames: string[]; existingNames: string[];
project: Project | null;
npcGroups: ProjectNpcGroup[];
onClose: () => void; onClose: () => void;
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>; onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
onSave: (input: { name: string; filePath?: string }) => Promise<void>; onSave: (input: {
name: string;
filePath?: string;
groupId?: NpcGroupId | null;
binding?: NpcBinding;
}) => Promise<void>;
}; };
export function NpcEditModal({ export function NpcEditModal({
open, open,
initial, initial,
existingNames, existingNames,
project,
npcGroups,
onClose, onClose,
onPickImage, onPickImage,
onSave, onSave,
@@ -39,15 +66,24 @@ export function NpcEditModal({
const [name, setName] = useState(''); const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null); const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null); const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
const groupOptions = useMemo(() => {
const { roots } = buildNpcGroupForest(npcGroups, []);
return flattenGroupOptions(roots);
}, [npcGroups]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setName(initial?.name ?? ''); setName(initial?.name ?? '');
setFilePath(null); setFilePath(null);
setLocalPreviewUrl(null); setLocalPreviewUrl(null);
setGroupId(initial?.groupId ?? '');
setBinding(initial?.binding ?? noneBinding());
setSaving(false); setSaving(false);
setError(null); setError(null);
}, [initial, open]); }, [initial, open]);
@@ -95,7 +131,7 @@ export function NpcEditModal({
); );
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId); const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
const canSave = nameOk && !nameDup && hasImage && !saving; const canSave = nameOk && !nameDup && hasImage && !saving;
const previewSrc = localPreviewUrl || existingUrl; const previewSrc = localPreviewUrl ?? existingUrl;
if (!open) return null; if (!open) return null;
@@ -109,9 +145,7 @@ export function NpcEditModal({
/> />
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}> <div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}> <div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}> <div className={editorStyles.modalTitle}>{initial ? t('npcs.editTitle') : t('npcs.addTitle')}</div>
{initial ? t('npcs.editTitle') : t('npcs.addTitle')}
</div>
<button <button
type="button" type="button"
aria-label={t('common.close')} aria-label={t('common.close')}
@@ -129,6 +163,24 @@ export function NpcEditModal({
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null} {nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null}
</div> </div>
{!initial && groupOptions.length > 0 ? (
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.group')}</div>
<select
className={controlStyles.input}
value={groupId}
onChange={(e) => setGroupId(e.target.value as NpcGroupId | '')}
>
<option value="">{t('npcs.ungrouped')}</option>
{groupOptions.map((g) => (
<option key={g.id} value={g.id}>
{g.label}
</option>
))}
</select>
</div>
) : null}
<div className={editorStyles.fieldGrid}> <div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.avatar')}</div> <div className={editorStyles.fieldLabel}>{t('npcs.avatar')}</div>
<div <div
@@ -139,11 +191,11 @@ export function NpcEditModal({
onDrop={(e) => { onDrop={(e) => {
drop.onDrop(e); drop.onDrop(e);
const entries = getDroppedFileEntries(e); const entries = getDroppedFileEntries(e);
const files = e.dataTransfer?.files; const files = e.dataTransfer.files;
for (let i = 0; i < entries.length; i += 1) { for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i]!; const entry = entries[i];
if (!pickFirstMaterialImagePath([entry.path])) continue; if (!entry || !pickFirstMaterialImagePath([entry.path])) continue;
const file = files?.[i]; const file = files[i];
if (file) { if (file) {
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file)); setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
return; return;
@@ -153,9 +205,7 @@ export function NpcEditModal({
} }
}} }}
> >
{drop.dragOver ? ( {drop.dragOver ? <div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div> : null}
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
) : null}
{previewSrc ? ( {previewSrc ? (
<img className={matStyles.previewThumb} src={previewSrc} alt="" /> <img className={matStyles.previewThumb} src={previewSrc} alt="" />
) : ( ) : (
@@ -176,6 +226,10 @@ export function NpcEditModal({
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null} {!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
</div> </div>
{project && !initial ? (
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
) : null}
{error ? <div className={editorStyles.fieldError}>{error}</div> : null} {error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}> <div className={editorStyles.modalFooter}>
@@ -191,7 +245,11 @@ export function NpcEditModal({
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed }); await onSave({
name: trimmed,
...(filePath ? { filePath } : {}),
...(!initial ? { groupId: groupId || null, binding } : {}),
});
onClose(); onClose();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
+26 -1
View File
@@ -40,11 +40,20 @@
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
background: #18181b; background: #18181b;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); 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 { .nodeActive {
border-color: #60a5fa; border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa, 0 8px 24px rgba(0, 0, 0, 0.35); 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 { .avatar {
@@ -152,3 +161,19 @@
background: rgba(239, 68, 68, 0.18); background: rgba(239, 68, 68, 0.18);
color: #fca5a5; color: #fca5a5;
} }
.filterSelect {
min-width: 160px;
padding: 6px 10px;
padding-right: 28px;
border-radius: 8px;
border: 1px solid var(--stroke);
background-color: rgba(24, 24, 27, 0.92);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
color: var(--text1);
font-size: 12px;
font-weight: 600;
}
+122 -44
View File
@@ -22,7 +22,15 @@ import ReactFlow, {
} from 'reactflow'; } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types'; import { collectDescendantGroupIds } from '../../shared/npcs/npcGroups';
import type {
NpcGroupId,
NpcId,
NpcRelationId,
ProjectNpc,
ProjectNpcGroup,
ProjectNpcRelation,
} from '../../shared/types';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './NpcGraph.module.css'; import styles from './NpcGraph.module.css';
@@ -32,6 +40,8 @@ type SelectSourceNpcFn = (sourceNpcId: NpcId) => void;
const OpenEdgeMenuContext = createContext<OpenEdgeMenuFn | null>(null); const OpenEdgeMenuContext = createContext<OpenEdgeMenuFn | null>(null);
const SelectSourceNpcContext = createContext<SelectSourceNpcFn | null>(null); const SelectSourceNpcContext = createContext<SelectSourceNpcFn | null>(null);
export type GraphGroupFilter = 'all' | 'ungrouped' | NpcGroupId;
export type NpcGraphUiStrings = { export type NpcGraphUiStrings = {
zoomBar: string; zoomBar: string;
zoomIn: string; zoomIn: string;
@@ -40,12 +50,17 @@ export type NpcGraphUiStrings = {
editRelation: string; editRelation: string;
deleteRelation: string; deleteRelation: string;
untitled: string; untitled: string;
graphFilter: string;
graphFilterAll: string;
graphFilterUngrouped: string;
}; };
type NpcNodeData = { type NpcNodeData = {
name: string; name: string;
avatarAssetId: ProjectNpc['avatarAssetId']; avatarAssetId: ProjectNpc['avatarAssetId'];
active: boolean; active: boolean;
groupColor: string | null;
dimmed: boolean;
}; };
const NPC_ACCENT = '#60a5fa'; const NPC_ACCENT = '#60a5fa';
@@ -102,30 +117,35 @@ function pickEndpointSides(
? { sourceSide: 'right', targetSide: 'left' } ? { sourceSide: 'right', targetSide: 'left' }
: { sourceSide: 'left', targetSide: 'right' }; : { sourceSide: 'left', targetSide: 'right' };
} }
return dy >= 0 return dy >= 0 ? { sourceSide: 'bottom', targetSide: 'top' } : { sourceSide: 'top', targetSide: 'bottom' };
? { sourceSide: 'bottom', targetSide: 'top' }
: { sourceSide: 'top', targetSide: 'bottom' };
} }
function NpcNode({ data, selected }: NodeProps<NpcNodeData>) { function NpcNode({ data, selected }: NodeProps<NpcNodeData>) {
const url = useAssetUrl(data.avatarAssetId); const url = useAssetUrl(data.avatarAssetId);
const sides: Side[] = ['left', 'right', 'top', 'bottom']; const sides: Side[] = ['left', 'right', 'top', 'bottom'];
const accent = data.groupColor ?? NPC_ACCENT;
return ( return (
<div className={[styles.node, data.active || selected ? styles.nodeActive : ''].filter(Boolean).join(' ')}> <div
className={[
styles.node,
data.active || selected ? styles.nodeActive : '',
data.dimmed ? styles.nodeDimmed : '',
]
.filter(Boolean)
.join(' ')}
style={
data.groupColor
? ({
'--npc-group-color': accent,
borderColor: data.active || selected ? accent : undefined,
} as React.CSSProperties)
: undefined
}
>
{sides.map((side) => ( {sides.map((side) => (
<React.Fragment key={side}> <React.Fragment key={side}>
<Handle <Handle type="source" position={sideToPosition(side)} id={`s-${side}`} className={styles.handle} />
type="source" <Handle type="target" position={sideToPosition(side)} id={`t-${side}`} className={styles.handle} />
position={sideToPosition(side)}
id={`s-${side}`}
className={styles.handle}
/>
<Handle
type="target"
position={sideToPosition(side)}
id={`t-${side}`}
className={styles.handle}
/>
</React.Fragment> </React.Fragment>
))} ))}
<div className={styles.avatar}> <div className={styles.avatar}>
@@ -151,9 +171,7 @@ function parallelCubicPath(
): { path: string; labelX: number; labelY: number } { ): { path: string; labelX: number; labelY: number } {
// Канонический вектор между концами (не зависит от направления стрелки). // Канонический вектор между концами (не зависит от направления стрелки).
const [ax, ay, bx, by] = const [ax, ay, bx, by] =
sourceNpcId < targetNpcId sourceNpcId < targetNpcId ? [sourceX, sourceY, targetX, targetY] : [targetX, targetY, sourceX, sourceY];
? [sourceX, sourceY, targetX, targetY]
: [targetX, targetY, sourceX, sourceY];
const cdx = bx - ax; const cdx = bx - ax;
const cdy = by - ay; const cdy = by - ay;
const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1; const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1;
@@ -195,15 +213,7 @@ function LabeledNpcEdge({
const targetNpcId = data?.targetNpcId; const targetNpcId = data?.targetNpcId;
const { path, labelX, labelY } = const { path, labelX, labelY } =
sourceNpcId && targetNpcId sourceNpcId && targetNpcId
? parallelCubicPath( ? parallelCubicPath(sourceNpcId, targetNpcId, sourceX, sourceY, targetX, targetY, worldOffset)
sourceNpcId,
targetNpcId,
sourceX,
sourceY,
targetX,
targetY,
worldOffset,
)
: { : {
path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`, path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
labelX: (sourceX + targetX) / 2, labelX: (sourceX + targetX) / 2,
@@ -225,12 +235,7 @@ function LabeledNpcEdge({
{label && relationId ? ( {label && relationId ? (
<EdgeLabelRenderer> <EdgeLabelRenderer>
<div <div
className={[ className={[styles.edgeLabel, highlighted ? styles.edgeLabelActive : '', 'nodrag', 'nopan']
styles.edgeLabel,
highlighted ? styles.edgeLabelActive : '',
'nodrag',
'nopan',
]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
style={{ style={{
@@ -269,7 +274,12 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}> <button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
</button> </button>
<button type="button" className={styles.zoomBtn} onClick={() => fitView({ padding: 0.2 })} aria-label={ui.fitAll}> <button
type="button"
className={styles.zoomBtn}
onClick={() => fitView({ padding: 0.2 })}
aria-label={ui.fitAll}
>
</button> </button>
</div> </div>
@@ -277,10 +287,44 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
); );
} }
function FilterToolbar({
ui,
groups,
value,
onChange,
}: {
ui: NpcGraphUiStrings;
groups: ProjectNpcGroup[];
value: GraphGroupFilter;
onChange: (v: GraphGroupFilter) => void;
}) {
return (
<Panel position="top-left">
<select
className={styles.filterSelect}
aria-label={ui.graphFilter}
value={value}
onChange={(e) => onChange(e.target.value as GraphGroupFilter)}
>
<option value="all">{ui.graphFilterAll}</option>
<option value="ungrouped">{ui.graphFilterUngrouped}</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</Panel>
);
}
export type NpcGraphProps = { export type NpcGraphProps = {
npcs: ProjectNpc[]; npcs: ProjectNpc[];
relations: ProjectNpcRelation[]; relations: ProjectNpcRelation[];
npcGroups: ProjectNpcGroup[];
selectedNpcId: NpcId | null; selectedNpcId: NpcId | null;
graphFilter: GraphGroupFilter;
onGraphFilterChange: (filter: GraphGroupFilter) => void;
graphUi: NpcGraphUiStrings; graphUi: NpcGraphUiStrings;
onSelect: (npcId: NpcId) => void; onSelect: (npcId: NpcId) => void;
onConnectRequest: (sourceNpcId: NpcId, targetNpcId: NpcId) => void; onConnectRequest: (sourceNpcId: NpcId, targetNpcId: NpcId) => void;
@@ -292,7 +336,10 @@ export type NpcGraphProps = {
function NpcGraphInner({ function NpcGraphInner({
npcs, npcs,
relations, relations,
npcGroups,
selectedNpcId, selectedNpcId,
graphFilter,
onGraphFilterChange,
graphUi, graphUi,
onSelect, onSelect,
onConnectRequest, onConnectRequest,
@@ -300,9 +347,7 @@ function NpcGraphInner({
onEditRelation, onEditRelation,
onDeleteRelation, onDeleteRelation,
}: NpcGraphProps) { }: NpcGraphProps) {
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>( const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(null);
null,
);
/** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */ /** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */
const connectFromRef = useRef<NpcId | null>(null); const connectFromRef = useRef<NpcId | null>(null);
@@ -320,6 +365,23 @@ function NpcGraphInner({
}; };
}, [menu]); }, [menu]);
const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]);
const filterGroupIds = useMemo(() => {
if (graphFilter === 'all' || graphFilter === 'ungrouped') return null;
return collectDescendantGroupIds(npcGroups, graphFilter);
}, [graphFilter, npcGroups]);
const isNpcDimmed = useCallback(
(npc: ProjectNpc) => {
if (graphFilter === 'all') return false;
if (graphFilter === 'ungrouped') return npc.groupId !== null;
if (!filterGroupIds) return true;
return npc.groupId === null || !filterGroupIds.has(npc.groupId);
},
[filterGroupIds, graphFilter],
);
const initialNodes: Node<NpcNodeData>[] = useMemo( const initialNodes: Node<NpcNodeData>[] = useMemo(
() => () =>
npcs.map((n) => ({ npcs.map((n) => ({
@@ -330,9 +392,11 @@ function NpcGraphInner({
name: n.name, name: n.name,
avatarAssetId: n.avatarAssetId, avatarAssetId: n.avatarAssetId,
active: n.id === selectedNpcId, active: n.id === selectedNpcId,
groupColor: n.groupId ? (groupColorById.get(n.groupId) ?? null) : null,
dimmed: isNpcDimmed(n),
}, },
})), })),
[npcs, selectedNpcId], [groupColorById, isNpcDimmed, npcs, selectedNpcId],
); );
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
@@ -364,6 +428,11 @@ function NpcGraphInner({
const { sourceSide, targetSide } = pickEndpointSides(sourcePos, targetPos); const { sourceSide, targetSide } = pickEndpointSides(sourcePos, targetPos);
const offset = (index - (total - 1) / 2) * PARALLEL_EDGE_GAP; const offset = (index - (total - 1) / 2) * PARALLEL_EDGE_GAP;
const highlighted = selectedNpcId !== null && r.sourceNpcId === selectedNpcId; const highlighted = selectedNpcId !== null && r.sourceNpcId === selectedNpcId;
const sourceNpc = npcs.find((n) => n.id === r.sourceNpcId);
const targetNpc = npcs.find((n) => n.id === r.targetNpcId);
const edgeDimmed =
graphFilter !== 'all' &&
((sourceNpc && isNpcDimmed(sourceNpc)) || (targetNpc && isNpcDimmed(targetNpc)));
const color = highlighted ? NPC_ACCENT : NPC_EDGE_IDLE; const color = highlighted ? NPC_ACCENT : NPC_EDGE_IDLE;
out.push({ out.push({
id: r.id, id: r.id,
@@ -381,7 +450,11 @@ function NpcGraphInner({
targetNpcId: r.targetNpcId, targetNpcId: r.targetNpcId,
highlighted, highlighted,
}, },
style: { stroke: color, strokeWidth: highlighted ? 2.5 : 2 }, style: {
stroke: color,
strokeWidth: highlighted ? 2.5 : 2,
opacity: edgeDimmed ? 0.2 : 1,
},
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
width: 16, width: 16,
@@ -392,7 +465,7 @@ function NpcGraphInner({
}); });
} }
return out; return out;
}, [nodes, npcs, relations, selectedNpcId]); }, [graphFilter, isNpcDimmed, nodes, npcs, relations, selectedNpcId]);
useEffect(() => { useEffect(() => {
setNodes(initialNodes); setNodes(initialNodes);
@@ -461,8 +534,7 @@ function NpcGraphInner({
onEdgeClick={(e, edge) => { onEdgeClick={(e, edge) => {
e.stopPropagation(); e.stopPropagation();
setMenu(null); setMenu(null);
const sourceId = const sourceId = (edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
(edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
onSelect(sourceId); onSelect(sourceId);
}} }}
onEdgeContextMenu={(e, edge) => { onEdgeContextMenu={(e, edge) => {
@@ -479,6 +551,12 @@ function NpcGraphInner({
}} }}
> >
<Background gap={18} size={1} color="#27272a" /> <Background gap={18} size={1} color="#27272a" />
<FilterToolbar
ui={graphUi}
groups={npcGroups}
value={graphFilter}
onChange={onGraphFilterChange}
/>
<ZoomToolbar ui={graphUi} /> <ZoomToolbar ui={graphUi} />
</ReactFlow> </ReactFlow>
{menu && menuPosition {menu && menuPosition
@@ -0,0 +1,32 @@
.nameColorRow {
display: flex;
gap: 10px;
align-items: center;
}
.colorInput {
flex: 0 0 40px;
width: 40px;
height: 34px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
overflow: hidden;
}
.colorInput::-webkit-color-swatch-wrapper {
padding: 0;
border-radius: inherit;
}
.colorInput::-webkit-color-swatch {
border: none;
border-radius: inherit;
}
.colorInput::-moz-color-swatch {
border: none;
border-radius: inherit;
}
+133
View File
@@ -0,0 +1,133 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { DEFAULT_NPC_GROUP_COLOR } from '../../shared/npcs/npcGroups';
import type { ProjectNpcGroup } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { Button, Input } from '../shared/ui/controls';
import styles from './NpcGroupModal.module.css';
type NpcGroupModalProps = {
open: boolean;
initial: ProjectNpcGroup | null;
siblingNames: string[];
onClose: () => void;
onSave: (input: { name: string; color: string }) => Promise<void>;
};
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
export function NpcGroupModal({ open, initial, siblingNames, onClose, onSave }: NpcGroupModalProps) {
const { t } = useEditorI18n();
const [name, setName] = useState('');
const [color, setColor] = useState(DEFAULT_NPC_GROUP_COLOR);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setName(initial?.name ?? '');
setColor(initial?.color ?? DEFAULT_NPC_GROUP_COLOR);
setSaving(false);
setError(null);
}, [initial, 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]);
const trimmed = name.trim();
const nameOk = trimmed.length >= 1;
const nameDup = useMemo(() => {
if (!nameOk) return false;
const key = normalizeName(trimmed);
const except = normalizeName(initial?.name ?? '');
return siblingNames.some((n) => {
const nk = normalizeName(n);
return nk === key && nk !== except;
});
}, [initial?.name, nameOk, siblingNames, trimmed]);
const canSave = nameOk && !nameDup && !saving;
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editGroup') : t('npcs.addGroup')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={editorStyles.modalClose}
>
×
</button>
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.groupName')}</div>
<div className={styles.nameColorRow}>
<input
type="color"
className={styles.colorInput}
value={color}
onChange={(e) => setColor(e.target.value)}
aria-label={t('npcs.groupColor')}
/>
<Input value={name} onChange={setName} placeholder={t('npcs.groupName')} />
</div>
{!nameOk ? <div className={editorStyles.fieldError}>{t('npcs.groupNameRequired')}</div> : null}
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.groupNameDup')}</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 () => {
setSaving(true);
setError(null);
try {
await onSave({ name: trimmed, color });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
+1 -5
View File
@@ -64,11 +64,7 @@ export function NpcRelationModal({ open, initialLabel = '', onClose, onSave }: N
<div className={editorStyles.fieldGrid}> <div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.relationLabel')}</div> <div className={editorStyles.fieldLabel}>{t('npcs.relationLabel')}</div>
<Input <Input value={label} onChange={setLabel} placeholder={t('npcs.relationLabelPlaceholder')} />
value={label}
onChange={setLabel}
placeholder={t('npcs.relationLabelPlaceholder')}
/>
{trimmed.length < 1 ? ( {trimmed.length < 1 ? (
<div className={editorStyles.fieldError}>{t('npcs.relationLabelRequired')}</div> <div className={editorStyles.fieldError}>{t('npcs.relationLabelRequired')}</div>
) : null} ) : null}
+63
View File
@@ -158,3 +158,66 @@
color: var(--text2); color: var(--text2);
font-size: 13px; font-size: 13px;
} }
.groupBlock {
display: grid;
gap: 4px;
}
.groupHeader {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 6px;
padding: 4px 2px;
border-bottom: 1px solid var(--stroke);
user-select: none;
}
.groupToggle {
border: 0;
background: transparent;
color: var(--text2);
cursor: pointer;
width: 18px;
padding: 0;
font-size: 11px;
}
.groupColorDot {
width: 10px;
height: 10px;
border-radius: 999px;
flex-shrink: 0;
}
.groupTitle {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groupBody {
display: grid;
gap: 6px;
padding: 4px 0 8px 4px;
min-height: 0;
}
.ungroupedSection {
display: grid;
gap: 4px;
margin-top: 4px;
}
.ungroupedHeader {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.6px;
color: var(--text2);
padding: 6px 2px 4px;
border-bottom: 1px solid var(--stroke);
}
+160 -18
View File
@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { NpcId, ProjectNpc } from '../../shared/types'; import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml'; import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { Button, Input } from '../shared/ui/controls'; import { Button, Input } from '../shared/ui/controls';
@@ -37,28 +38,38 @@ function ZoomOutIcon() {
); );
} }
/** Убрать пустые ветки групп (удобно при поиске). */
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
const out: NpcGroupTreeNode[] = [];
for (const node of nodes) {
const children = pruneEmptyGroupNodes(node.children);
if (node.npcs.length === 0 && children.length === 0) continue;
out.push({ ...node, children });
}
return out;
}
function RuntimeNpcTile({ function RuntimeNpcTile({
npc, npc,
selected, selected,
active, active,
accentColor,
onActivate, onActivate,
}: { }: {
npc: ProjectNpc; npc: ProjectNpc;
selected: boolean; selected: boolean;
active: boolean; active: boolean;
accentColor?: string | null;
onActivate: () => void; onActivate: () => void;
}) { }) {
const url = useAssetUrl(npc.avatarAssetId); const url = useAssetUrl(npc.avatarAssetId);
return ( return (
<button <button
type="button" type="button"
className={[ className={[styles.tile, selected ? styles.tileSelected : '', active ? styles.tileActive : '']
styles.tile,
selected ? styles.tileSelected : '',
active ? styles.tileActive : '',
]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
onClick={onActivate} onClick={onActivate}
> >
<div className={styles.tileAvatar}> <div className={styles.tileAvatar}>
@@ -69,6 +80,70 @@ function RuntimeNpcTile({
); );
} }
function RuntimeGroupSection({
node,
depth,
isExpanded,
onToggleExpanded,
selectedId,
activeId,
onActivate,
}: {
node: NpcGroupTreeNode;
depth: number;
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedId: NpcId | null;
activeId: NpcId | null;
onActivate: (id: NpcId) => void;
}) {
const g = node.group;
const expanded = isExpanded(g.id);
return (
<div className={styles.groupBlock} style={{ paddingLeft: depth > 0 ? 12 : 0 }}>
<div className={styles.groupHeader}>
<button
type="button"
className={styles.groupToggle}
onClick={() => onToggleExpanded(g.id)}
aria-expanded={expanded}
>
{expanded ? '▾' : '▸'}
</button>
<span className={styles.groupColorDot} style={{ background: g.color }} aria-hidden />
<span className={styles.groupTitle}>{g.name}</span>
</div>
{expanded ? (
<div className={styles.groupBody}>
{node.npcs.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
active={n.id === activeId}
accentColor={g.color}
onActivate={() => onActivate(n.id)}
/>
))}
{node.children.map((child) => (
<RuntimeGroupSection
key={child.group.id}
node={child}
depth={depth + 1}
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedId={selectedId}
activeId={activeId}
onActivate={onActivate}
/>
))}
</div>
) : null}
</div>
);
}
export function NpcsApp() { export function NpcsApp() {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const api = getDndApi(); const api = getDndApi();
@@ -76,6 +151,7 @@ export function NpcsApp() {
const [overlay, overlayApi] = useNpcsOverlayState(); const [overlay, overlayApi] = useNpcsOverlayState();
const [selectedId, setSelectedId] = useState<NpcId | null>(null); const [selectedId, setSelectedId] = useState<NpcId | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
useEffect(() => { useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => { void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
@@ -106,18 +182,53 @@ export function NpcsApp() {
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]); }, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
const npcs = session?.project?.npcs ?? []; const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
const relations = session?.project?.npcRelations ?? []; const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
const activeId = overlay?.activeNpcId ?? null; const activeId = overlay?.activeNpcId ?? null;
const zoomTool = overlay?.zoomTool ?? null; const zoomTool = overlay?.zoomTool ?? null;
const selected = npcs.find((n) => n.id === selectedId) ?? null; const effectiveSelectedId =
selectedId && npcs.some((n) => n.id === selectedId) ? selectedId : (npcs[0]?.id ?? null);
const selected = npcs.find((n) => n.id === effectiveSelectedId) ?? null;
const filtered = useMemo(() => { const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return npcs; if (!q) return npcs;
return npcs.filter((n) => n.name.toLowerCase().includes(q)); return npcs.filter((n) => n.name.toLowerCase().includes(q));
}, [npcs, query]); }, [npcs, query]);
const searching = query.trim().length > 0;
const { roots, ungrouped } = useMemo(() => {
const forest = buildNpcGroupForest(npcGroups, filteredNpcs);
if (!searching) return forest;
return {
roots: pruneEmptyGroupNodes(forest.roots),
ungrouped: forest.ungrouped,
};
}, [filteredNpcs, npcGroups, searching]);
const isExpanded = useCallback(
(id: NpcGroupId) => {
if (searching) return true;
return !collapsedGroups.has(id);
},
[collapsedGroups, searching],
);
const toggleExpanded = useCallback(
(id: NpcGroupId) => {
if (searching) return;
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
},
[searching],
);
const relationsForSelected = useMemo(() => { const relationsForSelected = useMemo(() => {
if (!selected) return []; if (!selected) return [];
return relations return relations
@@ -202,10 +313,7 @@ export function NpcsApp() {
{safeHtml ? ( {safeHtml ? (
<div> <div>
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div> <div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
<div <div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
className={styles.detailDesc}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
</div> </div>
) : ( ) : (
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div> <div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
@@ -231,17 +339,51 @@ export function NpcsApp() {
<div className={styles.listCol}> <div className={styles.listCol}>
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} /> <Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
<div className={styles.list}> <div className={styles.list}>
{filtered.map((n) => ( {npcGroups.length === 0 ? (
filteredNpcs.map((n) => (
<RuntimeNpcTile <RuntimeNpcTile
key={n.id} key={n.id}
npc={n} npc={n}
selected={n.id === selectedId} selected={n.id === effectiveSelectedId}
active={n.id === activeId}
onActivate={() => onSelectTile(n.id)}
/>
))
) : (
<>
{roots.map((node) => (
<RuntimeGroupSection
key={node.group.id}
node={node}
depth={0}
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedId={effectiveSelectedId}
activeId={activeId}
onActivate={onSelectTile}
/>
))}
{ungrouped.length > 0 || !searching ? (
<div className={styles.ungroupedSection}>
<div className={styles.ungroupedHeader}>{t('npcs.ungrouped')}</div>
<div className={styles.groupBody}>
{ungrouped.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={n.id === effectiveSelectedId}
active={n.id === activeId} active={n.id === activeId}
onActivate={() => onSelectTile(n.id)} onActivate={() => onSelectTile(n.id)}
/> />
))} ))}
</div>
</div>
) : null}
</>
)}
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null} {npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null}
{npcs.length > 0 && filtered.length === 0 ? ( {npcs.length > 0 && filteredNpcs.length === 0 ? (
<div className={styles.muted}>{t('npcs.searchEmpty')}</div> <div className={styles.muted}>{t('npcs.searchEmpty')}</div>
) : null} ) : null}
</div> </div>
+118
View File
@@ -45,6 +45,27 @@
background: #0f0f12; background: #0f0f12;
} }
.sideActions {
display: flex;
gap: 8px;
flex-wrap: nowrap;
align-items: stretch;
}
.sideActions > * {
flex: 1 1 0;
min-width: 0;
}
.sideActions button {
width: 100%;
padding-left: 8px;
padding-right: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list { .list {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -272,3 +293,100 @@
.menuItemDanger:hover { .menuItemDanger:hover {
background: rgba(239, 68, 68, 0.18); background: rgba(239, 68, 68, 0.18);
} }
.groupBlock {
display: grid;
gap: 4px;
}
.groupHeader {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 6px;
padding: 4px 2px;
border-bottom: 1px solid var(--stroke);
cursor: grab;
user-select: none;
}
.groupHeaderDragging {
opacity: 0.55;
}
.groupDropBefore {
box-shadow: inset 0 2px 0 #60a5fa;
}
.groupDropAfter {
box-shadow: inset 0 -2px 0 #60a5fa;
}
.groupToggle {
border: 0;
background: transparent;
color: var(--text2);
cursor: pointer;
width: 18px;
padding: 0;
font-size: 11px;
}
.groupColorDot {
width: 10px;
height: 10px;
border-radius: 999px;
flex-shrink: 0;
}
.groupTitle {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groupMenuBtn {
width: 24px;
height: 24px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text2);
cursor: pointer;
}
.groupMenuBtn:hover {
background: #27272a;
color: var(--text1);
}
.groupBody {
display: grid;
gap: 6px;
padding: 4px 0 8px 4px;
min-height: 8px;
}
.groupBodyDrop {
background: rgba(96, 165, 250, 0.08);
border-radius: 8px;
outline: 1px dashed rgba(96, 165, 250, 0.45);
}
.ungroupedSection {
display: grid;
gap: 4px;
margin-top: 4px;
}
.ungroupedHeader {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.6px;
color: var(--text2);
padding: 6px 2px 4px;
border-bottom: 1px solid var(--stroke);
}
+601 -38
View File
@@ -2,7 +2,16 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types'; import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type {
NpcBinding,
NpcGroupId,
NpcId,
NpcRelationId,
ProjectNpc,
ProjectNpcGroup,
ProjectNpcRelation,
} from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css'; import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
@@ -10,19 +19,33 @@ import { Button, Input } from '../shared/ui/controls';
import controlStyles from '../shared/ui/Controls.module.css'; import controlStyles from '../shared/ui/Controls.module.css';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
import { NpcDescriptionField } from './NpcDescriptionField'; import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal'; import { NpcEditModal } from './NpcEditModal';
import { NpcGraph } from './NpcGraph'; import { NpcGraph, type GraphGroupFilter } from './NpcGraph';
import { NpcGroupModal } from './NpcGroupModal';
import {
flattenGroupOptions,
moveNpcToGroupEnd,
reorderNpcIds,
reorderSiblingGroups,
} from './npcListHelpers';
import { NpcRelationModal } from './NpcRelationModal'; import { NpcRelationModal } from './NpcRelationModal';
import styles from './NpcsEditorApp.module.css'; import styles from './NpcsEditorApp.module.css';
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id'; const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
type GroupModalState =
| { mode: 'create'; parentId: NpcGroupId | null }
| { mode: 'edit'; group: ProjectNpcGroup };
function NpcTile({ function NpcTile({
npc, npc,
selected, selected,
dragging, dragging,
dropPlace, dropPlace,
accentColor,
onSelect, onSelect,
onMenu, onMenu,
onDragStart, onDragStart,
@@ -34,6 +57,7 @@ function NpcTile({
selected: boolean; selected: boolean;
dragging: boolean; dragging: boolean;
dropPlace: 'before' | 'after' | null; dropPlace: 'before' | 'after' | null;
accentColor?: string | null;
onSelect: () => void; onSelect: () => void;
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void; onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
onDragStart: () => void; onDragStart: () => void;
@@ -54,6 +78,7 @@ function NpcTile({
] ]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
draggable draggable
onDragStart={(e) => { onDragStart={(e) => {
e.dataTransfer.setData(DND_NPC_ID_MIME, npc.id); e.dataTransfer.setData(DND_NPC_ID_MIME, npc.id);
@@ -91,6 +116,193 @@ function NpcTile({
); );
} }
function GroupSection({
node,
depth,
isExpanded,
onToggleExpanded,
selectedId,
dragId,
dropPlace,
dragGroupId,
groupDropPlace,
dropTargetGroup,
groupColorById,
onSelectNpc,
onNpcMenu,
onGroupMenu,
onNpcDragStart,
onNpcDragEnd,
onNpcDragOver,
onNpcDropReorder,
onGroupDragStart,
onGroupDragEnd,
onGroupDragOver,
onGroupDropReorder,
onGroupBodyDragOver,
onGroupBodyDrop,
}: {
node: NpcGroupTreeNode;
depth: number;
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedId: NpcId | null;
dragId: NpcId | null;
dropPlace: { id: NpcId; place: 'before' | 'after' } | null;
dragGroupId: NpcGroupId | null;
groupDropPlace: { id: NpcGroupId; place: 'before' | 'after' } | null;
dropTargetGroup: NpcGroupId | null;
groupColorById: Map<NpcGroupId, string>;
onSelectNpc: (id: NpcId) => void;
onNpcMenu: (id: NpcId, e: React.MouseEvent<HTMLButtonElement>) => void;
onGroupMenu: (id: NpcGroupId, e: React.MouseEvent<HTMLButtonElement>) => void;
onNpcDragStart: (id: NpcId) => void;
onNpcDragEnd: () => void;
onNpcDragOver: (id: NpcId, place: 'before' | 'after') => void;
onNpcDropReorder: (targetId: NpcId) => void;
onGroupDragStart: (id: NpcGroupId) => void;
onGroupDragEnd: () => void;
onGroupDragOver: (id: NpcGroupId, place: 'before' | 'after') => void;
onGroupDropReorder: (targetId: NpcGroupId) => void;
onGroupBodyDragOver: (groupId: NpcGroupId) => void;
onGroupBodyDrop: (groupId: NpcGroupId) => void;
}) {
const { t } = useEditorI18n();
const g = node.group;
const expanded = isExpanded(g.id);
const isGroupDragging = dragGroupId === g.id;
const groupDropBefore = groupDropPlace?.id === g.id && groupDropPlace.place === 'before';
const groupDropAfter = groupDropPlace?.id === g.id && groupDropPlace.place === 'after';
const bodyHighlight = dropTargetGroup === g.id && dragId !== null;
return (
<div className={styles.groupBlock} style={{ paddingLeft: depth > 0 ? 12 : 0 }}>
<div
className={[
styles.groupHeader,
isGroupDragging ? styles.groupHeaderDragging : '',
groupDropBefore ? styles.groupDropBefore : '',
groupDropAfter ? styles.groupDropAfter : '',
]
.filter(Boolean)
.join(' ')}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(DND_NPC_GROUP_ID_MIME, g.id);
e.dataTransfer.effectAllowed = 'move';
onGroupDragStart(g.id);
}}
onDragEnd={onGroupDragEnd}
onDragOver={(e) => {
if (dragGroupId && dragGroupId !== g.id) {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
onGroupDragOver(g.id, e.clientY < mid ? 'before' : 'after');
return;
}
if (dragId) {
e.preventDefault();
onGroupBodyDragOver(g.id);
}
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
if (dragGroupId && dragGroupId !== g.id && groupDropPlace?.id === g.id) {
onGroupDropReorder(g.id);
return;
}
if (dragId) onGroupBodyDrop(g.id);
}}
>
<button
type="button"
className={styles.groupToggle}
onClick={() => onToggleExpanded(g.id)}
aria-expanded={expanded}
>
{expanded ? '▾' : '▸'}
</button>
<span className={styles.groupColorDot} style={{ background: g.color }} aria-hidden />
<span className={styles.groupTitle}>{g.name}</span>
<button
type="button"
className={styles.groupMenuBtn}
data-npc-menu-root="1"
aria-label={t('npcs.tileMenu')}
onClick={(e) => onGroupMenu(g.id, e)}
>
</button>
</div>
{expanded ? (
<div
className={[styles.groupBody, bodyHighlight ? styles.groupBodyDrop : ''].filter(Boolean).join(' ')}
onDragOver={(e) => {
if (!dragId) return;
e.preventDefault();
onGroupBodyDragOver(g.id);
}}
onDrop={(e) => {
if (!dragId) return;
e.preventDefault();
onGroupBodyDrop(g.id);
}}
>
{node.npcs.map((n) => (
<NpcTile
key={n.id}
npc={n}
selected={n.id === selectedId}
dragging={dragId === n.id}
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
accentColor={groupColorById.get(g.id) ?? g.color}
onSelect={() => onSelectNpc(n.id)}
onMenu={(e) => onNpcMenu(n.id, e)}
onDragStart={() => onNpcDragStart(n.id)}
onDragEnd={onNpcDragEnd}
onDragOver={(place) => onNpcDragOver(n.id, place)}
onDropReorder={() => onNpcDropReorder(n.id)}
/>
))}
{node.children.map((child) => (
<GroupSection
key={child.group.id}
node={child}
depth={depth + 1}
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedId={selectedId}
dragId={dragId}
dropPlace={dropPlace}
dragGroupId={dragGroupId}
groupDropPlace={groupDropPlace}
dropTargetGroup={dropTargetGroup}
groupColorById={groupColorById}
onSelectNpc={onSelectNpc}
onNpcMenu={onNpcMenu}
onGroupMenu={onGroupMenu}
onNpcDragStart={onNpcDragStart}
onNpcDragEnd={onNpcDragEnd}
onNpcDragOver={onNpcDragOver}
onNpcDropReorder={onNpcDropReorder}
onGroupDragStart={onGroupDragStart}
onGroupDragEnd={onGroupDragEnd}
onGroupDragOver={onGroupDragOver}
onGroupDropReorder={onGroupDropReorder}
onGroupBodyDragOver={onGroupBodyDragOver}
onGroupBodyDrop={onGroupBodyDrop}
/>
))}
</div>
) : null}
</div>
);
}
export function NpcsEditorApp() { export function NpcsEditorApp() {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const api = getDndApi(); const api = getDndApi();
@@ -101,9 +313,20 @@ export function NpcsEditorApp() {
const [editInitial, setEditInitial] = useState<ProjectNpc | null>(null); const [editInitial, setEditInitial] = useState<ProjectNpc | null>(null);
const [pendingDelete, setPendingDelete] = useState<ProjectNpc | null>(null); const [pendingDelete, setPendingDelete] = useState<ProjectNpc | null>(null);
const [menuFor, setMenuFor] = useState<NpcId | null>(null); const [menuFor, setMenuFor] = useState<NpcId | null>(null);
const [groupMenuFor, setGroupMenuFor] = useState<NpcGroupId | null>(null);
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null); const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const [dragId, setDragId] = useState<NpcId | null>(null); const [dragId, setDragId] = useState<NpcId | null>(null);
const [dropPlace, setDropPlace] = useState<{ id: NpcId; place: 'before' | 'after' } | null>(null); const [dropPlace, setDropPlace] = useState<{ id: NpcId; place: 'before' | 'after' } | null>(null);
const [dragGroupId, setDragGroupId] = useState<NpcGroupId | null>(null);
const [groupDropPlace, setGroupDropPlace] = useState<{
id: NpcGroupId;
place: 'before' | 'after';
} | null>(null);
const [dropTargetGroup, setDropTargetGroup] = useState<NpcGroupId | 'ungrouped' | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<NpcGroupId>>(() => new Set());
const [groupModal, setGroupModal] = useState<GroupModalState | null>(null);
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<ProjectNpcGroup | null>(null);
const [graphFilter, setGraphFilter] = useState<GraphGroupFilter>('all');
const [nameDraft, setNameDraft] = useState(''); const [nameDraft, setNameDraft] = useState('');
const [relationModal, setRelationModal] = useState< const [relationModal, setRelationModal] = useState<
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId } | { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
@@ -118,14 +341,18 @@ export function NpcsEditorApp() {
setSession({ project, currentSceneId: project?.currentSceneId ?? null }); setSession({ project, currentSceneId: project?.currentSceneId ?? null });
const list = project?.npcs ?? []; const list = project?.npcs ?? [];
setSelectedId(list[0]?.id ?? null); setSelectedId(list[0]?.id ?? null);
const groups = project?.npcGroups ?? [];
setExpandedGroups(new Set(groups.map((g) => g.id)));
}); });
return api.on(ipcChannels.session.stateChanged, ({ state }) => { return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state); setSession(state);
}); });
}, [api]); }, [api]);
const npcs = session?.project?.npcs ?? []; const project = session?.project ?? null;
const relations = session?.project?.npcRelations ?? []; const npcs = useMemo(() => project?.npcs ?? [], [project?.npcs]);
const npcGroups = useMemo(() => project?.npcGroups ?? [], [project?.npcGroups]);
const relations = useMemo(() => project?.npcRelations ?? [], [project?.npcRelations]);
const selected = npcs.find((n) => n.id === selectedId) ?? null; const selected = npcs.find((n) => n.id === selectedId) ?? null;
useEffect(() => { useEffect(() => {
@@ -138,23 +365,33 @@ export function NpcsEditorApp() {
}, [npcs, selectedId]); }, [npcs, selectedId]);
useEffect(() => { useEffect(() => {
if (!menuFor) return; if (!menuFor && !groupMenuFor) return;
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
const tgt = e.target as HTMLElement | null; const tgt = e.target as HTMLElement | null;
if (tgt?.closest('[data-npc-menu-root="1"]')) return; if (tgt?.closest('[data-npc-menu-root="1"]')) return;
setMenuFor(null); setMenuFor(null);
setGroupMenuFor(null);
setMenuPos(null); setMenuPos(null);
}; };
window.addEventListener('mousedown', onDown); window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown);
}, [menuFor]); }, [groupMenuFor, menuFor]);
const filtered = useMemo(() => { const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return npcs; if (!q) return npcs;
return npcs.filter((n) => n.name.toLowerCase().includes(q)); return npcs.filter((n) => n.name.toLowerCase().includes(q));
}, [npcs, query]); }, [npcs, query]);
const { roots, ungrouped } = useMemo(
() => buildNpcGroupForest(npcGroups, filteredNpcs),
[filteredNpcs, npcGroups],
);
const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]);
const groupOptions = useMemo(() => flattenGroupOptions(roots), [roots]);
const selectedUrl = useAssetUrl(selected?.avatarAssetId ?? null); const selectedUrl = useAssetUrl(selected?.avatarAssetId ?? null);
const relationsForSelected = useMemo(() => { const relationsForSelected = useMemo(() => {
@@ -173,6 +410,83 @@ export function NpcsEditorApp() {
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl }; return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
}, [api]); }, [api]);
const commitNpcGroupChange = useCallback(
async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => {
const npc = npcs.find((n) => n.id === npcId);
if (npc && npc.groupId !== groupId) {
await api.invoke(ipcChannels.project.updateNpcFields, { npcId, groupId });
}
if (orderIds) {
await api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: orderIds });
}
},
[api, npcs],
);
const handleNpcDropOnTile = useCallback(
(targetId: NpcId) => {
if (!dragId || dropPlace?.id !== targetId || dragId === targetId) return;
const targetNpc = npcs.find((n) => n.id === targetId);
if (!targetNpc) return;
const dragNpc = npcs.find((n) => n.id === dragId);
if (!dragNpc) return;
const newGroupId = targetNpc.groupId;
const orderIds = reorderNpcIds(
dragNpc.groupId === newGroupId
? npcs
: npcs.map((n) => (n.id === dragId ? { ...n, groupId: newGroupId } : n)),
dragId,
targetId,
dropPlace.place,
);
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
void commitNpcGroupChange(dragId, newGroupId, orderIds);
},
[commitNpcGroupChange, dragId, dropPlace, npcs],
);
const handleNpcDropOnGroup = useCallback(
(groupId: NpcGroupId | null) => {
if (!dragId) return;
const dragNpc = npcs.find((n) => n.id === dragId);
if (!dragNpc) return;
const orderIds = moveNpcToGroupEnd(
dragNpc.groupId === groupId ? npcs : npcs.map((n) => (n.id === dragId ? { ...n, groupId } : n)),
dragId,
groupId,
);
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
void commitNpcGroupChange(dragId, groupId, orderIds);
},
[commitNpcGroupChange, dragId, npcs],
);
const handleGroupDropReorder = useCallback(
(targetId: NpcGroupId) => {
if (!dragGroupId || !groupDropPlace || dragGroupId === groupDropPlace.id) return;
const order = reorderSiblingGroups(npcGroups, dragGroupId, targetId, groupDropPlace.place);
setDragGroupId(null);
setGroupDropPlace(null);
void api.invoke(ipcChannels.project.setNpcGroupsOrder, { groupIds: order });
},
[api, dragGroupId, groupDropPlace, npcGroups],
);
const openMenuAt = (e: React.MouseEvent<HTMLButtonElement>) => {
const r = e.currentTarget.getBoundingClientRect();
const menuW = 180;
const menuH = 120;
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 });
};
const graphUi = useMemo( const graphUi = useMemo(
() => ({ () => ({
zoomBar: t('npcs.graphZoomBar'), zoomBar: t('npcs.graphZoomBar'),
@@ -182,10 +496,28 @@ export function NpcsEditorApp() {
editRelation: t('npcs.relationEdit'), editRelation: t('npcs.relationEdit'),
deleteRelation: t('npcs.relationDelete'), deleteRelation: t('npcs.relationDelete'),
untitled: t('npcs.untitled'), untitled: t('npcs.untitled'),
graphFilter: t('npcs.graphFilter'),
graphFilterAll: t('npcs.graphFilterAll'),
graphFilterUngrouped: t('npcs.graphFilterUngrouped'),
}), }),
[t], [t],
); );
const isExpanded = (id: NpcGroupId) => expandedGroups.has(id);
const toggleExpanded = (id: NpcGroupId) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const siblingNamesForGroup = (parentId: NpcGroupId | null, exceptId?: NpcGroupId) =>
npcGroups.filter((g) => g.parentId === parentId && g.id !== exceptId).map((g) => g.name);
const hasListContent = npcGroups.length > 0 || ungrouped.length > 0;
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.topBar}> <div className={styles.topBar}>
@@ -202,6 +534,7 @@ export function NpcsEditorApp() {
<div className={styles.body}> <div className={styles.body}>
<div className={[styles.col, styles.side].join(' ')}> <div className={[styles.col, styles.side].join(' ')}>
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} /> <Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
<div className={styles.sideActions}>
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
@@ -211,8 +544,93 @@ export function NpcsEditorApp() {
> >
{t('npcs.add')} {t('npcs.add')}
</Button> </Button>
<Button
onClick={() => {
setGroupModal({ mode: 'create', parentId: null });
}}
>
{t('npcs.addGroup')}
</Button>
</div>
<div className={styles.list}> <div className={styles.list}>
{filtered.map((n) => ( {roots.map((node) => (
<GroupSection
key={node.group.id}
node={node}
depth={0}
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedId={selectedId}
dragId={dragId}
dropPlace={dropPlace}
dragGroupId={dragGroupId}
groupDropPlace={groupDropPlace}
dropTargetGroup={dropTargetGroup === 'ungrouped' ? null : dropTargetGroup}
groupColorById={groupColorById}
onSelectNpc={setSelectedId}
onNpcMenu={(id, e) => {
openMenuAt(e);
setGroupMenuFor(null);
setMenuFor((cur) => (cur === id ? null : id));
}}
onGroupMenu={(id, e) => {
openMenuAt(e);
setMenuFor(null);
setGroupMenuFor((cur) => (cur === id ? null : id));
}}
onNpcDragStart={setDragId}
onNpcDragEnd={() => {
setDragId(null);
setDropPlace(null);
setDropTargetGroup(null);
}}
onNpcDragOver={(id, place) => {
if (!dragId || dragId === id) {
setDropPlace(null);
return;
}
setDropPlace({ id, place });
}}
onNpcDropReorder={handleNpcDropOnTile}
onGroupDragStart={setDragGroupId}
onGroupDragEnd={() => {
setDragGroupId(null);
setGroupDropPlace(null);
}}
onGroupDragOver={(id, place) => {
if (!dragGroupId || dragGroupId === id) {
setGroupDropPlace(null);
return;
}
setGroupDropPlace({ id, place });
}}
onGroupDropReorder={handleGroupDropReorder}
onGroupBodyDragOver={setDropTargetGroup}
onGroupBodyDrop={(groupId) => handleNpcDropOnGroup(groupId)}
/>
))}
<div className={styles.ungroupedSection}>
<div className={styles.ungroupedHeader}>{t('npcs.ungrouped')}</div>
<div
className={[
styles.groupBody,
dropTargetGroup === 'ungrouped' && dragId ? styles.groupBodyDrop : '',
]
.filter(Boolean)
.join(' ')}
onDragOver={(e) => {
if (!dragId) return;
e.preventDefault();
setDropTargetGroup('ungrouped');
}}
onDrop={(e) => {
if (!dragId) return;
e.preventDefault();
handleNpcDropOnGroup(null);
}}
>
{ungrouped.map((n) => (
<NpcTile <NpcTile
key={n.id} key={n.id}
npc={n} npc={n}
@@ -221,21 +639,15 @@ export function NpcsEditorApp() {
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null} dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
onSelect={() => setSelectedId(n.id)} onSelect={() => setSelectedId(n.id)}
onMenu={(e) => { onMenu={(e) => {
const r = e.currentTarget.getBoundingClientRect(); openMenuAt(e);
const menuW = 180; setGroupMenuFor(null);
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 === n.id ? null : n.id)); setMenuFor((cur) => (cur === n.id ? null : n.id));
}} }}
onDragStart={() => setDragId(n.id)} onDragStart={() => setDragId(n.id)}
onDragEnd={() => { onDragEnd={() => {
setDragId(null); setDragId(null);
setDropPlace(null); setDropPlace(null);
setDropTargetGroup(null);
}} }}
onDragOver={(place) => { onDragOver={(place) => {
if (!dragId || dragId === n.id) { if (!dragId || dragId === n.id) {
@@ -244,24 +656,16 @@ export function NpcsEditorApp() {
} }
setDropPlace({ id: n.id, place }); setDropPlace({ id: n.id, place });
}} }}
onDropReorder={() => { onDropReorder={() => handleNpcDropOnTile(n.id)}
if (!dragId || !dropPlace || dragId === dropPlace.id) return;
const ids = npcs.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);
void api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: ids });
}}
/> />
))} ))}
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.empty')}</div> : null} </div>
{npcs.length > 0 && filtered.length === 0 ? ( </div>
{!hasListContent && npcs.length === 0 ? (
<div className={styles.muted}>{t('npcs.empty')}</div>
) : null}
{npcs.length > 0 && filteredNpcs.length === 0 ? (
<div className={styles.muted}>{t('npcs.searchEmpty')}</div> <div className={styles.muted}>{t('npcs.searchEmpty')}</div>
) : null} ) : null}
</div> </div>
@@ -271,7 +675,10 @@ export function NpcsEditorApp() {
<NpcGraph <NpcGraph
npcs={npcs} npcs={npcs}
relations={relations} relations={relations}
npcGroups={npcGroups}
selectedNpcId={selectedId} selectedNpcId={selectedId}
graphFilter={graphFilter}
onGraphFilterChange={setGraphFilter}
graphUi={graphUi} graphUi={graphUi}
onSelect={setSelectedId} onSelect={setSelectedId}
onConnectRequest={(sourceNpcId, targetNpcId) => { onConnectRequest={(sourceNpcId, targetNpcId) => {
@@ -295,7 +702,7 @@ export function NpcsEditorApp() {
<div className={[styles.col, styles.inspector].join(' ')}> <div className={[styles.col, styles.inspector].join(' ')}>
<div className={styles.inspectorScroll}> <div className={styles.inspectorScroll}>
{selected ? ( {selected && project ? (
<> <>
<div> <div>
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div> <div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
@@ -352,6 +759,28 @@ export function NpcsEditorApp() {
/> />
</div> </div>
<div>
<div className={styles.fieldLabel}>{t('npcs.group')}</div>
<select
className={controlStyles.input}
value={selected.groupId ?? ''}
onChange={(e) => {
const groupId = (e.target.value || null) as NpcGroupId | null;
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
groupId,
});
}}
>
<option value="">{t('npcs.ungrouped')}</option>
{groupOptions.map((g) => (
<option key={g.id} value={g.id}>
{g.label}
</option>
))}
</select>
</div>
<div> <div>
<div className={styles.fieldLabel}>{t('npcs.description')}</div> <div className={styles.fieldLabel}>{t('npcs.description')}</div>
<NpcDescriptionField <NpcDescriptionField
@@ -366,6 +795,20 @@ export function NpcsEditorApp() {
/> />
</div> </div>
<div>
<div className={styles.fieldLabel}>{t('npcs.bindingEnable')}</div>
<NpcBindingFields
project={project}
binding={selected.binding}
onChange={(binding: NpcBinding) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
binding,
});
}}
/>
</div>
{relationsForSelected.length > 0 ? ( {relationsForSelected.length > 0 ? (
<div> <div>
<div className={styles.relationsTitle}>{t('npcs.relations')}</div> <div className={styles.relationsTitle}>{t('npcs.relations')}</div>
@@ -390,6 +833,8 @@ export function NpcsEditorApp() {
open={editOpen} open={editOpen}
initial={editInitial} initial={editInitial}
existingNames={npcs.map((n) => n.name)} existingNames={npcs.map((n) => n.name)}
project={project}
npcGroups={npcGroups}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
onPickImage={pickAvatar} onPickImage={pickAvatar}
onSave={async (input) => { onSave={async (input) => {
@@ -397,12 +842,43 @@ export function NpcsEditorApp() {
...(editInitial ? { npcId: editInitial.id } : {}), ...(editInitial ? { npcId: editInitial.id } : {}),
name: input.name, name: input.name,
...(input.filePath ? { filePath: input.filePath } : {}), ...(input.filePath ? { filePath: input.filePath } : {}),
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
...(input.binding !== undefined ? { binding: input.binding } : {}),
}); });
const created = res.project.npcs.find((n) => n.name === input.name.trim()); const created = res.project.npcs.find((n) => n.name === input.name.trim());
if (created) setSelectedId(created.id); if (created) setSelectedId(created.id);
}} }}
/> />
<NpcGroupModal
open={Boolean(groupModal)}
initial={groupModal?.mode === 'edit' ? groupModal.group : null}
siblingNames={
groupModal?.mode === 'edit'
? siblingNamesForGroup(groupModal.group.parentId, groupModal.group.id)
: groupModal?.mode === 'create'
? siblingNamesForGroup(groupModal.parentId)
: []
}
onClose={() => setGroupModal(null)}
onSave={async ({ name, color }) => {
if (!groupModal) return;
if (groupModal.mode === 'edit') {
await api.invoke(ipcChannels.project.upsertNpcGroup, {
groupId: groupModal.group.id,
name,
color,
});
} else {
await api.invoke(ipcChannels.project.upsertNpcGroup, {
name,
color,
parentId: groupModal.parentId,
});
}
}}
/>
<NpcRelationModal <NpcRelationModal
open={Boolean(relationModal)} open={Boolean(relationModal)}
initialLabel={relationModal?.mode === 'edit' ? relationModal.label : ''} initialLabel={relationModal?.mode === 'edit' ? relationModal.label : ''}
@@ -431,13 +907,14 @@ export function NpcsEditorApp() {
{menuFor && menuPos {menuFor && menuPos
? createPortal( ? createPortal(
<div <div
role="menu"
className={styles.menu} className={styles.menu}
style={{ left: menuPos.left, top: menuPos.top }} style={{ left: menuPos.left, top: menuPos.top }}
data-npc-menu-root="1" data-npc-menu-root="1"
onMouseDown={(e) => e.stopPropagation()}
> >
<button <button
type="button" type="button"
role="menuitem"
className={styles.menuItemDanger} className={styles.menuItemDanger}
onClick={() => { onClick={() => {
const npc = npcs.find((n) => n.id === menuFor); const npc = npcs.find((n) => n.id === menuFor);
@@ -452,6 +929,54 @@ export function NpcsEditorApp() {
) )
: null} : null}
{groupMenuFor && menuPos
? createPortal(
<div
role="menu"
className={styles.menu}
style={{ left: menuPos.left, top: menuPos.top }}
data-npc-menu-root="1"
>
<button
type="button"
role="menuitem"
className={styles.menuItem}
onClick={() => {
const g = npcGroups.find((x) => x.id === groupMenuFor);
if (g) setGroupModal({ mode: 'edit', group: g });
setGroupMenuFor(null);
}}
>
{t('npcs.editGroup')}
</button>
<button
type="button"
role="menuitem"
className={styles.menuItem}
onClick={() => {
setGroupModal({ mode: 'create', parentId: groupMenuFor });
setGroupMenuFor(null);
}}
>
{t('npcs.addSubgroup')}
</button>
<button
type="button"
role="menuitem"
className={styles.menuItemDanger}
onClick={() => {
const g = npcGroups.find((x) => x.id === groupMenuFor);
if (g) setPendingDeleteGroup(g);
setGroupMenuFor(null);
}}
>
{t('npcs.deleteGroup')}
</button>
</div>,
document.body,
)
: null}
{pendingDelete {pendingDelete
? createPortal( ? createPortal(
<> <>
@@ -492,6 +1017,46 @@ export function NpcsEditorApp() {
) )
: null} : null}
{pendingDeleteGroup
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteGroup(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>{t('npcs.deleteGroupTitle')}</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDeleteGroup(null)}
>
×
</button>
</div>
<div>{t('npcs.deleteGroupConfirm', { name: pendingDeleteGroup.name })}</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteGroup(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteGroup.id;
setPendingDeleteGroup(null);
void api.invoke(ipcChannels.project.deleteNpcGroup, { groupId: id });
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
{pendingDeleteRelation {pendingDeleteRelation
? createPortal( ? createPortal(
<> <>
@@ -512,9 +1077,7 @@ export function NpcsEditorApp() {
× ×
</button> </button>
</div> </div>
<div> <div>{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}</div>
{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}
</div>
<div className={editorStyles.modalFooter}> <div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteRelation(null)}>{t('common.cancel')}</Button> <Button onClick={() => setPendingDeleteRelation(null)}>{t('common.cancel')}</Button>
<Button <Button
+95
View File
@@ -0,0 +1,95 @@
import type { NpcGroupId, NpcId, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
export function reorderNpcIds(
npcs: ProjectNpc[],
dragId: NpcId,
targetId: NpcId,
place: 'before' | 'after',
): NpcId[] {
const ids = npcs.map((n) => n.id);
const from = ids.indexOf(dragId);
if (from < 0) return ids;
ids.splice(from, 1);
let to = ids.indexOf(targetId);
if (to < 0) return npcs.map((n) => n.id);
if (place === 'after') to += 1;
ids.splice(to, 0, dragId);
return ids;
}
/** Move NPC to end of its group block in global order. */
export function moveNpcToGroupEnd(npcs: ProjectNpc[], dragId: NpcId, groupId: NpcGroupId | null): NpcId[] {
const updated = npcs.map((n) => (n.id === dragId ? { ...n, groupId } : n));
const ids = updated.map((n) => n.id);
const from = ids.indexOf(dragId);
if (from < 0) return ids;
ids.splice(from, 1);
let insertAt = ids.length;
for (let i = ids.length - 1; i >= 0; i -= 1) {
const npc = updated.find((n) => n.id === ids[i]);
if (npc?.groupId === groupId) {
insertAt = i + 1;
break;
}
}
if (groupId === null) {
for (let i = ids.length - 1; i >= 0; i -= 1) {
const npc = updated.find((n) => n.id === ids[i]);
if (npc?.groupId === null) {
insertAt = i + 1;
break;
}
}
}
ids.splice(insertAt, 0, dragId);
return ids;
}
export function reorderSiblingGroups(
groups: ProjectNpcGroup[],
dragId: NpcGroupId,
targetId: NpcGroupId,
place: 'before' | 'after',
): NpcGroupId[] {
const drag = groups.find((g) => g.id === dragId);
const target = groups.find((g) => g.id === targetId);
if (!drag || drag.parentId !== target?.parentId) return groups.map((g) => g.id);
const parentId = drag.parentId;
const siblingIds = groups.filter((g) => g.parentId === parentId).map((g) => g.id);
const from = siblingIds.indexOf(dragId);
if (from < 0) return groups.map((g) => g.id);
siblingIds.splice(from, 1);
let to = siblingIds.indexOf(targetId);
if (to < 0) return groups.map((g) => g.id);
if (place === 'after') to += 1;
siblingIds.splice(to, 0, dragId);
const result: NpcGroupId[] = [];
let inserted = false;
for (const g of groups) {
if (g.parentId === parentId) {
if (!inserted) {
result.push(...siblingIds);
inserted = true;
}
continue;
}
result.push(g.id);
}
return result;
}
export function flattenGroupOptions(
nodes: { group: ProjectNpcGroup; children: unknown[] }[],
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 as typeof nodes, depth + 1));
}
return out;
}
+10 -1
View File
@@ -85,6 +85,15 @@
padding: 0 12px; padding: 0 12px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3); background-color: var(--color-overlay-dark-3);
outline: none; outline: none;
color: inherit;
}
select.input {
padding-right: 28px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
} }
+13 -1
View File
@@ -44,11 +44,23 @@ a {
button, button,
input, input,
textarea { textarea,
select {
font: inherit; font: inherit;
color: inherit; color: inherit;
} }
/* Кастомная стрелка select: 8px от правого края (нативные часто вплотную). */
select {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
padding-right: 28px;
}
::selection { ::selection {
background: var(--selection-bg); background: var(--selection-bg);
} }
+139
View File
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
extractMonksTeleportEdges,
filterScenesForImport,
pickFoundryStartSceneId,
planFoundrySceneGraph,
sceneBackgroundSrc,
} from './foundryGraph';
import { decodeFoundryAssetPath } from './foundryPaths';
import type { FoundrySceneDoc } from './foundryTypes';
import { isSupportedFoundryPackage } from './foundryVersion';
function scene(id: string, name: string, extra: Partial<FoundrySceneDoc> = {}): FoundrySceneDoc {
return { _id: id, name, navigation: true, ...extra };
}
function teleportTile(targetId: string) {
return {
flags: {
'monks-active-tiles': {
active: true,
actions: [{ action: 'scene', data: { sceneid: { id: `Scene.${targetId}`, name: 'x' } } }],
},
},
};
}
void test('filterScenesForImport keeps only navigation when present', () => {
const scenes = [
scene('aaaaaaaaaaaaaaaa', 'Nav', { navigation: true }),
scene('bbbbbbbbbbbbbbbb', 'Hidden', { navigation: false }),
];
assert.equal(filterScenesForImport(scenes).length, 1);
assert.equal(filterScenesForImport(scenes)[0]?._id, 'aaaaaaaaaaaaaaaa');
});
void test('pickFoundryStartSceneId: min sort, then video, not name', () => {
const scenes = [
scene('aaaaaaaaaaaaaaaa', 'Тент', { sort: 0, tiles: [{}] }),
scene('bbbbbbbbbbbbbbbb', 'Стартовая', {
sort: 0,
background: { src: 'modules/x/Map/Swamp_start.mp4' },
tiles: [{}],
}),
scene('cccccccccccccccc', 'Болота', { sort: 100000 }),
];
assert.equal(pickFoundryStartSceneId(scenes), 'bbbbbbbbbbbbbbbb');
});
void test('planFoundrySceneGraph: monks teleports create branches, not a line', () => {
const hub = 'aaaaaaaaaaaaaaaa';
const a = 'bbbbbbbbbbbbbbbb';
const b = 'cccccccccccccccc';
const start = 'dddddddddddddddd';
const scenes = [
scene(hub, 'Hub', {
sort: 100000,
tiles: [teleportTile(a), teleportTile(b)],
}),
scene(a, 'A', { sort: 200000, tiles: [teleportTile(hub)] }),
scene(b, 'B', { sort: 300000, tiles: [teleportTile(hub)] }),
scene(start, 'Intro', {
sort: 0,
background: { src: 'intro.mp4' },
tiles: [teleportTile(hub)],
}),
];
const plan = planFoundrySceneGraph(scenes, [], []);
assert.equal(plan.heuristic.kind, 'monks-teleports');
assert.equal(plan.startSceneId, start);
assert.ok(plan.edges.some((e) => e.sourceId === hub && e.targetId === a));
assert.ok(plan.edges.some((e) => e.sourceId === hub && e.targetId === b));
assert.ok(plan.edges.some((e) => e.sourceId === start && e.targetId === hub));
// Не линейная цепочка из 3 рёбер подряд по всем сценам.
assert.ok(plan.edges.length >= 3);
});
void test('extractMonksTeleportEdges ignores inactive tiles', () => {
const scenes = [
scene('aaaaaaaaaaaaaaaa', 'A', {
tiles: [
{
flags: {
'monks-active-tiles': {
active: false,
actions: [{ action: 'scene', data: { sceneid: 'Scene.bbbbbbbbbbbbbbbb' } }],
},
},
},
],
}),
scene('bbbbbbbbbbbbbbbb', 'B'),
];
assert.equal(extractMonksTeleportEdges(scenes).length, 0);
});
void test('planFoundrySceneGraph: sort-order fallback when no teleports', () => {
const scenes = [
scene('aaaaaaaaaaaaaaaa', 'Z', { sort: 3 }),
scene('bbbbbbbbbbbbbbbb', 'A', { sort: 1 }),
scene('cccccccccccccccc', 'M', { sort: 2 }),
];
const plan = planFoundrySceneGraph(scenes, [], []);
assert.equal(plan.heuristic.kind, 'navigation');
assert.equal(plan.startSceneId, 'bbbbbbbbbbbbbbbb');
assert.deepEqual(plan.orderedSceneIds[0], 'bbbbbbbbbbbbbbbb');
});
void test('sceneBackgroundSrc prefers background.src', () => {
assert.equal(
sceneBackgroundSrc({
_id: 'aaaaaaaaaaaaaaaa',
name: 'S',
img: 'old.png',
background: { src: 'new.webp' },
}),
'new.webp',
);
});
void test('isSupportedFoundryPackage rejects pre-v11 maximum', () => {
assert.equal(
isSupportedFoundryPackage({ compatibility: { maximum: '10' }, coreVersion: '10.291' }).ok,
false,
);
assert.equal(
isSupportedFoundryPackage({ compatibility: { minimum: '11', verified: '12' }, coreVersion: '12.331' }).ok,
true,
);
});
void test('decodeFoundryAssetPath decodes %20', () => {
assert.equal(
decodeFoundryAssetPath('modules/x/Unwelcome_Spirits/Map/The%20Withered%20Grove%20(day).webp'),
'modules/x/Unwelcome_Spirits/Map/The Withered Grove (day).webp',
);
});
+281
View File
@@ -0,0 +1,281 @@
import type {
FoundryActorDoc,
FoundryAdventureDoc,
FoundryJournalDoc,
FoundrySceneDoc,
FoundrySceneEdgePlan,
FoundrySceneLinkHeuristic,
FoundrySceneTile,
} from './foundryTypes';
/** UUID / @UUID[...] / @Scene[...] ссылки Foundry. */
const SCENE_REF_RE =
/(?:@UUID\[(?:(?:Scene|Compendium\.[^.\]]+\.Scene)\.)?([A-Za-z0-9]{16})\]|@Scene\[([A-Za-z0-9]{16})\])/giu;
function uniquePush(ids: string[], id: string): void {
if (!ids.includes(id)) ids.push(id);
}
function dedupeEdges(
edges: { sourceId: string; targetId: string }[],
): { sourceId: string; targetId: string }[] {
const seen = new Set<string>();
const out: { sourceId: string; targetId: string }[] = [];
for (const e of edges) {
if (e.sourceId === e.targetId) continue;
const key = `${e.sourceId}->${e.targetId}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(e);
}
return out;
}
function chainEdges(orderedIds: string[]): { sourceId: string; targetId: string }[] {
const edges: { sourceId: string; targetId: string }[] = [];
for (let i = 0; i < orderedIds.length - 1; i += 1) {
const sourceId = orderedIds[i];
const targetId = orderedIds[i + 1];
if (!sourceId || !targetId) continue;
if (sourceId !== targetId) edges.push({ sourceId, targetId });
}
return edges;
}
function sceneSortValue(scene: FoundrySceneDoc): number {
return typeof scene.sort === 'number' ? scene.sort : 0;
}
function tileCount(scene: FoundrySceneDoc): number {
return Array.isArray(scene.tiles) ? scene.tiles.length : 0;
}
function hasVideoBackground(scene: FoundrySceneDoc): boolean {
const src = sceneBackgroundSrc(scene);
if (!src) return false;
const lower = src.toLowerCase();
return lower.endsWith('.mp4') || lower.endsWith('.webm') || lower.endsWith('.mov');
}
/** Порядок: navigation → navOrder → sort → имя. */
export function sortScenesByNavThenSort(scenes: FoundrySceneDoc[]): FoundrySceneDoc[] {
return [...scenes].sort((a, b) => {
const navA = a.navigation === true ? 0 : 1;
const navB = b.navigation === true ? 0 : 1;
if (navA !== navB) return navA - navB;
const navOrderA = typeof a.navOrder === 'number' ? a.navOrder : Number.MAX_SAFE_INTEGER;
const navOrderB = typeof b.navOrder === 'number' ? b.navOrder : Number.MAX_SAFE_INTEGER;
if (navOrderA !== navOrderB) return navOrderA - navOrderB;
const sortA = sceneSortValue(a);
const sortB = sceneSortValue(b);
if (sortA !== sortB) return sortA - sortB;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
}
/**
* Выбор стартовой сцены без опоры на слово «старт» в названии:
* 1) среди navigation (если есть),
* 2) минимальный sort,
* 3) видео-фон (часто intro),
* 4) меньше плиток,
* 5) имя.
*/
export function pickFoundryStartSceneId(scenes: FoundrySceneDoc[]): string | null {
if (scenes.length === 0) return null;
const nav = scenes.filter((s) => s.navigation === true);
const pool = nav.length > 0 ? nav : scenes;
const minSort = Math.min(...pool.map(sceneSortValue));
const tied = pool.filter((s) => sceneSortValue(s) === minSort);
tied.sort((a, b) => {
const videoA = hasVideoBackground(a) ? 0 : 1;
const videoB = hasVideoBackground(b) ? 0 : 1;
if (videoA !== videoB) return videoA - videoB;
const tilesA = tileCount(a);
const tilesB = tileCount(b);
if (tilesA !== tilesB) return tilesA - tilesB;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
return tied[0]?._id ?? null;
}
function parseSceneIdRef(raw: unknown): string | null {
if (typeof raw === 'string' && raw.trim()) {
const s = raw.trim();
const m = /(?:^|\.)([A-Za-z0-9]{16})$/u.exec(s);
return m?.[1] ?? (s.length === 16 ? s : null);
}
if (raw && typeof raw === 'object') {
const id = (raw as { id?: unknown }).id;
return parseSceneIdRef(id);
}
return null;
}
/** Рёбра телепортов из Monks Active Tiles (`action: "scene"`). */
export function extractMonksTeleportEdges(
scenes: FoundrySceneDoc[],
): { sourceId: string; targetId: string }[] {
const known = new Set(scenes.map((s) => s._id));
const edges: { sourceId: string; targetId: string }[] = [];
for (const scene of scenes) {
const tiles: FoundrySceneTile[] = Array.isArray(scene.tiles) ? scene.tiles : [];
for (const tile of tiles) {
const mat = tile.flags?.['monks-active-tiles'];
if (!mat || mat.active === false) continue;
const actions = Array.isArray(mat.actions) ? mat.actions : [];
for (const action of actions) {
if (action.action !== 'scene') continue;
const targetId = parseSceneIdRef(action.data?.sceneid);
if (!targetId || !known.has(targetId)) continue;
edges.push({ sourceId: scene._id, targetId });
}
}
}
return dedupeEdges(edges);
}
/**
* Если в Adventure есть сцены с navigation оставляем только их
* (отсекает дубликаты карт с remote URL и скрытые GM-копии без навигации).
*/
export function filterScenesForImport(scenes: FoundrySceneDoc[]): FoundrySceneDoc[] {
const nav = scenes.filter((s) => s.navigation === true);
return nav.length > 0 ? nav : scenes;
}
function extractSceneIdsFromText(text: string, knownIds: Set<string>): string[] {
const out: string[] = [];
SCENE_REF_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = SCENE_REF_RE.exec(text)) !== null) {
const id = m[1] ?? m[2];
if (id && knownIds.has(id)) uniquePush(out, id);
}
return out;
}
function journalText(journal: FoundryJournalDoc): string {
const parts: string[] = [];
if (typeof journal.content === 'string') parts.push(journal.content);
const pages = Array.isArray(journal.pages) ? journal.pages : [];
for (const page of [...pages].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))) {
if (typeof page.text?.content === 'string') parts.push(page.text.content);
}
return parts.join('\n');
}
function orderFromStart(startId: string | null, scenes: FoundrySceneDoc[]): string[] {
const bySort = sortScenesByNavThenSort(scenes).map((s) => s._id);
if (!startId || !bySort.includes(startId)) return bySort;
return [startId, ...bySort.filter((id) => id !== startId)];
}
function buildPlan(
scenes: FoundrySceneDoc[],
edges: { sourceId: string; targetId: string }[],
heuristic: FoundrySceneLinkHeuristic,
): FoundrySceneEdgePlan {
const startSceneId = pickFoundryStartSceneId(scenes);
return {
edges: dedupeEdges(edges),
heuristic,
orderedSceneIds: orderFromStart(startSceneId, scenes),
startSceneId,
};
}
/**
* Строит рёбра графа:
* 1) телепорты Monks Active Tiles (ветвления),
* 2) ссылки в журналах,
* 3) цепочка по sort среди navigation,
* 4) fallback по sort всех сцен.
*
* Порядок Adventure.scenes в JSON не используем там часто произвольный порядок.
*/
export function planFoundrySceneGraph(
scenes: FoundrySceneDoc[],
_adventures: FoundryAdventureDoc[],
journals: FoundryJournalDoc[],
): FoundrySceneEdgePlan {
const working = filterScenesForImport(scenes);
const byId = new Map(working.map((s) => [s._id, s]));
const knownIds = new Set(byId.keys());
const teleports = extractMonksTeleportEdges(working).filter(
(e) => knownIds.has(e.sourceId) && knownIds.has(e.targetId),
);
if (teleports.length > 0) {
return buildPlan(working, teleports, { kind: 'monks-teleports' });
}
const fromJournals: string[] = [];
for (const j of journals) {
for (const id of extractSceneIdsFromText(journalText(j), knownIds)) {
uniquePush(fromJournals, id);
}
}
if (fromJournals.length >= 2) {
return buildPlan(working, chainEdges(fromJournals), { kind: 'journal-refs' });
}
const nav = working.filter((s) => s.navigation === true);
if (nav.length >= 2) {
const ordered = sortScenesByNavThenSort(nav).map((s) => s._id);
return buildPlan(working, chainEdges(ordered), { kind: 'navigation' });
}
const ordered = sortScenesByNavThenSort(working).map((s) => s._id);
return buildPlan(working, chainEdges(ordered), { kind: 'sort-order' });
}
export function sceneBackgroundSrc(scene: FoundrySceneDoc): string | null {
const bg = scene.background?.src;
if (typeof bg === 'string' && bg.trim()) return bg.trim();
if (typeof scene.img === 'string' && scene.img.trim()) return scene.img.trim();
return null;
}
export function actorPortraitSrc(actor: FoundryActorDoc): string | null {
const token = actor.prototypeToken?.texture?.src;
if (typeof token === 'string' && token.trim()) return token.trim();
if (typeof actor.img === 'string' && actor.img.trim()) return actor.img.trim();
return null;
}
export function extractActorDescriptionHtml(actor: FoundryActorDoc): string {
const system = actor.system;
if (!system || typeof system !== 'object') return '';
const sys = system as Record<string, unknown>;
const candidates: unknown[] = [
(sys.details as { biography?: { value?: unknown } } | undefined)?.biography?.value,
(sys.description as { value?: unknown } | undefined)?.value,
(sys.details as { biography?: unknown } | undefined)?.biography,
sys.biography,
];
for (const c of candidates) {
if (typeof c === 'string' && c.trim()) return c.trim();
}
return '';
}
export function journalDescriptionHtml(
scene: FoundrySceneDoc,
journalsById: Map<string, FoundryJournalDoc>,
): string {
if (typeof scene.description === 'string' && scene.description.trim()) {
return scene.description.trim();
}
const journalId = typeof scene.journal === 'string' ? scene.journal : null;
if (!journalId) return '';
const journal = journalsById.get(journalId);
if (!journal) return '';
const text = journalText(journal).trim();
if (text) return text;
return journal.name ? `<p>${escapeHtml(journal.name)}</p>` : '';
}
function escapeHtml(s: string): string {
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}
+11
View File
@@ -0,0 +1,11 @@
/** Декодирует %20 и т.п. в путях Foundry; безопасен для уже декодированных строк. */
export function decodeFoundryAssetPath(foundryPath: string): string {
const trimmed = foundryPath.trim().replace(/\\/gu, '/').replace(/^\/+/u, '');
if (!trimmed) return '';
if (/^https?:\/\//iu.test(trimmed)) return trimmed;
try {
return decodeURIComponent(trimmed);
} catch {
return trimmed;
}
}
+144
View File
@@ -0,0 +1,144 @@
/** Документы Foundry VTT, нужные для импорта (v11+). */
export type FoundryPackageKind = 'world' | 'module';
export type FoundryCompatibility = {
minimum?: string;
verified?: string;
maximum?: string;
};
export type FoundryPackageManifest = {
kind: FoundryPackageKind;
/** id пакета (папка / manifest id). */
id: string;
/** Человекочитаемое название. */
title: string;
rootDir: string;
compatibility?: FoundryCompatibility;
coreVersion?: string;
/** Пути компендиумов относительно rootDir (только для module). */
packs: FoundryPackRef[];
};
export type FoundryPackRef = {
name: string;
label: string;
path: string;
type: string;
};
/** Плитка сцены (нужна для телепортов Monks Active Tiles). */
export type FoundrySceneTile = {
flags?: {
'monks-active-tiles'?: {
active?: boolean;
actions?: {
action?: string;
data?: {
sceneid?: string | { id?: string; name?: string };
};
}[];
};
};
};
export type FoundrySceneDoc = {
_id: string;
name: string;
img?: string | null;
background?: { src?: string | null } | null;
journal?: string | null;
playlist?: string | null;
playlistSound?: string | null;
navigation?: boolean;
navOrder?: number;
sort?: number;
description?: string | null;
tiles?: FoundrySceneTile[];
};
export type FoundryActorDoc = {
_id: string;
name: string;
img?: string | null;
prototypeToken?: { texture?: { src?: string | null } | null } | null;
system?: unknown;
folder?: string | null;
type?: string;
};
export type FoundryFolderDoc = {
_id: string;
name: string;
type?: string;
folder?: string | null;
sort?: number;
color?: string | null;
};
export type FoundryPlaylistSound = {
_id?: string;
name?: string;
path?: string | null;
sort?: number;
};
export type FoundryPlaylistDoc = {
_id: string;
name: string;
sounds?: FoundryPlaylistSound[];
sort?: number;
};
export type FoundryJournalPage = {
_id?: string;
name?: string;
type?: string;
text?: { content?: string; format?: number } | null;
sort?: number;
};
export type FoundryJournalDoc = {
_id: string;
name: string;
pages?: FoundryJournalPage[];
content?: string;
};
/** Adventure содержит вложенные документы. */
export type FoundryAdventureDoc = {
_id: string;
name: string;
scenes?: FoundrySceneDoc[];
actors?: FoundryActorDoc[];
playlists?: FoundryPlaylistDoc[];
journal?: FoundryJournalDoc[];
folders?: FoundryFolderDoc[];
sort?: number;
};
export type FoundryLoadedDocuments = {
scenes: FoundrySceneDoc[];
actors: FoundryActorDoc[];
playlists: FoundryPlaylistDoc[];
journals: FoundryJournalDoc[];
adventures: FoundryAdventureDoc[];
folders: FoundryFolderDoc[];
};
export type FoundrySceneLinkHeuristic =
| { kind: 'monks-teleports' }
| { kind: 'journal-refs' }
| { kind: 'navigation' }
| { kind: 'sort-order' };
export type FoundrySceneEdgePlan = {
/** Пары sourceSceneFoundryId → targetSceneFoundryId (могут быть ветвления). */
edges: { sourceId: string; targetId: string }[];
heuristic: FoundrySceneLinkHeuristic;
/** Порядок сцен для списка / раскладки. */
orderedSceneIds: string[];
/** Foundry id сцены, которую стоит пометить START. */
startSceneId: string | null;
};
+63
View File
@@ -0,0 +1,63 @@
import type { FoundryCompatibility, FoundryPackageManifest } from './foundryTypes';
/** Актуальные major-версии Foundry, которые поддерживает импортёр. */
export const FOUNDRY_SUPPORTED_MAJORS = new Set([11, 12, 13]);
function parseMajor(version: string | undefined): number | null {
if (!version || typeof version !== 'string') return null;
const m = /^(\d+)/u.exec(version.trim());
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
/**
* true пакет выглядит как актуальный (v11+).
* Если версия не указана разрешаем (попробуем прочитать данные).
* Если явно только старая (<11) отклоняем.
*/
export function isSupportedFoundryPackage(
manifest: Pick<FoundryPackageManifest, 'compatibility' | 'coreVersion'>,
): {
ok: boolean;
reason?: string;
} {
const compat: FoundryCompatibility = manifest.compatibility ?? {};
const majors = [compat.minimum, compat.verified, compat.maximum, manifest.coreVersion]
.map(parseMajor)
.filter((n): n is number => n !== null);
if (majors.length === 0) return { ok: true };
const maxMajor = Math.max(...majors);
const minMajor = Math.min(...majors);
// Явно только до v10 и ниже.
if (typeof compat.maximum === 'string') {
const max = parseMajor(compat.maximum);
if (max !== null && max < 11) {
return {
ok: false,
reason: `Пакет Foundry слишком старый (maximum ${compat.maximum}). Нужна версия 11+.`,
};
}
}
if (maxMajor < 11) {
return {
ok: false,
reason: `Пакет Foundry слишком старый (обнаружена версия ${String(maxMajor)}). Нужна версия 11+.`,
};
}
// Если минимум уже далеко в будущем — всё равно пробуем, если пересекается с 11–13.
const overlapsSupported = majors.some((m) => FOUNDRY_SUPPORTED_MAJORS.has(m)) || minMajor <= 13;
if (!overlapsSupported) {
return {
ok: false,
reason: `Версия Foundry не поддерживается импортом (нужны 11–13).`,
};
}
return { ok: true };
}
@@ -61,7 +61,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
updatedAt: '2020-01-01T00:00:00.000Z', updatedAt: '2020-01-01T00:00:00.000Z',
createdWithAppVersion: '1', createdWithAppVersion: '1',
appVersion: '1', appVersion: '1',
schemaVersion: 8, schemaVersion: 9,
}, },
scenes: {}, scenes: {},
sceneListOrder: [], sceneListOrder: [],
@@ -69,6 +69,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
campaignAudios: [], campaignAudios: [],
materials: [], materials: [],
npcs: [], npcs: [],
npcGroups: [],
npcRelations: [], npcRelations: [],
currentSceneId: null, currentSceneId: null,
currentGraphNodeId: null, currentGraphNodeId: null,
+240 -44
View File
@@ -1,35 +1,39 @@
import { import { noneBinding } from '../npcs/npcBinding';
getConnectedComponent,
getSideStoryComponentNodeIds,
listSideStoryStarts,
} from './sceneGraphLineage';
import { reconcileSceneListOrder } from './sceneListOrder';
import type { import type {
ExportedStorylineRef, ExportedStorylineRef,
GraphNodeId, GraphNodeId,
NpcBinding,
Project, Project,
ProjectNpc,
ProjectNpcGroup,
Scene, Scene,
SceneGraphEdge, SceneGraphEdge,
SceneGraphNode, SceneGraphNode,
SceneId, SceneId,
} from '../types'; } from '../types';
import type { AssetId, ProjectId } from '../types/ids'; import type { AssetId, NpcGroupId, ProjectId } from '../types/ids';
import { import {
asAssetId, asAssetId,
asGraphNodeId, asGraphNodeId,
asMaterialId, asMaterialId,
asNpcGroupId,
asNpcId, asNpcId,
asNpcRelationId, asNpcRelationId,
asProjectId, asProjectId,
asSceneId, asSceneId,
} from '../types/ids'; } from '../types/ids';
import {
getConnectedComponent,
getSideStoryComponentNodeIds,
listSideStoryStarts,
} from './sceneGraphLineage';
import { reconcileSceneListOrder } from './sceneListOrder';
export type StorylineKind = 'main' | 'side'; export type StorylineKind = 'main' | 'side';
/** Выбор сюжетной линии для экспорта/импорта. */ /** Выбор сюжетной линии для экспорта/импорта. */
export type StorylineSelection = export type StorylineSelection = { kind: 'main' } | { kind: 'side'; startGraphNodeId: GraphNodeId };
| { kind: 'main' }
| { kind: 'side'; startGraphNodeId: GraphNodeId };
export type StorylineListItem = { export type StorylineListItem = {
selection: StorylineSelection; selection: StorylineSelection;
@@ -48,6 +52,16 @@ export type SceneTitleConflict = {
matches: { sceneId: SceneId; title: string }[]; matches: { sceneId: SceneId; title: string }[];
}; };
export type NpcImportResolution =
| { sourceNpcId: string; mode: 'create' }
| { sourceNpcId: string; mode: 'use'; targetNpcId: string };
export type NpcNameConflict = {
sourceNpcId: string;
sourceName: string;
matches: { npcId: string; name: string }[];
};
export type StorylineImportMergeReport = { export type StorylineImportMergeReport = {
storylinesImported: number; storylinesImported: number;
scenesCreated: number; scenesCreated: number;
@@ -56,6 +70,8 @@ export type StorylineImportMergeReport = {
edgesAdded: number; edgesAdded: number;
assetsCopied: number; assetsCopied: number;
assetsReused: number; assetsReused: number;
npcsCreated: number;
npcsReused: number;
renamedSideTitles: string[]; renamedSideTitles: string[];
}; };
@@ -161,10 +177,7 @@ export function listImportableStorylines(
}); });
} }
function recomputeOutgoing( function recomputeOutgoing(nodes: SceneGraphNode[], edges: SceneGraphEdge[]): Map<SceneId, Set<SceneId>> {
nodes: SceneGraphNode[],
edges: SceneGraphEdge[],
): Map<SceneId, Set<SceneId>> {
const gnMap = new Map(nodes.map((n) => [n.id, n])); const gnMap = new Map(nodes.map((n) => [n.id, n]));
const outgoing = new Map<SceneId, Set<SceneId>>(); const outgoing = new Map<SceneId, Set<SceneId>>();
for (const e of edges) { for (const e of edges) {
@@ -181,7 +194,10 @@ function recomputeOutgoing(
return outgoing; return outgoing;
} }
function applyConnectionSets(scenes: Record<SceneId, Scene>, outgoing: Map<SceneId, Set<SceneId>>): Record<SceneId, Scene> { function applyConnectionSets(
scenes: Record<SceneId, Scene>,
outgoing: Map<SceneId, Set<SceneId>>,
): Record<SceneId, Scene> {
const next: Record<SceneId, Scene> = { ...scenes }; const next: Record<SceneId, Scene> = { ...scenes };
for (const sid of Object.keys(next) as SceneId[]) { for (const sid of Object.keys(next) as SceneId[]) {
const prev = next[sid]; const prev = next[sid];
@@ -204,16 +220,11 @@ function selectionToExportedRef(
return { return {
kind: 'side', kind: 'side',
startGraphNodeId: selection.startGraphNodeId, startGraphNodeId: selection.startGraphNodeId,
label: start label: start ? sideStoryDisplayLabel(start, project.scenes, labels.untitled) : labels.untitled,
? sideStoryDisplayLabel(start, project.scenes, labels.untitled)
: labels.untitled,
}; };
} }
export function collectSceneIdsForSelections( export function collectSceneIdsForSelections(project: Project, selections: StorylineSelection[]): SceneId[] {
project: Project,
selections: StorylineSelection[],
): SceneId[] {
const nodeIds = collectSelectionsGraphNodeIds(project, selections); const nodeIds = collectSelectionsGraphNodeIds(project, selections);
const sceneIds = new Set<SceneId>(); const sceneIds = new Set<SceneId>();
for (const gn of project.sceneGraphNodes) { for (const gn of project.sceneGraphNodes) {
@@ -234,9 +245,7 @@ export function buildPartialExportProject(
const nodeIds = collectSelectionsGraphNodeIds(source, selections); const nodeIds = collectSelectionsGraphNodeIds(source, selections);
const sceneIds = new Set(collectSceneIdsForSelections(source, selections)); const sceneIds = new Set(collectSceneIdsForSelections(source, selections));
const sceneGraphNodes = source.sceneGraphNodes const sceneGraphNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id)).map((n) => ({ ...n }));
.filter((n) => nodeIds.has(n.id))
.map((n) => ({ ...n }));
const sceneGraphEdges = source.sceneGraphEdges.filter( const sceneGraphEdges = source.sceneGraphEdges.filter(
(e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId), (e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId),
@@ -268,6 +277,21 @@ export function buildPartialExportProject(
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges); const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) }; draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) };
const exportedNpcs = filterNpcsForStorylineExport(source, selections);
const exportedNpcIds = new Set(exportedNpcs.map((n) => n.id));
const exportedRelations = (source.npcRelations ?? []).filter(
(r) => exportedNpcIds.has(r.sourceNpcId) && exportedNpcIds.has(r.targetNpcId),
);
const exportedGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], exportedNpcs);
const exportedGroups = (source.npcGroups ?? []).filter((g) => exportedGroupIds.has(g.id));
draft = {
...draft,
npcs: exportedNpcs.map((n) => ({ ...n })),
npcGroups: exportedGroups.map((g) => ({ ...g })),
npcRelations: exportedRelations.map((r) => ({ ...r })),
};
const assetIds = collectReferencedAssetIdsForProject(draft); const assetIds = collectReferencedAssetIdsForProject(draft);
const assets: Project['assets'] = {} as Project['assets']; const assets: Project['assets'] = {} as Project['assets'];
for (const id of assetIds) { for (const id of assetIds) {
@@ -282,13 +306,88 @@ export function buildPartialExportProject(
assets, assets,
campaignAudios: source.campaignAudios.map((a) => ({ ...a })), campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
materials: (source.materials ?? []).map((m) => ({ ...m })), materials: (source.materials ?? []).map((m) => ({ ...m })),
npcs: (source.npcs ?? []).map((n) => ({ ...n })),
npcRelations: (source.npcRelations ?? []).map((r) => ({ ...r })),
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null, currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null, currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
}; };
} }
/** НПС для экспорта выбранных линий (main + unbound; side только свои). */
export function filterNpcsForStorylineExport(
project: Project,
selections: StorylineSelection[],
): ProjectNpc[] {
const npcs = project.npcs ?? [];
if (selections.length === 0) return [];
const includeUnbound = selections.some((s) => s.kind === 'main');
const hasMain = selections.some((s) => s.kind === 'main');
const sideRefs = new Set(
selections
.filter((s): s is { kind: 'side'; startGraphNodeId: GraphNodeId } => s.kind === 'side')
.map((s) => s.startGraphNodeId),
);
const sceneIdsInExport = new Set(collectSceneIdsForSelections(project, selections));
return npcs.filter((npc) => {
const b: NpcBinding = npc.binding ?? noneBinding();
if (b.kind === 'none') return includeUnbound;
if (b.kind === 'storyline') {
if (b.storyline.kind === 'main') return hasMain;
return sideRefs.has(b.storyline.startGraphNodeId);
}
if (b.kind === 'scene') return sceneIdsInExport.has(b.sceneId);
return false;
});
}
function collectNpcGroupIdsForNpcs(groups: ProjectNpcGroup[], npcs: ProjectNpc[]): Set<NpcGroupId> {
const needed = new Set<NpcGroupId>();
for (const n of npcs) {
if (n.groupId) needed.add(n.groupId);
}
let changed = true;
while (changed) {
changed = false;
for (const g of groups) {
if (needed.has(g.id) && g.parentId && !needed.has(g.parentId)) {
needed.add(g.parentId);
changed = true;
}
}
}
return needed;
}
export function findNpcNameConflicts(
target: Project,
source: Project,
sourceNpcIds: string[],
): NpcNameConflict[] {
const targetByName = new Map<string, { npcId: string; name: string }[]>();
for (const n of target.npcs ?? []) {
const key = n.name.trim().toLowerCase();
if (!key) continue;
const list = targetByName.get(key) ?? [];
list.push({ npcId: n.id, name: n.name });
targetByName.set(key, list);
}
const conflicts: NpcNameConflict[] = [];
const seen = new Set<string>();
for (const id of sourceNpcIds) {
if (seen.has(id)) continue;
seen.add(id);
const src = (source.npcs ?? []).find((n) => n.id === id);
if (!src) continue;
const key = src.name.trim().toLowerCase();
if (!key) continue;
const matches = targetByName.get(key);
if (matches && matches.length > 0) {
conflicts.push({ sourceNpcId: src.id, sourceName: src.name, matches });
}
}
return conflicts;
}
function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> { function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> {
const refs = new Set<AssetId>(); const refs = new Set<AssetId>();
for (const sc of Object.values(p.scenes)) { for (const sc of Object.values(p.scenes)) {
@@ -368,9 +467,14 @@ export function mergeStorylinesIntoProject(
source: Project, source: Project,
selections: StorylineSelection[], selections: StorylineSelection[],
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
opts: { graphOffsetX: number }, opts: { graphOffsetX: number; npcResolutions?: NpcImportResolution[] },
): { project: Project; report: StorylineImportMergeReport; assetCopies: { fromId: AssetId; toId: AssetId }[] } { ): {
project: Project;
report: StorylineImportMergeReport;
assetCopies: { fromId: AssetId; toId: AssetId }[];
} {
const resolutionBySource = new Map(sceneResolutions.map((r) => [r.sourceSceneId, r])); const resolutionBySource = new Map(sceneResolutions.map((r) => [r.sourceSceneId, r]));
const npcResolutionBySource = new Map((opts.npcResolutions ?? []).map((r) => [r.sourceNpcId, r]));
const nodeIds = collectSelectionsGraphNodeIds(source, selections); const nodeIds = collectSelectionsGraphNodeIds(source, selections);
const sourceSceneIds = collectSceneIdsForSelections(source, selections); const sourceSceneIds = collectSceneIdsForSelections(source, selections);
@@ -387,7 +491,13 @@ export function mergeStorylinesIntoProject(
} }
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId); for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId); for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
for (const n of source.npcs ?? []) neededAssetIds.add(n.avatarAssetId);
const exportedNpcs = filterNpcsForStorylineExport(source, selections);
for (const n of exportedNpcs) {
const res = npcResolutionBySource.get(n.id);
if (res?.mode === 'use') continue;
neededAssetIds.add(n.avatarAssetId);
}
const assetMap = new Map<AssetId, AssetId>(); const assetMap = new Map<AssetId, AssetId>();
const targetSha = new Map<string, AssetId>(); const targetSha = new Map<string, AssetId>();
@@ -521,17 +631,57 @@ export function mergeStorylinesIntoProject(
materialAssetIds.add(mapped); materialAssetIds.add(mapped);
} }
const npcs = [...(target.npcs ?? [])]; // Группы только для создаваемых НПС выбранных линий.
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase())); const npcsToCreate = exportedNpcs.filter((n) => npcResolutionBySource.get(n.id)?.mode !== 'use');
const npcAssetIds = new Set(npcs.map((n) => n.avatarAssetId)); const neededGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], npcsToCreate);
const npcIdMap = new Map<string, string>(); const npcGroups = [...(target.npcGroups ?? [])];
for (const n of source.npcs ?? []) { const groupIdMap = new Map<string, string>();
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId; const orderedSourceGroups = topologicalNpcGroups(source.npcGroups ?? []).filter((g) =>
const existingByAsset = npcs.find((x) => x.avatarAssetId === mappedAvatar); neededGroupIds.has(g.id),
if (existingByAsset || npcAssetIds.has(mappedAvatar)) { );
if (existingByAsset) npcIdMap.set(n.id, existingByAsset.id); for (const g of orderedSourceGroups) {
const mappedParent = g.parentId
? (asNpcGroupId(groupIdMap.get(g.parentId) ?? g.parentId) as NpcGroupId | null)
: null;
const parentKey = mappedParent;
const siblings = npcGroups.filter((x) => x.parentId === parentKey);
const nameKey = g.name.trim().toLowerCase();
const existing = siblings.find((x) => x.name.trim().toLowerCase() === nameKey);
if (existing) {
groupIdMap.set(g.id, existing.id);
continue; continue;
} }
let name = g.name.trim();
const siblingKeys = new Set(siblings.map((x) => x.name.trim().toLowerCase()));
if (siblingKeys.has(name.toLowerCase())) {
let i = 2;
while (siblingKeys.has(`${name.toLowerCase()} (${String(i)})`)) i += 1;
name = `${name} (${String(i)})`;
}
const newId = asNpcGroupId(`ng_${generateId()}`);
groupIdMap.set(g.id, newId);
npcGroups.push({
id: newId,
name,
color: g.color,
parentId: mappedParent,
});
}
const npcs = [...(target.npcs ?? [])];
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
const npcIdMap = new Map<string, string>();
let npcsCreated = 0;
let npcsReused = 0;
for (const n of exportedNpcs) {
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
const res = npcResolutionBySource.get(n.id);
if (res?.mode === 'use') {
npcIdMap.set(n.id, res.targetNpcId);
npcsReused += 1;
continue;
}
// default create (also when no resolution entry)
let name = n.name.trim(); let name = n.name.trim();
const baseKey = name.toLowerCase(); const baseKey = name.toLowerCase();
if (npcNameKeys.has(baseKey)) { if (npcNameKeys.has(baseKey)) {
@@ -541,6 +691,10 @@ export function mergeStorylinesIntoProject(
} }
const newId = asNpcId(`npc_${generateId()}`); const newId = asNpcId(`npc_${generateId()}`);
npcIdMap.set(n.id, newId); npcIdMap.set(n.id, newId);
const mappedGroupId = n.groupId
? (asNpcGroupId(groupIdMap.get(n.groupId) ?? n.groupId) as typeof n.groupId)
: null;
const binding = remapNpcBinding(n.binding ?? noneBinding(), sceneIdMap, graphNodeIdMap);
npcs.push({ npcs.push({
id: newId, id: newId,
name, name,
@@ -548,21 +702,25 @@ export function mergeStorylinesIntoProject(
description: n.description ?? '', description: n.description ?? '',
x: n.x, x: n.x,
y: n.y, y: n.y,
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
binding,
}); });
npcNameKeys.add(name.toLowerCase()); npcNameKeys.add(name.toLowerCase());
npcAssetIds.add(mappedAvatar); npcsCreated += 1;
} }
const npcRelations = [...(target.npcRelations ?? [])]; const npcRelations = [...(target.npcRelations ?? [])];
const exportedNpcIdSet = new Set(exportedNpcs.map((n) => n.id));
for (const r of source.npcRelations ?? []) { for (const r of source.npcRelations ?? []) {
const sourceId = npcIdMap.get(r.sourceNpcId) ?? r.sourceNpcId; if (!exportedNpcIdSet.has(r.sourceNpcId) || !exportedNpcIdSet.has(r.targetNpcId)) continue;
const targetId = npcIdMap.get(r.targetNpcId) ?? r.targetNpcId; const sourceId = npcIdMap.get(r.sourceNpcId);
const targetId = npcIdMap.get(r.targetNpcId);
if (!sourceId || !targetId) continue;
if (!npcs.some((n) => n.id === sourceId) || !npcs.some((n) => n.id === targetId)) continue; if (!npcs.some((n) => n.id === sourceId) || !npcs.some((n) => n.id === targetId)) continue;
const label = r.label.trim(); const label = r.label.trim();
if (!label) continue; if (!label) continue;
const dup = npcRelations.some( const dup = npcRelations.some(
(x) => (x) => x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
); );
if (dup) continue; if (dup) continue;
npcRelations.push({ npcRelations.push({
@@ -580,6 +738,7 @@ export function mergeStorylinesIntoProject(
campaignAudios, campaignAudios,
materials, materials,
npcs, npcs,
npcGroups,
npcRelations, npcRelations,
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes], sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges], sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
@@ -602,6 +761,8 @@ export function mergeStorylinesIntoProject(
edgesAdded: newEdges.length, edgesAdded: newEdges.length,
assetsCopied, assetsCopied,
assetsReused, assetsReused,
npcsCreated,
npcsReused,
renamedSideTitles, renamedSideTitles,
}, },
assetCopies, assetCopies,
@@ -613,6 +774,41 @@ export function computeGraphImportOffsetX(target: Project, padding = 120): numbe
return maxX + padding; return maxX + padding;
} }
function topologicalNpcGroups(groups: ProjectNpcGroup[]): ProjectNpcGroup[] {
const byId = new Map(groups.map((g) => [g.id, g]));
const out: ProjectNpcGroup[] = [];
const seen = new Set<string>();
const visit = (id: string): void => {
if (seen.has(id)) return;
seen.add(id);
const g = byId.get(asNpcGroupId(id));
if (!g) return;
if (g.parentId && byId.has(g.parentId)) visit(g.parentId);
out.push(g);
};
for (const g of groups) visit(g.id);
return out;
}
function remapNpcBinding(
binding: NpcBinding,
sceneIdMap: Map<SceneId, SceneId>,
graphNodeIdMap: Map<GraphNodeId, GraphNodeId>,
): NpcBinding {
if (binding.kind === 'none') return noneBinding();
if (binding.kind === 'scene') {
const mapped = sceneIdMap.get(binding.sceneId);
return mapped ? { kind: 'scene', sceneId: mapped } : noneBinding();
}
if (binding.storyline.kind === 'main') {
return { kind: 'storyline', storyline: { kind: 'main' } };
}
const mappedGn = graphNodeIdMap.get(binding.storyline.startGraphNodeId);
return mappedGn
? { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: mappedGn } }
: noneBinding();
}
export function newExportBundleProjectId(): ProjectId { export function newExportBundleProjectId(): ProjectId {
return asProjectId(`p_${generateId()}`); return asProjectId(`p_${generateId()}`);
} }
+50 -2
View File
@@ -10,6 +10,8 @@ import type {
MediaAsset, MediaAsset,
NpcId, NpcId,
NpcRelationId, NpcRelationId,
NpcGroupId,
NpcBinding,
NpcsOverlayEvent, NpcsOverlayEvent,
NpcsOverlayState, NpcsOverlayState,
Project, Project,
@@ -22,6 +24,7 @@ import type {
VideoPlaybackState, VideoPlaybackState,
} from '../types'; } from '../types';
import type { import type {
NpcImportResolution,
SceneImportResolution, SceneImportResolution,
StorylineImportMergeReport, StorylineImportMergeReport,
StorylineLabels, StorylineLabels,
@@ -66,6 +69,9 @@ export const ipcChannels = {
pickNpcAvatar: 'project.pickNpcAvatar', pickNpcAvatar: 'project.pickNpcAvatar',
upsertNpcRelation: 'project.upsertNpcRelation', upsertNpcRelation: 'project.upsertNpcRelation',
deleteNpcRelation: 'project.deleteNpcRelation', deleteNpcRelation: 'project.deleteNpcRelation',
upsertNpcGroup: 'project.upsertNpcGroup',
deleteNpcGroup: 'project.deleteNpcGroup',
setNpcGroupsOrder: 'project.setNpcGroupsOrder',
importScenePreview: 'project.importScenePreview', importScenePreview: 'project.importScenePreview',
clearScenePreview: 'project.clearScenePreview', clearScenePreview: 'project.clearScenePreview',
assetFileUrl: 'project.assetFileUrl', assetFileUrl: 'project.assetFileUrl',
@@ -90,6 +96,8 @@ export const ipcChannels = {
mergeImportZip: 'project.mergeImportZip', mergeImportZip: 'project.mergeImportZip',
mergeImportFromProject: 'project.mergeImportFromProject', mergeImportFromProject: 'project.mergeImportFromProject',
importZipFromPath: 'project.importZipFromPath', importZipFromPath: 'project.importZipFromPath',
importFoundry: 'project.importFoundry',
pickFoundrySource: 'project.pickFoundrySource',
deleteProject: 'project.deleteProject', deleteProject: 'project.deleteProject',
importZipProgress: 'project.importZipProgress', importZipProgress: 'project.importZipProgress',
exportZipProgress: 'project.exportZipProgress', exportZipProgress: 'project.exportZipProgress',
@@ -297,11 +305,24 @@ export type IpcInvokeMap = {
| { canceled: false; filePath: string; previewDataUrl: string }; | { canceled: false; filePath: string; previewDataUrl: string };
}; };
[ipcChannels.project.upsertNpc]: { [ipcChannels.project.upsertNpc]: {
req: { npcId?: NpcId; name: string; description?: string; filePath?: string }; req: {
npcId?: NpcId;
name: string;
description?: string;
filePath?: string;
groupId?: NpcGroupId | null;
binding?: NpcBinding;
};
res: { project: Project }; res: { project: Project };
}; };
[ipcChannels.project.updateNpcFields]: { [ipcChannels.project.updateNpcFields]: {
req: { npcId: NpcId; name?: string; description?: string }; req: {
npcId: NpcId;
name?: string;
description?: string;
groupId?: NpcGroupId | null;
binding?: NpcBinding;
};
res: { project: Project }; res: { project: Project };
}; };
[ipcChannels.project.updateNpcPosition]: { [ipcChannels.project.updateNpcPosition]: {
@@ -335,6 +356,23 @@ export type IpcInvokeMap = {
req: { relationId: NpcRelationId }; req: { relationId: NpcRelationId };
res: { project: Project }; res: { project: Project };
}; };
[ipcChannels.project.upsertNpcGroup]: {
req: {
groupId?: NpcGroupId;
name: string;
color?: string;
parentId?: NpcGroupId | null;
};
res: { project: Project };
};
[ipcChannels.project.deleteNpcGroup]: {
req: { groupId: NpcGroupId };
res: { project: Project };
};
[ipcChannels.project.setNpcGroupsOrder]: {
req: { groupIds: NpcGroupId[] };
res: { project: Project };
};
[ipcChannels.project.importScenePreview]: { [ipcChannels.project.importScenePreview]: {
req: { sceneId: SceneId; filePath?: string }; req: { sceneId: SceneId; filePath?: string };
res: { project: Project; assetId: AssetId | null; background: boolean }; res: { project: Project; assetId: AssetId | null; background: boolean };
@@ -438,6 +476,7 @@ export type IpcInvokeMap = {
filePath: string; filePath: string;
storylineSelections: StorylineSelection[]; storylineSelections: StorylineSelection[];
sceneResolutions: SceneImportResolution[]; sceneResolutions: SceneImportResolution[];
npcResolutions?: NpcImportResolution[];
}; };
res: { project: Project; report: StorylineImportMergeReport }; res: { project: Project; report: StorylineImportMergeReport };
}; };
@@ -446,6 +485,7 @@ export type IpcInvokeMap = {
sourceProjectId: ProjectId; sourceProjectId: ProjectId;
storylineSelections: StorylineSelection[]; storylineSelections: StorylineSelection[];
sceneResolutions: SceneImportResolution[]; sceneResolutions: SceneImportResolution[];
npcResolutions?: NpcImportResolution[];
}; };
res: { project: Project; report: StorylineImportMergeReport }; res: { project: Project; report: StorylineImportMergeReport };
}; };
@@ -453,6 +493,14 @@ export type IpcInvokeMap = {
req: { filePath: string }; req: { filePath: string };
res: { project: Project }; res: { project: Project };
}; };
[ipcChannels.project.pickFoundrySource]: {
req: { mode: 'folder' | 'archive' };
res: { canceled: true } | { canceled: false; sourcePath: string };
};
[ipcChannels.project.importFoundry]: {
req: { sourcePath: string };
res: { project: Project };
};
[ipcChannels.project.exportZip]: { [ipcChannels.project.exportZip]: {
req: { projectId: ProjectId; storylineSelections: StorylineSelection[]; labels: StorylineLabels }; req: { projectId: ProjectId; storylineSelections: StorylineSelection[]; labels: StorylineLabels };
res: { canceled: true } | { canceled: false }; res: { canceled: true } | { canceled: false };
+80
View File
@@ -0,0 +1,80 @@
import { listSideStoryStarts } from '../graph/sceneGraphLineage';
import type { GraphNodeId, NpcBinding, NpcStorylineRef, Project, ProjectNpc, SceneId } from '../types';
export function noneBinding(): NpcBinding {
return { kind: 'none' };
}
export function isNpcBindingNone(b: NpcBinding | undefined | null): boolean {
return !b || b.kind === 'none';
}
export function storylineRefKey(ref: NpcStorylineRef): string {
return ref.kind === 'main' ? 'main' : `side:${ref.startGraphNodeId}`;
}
export function normalizeNpcBinding(
raw: unknown,
ctx: { sceneIds: Set<SceneId>; sideStartIds: Set<GraphNodeId>; hasMainStart: boolean },
): NpcBinding {
if (!raw || typeof raw !== 'object') return noneBinding();
const obj = raw as {
kind?: string;
sceneId?: string;
storyline?: { kind?: string; startGraphNodeId?: string };
};
if (obj.kind === 'scene' && typeof obj.sceneId === 'string') {
const sceneId = obj.sceneId as SceneId;
if (ctx.sceneIds.has(sceneId)) return { kind: 'scene', sceneId };
return noneBinding();
}
if (obj.kind === 'storyline' && obj.storyline && typeof obj.storyline === 'object') {
if (obj.storyline.kind === 'main') {
return ctx.hasMainStart ? { kind: 'storyline', storyline: { kind: 'main' } } : noneBinding();
}
if (obj.storyline.kind === 'side' && typeof obj.storyline.startGraphNodeId === 'string') {
const id = obj.storyline.startGraphNodeId as GraphNodeId;
if (ctx.sideStartIds.has(id)) {
return { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: id } };
}
}
}
return noneBinding();
}
export function clearNpcBindingsForDeletedScene(npcs: ProjectNpc[], sceneId: SceneId): ProjectNpc[] {
return npcs.map((n) => {
if (n.binding?.kind === 'scene' && n.binding.sceneId === sceneId) {
return { ...n, binding: noneBinding() };
}
return n;
});
}
export function clearNpcBindingsForRemovedStoryline(
npcs: ProjectNpc[],
removed: NpcStorylineRef,
): ProjectNpc[] {
return npcs.map((n) => {
if (n.binding?.kind !== 'storyline') return n;
const ref = n.binding.storyline;
if (removed.kind === 'main' && ref.kind === 'main') return { ...n, binding: noneBinding() };
if (removed.kind === 'side' && ref.kind === 'side' && ref.startGraphNodeId === removed.startGraphNodeId) {
return { ...n, binding: noneBinding() };
}
return n;
});
}
export function listStorylineOptionsForBinding(project: Project): {
main: boolean;
sides: { startGraphNodeId: GraphNodeId; label: string }[];
} {
const hasMain = project.sceneGraphNodes.some((n) => n.isStartScene);
const sides = listSideStoryStarts(project.sceneGraphNodes).map((n) => {
const scene = project.scenes[n.sceneId];
const label = n.sideStoryLineTitle.trim() || scene?.title.trim() || String(n.id);
return { startGraphNodeId: n.id, label };
});
return { main: hasMain, sides };
}
+128
View File
@@ -0,0 +1,128 @@
import type { NpcGroupId, ProjectNpc, ProjectNpcGroup } from '../types';
import { asNpcGroupId } from '../types/ids';
export const DEFAULT_NPC_GROUP_COLOR = '#6b7280';
export function normalizeHexColor(raw: unknown, fallback = DEFAULT_NPC_GROUP_COLOR): string {
if (typeof raw !== 'string') return fallback;
const s = raw.trim();
if (/^#[0-9a-fA-F]{6}$/u.test(s)) return s.toLowerCase();
if (/^#[0-9a-fA-F]{3}$/u.test(s)) {
const r = s[1]!;
const g = s[2]!;
const b = s[3]!;
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
}
return fallback;
}
export function uniqueNpcGroupName(base: string, existing: string[], exceptId?: NpcGroupId | null): string {
const root = base.trim() || 'Group';
const used = new Set(
existing
.map((n, i) => ({ n: n.trim().toLowerCase(), i }))
.filter(() => true)
.map((x) => x.n),
);
// existing is just names; caller filters except
void exceptId;
if (!used.has(root.toLowerCase())) return root;
let i = 2;
while (used.has(`${root.toLowerCase()} (${String(i)})`)) i += 1;
return `${root} (${String(i)})`;
}
export function normalizeNpcGroups(raw: unknown): ProjectNpcGroup[] {
if (!Array.isArray(raw)) return [];
const parsed: ProjectNpcGroup[] = [];
const ids = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const obj = item as { id?: string; name?: string; color?: string; parentId?: string | null };
if (!obj.id || typeof obj.name !== 'string') continue;
const name = obj.name.trim();
if (!name) continue;
const id = String(obj.id);
if (ids.has(id)) continue;
ids.add(id);
parsed.push({
id: asNpcGroupId(id),
name,
color: normalizeHexColor(obj.color),
parentId:
typeof obj.parentId === 'string' && obj.parentId.trim() ? asNpcGroupId(obj.parentId.trim()) : null,
});
}
// Drop parent links to missing groups (flatten to root).
const idSet = new Set(parsed.map((g) => g.id));
return parsed.map((g) => ({
...g,
parentId: g.parentId && idSet.has(g.parentId) && g.parentId !== g.id ? g.parentId : null,
}));
}
/** Разрешить groupId НПС; неизвестный → null («Без группы»). */
export function resolveNpcGroupId(raw: unknown, groupIds: Set<NpcGroupId>): NpcGroupId | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
const id = asNpcGroupId(raw.trim());
return groupIds.has(id) ? id : null;
}
export type NpcGroupTreeNode = {
group: ProjectNpcGroup;
children: NpcGroupTreeNode[];
npcs: ProjectNpc[];
};
/** Дерево групп + НПС без группы отдельно. */
export function buildNpcGroupForest(
groups: ProjectNpcGroup[],
npcs: ProjectNpc[],
): { roots: NpcGroupTreeNode[]; ungrouped: ProjectNpc[] } {
const byParent = new Map<string | null, ProjectNpcGroup[]>();
for (const g of groups) {
const key = g.parentId;
const list = byParent.get(key) ?? [];
list.push(g);
byParent.set(key, list);
}
// Preserve array order among siblings (already ordered in groups array).
const build = (parentId: NpcGroupId | null): NpcGroupTreeNode[] => {
const siblings = groups.filter((g) => g.parentId === parentId);
return siblings.map((group) => ({
group,
children: build(group.id),
npcs: npcs.filter((n) => n.groupId === group.id),
}));
};
return {
roots: build(null),
ungrouped: npcs.filter((n) => n.groupId === null),
};
}
export function collectDescendantGroupIds(groups: ProjectNpcGroup[], rootId: NpcGroupId): Set<NpcGroupId> {
const out = new Set<NpcGroupId>([rootId]);
let changed = true;
while (changed) {
changed = false;
for (const g of groups) {
if (g.parentId && out.has(g.parentId) && !out.has(g.id)) {
out.add(g.id);
changed = true;
}
}
}
return out;
}
/** Нельзя сделать parentId потомком (цикл). */
export function wouldCreateGroupCycle(
groups: ProjectNpcGroup[],
groupId: NpcGroupId,
newParentId: NpcGroupId | null,
): boolean {
if (!newParentId) return false;
if (newParentId === groupId) return true;
return collectDescendantGroupIds(groups, groupId).has(newParentId);
}
+36 -2
View File
@@ -1,6 +1,15 @@
import type { AssetId, GraphNodeId, MaterialId, NpcId, NpcRelationId, ProjectId, SceneId } from './ids'; import type {
AssetId,
GraphNodeId,
MaterialId,
NpcGroupId,
NpcId,
NpcRelationId,
ProjectId,
SceneId,
} from './ids';
export const PROJECT_SCHEMA_VERSION = 8 as const; export const PROJECT_SCHEMA_VERSION = 9 as const;
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */ /** Материал кампании: изображение, показываемое поверх сцены во время игры. */
export type ProjectMaterial = { export type ProjectMaterial = {
@@ -10,6 +19,26 @@ export type ProjectMaterial = {
rotationDeg: 0 | 90 | 180 | 270; rotationDeg: 0 | 90 | 180 | 270;
}; };
/** Группа НПС (дерево через parentId). */
export type ProjectNpcGroup = {
id: NpcGroupId;
name: string;
/** Hex `#rrggbb`. */
color: string;
parentId: NpcGroupId | null;
};
/** Привязка НПС к сюжетной линии. */
export type NpcStorylineRef =
| { kind: 'main' }
| { kind: 'side'; startGraphNodeId: GraphNodeId };
/** Привязка НПС к линии или сцене; `none` — без привязки. */
export type NpcBinding =
| { kind: 'none' }
| { kind: 'storyline'; storyline: NpcStorylineRef }
| { kind: 'scene'; sceneId: SceneId };
/** НПС кампании: персонаж с аватаром, описанием и связями на графе. */ /** НПС кампании: персонаж с аватаром, описанием и связями на графе. */
export type ProjectNpc = { export type ProjectNpc = {
id: NpcId; id: NpcId;
@@ -20,6 +49,9 @@ export type ProjectNpc = {
/** Позиция карточки на графе взаимосвязей. */ /** Позиция карточки на графе взаимосвязей. */
x: number; x: number;
y: number; y: number;
/** `null` — системная секция «Без группы». */
groupId: NpcGroupId | null;
binding: NpcBinding;
}; };
/** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */ /** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */
@@ -167,6 +199,8 @@ export type Project = {
materials: ProjectMaterial[]; materials: ProjectMaterial[];
/** НПС кампании (порядок = порядок в списке редактора/пульта). */ /** НПС кампании (порядок = порядок в списке редактора/пульта). */
npcs: ProjectNpc[]; npcs: ProjectNpc[];
/** Группы НПС (порядок среди siblings = порядок в массиве). */
npcGroups: ProjectNpcGroup[];
/** Связи между НПС (однонаправленные; между одной парой направлений может быть несколько). */ /** Связи между НПС (однонаправленные; между одной парой направлений может быть несколько). */
npcRelations: ProjectNpcRelation[]; npcRelations: ProjectNpcRelation[];
currentSceneId: SceneId | null; currentSceneId: SceneId | null;
+5
View File
@@ -7,6 +7,7 @@ export type GraphNodeId = Brand<string, 'GraphNodeId'>;
export type MaterialId = Brand<string, 'MaterialId'>; export type MaterialId = Brand<string, 'MaterialId'>;
export type NpcId = Brand<string, 'NpcId'>; export type NpcId = Brand<string, 'NpcId'>;
export type NpcRelationId = Brand<string, 'NpcRelationId'>; export type NpcRelationId = Brand<string, 'NpcRelationId'>;
export type NpcGroupId = Brand<string, 'NpcGroupId'>;
export function asProjectId(value: string): ProjectId { export function asProjectId(value: string): ProjectId {
return value as ProjectId; return value as ProjectId;
@@ -35,3 +36,7 @@ export function asNpcId(value: string): NpcId {
export function asNpcRelationId(value: string): NpcRelationId { export function asNpcRelationId(value: string): NpcRelationId {
return value as NpcRelationId; return value as NpcRelationId;
} }
export function asNpcGroupId(value: string): NpcGroupId {
return value as NpcGroupId;
}
+162 -2
View File
@@ -14,6 +14,7 @@
"@tiptap/extension-placeholder": "^3.27.4", "@tiptap/extension-placeholder": "^3.27.4",
"@tiptap/react": "^3.27.4", "@tiptap/react": "^3.27.4",
"@tiptap/starter-kit": "^3.27.4", "@tiptap/starter-kit": "^3.27.4",
"classic-level": "^3.0.0",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"pixi.js": "^8.18.1", "pixi.js": "^8.18.1",
@@ -4665,6 +4666,70 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/abstract-level": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-3.1.1.tgz",
"integrity": "sha512-CW2gKbJFTuX1feMvOrvsVMmijAOgI9kg2Ie9Dq3gOcMt/dVVoVmqNlLcEUCT13NxHFMEajcUcVBIplbyDroDiw==",
"license": "MIT",
"dependencies": {
"buffer": "^6.0.3",
"is-buffer": "^2.0.5",
"level-supports": "^6.2.0",
"level-transcoder": "^1.0.1",
"maybe-combine-errors": "^1.0.0",
"module-error": "^1.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/abstract-level/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/abstract-level/node_modules/is-buffer": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -5356,7 +5421,6 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5973,6 +6037,22 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/classic-level": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/classic-level/-/classic-level-3.0.0.tgz",
"integrity": "sha512-yGy8j8LjPbN0Bh3+ygmyYvrmskVita92pD/zCoalfcC9XxZj6iDtZTAnz+ot7GG8p9KLTG+MZ84tSA4AhkgVZQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"abstract-level": "^3.1.0",
"module-error": "^1.0.1",
"napi-macros": "^2.2.2",
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/clean-regexp": { "node_modules/clean-regexp": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz",
@@ -9260,7 +9340,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -10467,6 +10546,52 @@
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/level-supports": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/level-supports/-/level-supports-6.2.0.tgz",
"integrity": "sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/level-transcoder": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/level-transcoder/-/level-transcoder-1.0.1.tgz",
"integrity": "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==",
"license": "MIT",
"dependencies": {
"buffer": "^6.0.3",
"module-error": "^1.0.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/level-transcoder/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -10935,6 +11060,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/maybe-combine-errors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/maybe-combine-errors/-/maybe-combine-errors-1.0.0.tgz",
"integrity": "sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/md5": { "node_modules/md5": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
@@ -11246,6 +11380,15 @@
"mkdirp": "bin/cmd.js" "mkdirp": "bin/cmd.js"
} }
}, },
"node_modules/module-error": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz",
"integrity": "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -11322,6 +11465,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/napi-macros": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz",
"integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==",
"license": "MIT"
},
"node_modules/napi-postinstall": { "node_modules/napi-postinstall": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@@ -11456,6 +11605,17 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp/node_modules/semver": { "node_modules/node-gyp/node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+10 -2
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate", "build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit", "typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs", "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
"format": "prettier . --check", "format": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",
@@ -34,6 +34,7 @@
"@tiptap/extension-placeholder": "^3.27.4", "@tiptap/extension-placeholder": "^3.27.4",
"@tiptap/react": "^3.27.4", "@tiptap/react": "^3.27.4",
"@tiptap/starter-kit": "^3.27.4", "@tiptap/starter-kit": "^3.27.4",
"classic-level": "^3.0.0",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"pixi.js": "^8.18.1", "pixi.js": "^8.18.1",
@@ -101,7 +102,14 @@
"dist/renderer/app-window-icon.png", "dist/renderer/app-window-icon.png",
"node_modules/sharp/**", "node_modules/sharp/**",
"node_modules/@img/**", "node_modules/@img/**",
"node_modules/ffmpeg-static/**" "node_modules/ffmpeg-static/**",
"node_modules/classic-level/**",
"node_modules/abstract-level/**",
"node_modules/level-supports/**",
"node_modules/level-transcoder/**",
"node_modules/module-error/**",
"node_modules/buffer/**",
"node_modules/napi-macros/**"
], ],
"toolsets": { "toolsets": {
"appimage": "1.0.2" "appimage": "1.0.2"
+1 -1
View File
@@ -62,7 +62,7 @@ async function buildNodeTargets() {
bundle: true, bundle: true,
minify: isProd, minify: isProd,
sourcemap: !isProd, sourcemap: !isProd,
external: ['electron', 'electron-updater', 'sharp', 'ffmpeg-static'], external: ['electron', 'electron-updater', 'sharp', 'ffmpeg-static', 'classic-level'],
define, define,
drop: isProd ? ['console', 'debugger'] : [], drop: isProd ? ['console', 'debugger'] : [],
}; };
+4 -3
View File
@@ -12,7 +12,8 @@ const electronEnv = {
...process.env, ...process.env,
NODE_ENV: 'development', NODE_ENV: 'development',
DND_SKIP_LICENSE: process.env.DND_SKIP_LICENSE ?? '1', DND_SKIP_LICENSE: process.env.DND_SKIP_LICENSE ?? '1',
VITE_DEV_SERVER_URL: 'http://localhost:5173/', // Совпадает с vite.config.ts `server.host` (не localhost: на Windows он часто → ::1).
VITE_DEV_SERVER_URL: 'http://127.0.0.1:5173/',
}; };
function spawnShell(command, opts = {}) { function spawnShell(command, opts = {}) {
@@ -49,7 +50,7 @@ function killTree(child) {
} }
} }
function waitForVite(url = 'http://localhost:5173/editor.html', timeoutMs = 60000) { function waitForVite(url = 'http://127.0.0.1:5173/editor.html', timeoutMs = 60000) {
const started = Date.now(); const started = Date.now();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tick = () => { const tick = () => {
@@ -134,7 +135,7 @@ async function watchMainAndPreload() {
format: 'cjs', format: 'cjs',
bundle: true, bundle: true,
sourcemap: true, sourcemap: true,
external: ['electron'], external: ['electron', 'electron-updater', 'sharp', 'ffmpeg-static', 'classic-level'],
define: { 'process.env.NODE_ENV': JSON.stringify('development') }, define: { 'process.env.NODE_ENV': JSON.stringify('development') },
plugins: [restartPlugin], plugins: [restartPlugin],
}); });