Files
AFFiNE-Mirror/blocksuite/affine/blocks/table/src/selection-controller.ts
T
DarkSky 6a2b73e76f feat(editor): improve database & table behavior (#15100)
fix #14982
fix #15028
fix #15099

#### PR Dependency Tree


* **PR #15100** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Prevented Enter handling during IME composition to avoid unintended
input.
* Avoided overwriting external native selections when interacting with
tables.
* Improved validation of inline text selection ranges for more reliable
behavior.

* **Enhancements**
* Scoped and refined text-selection styling and editability within
tables and cells.
  * Added managed sorting for Kanban views to control card ordering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-11 13:50:23 +08:00

578 lines
18 KiB
TypeScript

import {
domToOffsets,
getAreaByOffsets,
getTargetIndexByDraggingOffset,
} from '@blocksuite/affine-shared/utils';
import { IS_MOBILE } from '@blocksuite/global/env';
import type { UIEventStateContext } from '@blocksuite/std';
import { computed } from '@preact/signals-core';
import type { ReactiveController } from 'lit';
import { ColumnMinWidth, DefaultColumnWidth } from './consts';
import {
type TableAreaSelection,
TableSelection,
TableSelectionData,
} from './selection-schema';
import type { TableBlockComponent } from './table-block';
import {
createColumnDragPreview,
createRowDragPreview,
type TableCell,
TableCellComponentName,
} from './table-cell';
import { cleanSelection } from './utils';
type Cells = string[][];
const TEXT = 'text/plain';
export class SelectionController implements ReactiveController {
constructor(public readonly host: TableBlockComponent) {
this.host.addController(this);
}
hostConnected() {
this.dragListener();
this.host.handleEvent('copy', this.onCopy);
this.host.handleEvent('cut', this.onCut);
this.host.handleEvent('paste', this.onPaste);
this.host.handleEvent('dragStart', context => {
if (IS_MOBILE || this.dataManager.readonly$.value) return false;
const event = context.get('pointerState').raw;
const target = event.target;
if (
target instanceof Element &&
target.closest(
'[data-width-adjust-column-id], [data-drag-column-id], [data-drag-row-id]'
)
) {
event.preventDefault();
event.stopPropagation();
return true;
}
return false;
});
}
private get dataManager() {
return this.host.dataManager;
}
private get clipboard() {
return this.host.std.clipboard;
}
private get scale() {
return this.host.getScale();
}
widthAdjust(dragHandle: HTMLElement, event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
const initialX = event.clientX;
const currentWidth =
dragHandle.closest('td')?.getBoundingClientRect().width ??
DefaultColumnWidth;
const adjustedWidth = currentWidth / this.scale;
const columnId = dragHandle.dataset['widthAdjustColumnId'];
if (!columnId) {
return;
}
const onMove = (event: MouseEvent) => {
this.dataManager.widthAdjustColumnId$.value = columnId;
this.dataManager.virtualWidth$.value = {
columnId,
width: Math.max(
ColumnMinWidth,
(event.clientX - initialX) / this.scale + adjustedWidth
),
};
};
const onUp = () => {
const width = this.dataManager.virtualWidth$.value?.width;
this.dataManager.widthAdjustColumnId$.value = undefined;
this.dataManager.virtualWidth$.value = undefined;
if (width) {
this.dataManager.setColumnWidth(columnId, width);
}
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
dragListener() {
if (IS_MOBILE || this.dataManager.readonly$.value) {
return;
}
this.host.disposables.addFromEvent(this.host, 'pointerdown', event => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (
target.closest(
'[data-width-adjust-column-id], [data-drag-column-id], [data-drag-row-id]'
)
) {
event.stopPropagation();
}
});
this.host.disposables.addFromEvent(this.host, 'mousedown', event => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const widthAdjustColumn = target.closest('[data-width-adjust-column-id]');
if (widthAdjustColumn instanceof HTMLElement) {
this.widthAdjust(widthAdjustColumn, event);
return;
}
const columnDragHandle = target.closest('[data-drag-column-id]');
if (columnDragHandle instanceof HTMLElement) {
this.columnDrag(columnDragHandle, event);
return;
}
const rowDragHandle = target.closest('[data-drag-row-id]');
if (rowDragHandle instanceof HTMLElement) {
this.rowDrag(rowDragHandle, event);
return;
}
this.onDragStart(event);
});
}
startColumnDrag(x: number, columnDragHandle: HTMLElement) {
const columnId = columnDragHandle.dataset['dragColumnId'];
if (!columnId) {
return;
}
const cellRect = columnDragHandle.closest('td')?.getBoundingClientRect();
const containerRect = this.host.getBoundingClientRect();
if (!cellRect) {
return;
}
const initialDiffX = x - cellRect.left;
const cells = Array.from(
this.host.querySelectorAll(`td[data-column-id="${columnId}"]`)
).map(td => td.closest(TableCellComponentName) as TableCell);
const firstCell = cells[0];
if (!firstCell) {
return;
}
const draggingIndex = firstCell.columnIndex;
const columns = Array.from(
this.host.querySelectorAll(`td[data-row-id="${firstCell?.row?.rowId}"]`)
).map(td => td.getBoundingClientRect());
const columnOffsets = columns.flatMap((column, index) =>
index === columns.length - 1 ? [column.left, column.right] : [column.left]
);
const columnDragPreview = createColumnDragPreview(cells);
columnDragPreview.style.top = `${cellRect.top - containerRect.top - 0.5}px`;
columnDragPreview.style.left = `${cellRect.left - containerRect.left}px`;
columnDragPreview.style.width = `${cellRect.width}px`;
this.host.append(columnDragPreview);
document.body.style.pointerEvents = 'none';
const onMove = (x: number) => {
const { targetIndex, isForward } = getTargetIndexByDraggingOffset(
columnOffsets,
draggingIndex,
x - initialDiffX
);
if (targetIndex != null) {
this.dataManager.ui.columnIndicatorIndex$.value = isForward
? targetIndex + 1
: targetIndex;
} else {
this.dataManager.ui.columnIndicatorIndex$.value = undefined;
}
columnDragPreview.style.left = `${x - initialDiffX - containerRect.left}px`;
};
const onEnd = () => {
const targetIndex = this.dataManager.ui.columnIndicatorIndex$.value;
this.dataManager.ui.columnIndicatorIndex$.value = undefined;
document.body.style.pointerEvents = 'auto';
columnDragPreview.remove();
if (targetIndex != null) {
this.dataManager.moveColumn(
draggingIndex,
targetIndex === 0 ? undefined : targetIndex - 1
);
}
};
return {
onMove,
onEnd,
};
}
columnDrag(columnDragHandle: HTMLElement, event: MouseEvent) {
let drag: { onMove: (x: number) => void; onEnd: () => void } | undefined =
undefined;
const initialX = event.clientX;
const onMove = (event: MouseEvent) => {
const diffX = event.clientX - initialX;
if (!drag && Math.abs(diffX) > 10) {
event.preventDefault();
event.stopPropagation();
cleanSelection();
this.setSelected(undefined);
drag = this.startColumnDrag(initialX, columnDragHandle);
}
drag?.onMove(event.clientX);
};
const onUp = () => {
drag?.onEnd();
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
startRowDrag(y: number, rowDragHandle: HTMLElement) {
const rowId = rowDragHandle.dataset['dragRowId'];
if (!rowId) {
return;
}
const cellRect = rowDragHandle.closest('td')?.getBoundingClientRect();
const containerRect = this.host.getBoundingClientRect();
if (!cellRect) {
return;
}
const initialDiffY = y - cellRect.top;
const cells = Array.from(
this.host.querySelectorAll(`td[data-row-id="${rowId}"]`)
).map(td => td.closest(TableCellComponentName) as TableCell);
const firstCell = cells[0];
if (!firstCell) {
return;
}
const draggingIndex = firstCell.rowIndex;
const rows = Array.from(
this.host.querySelectorAll(
`td[data-column-id="${firstCell?.column?.columnId}"]`
)
).map(td => td.getBoundingClientRect());
const rowOffsets = rows.flatMap((row, index) =>
index === rows.length - 1 ? [row.top, row.bottom] : [row.top]
);
const rowDragPreview = createRowDragPreview(cells);
rowDragPreview.style.left = `${cellRect.left - containerRect.left}px`;
rowDragPreview.style.top = `${cellRect.top - containerRect.top - 0.5}px`;
rowDragPreview.style.height = `${cellRect.height}px`;
this.host.append(rowDragPreview);
document.body.style.pointerEvents = 'none';
const onMove = (y: number) => {
const { targetIndex, isForward } = getTargetIndexByDraggingOffset(
rowOffsets,
draggingIndex,
y - initialDiffY
);
if (targetIndex != null) {
this.dataManager.ui.rowIndicatorIndex$.value = isForward
? targetIndex + 1
: targetIndex;
} else {
this.dataManager.ui.rowIndicatorIndex$.value = undefined;
}
rowDragPreview.style.top = `${y - initialDiffY - containerRect.top}px`;
};
const onEnd = () => {
const targetIndex = this.dataManager.ui.rowIndicatorIndex$.value;
this.dataManager.ui.rowIndicatorIndex$.value = undefined;
document.body.style.pointerEvents = 'auto';
rowDragPreview.remove();
if (targetIndex != null) {
this.dataManager.moveRow(
draggingIndex,
targetIndex === 0 ? undefined : targetIndex - 1
);
}
};
return {
onMove,
onEnd,
};
}
rowDrag(rowDragHandle: HTMLElement, event: MouseEvent) {
let drag: { onMove: (x: number) => void; onEnd: () => void } | undefined =
undefined;
const initialY = event.clientY;
const onMove = (event: MouseEvent) => {
const diffY = event.clientY - initialY;
if (!drag && Math.abs(diffY) > 10) {
event.preventDefault();
event.stopPropagation();
cleanSelection();
this.setSelected(undefined);
drag = this.startRowDrag(initialY, rowDragHandle);
}
drag?.onMove(event.clientY);
};
// oxlint-disable-next-line sonarjs/no-identical-functions
const onUp = () => {
drag?.onEnd();
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
readonly doCopyOrCut = (selection: TableAreaSelection, isCut: boolean) => {
const columns = this.dataManager.uiColumns$.value;
const rows = this.dataManager.uiRows$.value;
const cells: Cells = [];
const deleteCells: { rowId: string; columnId: string }[] = [];
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
const row = rows[i];
if (!row) {
continue;
}
const rowCells: string[] = [];
for (
let j = selection.columnStartIndex;
j <= selection.columnEndIndex;
j++
) {
const column = columns[j];
if (!column) {
continue;
}
const cell = this.dataManager.getCell(row.rowId, column.columnId);
rowCells.push(cell?.text.toString() ?? '');
if (isCut) {
deleteCells.push({ rowId: row.rowId, columnId: column.columnId });
}
}
cells.push(rowCells);
}
if (isCut) {
this.dataManager.clearCells(deleteCells);
}
const text = cells.map(row => row.join('\t')).join('\n');
const htmlTable = `<table style="border-collapse: collapse;">
<tbody>
${cells
.map(
row => `
<tr>
${row
.map(
cell => `
<td style="border: 1px solid var(--affine-border-color); padding: 8px 12px; min-width: ${DefaultColumnWidth}px; min-height: 22px;">${cell}</td>
`
)
.join('')}
</tr>
`
)
.join('')}
</tbody>
</table>`;
this.clipboard
.writeToClipboard(items => ({
...items,
[TEXT]: text,
'text/html': htmlTable,
}))
.catch(console.error);
};
onCopy = () => {
const selection = this.getSelected();
if (!selection || selection.type !== 'area') {
return false;
}
this.doCopyOrCut(selection, false);
return true;
};
onCut = () => {
const selection = this.getSelected();
if (!selection || selection.type !== 'area') {
return false;
}
this.doCopyOrCut(selection, true);
return true;
};
doPaste = (plainText: string, selection: TableAreaSelection) => {
try {
const rowTextLists = plainText
.split(/\r?\n/)
.map(line => line.split('\t').map(cell => cell.trim()))
.filter(row => row.some(cell => cell !== '')); // Filter out empty rows
const height = rowTextLists.length;
const width = rowTextLists[0]?.length ?? 0;
if (height > 0 && width > 0) {
const columns = this.dataManager.uiColumns$.value;
const rows = this.dataManager.uiRows$.value;
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
const row = rows[i];
if (!row) {
continue;
}
for (
let j = selection.columnStartIndex;
j <= selection.columnEndIndex;
j++
) {
const column = columns[j];
if (!column) {
continue;
}
const text = this.dataManager.getCell(
row.rowId,
column.columnId
)?.text;
if (text) {
const rowIndex = (i - selection.rowStartIndex) % height;
const columnIndex = (j - selection.columnStartIndex) % width;
text.replace(
0,
text.length,
rowTextLists[rowIndex]?.[columnIndex] ?? ''
);
}
}
}
}
} catch (error) {
console.error(error);
}
};
onPaste = (_context: UIEventStateContext) => {
const event = _context.get('clipboardState').raw;
event.stopPropagation();
const clipboardData = event.clipboardData;
if (!clipboardData) return false;
const selection = this.getSelected();
if (!selection || selection.type !== 'area') {
return false;
}
try {
const html = clipboardData.getData('text/html');
if (html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const table = doc.querySelector('table');
if (table) {
const rows: string[][] = [];
table.querySelectorAll('tr').forEach(tr => {
const rowData: string[] = [];
tr.querySelectorAll('td,th').forEach(cell => {
rowData.push(cell.textContent?.trim() ?? '');
});
if (rowData.length > 0) {
rows.push(rowData);
}
});
if (rows.length > 0) {
this.doPaste(rows.map(row => row.join('\t')).join('\n'), selection);
return true;
}
}
}
// If no HTML format or parsing failed, try to read plain text
const plainText = clipboardData.getData('text/plain');
if (plainText) {
this.doPaste(plainText, selection);
return true;
}
} catch (error) {
console.error('Failed to paste:', error);
}
return false;
};
onDragStart(event: MouseEvent) {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const offsets = domToOffsets(this.host, 'tr', 'td');
if (!offsets) return;
const startX = event.clientX;
const startY = event.clientY;
let selected = false;
const initCell = target.closest('affine-table-cell');
if (!initCell) {
selected = true;
}
const onMove = (event: MouseEvent) => {
const target = event.target;
if (target instanceof HTMLElement) {
const cell = target.closest('affine-table-cell');
if (!selected && initCell === cell) {
return;
}
selected = true;
const endX = event.clientX;
const endY = event.clientY;
const [left, right] = startX > endX ? [endX, startX] : [startX, endX];
const [top, bottom] = startY > endY ? [endY, startY] : [startY, endY];
const area = getAreaByOffsets(offsets, top, bottom, left, right);
this.setSelected({
type: 'area',
rowStartIndex: area.top,
rowEndIndex: area.bottom,
columnStartIndex: area.left,
columnEndIndex: area.right,
});
}
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
setSelected(
selection: TableSelectionData | undefined,
removeNativeSelection = true
) {
if (selection) {
if (this.hasExternalNativeSelection()) {
return;
}
const previous = this.getSelected();
if (TableSelectionData.equals(previous, selection)) {
return;
}
if (removeNativeSelection) {
getSelection()?.removeAllRanges();
}
this.host.selection.set([
new TableSelection({
blockId: this.host.model.id,
data: selection,
}),
]);
} else {
this.host.selection.clear();
}
}
selected$ = computed(() => this.getSelected());
getSelected(): TableSelectionData | undefined {
const selection = this.host.selection.value.find(
selection => selection.blockId === this.host.model.id
);
return selection?.is(TableSelection) ? selection.data : undefined;
}
private hasExternalNativeSelection() {
const selection = getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
if (!range.intersectsNode(this.host)) {
return false;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
return (
!!anchorNode &&
!!focusNode &&
(!this.host.contains(anchorNode) || !this.host.contains(focusNode))
);
}
}