feat(materials): show save progress overlay while optimizing images

Block the editor with a spinner and percent stages during material upsert so long image optimization is visible and non-interactive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-25 14:50:35 +08:00
parent 9d82e74272
commit 4aa0f257d5
5 changed files with 93 additions and 10 deletions
+15 -2
View File
@@ -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). По умолчанию не трогаем. * Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`. * При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
@@ -647,11 +657,14 @@ async function main() {
} }
filePath = filePaths[0]; filePath = filePaths[0];
} }
const project = await projectStore.upsertMaterial({ const project = await projectStore.upsertMaterial(
{
...(materialId ? { materialId } : {}), ...(materialId ? { materialId } : {}),
name, name,
...(filePath ? { filePath } : {}), ...(filePath ? { filePath } : {}),
}); },
(p) => emitMaterialUpsertProgress(p),
);
syncMaterialsOverlayWithProject(project); syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitSessionState(); emitSessionState();
+15 -2
View File
@@ -1117,11 +1117,17 @@ export class ZipProjectStore {
* Создаёт или обновляет материал кампании. * Создаёт или обновляет материал кампании.
* При создании `filePath` обязателен; при обновлении можно сменить только имя или только картинку. * При создании `filePath` обязателен; при обновлении можно сменить только имя или только картинку.
*/ */
async upsertMaterial(input: { async upsertMaterial(
input: {
materialId?: MaterialId; materialId?: MaterialId;
name: string; name: string;
filePath?: string; filePath?: string;
}): Promise<Project> { },
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; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) throw new Error('No open project');
const name = input.name.trim(); const name = input.name.trim();
@@ -1133,6 +1139,8 @@ export class ZipProjectStore {
throw new Error('Material name already exists'); throw new Error('Material name already exists');
} }
report(2, 'start', 'Подождите…');
let nextAssetId: AssetId | null = null; let nextAssetId: AssetId | null = null;
let stagedAsset: MediaAsset | null = null; let stagedAsset: MediaAsset | null = null;
if (input.filePath) { if (input.filePath) {
@@ -1142,13 +1150,16 @@ export class ZipProjectStore {
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
throw new Error('Material must be an image (png/jpg/webp)'); throw new Error('Material must be an image (png/jpg/webp)');
} }
report(8, 'read', 'Чтение изображения…');
let buf = await fs.readFile(input.filePath); let buf = await fs.readFile(input.filePath);
report(18, 'optimize', 'Оптимизация изображения…');
try { try {
const opt = await optimizeImageBufferVisuallyLossless(buf); const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer); if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch { } catch {
// keep original buffer // keep original buffer
} }
report(72, 'write', 'Сохранение файла…');
const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId()); const id = asAssetId(this.randomId());
const orig = path.basename(input.filePath); const orig = path.basename(input.filePath);
@@ -1161,6 +1172,7 @@ export class ZipProjectStore {
nextAssetId = id; nextAssetId = id;
} }
report(88, 'project', 'Обновление проекта…');
await this.updateProject((p) => { await this.updateProject((p) => {
const materials = [...(p.materials ?? [])]; const materials = [...(p.materials ?? [])];
const assets = { ...p.assets }; const assets = { ...p.assets };
@@ -1192,6 +1204,7 @@ export class ZipProjectStore {
const latest = this.getOpenProject(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project'); if (!latest) throw new Error('No open project');
report(100, 'done', 'Готово');
return latest; return latest;
} }
+43
View File
@@ -1,7 +1,9 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { createPortal, flushSync } from 'react-dom'; import { createPortal, flushSync } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types'; import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
import { getDndApi } from '../shared/dndApi';
import { Button, Input } from '../shared/ui/controls'; import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -38,10 +40,12 @@ export function MaterialEditModal({
onSave, onSave,
}: MaterialEditModalProps) { }: MaterialEditModalProps) {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const api = getDndApi();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null); const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const existingUrl = useAssetUrl(initial?.assetId ?? null); const existingUrl = useAssetUrl(initial?.assetId ?? null);
@@ -51,6 +55,7 @@ export function MaterialEditModal({
setFilePath(null); setFilePath(null);
setLocalPreviewUrl(null); setLocalPreviewUrl(null);
setSaving(false); setSaving(false);
setSaveProgress(null);
setError(null); setError(null);
}, [initial, open]); }, [initial, open]);
@@ -60,6 +65,16 @@ export function MaterialEditModal({
}; };
}, [localPreviewUrl]); }, [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(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -99,6 +114,9 @@ export function MaterialEditModal({
if (!open) return null; if (!open) return null;
const progressPercent = saveProgress?.percent ?? (saving ? 0 : 0);
const progressDetail = saveProgress?.detail ?? t('materials.savingWait');
return createPortal( return createPortal(
<> <>
<button <button
@@ -194,6 +212,7 @@ export function MaterialEditModal({
void (async () => { void (async () => {
flushSync(() => { flushSync(() => {
setSaving(true); setSaving(true);
setSaveProgress({ percent: 0, detail: t('materials.savingWait') });
setError(null); setError(null);
}); });
try { try {
@@ -203,6 +222,7 @@ export function MaterialEditModal({
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
} finally { } finally {
setSaving(false); setSaving(false);
setSaveProgress(null);
} }
})(); })();
}} }}
@@ -211,6 +231,29 @@ export function MaterialEditModal({
</Button> </Button>
</div> </div>
</div> </div>
{saving ? (
<div
className={styles.progressOverlay}
role="dialog"
aria-label={t('materials.savingProgress')}
aria-busy
>
<div className={styles.progressModal}>
<div className={styles.progressTitle}>{t('materials.savingTitle')}</div>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${String(Math.max(0, Math.min(100, progressPercent)))}%` }}
/>
</div>
<div className={styles.progressMeta}>
<div>{progressDetail}</div>
<div>{progressPercent}%</div>
</div>
</div>
</div>
) : null}
</>, </>,
document.body, document.body,
); );
@@ -365,6 +365,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'materials.add': 'Добавить', 'materials.add': 'Добавить',
'materials.addTitle': 'Новый материал', 'materials.addTitle': 'Новый материал',
'materials.editTitle': 'Изменить материал', 'materials.editTitle': 'Изменить материал',
'materials.savingTitle': 'Сохранение материала',
'materials.savingWait': 'Подождите…',
'materials.savingProgress': 'Прогресс сохранения материала',
'materials.edit': 'Изменить', 'materials.edit': 'Изменить',
'materials.search': 'Поиск материалов…', 'materials.search': 'Поиск материалов…',
'materials.searchEmpty': 'Ничего не найдено.', 'materials.searchEmpty': 'Ничего не найдено.',
@@ -912,6 +915,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'materials.add': 'Add', 'materials.add': 'Add',
'materials.addTitle': 'New material', 'materials.addTitle': 'New material',
'materials.editTitle': 'Edit material', 'materials.editTitle': 'Edit material',
'materials.savingTitle': 'Saving material',
'materials.savingWait': 'Please wait…',
'materials.savingProgress': 'Material save progress',
'materials.edit': 'Edit', 'materials.edit': 'Edit',
'materials.search': 'Search materials…', 'materials.search': 'Search materials…',
'materials.searchEmpty': 'No matches.', 'materials.searchEmpty': 'No matches.',
+8
View File
@@ -109,6 +109,7 @@ export const ipcChannels = {
importZipProgress: 'project.importZipProgress', importZipProgress: 'project.importZipProgress',
exportZipProgress: 'project.exportZipProgress', exportZipProgress: 'project.exportZipProgress',
scenePreviewImportProgress: 'project.scenePreviewImportProgress', scenePreviewImportProgress: 'project.scenePreviewImportProgress',
materialUpsertProgress: 'project.materialUpsertProgress',
}, },
windows: { windows: {
openMultiWindow: 'windows.openMultiWindow', openMultiWindow: 'windows.openMultiWindow',
@@ -191,6 +192,12 @@ export type ScenePreviewImportEvent = {
message?: string; message?: string;
}; };
export type MaterialUpsertProgressEvent = {
percent: number;
stage: string;
detail?: string;
};
export type UpdaterCheckResponse = export type UpdaterCheckResponse =
| { outcome: 'not_packaged' } | { outcome: 'not_packaged' }
| { outcome: 'no_license' } | { outcome: 'no_license' }
@@ -231,6 +238,7 @@ export type IpcEventMap = {
[ipcChannels.project.importZipProgress]: ZipProgressEvent; [ipcChannels.project.importZipProgress]: ZipProgressEvent;
[ipcChannels.project.exportZipProgress]: ZipProgressEvent; [ipcChannels.project.exportZipProgress]: ZipProgressEvent;
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent; [ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
[ipcChannels.project.materialUpsertProgress]: MaterialUpsertProgressEvent;
[ipcChannels.updater.progress]: UpdaterProgressEvent; [ipcChannels.updater.progress]: UpdaterProgressEvent;
}; };