diff --git a/app/main/index.ts b/app/main/index.ts index dff6aea..5062115 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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(); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 3b8289e..2627070 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -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 { + 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 { + 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; } diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index 5b5c673..bbd23e0 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -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/); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 2b8acaa..82d684c 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -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(); } diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 13859f7..821e1d0 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -414,6 +414,10 @@ export const EDITOR_MESSAGES: Record> = { '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> = { '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…', diff --git a/app/renderer/npcs/NpcEditModal.tsx b/app/renderer/npcs/NpcEditModal.tsx index c6c144a..b04d313 100644 --- a/app/renderer/npcs/NpcEditModal.tsx +++ b/app/renderer/npcs/NpcEditModal.tsx @@ -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(null); const [localPreviewUrl, setLocalPreviewUrl] = useState(null); const [groupId, setGroupId] = useState(''); const [binding, setBinding] = useState(noneBinding()); const [saving, setSaving] = useState(false); + const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null); const [error, setError] = useState(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({ @@ -208,6 +230,7 @@ export function NpcEditModal({
{t('npcs.avatarEmpty')}
+ {saving ? ( +
+
+
{t('npcs.savingTitle')}
+
+
+
+
+
+
{progressDetail}
+
{progressPercent}%
+
+
+
+ ) : null} , document.body, ); diff --git a/app/renderer/npcs/NpcsEditorApp.module.css b/app/renderer/npcs/NpcsEditorApp.module.css index e5fd0ce..2e2ec48 100644 --- a/app/renderer/npcs/NpcsEditorApp.module.css +++ b/app/renderer/npcs/NpcsEditorApp.module.css @@ -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; } diff --git a/app/renderer/npcs/NpcsEditorApp.tsx b/app/renderer/npcs/NpcsEditorApp.tsx index c4e392c..664fef3 100644 --- a/app/renderer/npcs/NpcsEditorApp.tsx +++ b/app/renderer/npcs/NpcsEditorApp.tsx @@ -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() {
- { - 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, + {t('npcs.graphLoading')}
}> + { + 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); + }} + /> +
@@ -761,10 +772,9 @@ export function NpcsEditorApp() {
{t('npcs.name')}
- setNameDraft(e.target.value)} + onChange={setNameDraft} onBlur={() => { const next = nameDraft.trim(); if (!next || next === selected.name) { @@ -803,16 +813,18 @@ export function NpcsEditorApp() {
{t('npcs.description')}
- { - if (html === selected.description) return; - void api.invoke(ipcChannels.project.updateNpcFields, { - npcId: selected.id, - description: html, - }); - }} - /> + {t('npcs.savingWait')}
}> + { + if (html === selected.description) return; + void api.invoke(ipcChannels.project.updateNpcFields, { + npcId: selected.id, + description: html, + }); + }} + /> +
diff --git a/app/renderer/shared/ui/controls.tsx b/app/renderer/shared/ui/controls.tsx index 837c102..5856681 100644 --- a/app/renderer/shared/ui/controls.tsx +++ b/app/renderer/shared/ui/controls.tsx @@ -122,9 +122,10 @@ type InputProps = { onChange: (v: string) => void; autoFocus?: boolean; onKeyDown?: (e: React.KeyboardEvent) => void; + onBlur?: () => void; }; -export function Input({ value, placeholder, onChange, autoFocus, onKeyDown }: InputProps) { +export function Input({ value, placeholder, onChange, autoFocus, onKeyDown, onBlur }: InputProps) { return ( onChange(e.target.value)} onKeyDown={onKeyDown} + onBlur={onBlur} /> ); } diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 7aea41d..7990070 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -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; };