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:
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user