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:
@@ -0,0 +1,242 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ClassicLevel } from 'classic-level';
|
||||
|
||||
import type {
|
||||
FoundryActorDoc,
|
||||
FoundryAdventureDoc,
|
||||
FoundryFolderDoc,
|
||||
FoundryJournalDoc,
|
||||
FoundryLoadedDocuments,
|
||||
FoundryPlaylistDoc,
|
||||
FoundrySceneDoc,
|
||||
} from '../../shared/foundry/foundryTypes';
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function asDocArray<T>(docs: unknown[]): T[] {
|
||||
return docs.filter((d) => isRecord(d) && typeof d._id === 'string' && typeof d.name === 'string') as T[];
|
||||
}
|
||||
|
||||
/** NeDB: по строке JSON на линию (игнор пустых / битых). */
|
||||
export async function readNedbDocuments(filePath: string): Promise<Record<string, unknown>[]> {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (const line of raw.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (isRecord(parsed) && typeof parsed._id === 'string') out.push(parsed);
|
||||
} catch {
|
||||
// skip broken line
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isLevelDbDir(entries: { name: string; isFile(): boolean; isDirectory(): boolean }[]): boolean {
|
||||
const names = new Set(entries.map((e) => e.name));
|
||||
return (
|
||||
names.has('CURRENT') ||
|
||||
names.has('LOG') ||
|
||||
[...names].some((n) => n.endsWith('.ldb') || n.startsWith('MANIFEST-'))
|
||||
);
|
||||
}
|
||||
|
||||
/** Читает primary documents из LevelDB-папки Foundry (ключи `!collection!id`). */
|
||||
export async function readLevelDbDocuments(dirPath: string): Promise<Record<string, unknown>[]> {
|
||||
const db = new ClassicLevel(dirPath, {
|
||||
keyEncoding: 'utf8',
|
||||
valueEncoding: 'json',
|
||||
createIfMissing: false,
|
||||
});
|
||||
try {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for await (const [key, value] of db.iterator()) {
|
||||
if (typeof key !== 'string') continue;
|
||||
const parts = key.split('!');
|
||||
// "", collection, id
|
||||
if (parts.length < 3) continue;
|
||||
const collection = parts[1] ?? '';
|
||||
if (!collection || collection.includes('.')) continue; // embedded
|
||||
if (!isRecord(value)) continue;
|
||||
if (typeof value._id !== 'string') continue;
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
await db.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readCollectionDocs(baseDir: string, collection: string): Promise<Record<string, unknown>[]> {
|
||||
const candidates = [
|
||||
path.join(baseDir, collection),
|
||||
path.join(baseDir, `${collection}.db`),
|
||||
path.join(baseDir, 'data', collection),
|
||||
path.join(baseDir, 'data', `${collection}.db`),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!(await pathExists(candidate))) continue;
|
||||
const st = await fs.stat(candidate);
|
||||
if (st.isFile() && candidate.endsWith('.db')) {
|
||||
return readNedbDocuments(candidate);
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
const entries = await fs.readdir(candidate, { withFileTypes: true });
|
||||
if (isLevelDbDir(entries)) {
|
||||
return readLevelDbDocuments(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function readPackDocuments(
|
||||
packAbsPath: string,
|
||||
documentType: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (!(await pathExists(packAbsPath))) return [];
|
||||
const st = await fs.stat(packAbsPath);
|
||||
if (st.isFile() && packAbsPath.endsWith('.db')) {
|
||||
return readNedbDocuments(packAbsPath);
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
const entries = await fs.readdir(packAbsPath, { withFileTypes: true });
|
||||
if (isLevelDbDir(entries)) {
|
||||
return readLevelDbDocuments(packAbsPath);
|
||||
}
|
||||
// Иногда path указывает на папку с .db внутри.
|
||||
const nestedDb = path.join(packAbsPath, `${path.basename(packAbsPath)}.db`);
|
||||
if (await pathExists(nestedDb)) return readNedbDocuments(nestedDb);
|
||||
}
|
||||
void documentType;
|
||||
return [];
|
||||
}
|
||||
|
||||
function mergeById<T extends { _id: string }>(lists: T[][]): T[] {
|
||||
const map = new Map<string, T>();
|
||||
for (const list of lists) {
|
||||
for (const doc of list) {
|
||||
if (!map.has(doc._id)) map.set(doc._id, doc);
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
function flattenAdventureDocs(adventures: FoundryAdventureDoc[]): {
|
||||
scenes: FoundrySceneDoc[];
|
||||
actors: FoundryActorDoc[];
|
||||
playlists: FoundryPlaylistDoc[];
|
||||
journals: FoundryJournalDoc[];
|
||||
folders: FoundryFolderDoc[];
|
||||
} {
|
||||
const scenes: FoundrySceneDoc[] = [];
|
||||
const actors: FoundryActorDoc[] = [];
|
||||
const playlists: FoundryPlaylistDoc[] = [];
|
||||
const journals: FoundryJournalDoc[] = [];
|
||||
const folders: FoundryFolderDoc[] = [];
|
||||
for (const adv of adventures) {
|
||||
if (Array.isArray(adv.scenes)) scenes.push(...asDocArray<FoundrySceneDoc>(adv.scenes));
|
||||
if (Array.isArray(adv.actors)) actors.push(...asDocArray<FoundryActorDoc>(adv.actors));
|
||||
if (Array.isArray(adv.playlists)) playlists.push(...asDocArray<FoundryPlaylistDoc>(adv.playlists));
|
||||
if (Array.isArray(adv.journal)) journals.push(...asDocArray<FoundryJournalDoc>(adv.journal));
|
||||
if (Array.isArray(adv.folders)) {
|
||||
for (const f of adv.folders) {
|
||||
if (f && typeof f === 'object' && typeof (f as FoundryFolderDoc)._id === 'string') {
|
||||
folders.push(f as FoundryFolderDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { scenes, actors, playlists, journals, folders };
|
||||
}
|
||||
|
||||
function asFolderArray(docs: unknown[]): FoundryFolderDoc[] {
|
||||
return docs.filter(
|
||||
(d): d is FoundryFolderDoc =>
|
||||
Boolean(d) &&
|
||||
typeof d === 'object' &&
|
||||
typeof (d as FoundryFolderDoc)._id === 'string' &&
|
||||
typeof (d as FoundryFolderDoc).name === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadWorldDocuments(rootDir: string): Promise<FoundryLoadedDocuments> {
|
||||
const [scenesRaw, actorsRaw, playlistsRaw, journalsRaw, adventuresRaw, foldersRaw] =
|
||||
await Promise.all([
|
||||
readCollectionDocs(rootDir, 'scenes'),
|
||||
readCollectionDocs(rootDir, 'actors'),
|
||||
readCollectionDocs(rootDir, 'playlists'),
|
||||
readCollectionDocs(rootDir, 'journal'),
|
||||
readCollectionDocs(rootDir, 'adventures'),
|
||||
readCollectionDocs(rootDir, 'folders'),
|
||||
]);
|
||||
|
||||
const adventures = asDocArray<FoundryAdventureDoc>(adventuresRaw);
|
||||
const embedded = flattenAdventureDocs(adventures);
|
||||
|
||||
return {
|
||||
scenes: mergeById([asDocArray<FoundrySceneDoc>(scenesRaw), embedded.scenes]),
|
||||
actors: mergeById([asDocArray<FoundryActorDoc>(actorsRaw), embedded.actors]),
|
||||
playlists: mergeById([asDocArray<FoundryPlaylistDoc>(playlistsRaw), embedded.playlists]),
|
||||
journals: mergeById([asDocArray<FoundryJournalDoc>(journalsRaw), embedded.journals]),
|
||||
adventures,
|
||||
folders: mergeById([asFolderArray(foldersRaw), embedded.folders]),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadModuleDocuments(
|
||||
rootDir: string,
|
||||
packs: { path: string; type: string }[],
|
||||
): Promise<FoundryLoadedDocuments> {
|
||||
const scenes: FoundrySceneDoc[][] = [];
|
||||
const actors: FoundryActorDoc[][] = [];
|
||||
const playlists: FoundryPlaylistDoc[][] = [];
|
||||
const journals: FoundryJournalDoc[][] = [];
|
||||
const adventures: FoundryAdventureDoc[][] = [];
|
||||
|
||||
for (const pack of packs) {
|
||||
const abs = path.isAbsolute(pack.path) ? pack.path : path.join(rootDir, pack.path);
|
||||
// path в module.json иногда с `./` и иногда ещё со старым `.db`
|
||||
const variants = [abs, abs.endsWith('.db') ? abs : `${abs}.db`, abs.replace(/\.db$/u, '')];
|
||||
let docs: Record<string, unknown>[] = [];
|
||||
for (const v of variants) {
|
||||
docs = await readPackDocuments(v, pack.type);
|
||||
if (docs.length > 0) break;
|
||||
}
|
||||
|
||||
const type = pack.type;
|
||||
if (type === 'Scene') scenes.push(asDocArray<FoundrySceneDoc>(docs));
|
||||
else if (type === 'Actor') actors.push(asDocArray<FoundryActorDoc>(docs));
|
||||
else if (type === 'Playlist') playlists.push(asDocArray<FoundryPlaylistDoc>(docs));
|
||||
else if (type === 'JournalEntry') journals.push(asDocArray<FoundryJournalDoc>(docs));
|
||||
else if (type === 'Adventure') adventures.push(asDocArray<FoundryAdventureDoc>(docs));
|
||||
}
|
||||
|
||||
const advMerged = mergeById(adventures);
|
||||
const embedded = flattenAdventureDocs(advMerged);
|
||||
|
||||
return {
|
||||
scenes: mergeById([...scenes, embedded.scenes]),
|
||||
actors: mergeById([...actors, embedded.actors]),
|
||||
playlists: mergeById([...playlists, embedded.playlists]),
|
||||
journals: mergeById([...journals, embedded.journals]),
|
||||
adventures: advMerged,
|
||||
folders: mergeById([embedded.folders]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { decodeFoundryAssetPath } from '../../shared/foundry/foundryPaths';
|
||||
import type { FoundryPackageManifest, FoundryPackRef } from '../../shared/foundry/foundryTypes';
|
||||
import { isSupportedFoundryPackage } from '../../shared/foundry/foundryVersion';
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<unknown> {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw) as unknown;
|
||||
}
|
||||
|
||||
function parsePacks(raw: unknown): FoundryPackRef[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: FoundryPackRef[] = [];
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue;
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const label = typeof item.label === 'string' ? item.label : name;
|
||||
const packPath = typeof item.path === 'string' ? item.path.replace(/^\.\//u, '') : '';
|
||||
const type =
|
||||
typeof item.type === 'string' ? item.type : typeof item.entity === 'string' ? item.entity : '';
|
||||
if (!packPath || !type) continue;
|
||||
out.push({ name: name || path.basename(packPath), label: label || name, path: packPath, type });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function titleFromManifest(data: Record<string, unknown>, fallbackId: string): string {
|
||||
if (typeof data.title === 'string' && data.title.trim()) return data.title.trim();
|
||||
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
function idFromManifest(data: Record<string, unknown>, dirName: string): string {
|
||||
if (typeof data.id === 'string' && data.id.trim()) return data.id.trim();
|
||||
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
|
||||
return dirName;
|
||||
}
|
||||
|
||||
async function tryParseManifest(dir: string): Promise<FoundryPackageManifest | null> {
|
||||
const worldPath = path.join(dir, 'world.json');
|
||||
const modulePath = path.join(dir, 'module.json');
|
||||
|
||||
try {
|
||||
await fs.access(worldPath);
|
||||
const data = await readJsonFile(worldPath);
|
||||
if (!isRecord(data)) return null;
|
||||
const id = idFromManifest(data, path.basename(dir));
|
||||
const manifest: FoundryPackageManifest = {
|
||||
kind: 'world',
|
||||
id,
|
||||
title: titleFromManifest(data, id),
|
||||
rootDir: dir,
|
||||
packs: [],
|
||||
};
|
||||
const compatibility = parseCompatibility(data.compatibility);
|
||||
if (compatibility) manifest.compatibility = compatibility;
|
||||
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
|
||||
return manifest;
|
||||
} catch {
|
||||
// not a world
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(modulePath);
|
||||
const data = await readJsonFile(modulePath);
|
||||
if (!isRecord(data)) return null;
|
||||
const id = idFromManifest(data, path.basename(dir));
|
||||
const manifest: FoundryPackageManifest = {
|
||||
kind: 'module',
|
||||
id,
|
||||
title: titleFromManifest(data, id),
|
||||
rootDir: dir,
|
||||
packs: parsePacks(data.packs),
|
||||
};
|
||||
const compatibility = parseCompatibility(data.compatibility);
|
||||
if (compatibility) manifest.compatibility = compatibility;
|
||||
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
|
||||
return manifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCompatibility(raw: unknown): FoundryPackageManifest['compatibility'] | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const out: NonNullable<FoundryPackageManifest['compatibility']> = {};
|
||||
if (typeof raw.minimum === 'string') out.minimum = raw.minimum;
|
||||
if (typeof raw.verified === 'string') out.verified = raw.verified;
|
||||
if (typeof raw.maximum === 'string') out.maximum = raw.maximum;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ищет world.json / module.json в папке и на 1–2 уровня глубже. */
|
||||
export async function detectFoundryPackage(rootPath: string): Promise<FoundryPackageManifest> {
|
||||
const abs = path.resolve(rootPath);
|
||||
const direct = await tryParseManifest(abs);
|
||||
if (direct) {
|
||||
const ver = isSupportedFoundryPackage(direct);
|
||||
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
|
||||
return direct;
|
||||
}
|
||||
|
||||
// Архив мог содержать один корневой каталог.
|
||||
const entries = await fs.readdir(abs, { withFileTypes: true });
|
||||
const dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(abs, e.name));
|
||||
|
||||
for (const dir of dirs) {
|
||||
const found = await tryParseManifest(dir);
|
||||
if (found) {
|
||||
const ver = isSupportedFoundryPackage(found);
|
||||
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// Data/worlds/<id> или Data/modules/<id>
|
||||
for (const mid of ['worlds', 'modules']) {
|
||||
const midDir = path.join(abs, mid);
|
||||
try {
|
||||
const st = await fs.stat(midDir);
|
||||
if (!st.isDirectory()) continue;
|
||||
const children = await fs.readdir(midDir, { withFileTypes: true });
|
||||
for (const child of children.filter((c) => c.isDirectory())) {
|
||||
const found = await tryParseManifest(path.join(midDir, child.name));
|
||||
if (found) {
|
||||
const ver = isSupportedFoundryPackage(found);
|
||||
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
|
||||
return found;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Data как корень выбранной папки
|
||||
const dataDir = path.join(abs, 'Data');
|
||||
try {
|
||||
const st = await fs.stat(dataDir);
|
||||
if (st.isDirectory()) {
|
||||
return await detectFoundryPackage(dataDir);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Не похоже на пакет Foundry VTT: не найдены world.json или module.json. Выберите папку мира/модуля или архив с ними.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Резолвит путь ассета Foundry относительно корня пакета / Data.
|
||||
* foundryPath — как в документах: `worlds/id/...`, `modules/id/...` или относительный.
|
||||
* Поддерживает URL-encoding (`The%20Withered%20Grove%20(day).webp`).
|
||||
*/
|
||||
export async function resolveFoundryAssetPath(
|
||||
manifest: FoundryPackageManifest,
|
||||
foundryPath: string,
|
||||
): Promise<string | null> {
|
||||
const cleaned = decodeFoundryAssetPath(foundryPath);
|
||||
if (!cleaned) return null;
|
||||
if (/^https?:\/\//iu.test(cleaned)) return null;
|
||||
|
||||
const packagePrefix = manifest.kind === 'world' ? `worlds/${manifest.id}/` : `modules/${manifest.id}/`;
|
||||
|
||||
const rawNormalized = foundryPath.trim().replace(/\\/gu, '/');
|
||||
const pathVariants = cleaned === rawNormalized ? [cleaned] : [cleaned, rawNormalized];
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const variant of pathVariants) {
|
||||
const v = variant.replace(/^\/+/u, '');
|
||||
if (!v) continue;
|
||||
if (v.toLowerCase().startsWith(packagePrefix.toLowerCase())) {
|
||||
candidates.push(path.join(manifest.rootDir, v.slice(packagePrefix.length)));
|
||||
}
|
||||
candidates.push(path.join(manifest.rootDir, v));
|
||||
|
||||
const dataRootGuesses = [
|
||||
path.resolve(manifest.rootDir, '..', '..'),
|
||||
path.resolve(manifest.rootDir, '..'),
|
||||
];
|
||||
for (const dataRoot of dataRootGuesses) {
|
||||
candidates.push(path.join(dataRoot, v));
|
||||
}
|
||||
|
||||
const base = path.basename(v);
|
||||
if (base && base !== v) {
|
||||
candidates.push(path.join(manifest.rootDir, base));
|
||||
candidates.push(path.join(manifest.rootDir, 'assets', base));
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const st = await fs.stat(c);
|
||||
if (st.isFile()) return c;
|
||||
} catch {
|
||||
// next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -625,7 +625,9 @@ async function main() {
|
||||
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
||||
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;
|
||||
if (!filePath && !npcId) {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
@@ -647,20 +649,28 @@ async function main() {
|
||||
name,
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
});
|
||||
syncNpcsOverlayWithProject(project);
|
||||
emitNpcsOverlayState();
|
||||
emitSessionState();
|
||||
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, {
|
||||
...(typeof name === 'string' ? { name } : {}),
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
},
|
||||
);
|
||||
registerHandler(ipcChannels.project.updateNpcPosition, async ({ npcId, x, y }) => {
|
||||
const project = await projectStore.updateNpcPosition(npcId, x, y);
|
||||
emitSessionState();
|
||||
@@ -715,6 +725,31 @@ async function main() {
|
||||
emitSessionState();
|
||||
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 }) => {
|
||||
let filePath = pathFromDrop;
|
||||
if (!filePath) {
|
||||
@@ -880,7 +915,9 @@ async function main() {
|
||||
registerHandler(ipcChannels.project.peekImportFromProject, async ({ 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: 'Импорт линий…' });
|
||||
const { project, report } = await projectStore.mergeStorylinesFromExternalZip(
|
||||
filePath,
|
||||
@@ -894,14 +931,16 @@ async function main() {
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
},
|
||||
npcResolutions,
|
||||
);
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
return { project, report };
|
||||
});
|
||||
},
|
||||
);
|
||||
registerHandler(
|
||||
ipcChannels.project.mergeImportFromProject,
|
||||
async ({ sourceProjectId, storylineSelections, sceneResolutions }) => {
|
||||
async ({ sourceProjectId, storylineSelections, sceneResolutions, npcResolutions }) => {
|
||||
emitZipProgress({ kind: 'import', stage: 'copy', percent: 0, detail: 'Импорт линий…' });
|
||||
const { project, report } = await projectStore.mergeStorylinesFromProjectId(
|
||||
sourceProjectId,
|
||||
@@ -915,6 +954,7 @@ async function main() {
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
},
|
||||
npcResolutions,
|
||||
);
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
@@ -935,6 +975,45 @@ async function main() {
|
||||
emitSessionState();
|
||||
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 }) => {
|
||||
const list = await projectStore.listProjects();
|
||||
const entry = list.find((p) => p.id === projectId);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
listImportableStorylines,
|
||||
mergeStorylinesIntoProject,
|
||||
newExportBundleProjectId,
|
||||
type NpcImportResolution,
|
||||
type SceneImportResolution,
|
||||
type StorylineImportMergeReport,
|
||||
type StorylineLabels,
|
||||
@@ -35,9 +36,11 @@ import {
|
||||
import type {
|
||||
MediaAsset,
|
||||
MediaAssetType,
|
||||
NpcBinding,
|
||||
Project,
|
||||
ProjectId,
|
||||
ProjectNpc,
|
||||
ProjectNpcGroup,
|
||||
ProjectNpcRelation,
|
||||
Scene,
|
||||
SceneGraphEdge,
|
||||
@@ -45,15 +48,34 @@ import type {
|
||||
SceneId,
|
||||
} 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 {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
asMaterialId,
|
||||
asNpcGroupId,
|
||||
asNpcId,
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
} 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 { reconcileAssetFiles } from './assetPrune';
|
||||
@@ -236,6 +258,7 @@ export class ZipProjectStore {
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcGroups: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
@@ -660,9 +683,25 @@ export class ZipProjectStore {
|
||||
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 {
|
||||
...withGraph,
|
||||
scenes: nextScenes,
|
||||
npcs,
|
||||
sceneListOrder: removeFromSceneListOrder(
|
||||
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
|
||||
sceneId,
|
||||
@@ -724,8 +763,25 @@ export class ZipProjectStore {
|
||||
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
|
||||
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,
|
||||
npcs,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
const isMain = graphNodeId !== null && n.id === graphNodeId;
|
||||
if (isMain) {
|
||||
@@ -733,7 +789,8 @@ export class ZipProjectStore {
|
||||
}
|
||||
return { ...n, isStartScene: false };
|
||||
}),
|
||||
}));
|
||||
};
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
@@ -754,8 +811,20 @@ export class ZipProjectStore {
|
||||
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) {
|
||||
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,
|
||||
npcs,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
if (n.id !== graphNodeId) return n;
|
||||
if (enabling) {
|
||||
@@ -763,7 +832,8 @@ export class ZipProjectStore {
|
||||
}
|
||||
return { ...n, isSideStoryStart: false, sideStoryLineTitle: '' };
|
||||
}),
|
||||
}));
|
||||
};
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
@@ -817,7 +887,23 @@ export class ZipProjectStore {
|
||||
await this.updateProject((p) => {
|
||||
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
|
||||
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();
|
||||
if (!latest) throw new Error('No open project');
|
||||
@@ -1154,6 +1240,8 @@ export class ZipProjectStore {
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
}): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
@@ -1199,6 +1287,13 @@ export class ZipProjectStore {
|
||||
const assets = { ...p.assets };
|
||||
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) {
|
||||
const idx = npcs.findIndex((n) => n.id === editingId);
|
||||
if (idx < 0) throw new Error('NPC not found');
|
||||
@@ -1209,6 +1304,8 @@ export class ZipProjectStore {
|
||||
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
|
||||
description:
|
||||
typeof input.description === 'string' ? input.description : prev.description,
|
||||
groupId: resolveGroup(input.groupId, prev.groupId),
|
||||
binding: input.binding !== undefined ? input.binding : prev.binding,
|
||||
};
|
||||
} else {
|
||||
if (!nextAssetId) throw new Error('NPC avatar is required');
|
||||
@@ -1220,6 +1317,8 @@ export class ZipProjectStore {
|
||||
description: typeof input.description === 'string' ? input.description : '',
|
||||
x: 80 + (count % 4) * 220,
|
||||
y: 80 + Math.floor(count / 4) * 200,
|
||||
groupId: resolveGroup(input.groupId, null),
|
||||
binding: input.binding ?? noneBinding(),
|
||||
});
|
||||
}
|
||||
return { ...p, assets, npcs };
|
||||
@@ -1232,7 +1331,12 @@ export class ZipProjectStore {
|
||||
|
||||
async updateNpcFields(
|
||||
npcId: NpcId,
|
||||
patch: { name?: string; description?: string },
|
||||
patch: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
},
|
||||
): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
@@ -1250,12 +1354,20 @@ export class ZipProjectStore {
|
||||
}
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
|
||||
const npcs = (p.npcs ?? []).map((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 {
|
||||
...n,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||
groupId,
|
||||
...(patch.binding !== undefined ? { binding: patch.binding } : {}),
|
||||
};
|
||||
});
|
||||
return { ...p, npcs };
|
||||
@@ -1313,6 +1425,101 @@ export class ZipProjectStore {
|
||||
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: {
|
||||
relationId?: NpcRelationId;
|
||||
sourceNpcId: NpcId;
|
||||
@@ -1583,6 +1790,50 @@ export class ZipProjectStore {
|
||||
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`). */
|
||||
async exportProjectZipToPath(
|
||||
projectId: ProjectId,
|
||||
@@ -1809,6 +2060,7 @@ export class ZipProjectStore {
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
if (!this.openProject) throw new Error('Нет открытого проекта');
|
||||
const offsetX = computeGraphImportOffsetX(this.openProject.project);
|
||||
@@ -1817,7 +2069,10 @@ export class ZipProjectStore {
|
||||
source,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
{ graphOffsetX: offsetX },
|
||||
{
|
||||
graphOffsetX: offsetX,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
const targetCache = this.openProject.cacheDir;
|
||||
@@ -1851,6 +2106,7 @@ export class ZipProjectStore {
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
this.assertStorylineMergeAllowed(selections);
|
||||
const snap = await this.loadProjectSnapshot(sourceProjectId);
|
||||
@@ -1861,6 +2117,7 @@ export class ZipProjectStore {
|
||||
selections,
|
||||
sceneResolutions,
|
||||
onProgress,
|
||||
npcResolutions,
|
||||
);
|
||||
} finally {
|
||||
if (snap.ownsCache) {
|
||||
@@ -1874,6 +2131,7 @@ export class ZipProjectStore {
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
this.assertStorylineMergeAllowed(selections);
|
||||
const source = await this.readExternalProjectForImport(sourcePath);
|
||||
@@ -1887,6 +2145,7 @@ export class ZipProjectStore {
|
||||
selections,
|
||||
sceneResolutions,
|
||||
onProgress,
|
||||
npcResolutions,
|
||||
);
|
||||
} finally {
|
||||
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 } =>
|
||||
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 npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
|
||||
.map((n, index) => {
|
||||
@@ -2106,6 +2372,8 @@ function normalizeProject(p: Project): Project {
|
||||
description?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
groupId?: string | null;
|
||||
binding?: unknown;
|
||||
};
|
||||
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
@@ -2120,6 +2388,12 @@ function normalizeProject(p: Project): Project {
|
||||
description: typeof obj.description === 'string' ? obj.description : '',
|
||||
x,
|
||||
y,
|
||||
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
||||
binding: normalizeNpcBinding(obj.binding, {
|
||||
sceneIds: sceneIdSet,
|
||||
sideStartIds,
|
||||
hasMainStart,
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((x): x is ProjectNpc => Boolean(x));
|
||||
@@ -2171,6 +2445,7 @@ function normalizeProject(p: Project): Project {
|
||||
campaignAudios,
|
||||
materials,
|
||||
npcs,
|
||||
npcGroups,
|
||||
npcRelations,
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
|
||||
@@ -153,7 +153,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
/* Выше модалок (20001), чтобы прогресс импорта не уходил под диалог выбора. */
|
||||
z-index: 30000;
|
||||
}
|
||||
|
||||
.editorLockOverlay {
|
||||
@@ -380,9 +381,14 @@
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
padding-right: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
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);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
|
||||
import type {
|
||||
NpcImportResolution,
|
||||
SceneImportResolution,
|
||||
StorylineImportMergeReport,
|
||||
StorylineSelection,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
sceneTitleFromMediaPath,
|
||||
useFileDropZone,
|
||||
} from './fileDrop';
|
||||
import { FoundryImportModal } from './FoundryImportModal';
|
||||
import { buildNextSceneCardById } from './graph/sceneCardById';
|
||||
import {
|
||||
DND_SCENE_ID_MIME,
|
||||
@@ -58,12 +60,15 @@ import { SceneDescriptionModal } from './SceneDescriptionModal';
|
||||
import type { ProjectNoticeCode } from './state/projectState';
|
||||
import { useProjectState } from './state/projectState';
|
||||
import {
|
||||
buildNpcResolutionsForImport,
|
||||
buildSceneResolutionsForImport,
|
||||
computeImportConflicts,
|
||||
computeNpcImportConflicts,
|
||||
ExportProjectModal,
|
||||
ImportReportModal,
|
||||
ImportSourceModal,
|
||||
ImportStorylinesModal,
|
||||
NpcConflictModal,
|
||||
SceneConflictModal,
|
||||
useStorylineLabels,
|
||||
type ImportPeekResult,
|
||||
@@ -123,11 +128,17 @@ export function EditorApp() {
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [importSourceOpen, setImportSourceOpen] = useState(false);
|
||||
const [foundryImportOpen, setFoundryImportOpen] = useState(false);
|
||||
const [importPeek, setImportPeek] = useState<ImportPeekResult | null>(null);
|
||||
const [importStorylinesOpen, setImportStorylinesOpen] = useState(false);
|
||||
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
|
||||
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 [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
|
||||
const [importReportOpen, setImportReportOpen] = useState(false);
|
||||
const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null);
|
||||
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
|
||||
@@ -499,22 +510,71 @@ export function EditorApp() {
|
||||
[actions, storylineLabels],
|
||||
);
|
||||
|
||||
const runStorylineMerge = 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);
|
||||
const clearImportFlow = useCallback(() => {
|
||||
setImportPeek(null);
|
||||
setImportStorylinesOpen(false);
|
||||
setImportConflictsOpen(false);
|
||||
setImportNpcConflictsOpen(false);
|
||||
setPendingImportSelections([]);
|
||||
setPendingSceneResolutions([]);
|
||||
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(
|
||||
@@ -554,10 +614,16 @@ export function EditorApp() {
|
||||
setImportSourceOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleImportFoundry = useCallback(() => {
|
||||
setProjectMenuOpen(false);
|
||||
setFoundryImportOpen(true);
|
||||
}, []);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
setProjectMenuOpen(false);
|
||||
setExportModalOpen(false);
|
||||
setImportSourceOpen(false);
|
||||
setFoundryImportOpen(false);
|
||||
setImportStorylinesOpen(false);
|
||||
setImportConflictsOpen(false);
|
||||
setImportReportOpen(false);
|
||||
@@ -1294,6 +1360,18 @@ export function EditorApp() {
|
||||
>
|
||||
{t('projectMenu.import')}
|
||||
</button>
|
||||
{!state.project ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
onClick={() => {
|
||||
handleImportFoundry();
|
||||
}}
|
||||
>
|
||||
{t('projectMenu.importFoundry')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -1373,6 +1451,16 @@ export function EditorApp() {
|
||||
onClose={() => setImportSourceOpen(false)}
|
||||
onContinue={handleImportSourceContinue}
|
||||
/>
|
||||
<FoundryImportModal
|
||||
open={foundryImportOpen}
|
||||
pickSource={actions.pickFoundrySource}
|
||||
onClose={() => setFoundryImportOpen(false)}
|
||||
onImport={async (selection) => {
|
||||
// Сразу закрываем диалог, чтобы прогресс импорта не оказался под ним.
|
||||
setFoundryImportOpen(false);
|
||||
await actions.importFoundryProject(selection.sourcePath);
|
||||
}}
|
||||
/>
|
||||
<ImportStorylinesModal
|
||||
open={importStorylinesOpen}
|
||||
sourceName={importPeek?.projectName ?? ''}
|
||||
@@ -1385,10 +1473,10 @@ export function EditorApp() {
|
||||
if (!importPeek || !state.project) return;
|
||||
const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections);
|
||||
setPendingImportSelections(selections);
|
||||
setImportStorylinesOpen(false);
|
||||
if (conflicts.length > 0) {
|
||||
setImportConflicts(conflicts);
|
||||
setImportConflictsOpen(true);
|
||||
setImportStorylinesOpen(false);
|
||||
return;
|
||||
}
|
||||
const resolutions = buildSceneResolutionsForImport(
|
||||
@@ -1398,18 +1486,13 @@ export function EditorApp() {
|
||||
[],
|
||||
[],
|
||||
);
|
||||
void runStorylineMerge(selections, resolutions);
|
||||
continueImportAfterScenes(selections, resolutions);
|
||||
}}
|
||||
/>
|
||||
<SceneConflictModal
|
||||
open={importConflictsOpen}
|
||||
conflicts={importConflicts}
|
||||
onClose={() => {
|
||||
setImportConflictsOpen(false);
|
||||
setImportPeek(null);
|
||||
setPendingImportSelections([]);
|
||||
setImportConflicts([]);
|
||||
}}
|
||||
onClose={clearImportFlow}
|
||||
onConfirm={(userResolutions) => {
|
||||
if (!importPeek || !state.project) return;
|
||||
const resolutions = buildSceneResolutionsForImport(
|
||||
@@ -1419,7 +1502,23 @@ export function EditorApp() {
|
||||
importConflicts,
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,12 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
collectSceneIdsForSelections,
|
||||
filterNpcsForStorylineExport,
|
||||
findNpcNameConflicts,
|
||||
findSceneTitleConflicts,
|
||||
storylineSelectionKey,
|
||||
type NpcImportResolution,
|
||||
type NpcNameConflict,
|
||||
type SceneImportResolution,
|
||||
type SceneTitleConflict,
|
||||
type StorylineImportMergeReport,
|
||||
@@ -14,8 +18,9 @@ import {
|
||||
} from '../../shared/graph/storylineExportImport';
|
||||
import type { Project, ProjectId, SceneId } from '../../shared/types';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
|
||||
type ExportProjectModalProps = {
|
||||
open: boolean;
|
||||
@@ -109,11 +114,21 @@ export function ExportProjectModal({
|
||||
|
||||
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 className={styles.modalHeader}>
|
||||
<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>
|
||||
</div>
|
||||
@@ -145,7 +160,10 @@ export function ExportProjectModal({
|
||||
const checked = selectedKeys.has(key);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label key={key} className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}>
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
@@ -266,17 +284,25 @@ export function ImportSourceModal({
|
||||
|
||||
const canContinue =
|
||||
!submitting &&
|
||||
(importKind === 'project'
|
||||
? canImportFromProject && sourceProjectId !== null
|
||||
: pickedFile !== null);
|
||||
(importKind === 'project' ? canImportFromProject && sourceProjectId !== null : pickedFile !== null);
|
||||
|
||||
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 className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importSource.title')}</div>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -432,11 +458,21 @@ export function ImportStorylinesModal({
|
||||
|
||||
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 className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.title')}</div>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -454,7 +490,10 @@ export function ImportStorylinesModal({
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label key={key} className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}>
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.has(key)}
|
||||
@@ -474,11 +513,7 @@ export function ImportStorylinesModal({
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canContinue}
|
||||
onClick={() => onContinue(selectedSelections)}
|
||||
>
|
||||
<Button variant="primary" disabled={!canContinue} onClick={() => onContinue(selectedSelections)}>
|
||||
{t('importStoryline.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -512,11 +547,21 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen
|
||||
|
||||
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 className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.conflictsTitle')}</div>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</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 = {
|
||||
open: boolean;
|
||||
report: StorylineImportMergeReport | null;
|
||||
@@ -593,11 +753,21 @@ export function ImportReportModal({ open, report, onClose }: ImportReportModalPr
|
||||
|
||||
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 className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.reportTitle')}</div>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -606,14 +776,14 @@ export function ImportReportModal({ open, report, onClose }: ImportReportModalPr
|
||||
<li>{t('importStoryline.reportLines', { count: report.storylinesImported })}</li>
|
||||
<li>{t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}</li>
|
||||
<li>{t('importStoryline.reportScenesReused', { count: report.scenesReused })}</li>
|
||||
<li>{t('importStoryline.reportNpcsCreated', { count: report.npcsCreated })}</li>
|
||||
<li>{t('importStoryline.reportNpcsReused', { count: report.npcsReused })}</li>
|
||||
<li>{t('importStoryline.reportNodes', { count: report.graphNodesAdded })}</li>
|
||||
<li>{t('importStoryline.reportEdges', { count: report.edgesAdded })}</li>
|
||||
<li>{t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}</li>
|
||||
<li>{t('importStoryline.reportAssetsReused', { count: report.assetsReused })}</li>
|
||||
{report.renamedSideTitles.length > 0 ? (
|
||||
<li>
|
||||
{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}
|
||||
</li>
|
||||
<li>{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
@@ -659,6 +829,41 @@ export function computeImportConflicts(
|
||||
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 {
|
||||
const { t } = useEditorI18n();
|
||||
return useMemo(
|
||||
|
||||
@@ -235,9 +235,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'projectMenu.home': 'Начальный экран',
|
||||
'projectMenu.import': 'Импорт',
|
||||
'projectMenu.importFoundry': 'Импорт из Foundry',
|
||||
'projectMenu.export': 'Экспорт',
|
||||
'projectMenu.noProjects': 'Нет сохранённых проектов',
|
||||
|
||||
'foundryImport.title': 'Импорт из Foundry',
|
||||
'foundryImport.hint':
|
||||
'Выберите папку или архив мира (.world) либо модуля Foundry VTT (версии 11+). Будет создан новый проект.',
|
||||
'foundryImport.sourceType': 'ТИП ИСТОЧНИКА',
|
||||
'foundryImport.folder': 'Папка',
|
||||
'foundryImport.archive': 'Архив (.zip / .fvtt)',
|
||||
'foundryImport.source': 'ИСТОЧНИК',
|
||||
'foundryImport.chooseFolder': 'Выбрать папку',
|
||||
'foundryImport.chooseArchive': 'Выбрать архив',
|
||||
'foundryImport.noSourceSelected': 'Не выбрано',
|
||||
'foundryImport.import': 'Импортировать',
|
||||
|
||||
'fileMenu.rename': 'Переименовать проект',
|
||||
|
||||
'scenes.search': 'Поиск сцен…',
|
||||
@@ -301,11 +314,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'importStoryline.reportLines': 'Импортировано линий: {count}',
|
||||
'importStoryline.reportScenesCreated': 'Создано новых сцен: {count}',
|
||||
'importStoryline.reportScenesReused': 'Использовано существующих сцен: {count}',
|
||||
'importStoryline.reportNpcsCreated': 'Создано новых НПС: {count}',
|
||||
'importStoryline.reportNpcsReused': 'Использовано существующих НПС: {count}',
|
||||
'importStoryline.reportNodes': 'Добавлено карточек на граф: {count}',
|
||||
'importStoryline.reportEdges': 'Добавлено связей: {count}',
|
||||
'importStoryline.reportAssetsCopied': 'Скопировано файлов материалов: {count}',
|
||||
'importStoryline.reportAssetsReused': 'Повторно использовано материалов: {count}',
|
||||
'importStoryline.reportRenamedSides': 'Переименованы побочные линии: {names}',
|
||||
'importStoryline.npcConflictsTitle': 'Совпадение имён НПС',
|
||||
'importStoryline.npcConflictsHint':
|
||||
'В импортируемых линиях есть НПС с такими же именами, как в текущем проекте. Выберите действие для каждого.',
|
||||
'importStoryline.createNewNpc': 'Создать нового НПС',
|
||||
'importStoryline.useExistingNpc': 'Использовать «{name}»',
|
||||
|
||||
'confirmDelete.title': 'Удаление проекта',
|
||||
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
|
||||
@@ -411,6 +431,28 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
|
||||
'npcs.zoomOutHint': 'Кликните по аватару в предпросмотре пульта, чтобы уменьшить.',
|
||||
'npcs.zoomIdleHint': 'Выберите лупу, затем кликните по аватару в предпросмотре пульта.',
|
||||
'npcs.ungrouped': 'Без группы',
|
||||
'npcs.addGroup': 'Новая группа',
|
||||
'npcs.editGroup': 'Изменить группу',
|
||||
'npcs.deleteGroup': 'Удалить группу',
|
||||
'npcs.groupName': 'Название группы',
|
||||
'npcs.groupColor': 'Цвет',
|
||||
'npcs.groupNameRequired': 'Укажите название группы.',
|
||||
'npcs.groupNameDup': 'Группа с таким названием уже есть.',
|
||||
'npcs.deleteGroupTitle': 'Удаление группы',
|
||||
'npcs.deleteGroupConfirm':
|
||||
'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.',
|
||||
'npcs.addSubgroup': 'Добавить подгруппу',
|
||||
'npcs.group': 'ГРУППА',
|
||||
'npcs.bindingEnable': 'Привязать…',
|
||||
'npcs.bindingKind': 'ТИП ПРИВЯЗКИ',
|
||||
'npcs.bindingStoryline': 'Сюжетная линия',
|
||||
'npcs.bindingScene': 'Сцена',
|
||||
'npcs.bindingMain': 'Основная линия',
|
||||
'npcs.bindingSelect': 'ОБЪЕКТ',
|
||||
'npcs.graphFilterAll': 'Все',
|
||||
'npcs.graphFilterUngrouped': 'Без группы',
|
||||
'npcs.graphFilter': 'Фильтр графа',
|
||||
|
||||
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
|
||||
'scene.description': 'ОПИСАНИЕ',
|
||||
@@ -727,9 +769,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'projectMenu.home': 'Home',
|
||||
'projectMenu.import': 'Import',
|
||||
'projectMenu.importFoundry': 'Import from Foundry',
|
||||
'projectMenu.export': 'Export',
|
||||
'projectMenu.noProjects': 'No saved projects',
|
||||
|
||||
'foundryImport.title': 'Import from Foundry',
|
||||
'foundryImport.hint':
|
||||
'Choose a Foundry VTT world or module folder or archive (version 11+). A new project will be created.',
|
||||
'foundryImport.sourceType': 'SOURCE TYPE',
|
||||
'foundryImport.folder': 'Folder',
|
||||
'foundryImport.archive': 'Archive (.zip / .fvtt)',
|
||||
'foundryImport.source': 'SOURCE',
|
||||
'foundryImport.chooseFolder': 'Choose folder',
|
||||
'foundryImport.chooseArchive': 'Choose archive',
|
||||
'foundryImport.noSourceSelected': 'Nothing selected',
|
||||
'foundryImport.import': 'Import',
|
||||
|
||||
'fileMenu.rename': 'Rename project',
|
||||
|
||||
'scenes.search': 'Search scenes…',
|
||||
@@ -793,11 +848,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'importStoryline.reportLines': 'Storylines imported: {count}',
|
||||
'importStoryline.reportScenesCreated': 'New scenes created: {count}',
|
||||
'importStoryline.reportScenesReused': 'Existing scenes reused: {count}',
|
||||
'importStoryline.reportNpcsCreated': 'New NPCs created: {count}',
|
||||
'importStoryline.reportNpcsReused': 'Existing NPCs reused: {count}',
|
||||
'importStoryline.reportNodes': 'Graph cards added: {count}',
|
||||
'importStoryline.reportEdges': 'Connections added: {count}',
|
||||
'importStoryline.reportAssetsCopied': 'Asset files copied: {count}',
|
||||
'importStoryline.reportAssetsReused': 'Assets reused: {count}',
|
||||
'importStoryline.reportRenamedSides': 'Renamed side storylines: {names}',
|
||||
'importStoryline.npcConflictsTitle': 'Duplicate NPC names',
|
||||
'importStoryline.npcConflictsHint':
|
||||
'Imported storylines contain NPCs with the same names as in the current project. Choose what to do for each.',
|
||||
'importStoryline.createNewNpc': 'Create new NPC',
|
||||
'importStoryline.useExistingNpc': 'Use existing «{name}»',
|
||||
|
||||
'confirmDelete.title': 'Delete project',
|
||||
'confirmDelete.body':
|
||||
@@ -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.zoomOutHint': 'Click the avatar on the control preview to zoom out.',
|
||||
'npcs.zoomIdleHint': 'Pick a magnifier, then click the avatar on the control preview.',
|
||||
'npcs.ungrouped': 'Ungrouped',
|
||||
'npcs.addGroup': 'New group',
|
||||
'npcs.editGroup': 'Edit group',
|
||||
'npcs.deleteGroup': 'Delete group',
|
||||
'npcs.groupName': 'Group name',
|
||||
'npcs.groupColor': 'Color',
|
||||
'npcs.groupNameRequired': 'Group name is required.',
|
||||
'npcs.groupNameDup': 'A group with this name already exists.',
|
||||
'npcs.deleteGroupTitle': 'Delete group',
|
||||
'npcs.deleteGroupConfirm':
|
||||
'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.',
|
||||
'npcs.addSubgroup': 'Add subgroup',
|
||||
'npcs.group': 'GROUP',
|
||||
'npcs.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.description': 'DESCRIPTION',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
NpcImportResolution,
|
||||
SceneImportResolution,
|
||||
StorylineImportMergeReport,
|
||||
StorylineLabels,
|
||||
@@ -94,6 +95,10 @@ type Actions = {
|
||||
setSceneListOrder: (sceneListOrder: SceneId[]) => Promise<void>;
|
||||
renameProject: (name: string, fileBaseName: string) => 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<
|
||||
| { canceled: true }
|
||||
| {
|
||||
@@ -129,11 +134,13 @@ type Actions = {
|
||||
filePath: string,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
mergeImportFromProject: (
|
||||
sourceProjectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
importProjectFromPath: (filePath: string) => Promise<void>;
|
||||
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<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) => {
|
||||
return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart });
|
||||
};
|
||||
@@ -781,11 +806,13 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
filePath: string,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.mergeImportZip, {
|
||||
filePath,
|
||||
storylineSelections,
|
||||
sceneResolutions,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
});
|
||||
setState((s) => ({
|
||||
...s,
|
||||
@@ -799,11 +826,13 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
sourceProjectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.mergeImportFromProject, {
|
||||
sourceProjectId,
|
||||
storylineSelections,
|
||||
sceneResolutions,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
});
|
||||
setState((s) => ({
|
||||
...s,
|
||||
@@ -884,6 +913,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
renameProject,
|
||||
importProject,
|
||||
importProjectFromPath,
|
||||
pickFoundrySource,
|
||||
importFoundryProject,
|
||||
peekImportZip,
|
||||
pickImportZipFile,
|
||||
peekImportZipPath,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { ProjectNpc } from '../../shared/types';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { noneBinding } from '../../shared/npcs/npcBinding';
|
||||
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
|
||||
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
@@ -13,24 +13,51 @@ import {
|
||||
} from '../editor/fileDrop';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
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 {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function flattenGroupOptions(
|
||||
nodes: ReturnType<typeof buildNpcGroupForest>['roots'],
|
||||
depth = 0,
|
||||
): { id: NpcGroupId; label: string }[] {
|
||||
const out: { id: NpcGroupId; label: string }[] = [];
|
||||
for (const node of nodes) {
|
||||
const prefix = depth > 0 ? ' '.repeat(depth) : '';
|
||||
out.push({ id: node.group.id, label: `${prefix}${node.group.name}` });
|
||||
out.push(...flattenGroupOptions(node.children, depth + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type NpcEditModalProps = {
|
||||
open: boolean;
|
||||
initial: ProjectNpc | null;
|
||||
existingNames: string[];
|
||||
project: Project | null;
|
||||
npcGroups: ProjectNpcGroup[];
|
||||
onClose: () => void;
|
||||
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({
|
||||
open,
|
||||
initial,
|
||||
existingNames,
|
||||
project,
|
||||
npcGroups,
|
||||
onClose,
|
||||
onPickImage,
|
||||
onSave,
|
||||
@@ -39,15 +66,24 @@ export function NpcEditModal({
|
||||
const [name, setName] = useState('');
|
||||
const [filePath, setFilePath] = 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 [error, setError] = useState<string | null>(null);
|
||||
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
|
||||
|
||||
const groupOptions = useMemo(() => {
|
||||
const { roots } = buildNpcGroupForest(npcGroups, []);
|
||||
return flattenGroupOptions(roots);
|
||||
}, [npcGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? '');
|
||||
setFilePath(null);
|
||||
setLocalPreviewUrl(null);
|
||||
setGroupId(initial?.groupId ?? '');
|
||||
setBinding(initial?.binding ?? noneBinding());
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
}, [initial, open]);
|
||||
@@ -95,7 +131,7 @@ export function NpcEditModal({
|
||||
);
|
||||
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
|
||||
const canSave = nameOk && !nameDup && hasImage && !saving;
|
||||
const previewSrc = localPreviewUrl || existingUrl;
|
||||
const previewSrc = localPreviewUrl ?? existingUrl;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -109,9 +145,7 @@ export function NpcEditModal({
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>
|
||||
{initial ? t('npcs.editTitle') : t('npcs.addTitle')}
|
||||
</div>
|
||||
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editTitle') : t('npcs.addTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
@@ -129,6 +163,24 @@ export function NpcEditModal({
|
||||
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null}
|
||||
</div>
|
||||
|
||||
{!initial && groupOptions.length > 0 ? (
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.group')}</div>
|
||||
<select
|
||||
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.fieldLabel}>{t('npcs.avatar')}</div>
|
||||
<div
|
||||
@@ -139,11 +191,11 @@ export function NpcEditModal({
|
||||
onDrop={(e) => {
|
||||
drop.onDrop(e);
|
||||
const entries = getDroppedFileEntries(e);
|
||||
const files = e.dataTransfer?.files;
|
||||
const files = e.dataTransfer.files;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i]!;
|
||||
if (!pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files?.[i];
|
||||
const entry = entries[i];
|
||||
if (!entry || !pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files[i];
|
||||
if (file) {
|
||||
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
|
||||
return;
|
||||
@@ -153,9 +205,7 @@ export function NpcEditModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? (
|
||||
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
|
||||
) : null}
|
||||
{drop.dragOver ? <div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div> : null}
|
||||
{previewSrc ? (
|
||||
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
|
||||
) : (
|
||||
@@ -176,6 +226,10 @@ export function NpcEditModal({
|
||||
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
|
||||
</div>
|
||||
|
||||
{project && !initial ? (
|
||||
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
|
||||
) : null}
|
||||
|
||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={editorStyles.modalFooter}>
|
||||
@@ -191,7 +245,11 @@ export function NpcEditModal({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
|
||||
await onSave({
|
||||
name: trimmed,
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(!initial ? { groupId: groupId || null, binding } : {}),
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
||||
@@ -40,11 +40,20 @@
|
||||
border: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
border-left-width: 3px;
|
||||
border-left-color: var(--npc-group-color, var(--stroke));
|
||||
}
|
||||
|
||||
.nodeActive {
|
||||
border-color: #60a5fa;
|
||||
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 {
|
||||
@@ -152,3 +161,19 @@
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
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
@@ -22,7 +22,15 @@ import ReactFlow, {
|
||||
} from 'reactflow';
|
||||
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 styles from './NpcGraph.module.css';
|
||||
@@ -32,6 +40,8 @@ type SelectSourceNpcFn = (sourceNpcId: NpcId) => void;
|
||||
const OpenEdgeMenuContext = createContext<OpenEdgeMenuFn | null>(null);
|
||||
const SelectSourceNpcContext = createContext<SelectSourceNpcFn | null>(null);
|
||||
|
||||
export type GraphGroupFilter = 'all' | 'ungrouped' | NpcGroupId;
|
||||
|
||||
export type NpcGraphUiStrings = {
|
||||
zoomBar: string;
|
||||
zoomIn: string;
|
||||
@@ -40,12 +50,17 @@ export type NpcGraphUiStrings = {
|
||||
editRelation: string;
|
||||
deleteRelation: string;
|
||||
untitled: string;
|
||||
graphFilter: string;
|
||||
graphFilterAll: string;
|
||||
graphFilterUngrouped: string;
|
||||
};
|
||||
|
||||
type NpcNodeData = {
|
||||
name: string;
|
||||
avatarAssetId: ProjectNpc['avatarAssetId'];
|
||||
active: boolean;
|
||||
groupColor: string | null;
|
||||
dimmed: boolean;
|
||||
};
|
||||
|
||||
const NPC_ACCENT = '#60a5fa';
|
||||
@@ -102,30 +117,35 @@ function pickEndpointSides(
|
||||
? { sourceSide: 'right', targetSide: 'left' }
|
||||
: { sourceSide: 'left', targetSide: 'right' };
|
||||
}
|
||||
return dy >= 0
|
||||
? { sourceSide: 'bottom', targetSide: 'top' }
|
||||
: { sourceSide: 'top', targetSide: 'bottom' };
|
||||
return dy >= 0 ? { sourceSide: 'bottom', targetSide: 'top' } : { sourceSide: 'top', targetSide: 'bottom' };
|
||||
}
|
||||
|
||||
function NpcNode({ data, selected }: NodeProps<NpcNodeData>) {
|
||||
const url = useAssetUrl(data.avatarAssetId);
|
||||
const sides: Side[] = ['left', 'right', 'top', 'bottom'];
|
||||
const accent = data.groupColor ?? NPC_ACCENT;
|
||||
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) => (
|
||||
<React.Fragment key={side}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={sideToPosition(side)}
|
||||
id={`s-${side}`}
|
||||
className={styles.handle}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={sideToPosition(side)}
|
||||
id={`t-${side}`}
|
||||
className={styles.handle}
|
||||
/>
|
||||
<Handle type="source" position={sideToPosition(side)} id={`s-${side}`} className={styles.handle} />
|
||||
<Handle type="target" position={sideToPosition(side)} id={`t-${side}`} className={styles.handle} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div className={styles.avatar}>
|
||||
@@ -151,9 +171,7 @@ function parallelCubicPath(
|
||||
): { path: string; labelX: number; labelY: number } {
|
||||
// Канонический вектор между концами (не зависит от направления стрелки).
|
||||
const [ax, ay, bx, by] =
|
||||
sourceNpcId < targetNpcId
|
||||
? [sourceX, sourceY, targetX, targetY]
|
||||
: [targetX, targetY, sourceX, sourceY];
|
||||
sourceNpcId < targetNpcId ? [sourceX, sourceY, targetX, targetY] : [targetX, targetY, sourceX, sourceY];
|
||||
const cdx = bx - ax;
|
||||
const cdy = by - ay;
|
||||
const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1;
|
||||
@@ -195,15 +213,7 @@ function LabeledNpcEdge({
|
||||
const targetNpcId = data?.targetNpcId;
|
||||
const { path, labelX, labelY } =
|
||||
sourceNpcId && targetNpcId
|
||||
? parallelCubicPath(
|
||||
sourceNpcId,
|
||||
targetNpcId,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
worldOffset,
|
||||
)
|
||||
? parallelCubicPath(sourceNpcId, targetNpcId, sourceX, sourceY, targetX, targetY, worldOffset)
|
||||
: {
|
||||
path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
|
||||
labelX: (sourceX + targetX) / 2,
|
||||
@@ -225,12 +235,7 @@ function LabeledNpcEdge({
|
||||
{label && relationId ? (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className={[
|
||||
styles.edgeLabel,
|
||||
highlighted ? styles.edgeLabelActive : '',
|
||||
'nodrag',
|
||||
'nopan',
|
||||
]
|
||||
className={[styles.edgeLabel, highlighted ? styles.edgeLabelActive : '', 'nodrag', 'nopan']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
@@ -269,7 +274,12 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
|
||||
−
|
||||
</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>
|
||||
</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 = {
|
||||
npcs: ProjectNpc[];
|
||||
relations: ProjectNpcRelation[];
|
||||
npcGroups: ProjectNpcGroup[];
|
||||
selectedNpcId: NpcId | null;
|
||||
graphFilter: GraphGroupFilter;
|
||||
onGraphFilterChange: (filter: GraphGroupFilter) => void;
|
||||
graphUi: NpcGraphUiStrings;
|
||||
onSelect: (npcId: NpcId) => void;
|
||||
onConnectRequest: (sourceNpcId: NpcId, targetNpcId: NpcId) => void;
|
||||
@@ -292,7 +336,10 @@ export type NpcGraphProps = {
|
||||
function NpcGraphInner({
|
||||
npcs,
|
||||
relations,
|
||||
npcGroups,
|
||||
selectedNpcId,
|
||||
graphFilter,
|
||||
onGraphFilterChange,
|
||||
graphUi,
|
||||
onSelect,
|
||||
onConnectRequest,
|
||||
@@ -300,9 +347,7 @@ function NpcGraphInner({
|
||||
onEditRelation,
|
||||
onDeleteRelation,
|
||||
}: NpcGraphProps) {
|
||||
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(null);
|
||||
/** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */
|
||||
const connectFromRef = useRef<NpcId | null>(null);
|
||||
|
||||
@@ -320,6 +365,23 @@ function NpcGraphInner({
|
||||
};
|
||||
}, [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(
|
||||
() =>
|
||||
npcs.map((n) => ({
|
||||
@@ -330,9 +392,11 @@ function NpcGraphInner({
|
||||
name: n.name,
|
||||
avatarAssetId: n.avatarAssetId,
|
||||
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);
|
||||
@@ -364,6 +428,11 @@ function NpcGraphInner({
|
||||
const { sourceSide, targetSide } = pickEndpointSides(sourcePos, targetPos);
|
||||
const offset = (index - (total - 1) / 2) * PARALLEL_EDGE_GAP;
|
||||
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;
|
||||
out.push({
|
||||
id: r.id,
|
||||
@@ -381,7 +450,11 @@ function NpcGraphInner({
|
||||
targetNpcId: r.targetNpcId,
|
||||
highlighted,
|
||||
},
|
||||
style: { stroke: color, strokeWidth: highlighted ? 2.5 : 2 },
|
||||
style: {
|
||||
stroke: color,
|
||||
strokeWidth: highlighted ? 2.5 : 2,
|
||||
opacity: edgeDimmed ? 0.2 : 1,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 16,
|
||||
@@ -392,7 +465,7 @@ function NpcGraphInner({
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [nodes, npcs, relations, selectedNpcId]);
|
||||
}, [graphFilter, isNpcDimmed, nodes, npcs, relations, selectedNpcId]);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes);
|
||||
@@ -461,8 +534,7 @@ function NpcGraphInner({
|
||||
onEdgeClick={(e, edge) => {
|
||||
e.stopPropagation();
|
||||
setMenu(null);
|
||||
const sourceId =
|
||||
(edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
|
||||
const sourceId = (edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
|
||||
onSelect(sourceId);
|
||||
}}
|
||||
onEdgeContextMenu={(e, edge) => {
|
||||
@@ -479,6 +551,12 @@ function NpcGraphInner({
|
||||
}}
|
||||
>
|
||||
<Background gap={18} size={1} color="#27272a" />
|
||||
<FilterToolbar
|
||||
ui={graphUi}
|
||||
groups={npcGroups}
|
||||
value={graphFilter}
|
||||
onChange={onGraphFilterChange}
|
||||
/>
|
||||
<ZoomToolbar ui={graphUi} />
|
||||
</ReactFlow>
|
||||
{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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -64,11 +64,7 @@ export function NpcRelationModal({ open, initialLabel = '', onClose, onSave }: N
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.relationLabel')}</div>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={setLabel}
|
||||
placeholder={t('npcs.relationLabelPlaceholder')}
|
||||
/>
|
||||
<Input value={label} onChange={setLabel} placeholder={t('npcs.relationLabelPlaceholder')} />
|
||||
{trimmed.length < 1 ? (
|
||||
<div className={editorStyles.fieldError}>{t('npcs.relationLabelRequired')}</div>
|
||||
) : null}
|
||||
|
||||
@@ -158,3 +158,66 @@
|
||||
color: var(--text2);
|
||||
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
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { NpcId, ProjectNpc } from '../../shared/types';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
|
||||
import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||
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({
|
||||
npc,
|
||||
selected,
|
||||
active,
|
||||
accentColor,
|
||||
onActivate,
|
||||
}: {
|
||||
npc: ProjectNpc;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
accentColor?: string | null;
|
||||
onActivate: () => void;
|
||||
}) {
|
||||
const url = useAssetUrl(npc.avatarAssetId);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.tile,
|
||||
selected ? styles.tileSelected : '',
|
||||
active ? styles.tileActive : '',
|
||||
]
|
||||
className={[styles.tile, selected ? styles.tileSelected : '', active ? styles.tileActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
|
||||
onClick={onActivate}
|
||||
>
|
||||
<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() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
@@ -76,6 +151,7 @@ export function NpcsApp() {
|
||||
const [overlay, overlayApi] = useNpcsOverlayState();
|
||||
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
||||
@@ -106,18 +182,53 @@ export function NpcsApp() {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
|
||||
|
||||
const npcs = session?.project?.npcs ?? [];
|
||||
const relations = session?.project?.npcRelations ?? [];
|
||||
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
|
||||
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
|
||||
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
|
||||
const activeId = overlay?.activeNpcId ?? 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();
|
||||
if (!q) return npcs;
|
||||
return npcs.filter((n) => n.name.toLowerCase().includes(q));
|
||||
}, [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(() => {
|
||||
if (!selected) return [];
|
||||
return relations
|
||||
@@ -202,10 +313,7 @@ export function NpcsApp() {
|
||||
{safeHtml ? (
|
||||
<div>
|
||||
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
|
||||
<div
|
||||
className={styles.detailDesc}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
||||
@@ -231,17 +339,51 @@ export function NpcsApp() {
|
||||
<div className={styles.listCol}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
|
||||
<div className={styles.list}>
|
||||
{filtered.map((n) => (
|
||||
{npcGroups.length === 0 ? (
|
||||
filteredNpcs.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
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}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,27 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -272,3 +293,100 @@
|
||||
.menuItemDanger:hover {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
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 { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
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 { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { NpcBindingFields } from './NpcBindingFields';
|
||||
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||
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 styles from './NpcsEditorApp.module.css';
|
||||
|
||||
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({
|
||||
npc,
|
||||
selected,
|
||||
dragging,
|
||||
dropPlace,
|
||||
accentColor,
|
||||
onSelect,
|
||||
onMenu,
|
||||
onDragStart,
|
||||
@@ -34,6 +57,7 @@ function NpcTile({
|
||||
selected: boolean;
|
||||
dragging: boolean;
|
||||
dropPlace: 'before' | 'after' | null;
|
||||
accentColor?: string | null;
|
||||
onSelect: () => void;
|
||||
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDragStart: () => void;
|
||||
@@ -54,6 +78,7 @@ function NpcTile({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
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() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
@@ -101,9 +313,20 @@ export function NpcsEditorApp() {
|
||||
const [editInitial, setEditInitial] = useState<ProjectNpc | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<ProjectNpc | 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 [dragId, setDragId] = useState<NpcId | 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 [relationModal, setRelationModal] = useState<
|
||||
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
|
||||
@@ -118,14 +341,18 @@ export function NpcsEditorApp() {
|
||||
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
|
||||
const list = project?.npcs ?? [];
|
||||
setSelectedId(list[0]?.id ?? null);
|
||||
const groups = project?.npcGroups ?? [];
|
||||
setExpandedGroups(new Set(groups.map((g) => g.id)));
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const npcs = session?.project?.npcs ?? [];
|
||||
const relations = session?.project?.npcRelations ?? [];
|
||||
const project = session?.project ?? null;
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,23 +365,33 @@ export function NpcsEditorApp() {
|
||||
}, [npcs, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuFor) return;
|
||||
if (!menuFor && !groupMenuFor) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const tgt = e.target as HTMLElement | null;
|
||||
if (tgt?.closest('[data-npc-menu-root="1"]')) return;
|
||||
setMenuFor(null);
|
||||
setGroupMenuFor(null);
|
||||
setMenuPos(null);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [menuFor]);
|
||||
}, [groupMenuFor, menuFor]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredNpcs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return npcs;
|
||||
return npcs.filter((n) => n.name.toLowerCase().includes(q));
|
||||
}, [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 relationsForSelected = useMemo(() => {
|
||||
@@ -173,6 +410,83 @@ export function NpcsEditorApp() {
|
||||
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
|
||||
}, [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(
|
||||
() => ({
|
||||
zoomBar: t('npcs.graphZoomBar'),
|
||||
@@ -182,10 +496,28 @@ export function NpcsEditorApp() {
|
||||
editRelation: t('npcs.relationEdit'),
|
||||
deleteRelation: t('npcs.relationDelete'),
|
||||
untitled: t('npcs.untitled'),
|
||||
graphFilter: t('npcs.graphFilter'),
|
||||
graphFilterAll: t('npcs.graphFilterAll'),
|
||||
graphFilterUngrouped: t('npcs.graphFilterUngrouped'),
|
||||
}),
|
||||
[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 (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.topBar}>
|
||||
@@ -202,6 +534,7 @@ export function NpcsEditorApp() {
|
||||
<div className={styles.body}>
|
||||
<div className={[styles.col, styles.side].join(' ')}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
|
||||
<div className={styles.sideActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
@@ -211,8 +544,93 @@ export function NpcsEditorApp() {
|
||||
>
|
||||
{t('npcs.add')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setGroupModal({ mode: 'create', parentId: null });
|
||||
}}
|
||||
>
|
||||
{t('npcs.addGroup')}
|
||||
</Button>
|
||||
</div>
|
||||
<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
|
||||
key={n.id}
|
||||
npc={n}
|
||||
@@ -221,21 +639,15 @@ export function NpcsEditorApp() {
|
||||
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
|
||||
onSelect={() => setSelectedId(n.id)}
|
||||
onMenu={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const menuW = 180;
|
||||
const menuH = 88;
|
||||
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
|
||||
const top =
|
||||
r.bottom + 8 + menuH > window.innerHeight - 8
|
||||
? Math.max(8, r.top - menuH - 8)
|
||||
: r.bottom + 8;
|
||||
setMenuPos({ left, top });
|
||||
openMenuAt(e);
|
||||
setGroupMenuFor(null);
|
||||
setMenuFor((cur) => (cur === n.id ? null : n.id));
|
||||
}}
|
||||
onDragStart={() => setDragId(n.id)}
|
||||
onDragEnd={() => {
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
setDropTargetGroup(null);
|
||||
}}
|
||||
onDragOver={(place) => {
|
||||
if (!dragId || dragId === n.id) {
|
||||
@@ -244,24 +656,16 @@ export function NpcsEditorApp() {
|
||||
}
|
||||
setDropPlace({ id: n.id, place });
|
||||
}}
|
||||
onDropReorder={() => {
|
||||
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 });
|
||||
}}
|
||||
onDropReorder={() => handleNpcDropOnTile(n.id)}
|
||||
/>
|
||||
))}
|
||||
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.empty')}</div> : null}
|
||||
{npcs.length > 0 && filtered.length === 0 ? (
|
||||
</div>
|
||||
</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>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -271,7 +675,10 @@ export function NpcsEditorApp() {
|
||||
<NpcGraph
|
||||
npcs={npcs}
|
||||
relations={relations}
|
||||
npcGroups={npcGroups}
|
||||
selectedNpcId={selectedId}
|
||||
graphFilter={graphFilter}
|
||||
onGraphFilterChange={setGraphFilter}
|
||||
graphUi={graphUi}
|
||||
onSelect={setSelectedId}
|
||||
onConnectRequest={(sourceNpcId, targetNpcId) => {
|
||||
@@ -295,7 +702,7 @@ export function NpcsEditorApp() {
|
||||
|
||||
<div className={[styles.col, styles.inspector].join(' ')}>
|
||||
<div className={styles.inspectorScroll}>
|
||||
{selected ? (
|
||||
{selected && project ? (
|
||||
<>
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
|
||||
@@ -352,6 +759,28 @@ export function NpcsEditorApp() {
|
||||
/>
|
||||
</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 className={styles.fieldLabel}>{t('npcs.description')}</div>
|
||||
<NpcDescriptionField
|
||||
@@ -366,6 +795,20 @@ export function NpcsEditorApp() {
|
||||
/>
|
||||
</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 ? (
|
||||
<div>
|
||||
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
|
||||
@@ -390,6 +833,8 @@ export function NpcsEditorApp() {
|
||||
open={editOpen}
|
||||
initial={editInitial}
|
||||
existingNames={npcs.map((n) => n.name)}
|
||||
project={project}
|
||||
npcGroups={npcGroups}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onPickImage={pickAvatar}
|
||||
onSave={async (input) => {
|
||||
@@ -397,12 +842,43 @@ export function NpcsEditorApp() {
|
||||
...(editInitial ? { npcId: editInitial.id } : {}),
|
||||
name: input.name,
|
||||
...(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());
|
||||
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
|
||||
open={Boolean(relationModal)}
|
||||
initialLabel={relationModal?.mode === 'edit' ? relationModal.label : ''}
|
||||
@@ -431,13 +907,14 @@ export function NpcsEditorApp() {
|
||||
{menuFor && menuPos
|
||||
? createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
className={styles.menu}
|
||||
style={{ left: menuPos.left, top: menuPos.top }}
|
||||
data-npc-menu-root="1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.menuItemDanger}
|
||||
onClick={() => {
|
||||
const npc = npcs.find((n) => n.id === menuFor);
|
||||
@@ -452,6 +929,54 @@ export function NpcsEditorApp() {
|
||||
)
|
||||
: 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
|
||||
? createPortal(
|
||||
<>
|
||||
@@ -492,6 +1017,46 @@ export function NpcsEditorApp() {
|
||||
)
|
||||
: 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
|
||||
? createPortal(
|
||||
<>
|
||||
@@ -512,9 +1077,7 @@ export function NpcsEditorApp() {
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}
|
||||
</div>
|
||||
<div>{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}</div>
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeleteRelation(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -85,6 +85,15 @@
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
background-color: var(--color-overlay-dark-3);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -44,11 +44,23 @@ a {
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
select {
|
||||
font: 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 {
|
||||
background: var(--selection-bg);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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',
|
||||
createdWithAppVersion: '1',
|
||||
appVersion: '1',
|
||||
schemaVersion: 8,
|
||||
schemaVersion: 9,
|
||||
},
|
||||
scenes: {},
|
||||
sceneListOrder: [],
|
||||
@@ -69,6 +69,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
npcs: [],
|
||||
npcGroups: [],
|
||||
npcRelations: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
import {
|
||||
getConnectedComponent,
|
||||
getSideStoryComponentNodeIds,
|
||||
listSideStoryStarts,
|
||||
} from './sceneGraphLineage';
|
||||
import { reconcileSceneListOrder } from './sceneListOrder';
|
||||
import { noneBinding } from '../npcs/npcBinding';
|
||||
import type {
|
||||
ExportedStorylineRef,
|
||||
GraphNodeId,
|
||||
NpcBinding,
|
||||
Project,
|
||||
ProjectNpc,
|
||||
ProjectNpcGroup,
|
||||
Scene,
|
||||
SceneGraphEdge,
|
||||
SceneGraphNode,
|
||||
SceneId,
|
||||
} from '../types';
|
||||
import type { AssetId, ProjectId } from '../types/ids';
|
||||
import type { AssetId, NpcGroupId, ProjectId } from '../types/ids';
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
asMaterialId,
|
||||
asNpcGroupId,
|
||||
asNpcId,
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
asSceneId,
|
||||
} from '../types/ids';
|
||||
|
||||
import {
|
||||
getConnectedComponent,
|
||||
getSideStoryComponentNodeIds,
|
||||
listSideStoryStarts,
|
||||
} from './sceneGraphLineage';
|
||||
import { reconcileSceneListOrder } from './sceneListOrder';
|
||||
|
||||
export type StorylineKind = 'main' | 'side';
|
||||
|
||||
/** Выбор сюжетной линии для экспорта/импорта. */
|
||||
export type StorylineSelection =
|
||||
| { kind: 'main' }
|
||||
| { kind: 'side'; startGraphNodeId: GraphNodeId };
|
||||
export type StorylineSelection = { kind: 'main' } | { kind: 'side'; startGraphNodeId: GraphNodeId };
|
||||
|
||||
export type StorylineListItem = {
|
||||
selection: StorylineSelection;
|
||||
@@ -48,6 +52,16 @@ export type SceneTitleConflict = {
|
||||
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 = {
|
||||
storylinesImported: number;
|
||||
scenesCreated: number;
|
||||
@@ -56,6 +70,8 @@ export type StorylineImportMergeReport = {
|
||||
edgesAdded: number;
|
||||
assetsCopied: number;
|
||||
assetsReused: number;
|
||||
npcsCreated: number;
|
||||
npcsReused: number;
|
||||
renamedSideTitles: string[];
|
||||
};
|
||||
|
||||
@@ -161,10 +177,7 @@ export function listImportableStorylines(
|
||||
});
|
||||
}
|
||||
|
||||
function recomputeOutgoing(
|
||||
nodes: SceneGraphNode[],
|
||||
edges: SceneGraphEdge[],
|
||||
): Map<SceneId, Set<SceneId>> {
|
||||
function recomputeOutgoing(nodes: SceneGraphNode[], edges: SceneGraphEdge[]): Map<SceneId, Set<SceneId>> {
|
||||
const gnMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
const outgoing = new Map<SceneId, Set<SceneId>>();
|
||||
for (const e of edges) {
|
||||
@@ -181,7 +194,10 @@ function recomputeOutgoing(
|
||||
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 };
|
||||
for (const sid of Object.keys(next) as SceneId[]) {
|
||||
const prev = next[sid];
|
||||
@@ -204,16 +220,11 @@ function selectionToExportedRef(
|
||||
return {
|
||||
kind: 'side',
|
||||
startGraphNodeId: selection.startGraphNodeId,
|
||||
label: start
|
||||
? sideStoryDisplayLabel(start, project.scenes, labels.untitled)
|
||||
: labels.untitled,
|
||||
label: start ? sideStoryDisplayLabel(start, project.scenes, labels.untitled) : labels.untitled,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectSceneIdsForSelections(
|
||||
project: Project,
|
||||
selections: StorylineSelection[],
|
||||
): SceneId[] {
|
||||
export function collectSceneIdsForSelections(project: Project, selections: StorylineSelection[]): SceneId[] {
|
||||
const nodeIds = collectSelectionsGraphNodeIds(project, selections);
|
||||
const sceneIds = new Set<SceneId>();
|
||||
for (const gn of project.sceneGraphNodes) {
|
||||
@@ -234,9 +245,7 @@ export function buildPartialExportProject(
|
||||
const nodeIds = collectSelectionsGraphNodeIds(source, selections);
|
||||
const sceneIds = new Set(collectSceneIdsForSelections(source, selections));
|
||||
|
||||
const sceneGraphNodes = source.sceneGraphNodes
|
||||
.filter((n) => nodeIds.has(n.id))
|
||||
.map((n) => ({ ...n }));
|
||||
const sceneGraphNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id)).map((n) => ({ ...n }));
|
||||
|
||||
const sceneGraphEdges = source.sceneGraphEdges.filter(
|
||||
(e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId),
|
||||
@@ -268,6 +277,21 @@ export function buildPartialExportProject(
|
||||
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
|
||||
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 assets: Project['assets'] = {} as Project['assets'];
|
||||
for (const id of assetIds) {
|
||||
@@ -282,13 +306,88 @@ export function buildPartialExportProject(
|
||||
assets,
|
||||
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
|
||||
materials: (source.materials ?? []).map((m) => ({ ...m })),
|
||||
npcs: (source.npcs ?? []).map((n) => ({ ...n })),
|
||||
npcRelations: (source.npcRelations ?? []).map((r) => ({ ...r })),
|
||||
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? 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> {
|
||||
const refs = new Set<AssetId>();
|
||||
for (const sc of Object.values(p.scenes)) {
|
||||
@@ -368,9 +467,14 @@ export function mergeStorylinesIntoProject(
|
||||
source: Project,
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
opts: { graphOffsetX: number },
|
||||
): { project: Project; report: StorylineImportMergeReport; assetCopies: { fromId: AssetId; toId: AssetId }[] } {
|
||||
opts: { graphOffsetX: number; npcResolutions?: NpcImportResolution[] },
|
||||
): {
|
||||
project: Project;
|
||||
report: StorylineImportMergeReport;
|
||||
assetCopies: { fromId: AssetId; toId: AssetId }[];
|
||||
} {
|
||||
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 sourceSceneIds = collectSceneIdsForSelections(source, selections);
|
||||
|
||||
@@ -387,7 +491,13 @@ export function mergeStorylinesIntoProject(
|
||||
}
|
||||
for (const au of source.campaignAudios) neededAssetIds.add(au.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 targetSha = new Map<string, AssetId>();
|
||||
@@ -521,17 +631,57 @@ export function mergeStorylinesIntoProject(
|
||||
materialAssetIds.add(mapped);
|
||||
}
|
||||
|
||||
const npcs = [...(target.npcs ?? [])];
|
||||
const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
|
||||
const npcAssetIds = new Set(npcs.map((n) => n.avatarAssetId));
|
||||
const npcIdMap = new Map<string, string>();
|
||||
for (const n of source.npcs ?? []) {
|
||||
const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
|
||||
const existingByAsset = npcs.find((x) => x.avatarAssetId === mappedAvatar);
|
||||
if (existingByAsset || npcAssetIds.has(mappedAvatar)) {
|
||||
if (existingByAsset) npcIdMap.set(n.id, existingByAsset.id);
|
||||
// Группы только для создаваемых НПС выбранных линий.
|
||||
const npcsToCreate = exportedNpcs.filter((n) => npcResolutionBySource.get(n.id)?.mode !== 'use');
|
||||
const neededGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], npcsToCreate);
|
||||
const npcGroups = [...(target.npcGroups ?? [])];
|
||||
const groupIdMap = new Map<string, string>();
|
||||
const orderedSourceGroups = topologicalNpcGroups(source.npcGroups ?? []).filter((g) =>
|
||||
neededGroupIds.has(g.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;
|
||||
}
|
||||
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();
|
||||
const baseKey = name.toLowerCase();
|
||||
if (npcNameKeys.has(baseKey)) {
|
||||
@@ -541,6 +691,10 @@ export function mergeStorylinesIntoProject(
|
||||
}
|
||||
const newId = asNpcId(`npc_${generateId()}`);
|
||||
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({
|
||||
id: newId,
|
||||
name,
|
||||
@@ -548,21 +702,25 @@ export function mergeStorylinesIntoProject(
|
||||
description: n.description ?? '',
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
|
||||
binding,
|
||||
});
|
||||
npcNameKeys.add(name.toLowerCase());
|
||||
npcAssetIds.add(mappedAvatar);
|
||||
npcsCreated += 1;
|
||||
}
|
||||
|
||||
const npcRelations = [...(target.npcRelations ?? [])];
|
||||
const exportedNpcIdSet = new Set(exportedNpcs.map((n) => n.id));
|
||||
for (const r of source.npcRelations ?? []) {
|
||||
const sourceId = npcIdMap.get(r.sourceNpcId) ?? r.sourceNpcId;
|
||||
const targetId = npcIdMap.get(r.targetNpcId) ?? r.targetNpcId;
|
||||
if (!exportedNpcIdSet.has(r.sourceNpcId) || !exportedNpcIdSet.has(r.targetNpcId)) continue;
|
||||
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;
|
||||
const label = r.label.trim();
|
||||
if (!label) continue;
|
||||
const dup = npcRelations.some(
|
||||
(x) =>
|
||||
x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
|
||||
(x) => x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
|
||||
);
|
||||
if (dup) continue;
|
||||
npcRelations.push({
|
||||
@@ -580,6 +738,7 @@ export function mergeStorylinesIntoProject(
|
||||
campaignAudios,
|
||||
materials,
|
||||
npcs,
|
||||
npcGroups,
|
||||
npcRelations,
|
||||
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
|
||||
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
|
||||
@@ -602,6 +761,8 @@ export function mergeStorylinesIntoProject(
|
||||
edgesAdded: newEdges.length,
|
||||
assetsCopied,
|
||||
assetsReused,
|
||||
npcsCreated,
|
||||
npcsReused,
|
||||
renamedSideTitles,
|
||||
},
|
||||
assetCopies,
|
||||
@@ -613,6 +774,41 @@ export function computeGraphImportOffsetX(target: Project, padding = 120): numbe
|
||||
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 {
|
||||
return asProjectId(`p_${generateId()}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
MediaAsset,
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
NpcGroupId,
|
||||
NpcBinding,
|
||||
NpcsOverlayEvent,
|
||||
NpcsOverlayState,
|
||||
Project,
|
||||
@@ -22,6 +24,7 @@ import type {
|
||||
VideoPlaybackState,
|
||||
} from '../types';
|
||||
import type {
|
||||
NpcImportResolution,
|
||||
SceneImportResolution,
|
||||
StorylineImportMergeReport,
|
||||
StorylineLabels,
|
||||
@@ -66,6 +69,9 @@ export const ipcChannels = {
|
||||
pickNpcAvatar: 'project.pickNpcAvatar',
|
||||
upsertNpcRelation: 'project.upsertNpcRelation',
|
||||
deleteNpcRelation: 'project.deleteNpcRelation',
|
||||
upsertNpcGroup: 'project.upsertNpcGroup',
|
||||
deleteNpcGroup: 'project.deleteNpcGroup',
|
||||
setNpcGroupsOrder: 'project.setNpcGroupsOrder',
|
||||
importScenePreview: 'project.importScenePreview',
|
||||
clearScenePreview: 'project.clearScenePreview',
|
||||
assetFileUrl: 'project.assetFileUrl',
|
||||
@@ -90,6 +96,8 @@ export const ipcChannels = {
|
||||
mergeImportZip: 'project.mergeImportZip',
|
||||
mergeImportFromProject: 'project.mergeImportFromProject',
|
||||
importZipFromPath: 'project.importZipFromPath',
|
||||
importFoundry: 'project.importFoundry',
|
||||
pickFoundrySource: 'project.pickFoundrySource',
|
||||
deleteProject: 'project.deleteProject',
|
||||
importZipProgress: 'project.importZipProgress',
|
||||
exportZipProgress: 'project.exportZipProgress',
|
||||
@@ -297,11 +305,24 @@ export type IpcInvokeMap = {
|
||||
| { canceled: false; filePath: string; previewDataUrl: string };
|
||||
};
|
||||
[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 };
|
||||
};
|
||||
[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 };
|
||||
};
|
||||
[ipcChannels.project.updateNpcPosition]: {
|
||||
@@ -335,6 +356,23 @@ export type IpcInvokeMap = {
|
||||
req: { relationId: NpcRelationId };
|
||||
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]: {
|
||||
req: { sceneId: SceneId; filePath?: string };
|
||||
res: { project: Project; assetId: AssetId | null; background: boolean };
|
||||
@@ -438,6 +476,7 @@ export type IpcInvokeMap = {
|
||||
filePath: string;
|
||||
storylineSelections: StorylineSelection[];
|
||||
sceneResolutions: SceneImportResolution[];
|
||||
npcResolutions?: NpcImportResolution[];
|
||||
};
|
||||
res: { project: Project; report: StorylineImportMergeReport };
|
||||
};
|
||||
@@ -446,6 +485,7 @@ export type IpcInvokeMap = {
|
||||
sourceProjectId: ProjectId;
|
||||
storylineSelections: StorylineSelection[];
|
||||
sceneResolutions: SceneImportResolution[];
|
||||
npcResolutions?: NpcImportResolution[];
|
||||
};
|
||||
res: { project: Project; report: StorylineImportMergeReport };
|
||||
};
|
||||
@@ -453,6 +493,14 @@ export type IpcInvokeMap = {
|
||||
req: { filePath: string };
|
||||
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]: {
|
||||
req: { projectId: ProjectId; storylineSelections: StorylineSelection[]; labels: StorylineLabels };
|
||||
res: { canceled: true } | { canceled: false };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 = {
|
||||
@@ -10,6 +19,26 @@ export type ProjectMaterial = {
|
||||
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 = {
|
||||
id: NpcId;
|
||||
@@ -20,6 +49,9 @@ export type ProjectNpc = {
|
||||
/** Позиция карточки на графе взаимосвязей. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** `null` — системная секция «Без группы». */
|
||||
groupId: NpcGroupId | null;
|
||||
binding: NpcBinding;
|
||||
};
|
||||
|
||||
/** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */
|
||||
@@ -167,6 +199,8 @@ export type Project = {
|
||||
materials: ProjectMaterial[];
|
||||
/** НПС кампании (порядок = порядок в списке редактора/пульта). */
|
||||
npcs: ProjectNpc[];
|
||||
/** Группы НПС (порядок среди siblings = порядок в массиве). */
|
||||
npcGroups: ProjectNpcGroup[];
|
||||
/** Связи между НПС (однонаправленные; между одной парой направлений может быть несколько). */
|
||||
npcRelations: ProjectNpcRelation[];
|
||||
currentSceneId: SceneId | null;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type GraphNodeId = Brand<string, 'GraphNodeId'>;
|
||||
export type MaterialId = Brand<string, 'MaterialId'>;
|
||||
export type NpcId = Brand<string, 'NpcId'>;
|
||||
export type NpcRelationId = Brand<string, 'NpcRelationId'>;
|
||||
export type NpcGroupId = Brand<string, 'NpcGroupId'>;
|
||||
|
||||
export function asProjectId(value: string): ProjectId {
|
||||
return value as ProjectId;
|
||||
@@ -35,3 +36,7 @@ export function asNpcId(value: string): NpcId {
|
||||
export function asNpcRelationId(value: string): NpcRelationId {
|
||||
return value as NpcRelationId;
|
||||
}
|
||||
|
||||
export function asNpcGroupId(value: string): NpcGroupId {
|
||||
return value as NpcGroupId;
|
||||
}
|
||||
|
||||
Generated
+162
-2
@@ -14,6 +14,7 @@
|
||||
"@tiptap/extension-placeholder": "^3.27.4",
|
||||
"@tiptap/react": "^3.27.4",
|
||||
"@tiptap/starter-kit": "^3.27.4",
|
||||
"classic-level": "^3.0.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"pixi.js": "^8.18.1",
|
||||
@@ -4665,6 +4666,70 @@
|
||||
"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": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -5356,7 +5421,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5973,6 +6037,22 @@
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz",
|
||||
@@ -9260,7 +9340,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -10467,6 +10546,52 @@
|
||||
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
|
||||
"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": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -10935,6 +11060,15 @@
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
|
||||
@@ -11246,6 +11380,15 @@
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"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_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": {
|
||||
"version": "0.3.4",
|
||||
"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_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": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
||||
+10
-2
@@ -10,7 +10,7 @@
|
||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"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:write": "prettier . --write",
|
||||
"postinstall": "patch-package",
|
||||
@@ -34,6 +34,7 @@
|
||||
"@tiptap/extension-placeholder": "^3.27.4",
|
||||
"@tiptap/react": "^3.27.4",
|
||||
"@tiptap/starter-kit": "^3.27.4",
|
||||
"classic-level": "^3.0.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"pixi.js": "^8.18.1",
|
||||
@@ -101,7 +102,14 @@
|
||||
"dist/renderer/app-window-icon.png",
|
||||
"node_modules/sharp/**",
|
||||
"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": {
|
||||
"appimage": "1.0.2"
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ async function buildNodeTargets() {
|
||||
bundle: true,
|
||||
minify: isProd,
|
||||
sourcemap: !isProd,
|
||||
external: ['electron', 'electron-updater', 'sharp', 'ffmpeg-static'],
|
||||
external: ['electron', 'electron-updater', 'sharp', 'ffmpeg-static', 'classic-level'],
|
||||
define,
|
||||
drop: isProd ? ['console', 'debugger'] : [],
|
||||
};
|
||||
|
||||
+4
-3
@@ -12,7 +12,8 @@ const electronEnv = {
|
||||
...process.env,
|
||||
NODE_ENV: 'development',
|
||||
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 = {}) {
|
||||
@@ -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();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
@@ -134,7 +135,7 @@ async function watchMainAndPreload() {
|
||||
format: 'cjs',
|
||||
bundle: true,
|
||||
sourcemap: true,
|
||||
external: ['electron'],
|
||||
external: ['electron', 'electron-updater', 'sharp', 'ffmpeg-static', 'classic-level'],
|
||||
define: { 'process.env.NODE_ENV': JSON.stringify('development') },
|
||||
plugins: [restartPlugin],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user