Files
AFFiNE-Mirror/blocksuite/affine/block-table/src/table-cell.ts
T
Saul-Mirone 1d865ad883 test(editor): collab table test (#10506)
1. **Table UI Enhancements - Test IDs Added**
   - Added `data-testid` attributes to several table components for better testability:
     - `add-column-button` for the column addition button
     - `add-row-button` for the row addition button
     - `drag-column-handle` for column drag handles
     - `drag-row-handle` for row drag handles

2. **New Test Infrastructure**
   - Added new testing utilities in `tests/kit/src/bs/`:
     - `misc.ts`: Added `waitNextFrame` utility function for handling animation frame timing in tests
     - `table.ts`: Added comprehensive table testing utilities including:
       - `createTable`: Creates a new table with initial cells
       - `getCellText`: Retrieves text from a specific table cell
       - `inputToCell`: Inputs text into a specific table cell
       - `clickDeleteButtonInTableMenu`: Handles table deletion operations

3. **New Collaboration Test**
   - Added a new test file `tests/affine-local/e2e/blocksuite/table/collab.spec.ts` that tests table collaboration features:
     - Tests synchronization between two pages (A and B)
     - Verifies table operations sync correctly:
       - Adding columns and rows
       - Inputting cell content
       - Deleting columns and rows
     - Validates cell content consistency across both pages
     - Tests the complete table manipulation workflow in a collaborative setting

4. **Package Configuration Update**
   - Modified `tests/kit/package.json` to expose new test utilities:
     - Added new export mapping: `"./bs/*": "./src/bs/*.ts"` to make the new table testing utilities accessible

This PR primarily focuses on improving table testing infrastructure and adding comprehensive collaboration tests for the table functionality, while also enhancing component testability through data-test-ids.
2025-02-28 04:37:12 +00:00

851 lines
25 KiB
TypeScript

import {
menu,
popMenu,
type PopupTarget,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { TextBackgroundDuotoneIcon } from '@blocksuite/affine-components/icons';
import {
DefaultInlineManagerExtension,
RichText,
} from '@blocksuite/affine-components/rich-text';
import type { TableColumn, TableRow } from '@blocksuite/affine-model';
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import { ShadowlessElement } from '@blocksuite/block-std';
import { IS_MAC } from '@blocksuite/global/env';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import {
ArrowDownBigIcon,
ArrowLeftBigIcon,
ArrowRightBigIcon,
ArrowUpBigIcon,
CloseIcon,
ColorPickerIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
InsertAboveIcon,
InsertBelowIcon,
InsertLeftIcon,
InsertRightIcon,
PasteIcon,
} from '@blocksuite/icons/lit';
import type { Text } from '@blocksuite/store';
import { computed, effect, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import { colorList } from './color';
import { ColumnMaxWidth, DefaultColumnWidth } from './consts';
import type { SelectionController } from './selection-controller';
import {
type TableAreaSelection,
TableSelectionData,
} from './selection-schema';
import type { TableBlockComponent } from './table-block';
import {
cellContainerStyle,
columnLeftIndicatorStyle,
columnOptionsCellStyle,
columnOptionsStyle,
columnRightIndicatorStyle,
rowBottomIndicatorStyle,
rowOptionsCellStyle,
rowOptionsStyle,
rowTopIndicatorStyle,
threePointerIconDotStyle,
threePointerIconStyle,
} from './table-cell.css';
import type { TableDataManager } from './table-data-manager';
export const TableCellComponentName = 'affine-table-cell';
export class TableCell extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
@property({ attribute: false })
accessor text: Text | undefined = undefined;
get readonly() {
return this.dataManager.readonly$.value;
}
@property({ attribute: false })
accessor dataManager!: TableDataManager;
@query('rich-text')
accessor richText: RichText | null = null;
@property({ type: Number })
accessor rowIndex = 0;
@property({ type: Number })
accessor columnIndex = 0;
@property({ attribute: false })
accessor row: TableRow | undefined = undefined;
@property({ attribute: false })
accessor column: TableColumn | undefined = undefined;
@property({ attribute: false })
accessor selectionController!: SelectionController;
@property({ attribute: false })
accessor height: number | undefined;
get hoverColumnIndex$() {
return this.dataManager.hoverColumnIndex$;
}
get hoverRowIndex$() {
return this.dataManager.hoverRowIndex$;
}
get inlineManager() {
return this.closest<TableBlockComponent>('affine-table')?.std.get(
DefaultInlineManagerExtension.identifier
);
}
get topContenteditableElement() {
return this.closest<TableBlockComponent>('affine-table')
?.topContenteditableElement;
}
openColumnOptions(
target: PopupTarget,
column: TableColumn,
columnIndex: number
) {
this.selectionController.setSelected({
type: 'column',
columnId: column.columnId,
});
popMenu(target, {
options: {
onClose: () => {
this.selectionController.setSelected(undefined);
},
items: [
menu.group({
items: [
menu.subMenu({
name: 'Background color',
prefix: ColorPickerIcon(),
options: {
items: [
{ name: 'Default', color: undefined },
...colorList,
].map(item =>
menu.action({
prefix: html`<div
style="color: ${item.color ??
cssVarV2.layer.background
.primary};display: flex;align-items: center;justify-content: center;"
>
${TextBackgroundDuotoneIcon}
</div>`,
name: item.name,
isSelected: column.backgroundColor === item.color,
select: () => {
this.dataManager.setColumnBackgroundColor(
column.columnId,
item.color
);
},
})
),
},
}),
...(column.backgroundColor
? [
menu.action({
name: 'Clear column style',
prefix: CloseIcon(),
select: () => {
this.dataManager.setColumnBackgroundColor(
column.columnId,
undefined
);
},
}),
]
: []),
],
}),
menu.group({
items: [
menu.action({
name: 'Insert Left',
prefix: InsertLeftIcon(),
select: () => {
this.dataManager.insertColumn(columnIndex - 1);
},
}),
menu.action({
name: 'Insert Right',
prefix: InsertRightIcon(),
select: () => {
this.dataManager.insertColumn(columnIndex + 1);
},
}),
menu.action({
name: 'Move Left',
prefix: ArrowLeftBigIcon(),
select: () => {
this.dataManager.moveColumn(columnIndex, columnIndex - 2);
},
}),
menu.action({
name: 'Move Right',
prefix: ArrowRightBigIcon(),
select: () => {
this.dataManager.moveColumn(columnIndex, columnIndex + 1);
},
}),
],
}),
menu.group({
items: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
select: () => {
this.dataManager.duplicateColumn(columnIndex);
},
}),
menu.action({
name: 'Clear column contents',
prefix: CloseIcon(),
select: () => {
this.dataManager.clearColumn(column.columnId);
},
}),
menu.action({
name: 'Delete',
class: {
'delete-item': true,
},
prefix: DeleteIcon(),
select: () => {
this.dataManager.deleteColumn(column.columnId);
},
}),
],
}),
],
},
});
}
openRowOptions(target: PopupTarget, row: TableRow, rowIndex: number) {
this.selectionController.setSelected({
type: 'row',
rowId: row.rowId,
});
popMenu(target, {
options: {
onClose: () => {
this.selectionController.setSelected(undefined);
},
items: [
menu.group({
items: [
menu.subMenu({
name: 'Background color',
prefix: ColorPickerIcon(),
options: {
items: [
{ name: 'Default', color: undefined },
...colorList,
].map(item =>
menu.action({
prefix: html`<div
style="color: ${item.color ??
cssVarV2.layer.background
.primary};display: flex;align-items: center;justify-content: center;"
>
${TextBackgroundDuotoneIcon}
</div>`,
name: item.name,
isSelected: row.backgroundColor === item.color,
select: () => {
this.dataManager.setRowBackgroundColor(
row.rowId,
item.color
);
},
})
),
},
}),
...(row.backgroundColor
? [
menu.action({
name: 'Clear row style',
prefix: CloseIcon(),
select: () => {
this.dataManager.setRowBackgroundColor(
row.rowId,
undefined
);
},
}),
]
: []),
],
}),
menu.group({
items: [
menu.action({
name: 'Insert Above',
prefix: InsertAboveIcon(),
select: () => {
this.dataManager.insertRow(rowIndex - 1);
},
}),
menu.action({
name: 'Insert Below',
prefix: InsertBelowIcon(),
select: () => {
this.dataManager.insertRow(rowIndex + 1);
},
}),
menu.action({
name: 'Move Up',
prefix: ArrowUpBigIcon(),
select: () => {
this.dataManager.moveRow(rowIndex, rowIndex - 1);
},
}),
menu.action({
name: 'Move Down',
prefix: ArrowDownBigIcon(),
select: () => {
this.dataManager.moveRow(rowIndex, rowIndex + 1);
},
}),
],
}),
menu.group({
items: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
select: () => {
this.dataManager.duplicateRow(rowIndex);
},
}),
menu.action({
name: 'Clear row contents',
prefix: CloseIcon(),
select: () => {
this.dataManager.clearRow(row.rowId);
},
}),
menu.action({
name: 'Delete',
class: {
'delete-item': true,
},
prefix: DeleteIcon(),
select: () => {
this.dataManager.deleteRow(row.rowId);
},
}),
],
}),
],
},
});
}
createColorPickerMenu(
currentColor: string | undefined,
select: (color?: string) => void
) {
return menu.subMenu({
name: 'Background color',
prefix: ColorPickerIcon(),
options: {
items: [{ name: 'Default', color: undefined }, ...colorList].map(item =>
menu.action({
prefix: html`<div
style="color: ${item.color ??
cssVarV2.layer.background
.primary};display: flex;align-items: center;justify-content: center;"
>
${TextBackgroundDuotoneIcon}
</div>`,
name: item.name,
isSelected: currentColor === item.color,
select: () => {
select(item.color);
},
})
),
},
});
}
onContextMenu(e: Event) {
e.preventDefault();
e.stopPropagation();
const selected = this.selectionController.selected$.value;
if (!selected) {
return;
}
if (selected.type === 'area' && e.currentTarget instanceof HTMLElement) {
const target = popupTargetFromElement(e.currentTarget);
popMenu(target, {
options: {
items: [
menu.group({
items: [
menu.action({
name: 'Copy',
prefix: CopyIcon(),
select: () => {
this.selectionController.doCopyOrCut(selected, false);
},
}),
menu.action({
name: 'Paste',
prefix: PasteIcon(),
select: () => {
navigator.clipboard.readText().then(text => {
this.selectionController.doPaste(text, selected);
});
},
}),
],
}),
menu.group({
items: [
menu.action({
name: 'Clear contents',
prefix: CloseIcon(),
select: () => {
this.dataManager.clearCellsBySelection(selected);
},
}),
],
}),
],
},
});
}
}
renderColumnOptions(column: TableColumn, columnIndex: number) {
const openColumnOptions = (e: Event) => {
e.stopPropagation();
const element = e.currentTarget;
if (element instanceof HTMLElement) {
this.openColumnOptions(
popupTargetFromElement(element),
column,
columnIndex
);
}
};
return html`<div class=${columnOptionsCellStyle}>
<div
data-testid="drag-column-handle"
data-drag-column-id=${column.columnId}
class=${classMap({
[columnOptionsStyle]: true,
})}
style=${styleMap({
opacity: columnIndex === this.hoverColumnIndex$.value ? 1 : undefined,
})}
@click=${openColumnOptions}
>
${threePointerIcon()}
</div>
</div>`;
}
renderRowOptions(row: TableRow, rowIndex: number) {
const openRowOptions = (e: Event) => {
e.stopPropagation();
const element = e.currentTarget;
if (element instanceof HTMLElement) {
this.openRowOptions(popupTargetFromElement(element), row, rowIndex);
}
};
return html`<div class=${rowOptionsCellStyle}>
<div
data-testid="drag-row-handle"
data-drag-row-id=${row.rowId}
class=${classMap({
[rowOptionsStyle]: true,
})}
style=${styleMap({
opacity: rowIndex === this.hoverRowIndex$.value ? 1 : undefined,
})}
@click=${openRowOptions}
>
${threePointerIcon(true)}
</div>
</div>`;
}
renderOptionsButton() {
if (this.readonly || !this.row || !this.column) {
return nothing;
}
return html`
${this.rowIndex === 0
? this.renderColumnOptions(this.column, this.columnIndex)
: nothing}
${this.columnIndex === 0
? this.renderRowOptions(this.row, this.rowIndex)
: nothing}
`;
}
tdMouseEnter(rowIndex: number, columnIndex: number) {
this.hoverColumnIndex$.value = columnIndex;
this.hoverRowIndex$.value = rowIndex;
}
tdMouseLeave() {
this.hoverColumnIndex$.value = undefined;
this.hoverRowIndex$.value = undefined;
}
virtualWidth$ = computed(() => {
const virtualWidth = this.dataManager.virtualWidth$.value;
if (!virtualWidth || this.column?.columnId !== virtualWidth.columnId) {
return undefined;
}
return virtualWidth.width;
});
tdStyle() {
const columnWidth = this.virtualWidth$.value ?? this.column?.width;
const backgroundColor =
this.column?.backgroundColor ?? this.row?.backgroundColor ?? undefined;
return styleMap({
backgroundColor,
minWidth: columnWidth ? `${columnWidth}px` : `${DefaultColumnWidth}px`,
maxWidth: columnWidth ? `${columnWidth}px` : `${ColumnMaxWidth}px`,
});
}
showColumnIndicator$ = computed(() => {
const indicatorIndex =
this.dataManager.ui.columnIndicatorIndex$.value ?? -1;
if (indicatorIndex === 0 && this.columnIndex === 0) {
return 'left';
}
if (indicatorIndex - 1 === this.columnIndex) {
return 'right';
}
return;
});
showRowIndicator$ = computed(() => {
const indicatorIndex = this.dataManager.ui.rowIndicatorIndex$.value ?? -1;
if (indicatorIndex === 0 && this.rowIndex === 0) {
return 'top';
}
if (indicatorIndex - 1 === this.rowIndex) {
return 'bottom';
}
return;
});
renderRowIndicator() {
if (this.readonly) {
return nothing;
}
const columnIndex = this.columnIndex;
const isFirstColumn = columnIndex === 0;
const isLastColumn =
columnIndex === this.dataManager.uiColumns$.value.length - 1;
const showIndicator = this.showRowIndicator$.value;
const style = (show: boolean) =>
styleMap({
opacity: show ? 1 : 0,
borderRadius: isFirstColumn
? '3px 0 0 3px'
: isLastColumn
? '0 3px 3px 0'
: '0',
});
const indicator0 =
this.rowIndex === 0
? html`
<div
style=${style(showIndicator === 'top')}
class=${rowTopIndicatorStyle}
></div>
`
: nothing;
return html`
${indicator0}
<div
style=${style(showIndicator === 'bottom')}
class=${rowBottomIndicatorStyle}
></div>
`;
}
renderColumnIndicator() {
if (this.readonly) {
return nothing;
}
const hoverColumnId$ = this.dataManager.hoverDragHandleColumnId$;
const draggingColumnId$ = this.dataManager.widthAdjustColumnId$;
const rowIndex = this.rowIndex;
const isFirstRow = rowIndex === 0;
const isLastRow = rowIndex === this.dataManager.uiRows$.value.length - 1;
const showWidthAdjustIndicator =
draggingColumnId$.value === this.column?.columnId ||
hoverColumnId$.value === this.column?.columnId;
const showIndicator = this.showColumnIndicator$.value;
const style = (show: boolean) =>
styleMap({
opacity: show ? 1 : 0,
borderRadius: isFirstRow
? '3px 3px 0 0'
: isLastRow
? '0 0 3px 3px'
: '0',
});
const indicator0 =
this.columnIndex === 0
? html`
<div
style=${style(showIndicator === 'left')}
class=${columnLeftIndicatorStyle}
></div>
`
: nothing;
const mouseEnter = () => {
hoverColumnId$.value = this.column?.columnId;
};
const mouseLeave = () => {
hoverColumnId$.value = undefined;
};
return html` ${indicator0}
<div
@mouseenter=${mouseEnter}
@mouseleave=${mouseLeave}
style=${style(showWidthAdjustIndicator || showIndicator === 'right')}
data-width-adjust-column-id=${this.column?.columnId}
class=${columnRightIndicatorStyle}
></div>`;
}
richText$ = signal<RichText>();
get inlineEditor() {
return this.richText$.value?.inlineEditor;
}
override connectedCallback() {
super.connectedCallback();
if (this.readonly) {
return;
}
const selectAll = (e: KeyboardEvent) => {
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
e.stopPropagation();
e.preventDefault();
this.inlineEditor?.selectAll();
}
};
this.addEventListener('keydown', selectAll);
this.disposables.add(() => {
this.removeEventListener('keydown', selectAll);
});
this.disposables.addFromEvent(this, 'click', (e: MouseEvent) => {
e.stopPropagation();
requestAnimationFrame(() => {
if (!this.inlineEditor?.inlineRange$.value) {
this.inlineEditor?.focusEnd();
}
});
});
}
override firstUpdated() {
if (this.readonly) {
return;
}
this.richText$.value?.updateComplete
.then(() => {
this.disposables.add(
effect(() => {
const richText = this.richText$.value;
if (!richText) {
return;
}
const inlineEditor = this.inlineEditor;
if (!inlineEditor) {
return;
}
const inlineRange = inlineEditor.inlineRange$.value;
const targetSelection: TableAreaSelection = {
type: 'area',
rowStartIndex: this.rowIndex,
rowEndIndex: this.rowIndex,
columnStartIndex: this.columnIndex,
columnEndIndex: this.columnIndex,
};
const currentSelection = this.selectionController.selected$.peek();
if (
inlineRange &&
!TableSelectionData.equals(targetSelection, currentSelection)
) {
this.selectionController.setSelected(targetSelection, false);
}
})
);
})
.catch(console.error);
}
override render() {
if (!this.text) {
return html`<td class=${cellContainerStyle} style=${this.tdStyle()}>
<div
style=${styleMap({
padding: '8px 12px',
})}
>
<div style="height:22px"></div>
</div>
</td>`;
}
return html`
<td
data-row-id=${this.row?.rowId}
data-column-id=${this.column?.columnId}
@mouseenter=${() => {
this.tdMouseEnter(this.rowIndex, this.columnIndex);
}}
@mouseleave=${() => {
this.tdMouseLeave();
}}
@contextmenu=${this.onContextMenu}
class=${cellContainerStyle}
style=${this.tdStyle()}
>
<rich-text
${ref(this.richText$)}
data-disable-ask-ai
data-not-block-text
style=${styleMap({
minHeight: '22px',
padding: '8px 12px',
})}
.yText="${this.text}"
.inlineEventSource="${this.topContenteditableElement}"
.attributesSchema="${this.inlineManager?.getSchema()}"
.attributeRenderer="${this.inlineManager?.getRenderer()}"
.embedChecker="${this.inlineManager?.embedChecker}"
.markdownMatches="${this.inlineManager?.markdownMatches}"
.readonly="${this.readonly}"
.enableClipboard="${true}"
.verticalScrollContainerGetter="${() =>
this.topContenteditableElement?.host
? getViewportElement(this.topContenteditableElement.host)
: null}"
data-parent-flavour="affine:table"
></rich-text>
${this.renderOptionsButton()} ${this.renderColumnIndicator()}
${this.renderRowIndicator()}
</td>
`;
}
}
export const createColumnDragPreview = (cells: TableCell[]) => {
const container = document.createElement('div');
container.style.position = 'absolute';
container.style.opacity = '0.8';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.zIndex = '1000';
container.style.boxShadow = '0 0 10px 0 rgba(0, 0, 0, 0.1)';
container.style.backgroundColor = cssVarV2.layer.background.primary;
cells.forEach((cell, index) => {
const div = document.createElement('div');
const td = cell.querySelector('td');
if (index !== 0) {
div.style.borderTop = `1px solid ${cssVarV2.layer.insideBorder.border}`;
}
if (td) {
div.style.height = `${td.getBoundingClientRect().height}px`;
}
if (cell.text) {
const text = new RichText();
text.style.padding = '8px 12px';
text.yText = cell.text;
text.readonly = true;
text.attributesSchema = cell.inlineManager?.getSchema();
text.attributeRenderer = cell.inlineManager?.getRenderer();
text.embedChecker = cell.inlineManager?.embedChecker ?? (() => false);
div.append(text);
}
container.append(div);
});
return container;
};
export const createRowDragPreview = (cells: TableCell[]) => {
const container = document.createElement('div');
container.style.position = 'absolute';
container.style.opacity = '0.8';
container.style.display = 'flex';
container.style.flexDirection = 'row';
container.style.zIndex = '1000';
container.style.boxShadow = '0 0 10px 0 rgba(0, 0, 0, 0.1)';
container.style.backgroundColor = cssVarV2.layer.background.primary;
cells.forEach((cell, index) => {
const div = document.createElement('div');
const td = cell.querySelector('td');
if (index !== 0) {
div.style.borderLeft = `1px solid ${cssVarV2.layer.insideBorder.border}`;
}
if (td) {
div.style.width = `${td.getBoundingClientRect().width}px`;
}
if (cell.text) {
const text = new RichText();
text.style.padding = '8px 12px';
text.yText = cell.text;
text.readonly = true;
text.attributesSchema = cell.inlineManager?.getSchema();
text.attributeRenderer = cell.inlineManager?.getRenderer();
text.embedChecker = cell.inlineManager?.embedChecker ?? (() => false);
div.append(text);
}
container.append(div);
});
return container;
};
const threePointerIcon = (vertical: boolean = false) => {
return html`
<div
class=${threePointerIconStyle}
style=${styleMap({
transform: vertical ? 'rotate(90deg)' : undefined,
})}
>
<div class=${threePointerIconDotStyle}></div>
<div class=${threePointerIconDotStyle}></div>
<div class=${threePointerIconDotStyle}></div>
</div>
`;
};
declare global {
interface HTMLElementTagNameMap {
[TableCellComponentName]: TableCell;
}
}