import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; import type { TableBlockModel } from '@blocksuite/affine-model'; import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts'; import { DocModeProvider } from '@blocksuite/affine-shared/services'; import { VirtualPaddingController } from '@blocksuite/affine-shared/utils'; import { IS_MOBILE } from '@blocksuite/global/env'; import type { BlockComponent } from '@blocksuite/std'; import { RANGE_QUERY_EXCLUDE_ATTR, RANGE_SYNC_EXCLUDE_ATTR, } from '@blocksuite/std/inline'; import { signal } from '@preact/signals-core'; import { html, nothing } from 'lit'; import { ref } from 'lit/directives/ref.js'; import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; import { SelectionController } from './selection-controller'; import { rowStyle, table, tableContainer, tableWrapper, } from './table-block-css'; import { TableDataManager } from './table-data-manager'; export const TableBlockComponentName = 'affine-table'; export class TableBlockComponent extends CaptionedBlockComponent { private _dataManager: TableDataManager | null = null; get dataManager(): TableDataManager { if (!this._dataManager) { this._dataManager = new TableDataManager(this.model); } return this._dataManager; } selectionController = new SelectionController(this); override connectedCallback() { super.connectedCallback(); this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); this.setAttribute(RANGE_QUERY_EXCLUDE_ATTR, 'true'); this.style.position = 'relative'; const doc = this.ownerDocument; this.disposables.addFromEvent(doc, 'selectionchange', () => { const hasExternalNativeSelection = this.hasExternalNativeSelection(); this.toggleAttribute( 'data-external-range-selection', hasExternalNativeSelection ); if (hasExternalNativeSelection) { delete this.dataset.internalRangeSelection; } this.setInternalEditablesEnabled(!hasExternalNativeSelection); }); this.disposables.addFromEvent( doc, 'pointerdown', event => { const target = event.target; const NodeConstructor = this.ownerDocument.defaultView?.Node; if ( NodeConstructor && target instanceof NodeConstructor && this.contains(target) ) { this.setInternalEditablesEnabled(true); if (this.hasExternalNativeSelection()) { this.ownerDocument.getSelection()?.removeAllRanges(); } delete this.dataset.externalRangeSelection; this.dataset.internalRangeSelection = 'true'; } else { delete this.dataset.internalRangeSelection; } }, { capture: true } ); } private setInternalEditablesEnabled(enabled: boolean) { this.querySelectorAll('.inline-editor').forEach(editor => { if (enabled) { if (editor.dataset.tableExternalSelectionDisabled === 'true') { editor.contentEditable = 'true'; delete editor.dataset.tableExternalSelectionDisabled; } return; } if (editor.contentEditable === 'true') { editor.contentEditable = 'false'; editor.dataset.tableExternalSelectionDisabled = 'true'; } }); } private hasExternalNativeSelection() { const selection = this.ownerDocument.getSelection(); if (!selection || selection.isCollapsed || selection.rangeCount === 0) { return false; } const range = selection.getRangeAt(0); if (!range.intersectsNode(this)) { return false; } const anchorNode = selection.anchorNode; const focusNode = selection.focusNode; return ( !!anchorNode && !!focusNode && (!this.contains(anchorNode) || !this.contains(focusNode)) ); } override get topContenteditableElement() { if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { return this.closest( EDGELESS_TOP_CONTENTEDITABLE_SELECTOR ); } return this.rootComponent; } private readonly virtualPaddingController: VirtualPaddingController = new VirtualPaddingController(this); table$ = signal(); public getScale(): number { const table = this.table$.value; if (!table) return 1; return table.getBoundingClientRect().width / table.offsetWidth; } private readonly getRootRect = () => { const table = this.table$.value; if (!table) return; return table.getBoundingClientRect(); }; private readonly getRowRect = (rowId: string) => { const row = this.querySelector(`tr[data-row-id="${rowId}"]`); const rootRect = this.getRootRect(); if (!row || !rootRect) return; const rect = row.getBoundingClientRect(); const scale = this.getScale(); return { top: (rect.top - rootRect.top) / scale, left: (rect.left - rootRect.left) / scale, width: rect.width / scale, height: rect.height / scale, }; }; private readonly getColumnRect = (columnId: string) => { const columns = this.querySelectorAll(`td[data-column-id="${columnId}"]`); const rootRect = this.getRootRect(); if (!rootRect) return; const firstRect = columns.item(0)?.getBoundingClientRect(); const lastRect = columns.item(columns.length - 1)?.getBoundingClientRect(); if (!firstRect || !lastRect) return; const scale = this.getScale(); return { top: (firstRect.top - rootRect.top) / scale, left: (firstRect.left - rootRect.left) / scale, width: firstRect.width / scale, height: (lastRect.bottom - firstRect.top) / scale, }; }; private readonly getAreaRect = ( rowStartIndex: number, rowEndIndex: number, columnStartIndex: number, columnEndIndex: number ) => { const rootRect = this.getRootRect(); const rows = this.querySelectorAll('tr'); const startRow = rows.item(rowStartIndex); const endRow = rows.item(rowEndIndex); if (!startRow || !endRow || !rootRect) return; const startCells = startRow.querySelectorAll('td'); const endCells = endRow.querySelectorAll('td'); const startCell = startCells.item(columnStartIndex); const endCell = endCells.item(columnEndIndex); if (!startCell || !endCell) return; const startRect = startCell.getBoundingClientRect(); const endRect = endCell.getBoundingClientRect(); const scale = this.getScale(); return { top: (startRect.top - rootRect.top) / scale, left: (startRect.left - rootRect.left) / scale, width: (endRect.right - startRect.left) / scale, height: (endRect.bottom - startRect.top) / scale, }; }; override renderBlock() { const rows = this.dataManager.uiRows$.value; const columns = this.dataManager.uiColumns$.value; const virtualPadding = this.virtualPaddingController.virtualPadding$.value; return html`
${repeat( rows, row => row.rowId, (row, rowIndex) => { return html` ${repeat( columns, column => column.columnId, (column, columnIndex) => { const cell = this.dataManager.getCell( row.rowId, column.columnId ); return html` `; } )} `; } )} ${IS_MOBILE || this.dataManager.readonly$.value ? nothing : html``} ${html``}
`; } } declare global { interface HTMLElementTagNameMap { [TableBlockComponentName]: TableBlockComponent; } }