feat(editor): побочные сюжетные линии и импорт/экспорт линий

Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на
пульте: визуальное выделение компонент графа, запрет недопустимых связей
между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий.

Реализован partial export/import сюжетных линий:
- экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json;
- импорт в открытый проект с выбором линий, разрешением конфликтов названий
  сцен (одна модалка на операцию) и отчётом о результате;
- новое окно источника импорта: «Из проекта» (dropdown, без текущего) или
  «Из файла»; на главном экране — только полный импорт из файла.

Исправлены гонки при открытии/закрытии проекта (сериализация open/close в
main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и
старт Electron в dev после готовности Vite.

Добавлены unit-тесты для lineage, export/import и контрактов zipStore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-09 14:47:28 +08:00
parent de9190959c
commit c8ab9dd567
26 changed files with 3507 additions and 266 deletions
+96 -29
View File
@@ -2,11 +2,19 @@ import { context } from 'esbuild';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import http from 'node:http';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, '..');
const electronEnv = {
...process.env,
NODE_ENV: 'development',
DND_SKIP_LICENSE: process.env.DND_SKIP_LICENSE ?? '1',
VITE_DEV_SERVER_URL: 'http://localhost:5173/',
};
function spawnShell(command, opts = {}) {
const child = spawn(command, {
cwd: root,
@@ -41,7 +49,83 @@ function killTree(child) {
}
}
function waitForVite(url = 'http://127.0.0.1:5173/editor.html', timeoutMs = 60000) {
const started = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
const req = http.get(url, (res) => {
res.resume();
resolve();
});
req.on('error', () => {
if (Date.now() - started > timeoutMs) {
reject(new Error(`Timed out waiting for Vite at ${url}`));
return;
}
setTimeout(tick, 300);
});
req.setTimeout(2000, () => {
req.destroy();
});
};
tick();
});
}
let shuttingDown = false;
let electron = null;
let vite = null;
let dispose = async () => {};
let restartTimer = null;
let electronStarted = false;
let restartingElectron = false;
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
if (restartTimer) clearTimeout(restartTimer);
killTree(vite);
killTree(electron);
await dispose();
process.exit(0);
}
function startElectron() {
if (shuttingDown) return;
restartingElectron = false;
if (electron) killTree(electron);
electron = spawnShell('npx electron .', { env: electronEnv });
electron.once('exit', () => {
if (restartingElectron || shuttingDown) return;
void shutdown();
});
electronStarted = true;
}
function scheduleElectronRestart() {
if (shuttingDown || !electronStarted) return;
if (restartTimer) clearTimeout(restartTimer);
restartTimer = setTimeout(() => {
restartTimer = null;
console.log('[dev] main/preload rebuilt — restarting Electron…');
restartingElectron = true;
startElectron();
}, 300);
}
function createRestartPlugin() {
return {
name: 'restart-electron-on-rebuild',
setup(build) {
build.onEnd((result) => {
if (result.errors.length === 0) scheduleElectronRestart();
});
},
};
}
async function watchMainAndPreload() {
const restartPlugin = createRestartPlugin();
const main = await context({
entryPoints: [path.join(root, 'app/main/index.ts')],
outfile: path.join(root, 'dist/main/index.cjs'),
@@ -52,6 +136,7 @@ async function watchMainAndPreload() {
sourcemap: true,
external: ['electron'],
define: { 'process.env.NODE_ENV': JSON.stringify('development') },
plugins: [restartPlugin],
});
await main.rebuild();
await main.watch();
@@ -66,6 +151,7 @@ async function watchMainAndPreload() {
sourcemap: true,
external: ['electron'],
define: { 'process.env.NODE_ENV': JSON.stringify('development') },
plugins: [restartPlugin],
});
await preload.rebuild();
await preload.watch();
@@ -75,42 +161,23 @@ async function watchMainAndPreload() {
};
}
const dispose = await watchMainAndPreload();
const vite = spawnShell('npx vite dev --strictPort', {
env: {
...process.env,
NODE_ENV: 'development',
DND_SKIP_LICENSE: process.env.DND_SKIP_LICENSE ?? '1',
VITE_DEV_SERVER_URL: 'http://localhost:5173/',
},
});
const electron = spawnShell('npx electron .', {
env: {
...process.env,
NODE_ENV: 'development',
DND_SKIP_LICENSE: process.env.DND_SKIP_LICENSE ?? '1',
VITE_DEV_SERVER_URL: 'http://localhost:5173/',
},
dispose = await watchMainAndPreload();
vite = spawnShell('npx vite dev --strictPort', {
env: electronEnv,
});
let shuttingDown = false;
try {
await waitForVite();
} catch (err) {
console.error('[dev] Vite did not start in time:', err);
}
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
killTree(vite);
killTree(electron);
await dispose();
process.exit(0);
};
startElectron();
process.on('SIGINT', () => void shutdown());
process.on('SIGTERM', () => void shutdown());
electron.once('exit', () => {
void shutdown();
});
vite.once('exit', (code) => {
if (code !== 0 && code !== null && !shuttingDown) {
void shutdown();