diff --git a/app/main/index.ts b/app/main/index.ts index 06278c4..77de575 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -83,6 +83,16 @@ function emitScenePreviewImportProgress(evt: ScenePreviewImportEvent): void { } } +function emitMaterialUpsertProgress(evt: { + percent: number; + stage: string; + detail?: string; +}): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.project.materialUpsertProgress, evt); + } +} + /** * Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем. * При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`. @@ -647,11 +657,14 @@ async function main() { } filePath = filePaths[0]; } - const project = await projectStore.upsertMaterial({ - ...(materialId ? { materialId } : {}), - name, - ...(filePath ? { filePath } : {}), - }); + const project = await projectStore.upsertMaterial( + { + ...(materialId ? { materialId } : {}), + name, + ...(filePath ? { filePath } : {}), + }, + (p) => emitMaterialUpsertProgress(p), + ); syncMaterialsOverlayWithProject(project); emitMaterialsOverlayState(); emitSessionState(); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index e2910f3..a222708 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -1117,11 +1117,17 @@ export class ZipProjectStore { * Создаёт или обновляет материал кампании. * При создании `filePath` обязателен; при обновлении можно сменить только имя или только картинку. */ - async upsertMaterial(input: { - materialId?: MaterialId; - name: string; - filePath?: string; - }): Promise { + async upsertMaterial( + input: { + materialId?: MaterialId; + name: string; + filePath?: string; + }, + 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(); @@ -1133,6 +1139,8 @@ export class ZipProjectStore { throw new Error('Material name already exists'); } + report(2, 'start', 'Подождите…'); + let nextAssetId: AssetId | null = null; let stagedAsset: MediaAsset | null = null; if (input.filePath) { @@ -1142,13 +1150,16 @@ export class ZipProjectStore { if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { throw new Error('Material 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); @@ -1161,6 +1172,7 @@ export class ZipProjectStore { nextAssetId = id; } + report(88, 'project', 'Обновление проекта…'); await this.updateProject((p) => { const materials = [...(p.materials ?? [])]; const assets = { ...p.assets }; @@ -1192,6 +1204,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/renderer/editor/MaterialsModals.tsx b/app/renderer/editor/MaterialsModals.tsx index 6af06ed..2c2ba31 100644 --- a/app/renderer/editor/MaterialsModals.tsx +++ b/app/renderer/editor/MaterialsModals.tsx @@ -1,7 +1,9 @@ import React, { useCallback, useEffect, useState } from 'react'; import { createPortal, flushSync } from 'react-dom'; +import { ipcChannels } from '../../shared/ipc/contracts'; import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types'; +import { getDndApi } from '../shared/dndApi'; import { Button, Input } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; @@ -38,10 +40,12 @@ export function MaterialEditModal({ onSave, }: MaterialEditModalProps) { const { t } = useEditorI18n(); + const api = getDndApi(); const [name, setName] = useState(''); const [filePath, setFilePath] = useState(null); const [localPreviewUrl, setLocalPreviewUrl] = useState(null); const [saving, setSaving] = useState(false); + const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null); const [error, setError] = useState(null); const existingUrl = useAssetUrl(initial?.assetId ?? null); @@ -51,6 +55,7 @@ export function MaterialEditModal({ setFilePath(null); setLocalPreviewUrl(null); setSaving(false); + setSaveProgress(null); setError(null); }, [initial, open]); @@ -60,6 +65,16 @@ export function MaterialEditModal({ }; }, [localPreviewUrl]); + useEffect(() => { + if (!open) return; + return api.on(ipcChannels.project.materialUpsertProgress, (evt) => { + setSaveProgress({ + percent: Math.max(0, Math.min(100, Math.round(evt.percent))), + detail: evt.detail?.trim() || t('materials.savingWait'), + }); + }); + }, [api, open, t]); + useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { @@ -99,6 +114,9 @@ export function MaterialEditModal({ if (!open) return null; + const progressPercent = saveProgress?.percent ?? (saving ? 0 : 0); + const progressDetail = saveProgress?.detail ?? t('materials.savingWait'); + return createPortal( <> + {saving ? ( +
+
+
{t('materials.savingTitle')}
+
+
+
+
+
+
{progressDetail}
+
{progressPercent}%
+
+
+
+ ) : null} , document.body, ); diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 6d069e9..3bfe40b 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -365,6 +365,9 @@ export const EDITOR_MESSAGES: Record> = { 'materials.add': 'Добавить', 'materials.addTitle': 'Новый материал', 'materials.editTitle': 'Изменить материал', + 'materials.savingTitle': 'Сохранение материала', + 'materials.savingWait': 'Подождите…', + 'materials.savingProgress': 'Прогресс сохранения материала', 'materials.edit': 'Изменить', 'materials.search': 'Поиск материалов…', 'materials.searchEmpty': 'Ничего не найдено.', @@ -912,6 +915,9 @@ export const EDITOR_MESSAGES: Record> = { 'materials.add': 'Add', 'materials.addTitle': 'New material', 'materials.editTitle': 'Edit material', + 'materials.savingTitle': 'Saving material', + 'materials.savingWait': 'Please wait…', + 'materials.savingProgress': 'Material save progress', 'materials.edit': 'Edit', 'materials.search': 'Search materials…', 'materials.searchEmpty': 'No matches.', diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 8bb7faf..ca9bae3 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -109,6 +109,7 @@ export const ipcChannels = { importZipProgress: 'project.importZipProgress', exportZipProgress: 'project.exportZipProgress', scenePreviewImportProgress: 'project.scenePreviewImportProgress', + materialUpsertProgress: 'project.materialUpsertProgress', }, windows: { openMultiWindow: 'windows.openMultiWindow', @@ -191,6 +192,12 @@ export type ScenePreviewImportEvent = { message?: string; }; +export type MaterialUpsertProgressEvent = { + percent: number; + stage: string; + detail?: string; +}; + export type UpdaterCheckResponse = | { outcome: 'not_packaged' } | { outcome: 'no_license' } @@ -231,6 +238,7 @@ export type IpcEventMap = { [ipcChannels.project.importZipProgress]: ZipProgressEvent; [ipcChannels.project.exportZipProgress]: ZipProgressEvent; [ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent; + [ipcChannels.project.materialUpsertProgress]: MaterialUpsertProgressEvent; [ipcChannels.updater.progress]: UpdaterProgressEvent; };