import { useCallback } from 'react' import { useGridContext, useGridSelector } from '../context/grid-context' import type { SpreadsheetGridPastedCell } from '../grids/spreadsheet-grid/types' import type { GridConfirmPayload, GridRowId } from '../types' import { healSpannedRange, isRangeEqual } from '../utils/range' function parsePlainTable(plain: string) { return plain.split('\n').map((r) => r.split('\t')) } function parseCustomTable( custom: string ): undefined | SpreadsheetGridPastedCell[][] { try { const parsed: { rowId: GridRowId values: Record< string, { label: string; value: unknown; colSpan?: number } > }[] = JSON.parse(custom) if (!Array.isArray(parsed)) { return undefined } const rows: SpreadsheetGridPastedCell[][] = [] for (const row of parsed) { const cells: SpreadsheetGridPastedCell[] = [] for (const key in row.values) { const cellValue = row.values[key] cells.push({ value: cellValue.value, colSpan: cellValue.colSpan ?? 1, }) } rows.push(cells) } return rows } catch { return undefined } } function parseHtmlTable( html: string ): undefined | SpreadsheetGridPastedCell[][] { const doc = new DOMParser().parseFromString(html, 'text/html') const table = doc.querySelector('table') if (!table) { return undefined } const rows: SpreadsheetGridPastedCell[][] = [] for (const rowElement of table.querySelectorAll('tr')) { if (rowElement.closest('thead')) { continue } const cells: SpreadsheetGridPastedCell[] = [] for (const cellElement of rowElement.children) { const headerCell = cellElement.closest('th') != null const colSpan = parseInt( cellElement.getAttribute('colspan') ?? '1', 10 ) if (!headerCell) { const cell: SpreadsheetGridPastedCell = { value: cellElement.textContent ?? '', colSpan, } cells.push(cell) } } rows.push(cells) } return rows } const RANGE_PASTE_ANIMATION_DURATION = 300 export const useRangePaste = ( onBulkCellChange?: ( payload: Map[]> ) => void, container: HTMLElement | null = null ) => { const grid = useGridContext() const isSpreadsheet = useGridSelector(grid.selectors.selectIsSpreadsheet) const handlePaste = useCallback( (e: React.ClipboardEvent) => { const state = grid.getState() const range = grid.selectors.selectRangeSelection(state) const isEditing = grid.selectors.selectIsEditing(state) if (!range || !onBulkCellChange || isEditing) { return } e.preventDefault() const plainSource = e.clipboardData?.getData('text/plain') const plain = plainSource ? parsePlainTable(plainSource) : undefined const htmlSource = e.clipboardData?.getData('text/html') const html = htmlSource ? parseHtmlTable(htmlSource) : undefined const customSource = e.clipboardData?.getData( 'application/x-pvds-grid' ) const custom = customSource ? parseCustomTable(customSource) : undefined if (!custom && !html && !plain) { /* No data found to process */ return } const gridRowIds = grid.selectors.selectRowIds(state) const gridColumnIds = grid.selectors.selectColumnIds(state) const rangeData = grid.selectors.selectRangeIds(state) const topRowId = rangeData.rowIds[0] const leftColumnId = rangeData.columnIds[0] const rows = (custom ?? html ?? plain)! const rowCount = rows.length const columnCount = Math.max( ...rows.map((r) => r.reduce( (a, c) => a + (typeof c === 'string' ? 1 : (c.colSpan ?? 1)), 0 ) ) ) const topRowIndex = gridRowIds.indexOf(topRowId) const endRowId = gridRowIds[ Math.min(topRowIndex + rowCount - 1, gridRowIds.length - 1) ] const endRowIndex = gridRowIds.indexOf(endRowId) const leftColumnIndex = gridColumnIds.indexOf(leftColumnId) const endColumnId = gridColumnIds[ Math.min( leftColumnIndex + columnCount - 1, gridColumnIds.length - 1 ) ] const payloads: GridConfirmPayload[] = [] for ( let rowIndex = topRowIndex, i = 0; rowIndex <= endRowIndex; rowIndex++, i++ ) { const rowId = gridRowIds[rowIndex] const htmlRow = rows[i] let htmlColumnIndex = 0 let columnIndex = leftColumnIndex let columnsToProcess: { columnId: string; value: unknown }[] = [] /* Take a first pass to map each pasted cell to a column, honoring pasted column spans, but ignoring column spanning in the grid */ while (htmlColumnIndex < columnCount) { const cell = htmlRow[htmlColumnIndex] const value = typeof cell === 'string' ? cell : (cell?.value ?? '') const colSpan = typeof cell === 'string' ? 1 : (cell?.colSpan ?? 1) for (let k = 0; k < colSpan; k++) { const currentColumnId = gridColumnIds[columnIndex + k] if (currentColumnId == null) { break } if (k === 0) { columnsToProcess.push({ columnId: currentColumnId, value, }) } else { columnsToProcess.push({ columnId: currentColumnId, value: '', }) } } htmlColumnIndex++ columnIndex += colSpan } /* Now process each column, taking into account column spanning in the grid */ while (columnsToProcess.length > 0) { const toProcess = columnsToProcess.shift()! let { columnId } = toProcess const nextValue = toProcess.value const colSpanConfig = grid.selectors.selectColumnSpanByRowId(state, rowId) const relatedSpanConfig = colSpanConfig == null ? null : [...colSpanConfig.values()].find((c) => c.positionColumnIds?.includes(columnId) ) if (relatedSpanConfig) { columnId = relatedSpanConfig.as ?? relatedSpanConfig.id const groupedColumnIds = relatedSpanConfig.positionColumnIds!.filter((id) => gridColumnIds.includes(id) ) columnsToProcess = columnsToProcess.filter( (c) => !groupedColumnIds.includes(c.columnId) ) } const editable = grid.api.content.isEditable({ rowId, columnId, }) if (editable) { const value = grid.api.content.getValue({ rowId, columnId, }) payloads.push({ rowId, columnId, nextValue, previousValue: value, }) } } } if (!payloads.length) { return } const groupedPayloads = new Map< GridRowId, GridConfirmPayload[] >() payloads.forEach((payload) => { if (!groupedPayloads.has(payload.rowId)) { groupedPayloads.set(payload.rowId, []) } groupedPayloads.get(payload.rowId)?.push(payload) }) const result = onBulkCellChange(groupedPayloads) if (result == null || result === true) { const newRange = healSpannedRange( { from: { rowId: topRowId, columnId: leftColumnId }, to: { rowId: endRowId, columnId: endColumnId }, }, grid ) if (!isRangeEqual(newRange, range)) { grid.api.rangeSelection.select(newRange) } const currentFocus = grid.selectors.selectCurrentFocus(state) if ( currentFocus.area !== 'body' || currentFocus.rowId !== topRowId || currentFocus.columnId !== leftColumnId ) { grid.api.focus.set(topRowId, leftColumnId, 'body', 'first') } container?.setAttribute('data-pvds-grid-range-pasted', 'true') setTimeout(() => { container?.removeAttribute('data-pvds-grid-range-pasted') }, RANGE_PASTE_ANIMATION_DURATION) } else { container?.setAttribute('data-pvds-grid-range-pasted', 'error') setTimeout(() => { container?.removeAttribute('data-pvds-grid-range-pasted') }, RANGE_PASTE_ANIMATION_DURATION) } }, [grid, onBulkCellChange, container] ) if (!isSpreadsheet || !onBulkCellChange) { return undefined } return handlePaste }