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:
+25
-8
@@ -63,6 +63,7 @@ import {
|
||||
sendToAppWindows,
|
||||
togglePresentationFullscreen,
|
||||
waitForEditorWindowReady,
|
||||
warmNpcsEditorWindow,
|
||||
} from './windows/createWindows';
|
||||
|
||||
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). По умолчанию не трогаем.
|
||||
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
|
||||
@@ -508,6 +519,7 @@ async function main() {
|
||||
registerHandler(ipcChannels.project.create, async ({ name }) => {
|
||||
const project = await projectStore.createProject(name);
|
||||
emitSessionState();
|
||||
warmNpcsEditorWindow();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||
@@ -517,9 +529,11 @@ async function main() {
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSessionState();
|
||||
warmNpcsEditorWindow();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.close, async () => {
|
||||
closeNpcsEditorWindow();
|
||||
await projectStore.closeOpenProject();
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
@@ -760,14 +774,17 @@ async function main() {
|
||||
}
|
||||
filePath = filePaths[0];
|
||||
}
|
||||
const project = await projectStore.upsertNpc({
|
||||
...(npcId ? { npcId } : {}),
|
||||
name,
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
});
|
||||
const project = await projectStore.upsertNpc(
|
||||
{
|
||||
...(npcId ? { npcId } : {}),
|
||||
name,
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
},
|
||||
(p) => emitNpcUpsertProgress(p),
|
||||
);
|
||||
syncNpcsOverlayWithProject(project);
|
||||
emitNpcsOverlayState();
|
||||
emitSessionState();
|
||||
|
||||
@@ -1302,14 +1302,20 @@ export class ZipProjectStore {
|
||||
* Создаёт или обновляет НПС.
|
||||
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
|
||||
*/
|
||||
async upsertNpc(input: {
|
||||
npcId?: NpcId;
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
}): Promise<Project> {
|
||||
async upsertNpc(
|
||||
input: {
|
||||
npcId?: NpcId;
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
},
|
||||
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;
|
||||
if (!open) throw new Error('No open project');
|
||||
const name = input.name.trim();
|
||||
@@ -1321,6 +1327,8 @@ export class ZipProjectStore {
|
||||
throw new Error('NPC name already exists');
|
||||
}
|
||||
|
||||
report(2, 'start', 'Подождите…');
|
||||
|
||||
let nextAssetId: AssetId | null = null;
|
||||
let stagedAsset: MediaAsset | null = null;
|
||||
if (input.filePath) {
|
||||
@@ -1330,13 +1338,16 @@ export class ZipProjectStore {
|
||||
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||
throw new Error('NPC avatar must be an image (png/jpg/webp)');
|
||||
}
|
||||
report(8, 'read', 'Чтение изображения…');
|
||||
let buf = await fs.readFile(input.filePath);
|
||||
report(18, 'optimize', 'Оптимизация изображения…');
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
// keep original buffer
|
||||
}
|
||||
report(72, 'write', 'Сохранение файла…');
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const id = asAssetId(this.randomId());
|
||||
const orig = path.basename(input.filePath);
|
||||
@@ -1349,6 +1360,7 @@ export class ZipProjectStore {
|
||||
nextAssetId = id;
|
||||
}
|
||||
|
||||
report(88, 'project', 'Обновление проекта…');
|
||||
await this.updateProject((p) => {
|
||||
const npcs = [...(p.npcs ?? [])];
|
||||
const assets = { ...p.assets };
|
||||
@@ -1393,6 +1405,7 @@ export class ZipProjectStore {
|
||||
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
report(100, 'done', 'Готово');
|
||||
return latest;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ void test('createWindows: окно НПС закрывается с multi-window
|
||||
assert.ok(src.includes('openNpcsWindow'));
|
||||
assert.ok(src.includes('closeNpcsWindow'));
|
||||
assert.ok(src.includes('openNpcsEditorWindow'));
|
||||
assert.ok(src.includes('warmNpcsEditorWindow'));
|
||||
assert.ok(src.includes("createWindow('npcs'"));
|
||||
assert.ok(src.includes("createWindow('npcsEditor'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
||||
|
||||
@@ -163,7 +163,7 @@ export function applyDockIconIfNeeded(): void {
|
||||
type CreateWindowOpts = {
|
||||
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
||||
parent?: BrowserWindow;
|
||||
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
|
||||
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
|
||||
deferVisibility?: boolean;
|
||||
};
|
||||
|
||||
@@ -202,7 +202,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
||||
}
|
||||
|
||||
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||||
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
|
||||
const deferShow = opts?.deferVisibility === true;
|
||||
const icon = loadBrandingWindowIcon();
|
||||
const size = windowSizeForKind(kind);
|
||||
const win = new BrowserWindow({
|
||||
@@ -300,7 +300,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
|
||||
});
|
||||
|
||||
if (!deferEditor) {
|
||||
if (!deferShow) {
|
||||
ensureWindowBecomesVisible(win);
|
||||
}
|
||||
loadWindowPage(win, kind);
|
||||
@@ -527,19 +527,8 @@ export function openMaterialsWindow(): 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;
|
||||
}
|
||||
|
||||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', parent ? { parent } : undefined);
|
||||
const { width, height } = win.getBounds();
|
||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||
const { x, y, width: dw, height: dh } = display.workArea;
|
||||
@@ -549,9 +538,43 @@ export function openNpcsEditorWindow(): void {
|
||||
width,
|
||||
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', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ export const ipcChannels = {
|
||||
exportZipProgress: 'project.exportZipProgress',
|
||||
scenePreviewImportProgress: 'project.scenePreviewImportProgress',
|
||||
materialUpsertProgress: 'project.materialUpsertProgress',
|
||||
npcUpsertProgress: 'project.npcUpsertProgress',
|
||||
},
|
||||
windows: {
|
||||
openMultiWindow: 'windows.openMultiWindow',
|
||||
@@ -217,6 +218,8 @@ export type MaterialUpsertProgressEvent = {
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type NpcUpsertProgressEvent = MaterialUpsertProgressEvent;
|
||||
|
||||
export type UpdaterCheckResponse =
|
||||
| { outcome: 'not_packaged' }
|
||||
| { outcome: 'no_license' }
|
||||
@@ -260,6 +263,7 @@ export type IpcEventMap = {
|
||||
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
|
||||
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
|
||||
[ipcChannels.project.materialUpsertProgress]: MaterialUpsertProgressEvent;
|
||||
[ipcChannels.project.npcUpsertProgress]: NpcUpsertProgressEvent;
|
||||
[ipcChannels.updater.progress]: UpdaterProgressEvent;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user