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>
This commit is contained in:
Ivan Fontosh
2026-07-30 08:50:05 +08:00
parent c7bf7cf449
commit e687303c57
10 changed files with 247 additions and 106 deletions
+77 -65
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 { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
@@ -19,9 +19,8 @@ import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal';
import { NpcGraph, type GraphGroupFilter } from './NpcGraph';
import type { GraphGroupFilter } from './NpcGraph';
import { NpcGroupModal } from './NpcGroupModal';
import {
flattenGroupOptions,
@@ -32,6 +31,16 @@ import {
import { NpcRelationModal } from './NpcRelationModal';
import styles from './NpcsEditorApp.module.css';
const NpcGraph = lazy(async () => {
const mod = await import('./NpcGraph');
return { default: mod.NpcGraph };
});
const NpcDescriptionField = lazy(async () => {
const mod = await import('./NpcDescriptionField');
return { default: mod.NpcDescriptionField };
});
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
@@ -671,56 +680,58 @@ export function NpcsEditorApp() {
</div>
<div className={styles.col}>
<NpcGraph
npcs={npcs}
relations={relations}
npcGroups={npcGroups}
selectedNpcId={selectedId}
graphFilter={graphFilter}
onGraphFilterChange={setGraphFilter}
graphUi={graphUi}
onSelect={setSelectedId}
onConnectRequest={(sourceNpcId, targetNpcId) => {
setRelationModal({ mode: 'create', sourceNpcId, targetNpcId });
}}
onNodePositionCommit={(npcId, x, y) => {
void (async () => {
setSession((prev) => {
if (!prev?.project) return prev;
return {
...prev,
project: {
...prev.project,
npcs: prev.project.npcs.map((n) => (n.id === npcId ? { ...n, x, y } : n)),
},
};
});
try {
const res = await api.invoke(ipcChannels.project.updateNpcPosition, {
npcId,
x,
y,
<Suspense fallback={<div className={styles.graphLoading}>{t('npcs.graphLoading')}</div>}>
<NpcGraph
npcs={npcs}
relations={relations}
npcGroups={npcGroups}
selectedNpcId={selectedId}
graphFilter={graphFilter}
onGraphFilterChange={setGraphFilter}
graphUi={graphUi}
onSelect={setSelectedId}
onConnectRequest={(sourceNpcId, targetNpcId) => {
setRelationModal({ mode: 'create', sourceNpcId, targetNpcId });
}}
onNodePositionCommit={(npcId, x, y) => {
void (async () => {
setSession((prev) => {
if (!prev?.project) return prev;
return {
...prev,
project: {
...prev.project,
npcs: prev.project.npcs.map((n) => (n.id === npcId ? { ...n, x, y } : n)),
},
};
});
setSession({
project: res.project,
currentSceneId: res.project?.currentSceneId ?? null,
});
} catch {
/* позиция уже оптимистично в UI; следующий session sync поправит при CRUD */
}
})();
}}
onEditRelation={(relationId) => {
const rel = relations.find((r) => r.id === relationId);
if (!rel) return;
setRelationModal({ mode: 'edit', relationId, label: rel.label });
}}
onDeleteRelation={(relationId) => {
const rel = relations.find((r) => r.id === relationId);
if (!rel) return;
setPendingDeleteRelation(rel);
}}
/>
try {
const res = await api.invoke(ipcChannels.project.updateNpcPosition, {
npcId,
x,
y,
});
setSession({
project: res.project,
currentSceneId: res.project?.currentSceneId ?? null,
});
} catch {
/* позиция уже оптимистично в UI; следующий session sync поправит при CRUD */
}
})();
}}
onEditRelation={(relationId) => {
const rel = relations.find((r) => r.id === relationId);
if (!rel) return;
setRelationModal({ mode: 'edit', relationId, label: rel.label });
}}
onDeleteRelation={(relationId) => {
const rel = relations.find((r) => r.id === relationId);
if (!rel) return;
setPendingDeleteRelation(rel);
}}
/>
</Suspense>
</div>
<div className={[styles.col, styles.inspector].join(' ')}>
@@ -761,10 +772,9 @@ export function NpcsEditorApp() {
<div>
<div className={styles.fieldLabel}>{t('npcs.name')}</div>
<input
className={controlStyles.input}
<Input
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onChange={setNameDraft}
onBlur={() => {
const next = nameDraft.trim();
if (!next || next === selected.name) {
@@ -803,16 +813,18 @@ export function NpcsEditorApp() {
<div>
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
<NpcDescriptionField
html={selected.description}
onCommit={(html) => {
if (html === selected.description) return;
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
description: html,
});
}}
/>
<Suspense fallback={<div className={styles.muted}>{t('npcs.savingWait')}</div>}>
<NpcDescriptionField
html={selected.description}
onCommit={(html) => {
if (html === selected.description) return;
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
description: html,
});
}}
/>
</Suspense>
</div>
<div>