import type { UIEventStateContext } from '@blockeditor/block-std'; import { assertExists } from '@blockeditor/global/utils'; import type { Y } from '@blockeditor/store'; import type { ReactiveController } from 'lit'; import type { TableViewSelection } from '../../../_common/types.js'; import type { DatabaseCellContainer } from '../components/cell-container.js'; import type { DataViewTable } from '../table-view.js'; import type { DataViewTableManager } from '../table-view-manager.js'; const BLOCKSUITE_DATABASE = 'blockeditor/database'; const TEXT = 'text/plain'; export class TableClipboardController implements ReactiveController { constructor(public host: DataViewTable) { host.addController(this); } private get readonly() { return this.host.view.readonly; } private get std() { return this.host.std; } hostConnected() { this.host.disposables.add( this.host.handleEvent('copy', ctx => { const tableSelection = this.host.selectionController.selection; if (!tableSelection) return false; this._onCopy(ctx, tableSelection).catch(console.error); return true; }) ); this.host.disposables.add( this.host.handleEvent('cut', ctx => { const tableSelection = this.host.selectionController.selection; if (!tableSelection) return false; this._onCut(ctx, tableSelection).catch(console.error); return true; }) ); this.host.disposables.add( this.host.handleEvent('paste', ctx => { if (this.readonly) return false; this._onPaste(ctx).catch(console.error); return true; }) ); } private _onCopy = async ( _context: UIEventStateContext, tableSelection: TableViewSelection, isCut = false ) => { const view = this.host; const data = this.host.view; // cells // For database paste inside. const copiedValues = getCopiedValuesFromSelection( tableSelection, data, view ); // For database paste outside(raw text). const cellsValue = copyCellsValue(tableSelection, data, view, isCut); const formatValue = cellsValue.map(value => value.join('\t')).join('\n'); this.std.clipboard .writeToClipboard(async items => { return { ...items, [TEXT]: formatValue, [BLOCKSUITE_DATABASE]: JSON.stringify(copiedValues), }; }) .catch(console.error); return true; }; private _onCut = async ( _context: UIEventStateContext, tableSelection: TableViewSelection ) => { this._onCopy(_context, tableSelection, true).catch(console.error); }; private _onPaste = async (_context: UIEventStateContext) => { const event = _context.get('clipboardState').raw; event.stopPropagation(); const view = this.host; const data = this.host.view; const clipboardData = event.clipboardData; if (!clipboardData) return; const tableSelection = this.host.selectionController.selection; if (tableSelection) { const json = await this.std.clipboard.readFromClipboard(clipboardData); const copiedValues = json[BLOCKSUITE_DATABASE]; if (!copiedValues) return; const copyedSelectionData = JSON.parse( copiedValues ) as CopyedSelectionData; // paste cells const targetRange = getTargetRangeFromSelection(tableSelection, data); let rowStartIndex = targetRange.row.start; let columnStartIndex = targetRange.column.start; let rowLength = targetRange.row.length; let columnLength = targetRange.column.length; if (targetRange.anchor) { rowStartIndex = tableSelection.focus.rowIndex; columnStartIndex = tableSelection.focus.columnIndex; rowLength = copyedSelectionData.length; columnLength = copyedSelectionData[0].length; } pasteToCells( data, view, copyedSelectionData, tableSelection.groupKey, rowStartIndex, columnStartIndex, rowLength, columnLength ); } return true; }; } function getColumnValue(container: DatabaseCellContainer | undefined) { const rowId = container?.dataset.rowId; assertExists(rowId); return container?.column.getStringValue(rowId) ?? ''; } /** * Columns that do not support cut operation yet */ const UNSUPPORTED_COLUMNS = ['progress', 'checkbox']; function copyCellsValue( selection: TableViewSelection, data: DataViewTableManager, view: DataViewTable, isCut = false ) { const { rowsSelection, columnsSelection, focus, groupKey } = selection; const values: string[][] = []; if (rowsSelection && !columnsSelection) { // rows const { start, end } = rowsSelection; const titleIndex = data.columnsWithoutFilter.findIndex( id => data.columnGetType(id) === 'title' ); for (let i = start; i <= end; i++) { const container = view.selectionController.getCellContainer( groupKey, i, titleIndex ); const value = (cellToStringMap['title']?.(container) ?? '') as string; values.push([value]); } } else if (rowsSelection && columnsSelection) { // multiple cells for (let i = rowsSelection.start; i <= rowsSelection.end; i++) { const value: string[] = []; for (let j = columnsSelection.start; j <= columnsSelection.end; j++) { const column = data.columnGet(data.columns[j]); const container = view.selectionController.getCellContainer( groupKey, i, j ); const cellValue = (cellToStringMap[column.type]?.(container) ?? '') as string; if (!isCut) { value.push(cellValue); } if (isCut && !UNSUPPORTED_COLUMNS.includes(column.type)) { value.push(cellValue); container?.column.setValue(container.rowId, undefined); if (column.type === 'title' || column.type === 'rich-text') { const yText = container?.column.getValue(container.rowId) as Y.Text; yText.delete(0, yText.length); } } } values.push(value); } } else if (!rowsSelection && !columnsSelection && focus) { // single cell const column = data.columnGet(data.columns[focus.columnIndex]); const container = view.selectionController.getCellContainer( groupKey, focus.rowIndex, focus.columnIndex ); const value = (cellToStringMap[column.type]?.(container) ?? '') as string; if (!isCut) { values.push([value]); } if (isCut && !UNSUPPORTED_COLUMNS.includes(column.type)) { values.push([value]); container?.column.setValue(container.rowId, undefined); if (column.type === 'title' || column.type === 'rich-text') { const yText = container?.column.getValue(container.rowId) as Y.Text; yText.delete(0, yText.length); } } } return values; } type CopyedColumn = { type: string; value: string }; type CopyedSelectionData = CopyedColumn[][]; function getCopiedValuesFromSelection( selection: TableViewSelection, data: DataViewTableManager, view: DataViewTable ): CopyedSelectionData { const { rowsSelection, columnsSelection, focus, groupKey } = selection; const values: CopyedColumn[][] = []; if (rowsSelection && !columnsSelection) { // rows const { start, end } = rowsSelection; for (let i = start; i <= end; i++) { const cellValues: CopyedColumn[] = []; for (let j = 0; j < data.columns.length; j++) { const column = data.columnGet(data.columns[j]); const container = view.selectionController.getCellContainer( groupKey, i, j ); const value = cellToStringMap[column.type]?.(container); cellValues.push({ type: column.type, value }); } values.push(cellValues); } } else if (rowsSelection && columnsSelection) { // multiple cells for (let i = rowsSelection.start; i <= rowsSelection.end; i++) { const cellValues: CopyedColumn[] = []; for (let j = columnsSelection.start; j <= columnsSelection.end; j++) { const column = data.columnGet(data.columns[j]); const container = view.selectionController.getCellContainer( groupKey, i, j ); const value = cellToStringMap[column.type]?.(container); cellValues.push({ type: column.type, value }); } values.push(cellValues); } } else if (!rowsSelection && !columnsSelection && focus) { // single cell const container = view.selectionController.getCellContainer( groupKey, focus.rowIndex, focus.columnIndex ); const column = data.columnGet(data.columns[focus.columnIndex]); const value = cellToStringMap[column.type]?.(container); values.push([{ type: column.type, value }]); } return values; } function getTargetRangeFromSelection( selection: TableViewSelection, data: DataViewTableManager ) { const { rowsSelection, columnsSelection, focus } = selection; let range: { row: { start: number; end: number; length: number }; column: { start: number; end: number; length: number }; anchor?: boolean; } = { row: { start: 0, end: 0, length: 0, }, column: { start: 0, end: 0, length: 0, }, }; if (rowsSelection && !columnsSelection) { // rows range = { row: { start: rowsSelection.start, end: rowsSelection.end, length: rowsSelection.end - rowsSelection.start + 1, }, column: { start: 0, end: data.columns.length - 1, length: data.columns.length, }, }; if (rowsSelection.start === rowsSelection.end) { range.anchor = true; } } else if (rowsSelection && columnsSelection) { // multiple cells range = { row: { start: rowsSelection.start, end: rowsSelection.end, length: rowsSelection.end - rowsSelection.start + 1, }, column: { start: columnsSelection.start, end: columnsSelection.end, length: columnsSelection.end - columnsSelection.start + 1, }, }; } else if (!rowsSelection && !columnsSelection && focus) { // single cell range = { anchor: true, row: { start: focus.rowIndex, end: focus.rowIndex, length: 1, }, column: { start: focus.columnIndex, end: focus.columnIndex, length: 1, }, }; } return range; } function pasteToCells( data: DataViewTableManager, view: DataViewTable, copied: CopyedSelectionData, groupKey: string | undefined, rowStartIndex: number, columnStartIndex: number, rowLength: number, columnLength: number ) { const srcColumns = copied; const srcRowLength = srcColumns.length; const srcColumnLength = srcColumns[0].length; for (let i = 0; i < rowLength; i++) { for (let j = 0; j < columnLength; j++) { const rowIndex = rowStartIndex + i; const columnIndex = columnStartIndex + j; const srcRowIndex = i % srcRowLength; const srcColumnIndex = j % srcColumnLength; const srcColumn = srcColumns[srcRowIndex][srcColumnIndex]; const targetContainer = view.selectionController.getCellContainer( groupKey, rowIndex, columnIndex ); const rowId = targetContainer?.dataset.rowId; const columnId = targetContainer?.dataset.columnId; if (rowId && columnId) { const value = targetContainer?.column.setValueFromString( srcColumn.value ); data.cellUpdateValue(rowId, columnId, value); } } } } const cellToStringMap: Record< string, (container: DatabaseCellContainer | undefined) => string > = { 'rich-text': container => { const cell = container?.querySelector( 'affine-database-rich-text-cell-editing' ); const value = getColumnValue(container); const range = cell?.inlineEditor?.getInlineRange(); if (range) { const start = range.index; const end = range.index + range.length; const data = value?.slice(start, end) ?? ''; return data; } return value ?? ''; }, title: container => { const cell = container?.querySelector('data-view-header-area-text-editing'); const value = getColumnValue(container); const range = cell?.inlineEditor?.getInlineRange(); if (range) { const start = range.index; const end = range.index + range.length; const data = value?.slice(start, end) ?? ''; return data; } return value ?? ''; }, date: container => { return getColumnValue(container); }, number: container => { return getColumnValue(container); }, select: container => { return getColumnValue(container); }, 'multi-select': container => { return getColumnValue(container); }, progress: container => { return getColumnValue(container); }, link: container => getColumnValue(container), checkbox: container => { return getColumnValue(container); }, };