import { getHtmlStringOutput, stripTags } from '@slickgrid-universal/utils'; import { SlickEvent, SlickEventData, SlickEventHandler, SlickRange, Utils as SlickUtils, type SlickDataView, type SlickGrid, } from '../core/index.js'; import type { Column, CssStyleHash, Editor, EditorConstructor, ElementPosition, ExcelCopyBufferOption, ExternalCopyClipCommand, OnEventArgs, } from '../interfaces/index.js'; import { DataWrapperService } from '../services/dataWrapperService.js'; // using external SlickGrid JS libraries const CLEAR_COPY_SELECTION_DELAY = 2000; /* v8 ignore next */ const noop = () => {}; /* This manager enables users to copy/paste data from/to an external Spreadsheet application such as MS-Excel® or OpenOffice-Spreadsheet. Since it is not possible to access directly the clipboard in JavaScript, the plugin uses a trick to do it's job. After detecting the keystroke, we dynamically create a textarea where the browser copies/pastes the serialized data. */ export class SlickCellExternalCopyManager { readonly pluginName = 'CellExternalCopyManager'; onCopyCells: SlickEvent<{ ranges: SlickRange[] }>; onCopyCancelled: SlickEvent<{ ranges: SlickRange[] }>; onPasteCells: SlickEvent<{ ranges: SlickRange[] }>; onBeforePasteCell: SlickEvent<{ cell: number; row: number; item: any; columnDef: Column; value: any }>; protected _addonOptions!: ExcelCopyBufferOption; protected _clearCopyTI?: any; protected _copiedCellStyle = 'copied'; protected _copiedCellStyleLayerKey = 'copy-manager'; protected _copiedRanges: SlickRange[] | null = null; protected _dataWrapper: DataWrapperService; protected _eventHandler: SlickEventHandler; protected _grid!: SlickGrid; protected _onCopyInit?: () => void; protected _onCopySuccess?: (rowCount: number) => void; constructor() { this._dataWrapper = new DataWrapperService(); this.onCopyCells = new SlickEvent<{ ranges: SlickRange[] }>('onCopyCells'); this.onCopyCancelled = new SlickEvent<{ ranges: SlickRange[] }>('onCopyCancelled'); this.onPasteCells = new SlickEvent<{ ranges: SlickRange[] }>('onPasteCells'); this.onBeforePasteCell = new SlickEvent<{ cell: number; row: number; item: any; columnDef: Column; value: any }>('onBeforePasteCell'); this._eventHandler = new SlickEventHandler(); } get addonOptions(): ExcelCopyBufferOption { return this._addonOptions; } get eventHandler(): SlickEventHandler { return this._eventHandler; } init(grid: SlickGrid, options?: ExcelCopyBufferOption): void { this._grid = grid; this._dataWrapper.init(grid); this._addonOptions = { ...this._addonOptions, ...options }; this._copiedCellStyleLayerKey = this._addonOptions.copiedCellStyleLayerKey || 'copy-manager'; this._copiedCellStyle = this._addonOptions.copiedCellStyle || 'copied'; this._onCopyInit = this._addonOptions.onCopyInit || undefined; this._onCopySuccess = this._addonOptions.onCopySuccess || undefined; // add PubSub instance to all SlickEvent const pubSub = grid.getPubSubService(); if (pubSub) { SlickUtils.addSlickEventPubSubWhenDefined(pubSub, this); } this._eventHandler.subscribe(this._grid.onKeyDown, this.handleKeyDown.bind(this)); // we need a cell selection model const cellSelectionModel = grid.getSelectionModel(); if (!cellSelectionModel) { throw new Error( `Selection model is mandatory for this plugin. Please set a selection model on the grid before adding this plugin: grid.setSelectionModel(new SlickHybridSelectionModel())` ); } // we give focus on the grid when a selection is done on it (unless it's an editor, if so the editor should have already set focus to the grid prior to editing a cell). // without this, if the user selects a range of cell without giving focus on a particular cell, the grid doesn't get the focus and key stroke handles (ctrl+c) don't work this._eventHandler.subscribe(cellSelectionModel.onSelectedRangesChanged, () => { if (!this._grid.getEditorLock().isActive() && !document.activeElement?.classList.contains('slick-filter')) { this._grid.focus('internal'); } }); if (grid && typeof this._addonOptions?.onBeforePasteCell === 'function') { // subscribe to this Slickgrid event of onBeforeEditCell this._eventHandler.subscribe(this.onBeforePasteCell, (e, args) => { const column = grid.getColumnByIdx(args.cell)!; const returnedArgs: OnEventArgs = { row: args.row!, cell: args.cell, dataView: grid.getData(), grid, columnDef: column, dataContext: grid.getDataItem(args.row!), }; // finally call up the Slick column.onBeforeEditCells.... function return this._addonOptions.onBeforePasteCell?.(e, returnedArgs); }); } } dispose(): void { clearTimeout(this._clearCopyTI); this._eventHandler.unsubscribeAll(); } clearCopySelection(): void { this._grid.removeCellCssStyles(this._copiedCellStyleLayerKey); } getHeaderValueForColumn(columnDef: Column): string { if (typeof this._addonOptions.headerColumnValueExtractor === 'function') { const val = getHtmlStringOutput(this._addonOptions.headerColumnValueExtractor(columnDef), 'innerHTML'); if (val) { return stripTags(val); } } return getHtmlStringOutput(columnDef.name || '', 'innerHTML'); } getDataItemValueForColumn(item: any, columnDef: Column, row: number, cell: number, event: SlickEventData): string { if (typeof this._addonOptions.dataItemColumnValueExtractor === 'function') { const val = this._addonOptions.dataItemColumnValueExtractor(item, columnDef, row, cell) as string | HTMLElement; if (val) { return val instanceof HTMLElement ? stripTags(val.innerHTML) : val; } } let retVal = ''; // if a custom getter is not defined, we call serializeValue of the editor to serialize if (columnDef) { if (columnDef.editorClass) { const tmpP = document.createElement('p'); const editor = new (columnDef.editorClass as EditorConstructor)({ container: tmpP, // a dummy container column: columnDef, event, position: { top: 0, left: 0 } as unknown as ElementPosition, // a dummy position required by some editors gridPosition: { top: 0, left: 0 } as unknown as ElementPosition, // a dummy position required by some editors grid: this._grid, cancelChanges: noop, commitChanges: noop, }); editor.loadValue(item); retVal = editor.serializeValue(); editor.destroy(); tmpP.remove(); } else { retVal = item[columnDef.field || '']; } } return retVal; } setDataItemValueForColumn(item: any, columnDef: Column, value: any): any | void { if (!columnDef?.denyPaste) { if (this._addonOptions.dataItemColumnValueSetter) { const setterResult = this._addonOptions.dataItemColumnValueSetter(item, columnDef, value); if (setterResult !== true) { return setterResult; } } // if a custom setter is not defined, we call applyValue of the editor to unserialize if (columnDef.editorClass) { const tmpDiv = document.createElement('div'); const editor = new (columnDef.editorClass as EditorConstructor)({ container: tmpDiv, // a dummy container column: columnDef, event: null as any, position: { top: 0, left: 0 } as unknown as ElementPosition, // a dummy position required by some editors gridPosition: { top: 0, left: 0 } as unknown as ElementPosition, // a dummy position required by some editors grid: this._grid, cancelChanges: noop, commitChanges: noop, }) as Editor; editor.loadValue({ ...item, [columnDef.field]: value }); const validationResults = editor.validate(undefined, value); if (!validationResults.valid) { const activeCell = this._grid.getActiveCell()!; this._grid.onValidationError.notify({ editor, cellNode: this._grid.getActiveCellNode()!, validationResults, row: activeCell?.row, cell: activeCell?.cell, column: columnDef, grid: this._grid, }); } editor.applyValue(item, value); editor.destroy(); tmpDiv.remove(); } else { item[columnDef.field] = value; } } } setIncludeHeaderWhenCopying(includeHeaderWhenCopying: boolean): void { this._addonOptions.includeHeaderWhenCopying = includeHeaderWhenCopying; } // // protected functions // --------------------- protected decodeTabularData(grid: SlickGrid, clipText: string): void { const columns = grid.getColumns(); const clipRows = clipText.replaceAll('\r\n', '\n').split(/[\n\f\r](?=(?:[^"]*"[^"]*")*[^"]*$)/); // trim trailing CR if present if (clipRows[clipRows.length - 1] === '') { clipRows.pop(); } let j = 0; const clippedRange: any[] = []; for (const clipRow of clipRows) { const jNext = j++; const splitRegex = /\t(?=(?:[^"]*"[^"]*")*[^"]*$)/; clippedRange[jNext] = clipRow.split(splitRegex).map((item) => item .replaceAll('\n', this._addonOptions.replaceNewlinesWith || '\n') .replaceAll('\r', '') .replaceAll('"', this._addonOptions.removeDoubleQuotesOnPaste ? '' : '"') ); } const selectedCell = this._grid.getActiveCell(); const ranges = this._grid.getSelectionModel()?.getSelectedRanges(); const selectedRange = ranges?.length ? ranges[0] : null; // pick only one selection let activeRow: number; let activeCell: number; if (selectedRange) { activeRow = selectedRange.fromRow; activeCell = selectedRange.fromCell; } else if (selectedCell) { activeRow = selectedCell.row; activeCell = selectedCell.cell; } else { return; // we don't know where to paste } let oneCellToMultiple = false; let destH = clippedRange.length; let destW = clippedRange.length ? clippedRange[0].length : 0; if (clippedRange.length === 1 && clippedRange[0].length === 1 && selectedRange) { oneCellToMultiple = true; destH = selectedRange.toRow - selectedRange.fromRow + 1; destW = selectedRange.toCell - selectedRange.fromCell + 1; } const availableRows = this._dataWrapper.getDataLength() - activeRow; // ignore new rows if we don't have a "newRowCreator" if (availableRows < destH && typeof this._addonOptions.newRowCreator === 'function') { const rowsToAdd = destH - availableRows; const rowsBeforePaste = this._dataWrapper.getDataLength(); this._addonOptions.newRowCreator(rowsToAdd); const rowsAfterPaste = this._dataWrapper.getDataLength(); if (rowsAfterPaste !== rowsBeforePaste + rowsToAdd) { console.warn( `[Slickgrid-Universal] The "newRowCreator" did not add the correct amount of rows, it should add "${rowsToAdd}" rows but it added "${rowsAfterPaste - rowsBeforePaste}" rows` ); } this._grid.render(); } const overflowsBottomOfGrid = activeRow + destH > this._dataWrapper.getDataLength(); if (overflowsBottomOfGrid && typeof this._addonOptions.newRowCreator === 'function') { const newRowsNeeded = activeRow + destH - this._dataWrapper.getDataLength(); this._addonOptions.newRowCreator(newRowsNeeded); } const clipCommand: ExternalCopyClipCommand = { isClipboardCommand: true, clippedRange, oldValues: [], cellExternalCopyManager: this, _options: this._addonOptions, setDataItemValueForColumn: this.setDataItemValueForColumn, markCopySelection: this.markCopySelection, oneCellToMultiple, activeRow, activeCell, destH, destW, maxDestY: this._dataWrapper.getDataLength(), maxDestX: this._grid.getColumns().length, h: 0, w: 0, execute: () => { clipCommand.h = 0; for (let y = 0; y < clipCommand.destH; y++) { clipCommand.oldValues[y] = []; clipCommand.w = 0; clipCommand.h++; let xOffset = 0; // the x offset for hidden col for (let x = 0; x < clipCommand.destW; x++) { const desty = activeRow + y; const destx = activeCell + x; const column = columns[destx]; // paste on hidden column will be skipped, but we need to paste 1 cell further on X axis // we'll increase our X and increase the offset` if (column.hidden) { clipCommand.destW++; xOffset++; continue; } clipCommand.w++; if (desty < clipCommand.maxDestY && destx < clipCommand.maxDestX) { const dt = this._dataWrapper.getDataItem(desty); if ( this._grid .triggerEvent(this.onBeforePasteCell, { row: desty, cell: destx, dt, column, target: 'grid' }) .getReturnValue() === false ) { continue; } clipCommand.oldValues[y][x - xOffset] = dt[column['field']]; if (oneCellToMultiple) { this.setDataItemValueForColumn(dt, column, clippedRange[0][0]); } else { this.setDataItemValueForColumn(dt, column, clippedRange[y] ? clippedRange[y][x - xOffset] : ''); } this._grid.updateCell(desty, destx); this._grid.onCellChange.notify({ row: desty, cell: destx, item: dt, grid: this._grid, column: {} as unknown as Column, }); } } } const bRange = new SlickRange(activeRow, activeCell, activeRow + clipCommand.h - 1, activeCell + clipCommand.w - 1); this.markCopySelection([bRange]); this._grid.getSelectionModel()?.setSelectedRanges([bRange]); this.onPasteCells.notify({ ranges: [bRange] }); }, undo: () => { for (let y = 0; y < clipCommand.destH; y++) { for (let x = 0; x < clipCommand.destW; x++) { const desty = activeRow + y; const destx = activeCell + x; if (desty < clipCommand.maxDestY && destx < clipCommand.maxDestX) { // const nd = this._grid.getCellNode(desty, destx); const dt = this._dataWrapper.getDataItem(desty); if (oneCellToMultiple) { this.setDataItemValueForColumn(dt, columns[destx], clipCommand.oldValues[0][0]); } else { this.setDataItemValueForColumn(dt, columns[destx], clipCommand.oldValues[y][x]); } this._grid.updateCell(desty, destx); this._grid.onCellChange.notify({ row: desty, cell: destx, item: dt, grid: this._grid, column: {} as unknown as Column, }); } } } const bRange = new SlickRange(activeRow, activeCell, activeRow + clipCommand.h - 1, activeCell + clipCommand.w - 1); this.markCopySelection([bRange]); this._grid.getSelectionModel()?.setSelectedRanges([bRange]); this.onPasteCells.notify({ ranges: [bRange] }); if (typeof this._addonOptions.onPasteCells === 'function') { this._addonOptions.onPasteCells(new SlickEventData(), { ranges: [bRange] }); } }, }; if (this._addonOptions.clipboardCommandHandler) { this._addonOptions.clipboardCommandHandler(clipCommand); } else { clipCommand.execute(); } } protected async handleKeyDown(e: SlickEventData): Promise { try { let ranges: SlickRange[]; if (!this._grid.getEditorLock().isActive() || this._grid.getOptions().autoEdit) { if (e.key === 'Escape') { if (this._copiedRanges) { e.preventDefault(); this.clearCopySelection(); this.onCopyCancelled.notify({ ranges: this._copiedRanges }); if (typeof this._addonOptions.onCopyCancelled === 'function') { this._addonOptions.onCopyCancelled(e, { ranges: this._copiedRanges }); } this._copiedRanges = null; } } if ((e.key === 'c' || e.key === 'Insert') && (e.ctrlKey || e.metaKey) && !e.shiftKey) { // CTRL+C or CTRL+INS if (typeof this._onCopyInit === 'function') { this._onCopyInit.call(this); } ranges = this._grid.getSelectionModel()?.getSelectedRanges() ?? []; if (ranges.length !== 0) { this._copiedRanges = ranges; this.markCopySelection(ranges); this.onCopyCells.notify({ ranges }); if (typeof this._addonOptions.onCopyCells === 'function') { this._addonOptions.onCopyCells(e, { ranges }); } const columns = this._grid.getColumns(); let clipText = ''; for (let rg = 0; rg < ranges.length; rg++) { const range = ranges[rg]; const clipTextRows: string[] = []; for (let i = range.fromRow; i < range.toRow + 1; i++) { const clipTextCells: string[] = []; const dt = this._dataWrapper.getDataItem(i); if (clipTextRows.length === 0 && this._addonOptions.includeHeaderWhenCopying) { const clipTextHeaders: string[] = []; for (let j = range.fromCell; j < range.toCell + 1; j++) { if (columns[j]) { const colName: string = columns[j].name instanceof HTMLElement ? stripTags((columns[j].name as HTMLElement).innerHTML) : (columns[j].name as string); if (colName.length > 0 && !columns[j].hidden) { clipTextHeaders.push(this.getHeaderValueForColumn(columns[j])); } } } clipTextRows.push(clipTextHeaders.join('\t')); } for (let j = range.fromCell; j < range.toCell + 1; j++) { if (columns[j]) { const colName: string = columns[j].name instanceof HTMLElement ? stripTags((columns[j].name as HTMLElement).innerHTML) : (columns[j].name as string); if (colName.length > 0 && !columns[j].hidden) { clipTextCells.push(this.getDataItemValueForColumn(dt, columns[j], i, j, e)); } } } clipTextRows.push(clipTextCells.join('\t')); } clipText += clipTextRows.join('\r\n') + '\r\n'; } // copy to clipboard using override or default browser Clipboard API const clipboardOverrideFn = this._grid.getOptions().clipboardWriteOverride; clipboardOverrideFn ? clipboardOverrideFn(clipText) : await navigator.clipboard.writeText(clipText); if (typeof this._onCopySuccess === 'function') { // If it's cell selection, use the toRow/fromRow fields const rowCount = ranges.length === 1 ? ranges[0].toRow + 1 - ranges[0].fromRow : ranges.length; this._onCopySuccess(rowCount); } return false; } } if ( !this._addonOptions.readOnlyMode && ((e.key === 'v' && (e.ctrlKey || e.metaKey) && !e.shiftKey) || (e.key === 'Insert' && e.shiftKey && !e.ctrlKey)) ) { // CTRL+V or Shift+INS const pastedText = await navigator.clipboard.readText(); this.decodeTabularData(this._grid, pastedText); } } } catch (err) { console.error(`Unable to read/write to clipboard. Please check your browser settings or permissions. Error: ${err}`); } } protected markCopySelection(ranges: SlickRange[]): void { this.clearCopySelection(); const columns = this._grid.getColumns(); const hash: CssStyleHash = {}; for (const range of ranges) { for (let j = range.fromRow; j <= range.toRow; j++) { hash[j] = {}; for (let k = range.fromCell; k <= range.toCell && k < columns.length; k++) { hash[j][columns[k].id] = this._copiedCellStyle; } } } this._grid.setCellCssStyles(this._copiedCellStyleLayerKey, hash); clearTimeout(this._clearCopyTI as number); this._clearCopyTI = setTimeout( () => this.clearCopySelection(), this.addonOptions?.clearCopySelectionDelay || CLEAR_COPY_SELECTION_DELAY ); } }