2 Commits

Author SHA1 Message Date
Ivan Fontosh 04c75cd725 fix(npcs): stop TipTap crash black screen in secondary windows
Guard TipTap getHTML under StrictMode, wrap secondary window roots in WindowErrorBoundary, and add stability regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 09:10:47 +08:00
Ivan Fontosh e687303c57 fix(npcs): faster editor open, save progress, black screen crash
Warm and show the NPC window sooner, lazy-load ReactFlow/TipTap, overlay save progress like materials, and fix the undefined controlStyles crash after creating an NPC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 08:50:05 +08:00
24 changed files with 524 additions and 142 deletions
+19 -2
View File
@@ -63,6 +63,7 @@ import {
sendToAppWindows, sendToAppWindows,
togglePresentationFullscreen, togglePresentationFullscreen,
waitForEditorWindowReady, waitForEditorWindowReady,
warmNpcsEditorWindow,
} from './windows/createWindows'; } from './windows/createWindows';
function emitZipProgress(evt: { function emitZipProgress(evt: {
@@ -95,6 +96,16 @@ function emitMaterialUpsertProgress(evt: {
} }
} }
function emitNpcUpsertProgress(evt: {
percent: number;
stage: string;
detail?: string;
}): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.project.npcUpsertProgress, evt);
}
}
/** /**
* Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем. * Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`. * При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
@@ -508,6 +519,7 @@ async function main() {
registerHandler(ipcChannels.project.create, async ({ name }) => { registerHandler(ipcChannels.project.create, async ({ name }) => {
const project = await projectStore.createProject(name); const project = await projectStore.createProject(name);
emitSessionState(); emitSessionState();
warmNpcsEditorWindow();
return { project }; return { project };
}); });
registerHandler(ipcChannels.project.open, async ({ projectId }) => { registerHandler(ipcChannels.project.open, async ({ projectId }) => {
@@ -517,9 +529,11 @@ async function main() {
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState(); emitSceneTokensSessionState();
emitSessionState(); emitSessionState();
warmNpcsEditorWindow();
return { project }; return { project };
}); });
registerHandler(ipcChannels.project.close, async () => { registerHandler(ipcChannels.project.close, async () => {
closeNpcsEditorWindow();
await projectStore.closeOpenProject(); await projectStore.closeOpenProject();
effectsStore.clear(); effectsStore.clear();
materialsOverlayStore.clear(); materialsOverlayStore.clear();
@@ -760,14 +774,17 @@ async function main() {
} }
filePath = filePaths[0]; filePath = filePaths[0];
} }
const project = await projectStore.upsertNpc({ const project = await projectStore.upsertNpc(
{
...(npcId ? { npcId } : {}), ...(npcId ? { npcId } : {}),
name, name,
...(typeof description === 'string' ? { description } : {}), ...(typeof description === 'string' ? { description } : {}),
...(filePath ? { filePath } : {}), ...(filePath ? { filePath } : {}),
...(groupId !== undefined ? { groupId } : {}), ...(groupId !== undefined ? { groupId } : {}),
...(binding !== undefined ? { binding } : {}), ...(binding !== undefined ? { binding } : {}),
}); },
(p) => emitNpcUpsertProgress(p),
);
syncNpcsOverlayWithProject(project); syncNpcsOverlayWithProject(project);
emitNpcsOverlayState(); emitNpcsOverlayState();
emitSessionState(); emitSessionState();
+15 -2
View File
@@ -1302,14 +1302,20 @@ export class ZipProjectStore {
* Создаёт или обновляет НПС. * Создаёт или обновляет НПС.
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар. * При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
*/ */
async upsertNpc(input: { async upsertNpc(
input: {
npcId?: NpcId; npcId?: NpcId;
name: string; name: string;
description?: string; description?: string;
filePath?: string; filePath?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
binding?: NpcBinding; binding?: NpcBinding;
}): Promise<Project> { },
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
): Promise<Project> {
const report = (percent: number, stage: string, detail?: string) => {
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
};
const open = this.openProject; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
const name = input.name.trim(); const name = input.name.trim();
@@ -1321,6 +1327,8 @@ export class ZipProjectStore {
throw new Error('NPC name already exists'); throw new Error('NPC name already exists');
} }
report(2, 'start', 'Подождите…');
let nextAssetId: AssetId | null = null; let nextAssetId: AssetId | null = null;
let stagedAsset: MediaAsset | null = null; let stagedAsset: MediaAsset | null = null;
if (input.filePath) { if (input.filePath) {
@@ -1330,13 +1338,16 @@ export class ZipProjectStore {
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
throw new Error('NPC avatar must be an image (png/jpg/webp)'); throw new Error('NPC avatar must be an image (png/jpg/webp)');
} }
report(8, 'read', 'Чтение изображения…');
let buf = await fs.readFile(input.filePath); let buf = await fs.readFile(input.filePath);
report(18, 'optimize', 'Оптимизация изображения…');
try { try {
const opt = await optimizeImageBufferVisuallyLossless(buf); const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer); if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch { } catch {
// keep original buffer // keep original buffer
} }
report(72, 'write', 'Сохранение файла…');
const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId()); const id = asAssetId(this.randomId());
const orig = path.basename(input.filePath); const orig = path.basename(input.filePath);
@@ -1349,6 +1360,7 @@ export class ZipProjectStore {
nextAssetId = id; nextAssetId = id;
} }
report(88, 'project', 'Обновление проекта…');
await this.updateProject((p) => { await this.updateProject((p) => {
const npcs = [...(p.npcs ?? [])]; const npcs = [...(p.npcs ?? [])];
const assets = { ...p.assets }; const assets = { ...p.assets };
@@ -1393,6 +1405,7 @@ export class ZipProjectStore {
const latest = this.getOpenProject(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project'); if (!latest) throw new Error('No open project');
report(100, 'done', 'Готово');
return latest; return latest;
} }
@@ -57,6 +57,7 @@ void test('createWindows: окно НПС закрывается с multi-window
assert.ok(src.includes('openNpcsWindow')); assert.ok(src.includes('openNpcsWindow'));
assert.ok(src.includes('closeNpcsWindow')); assert.ok(src.includes('closeNpcsWindow'));
assert.ok(src.includes('openNpcsEditorWindow')); assert.ok(src.includes('openNpcsEditorWindow'));
assert.ok(src.includes('warmNpcsEditorWindow'));
assert.ok(src.includes("createWindow('npcs'")); assert.ok(src.includes("createWindow('npcs'"));
assert.ok(src.includes("createWindow('npcsEditor'")); assert.ok(src.includes("createWindow('npcsEditor'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/); assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
+39 -16
View File
@@ -163,7 +163,7 @@ export function applyDockIconIfNeeded(): void {
type CreateWindowOpts = { type CreateWindowOpts = {
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */ /** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
parent?: BrowserWindow; parent?: BrowserWindow;
/** Только редактор: не показывать окно до `show()` (экран загрузки). */ /** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
deferVisibility?: boolean; deferVisibility?: boolean;
}; };
@@ -202,7 +202,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
} }
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow { function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
const deferEditor = kind === 'editor' && opts?.deferVisibility === true; const deferShow = opts?.deferVisibility === true;
const icon = loadBrandingWindowIcon(); const icon = loadBrandingWindowIcon();
const size = windowSizeForKind(kind); const size = windowSizeForKind(kind);
const win = new BrowserWindow({ const win = new BrowserWindow({
@@ -300,7 +300,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
safeConsoleError('[render-process-gone]', details.reason, details.exitCode); safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
}); });
if (!deferEditor) { if (!deferShow) {
ensureWindowBecomesVisible(win); ensureWindowBecomesVisible(win);
} }
loadWindowPage(win, kind); loadWindowPage(win, kind);
@@ -527,19 +527,8 @@ export function openMaterialsWindow(): void {
}); });
} }
/** Редактор НПС: отдельное окно с графом и инспектором. */ function positionNpcsEditorWindow(win: BrowserWindow): void {
export function openNpcsEditorWindow(): void {
const existing = windows.get('npcsEditor');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('editor'); const parent = windows.get('editor');
const win = createWindow('npcsEditor', parent ? { parent } : undefined);
const { width, height } = win.getBounds(); const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds()); const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea; const { x, y, width: dw, height: dh } = display.workArea;
@@ -549,9 +538,43 @@ export function openNpcsEditorWindow(): void {
width, width,
height, height,
}); });
}
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
export function warmNpcsEditorWindow(): void {
const existing = windows.get('npcsEditor');
if (existing && !existing.isDestroyed()) return;
const parent = windows.get('editor');
createWindow('npcsEditor', {
...(parent ? { parent } : {}),
deferVisibility: true,
});
}
/** Редактор НПС: отдельное окно с графом и инспектором. */
export function openNpcsEditorWindow(): void {
const existing = windows.get('npcsEditor');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
positionNpcsEditorWindow(existing);
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('editor');
const win = createWindow('npcsEditor', {
...(parent ? { parent } : {}),
deferVisibility: true,
});
positionNpcsEditorWindow(win);
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
win.show();
win.focus();
win.moveTop();
win.webContents.once('did-finish-load', () => { win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) { if (!win.isDestroyed()) {
win.show();
win.focus(); win.focus();
win.moveTop(); win.moveTop();
} }
+3
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css'; import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { ControlApp } from './ControlApp'; import { ControlApp } from './ControlApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<WindowErrorBoundary title="Пульт">
<EditorI18nProvider> <EditorI18nProvider>
<ControlApp /> <ControlApp />
</EditorI18nProvider> </EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
+45 -10
View File
@@ -73,7 +73,7 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
const editor = useEditor({ const editor = useEditor({
extensions, extensions,
content: initialHtml || '', content: initialHtml || '',
immediatelyRender: true, immediatelyRender: false,
shouldRerenderOnTransaction: true, shouldRerenderOnTransaction: true,
editorProps: { editorProps: {
attributes: { attributes: {
@@ -94,21 +94,56 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
const toolbarState = useEditorState({ const toolbarState = useEditorState({
editor, editor,
selector: ({ editor: ed }) => ({ selector: ({ editor: ed }) => ({
bold: ed.isActive('bold'), bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
italic: ed.isActive('italic'), italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
underline: ed.isActive('underline'), underline: Boolean(ed && !ed.isDestroyed && ed.isActive('underline')),
bulletList: ed.isActive('bulletList'), bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
orderedList: ed.isActive('orderedList'), orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
h2: ed.isActive('heading', { level: 2 }), h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
h3: ed.isActive('heading', { level: 3 }), h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
blockquote: ed.isActive('blockquote'), blockquote: Boolean(ed && !ed.isDestroyed && ed.isActive('blockquote')),
}), }),
}); });
const handleSave = () => { const handleSave = () => {
onSave(normalizeSceneDescriptionHtml(editor.getHTML())); if (!editor || editor.isDestroyed) return;
let raw = '';
try {
raw = editor.getHTML();
} catch {
return;
}
onSave(normalizeSceneDescriptionHtml(raw));
}; };
if (!editor || editor.isDestroyed) {
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={[styles.modalDialog, modalStyles.dialog].join(' ')}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('scene.descriptionModalTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={modalStyles.editorShell} />
</div>
</>,
document.body,
);
}
return createPortal( return createPortal(
<> <>
<button <button
@@ -414,6 +414,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.add': 'Добавить', 'npcs.add': 'Добавить',
'npcs.addTitle': 'Новый НПС', 'npcs.addTitle': 'Новый НПС',
'npcs.editTitle': 'Изменить НПС', 'npcs.editTitle': 'Изменить НПС',
'npcs.savingTitle': 'Сохранение НПС',
'npcs.savingWait': 'Подождите…',
'npcs.savingProgress': 'Прогресс сохранения НПС',
'npcs.graphLoading': 'Загрузка графа…',
'npcs.edit': 'Изменить', 'npcs.edit': 'Изменить',
'npcs.tileMenu': 'Меню НПС', 'npcs.tileMenu': 'Меню НПС',
'npcs.search': 'Поиск НПС…', 'npcs.search': 'Поиск НПС…',
@@ -979,6 +983,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.add': 'Add', 'npcs.add': 'Add',
'npcs.addTitle': 'New NPC', 'npcs.addTitle': 'New NPC',
'npcs.editTitle': 'Edit NPC', 'npcs.editTitle': 'Edit NPC',
'npcs.savingTitle': 'Saving NPC',
'npcs.savingWait': 'Please wait…',
'npcs.savingProgress': 'NPC save progress',
'npcs.graphLoading': 'Loading graph…',
'npcs.edit': 'Edit', 'npcs.edit': 'Edit',
'npcs.tileMenu': 'NPC menu', 'npcs.tileMenu': 'NPC menu',
'npcs.search': 'Search NPCs…', 'npcs.search': 'Search NPCs…',
+3
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css'; import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { MaterialsApp } from './MaterialsApp'; import { MaterialsApp } from './MaterialsApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<WindowErrorBoundary title="Материалы">
<EditorI18nProvider> <EditorI18nProvider>
<MaterialsApp /> <MaterialsApp />
</EditorI18nProvider> </EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
+23 -17
View File
@@ -8,6 +8,7 @@ import modalStyles from '../editor/SceneDescriptionModal.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import styles from './NpcsEditorApp.module.css'; import styles from './NpcsEditorApp.module.css';
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
type NpcDescriptionFieldProps = { type NpcDescriptionFieldProps = {
html: string; html: string;
@@ -59,7 +60,8 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
const editor = useEditor({ const editor = useEditor({
extensions, extensions,
content: html || '', content: html || '',
immediatelyRender: true, // StrictMode + true даёт destroy/recreate с null schema → падение getHTML (чёрный экран окна НПС).
immediatelyRender: false,
shouldRerenderOnTransaction: true, shouldRerenderOnTransaction: true,
editorProps: { editorProps: {
attributes: { attributes: {
@@ -68,13 +70,17 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
}, },
}, },
onBlur: ({ editor: ed }) => { onBlur: ({ editor: ed }) => {
onCommit(normalizeSceneDescriptionHtml(ed.getHTML())); const raw = readTipTapHtmlSafe(ed);
if (raw == null) return;
onCommit(normalizeSceneDescriptionHtml(raw));
}, },
}); });
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor || editor.isDestroyed) return;
const current = normalizeSceneDescriptionHtml(editor.getHTML()); const raw = readTipTapHtmlSafe(editor);
if (raw == null) return;
const current = normalizeSceneDescriptionHtml(raw);
const next = normalizeSceneDescriptionHtml(html); const next = normalizeSceneDescriptionHtml(html);
if (current !== next) { if (current !== next) {
editor.commands.setContent(html || '', { emitUpdate: false }); editor.commands.setContent(html || '', { emitUpdate: false });
@@ -84,30 +90,30 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
const toolbarState = useEditorState({ const toolbarState = useEditorState({
editor, editor,
selector: ({ editor: ed }) => ({ selector: ({ editor: ed }) => ({
bold: ed.isActive('bold'), bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
italic: ed.isActive('italic'), italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
bulletList: ed.isActive('bulletList'), bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
orderedList: ed.isActive('orderedList'), orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
h2: ed.isActive('heading', { level: 2 }), h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
h3: ed.isActive('heading', { level: 3 }), h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
}), }),
}); });
if (!editor) return null; if (!editor || editor.isDestroyed) return null;
return ( return (
<div className={styles.descShell}> <div className={styles.descShell}>
<div className={modalStyles.toolbar}> <div className={modalStyles.toolbar}>
<div className={modalStyles.toolbarGroup}> <div className={modalStyles.toolbarGroup}>
<ToolButton <ToolButton
active={toolbarState.bold} active={toolbarState?.bold ?? false}
title={t('scene.descriptionBold')} title={t('scene.descriptionBold')}
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
> >
B B
</ToolButton> </ToolButton>
<ToolButton <ToolButton
active={toolbarState.italic} active={toolbarState?.italic ?? false}
title={t('scene.descriptionItalic')} title={t('scene.descriptionItalic')}
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
> >
@@ -117,14 +123,14 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
<div className={modalStyles.toolbarSep} /> <div className={modalStyles.toolbarSep} />
<div className={modalStyles.toolbarGroup}> <div className={modalStyles.toolbarGroup}>
<ToolButton <ToolButton
active={toolbarState.h2} active={toolbarState?.h2 ?? false}
title={t('scene.descriptionHeading2')} title={t('scene.descriptionHeading2')}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
> >
H2 H2
</ToolButton> </ToolButton>
<ToolButton <ToolButton
active={toolbarState.h3} active={toolbarState?.h3 ?? false}
title={t('scene.descriptionHeading3')} title={t('scene.descriptionHeading3')}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
> >
@@ -134,14 +140,14 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
<div className={modalStyles.toolbarSep} /> <div className={modalStyles.toolbarSep} />
<div className={modalStyles.toolbarGroup}> <div className={modalStyles.toolbarGroup}>
<ToolButton <ToolButton
active={toolbarState.bulletList} active={toolbarState?.bulletList ?? false}
title={t('scene.descriptionBulletList')} title={t('scene.descriptionBulletList')}
onClick={() => editor.chain().focus().toggleBulletList().run()} onClick={() => editor.chain().focus().toggleBulletList().run()}
> >
</ToolButton> </ToolButton>
<ToolButton <ToolButton
active={toolbarState.orderedList} active={toolbarState?.orderedList ?? false}
title={t('scene.descriptionOrderedList')} title={t('scene.descriptionOrderedList')}
onClick={() => editor.chain().focus().toggleOrderedList().run()} onClick={() => editor.chain().focus().toggleOrderedList().run()}
> >
+57 -6
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal, flushSync } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import { noneBinding } from '../../shared/npcs/npcBinding'; import { noneBinding } from '../../shared/npcs/npcBinding';
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups'; import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types'; import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
@@ -13,6 +14,7 @@ import {
} from '../editor/fileDrop'; } from '../editor/fileDrop';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import matStyles from '../editor/MaterialsModals.module.css'; import matStyles from '../editor/MaterialsModals.module.css';
import { getDndApi } from '../shared/dndApi';
import { Button, Input, Select } from '../shared/ui/controls'; import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -62,12 +64,14 @@ export function NpcEditModal({
onSave, onSave,
}: NpcEditModalProps) { }: NpcEditModalProps) {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const api = getDndApi();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null); const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [groupId, setGroupId] = useState<NpcGroupId | ''>(''); const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
const [binding, setBinding] = useState<NpcBinding>(noneBinding()); const [binding, setBinding] = useState<NpcBinding>(noneBinding());
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null); const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
@@ -84,6 +88,7 @@ export function NpcEditModal({
setGroupId(initial?.groupId ?? ''); setGroupId(initial?.groupId ?? '');
setBinding(initial?.binding ?? noneBinding()); setBinding(initial?.binding ?? noneBinding());
setSaving(false); setSaving(false);
setSaveProgress(null);
setError(null); setError(null);
}, [initial, open]); }, [initial, open]);
@@ -93,14 +98,24 @@ export function NpcEditModal({
}; };
}, [localPreviewUrl]); }, [localPreviewUrl]);
useEffect(() => {
if (!open) return;
return api.on(ipcChannels.project.npcUpsertProgress, (evt) => {
setSaveProgress({
percent: Math.max(0, Math.min(100, Math.round(evt.percent))),
detail: evt.detail?.trim() || t('npcs.savingWait'),
});
});
}, [api, open, t]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape' && !saving) onClose();
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]); }, [onClose, open, saving]);
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => { const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
setFilePath(path); setFilePath(path);
@@ -131,6 +146,8 @@ export function NpcEditModal({
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId); const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
const canSave = nameOk && !nameDup && hasImage && !saving; const canSave = nameOk && !nameDup && hasImage && !saving;
const previewSrc = localPreviewUrl ?? existingUrl; const previewSrc = localPreviewUrl ?? existingUrl;
const progressPercent = saveProgress?.percent ?? (saving ? 0 : 0);
const progressDetail = saveProgress?.detail ?? t('npcs.savingWait');
if (!open) return null; if (!open) return null;
@@ -139,7 +156,9 @@ export function NpcEditModal({
<button <button
type="button" type="button"
aria-label={t('common.close')} aria-label={t('common.close')}
onClick={onClose} onClick={() => {
if (!saving) onClose();
}}
className={editorStyles.modalBackdrop} className={editorStyles.modalBackdrop}
/> />
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}> <div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
@@ -148,8 +167,11 @@ export function NpcEditModal({
<button <button
type="button" type="button"
aria-label={t('common.close')} aria-label={t('common.close')}
onClick={onClose} onClick={() => {
if (!saving) onClose();
}}
className={editorStyles.modalClose} className={editorStyles.modalClose}
disabled={saving}
> >
× ×
</button> </button>
@@ -208,6 +230,7 @@ export function NpcEditModal({
<div className={matStyles.imageDropEmpty}> <div className={matStyles.imageDropEmpty}>
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div> <div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div>
<Button <Button
disabled={saving}
onClick={() => { onClick={() => {
void (async () => { void (async () => {
const picked = await onPickImage(); const picked = await onPickImage();
@@ -222,6 +245,7 @@ export function NpcEditModal({
)} )}
{previewSrc ? ( {previewSrc ? (
<Button <Button
disabled={saving}
onClick={() => { onClick={() => {
void (async () => { void (async () => {
const picked = await onPickImage(); const picked = await onPickImage();
@@ -253,8 +277,11 @@ export function NpcEditModal({
onClick={() => { onClick={() => {
if (!canSave) return; if (!canSave) return;
void (async () => { void (async () => {
flushSync(() => {
setSaving(true); setSaving(true);
setSaveProgress({ percent: 0, detail: t('npcs.savingWait') });
setError(null); setError(null);
});
try { try {
await onSave({ await onSave({
name: trimmed, name: trimmed,
@@ -266,14 +293,38 @@ export function NpcEditModal({
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
} finally { } finally {
setSaving(false); setSaving(false);
setSaveProgress(null);
} }
})(); })();
}} }}
> >
{t('common.save')} {saving ? t('common.saving') : t('common.save')}
</Button> </Button>
</div> </div>
</div> </div>
{saving ? (
<div
className={editorStyles.progressOverlay}
role="dialog"
aria-label={t('npcs.savingProgress')}
aria-busy
>
<div className={editorStyles.progressModal}>
<div className={editorStyles.progressTitle}>{t('npcs.savingTitle')}</div>
<div className={editorStyles.previewSpinner} aria-hidden />
<div className={editorStyles.progressBar}>
<div
className={editorStyles.progressFill}
style={{ width: `${String(Math.max(0, Math.min(100, progressPercent)))}%` }}
/>
</div>
<div className={editorStyles.progressMeta}>
<div>{progressDetail}</div>
<div>{progressPercent}%</div>
</div>
</div>
</div>
) : null}
</>, </>,
document.body, document.body,
); );
@@ -32,6 +32,16 @@
border-right: 1px solid var(--stroke); border-right: 1px solid var(--stroke);
} }
.graphLoading {
height: 100%;
min-height: 240px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text2);
font-size: 13px;
}
.col:last-child { .col:last-child {
border-right: 0; border-right: 0;
} }
+12 -5
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
@@ -21,7 +21,7 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields'; import { NpcBindingFields } from './NpcBindingFields';
import { NpcDescriptionField } from './NpcDescriptionField'; import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal'; import { NpcEditModal } from './NpcEditModal';
import { NpcGraph, type GraphGroupFilter } from './NpcGraph'; import type { GraphGroupFilter } from './NpcGraph';
import { NpcGroupModal } from './NpcGroupModal'; import { NpcGroupModal } from './NpcGroupModal';
import { import {
flattenGroupOptions, flattenGroupOptions,
@@ -32,6 +32,11 @@ import {
import { NpcRelationModal } from './NpcRelationModal'; import { NpcRelationModal } from './NpcRelationModal';
import styles from './NpcsEditorApp.module.css'; import styles from './NpcsEditorApp.module.css';
const NpcGraph = lazy(async () => {
const mod = await import('./NpcGraph');
return { default: mod.NpcGraph };
});
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id'; const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id'; const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
@@ -671,6 +676,7 @@ export function NpcsEditorApp() {
</div> </div>
<div className={styles.col}> <div className={styles.col}>
<Suspense fallback={<div className={styles.graphLoading}>{t('npcs.graphLoading')}</div>}>
<NpcGraph <NpcGraph
npcs={npcs} npcs={npcs}
relations={relations} relations={relations}
@@ -721,6 +727,7 @@ export function NpcsEditorApp() {
setPendingDeleteRelation(rel); setPendingDeleteRelation(rel);
}} }}
/> />
</Suspense>
</div> </div>
<div className={[styles.col, styles.inspector].join(' ')}> <div className={[styles.col, styles.inspector].join(' ')}>
@@ -761,10 +768,9 @@ export function NpcsEditorApp() {
<div> <div>
<div className={styles.fieldLabel}>{t('npcs.name')}</div> <div className={styles.fieldLabel}>{t('npcs.name')}</div>
<input <Input
className={controlStyles.input}
value={nameDraft} value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)} onChange={setNameDraft}
onBlur={() => { onBlur={() => {
const next = nameDraft.trim(); const next = nameDraft.trim();
if (!next || next === selected.name) { if (!next || next === selected.name) {
@@ -804,6 +810,7 @@ export function NpcsEditorApp() {
<div> <div>
<div className={styles.fieldLabel}>{t('npcs.description')}</div> <div className={styles.fieldLabel}>{t('npcs.description')}</div>
<NpcDescriptionField <NpcDescriptionField
key={selected.id}
html={selected.description} html={selected.description}
onCommit={(html) => { onCommit={(html) => {
if (html === selected.description) return; if (html === selected.description) return;
+3
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css'; import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { NpcsEditorApp } from './NpcsEditorApp'; import { NpcsEditorApp } from './NpcsEditorApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<WindowErrorBoundary title="НПС">
<EditorI18nProvider> <EditorI18nProvider>
<NpcsEditorApp /> <NpcsEditorApp />
</EditorI18nProvider> </EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
+3
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css'; import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { NpcsApp } from './NpcsApp'; import { NpcsApp } from './NpcsApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<WindowErrorBoundary title="НПС">
<EditorI18nProvider> <EditorI18nProvider>
<NpcsApp /> <NpcsApp />
</EditorI18nProvider> </EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
void test('readTipTapHtmlSafe: null / destroyed → null', () => {
assert.equal(readTipTapHtmlSafe(null), null);
assert.equal(readTipTapHtmlSafe(undefined), null);
assert.equal(
readTipTapHtmlSafe({
isDestroyed: true,
getHTML: () => '<p>x</p>',
}),
null,
);
});
void test('readTipTapHtmlSafe: getHTML throw → null', () => {
assert.equal(
readTipTapHtmlSafe({
isDestroyed: false,
getHTML: () => {
throw new TypeError("Cannot read properties of null (reading 'cached')");
},
}),
null,
);
});
void test('readTipTapHtmlSafe: ok → html', () => {
assert.equal(
readTipTapHtmlSafe({
isDestroyed: false,
getHTML: () => '<p>ok</p>',
}),
'<p>ok</p>',
);
});
+12
View File
@@ -0,0 +1,12 @@
/** Безопасное чтение HTML из TipTap/ProseMirror (StrictMode / destroy mid-flight). */
export function readTipTapHtmlSafe(editor: {
isDestroyed?: boolean;
getHTML: () => string;
} | null | undefined): string | null {
if (!editor || editor.isDestroyed) return null;
try {
return editor.getHTML();
} catch {
return null;
}
}
+4
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css'; import '../shared/ui/globals.css';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { PresentationApp } from './PresentationApp'; import { PresentationApp } from './PresentationApp';
const rootEl = document.getElementById('root'); const rootEl = document.getElementById('root');
@@ -11,6 +13,8 @@ if (!rootEl) {
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<WindowErrorBoundary title="Презентация">
<PresentationApp /> <PresentationApp />
</WindowErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
+3
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css'; import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { SceneDescriptionApp } from './SceneDescriptionApp'; import { SceneDescriptionApp } from './SceneDescriptionApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<WindowErrorBoundary title="Описание сцены">
<EditorI18nProvider> <EditorI18nProvider>
<SceneDescriptionApp /> <SceneDescriptionApp />
</EditorI18nProvider> </EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
+3
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css'; import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { SceneEditorApp } from './SceneEditorApp'; import { SceneEditorApp } from './SceneEditorApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<WindowErrorBoundary title="Редактор сцены">
<EditorI18nProvider> <EditorI18nProvider>
<SceneEditorApp /> <SceneEditorApp />
</EditorI18nProvider> </EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
@@ -0,0 +1,80 @@
import React from 'react';
type WindowErrorBoundaryProps = {
title?: string;
children: React.ReactNode;
};
type WindowErrorBoundaryState = {
error: Error | null;
};
/**
* Ловит падения React в дочерних окнах (НПС, материалы, редактор сцены),
* чтобы вместо чёрного экрана показать сообщение и кнопку перезагрузки.
*/
export class WindowErrorBoundary extends React.Component<
WindowErrorBoundaryProps,
WindowErrorBoundaryState
> {
state: WindowErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): WindowErrorBoundaryState {
return { error };
}
override componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[WindowErrorBoundary]', error, info.componentStack);
}
private handleReload = (): void => {
this.setState({ error: null });
window.location.reload();
};
override render(): React.ReactNode {
if (!this.state.error) return this.props.children;
const title = this.props.title ?? 'Ошибка окна';
const message = this.state.error.message || String(this.state.error);
return (
<div
role="alert"
style={{
height: '100vh',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: 24,
background: '#09090b',
color: '#e4e4e7',
fontFamily: 'system-ui, sans-serif',
textAlign: 'center',
}}
>
<div style={{ fontWeight: 800, fontSize: 16 }}>{title}</div>
<div style={{ opacity: 0.85, fontSize: 13, maxWidth: 480, lineHeight: 1.45 }}>{message}</div>
<button
type="button"
onClick={this.handleReload}
style={{
marginTop: 8,
height: 34,
padding: '0 14px',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.16)',
background: '#27272a',
color: '#fafafa',
cursor: 'pointer',
}}
>
Перезагрузить
</button>
</div>
);
}
}
+3 -1
View File
@@ -122,9 +122,10 @@ type InputProps = {
onChange: (v: string) => void; onChange: (v: string) => void;
autoFocus?: boolean; autoFocus?: boolean;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void; onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onBlur?: () => void;
}; };
export function Input({ value, placeholder, onChange, autoFocus, onKeyDown }: InputProps) { export function Input({ value, placeholder, onChange, autoFocus, onKeyDown, onBlur }: InputProps) {
return ( return (
<input <input
className={styles.input} className={styles.input}
@@ -133,6 +134,7 @@ export function Input({ value, placeholder, onChange, autoFocus, onKeyDown }: In
autoFocus={autoFocus} autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onBlur={onBlur}
/> />
); );
} }
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const rendererRoot = path.resolve(here, '../..');
const SECONDARY_WINDOW_MAINS = [
'npcs/npcsEditorMain.tsx',
'npcs/npcsMain.tsx',
'materials/main.tsx',
'sceneEditor/main.tsx',
'sceneDescription/main.tsx',
'control/main.tsx',
'presentation/main.tsx',
] as const;
void test('secondary window mains: WindowErrorBoundary wraps app root', () => {
for (const rel of SECONDARY_WINDOW_MAINS) {
const src = fs.readFileSync(path.join(rendererRoot, rel), 'utf8');
assert.ok(
src.includes('WindowErrorBoundary'),
`${rel}: must wrap with WindowErrorBoundary to avoid black screen on React crash`,
);
assert.match(
src,
/<WindowErrorBoundary[\s\S]*?>[\s\S]*<\/WindowErrorBoundary>/,
`${rel}: WindowErrorBoundary must wrap children`,
);
}
});
void test('NpcDescriptionField: TipTap StrictMode-safe (no black screen on NPC open)', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcDescriptionField.tsx'), 'utf8');
assert.match(src, /immediatelyRender:\s*false/);
assert.ok(src.includes('readTipTapHtmlSafe'));
assert.ok(src.includes('isDestroyed'));
assert.doesNotMatch(src, /immediatelyRender:\s*true/);
});
void test('NpcsEditorApp: no undefined controlStyles (inspector crash)', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
assert.doesNotMatch(src, /controlStyles/);
});
void test('WindowErrorBoundary component exists and catches errors', () => {
const src = fs.readFileSync(path.join(here, 'WindowErrorBoundary.tsx'), 'utf8');
assert.ok(src.includes('getDerivedStateFromError'));
assert.ok(src.includes('componentDidCatch'));
assert.ok(src.includes('role="alert"'));
});
+4
View File
@@ -116,6 +116,7 @@ export const ipcChannels = {
exportZipProgress: 'project.exportZipProgress', exportZipProgress: 'project.exportZipProgress',
scenePreviewImportProgress: 'project.scenePreviewImportProgress', scenePreviewImportProgress: 'project.scenePreviewImportProgress',
materialUpsertProgress: 'project.materialUpsertProgress', materialUpsertProgress: 'project.materialUpsertProgress',
npcUpsertProgress: 'project.npcUpsertProgress',
}, },
windows: { windows: {
openMultiWindow: 'windows.openMultiWindow', openMultiWindow: 'windows.openMultiWindow',
@@ -217,6 +218,8 @@ export type MaterialUpsertProgressEvent = {
detail?: string; detail?: string;
}; };
export type NpcUpsertProgressEvent = MaterialUpsertProgressEvent;
export type UpdaterCheckResponse = export type UpdaterCheckResponse =
| { outcome: 'not_packaged' } | { outcome: 'not_packaged' }
| { outcome: 'no_license' } | { outcome: 'no_license' }
@@ -260,6 +263,7 @@ export type IpcEventMap = {
[ipcChannels.project.exportZipProgress]: ZipProgressEvent; [ipcChannels.project.exportZipProgress]: ZipProgressEvent;
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent; [ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
[ipcChannels.project.materialUpsertProgress]: MaterialUpsertProgressEvent; [ipcChannels.project.materialUpsertProgress]: MaterialUpsertProgressEvent;
[ipcChannels.project.npcUpsertProgress]: NpcUpsertProgressEvent;
[ipcChannels.updater.progress]: UpdaterProgressEvent; [ipcChannels.updater.progress]: UpdaterProgressEvent;
}; };
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate", "build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit", "typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.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/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.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 app/main/license/machineFingerprint.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/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.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/help/helpLinkify.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/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.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 app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
"format": "prettier . --check", "format": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",