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:
+15
-2
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user