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
@@ -414,6 +414,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.add': 'Добавить',
'npcs.addTitle': 'Новый НПС',
'npcs.editTitle': 'Изменить НПС',
'npcs.savingTitle': 'Сохранение НПС',
'npcs.savingWait': 'Подождите…',
'npcs.savingProgress': 'Прогресс сохранения НПС',
'npcs.graphLoading': 'Загрузка графа…',
'npcs.edit': 'Изменить',
'npcs.tileMenu': 'Меню НПС',
'npcs.search': 'Поиск НПС…',
@@ -979,6 +983,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.add': 'Add',
'npcs.addTitle': 'New 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.tileMenu': 'NPC menu',
'npcs.search': 'Search NPCs…',
+59 -8
View File
@@ -1,6 +1,7 @@
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 { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
@@ -13,6 +14,7 @@ import {
} from '../editor/fileDrop';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import matStyles from '../editor/MaterialsModals.module.css';
import { getDndApi } from '../shared/dndApi';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -62,12 +64,14 @@ export function NpcEditModal({
onSave,
}: NpcEditModalProps) {
const { t } = useEditorI18n();
const api = getDndApi();
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 [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
@@ -84,6 +88,7 @@ export function NpcEditModal({
setGroupId(initial?.groupId ?? '');
setBinding(initial?.binding ?? noneBinding());
setSaving(false);
setSaveProgress(null);
setError(null);
}, [initial, open]);
@@ -93,14 +98,24 @@ export function NpcEditModal({
};
}, [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(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Escape' && !saving) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
}, [onClose, open, saving]);
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
setFilePath(path);
@@ -131,6 +146,8 @@ export function NpcEditModal({
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
const canSave = nameOk && !nameDup && hasImage && !saving;
const previewSrc = localPreviewUrl ?? existingUrl;
const progressPercent = saveProgress?.percent ?? (saving ? 0 : 0);
const progressDetail = saveProgress?.detail ?? t('npcs.savingWait');
if (!open) return null;
@@ -139,7 +156,9 @@ export function NpcEditModal({
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
onClick={() => {
if (!saving) onClose();
}}
className={editorStyles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
@@ -148,8 +167,11 @@ export function NpcEditModal({
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
onClick={() => {
if (!saving) onClose();
}}
className={editorStyles.modalClose}
disabled={saving}
>
×
</button>
@@ -208,6 +230,7 @@ export function NpcEditModal({
<div className={matStyles.imageDropEmpty}>
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div>
<Button
disabled={saving}
onClick={() => {
void (async () => {
const picked = await onPickImage();
@@ -222,6 +245,7 @@ export function NpcEditModal({
)}
{previewSrc ? (
<Button
disabled={saving}
onClick={() => {
void (async () => {
const picked = await onPickImage();
@@ -253,8 +277,11 @@ export function NpcEditModal({
onClick={() => {
if (!canSave) return;
void (async () => {
setSaving(true);
setError(null);
flushSync(() => {
setSaving(true);
setSaveProgress({ percent: 0, detail: t('npcs.savingWait') });
setError(null);
});
try {
await onSave({
name: trimmed,
@@ -266,14 +293,38 @@ export function NpcEditModal({
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
setSaveProgress(null);
}
})();
}}
>
{t('common.save')}
{saving ? t('common.saving') : t('common.save')}
</Button>
</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,
);
@@ -32,6 +32,16 @@
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 {
border-right: 0;
}
+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>
+3 -1
View File
@@ -122,9 +122,10 @@ type InputProps = {
onChange: (v: string) => void;
autoFocus?: boolean;
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 (
<input
className={styles.input}
@@ -133,6 +134,7 @@ export function Input({ value, placeholder, onChange, autoFocus, onKeyDown }: In
autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>
);
}