feat(sheets-drawing-ui): add batch save images functionality with customizable file naming options (#6252)

This commit is contained in:
WEI ZHANG
2025-12-05 17:04:02 +08:00
committed by GitHub
parent 173168007d
commit 2fce949593
3 changed files with 394 additions and 5 deletions
@@ -17,9 +17,24 @@
import type { ISheetLocationBase } from '@univerjs/sheets';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { getCurrentTypeOfRenderer, IRenderManagerService } from '@univerjs/engine-render';
import { SheetDrawingUpdateController } from '@univerjs/sheets-drawing-ui';
import { FileNamePart, IBatchSaveImagesService, SheetDrawingUpdateController } from '@univerjs/sheets-drawing-ui';
import { FRange } from '@univerjs/sheets/facade';
/**
* Options for saving cell images
*/
export interface ISaveCellImagesOptions {
/**
* Whether to use cell address in file name (e.g., A1, B2)
* @default true
*/
useCellAddress?: boolean;
/**
* Column index to use for file name (0-based). If specified, the value from this column will be used in file name.
*/
useColumnIndex?: number;
}
export interface IFRangeSheetDrawingMixin {
/**
* Inserts an image into the current cell.
@@ -38,6 +53,32 @@ export interface IFRangeSheetDrawingMixin {
* ```
*/
insertCellImageAsync(file: File | string): Promise<boolean>;
/**
* Save all cell images in this range to the file system.
* This method will open a directory picker dialog and save all images to the selected directory.
*
* @param {ISaveCellImagesOptions} [options] Options for saving images
* @returns {Promise<boolean>} True if images are saved successfully, otherwise false
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getActiveSheet();
*
* // Save all cell images in range A1:D10
* const fRange = fWorksheet.getRange('A1:D10');
*
* // Save with default options (using cell address as file name)
* await fRange.saveCellImagesAsync();
*
* // Save with custom options
* await fRange.saveCellImagesAsync({
* useCellAddress: true,
* useColumnIndex: 0, // Use values from column A for file names
* });
* ```
*/
saveCellImagesAsync(options?: ISaveCellImagesOptions): Promise<boolean>;
}
export class FRangeSheetDrawingUI extends FRange implements IFRangeSheetDrawingMixin {
@@ -61,6 +102,59 @@ export class FRangeSheetDrawingUI extends FRange implements IFRangeSheetDrawingM
return controller.insertCellImageByFile(file, location);
}
}
override async saveCellImagesAsync(options?: ISaveCellImagesOptions): Promise<boolean> {
const batchSaveService = this._injector.get(IBatchSaveImagesService);
const unitId = this._workbook.getUnitId();
const subUnitId = this._worksheet.getSheetId();
const range = this.getRange();
// Get images in the range
const images = batchSaveService.getCellImagesFromRanges(unitId, subUnitId, [range]);
if (images.length === 0) {
return false;
}
// If only one image, download directly
if (images.length === 1) {
try {
await batchSaveService.downloadSingleImage(images[0]);
return true;
} catch (error) {
console.error('Failed to download image:', error);
return false;
}
}
// Build config from options
const fileNameParts: FileNamePart[] = [];
const useCellAddress = options?.useCellAddress ?? true;
const useColumnIndex = options?.useColumnIndex;
if (useCellAddress) {
fileNameParts.push(FileNamePart.CELL_ADDRESS);
}
if (useColumnIndex !== undefined) {
fileNameParts.push(FileNamePart.COLUMN_VALUE);
}
// Ensure at least one naming option
if (fileNameParts.length === 0) {
fileNameParts.push(FileNamePart.CELL_ADDRESS);
}
try {
await batchSaveService.saveImagesWithContext(images, {
fileNameParts,
columnIndex: useColumnIndex,
}, unitId, subUnitId);
return true;
} catch (error) {
console.error('Failed to save images:', error);
return false;
}
}
}
FRange.extend(FRangeSheetDrawingUI);
@@ -20,10 +20,11 @@ import type { ISheetFloatDom, ISheetImage } from '@univerjs/sheets-drawing';
import type { ICanvasFloatDom, ICanvasFloatDomInfo, IDOMAnchor } from '@univerjs/sheets-drawing-ui';
import type { IFComponentKey } from '@univerjs/sheets-ui/facade';
import type { FRange } from '@univerjs/sheets/facade';
import type { ISaveCellImagesOptions } from './f-range';
import { DrawingTypeEnum, ImageSourceType, toDisposable } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ISheetDrawingService } from '@univerjs/sheets-drawing';
import { InsertSheetDrawingCommand, RemoveSheetDrawingCommand, SetSheetDrawingCommand, SheetCanvasFloatDomManagerService, transformToDrawingPosition } from '@univerjs/sheets-drawing-ui';
import { FileNamePart, IBatchSaveImagesService, InsertSheetDrawingCommand, RemoveSheetDrawingCommand, SetSheetDrawingCommand, SheetCanvasFloatDomManagerService, transformToDrawingPosition } from '@univerjs/sheets-drawing-ui';
import { ISheetSelectionRenderService } from '@univerjs/sheets-ui';
import { transformComponentKey } from '@univerjs/sheets-ui/facade';
import { FWorksheet } from '@univerjs/sheets/facade';
@@ -588,6 +589,34 @@ export interface IFWorksheetLegacy {
* ```
*/
newOverGridImage(): FOverGridImageBuilder;
/**
* Save all cell images from specified ranges to the file system.
* This method will open a directory picker dialog and save all images to the selected directory.
*
* @param {ISaveCellImagesOptions} [options] - Options for saving images
* @param {FRange[]} [ranges] - The ranges to get cell images from. If not provided, all images in the worksheet will be saved.
* @returns {Promise<boolean>} True if images are saved successfully, otherwise false
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getActiveSheet();
*
* // Save cell images from multiple ranges
* const range1 = fWorksheet.getRange('A1:B10');
* const range2 = fWorksheet.getRange('D1:E10');
*
* // Save with default options (using cell address as file name)
* await fWorksheet.saveCellImagesAsync(undefined, [range1, range2]);
*
* // Save with custom options
* await fWorksheet.saveCellImagesAsync({
* useCellAddress: true,
* useColumnIndex: 2, // Use values from column C for file names
* }, [range1, range2]);
* ```
*/
saveCellImagesAsync(options?: ISaveCellImagesOptions, ranges?: FRange[]): Promise<boolean>;
}
export class FWorksheetLegacy extends FWorksheet implements IFWorksheetLegacy {
@@ -1011,6 +1040,61 @@ export class FWorksheetLegacy extends FWorksheet implements IFWorksheetLegacy {
const subUnitId = this.getSheetId();
return this._injector.createInstance(FOverGridImageBuilder, unitId, subUnitId);
}
override async saveCellImagesAsync(options?: ISaveCellImagesOptions, ranges?: FRange[]): Promise<boolean> {
const batchSaveService = this._injector.get(IBatchSaveImagesService);
const unitId = this._fWorkbook.getId();
const subUnitId = this.getSheetId();
// Get all ranges as IRange array
const iRanges = ranges ? ranges.map((r) => r.getRange()) : [this._worksheet.getCellMatrix().getDataRange()];
// Get images from all ranges
const images = batchSaveService.getCellImagesFromRanges(unitId, subUnitId, iRanges);
if (images.length === 0) {
return false;
}
// If only one image, download directly
if (images.length === 1) {
try {
await batchSaveService.downloadSingleImage(images[0]);
return true;
} catch (error) {
console.error('Failed to download image:', error);
return false;
}
}
// Build config from options
const fileNameParts: FileNamePart[] = [];
const useCellAddress = options?.useCellAddress ?? true;
const useColumnIndex = options?.useColumnIndex;
if (useCellAddress) {
fileNameParts.push(FileNamePart.CELL_ADDRESS);
}
if (useColumnIndex !== undefined) {
fileNameParts.push(FileNamePart.COLUMN_VALUE);
}
// Ensure at least one naming option
if (fileNameParts.length === 0) {
fileNameParts.push(FileNamePart.CELL_ADDRESS);
}
try {
await batchSaveService.saveImagesWithContext(images, {
fileNameParts,
columnIndex: useColumnIndex,
}, unitId, subUnitId);
return true;
} catch (error) {
console.error('Failed to save images:', error);
return false;
}
}
}
FWorksheet.extend(FWorksheetLegacy);
@@ -19,6 +19,13 @@ import type { IImageData } from '@univerjs/drawing';
import { createIdentifier, Disposable, IImageIoService, ImageSourceType, Inject, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { SheetsSelectionsService } from '@univerjs/sheets';
declare global {
// eslint-disable-next-line ts/naming-convention
interface Window {
showDirectoryPicker(options?: { mode?: 'read' | 'readwrite' }): Promise<FileSystemDirectoryHandle>;
}
}
/**
* File name part type for multi-select
*/
@@ -59,11 +66,27 @@ export interface IBatchSaveImagesService {
*/
getCellImagesInSelection(): ICellImageInfo[];
/**
* Get cell images from specified ranges
* @param unitId The workbook unit ID
* @param subUnitId The worksheet ID
* @param ranges The ranges to get images from
*/
getCellImagesFromRanges(unitId: string, subUnitId: string, ranges: IRange[]): ICellImageInfo[];
/**
* Get columns that have data in the current selection
*/
getDataColumns(): Array<{ index: number; label: string }>;
/**
* Get columns that have data for specified ranges
* @param unitId The workbook unit ID
* @param subUnitId The worksheet ID
* @param ranges The ranges to check
*/
getDataColumnsForRanges(unitId: string, subUnitId: string, ranges: IRange[]): Array<{ index: number; label: string }>;
/**
* Get current selection range as A1 notation
*/
@@ -76,6 +99,15 @@ export interface IBatchSaveImagesService {
*/
generateFileName(imageInfo: ICellImageInfo, config: IBatchSaveImagesConfig): string;
/**
* Generate file name with specified worksheet context
* @param imageInfo The cell image info
* @param config The file name configuration
* @param unitId The workbook unit ID
* @param subUnitId The worksheet ID
*/
generateFileNameWithContext(imageInfo: ICellImageInfo, config: IBatchSaveImagesConfig, unitId: string, subUnitId: string): string;
/**
* Save images to the file system
* @param images The images to save
@@ -83,6 +115,15 @@ export interface IBatchSaveImagesService {
*/
saveImages(images: ICellImageInfo[], config: IBatchSaveImagesConfig): Promise<void>;
/**
* Save images to the file system with specified worksheet context
* @param images The images to save
* @param config The file name configuration
* @param unitId The workbook unit ID
* @param subUnitId The worksheet ID
*/
saveImagesWithContext(images: ICellImageInfo[], config: IBatchSaveImagesConfig, unitId: string, subUnitId: string): Promise<void>;
/**
* Download a single image directly
* @param imageInfo The cell image info
@@ -249,6 +290,43 @@ export class BatchSaveImagesService extends Disposable implements IBatchSaveImag
return images;
}
getCellImagesFromRanges(unitId: string, subUnitId: string, ranges: IRange[]): ICellImageInfo[] {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) return [];
const worksheet = workbook.getSheetBySheetId(subUnitId);
if (!worksheet) return [];
const cellMatrix = worksheet.getCellMatrix();
const images: ICellImageInfo[] = [];
for (const range of ranges) {
const { startRow, endRow, startColumn, endColumn } = 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 [];
@@ -312,6 +390,66 @@ export class BatchSaveImagesService extends Disposable implements IBatchSaveImag
return columns;
}
getDataColumnsForRanges(unitId: string, subUnitId: string, ranges: IRange[]): Array<{ index: number; label: string }> {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) return [];
const worksheet = workbook.getSheetBySheetId(subUnitId);
if (!worksheet) return [];
const cellMatrix = worksheet.getCellMatrix();
const dataRange = cellMatrix.getDataRange();
// Get row range and column indices from ranges
let minRow = Infinity;
let maxRow = -Infinity;
const rangeColumnIndices = new Set<number>();
for (const range of ranges) {
minRow = Math.min(minRow, range.startRow);
maxRow = Math.max(maxRow, range.endRow);
// Collect all column indices within ranges
for (let col = range.startColumn; col <= range.endColumn; col++) {
rangeColumnIndices.add(col);
}
}
// Find columns that have values in the row range, excluding range columns
const columnsWithData = new Set<number>();
for (let col = dataRange.startColumn; col <= dataRange.endColumn; col++) {
// Skip columns that are within the ranges
if (rangeColumnIndices.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;
}
}
}
}
// 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 '';
@@ -390,11 +528,48 @@ export class BatchSaveImagesService extends Disposable implements IBatchSaveImag
return `${parts.join('_')}.${extension}`;
}
generateFileNameWithContext(imageInfo: ICellImageInfo, config: IBatchSaveImagesConfig, unitId: string, subUnitId: string): string {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, 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?.getSheetBySheetId(subUnitId);
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 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' });
const dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
// Track file names to handle duplicates
const fileNameCounts = new Map<string, number>();
@@ -427,6 +602,42 @@ export class BatchSaveImagesService extends Disposable implements IBatchSaveImag
}
}
async saveImagesWithContext(images: ICellImageInfo[], config: IBatchSaveImagesConfig, unitId: string, subUnitId: string): Promise<void> {
// Request directory access using File System Access API
const dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
// Track file names to handle duplicates
const fileNameCounts = new Map<string, number>();
for (const imageInfo of images) {
let fileName = this.generateFileNameWithContext(imageInfo, config, unitId, subUnitId);
// 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}`;