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
+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,
);