feat(editor): simple table block (#9740)

close: BS-2122, BS-2125, BS-2124, BS-2420, PD-2073, BS-2126, BS-2469, BS-2470, BS-2478, BS-2471
This commit is contained in:
zzj3720
2025-01-24 10:07:57 +00:00
parent 3f4311ff1c
commit 5a5779c05a
61 changed files with 3577 additions and 381 deletions

View File

@@ -0,0 +1,13 @@
import type { ExtensionType } from '@blocksuite/store';
import { TableBlockHtmlAdapterExtension } from './html.js';
import { TableBlockMarkdownAdapterExtension } from './markdown.js';
import { TableBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { TableBlockPlainTextAdapterExtension } from './plain-text.js';
export const TableBlockAdapterExtensions: ExtensionType[] = [
TableBlockHtmlAdapterExtension,
TableBlockMarkdownAdapterExtension,
TableBlockNotionHtmlAdapterExtension,
TableBlockPlainTextAdapterExtension,
];

View File

@@ -0,0 +1,126 @@
import {
type TableBlockPropsSerialized,
TableBlockSchema,
TableModelFlavour,
} from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
HastUtils,
type InlineHtmlAST,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
import type { Element } from 'hast';
import { DefaultColumnWidth } from '../consts';
import { parseTableFromHtml, processTable } from './utils';
const TABLE_NODE_TYPES = new Set(['table', 'thead', 'tbody', 'th', 'tr']);
export const tableBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: TableBlockSchema.model.flavour,
toMatch: o => {
return HastUtils.isElement(o.node) && TABLE_NODE_TYPES.has(o.node.tagName);
},
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
if (o.node.tagName === 'table') {
const tableProps = parseTableFromHtml(o.node);
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: TableModelFlavour,
props: tableProps as unknown as Record<string, unknown>,
children: [],
},
'children'
);
walkerContext.skipChildren();
}
},
leave: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
if (o.node.tagName === 'table') {
walkerContext.closeNode();
}
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext } = context;
const { columns, rows, cells } = o.node
.props as unknown as TableBlockPropsSerialized;
const table = processTable(columns, rows, cells);
const createAstTableCell = (
children: InlineHtmlAST[]
): InlineHtmlAST => ({
type: 'element',
tagName: 'td',
properties: Object.create(null),
children: [
{
type: 'element',
tagName: 'div',
properties: {
style: `min-height: 22px;min-width:${DefaultColumnWidth}px;padding: 8px 12px;`,
},
children,
},
],
});
const createAstTableRow = (cells: InlineHtmlAST[]): Element => ({
type: 'element',
tagName: 'tr',
properties: Object.create(null),
children: cells,
});
const { deltaConverter } = context;
const tableBodyAst: Element = {
type: 'element',
tagName: 'tbody',
properties: Object.create(null),
children: table.rows.map(v => {
return createAstTableRow(
v.cells.map(cell => {
return createAstTableCell(
typeof cell.value === 'string'
? [{ type: 'text', value: cell.value }]
: deltaConverter.deltaToAST(cell.value.delta)
);
})
);
}),
};
walkerContext
.openNode({
type: 'element',
tagName: 'table',
properties: {
border: true,
style: 'border-collapse: collapse;border-spacing: 0;',
},
children: [tableBodyAst],
})
.closeNode();
walkerContext.skipAllChildren();
},
},
};
export const TableBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
tableBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,4 @@
export * from './html';
export * from './markdown';
export * from './notion-html';
export * from './plain-text';

View File

@@ -0,0 +1,79 @@
import {
type TableBlockPropsSerialized,
TableBlockSchema,
TableModelFlavour,
} from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
import type { TableRow } from 'mdast';
import { parseTableFromMarkdown, processTable } from './utils';
const TABLE_NODE_TYPES = new Set(['table', 'tableRow']);
const isTableNode = (node: MarkdownAST) => TABLE_NODE_TYPES.has(node.type);
export const tableBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
flavour: TableBlockSchema.model.flavour,
toMatch: o => isTableNode(o.node),
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
const { walkerContext } = context;
if (o.node.type === 'table') {
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: TableModelFlavour,
props: parseTableFromMarkdown(o.node),
children: [],
},
'children'
);
walkerContext.skipChildren();
}
},
leave: (o, context) => {
const { walkerContext } = context;
if (o.node.type === 'table') {
walkerContext.closeNode();
}
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext, deltaConverter } = context;
const { columns, rows, cells } = o.node
.props as unknown as TableBlockPropsSerialized;
const table = processTable(columns, rows, cells);
const result: TableRow[] = [];
table.rows.forEach(v => {
result.push({
type: 'tableRow',
children: v.cells.map(v => ({
type: 'tableCell',
children: deltaConverter.deltaToAST(v.value.delta),
})),
});
});
walkerContext
.openNode({
type: 'table',
children: result,
})
.closeNode();
walkerContext.skipAllChildren();
},
},
};
export const TableBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
tableBlockMarkdownAdapterMatcher
);

View File

@@ -0,0 +1,21 @@
import { TableBlockSchema } from '@blocksuite/affine-model';
import {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
const TABLE_NODE_TYPES = new Set(['table', 'th', 'tr']);
export const tableBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
{
flavour: TableBlockSchema.model.flavour,
toMatch: o =>
HastUtils.isElement(o.node) && TABLE_NODE_TYPES.has(o.node.tagName),
fromMatch: () => false,
toBlockSnapshot: {},
fromBlockSnapshot: {},
};
export const TableBlockNotionHtmlAdapterExtension =
BlockNotionHtmlAdapterExtension(tableBlockNotionHtmlAdapterMatcher);

View File

@@ -0,0 +1,74 @@
import {
type TableBlockPropsSerialized,
TableBlockSchema,
TableModelFlavour,
} from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
import { createTableProps, formatTable, processTable } from './utils.js';
export const tableBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = {
flavour: TableBlockSchema.model.flavour,
toMatch: () => true,
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
const { walkerContext } = context;
const text = o.node.content;
const rowTexts = text.split('\n');
if (rowTexts.length <= 1) return;
const rowTextLists: string[][] = [];
let columnCount: number | null = null;
for (const row of rowTexts) {
const cells = row.split('\t');
if (cells.length <= 1) return;
if (columnCount == null) {
columnCount = cells.length;
} else if (columnCount !== cells.length) {
return;
}
rowTextLists.push(cells);
}
const tableProps = createTableProps(rowTextLists);
walkerContext.openNode({
type: 'block',
id: nanoid(),
flavour: TableModelFlavour,
props: tableProps,
children: [],
});
walkerContext.skipAllChildren();
},
leave: (_, context) => {
const { walkerContext } = context;
walkerContext.closeNode();
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext, deltaConverter } = context;
const result: string[][] = [];
const { columns, rows, cells } = o.node
.props as unknown as TableBlockPropsSerialized;
const table = processTable(columns, rows, cells);
table.rows.forEach(v => {
result.push(
v.cells.map(v => deltaConverter.deltaToAST(v.value.delta).join(''))
);
});
const tableString = formatTable(result);
context.textBuffer.content += tableString;
context.textBuffer.content += '\n';
walkerContext.skipAllChildren();
},
},
};
export const TableBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(tableBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,215 @@
import type {
TableBlockPropsSerialized,
TableCellSerialized,
TableColumn,
TableRow,
} from '@blocksuite/affine-model';
import { HastUtils, TextUtils } from '@blocksuite/affine-shared/adapters';
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
import type { DeltaInsert } from '@blocksuite/inline';
import { nanoid } from '@blocksuite/store';
import type { Element, ElementContent } from 'hast';
import type { PhrasingContent, Table as MarkdownTable, TableCell } from 'mdast';
function calculateColumnWidths(rows: string[][]): number[] {
return (
rows[0]?.map((_, colIndex) =>
Math.max(...rows.map(row => (row[colIndex] || '').length))
) ?? []
);
}
function formatRow(
row: string[],
columnWidths: number[],
isHeader: boolean
): string {
const cells = row.map((cell, colIndex) =>
cell?.padEnd(columnWidths[colIndex] ?? 0, ' ')
);
const rowString = `| ${cells.join(' | ')} |`;
return isHeader
? `${rowString}\n${formatSeparator(columnWidths)}`
: rowString;
}
function formatSeparator(columnWidths: number[]): string {
const separator = columnWidths.map(width => '-'.repeat(width)).join(' | ');
return `| ${separator} |`;
}
export function formatTable(rows: string[][]): string {
const columnWidths = calculateColumnWidths(rows);
const formattedRows = rows.map((row, index) =>
formatRow(row, columnWidths, index === 0)
);
return formattedRows.join('\n');
}
type Table = {
rows: Row[];
};
type Row = {
cells: Cell[];
};
type Cell = {
value: { delta: DeltaInsert[] };
};
export const processTable = (
columns: Record<string, TableColumn>,
rows: Record<string, TableRow>,
cells: Record<string, TableCellSerialized>
): Table => {
const sortedColumns = Object.values(columns).sort((a, b) =>
a.order.localeCompare(b.order)
);
const sortedRows = Object.values(rows).sort((a, b) =>
a.order.localeCompare(b.order)
);
const table: Table = {
rows: [],
};
sortedRows.forEach(r => {
const row: Row = {
cells: [],
};
sortedColumns.forEach(col => {
const cell = cells[`${r.rowId}:${col.columnId}`];
if (!cell) {
row.cells.push({
value: {
delta: [],
},
});
return;
}
row.cells.push({
value: cell.text,
});
});
table.rows.push(row);
});
return table;
};
const getTextFromElement = (element: ElementContent): string => {
if (element.type === 'text') {
return element.value;
}
if (element.type === 'element') {
return element.children.map(child => getTextFromElement(child)).join('');
}
return '';
};
const getAllTag = (node: Element | undefined, tagName: string): Element[] => {
if (!node) {
return [];
}
if (HastUtils.isElement(node)) {
if (node.tagName === tagName) {
return [node];
}
return node.children.flatMap(child => {
if (HastUtils.isElement(child)) {
return getAllTag(child, tagName);
}
return [];
});
}
return [];
};
export const createTableProps = (rowTextLists: string[][]) => {
const createIdAndOrder = (count: number) => {
const result: { id: string; order: string }[] = Array.from({
length: count,
});
for (let i = 0; i < count; i++) {
const id = nanoid();
const order = generateFractionalIndexingKeyBetween(
result[i - 1]?.order ?? null,
null
);
result[i] = { id, order };
}
return result;
};
const columnCount = Math.max(...rowTextLists.map(row => row.length));
const rowCount = rowTextLists.length;
const columns: TableColumn[] = createIdAndOrder(columnCount).map(v => ({
columnId: v.id,
order: v.order,
}));
const rows: TableRow[] = createIdAndOrder(rowCount).map(v => ({
rowId: v.id,
order: v.order,
}));
const cells: Record<string, TableCellSerialized> = {};
for (let i = 0; i < rowCount; i++) {
for (let j = 0; j < columnCount; j++) {
const row = rows[i];
const column = columns[j];
if (!row || !column) {
continue;
}
const cellId = `${row.rowId}:${column.columnId}`;
const text = rowTextLists[i]?.[j];
cells[cellId] = {
text: TextUtils.createText(text ?? ''),
};
}
}
return {
columns: Object.fromEntries(
columns.map(column => [column.columnId, column])
),
rows: Object.fromEntries(rows.map(row => [row.rowId, row])),
cells,
};
};
export const parseTableFromHtml = (
element: Element
): TableBlockPropsSerialized => {
const headerRows = getAllTag(element, 'thead').flatMap(node =>
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'th'))
);
const bodyRows = getAllTag(element, 'tbody').flatMap(node =>
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'td'))
);
const footerRows = getAllTag(element, 'tfoot').flatMap(node =>
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'td'))
);
const allRows = [...headerRows, ...bodyRows, ...footerRows];
const rowTextLists: string[][] = [];
allRows.forEach(cells => {
const row: string[] = [];
cells.forEach(cell => {
row.push(getTextFromElement(cell));
});
rowTextLists.push(row);
});
return createTableProps(rowTextLists);
};
const getTextFromTableCell = (node: TableCell) => {
const getTextFromPhrasingContent = (node: PhrasingContent) => {
if (node.type === 'text') {
return node.value;
}
return '';
};
return node.children.map(child => getTextFromPhrasingContent(child)).join('');
};
export const parseTableFromMarkdown = (node: MarkdownTable) => {
const rowTextLists: string[][] = [];
node.children.forEach(row => {
const rowText: string[] = [];
row.children.forEach(cell => {
rowText.push(getTextFromTableCell(cell));
});
rowTextLists.push(rowText);
});
return createTableProps(rowTextLists);
};

View File

@@ -0,0 +1,90 @@
import { cssVar, cssVarV2 } from '@blocksuite/affine-shared/theme';
import { style } from '@vanilla-extract/css';
export const addColumnButtonStyle = style({
cursor: 'col-resize',
backgroundColor: cssVarV2.layer.background.hoverOverlay,
fontSize: '10px',
color: cssVarV2.icon.secondary,
display: 'flex',
width: '12px',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
top: '0',
left: 'calc(100% + 2px)',
height: '100%',
transition:
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
borderRadius: '2px',
opacity: 0,
selectors: {
'&:hover, &.active': {
backgroundColor: cssVarV2.table.indicator.drag,
color: cssVarV2.icon.primary,
opacity: 1,
},
},
});
export const addRowButtonStyle = style({
cursor: 'row-resize',
backgroundColor: cssVarV2.layer.background.hoverOverlay,
fontSize: '10px',
color: cssVarV2.icon.secondary,
display: 'flex',
height: '12px',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
top: 'calc(100% + 2px)',
left: '0',
width: '100%',
transition:
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
borderRadius: '2px',
opacity: 0,
selectors: {
'&:hover, &.active': {
backgroundColor: cssVarV2.table.indicator.drag,
color: cssVarV2.icon.primary,
opacity: 1,
},
},
});
export const addRowColumnButtonStyle = style({
cursor: 'nwse-resize',
backgroundColor: cssVarV2.layer.background.hoverOverlay,
fontSize: '10px',
color: cssVarV2.icon.secondary,
display: 'flex',
width: '12px',
height: '12px',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
top: 'calc(100% + 2px)',
left: 'calc(100% + 2px)',
borderRadius: '2px',
opacity: 0,
transition:
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
selectors: {
'&:hover, &.active': {
backgroundColor: cssVarV2.table.indicator.drag,
color: cssVarV2.icon.primary,
opacity: 1,
},
},
});
export const cellCountTipsStyle = style({
position: 'absolute',
backgroundColor: cssVarV2.tooltips.background,
borderRadius: '4px',
padding: '4px',
boxShadow: cssVar('buttonShadow'),
color: cssVarV2.tooltips.foreground,
whiteSpace: 'nowrap',
});

View File

@@ -0,0 +1,327 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { PlusIcon } from '@blocksuite/icons/lit';
import {
autoPlacement,
autoUpdate,
computePosition,
offset,
shift,
} from '@floating-ui/dom';
import { signal } from '@preact/signals-core';
import { html } from 'lit';
import { property } 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 {
addColumnButtonStyle,
addRowButtonStyle,
addRowColumnButtonStyle,
cellCountTipsStyle,
} from './add-button.css';
import { DefaultColumnWidth, DefaultRowHeight } from './consts';
import type { TableDataManager } from './table-data-manager';
export class AddButton extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
@property({ type: Boolean })
accessor vertical = false;
@property({ attribute: false })
accessor dataManager!: TableDataManager;
get hoverColumnIndex$() {
return this.dataManager.hoverColumnIndex$;
}
get hoverRowIndex$() {
return this.dataManager.hoverRowIndex$;
}
get columns$() {
return this.dataManager.columns$;
}
get rows$() {
return this.dataManager.rows$;
}
addColumnButtonRef$ = signal<HTMLDivElement>();
addRowButtonRef$ = signal<HTMLDivElement>();
addRowColumnButtonRef$ = signal<HTMLDivElement>();
columnDragging$ = signal(false);
rowDragging$ = signal(false);
rowColumnDragging$ = signal(false);
popCellCountTips = (ele: Element) => {
const tip = document.createElement('div');
tip.classList.add(cellCountTipsStyle);
document.body.append(tip);
const dispose = autoUpdate(ele, tip, () => {
computePosition(ele, tip, {
middleware: [
autoPlacement({ allowedPlacements: ['bottom'] }),
offset(4),
shift(),
],
})
.then(({ x, y }) => {
tip.style.left = `${x}px`;
tip.style.top = `${y}px`;
})
.catch(e => {
console.error(e);
});
});
return {
tip,
dispose: () => {
dispose();
tip.remove();
},
};
};
getEmptyRows() {
const rows = this.rows$.value;
const columns = this.columns$.value;
const rowWidths: number[] = [];
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (!row) {
break;
}
const hasText = columns.some(column => {
const cell = this.dataManager.getCell(row.rowId, column.columnId);
if (!cell) {
return false;
}
return cell.text.length > 0;
});
if (hasText) {
break;
}
rowWidths.push((rowWidths[rowWidths.length - 1] ?? 0) + DefaultRowHeight);
}
return rowWidths;
}
getEmptyColumns() {
const columns = this.columns$.value;
const rows = this.rows$.value;
const columnWidths: number[] = [];
for (let i = columns.length - 1; i >= 0; i--) {
const column = columns[i];
if (!column) {
break;
}
const hasText = rows.some(row => {
const cell = this.dataManager.getCell(row.rowId, column.columnId);
if (!cell) {
return false;
}
return cell.text.length > 0;
});
if (hasText) {
break;
}
columnWidths.push(
(columnWidths[columnWidths.length - 1] ?? 0) +
(column.width ?? DefaultColumnWidth)
);
}
return columnWidths;
}
onDragStart(e: MouseEvent) {
e.stopPropagation();
const initialX = e.clientX;
const initialY = e.clientY;
const target = e.target as HTMLElement;
const isColumn = target.closest('.column-add');
const isRow = target.closest('.row-add');
const isRowColumn = target.closest('.row-column-add');
const realTarget = isColumn || isRowColumn || isRow;
if (!realTarget) {
return;
}
const tipsHandler = this.popCellCountTips(realTarget);
let emptyRows: number[] = [];
let emptyColumns: number[] = [];
if (isColumn) {
this.columnDragging$.value = true;
emptyColumns = this.getEmptyColumns();
}
if (isRow) {
this.rowDragging$.value = true;
emptyRows = this.getEmptyRows();
}
if (isRowColumn) {
this.rowColumnDragging$.value = true;
emptyRows = this.getEmptyRows();
emptyColumns = this.getEmptyColumns();
}
const onMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - initialX;
const deltaY = e.clientY - initialY;
const addColumn = isColumn || isRowColumn;
const addRow = isRow || isRowColumn;
if (addColumn) {
if (deltaX > 0) {
this.dataManager.virtualColumnCount$.value = Math.floor(
(deltaX + 30) / DefaultColumnWidth
);
} else {
let count = 0;
while (count < emptyColumns.length) {
const emptyColumnWidth = emptyColumns[count];
if (!emptyColumnWidth) {
continue;
}
if (-deltaX > emptyColumnWidth) {
count++;
} else {
break;
}
}
this.dataManager.virtualColumnCount$.value = -count;
}
}
if (addRow) {
if (deltaY > 0) {
this.dataManager.virtualRowCount$.value = Math.floor(
deltaY / DefaultRowHeight
);
} else {
let count = 0;
while (count < emptyRows.length) {
const emptyRowHeight = emptyRows[count];
if (!emptyRowHeight) {
continue;
}
if (-deltaY > emptyRowHeight) {
count++;
} else {
break;
}
}
this.dataManager.virtualRowCount$.value = -count;
}
}
tipsHandler.tip.textContent = this.dataManager.cellCountTips$.value;
};
const onMouseUp = () => {
this.columnDragging$.value = false;
this.rowDragging$.value = false;
this.rowColumnDragging$.value = false;
const rowCount = this.dataManager.virtualRowCount$.value;
const columnCount = this.dataManager.virtualColumnCount$.value;
this.dataManager.virtualColumnCount$.value = 0;
this.dataManager.virtualRowCount$.value = 0;
this.dataManager.addNRow(rowCount);
this.dataManager.addNColumn(columnCount);
tipsHandler.dispose();
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
override connectedCallback(): void {
super.connectedCallback();
this.disposables.addFromEvent(this, 'mousedown', (e: MouseEvent) => {
this.onDragStart(e);
});
}
renderAddColumnButton() {
const hovered =
this.hoverColumnIndex$.value === this.columns$.value.length - 1;
const dragging = this.columnDragging$.value;
return html` <div
class="${classMap({
[addColumnButtonStyle]: true,
active: dragging,
'column-add': true,
})}"
${ref(this.addColumnButtonRef$)}
style=${styleMap({
opacity: hovered || dragging ? 1 : undefined,
})}
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.dataManager.addColumn(this.columns$.value.length - 1);
}}"
>
${PlusIcon()}
</div>`;
}
renderAddRowButton() {
const hovered = this.hoverRowIndex$.value === this.rows$.value.length - 1;
const dragging = this.rowDragging$.value;
return html` <div
class="${classMap({
[addRowButtonStyle]: true,
active: dragging,
'row-add': true,
})}"
${ref(this.addRowButtonRef$)}
style=${styleMap({
opacity: hovered || dragging ? 1 : undefined,
})}
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.dataManager.addRow(this.rows$.value.length - 1);
}}"
>
${PlusIcon()}
</div>`;
}
renderAddRowColumnButton() {
const hovered =
this.hoverRowIndex$.value === this.rows$.value.length - 1 &&
this.hoverColumnIndex$.value === this.columns$.value.length - 1;
const dragging = this.rowColumnDragging$.value;
return html` <div
class="${classMap({
[addRowColumnButtonStyle]: true,
active: dragging,
'row-column-add': true,
})}"
${ref(this.addRowColumnButtonRef$)}
style=${styleMap({
opacity: hovered || dragging ? 1 : undefined,
})}
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.dataManager.addRow(this.rows$.value.length - 1);
this.dataManager.addColumn(this.columns$.value.length - 1);
}}"
>
${PlusIcon()}
</div>`;
}
override render() {
return html`
${this.renderAddColumnButton()} ${this.renderAddRowButton()}
${this.renderAddRowColumnButton()}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-table-add-button': AddButton;
}
}

View File

@@ -0,0 +1,45 @@
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
type Color = {
name: string;
color: string;
};
export const colorList: Color[] = [
{
name: 'Blue',
color: cssVarV2.table.headerBackground.blue,
},
{
name: 'Green',
color: cssVarV2.table.headerBackground.green,
},
{
name: 'Grey',
color: cssVarV2.table.headerBackground.grey,
},
{
name: 'Orange',
color: cssVarV2.table.headerBackground.orange,
},
{
name: 'Purple',
color: cssVarV2.table.headerBackground.purple,
},
{
name: 'Red',
color: cssVarV2.table.headerBackground.red,
},
{
name: 'Teal',
color: cssVarV2.table.headerBackground.teal,
},
{
name: 'Yellow',
color: cssVarV2.table.headerBackground.yellow,
},
];
const colorMap = Object.fromEntries(colorList.map(item => [item.color, item]));
export const getColorByColor = (color: string): Color | undefined => {
return colorMap[color] ?? undefined;
};

View File

@@ -0,0 +1,67 @@
import '@blocksuite/affine-shared/commands';
import { TableModelFlavour } from '@blocksuite/affine-model';
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
import type { BlockCommands, Command } from '@blocksuite/block-std';
import { nanoid, Text } from '@blocksuite/store';
export const insertTableBlockCommand: Command<
'selectedModels',
'insertedTableBlockId',
{
place?: 'after' | 'before';
removeEmptyLine?: boolean;
}
> = (ctx, next) => {
const { selectedModels, place, removeEmptyLine, std } = ctx;
if (!selectedModels?.length) return;
const targetModel =
place === 'before'
? selectedModels[0]
: selectedModels[selectedModels.length - 1];
if (!targetModel) return;
const row1Id = nanoid();
const row2Id = nanoid();
const col1Id = nanoid();
const col2Id = nanoid();
const order1 = generateFractionalIndexingKeyBetween(null, null);
const order2 = generateFractionalIndexingKeyBetween(order1, null);
const initialTableData = {
rows: {
[row1Id]: { rowId: row1Id, order: order1 },
[row2Id]: { rowId: row2Id, order: order2 },
},
columns: {
[col1Id]: { columnId: col1Id, order: order1 },
[col2Id]: { columnId: col2Id, order: order2 },
},
cells: {
[`${row1Id}:${col1Id}`]: { text: new Text() },
[`${row1Id}:${col2Id}`]: { text: new Text() },
[`${row2Id}:${col1Id}`]: { text: new Text() },
[`${row2Id}:${col2Id}`]: { text: new Text() },
},
};
const result = std.store.addSiblingBlocks(
targetModel,
[{ flavour: TableModelFlavour, ...initialTableData }],
place
);
const blockId = result[0];
if (blockId == null) return;
if (removeEmptyLine && targetModel.text?.length === 0) {
std.store.deleteBlock(targetModel);
}
next({ insertedTableBlockId: blockId });
};
export const tableCommands: BlockCommands = {
insertTableBlock: insertTableBlockCommand,
};

View File

@@ -0,0 +1,4 @@
export const ColumnMinWidth = 60;
export const ColumnMaxWidth = 240;
export const DefaultColumnWidth = 120;
export const DefaultRowHeight = 39;

View File

@@ -0,0 +1,24 @@
import { AddButton } from './add-button';
import type { insertTableBlockCommand } from './commands';
import { SelectionLayer } from './selection-layer';
import { TableBlockComponent } from './table-block';
import { TableCell } from './table-cell';
export function effects() {
customElements.define('affine-table', TableBlockComponent);
customElements.define('affine-table-cell', TableCell);
customElements.define('affine-table-add-button', AddButton);
customElements.define('affine-table-selection-layer', SelectionLayer);
}
declare global {
namespace BlockSuite {
interface CommandContext {
insertedTableBlockId?: string;
}
interface Commands {
insertTableBlock: typeof insertTableBlockCommand;
}
}
}

View File

@@ -0,0 +1,5 @@
export * from './adapters';
export * from './commands.js';
export * from './selection-schema.js';
export * from './table-data-manager.js';
export * from './table-spec.js';

View File

@@ -0,0 +1,276 @@
import {
domToOffsets,
getAreaByOffsets,
} from '@blocksuite/affine-shared/utils';
import type { UIEventStateContext } from '@blocksuite/block-std';
import { IS_MOBILE } from '@blocksuite/global/env';
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';
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);
}
private get dataManager() {
return this.host.dataManager;
}
private get clipboard() {
return this.host.std.clipboard;
}
widthAdjust(dragHandle: HTMLElement, event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
const initialX = event.clientX;
const currentWidth =
dragHandle.closest('td')?.getBoundingClientRect().width ??
DefaultColumnWidth;
const columnId = dragHandle.dataset['widthAdjustColumnId'];
if (!columnId) {
return;
}
const onMove = (event: MouseEvent) => {
this.dataManager.draggingColumnId$.value = columnId;
this.dataManager.virtualWidth$.value = {
columnId,
width: Math.max(
ColumnMinWidth,
event.clientX - initialX + currentWidth
),
};
};
const onUp = () => {
const width = this.dataManager.virtualWidth$.value?.width;
this.dataManager.draggingColumnId$.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) {
return;
}
this.host.disposables.addFromEvent(this.host, 'mousedown', event => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const dragHandle = target.closest('[data-width-adjust-column-id]');
if (dragHandle instanceof HTMLElement) {
this.widthAdjust(dragHandle, event);
return;
}
this.onDragStart(event);
});
}
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');
this.clipboard
.writeToClipboard(items => ({
...items,
[TEXT]: text,
}))
.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;
}
const plainText = clipboardData.getData('text/plain');
this.doPaste(plainText, selection);
return true;
};
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) {
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 selected = this.host.selected;
if (selected instanceof TableSelection) {
return selected.data;
}
return undefined;
}
}

View File

@@ -0,0 +1,110 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { computed, effect, signal } from '@preact/signals-core';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { SelectionController } from './selection-controller';
type Rect = {
top: number;
left: number;
width: number;
height: number;
};
export class SelectionLayer extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
@property({ attribute: false })
accessor selectionController!: SelectionController;
@property({ attribute: false })
accessor getRowRect!: (rowId: string) => Rect;
@property({ attribute: false })
accessor getColumnRect!: (columnId: string) => Rect;
@property({ attribute: false })
accessor getAreaRect!: (
rowStartIndex: number,
rowEndIndex: number,
columnStartIndex: number,
columnEndIndex: number
) => Rect;
selection$ = computed(() => {
return this.selectionController.selected$.value;
});
computeRect = () => {
const selection = this.selection$.value;
if (!selection) return;
if (selection.type === 'row') {
const rect = this.getRowRect(selection.rowId);
return rect;
}
if (selection.type === 'column') {
const rect = this.getColumnRect(selection.columnId);
return rect;
}
if (selection.type === 'area') {
const rect = this.getAreaRect(
selection.rowStartIndex,
selection.rowEndIndex,
selection.columnStartIndex,
selection.columnEndIndex
);
return rect;
}
return;
};
rect$ = signal<Rect>();
private getSelectionStyle() {
const rect = this.rect$.value;
if (!rect)
return styleMap({
display: 'none',
});
const border = '2px solid var(--affine-primary-color)';
return styleMap({
position: 'absolute',
pointerEvents: 'none',
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
borderRadius: '2px',
border,
});
}
override connectedCallback() {
super.connectedCallback();
const ob = new ResizeObserver(() => {
this.rect$.value = this.computeRect();
});
this.disposables.add(
effect(() => {
this.rect$.value = this.computeRect();
})
);
const table = this.selectionController.host.querySelector('table');
if (table) {
ob.observe(table);
this.disposables.add(() => {
ob.unobserve(table);
});
}
}
override render() {
return html` <div style=${this.getSelectionStyle()}></div> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-table-selection-layer': SelectionLayer;
}
}

View File

@@ -0,0 +1,118 @@
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
import { z } from 'zod';
const TableAreaSelectionSchema = z.object({
type: z.literal('area'),
rowStartIndex: z.number(),
rowEndIndex: z.number(),
columnStartIndex: z.number(),
columnEndIndex: z.number(),
});
export type TableAreaSelection = z.TypeOf<typeof TableAreaSelectionSchema>;
const TableRowSelectionSchema = z.object({
type: z.literal('row'),
rowId: z.string(),
});
const TableColumnSelectionSchema = z.object({
type: z.literal('column'),
columnId: z.string(),
});
const TableSelectionDataSchema = z.union([
TableAreaSelectionSchema,
TableRowSelectionSchema,
TableColumnSelectionSchema,
]);
export type TableSelectionData = z.TypeOf<typeof TableSelectionDataSchema>;
export const TableSelectionData = {
equals(a?: TableSelectionData, b?: TableSelectionData) {
if (a === b) {
return true;
}
if (a == null || b == null) {
return a === b;
}
if (a.type !== b.type) {
return false;
}
if (a.type === 'area' && b.type === 'area') {
return (
a.rowStartIndex === b.rowStartIndex &&
a.rowEndIndex === b.rowEndIndex &&
a.columnStartIndex === b.columnStartIndex &&
a.columnEndIndex === b.columnEndIndex
);
}
if (a.type === 'row' && b.type === 'row') {
return a.rowId === b.rowId;
}
if (a.type === 'column' && b.type === 'column') {
return a.columnId === b.columnId;
}
return false;
},
};
const TableSelectionSchema = z.object({
blockId: z.string(),
data: TableSelectionDataSchema,
});
export class TableSelection extends BaseSelection {
static override group = 'note';
static override type = 'table';
readonly data: TableSelectionData;
constructor({
blockId,
data,
}: {
blockId: string;
data: TableSelectionData;
}) {
super({
blockId,
});
this.data = data;
}
static override fromJSON(json: Record<string, unknown>): TableSelection {
TableSelectionSchema.parse(json);
return new TableSelection({
blockId: json.blockId as string,
data: json.data as TableSelectionData,
});
}
override equals(other: BaseSelection): boolean {
if (!(other instanceof TableSelection)) {
return false;
}
return this.blockId === other.blockId;
}
override toJSON(): Record<string, unknown> {
return {
type: 'table',
blockId: this.blockId,
data: this.data,
};
}
}
declare global {
namespace BlockSuite {
interface Selection {
table: typeof TableSelection;
}
}
}
export const TableSelectionExtension = SelectionExtension(TableSelection);

View File

@@ -0,0 +1,43 @@
import { style } from '@vanilla-extract/css';
export const tableContainer = style({
display: 'block',
backgroundColor: 'var(--affine-background-primary-color)',
padding: '10px 0 18px',
overflowX: 'auto',
overflowY: 'visible',
selectors: {
'&::-webkit-scrollbar': {
height: '8px',
},
'&::-webkit-scrollbar-thumb:horizontal': {
borderRadius: '4px',
backgroundColor: 'transparent',
},
'&::-webkit-scrollbar-track:horizontal': {
backgroundColor: 'transparent',
height: '8px',
},
'&:hover::-webkit-scrollbar-thumb:horizontal': {
borderRadius: '4px',
backgroundColor: 'var(--affine-black-30)',
},
'&:hover::-webkit-scrollbar-track:horizontal': {
backgroundColor: 'var(--affine-hover-color)',
height: '8px',
},
},
});
export const tableWrapper = style({
overflow: 'visible',
display: 'flex',
flexDirection: 'row',
gap: '8px',
position: 'relative',
width: 'max-content',
});
export const table = style({});
export const rowStyle = style({});

View File

@@ -0,0 +1,196 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import type { TableBlockModel } from '@blocksuite/affine-model';
import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
RANGE_SYNC_EXCLUDE_ATTR,
} from '@blocksuite/block-std';
import { IS_MOBILE } from '@blocksuite/global/env';
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 class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel> {
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');
}
override get topContenteditableElement() {
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
return this.closest<BlockComponent>(NOTE_SELECTOR);
}
return this.rootComponent;
}
private readonly virtualPaddingController: VirtualPaddingController =
new VirtualPaddingController(this);
table$ = signal<HTMLTableElement>();
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();
return {
top: rect.top - rootRect.top,
left: rect.left - rootRect.left,
width: rect.width,
height: rect.height,
};
};
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;
return {
top: firstRect.top - rootRect.top,
left: firstRect.left - rootRect.left,
width: firstRect.width,
height: lastRect.bottom - firstRect.top,
};
};
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 columns = startRow.querySelectorAll('td');
const startColumn = columns.item(columnStartIndex);
const endColumn = columns.item(columnEndIndex);
if (!startColumn || !endColumn) return;
const startRect = startRow.getBoundingClientRect();
const endRect = endRow.getBoundingClientRect();
const startColumnRect = startColumn.getBoundingClientRect();
const endColumnRect = endColumn.getBoundingClientRect();
return {
top: startRect.top - rootRect.top,
left: startColumnRect.left - rootRect.left,
width: endColumnRect.right - startColumnRect.left,
height: endRect.bottom - startRect.top,
};
};
override renderBlock() {
const rows = this.dataManager.uiRows$.value;
const columns = this.dataManager.uiColumns$.value;
const virtualPadding = this.virtualPaddingController.virtualPadding$.value;
return html`
<div
contenteditable="false"
class=${tableContainer}
style=${styleMap({
marginLeft: `-${virtualPadding}px`,
marginRight: `-${virtualPadding}px`,
position: 'relative',
})}
>
<div
style=${styleMap({
paddingLeft: `${virtualPadding}px`,
paddingRight: `${virtualPadding}px`,
width: 'max-content',
})}
>
<table class=${tableWrapper} ${ref(this.table$)}>
<tbody class=${table}>
${repeat(
rows,
row => row.rowId,
(row, rowIndex) => {
return html`
<tr class=${rowStyle} data-row-id=${row.rowId}>
${repeat(
columns,
column => column.columnId,
(column, columnIndex) => {
const cell = this.dataManager.getCell(
row.rowId,
column.columnId
);
return html`
<affine-table-cell
style="display: contents;"
.rowIndex=${rowIndex}
.columnIndex=${columnIndex}
.row=${row}
.column=${column}
.text=${cell?.text}
.dataManager=${this.dataManager}
.selectionController=${this.selectionController}
></affine-table-cell>
`;
}
)}
</tr>
`;
}
)}
</tbody>
${IS_MOBILE
? nothing
: html`<affine-table-add-button
style="display: contents;"
.dataManager=${this.dataManager}
></affine-table-add-button>`}
${html`<affine-table-selection-layer
style="display: contents;"
.selectionController=${this.selectionController}
.getRowRect=${this.getRowRect}
.getColumnRect=${this.getColumnRect}
.getAreaRect=${this.getAreaRect}
></affine-table-selection-layer>`}
</table>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-table': TableBlockComponent;
}
}

View File

@@ -0,0 +1,109 @@
import { cssVar, cssVarV2 } from '@blocksuite/affine-shared/theme';
import { style } from '@vanilla-extract/css';
export const cellContainerStyle = style({
position: 'relative',
alignItems: 'center',
border: '1px solid var(--affine-border-color)',
borderCollapse: 'collapse',
isolation: 'auto',
textAlign: 'start',
verticalAlign: 'top',
});
export const columnOptionsCellStyle = style({
position: 'absolute',
height: '0',
top: '0',
left: '0',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const columnOptionsStyle = style({
cursor: 'pointer',
zIndex: 2,
width: '22px',
height: '12px',
backgroundColor: cssVarV2.table.headerBackground.default,
borderRadius: '8px',
boxShadow: cssVar('buttonShadow'),
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
selectors: {
'&:hover': {
opacity: 1,
},
'&.active': {
opacity: 1,
backgroundColor: cssVarV2.table.indicator.activated,
},
},
});
export const rowOptionsCellStyle = style({
position: 'absolute',
top: '0',
left: '0',
width: '0',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
});
export const rowOptionsStyle = style({
cursor: 'pointer',
zIndex: 2,
width: '12px',
height: '22px',
backgroundColor: cssVarV2.table.headerBackground.default,
borderRadius: '8px',
boxShadow: cssVar('buttonShadow'),
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
selectors: {
'&:hover': {
opacity: 1,
},
'&.active': {
opacity: 1,
backgroundColor: cssVarV2.table.indicator.activated,
},
},
});
export const threePointerIconStyle = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2px',
});
export const threePointerIconDotStyle = style({
width: '2px',
height: '2px',
backgroundColor: cssVarV2.icon.secondary,
borderRadius: '50%',
});
export const widthDragHandleStyle = style({
position: 'absolute',
top: '-1px',
height: 'calc(100% + 2px)',
right: '-3px',
width: '5px',
backgroundColor: cssVarV2.table.indicator.activated,
cursor: 'ew-resize',
zIndex: 2,
transition: 'opacity 0.2s ease-in-out',
});

View File

@@ -0,0 +1,691 @@
import {
menu,
popMenu,
type PopupTarget,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { TextBackgroundDuotoneIcon } from '@blocksuite/affine-components/icons';
import {
DefaultInlineManagerExtension,
type 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,
columnOptionsCellStyle,
columnOptionsStyle,
rowOptionsCellStyle,
rowOptionsStyle,
threePointerIconDotStyle,
threePointerIconStyle,
widthDragHandleStyle,
} from './table-cell.css';
import type { TableDataManager } from './table-data-manager';
export class TableCell extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
@property({ attribute: false })
accessor text: Text | undefined = undefined;
@property({ type: Boolean })
accessor readonly = false;
@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;
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) => {
const element = e.currentTarget;
if (element instanceof HTMLElement) {
this.openColumnOptions(
popupTargetFromElement(element),
column,
columnIndex
);
}
};
return html`<div class=${columnOptionsCellStyle}>
<div
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) => {
const element = e.currentTarget;
if (element instanceof HTMLElement) {
this.openRowOptions(popupTargetFromElement(element), row, rowIndex);
}
};
return html`<div class=${rowOptionsCellStyle}>
<div
class=${classMap({
[rowOptionsStyle]: true,
})}
style=${styleMap({
opacity: rowIndex === this.hoverRowIndex$.value ? 1 : undefined,
})}
@click=${openRowOptions}
>
${threePointerIcon(true)}
</div>
</div>`;
}
renderOptionsButton() {
if (!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`,
});
}
renderWidthDragHandle() {
const hoverColumnId$ = this.dataManager.hoverDragHandleColumnId$;
const draggingColumnId$ = this.dataManager.draggingColumnId$;
const rowIndex = this.rowIndex;
const isFirstRow = rowIndex === 0;
const isLastRow = rowIndex === this.dataManager.uiRows$.value.length - 1;
const show =
draggingColumnId$.value === this.column?.columnId ||
hoverColumnId$.value === this.column?.columnId;
return html`<div
@mouseenter=${() => {
hoverColumnId$.value = this.column?.columnId;
}}
@mouseleave=${() => {
hoverColumnId$.value = undefined;
}}
style=${styleMap({
opacity: show ? 1 : 0,
borderRadius: isFirstRow
? '3px 3px 0 0'
: isLastRow
? '0 0 3px 3px'
: '0',
})}
data-width-adjust-column-id=${this.column?.columnId}
class=${widthDragHandleStyle}
></div>`;
}
richText$ = signal<RichText>();
get inlineEditor() {
return this.richText$.value?.inlineEditor;
}
override connectedCallback() {
super.connectedCallback();
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() {
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}"
.markdownShortcutHandler="${this.inlineManager
?.markdownShortcutHandler}"
.readonly="${this.readonly}"
.enableClipboard="${true}"
.verticalScrollContainerGetter="${() =>
this.topContenteditableElement?.host
? getViewportElement(this.topContenteditableElement.host)
: null}"
data-parent-flavour="affine:table"
></rich-text>
${this.renderOptionsButton()} ${this.renderWidthDragHandle()}
</td>
`;
}
}
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 {
'affine-table-cell': TableCell;
}
}

View File

@@ -0,0 +1,373 @@
import type { TableBlockModel, TableCell } from '@blocksuite/affine-model';
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
import { nanoid, Text } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import type { TableAreaSelection } from './selection-schema';
export class TableDataManager {
constructor(private readonly model: TableBlockModel) {}
hoverColumnIndex$ = signal<number>();
hoverRowIndex$ = signal<number>();
hoverDragHandleColumnId$ = signal<string>();
draggingColumnId$ = signal<string>();
virtualColumnCount$ = signal<number>(0);
virtualRowCount$ = signal<number>(0);
virtualWidth$ = signal<{ columnId: string; width: number } | undefined>();
cellCountTips$ = computed(
() =>
`${this.virtualRowCount$.value + this.rows$.value.length} x ${this.virtualColumnCount$.value + this.columns$.value.length}`
);
rows$ = computed(() => {
return Object.values(this.model.rows$.value).sort((a, b) =>
a.order > b.order ? 1 : -1
);
});
columns$ = computed(() => {
return Object.values(this.model.columns$.value).sort((a, b) =>
a.order > b.order ? 1 : -1
);
});
uiRows$ = computed(() => {
const virtualRowCount = this.virtualRowCount$.value;
const rows = this.rows$.value;
if (virtualRowCount === 0) {
return rows;
}
if (virtualRowCount > 0) {
return [
...rows,
...Array.from({ length: virtualRowCount }, (_, i) => ({
rowId: `${i}`,
backgroundColor: undefined,
})),
];
}
return rows.slice(0, rows.length + virtualRowCount);
});
uiColumns$ = computed(() => {
const virtualColumnCount = this.virtualColumnCount$.value;
const columns = this.columns$.value;
if (virtualColumnCount === 0) {
return columns;
}
if (virtualColumnCount > 0) {
return [
...columns,
...Array.from({ length: virtualColumnCount }, (_, i) => ({
columnId: `${i}`,
backgroundColor: undefined,
width: undefined,
})),
];
}
return columns.slice(0, columns.length + virtualColumnCount);
});
getCell(rowId: string, columnId: string): TableCell | undefined {
return this.model.cells$.value[`${rowId}:${columnId}`];
}
addRow(after?: number) {
const order = this.getOrder(this.rows$.value, after);
const rowId = nanoid();
this.model.doc.transact(() => {
this.model.rows[rowId] = {
rowId,
order,
};
this.columns$.value.forEach(column => {
this.model.cells[`${rowId}:${column.columnId}`] = {
text: new Text(),
};
});
});
return rowId;
}
addNRow(count: number) {
if (count === 0) {
return;
}
if (count > 0) {
this.model.doc.transact(() => {
for (let i = 0; i < count; i++) {
this.addRow(this.rows$.value.length - 1);
}
});
} else {
const rows = this.rows$.value;
const rowCount = rows.length;
this.model.doc.transact(() => {
rows.slice(rowCount + count, rowCount).forEach(row => {
this.deleteRow(row.rowId);
});
});
}
}
addNColumn(count: number) {
if (count === 0) {
return;
}
if (count > 0) {
this.model.doc.transact(() => {
for (let i = 0; i < count; i++) {
this.addColumn(this.columns$.value.length - 1);
}
});
} else {
const columns = this.columns$.value;
const columnCount = columns.length;
this.model.doc.transact(() => {
columns.slice(columnCount + count, columnCount).forEach(column => {
this.deleteColumn(column.columnId);
});
});
}
}
private getOrder<T extends { order: string }>(array: T[], after?: number) {
after = after != null ? (after < 0 ? undefined : after) : undefined;
const prevOrder = after == null ? null : array[after]?.order;
const nextOrder = after == null ? array[0]?.order : array[after + 1]?.order;
const order = generateFractionalIndexingKeyBetween(
prevOrder ?? null,
nextOrder ?? null
);
return order;
}
addColumn(after?: number) {
const order = this.getOrder(this.columns$.value, after);
const columnId = nanoid();
this.model.doc.transact(() => {
this.model.columns[columnId] = {
columnId,
order,
};
this.rows$.value.forEach(row => {
this.model.cells[`${row.rowId}:${columnId}`] = {
text: new Text(),
};
});
});
return columnId;
}
deleteRow(rowId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.rows).forEach(id => {
if (id === rowId) {
delete this.model.rows[id];
}
});
Object.keys(this.model.cells).forEach(id => {
if (id.startsWith(rowId)) {
delete this.model.cells[id];
}
});
});
}
deleteColumn(columnId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.columns).forEach(id => {
if (id === columnId) {
delete this.model.columns[id];
}
});
Object.keys(this.model.cells).forEach(id => {
if (id.endsWith(`:${columnId}`)) {
delete this.model.cells[id];
}
});
});
}
updateRowOrder(rowId: string, newOrder: string) {
this.model.doc.transact(() => {
if (this.model.rows[rowId]) {
this.model.rows[rowId].order = newOrder;
}
});
}
updateColumnOrder(columnId: string, newOrder: string) {
this.model.doc.transact(() => {
if (this.model.columns[columnId]) {
this.model.columns[columnId].order = newOrder;
}
});
}
setRowBackgroundColor(rowId: string, color?: string) {
this.model.doc.transact(() => {
if (this.model.rows[rowId]) {
this.model.rows[rowId].backgroundColor = color;
}
});
}
setColumnBackgroundColor(columnId: string, color?: string) {
this.model.doc.transact(() => {
if (this.model.columns[columnId]) {
this.model.columns[columnId].backgroundColor = color;
}
});
}
setColumnWidth(columnId: string, width: number) {
this.model.doc.transact(() => {
if (this.model.columns[columnId]) {
this.model.columns[columnId].width = width;
}
});
}
clearRow(rowId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.cells).forEach(id => {
if (id.startsWith(rowId)) {
this.model.cells[id]?.text.replace(
0,
this.model.cells[id]?.text.length,
''
);
}
});
});
}
clearColumn(columnId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.cells).forEach(id => {
if (id.endsWith(`:${columnId}`)) {
this.model.cells[id]?.text.replace(
0,
this.model.cells[id]?.text.length,
''
);
}
});
});
}
clearCellsBySelection(selection: TableAreaSelection) {
const columns = this.uiColumns$.value;
const rows = this.uiRows$.value;
const deleteCells: { rowId: string; columnId: string }[] = [];
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;
}
deleteCells.push({ rowId: row.rowId, columnId: column.columnId });
}
}
this.clearCells(deleteCells);
}
clearCells(cells: { rowId: string; columnId: string }[]) {
this.model.doc.transact(() => {
cells.forEach(({ rowId, columnId }) => {
const text = this.model.cells[`${rowId}:${columnId}`]?.text;
if (text) {
text.replace(0, text.length, '');
}
});
});
}
insertColumn(after?: number) {
this.addColumn(after);
}
insertRow(after?: number) {
this.addRow(after);
}
moveColumn(from: number, after?: number) {
const columns = this.columns$.value;
const column = columns[from];
if (!column) return;
const order = this.getOrder(columns, after);
this.model.doc.transact(() => {
const realColumn = this.model.columns[column.columnId];
if (realColumn) {
realColumn.order = order;
}
});
}
moveRow(from: number, after?: number) {
const rows = this.rows$.value;
const row = rows[from];
if (!row) return;
const order = this.getOrder(rows, after);
this.model.doc.transact(() => {
const realRow = this.model.rows[row.rowId];
if (realRow) {
realRow.order = order;
}
});
}
duplicateColumn(index: number) {
const oldColumn = this.columns$.value[index];
if (!oldColumn) return;
const order = this.getOrder(this.columns$.value, index);
const newColumnId = nanoid();
this.model.doc.transact(() => {
this.model.columns[newColumnId] = {
...oldColumn,
columnId: newColumnId,
order,
};
this.rows$.value.forEach(row => {
this.model.cells[`${row.rowId}:${newColumnId}`] = {
text:
this.model.cells[
`${row.rowId}:${oldColumn.columnId}`
]?.text.clone() ?? new Text(),
};
});
});
return newColumnId;
}
duplicateRow(index: number) {
const oldRow = this.rows$.value[index];
if (!oldRow) return;
const order = this.getOrder(this.rows$.value, index);
const newRowId = nanoid();
this.model.doc.transact(() => {
this.model.rows[newRowId] = {
...oldRow,
rowId: newRowId,
order,
};
this.columns$.value.forEach(column => {
this.model.cells[`${newRowId}:${column.columnId}`] = {
text:
this.model.cells[
`${oldRow.rowId}:${column.columnId}`
]?.text.clone() ?? new Text(),
};
});
});
return newRowId;
}
}

View File

@@ -0,0 +1,18 @@
import { TableModelFlavour } from '@blocksuite/affine-model';
import {
BlockViewExtension,
CommandExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { TableBlockAdapterExtensions } from './adapters/extension.js';
import { tableCommands } from './commands.js';
export const TableBlockSpec: ExtensionType[] = [
FlavourExtension(TableModelFlavour),
CommandExtension(tableCommands),
BlockViewExtension(TableModelFlavour, literal`affine-table`),
TableBlockAdapterExtensions,
].flat();