feat(sheets-drawing-ui): implement batch save images functionality (#6250)

This commit is contained in:
WEI ZHANG
2025-12-05 11:46:05 +08:00
committed by GitHub
parent 53b6c134a8
commit 173168007d
25 changed files with 1032 additions and 14 deletions
+1 -2
View File
@@ -51,7 +51,6 @@ import { UniverSheetsZenEditorPlugin } from '@univerjs/sheets-zen-editor';
import { UniverUIPlugin } from '@univerjs/ui';
import { UniverVue3AdapterPlugin } from '@univerjs/ui-adapter-vue3';
import { UniverWebComponentAdapterPlugin } from '@univerjs/ui-adapter-web-component';
import { customRangePopups } from './custom/custom-range-popup';
import { customRegisterEvent } from './custom/custom-register-event';
import { UniverSheetsCustomShortcutPlugin } from './custom/custom-shortcut';
import ImportCSVButtonPlugin from './custom/import-csv-button';
@@ -202,7 +201,7 @@ function createNewInstance() {
// ]);
customRegisterEvent(univer, window.univerAPI!);
customRangePopups(univer, window.univerAPI!);
// customRangePopups(univer, window.univerAPI!);
}
createNewInstance();
+1
View File
@@ -84,6 +84,7 @@
"@univerjs/drawing": "workspace:*",
"@univerjs/drawing-ui": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "^1.0.2",
"@univerjs/sheets": "workspace:*",
"@univerjs/sheets-drawing": "workspace:*",
"@univerjs/sheets-ui": "workspace:*",
@@ -0,0 +1,63 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import { CommandType, LocaleService } from '@univerjs/core';
import { IDialogService } from '@univerjs/ui';
import { IBatchSaveImagesService } from '../../services/batch-save-images.service';
import { BATCH_SAVE_IMAGES_DIALOG_ID } from '../../views/batch-save-images/component-name';
export const SaveCellImagesCommand: ICommand = {
id: 'sheet.command.save-cell-images',
type: CommandType.COMMAND,
handler: async (accessor) => {
const dialogService = accessor.get(IDialogService);
const batchSaveService = accessor.get(IBatchSaveImagesService);
const images = batchSaveService.getCellImagesInSelection();
// If only one image, download directly without dialog
if (images.length === 1) {
try {
await batchSaveService.downloadSingleImage(images[0]);
return true;
} catch (error) {
console.error('Failed to download image:', error);
return false;
}
}
// Multiple images: open batch save dialog
const localeService = accessor.get(LocaleService);
const selectionRange = batchSaveService.getSelectionRangeNotation();
const titleText = `${localeService.t('sheetImage.save.title')} (${selectionRange})`;
dialogService.open({
id: BATCH_SAVE_IMAGES_DIALOG_ID,
draggable: true,
width: 360,
title: { title: titleText },
children: {
label: BATCH_SAVE_IMAGES_DIALOG_ID,
},
destroyOnClose: true,
preservePositionOnDestroy: true,
onClose: () => dialogService.close(BATCH_SAVE_IMAGES_DIALOG_ID),
});
return true;
},
};
@@ -15,9 +15,11 @@
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { RibbonInsertGroup } from '@univerjs/ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { InsertCellImageCommand, InsertFloatImageCommand } from '../commands/commands/insert-image.command';
import { SaveCellImagesCommand } from '../commands/commands/save-cell-images.command';
import { ImageMenuFactory, SHEETS_IMAGE_MENU_ID, UploadCellImageMenuFactory, UploadFloatImageMenuFactory } from '../views/menu/image.menu';
import { SaveCellImagesMenuFactory } from '../views/menu/save-images.menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
@@ -34,4 +36,28 @@ export const menuSchema: MenuSchemaType = {
},
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.OTHERS]: {
[SaveCellImagesCommand.id]: {
order: 10,
menuItemFactory: SaveCellImagesMenuFactory,
},
},
},
[ContextMenuPosition.COL_HEADER]: {
[ContextMenuGroup.OTHERS]: {
[SaveCellImagesCommand.id]: {
order: 10,
menuItemFactory: SaveCellImagesMenuFactory,
},
},
},
[ContextMenuPosition.ROW_HEADER]: {
[ContextMenuGroup.OTHERS]: {
[SaveCellImagesCommand.id]: {
order: 10,
menuItemFactory: SaveCellImagesMenuFactory,
},
},
},
};
@@ -16,6 +16,7 @@
import { Disposable, ICommandService, Inject } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { DownloadImageIcon } from '@univerjs/icons';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { ComponentManager, IMenuManagerService, IShortcutService } from '@univerjs/ui';
@@ -26,12 +27,14 @@ import { InsertSheetDrawingCommand } from '../commands/commands/insert-sheet-dra
import { MoveDrawingsCommand } from '../commands/commands/move-drawings.command';
import { RemoveSheetDrawingCommand } from '../commands/commands/remove-sheet-drawing.command';
import { SaveCellImagesCommand } from '../commands/commands/save-cell-images.command';
import { SetDrawingArrangeCommand } from '../commands/commands/set-drawing-arrange.command';
import { SetSheetDrawingCommand } from '../commands/commands/set-sheet-drawing.command';
import { UngroupSheetDrawingCommand } from '../commands/commands/ungroup-sheet-drawing.command';
import { ClearSheetDrawingTransformerOperation } from '../commands/operations/clear-drawing-transformer.operation';
import { EditSheetDrawingOperation } from '../commands/operations/edit-sheet-drawing.operation';
import { SidebarSheetDrawingOperation } from '../commands/operations/open-drawing-panel.operation';
import { BATCH_SAVE_IMAGES_DIALOG_ID, BatchSaveImagesDialog } from '../views/batch-save-images';
import { COMPONENT_SHEET_DRAWING_PANEL } from '../views/sheet-image-panel/component-name';
import { SheetDrawingPanel } from '../views/sheet-image-panel/SheetDrawingPanel';
import { menuSchema } from './menu.schema';
@@ -54,6 +57,8 @@ export class SheetDrawingUIController extends Disposable {
private _initCustomComponents(): void {
const componentManager = this._componentManager;
this.disposeWithMe(componentManager.register(COMPONENT_SHEET_DRAWING_PANEL, SheetDrawingPanel));
this.disposeWithMe(componentManager.register(BATCH_SAVE_IMAGES_DIALOG_ID, BatchSaveImagesDialog));
this.disposeWithMe(componentManager.register('DownloadImageIcon', DownloadImageIcon));
}
private _initMenus(): void {
@@ -75,6 +80,7 @@ export class SheetDrawingUIController extends Disposable {
MoveDrawingsCommand,
DeleteDrawingsCommand,
SetDrawingArrangeCommand,
SaveCellImagesCommand,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
+3 -1
View File
@@ -24,6 +24,7 @@ export { InsertSheetDrawingCommand } from './commands/commands/insert-sheet-draw
export type { IDeleteDrawingCommandParams, IInsertDrawingCommandParams, ISetDrawingCommandParams } from './commands/commands/interfaces';
export { MoveDrawingsCommand } from './commands/commands/move-drawings.command';
export { RemoveSheetDrawingCommand } from './commands/commands/remove-sheet-drawing.command';
export { SaveCellImagesCommand } from './commands/commands/save-cell-images.command';
export { SetDrawingArrangeCommand } from './commands/commands/set-drawing-arrange.command';
export { SetSheetDrawingCommand } from './commands/commands/set-sheet-drawing.command';
export { UngroupSheetDrawingCommand } from './commands/commands/ungroup-sheet-drawing.command';
@@ -33,6 +34,7 @@ export { SidebarSheetDrawingOperation } from './commands/operations/open-drawing
export type { IUniverSheetsDrawingUIConfig } from './controllers/config.schema';
export { SheetDrawingUpdateController } from './controllers/sheet-drawing-update.controller';
export { UniverSheetsDrawingUIPlugin } from './plugin';
export { calcSheetFloatDomPosition, type ICanvasFloatDom, type ICanvasFloatDomInfo, type IDOMAnchor, SHEET_FLOAT_DOM_PREFIX, SheetCanvasFloatDomManagerService } from './services/canvas-float-dom-manager.service';
export { BatchSaveImagesService, FileNamePart, type IBatchSaveImagesConfig, IBatchSaveImagesService, type ICellImageInfo } from './services/batch-save-images.service';
export { calcSheetFloatDomPosition, type ICanvasFloatDom, type ICanvasFloatDomInfo, type IDOMAnchor, SHEET_FLOAT_DOM_PREFIX, SheetCanvasFloatDomManagerService } from './services/canvas-float-dom-manager.service';
export { SHEETS_IMAGE_MENU_ID } from './views/menu/image.menu';
@@ -28,6 +28,20 @@ const locale: typeof enUS = {
panel: {
title: 'Edita la imatge',
},
save: {
title: 'Desa les imatges de cel·la',
menuLabel: 'Desa les imatges de cel·la',
imageCount: 'Nombre d\'imatges',
fileNameConfig: 'Nom del fitxer',
useRowCol: 'Utilitza l\'adreça de la cel·la (A1, B2...)',
useColumnValue: 'Utilitza el valor de la columna',
selectColumn: 'Selecciona la columna',
cancel: 'Cancel·la',
confirm: 'Desa',
saving: 'Desant...',
error: 'No s\'han pogut desar les imatges de cel·la',
},
},
'image-popup': {
replace: 'Reemplaça',
@@ -26,6 +26,20 @@ const locale = {
panel: {
title: 'Edit Image',
},
save: {
title: 'Save Cell Images',
menuLabel: 'Save Cell Images',
imageCount: 'Image Count',
fileNameConfig: 'File Name',
useRowCol: 'Use Cell Address (A1, B2...)',
useColumnValue: 'Use Column Value',
selectColumn: 'Select Column',
cancel: 'Cancel',
confirm: 'Save',
saving: 'Saving...',
error: 'Failed to save cell images',
},
},
'image-popup': {
replace: 'Replace',
@@ -28,6 +28,20 @@ const locale: typeof enUS = {
panel: {
title: 'Editar imagen',
},
save: {
title: 'Guardar imágenes de celda',
menuLabel: 'Guardar imágenes de celda',
imageCount: 'Cantidad de imágenes',
fileNameConfig: 'Nombre del archivo',
useRowCol: 'Usar dirección de celda (A1, B2...)',
useColumnValue: 'Usar valor de columna',
selectColumn: 'Seleccionar columna',
cancel: 'Cancelar',
confirm: 'Guardar',
saving: 'Guardando...',
error: 'Error al guardar las imágenes de celda',
},
},
'image-popup': {
replace: 'Reemplazar',
@@ -28,6 +28,20 @@ const locale: typeof enUS = {
panel: {
title: 'ویرایش تصویر',
},
save: {
title: 'ذخیره تصاویر سلول',
menuLabel: 'ذخیره تصاویر سلول',
imageCount: 'تعداد تصاویر',
fileNameConfig: 'نام فایل',
useRowCol: 'استفاده از آدرس سلول (A1, B2...)',
useColumnValue: 'استفاده از مقدار ستون',
selectColumn: 'انتخاب ستون',
cancel: 'لغو',
confirm: 'ذخیره',
saving: 'در حال ذخیره...',
error: 'ذخیره تصاویر سلول ناموفق بود',
},
},
'image-popup': {
replace: 'تعویض',
@@ -28,6 +28,20 @@ const locale: typeof enUS = {
panel: {
title: 'Modifier l\'image',
},
save: {
title: 'Enregistrer les images de cellule',
menuLabel: 'Enregistrer les images de cellule',
imageCount: 'Nombre d\'images',
fileNameConfig: 'Nom du fichier',
useRowCol: 'Utiliser l\'adresse de cellule (A1, B2...)',
useColumnValue: 'Utiliser la valeur de la colonne',
selectColumn: 'Sélectionner la colonne',
cancel: 'Annuler',
confirm: 'Enregistrer',
saving: 'Enregistrement...',
error: 'Échec de l\'enregistrement des images de cellule',
},
},
'image-popup': {
replace: 'Remplacer',
@@ -28,6 +28,20 @@ const locale: typeof enUS = {
panel: {
title: '画像の編集',
},
save: {
title: 'セル画像を保存',
menuLabel: 'セル画像を保存',
imageCount: '画像数',
fileNameConfig: 'ファイル名',
useRowCol: 'セルアドレスを使用 (A1, B2...)',
useColumnValue: '列の値を使用',
selectColumn: '列を選択',
cancel: 'キャンセル',
confirm: '保存',
saving: '保存中...',
error: 'セル画像の保存に失敗しました',
},
},
'image-popup': {
replace: '画像の変更',
@@ -28,6 +28,20 @@ const locale: typeof enUS = {
panel: {
title: '이미지 편집',
},
save: {
title: '셀 이미지 저장',
menuLabel: '셀 이미지 저장',
imageCount: '이미지 수',
fileNameConfig: '파일 이름',
useRowCol: '셀 주소 사용 (A1, B2...)',
useColumnValue: '열 값 사용',
selectColumn: '열 선택',
cancel: '취소',
confirm: '저장',
saving: '저장 중...',
error: '셀 이미지 저장 실패',
},
},
'image-popup': {
replace: '바꾸기',
@@ -28,6 +28,20 @@ const locale: typeof enUS = {
panel: {
title: 'Редактировать изображение',
},
save: {
title: 'Сохранить изображения ячеек',
menuLabel: 'Сохранить изображения ячеек',
imageCount: 'Количество изображений',
fileNameConfig: 'Имя файла',
useRowCol: 'Использовать адрес ячейки (A1, B2...)',
useColumnValue: 'Использовать значение столбца',
selectColumn: 'Выбрать столбец',
cancel: 'Отмена',
confirm: 'Сохранить',
saving: 'Сохранение...',
error: 'Не удалось сохранить изображения ячеек',
},
},
'image-popup': {
replace: 'Заменить',
@@ -28,6 +28,20 @@ const locale: typeof enUS = {
panel: {
title: 'Chỉnh sửa hình ảnh',
},
save: {
title: 'Lưu hình ảnh ô',
menuLabel: 'Lưu hình ảnh ô',
imageCount: 'Số lượng hình ảnh',
fileNameConfig: 'Tên tệp',
useRowCol: 'Sử dụng địa chỉ ô (A1, B2...)',
useColumnValue: 'Sử dụng giá trị cột',
selectColumn: 'Chọn cột',
cancel: 'Hủy',
confirm: 'Lưu',
saving: 'Đang lưu...',
error: 'Lưu hình ảnh ô thất bại',
},
},
'image-popup': {
replace: 'Thay thế',
@@ -28,6 +28,20 @@ const locale: typeof enUS = {
panel: {
title: '编辑图片',
},
save: {
title: '保存单元格图片',
menuLabel: '保存单元格图片',
imageCount: '图片数量',
fileNameConfig: '文件名',
useRowCol: '使用单元格地址 (A1, B2...)',
useColumnValue: '使用某列的值',
selectColumn: '选择列',
cancel: '取消',
confirm: '保存',
saving: '保存中...',
error: '保存单元格图片失败',
},
},
'image-popup': {
replace: '替换',
@@ -28,6 +28,20 @@ const locale: typeof enUS = {
panel: {
title: '編圖',
},
save: {
title: '儲存儲存格圖片',
menuLabel: '儲存儲存格圖片',
imageCount: '圖片數量',
fileNameConfig: '檔案名稱',
useRowCol: '使用儲存格地址 (A1, B2...)',
useColumnValue: '使用某欄的值',
selectColumn: '選擇欄',
cancel: '取消',
confirm: '儲存',
saving: '儲存中...',
error: '儲存儲存格圖片失敗',
},
},
'image-popup': {
replace: '替換',
+2
View File
@@ -45,6 +45,7 @@ import { SheetDrawingPrintingController } from './controllers/sheet-drawing-prin
import { SheetDrawingTransformAffectedController } from './controllers/sheet-drawing-transform-affected.controller';
import { SheetDrawingUpdateController } from './controllers/sheet-drawing-update.controller';
import { SheetDrawingUIController } from './controllers/sheet-drawing.controller';
import { BatchSaveImagesService, IBatchSaveImagesService } from './services/batch-save-images.service';
import { SheetCanvasFloatDomManagerService } from './services/canvas-float-dom-manager.service';
const PLUGIN_NAME = 'SHEET_IMAGE_UI_PLUGIN';
@@ -85,6 +86,7 @@ export class UniverSheetsDrawingUIPlugin extends Plugin {
[SheetCellImageController],
[SheetCellImageAutofillController],
[SheetCellImageCopyPasteController],
[IBatchSaveImagesService, { useClass: BatchSaveImagesService }],
]);
touchDependencies(this._injector, [
@@ -0,0 +1,460 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICellData, IRange, Nullable, Workbook } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import { createIdentifier, Disposable, IImageIoService, ImageSourceType, Inject, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { SheetsSelectionsService } from '@univerjs/sheets';
/**
* File name part type for multi-select
*/
export enum FileNamePart {
/**
* Use cell address as part of file name (e.g., A1, B2)
*/
CELL_ADDRESS = 'cellAddress',
/**
* Use value from a specific column as part of file name
*/
COLUMN_VALUE = 'columnValue',
}
export interface ICellImageInfo {
row: number;
col: number;
cellAddress: string;
source: string;
imageSourceType: ImageSourceType;
imageId: string;
}
export interface IBatchSaveImagesConfig {
/**
* Selected file name parts (multi-select)
*/
fileNameParts: FileNamePart[];
/**
* Column index for COLUMN_VALUE part
*/
columnIndex?: number;
}
export interface IBatchSaveImagesService {
/**
* Get all cell images in the current selection
*/
getCellImagesInSelection(): ICellImageInfo[];
/**
* Get columns that have data in the current selection
*/
getDataColumns(): Array<{ index: number; label: string }>;
/**
* Get current selection range as A1 notation
*/
getSelectionRangeNotation(): string;
/**
* Generate file name for a cell image based on config
* @param imageInfo The cell image info
* @param config The file name configuration
*/
generateFileName(imageInfo: ICellImageInfo, config: IBatchSaveImagesConfig): string;
/**
* Save images to the file system
* @param images The images to save
* @param config The file name configuration
*/
saveImages(images: ICellImageInfo[], config: IBatchSaveImagesConfig): Promise<void>;
/**
* Download a single image directly
* @param imageInfo The cell image info
*/
downloadSingleImage(imageInfo: ICellImageInfo): Promise<void>;
/**
* Get the row range of current selection
* Returns the min and max row indices
*/
getSelectionRowRange(): { startRow: number; endRow: number } | null;
/**
* Get all column indices that are within the current selection
*/
getSelectionColumnIndices(): Set<number>;
}
export const IBatchSaveImagesService = createIdentifier<IBatchSaveImagesService>('sheets-drawing-ui.batch-save-images.service');
/**
* Convert column index to letter (0 -> A, 1 -> B, etc.)
*/
function columnIndexToLetter(index: number): string {
let letter = '';
let temp = index;
while (temp >= 0) {
letter = String.fromCharCode((temp % 26) + 65) + letter;
temp = Math.floor(temp / 26) - 1;
}
return letter;
}
/**
* Convert row and column to A1 notation
*/
function toA1Notation(row: number, col: number): string {
return `${columnIndexToLetter(col)}${row + 1}`;
}
/**
* Convert range to A1 notation
*/
function rangeToA1Notation(range: IRange): string {
const start = toA1Notation(range.startRow, range.startColumn);
const end = toA1Notation(range.endRow, range.endColumn);
return start === end ? start : `${start}:${end}`;
}
/**
* Check if a cell has image
*/
function cellHasImage(cell: Nullable<ICellData>): boolean {
return !!(cell?.p?.drawingsOrder?.length && cell?.p?.drawingsOrder?.length > 0);
}
/**
* Get image data from cell
*/
function getCellImageData(cell: ICellData): IImageData | null {
if (!cell.p?.drawingsOrder?.length || !cell.p?.drawings) {
return null;
}
const drawingId = cell.p.drawingsOrder[0];
const drawing = cell.p.drawings[drawingId];
if (!drawing || !('source' in drawing) || !('imageSourceType' in drawing)) {
return null;
}
return drawing as unknown as IImageData;
}
/**
* Get file extension from mime type or source
*/
function getFileExtension(source: string, imageSourceType: ImageSourceType): string {
if (imageSourceType === ImageSourceType.BASE64) {
const match = source.match(/^data:image\/(\w+);/);
if (match) {
return match[1] === 'jpeg' ? 'jpg' : match[1];
}
}
// Try to get extension from URL
if (imageSourceType === ImageSourceType.URL) {
const urlMatch = source.match(/\.(\w+)(?:\?|$)/);
if (urlMatch) {
return urlMatch[1].toLowerCase();
}
}
// Default to png
return 'png';
}
/**
* Convert image source to blob
*/
async function imageSourceToBlob(source: string, imageSourceType: ImageSourceType): Promise<Blob> {
if (imageSourceType === ImageSourceType.BASE64) {
const response = await fetch(source);
return response.blob();
}
if (imageSourceType === ImageSourceType.URL) {
const response = await fetch(source);
return response.blob();
}
// For UUID type, we need to get the actual URL from the service
throw new Error('UUID image type requires additional handling');
}
export class BatchSaveImagesService extends Disposable implements IBatchSaveImagesService {
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(SheetsSelectionsService) private readonly _selectionService: SheetsSelectionsService,
@IImageIoService private readonly _imageIoService: IImageIoService
) {
super();
}
getCellImagesInSelection(): ICellImageInfo[] {
const workbook = this._univerInstanceService.getCurrentUnitForType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) return [];
const worksheet = workbook.getActiveSheet();
if (!worksheet) return [];
const selections = this._selectionService.getCurrentSelections();
if (!selections || selections.length === 0) return [];
const cellMatrix = worksheet.getCellMatrix();
const images: ICellImageInfo[] = [];
for (const selection of selections) {
const { startRow, endRow, startColumn, endColumn } = selection.range;
for (let row = startRow; row <= endRow; row++) {
for (let col = startColumn; col <= endColumn; col++) {
const cell = cellMatrix.getValue(row, col);
if (cellHasImage(cell)) {
const imageData = getCellImageData(cell!);
if (imageData) {
images.push({
row,
col,
cellAddress: toA1Notation(row, col),
source: imageData.source,
imageSourceType: imageData.imageSourceType,
imageId: imageData.drawingId,
});
}
}
}
}
}
return images;
}
getDataColumns(): Array<{ index: number; label: string }> {
const workbook = this._univerInstanceService.getCurrentUnitForType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) return [];
const worksheet = workbook.getActiveSheet();
if (!worksheet) return [];
const selections = this._selectionService.getCurrentSelections();
if (!selections || selections.length === 0) return [];
const cellMatrix = worksheet.getCellMatrix();
const dataRange = cellMatrix.getDataRange();
// Get row range and column indices from selection
let minRow = Infinity;
let maxRow = -Infinity;
const selectionColumnIndices = new Set<number>();
for (const selection of selections) {
minRow = Math.min(minRow, selection.range.startRow);
maxRow = Math.max(maxRow, selection.range.endRow);
// Collect all column indices within selection
for (let col = selection.range.startColumn; col <= selection.range.endColumn; col++) {
selectionColumnIndices.add(col);
}
}
// Find columns that have values in the selection row range, excluding selection columns
const columnsWithData = new Set<number>();
for (let col = dataRange.startColumn; col <= dataRange.endColumn; col++) {
// Skip columns that are within the selection
if (selectionColumnIndices.has(col)) {
continue;
}
for (let row = minRow; row <= maxRow; row++) {
const cell = cellMatrix.getValue(row, col);
if (cell) {
const value = cell.v?.toString() || cell.p?.body?.dataStream?.trim() || '';
if (value) {
columnsWithData.add(col);
break; // Found data in this column, move to next column
}
}
}
}
// Convert to array and sort
const columns: Array<{ index: number; label: string }> = [];
const sortedCols = Array.from(columnsWithData).sort((a, b) => a - b);
for (const col of sortedCols) {
columns.push({
index: col,
label: columnIndexToLetter(col),
});
}
return columns;
}
getSelectionRangeNotation(): string {
const selections = this._selectionService.getCurrentSelections();
if (!selections || selections.length === 0) return '';
return selections.map((s) => rangeToA1Notation(s.range)).join(', ');
}
getSelectionRowRange(): { startRow: number; endRow: number } | null {
const selections = this._selectionService.getCurrentSelections();
if (!selections || selections.length === 0) return null;
let minRow = Infinity;
let maxRow = -Infinity;
for (const selection of selections) {
minRow = Math.min(minRow, selection.range.startRow);
maxRow = Math.max(maxRow, selection.range.endRow);
}
return { startRow: minRow, endRow: maxRow };
}
getSelectionColumnIndices(): Set<number> {
const selections = this._selectionService.getCurrentSelections();
if (!selections || selections.length === 0) return new Set();
const columnIndices = new Set<number>();
for (const selection of selections) {
for (let col = selection.range.startColumn; col <= selection.range.endColumn; col++) {
columnIndices.add(col);
}
}
return columnIndices;
}
generateFileName(imageInfo: ICellImageInfo, config: IBatchSaveImagesConfig): string {
const workbook = this._univerInstanceService.getCurrentUnitForType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const extension = getFileExtension(imageInfo.source, imageInfo.imageSourceType);
const parts: string[] = [];
// Process each selected file name part in order
for (const part of config.fileNameParts) {
if (part === FileNamePart.CELL_ADDRESS) {
parts.push(imageInfo.cellAddress);
} else if (part === FileNamePart.COLUMN_VALUE && config.columnIndex !== undefined) {
const worksheet = workbook?.getActiveSheet();
if (worksheet) {
const cellMatrix = worksheet.getCellMatrix();
const cell = cellMatrix.getValue(imageInfo.row, config.columnIndex);
if (cell) {
// Get cell display value
const value = cell.v?.toString() || cell.p?.body?.dataStream?.trim() || '';
if (value) {
// Sanitize file name (remove invalid characters)
const sanitized = value.replace(/[<>:"/\\|?*]/g, '_').trim();
if (sanitized) {
parts.push(sanitized);
}
// If sanitized is empty, skip this part (don't add anything)
}
// If value is empty, skip this part
}
// If cell is empty, skip this part
}
}
}
// If no parts selected or all parts failed, use cell address as default
if (parts.length === 0) {
return `${imageInfo.cellAddress}.${extension}`;
}
// Join parts with underscore
return `${parts.join('_')}.${extension}`;
}
async saveImages(images: ICellImageInfo[], config: IBatchSaveImagesConfig): Promise<void> {
// Request directory access using File System Access API
// eslint-disable-next-line ts/no-explicit-any
const dirHandle = await (window as any).showDirectoryPicker({ mode: 'readwrite' });
// Track file names to handle duplicates
const fileNameCounts = new Map<string, number>();
for (const imageInfo of images) {
let fileName = this.generateFileName(imageInfo, config);
// Handle duplicate file names
const baseName = fileName.replace(/\.\w+$/, '');
const ext = fileName.match(/\.\w+$/)?.[0] || '.png';
const count = fileNameCounts.get(baseName) || 0;
if (count > 0) {
fileName = `${baseName}_${count}${ext}`;
}
fileNameCounts.set(baseName, count + 1);
try {
// Get image blob
const blob = await this._getImageBlob(imageInfo);
// Create file and write
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} catch (error) {
console.error(`Failed to save image ${fileName}:`, error);
throw error;
}
}
}
async downloadSingleImage(imageInfo: ICellImageInfo): Promise<void> {
const extension = getFileExtension(imageInfo.source, imageInfo.imageSourceType);
const fileName = `${imageInfo.cellAddress}.${extension}`;
try {
const blob = await this._getImageBlob(imageInfo);
// Create download link
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error(`Failed to download image ${fileName}:`, error);
throw error;
}
}
private async _getImageBlob(imageInfo: ICellImageInfo): Promise<Blob> {
if (imageInfo.imageSourceType === ImageSourceType.UUID) {
// For UUID, we need to get the actual image from the service
const imageUrl = await this._imageIoService.getImage(imageInfo.source);
return imageSourceToBlob(imageUrl, ImageSourceType.URL);
}
return imageSourceToBlob(imageInfo.source, imageInfo.imageSourceType);
}
}
@@ -0,0 +1,164 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IRange } from '@univerjs/core';
import { LocaleService } from '@univerjs/core';
import { Button, Checkbox, CheckboxGroup, FormLayout, Select } from '@univerjs/design';
import { useHighlightRange } from '@univerjs/sheets-ui';
import { IDialogService, useDependency } from '@univerjs/ui';
import { useCallback, useMemo, useState } from 'react';
import { FileNamePart, IBatchSaveImagesService } from '../../services/batch-save-images.service';
import { BATCH_SAVE_IMAGES_DIALOG_ID } from './component-name';
export function BatchSaveImagesDialog() {
const localeService = useDependency(LocaleService);
const dialogService = useDependency(IDialogService);
const batchSaveService = useDependency(IBatchSaveImagesService);
const [fileNameParts, setFileNameParts] = useState<Array<string | number | boolean>>([FileNamePart.CELL_ADDRESS]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const images = useMemo(() => batchSaveService.getCellImagesInSelection(), [batchSaveService]);
const dataColumns = useMemo(() => batchSaveService.getDataColumns(), [batchSaveService]);
const rowRange = useMemo(() => batchSaveService.getSelectionRowRange(), [batchSaveService]);
// Check if there are available columns to select (columns outside the selection range)
const hasAvailableColumns = dataColumns.length > 0;
const columnOptions = useMemo(() => {
return dataColumns.map((col) => ({
label: col.label,
value: String(col.index),
}));
}, [dataColumns]);
const [selectedColumn, setSelectedColumn] = useState<string>(
() => columnOptions.length > 0 ? columnOptions[0].value : '0'
);
// Calculate highlight range based on selected column and original selection row range
const highlightRanges = useMemo<IRange[]>(() => {
const showColumnSelect = fileNameParts.includes(FileNamePart.COLUMN_VALUE);
if (!showColumnSelect || !rowRange) {
return [];
}
const colIndex = Number(selectedColumn);
return [{
startRow: rowRange.startRow,
endRow: rowRange.endRow,
startColumn: colIndex,
endColumn: colIndex,
}];
}, [fileNameParts, selectedColumn, rowRange]);
// Highlight the selected column range
useHighlightRange(highlightRanges);
const handleFileNamePartsChange = useCallback((value: Array<string | number | boolean>) => {
// Ensure at least one option is selected
if (value.length === 0) {
return;
}
setFileNameParts(value);
}, []);
const handleColumnChange = useCallback((value: string | number | boolean) => {
setSelectedColumn(String(value));
}, []);
const handleCancel = useCallback(() => {
dialogService.close(BATCH_SAVE_IMAGES_DIALOG_ID);
}, [dialogService]);
const handleConfirm = useCallback(async () => {
if (images.length === 0) {
return;
}
setSaving(true);
setError(null);
try {
await batchSaveService.saveImages(images, {
fileNameParts: fileNameParts as FileNamePart[],
columnIndex: fileNameParts.includes(FileNamePart.COLUMN_VALUE) ? Number(selectedColumn) : undefined,
});
dialogService.close(BATCH_SAVE_IMAGES_DIALOG_ID);
} catch (err) {
console.error('Failed to save images:', err);
setError(localeService.t('sheetImage.save.error'));
} finally {
setSaving(false);
}
}, [batchSaveService, images, fileNameParts, selectedColumn, dialogService, localeService]);
const showColumnSelect = fileNameParts.includes(FileNamePart.COLUMN_VALUE);
return (
<div className="univer-flex univer-flex-col">
<FormLayout label={localeService.t('sheetImage.save.imageCount')}>
<div className="univer-text-sm univer-text-gray-600">{images.length}</div>
</FormLayout>
<FormLayout label={localeService.t('sheetImage.save.fileNameConfig')}>
<CheckboxGroup value={fileNameParts} onChange={handleFileNamePartsChange} direction="vertical">
<Checkbox value={FileNamePart.CELL_ADDRESS} disabled={!hasAvailableColumns}>
{localeService.t('sheetImage.save.useRowCol')}
</Checkbox>
{hasAvailableColumns && (
<Checkbox value={FileNamePart.COLUMN_VALUE}>
{localeService.t('sheetImage.save.useColumnValue')}
</Checkbox>
)}
</CheckboxGroup>
</FormLayout>
{showColumnSelect && (
<FormLayout label={localeService.t('sheetImage.save.selectColumn')}>
<Select
value={selectedColumn}
options={columnOptions}
onChange={handleColumnChange}
/>
</FormLayout>
)}
{error && (
<div className="univer-text-xs univer-text-red-500">{error}</div>
)}
<div
className={`
univer-flex univer-justify-end univer-gap-2 univer-border-t univer-border-gray-200 univer-pt-3
`}
>
<Button onClick={handleCancel} disabled={saving}>
{localeService.t('sheetImage.save.cancel')}
</Button>
<Button
variant="primary"
onClick={handleConfirm}
disabled={saving || images.length === 0}
>
{saving ? localeService.t('sheetImage.save.saving') : localeService.t('sheetImage.save.confirm')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const BATCH_SAVE_IMAGES_DIALOG_ID = 'sheet.dialog.batch-save-images';
@@ -0,0 +1,18 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { BatchSaveImagesDialog } from './BatchSaveImagesDialog';
export { BATCH_SAVE_IMAGES_DIALOG_ID } from './component-name';
@@ -0,0 +1,112 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICellData, IRange, Nullable, Workbook } from '@univerjs/core';
import type { IMenuItem } from '@univerjs/ui';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { combineLatest, map, of, switchMap } from 'rxjs';
import { SaveCellImagesCommand } from '../../commands/commands/save-cell-images.command';
export const SAVE_CELL_IMAGES_MENU_ID = 'sheet.menu.save-cell-images';
/**
* Check if a cell has image
*/
function cellHasImage(cell: Nullable<ICellData>): boolean {
return !!(cell?.p?.drawingsOrder?.length && cell?.p?.drawingsOrder?.length > 0);
}
/**
* Check if selection range has any images
*/
function selectionHasImages(
workbook: Workbook,
selection: IRange
): boolean {
const worksheet = workbook.getActiveSheet();
if (!worksheet) return false;
const cellMatrix = worksheet.getCellMatrix();
const { startRow, endRow, startColumn, endColumn } = selection;
for (let row = startRow; row <= endRow; row++) {
for (let col = startColumn; col <= endColumn; col++) {
const cell = cellMatrix.getValue(row, col);
if (cellHasImage(cell)) {
return true;
}
}
}
return false;
}
/**
* Check if File System Access API is supported
*/
function isFileSystemAccessSupported(): boolean {
return 'showDirectoryPicker' in window;
}
export function SaveCellImagesMenuFactory(accessor: IAccessor): IMenuItem {
const univerInstanceService = accessor.get(IUniverInstanceService);
const selectionService = accessor.get(SheetsSelectionsService);
// Hide menu if File System Access API is not supported or no images in selection
const hidden$ = combineLatest([
getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET).pipe(
switchMap((workbook) => {
if (!workbook) return of(true);
return selectionService.selectionMoveEnd$.pipe(
map(() => {
// Hide if File System Access API is not supported
if (!isFileSystemAccessSupported()) {
return true;
}
const selections = selectionService.getCurrentSelections();
if (!selections || selections.length === 0) {
return true;
}
// Check if any selection has images
for (const selection of selections) {
if (selectionHasImages(workbook, selection.range)) {
return false;
}
}
return true;
})
);
})
),
]).pipe(
map(([hidden, noImages]) => hidden || noImages)
);
return {
id: SaveCellImagesCommand.id,
type: MenuItemType.BUTTON,
icon: 'DownloadImageIcon',
title: 'sheetImage.save.menuLabel',
hidden$,
};
}
@@ -30,16 +30,7 @@ export const useHighlightRange = (ranges: IRange[] = []) => {
stroke: '#49B811',
widgets: {},
},
primary: {
startColumn: range.startColumn,
endColumn: range.endColumn,
startRow: range.startRow,
endRow: range.endRow,
actualRow: range.startRow,
actualColumn: range.startColumn,
isMerged: false,
isMergedMainCell: false,
},
primary: null,
}));
return () => {
ids.forEach((id) => {
+3
View File
@@ -2249,6 +2249,9 @@ importers:
'@univerjs/engine-render':
specifier: workspace:*
version: link:../engine-render
'@univerjs/icons':
specifier: ^1.0.2
version: 1.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@univerjs/sheets':
specifier: workspace:*
version: link:../sheets