mirror of
https://github.com/dream-num/univer.git
synced 2026-08-29 07:13:59 +08:00
feat(sheets-drawing-ui): add batch save images functionality with customizable file naming options (#6252)
This commit is contained in:
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user