refactor(editor): unify directories naming (#11516)

**Directory Structure Changes**

- Renamed multiple block-related directories by removing the "block-" prefix:
  - `block-attachment` → `attachment`
  - `block-bookmark` → `bookmark`
  - `block-callout` → `callout`
  - `block-code` → `code`
  - `block-data-view` → `data-view`
  - `block-database` → `database`
  - `block-divider` → `divider`
  - `block-edgeless-text` → `edgeless-text`
  - `block-embed` → `embed`
This commit is contained in:
Saul-Mirone
2025-04-07 12:34:40 +00:00
parent e1bd2047c4
commit 1f45cc5dec
893 changed files with 439 additions and 460 deletions

View File

@@ -0,0 +1,44 @@
{
"name": "@blocksuite/affine-block-table",
"description": "Table block for BlockSuite.",
"type": "module",
"scripts": {
"build": "tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-inline-preset": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/affine-widget-slash-menu": "workspace:*",
"@blocksuite/data-view": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.10",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"rxjs": "^7.8.1",
"yjs": "^13.6.21",
"zod": "^3.24.1"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.21.0"
}

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,129 @@
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 astToDelta = context.deltaConverter.astToDelta.bind(
context.deltaConverter
);
const tableProps = parseTableFromHtml(o.node, astToDelta);
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: TableModelFlavour,
props: tableProps as unknown as Record<string, unknown>,
children: [],
},
'children'
);
walkerContext.skipAllChildren();
}
},
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,82 @@
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') {
const astToDelta = context.deltaConverter.astToDelta.bind(
context.deltaConverter
);
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: TableModelFlavour,
props: parseTableFromMarkdown(o.node, astToDelta),
children: [],
},
'children'
);
walkerContext.skipAllChildren();
}
},
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,79 @@
import {
type TableBlockPropsSerialized,
TableBlockSchema,
TableModelFlavour,
} from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/store';
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: DeltaInsert[][][] = [];
let columnCount: number | null = null;
for (const row of rowTexts) {
const cells = row.split('\t').map<DeltaInsert[]>(text => [
{
insert: text,
},
]);
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,212 @@
import type {
TableBlockPropsSerialized,
TableCellSerialized,
TableColumn,
TableRow,
} from '@blocksuite/affine-model';
import {
type HtmlAST,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import { HastUtils } from '@blocksuite/affine-shared/adapters';
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
import type { DeltaInsert } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
import type { Element } from 'hast';
import type { Table as MarkdownTable } from 'mdast';
type RichTextType = DeltaInsert[];
const createRichText = (text: RichTextType) => {
return {
'$blocksuite:internal:text$': true,
delta: text,
};
};
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 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 = (deltasLists: RichTextType[][]) => {
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(...deltasLists.map(row => row.length));
const rowCount = deltasLists.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 = deltasLists[i]?.[j];
cells[cellId] = {
text: createRichText(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,
astToDelta: (ast: HtmlAST) => RichTextType
): 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: RichTextType[][] = [];
allRows.forEach(cells => {
const row: RichTextType[] = [];
cells.forEach(cell => {
row.push(astToDelta(cell));
});
rowTextLists.push(row);
});
return createTableProps(rowTextLists);
};
export const parseTableFromMarkdown = (
node: MarkdownTable,
astToDelta: (ast: MarkdownAST) => RichTextType
) => {
const rowTextLists: RichTextType[][] = [];
node.children.forEach(row => {
const rowText: RichTextType[] = [];
row.children.forEach(cell => {
rowText.push(astToDelta(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: '16px',
color: cssVarV2.icon.secondary,
display: 'flex',
width: '16px',
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: '16px',
color: cssVarV2.icon.secondary,
display: 'flex',
height: '16px',
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: '16px',
color: cssVarV2.icon.secondary,
display: 'flex',
width: '16px',
height: '16px',
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,330 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { PlusIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
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 const AddButtonComponentName = 'affine-table-add-button';
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
data-testid="add-column-button"
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
data-testid="add-row-button"
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 {
[AddButtonComponentName]: 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,52 @@
import {
type TableBlockModel,
TableModelFlavour,
} from '@blocksuite/affine-model';
import type { Command } from '@blocksuite/std';
import { type BlockModel } from '@blocksuite/store';
import { TableDataManager } from './table-data-manager';
export const insertTableBlockCommand: Command<
{
place?: 'after' | 'before';
removeEmptyLine?: boolean;
selectedModels?: BlockModel[];
},
{
insertedTableBlockId: string;
}
> = (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 result = std.store.addSiblingBlocks(
targetModel,
[{ flavour: TableModelFlavour }],
place
);
const blockId = result[0];
if (blockId == null) return;
const model = std.store.getBlock(blockId)?.model as TableBlockModel;
if (model == null) return;
const dataManager = new TableDataManager(model);
dataManager.addNRow(2);
dataManager.addNColumn(2);
if (removeEmptyLine && targetModel.text?.length === 0) {
std.store.deleteBlock(targetModel);
}
next({ insertedTableBlockId: blockId });
};

View File

@@ -0,0 +1,44 @@
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils';
import type { SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
import { TableIcon } from '@blocksuite/icons/lit';
import { insertTableBlockCommand } from '../commands';
import { tableTooltip } from './tooltips';
export const tableSlashMenuConfig: SlashMenuConfig = {
disableWhen: ({ model }) => model.flavour === 'affine:table',
items: [
{
name: 'Table',
description: 'Create a simple table.',
icon: TableIcon(),
tooltip: {
figure: tableTooltip,
caption: 'Table',
},
group: '4_Content & Media@0',
when: ({ model }) =>
!isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text'),
action: ({ std }) => {
std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertTableBlockCommand, {
place: 'after',
removeEmptyLine: true,
})
.pipe(({ insertedTableBlockId }) => {
if (insertedTableBlockId) {
const telemetry = std.getOptional(TelemetryProvider);
telemetry?.track('BlockCreated', {
blockType: 'affine:table',
});
}
})
.run();
},
},
],
};

View File

@@ -0,0 +1,68 @@
import { html } from 'lit';
// prettier-ignore
export const tableTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="106" fill="white"/>
<mask id="path-1-inside-1_3479_144620" fill="white">
<path d="M8 26H50V43.25H8V26Z"/>
</mask>
<path d="M8 26V25H7V26H8ZM50 26H51V25H50V26ZM50 43.25V44.25H51V43.25H50ZM8 43.25H7V44.25H8V43.25ZM8 27H50V25H8V27ZM49 26V43.25H51V26H49ZM50 42.25H8V44.25H50V42.25ZM9 43.25V26H7V43.25H9Z" fill="black" fill-opacity="0.1" mask="url(#path-1-inside-1_3479_144620)"/>
<text fill="#7A7A7A" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="16" y="36.8068">Ranking</tspan></text>
<mask id="path-4-inside-2_3479_144620" fill="white">
<path d="M8 43.25H50V60.5H8V43.25Z"/>
</mask>
<path d="M50 60.5V61.5H51V60.5H50ZM8 60.5H7V61.5H8V60.5ZM49 43.25V60.5H51V43.25H49ZM50 59.5H8V61.5H50V59.5ZM9 60.5V43.25H7V60.5H9Z" fill="black" fill-opacity="0.1" mask="url(#path-4-inside-2_3479_144620)"/>
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="14" y="54.0568">&#x1f947; First</tspan></text>
<mask id="path-7-inside-3_3479_144620" fill="white">
<path d="M8 60.5H50V77.75H8V60.5Z"/>
</mask>
<path d="M50 77.75V78.75H51V77.75H50ZM8 77.75H7V78.75H8V77.75ZM49 60.5V77.75H51V60.5H49ZM50 76.75H8V78.75H50V76.75ZM9 77.75V60.5H7V77.75H9Z" fill="black" fill-opacity="0.1" mask="url(#path-7-inside-3_3479_144620)"/>
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="14" y="71.3068">&#x1f948; Second</tspan></text>
<mask id="path-10-inside-4_3479_144620" fill="white">
<path d="M8 77.75H50V95H8V77.75Z"/>
</mask>
<path d="M50 95V96H51V95H50ZM8 95H7V96H8V95ZM49 77.75V95H51V77.75H49ZM50 94H8V96H50V94ZM9 95V77.75H7V95H9Z" fill="black" fill-opacity="0.1" mask="url(#path-10-inside-4_3479_144620)"/>
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="14" y="88.5568">&#x1f949; Third</tspan></text>
<mask id="path-13-inside-5_3479_144620" fill="white">
<path d="M50 26H88V43.25H50V26Z"/>
</mask>
<path d="M88 26H89V25H88V26ZM88 43.25V44.25H89V43.25H88ZM50 27H88V25H50V27ZM87 26V43.25H89V26H87ZM88 42.25H50V44.25H88V42.25Z" fill="black" fill-opacity="0.1" mask="url(#path-13-inside-5_3479_144620)"/>
<text fill="#7A7A7A" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="56" y="36.8068">Country</tspan></text>
<mask id="path-16-inside-6_3479_144620" fill="white">
<path d="M50 43.25H88V60.5H50V43.25Z"/>
</mask>
<path d="M88 60.5V61.5H89V60.5H88ZM87 43.25V60.5H89V43.25H87ZM88 59.5H50V61.5H88V59.5Z" fill="black" fill-opacity="0.1" mask="url(#path-16-inside-6_3479_144620)"/>
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="56" y="54.0568">China</tspan></text>
<mask id="path-19-inside-7_3479_144620" fill="white">
<path d="M50 60.5H88V77.75H50V60.5Z"/>
</mask>
<path d="M88 77.75V78.75H89V77.75H88ZM87 60.5V77.75H89V60.5H87ZM88 76.75H50V78.75H88V76.75Z" fill="black" fill-opacity="0.1" mask="url(#path-19-inside-7_3479_144620)"/>
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="56" y="71.3068">Japan</tspan></text>
<mask id="path-22-inside-8_3479_144620" fill="white">
<path d="M50 77.75H88V95H50V77.75Z"/>
</mask>
<path d="M88 95V96H89V95H88ZM87 77.75V95H89V77.75H87ZM88 94H50V96H88V94Z" fill="black" fill-opacity="0.1" mask="url(#path-22-inside-8_3479_144620)"/>
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="56" y="88.5568">USA</tspan></text>
<mask id="path-25-inside-9_3479_144620" fill="white">
<path d="M88 26H164V43.25H88V26Z"/>
</mask>
<path d="M164 26H165V25H164V26ZM164 43.25V44.25H165V43.25H164ZM88 27H164V25H88V27ZM163 26V43.25H165V26H163ZM164 42.25H88V44.25H164V42.25Z" fill="black" fill-opacity="0.1" mask="url(#path-25-inside-9_3479_144620)"/>
<text fill="#7A7A7A" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="94" y="36.8068">Weekly Overtime (Hrs)</tspan></text>
<mask id="path-28-inside-10_3479_144620" fill="white">
<path d="M88 43.25H164V60.5H88V43.25Z"/>
</mask>
<path d="M164 60.5V61.5H165V60.5H164ZM163 43.25V60.5H165V43.25H163ZM164 59.5H88V61.5H164V59.5Z" fill="black" fill-opacity="0.1" mask="url(#path-28-inside-10_3479_144620)"/>
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="94" y="54.0568">10+</tspan></text>
<mask id="path-31-inside-11_3479_144620" fill="white">
<path d="M88 60.5H164V77.75H88V60.5Z"/>
</mask>
<path d="M164 77.75V78.75H165V77.75H164ZM163 60.5V77.75H165V60.5H163ZM164 76.75H88V78.75H164V76.75Z" fill="black" fill-opacity="0.1" mask="url(#path-31-inside-11_3479_144620)"/>
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="94" y="71.3068">8</tspan></text>
<mask id="path-34-inside-12_3479_144620" fill="white">
<path d="M88 77.75H164V95H88V77.75Z"/>
</mask>
<path d="M164 95V96H165V95H164ZM163 77.75V95H165V77.75H163ZM164 94H88V96H164V94Z" fill="black" fill-opacity="0.1" mask="url(#path-34-inside-12_3479_144620)"/>
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="94" y="88.5568">3</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert editable tabular data.</tspan></text>
</svg>
`;

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,11 @@
import { AddButton, AddButtonComponentName } from './add-button';
import { SelectionLayer, SelectionLayerComponentName } from './selection-layer';
import { TableBlockComponent, TableBlockComponentName } from './table-block';
import { TableCell, TableCellComponentName } from './table-cell';
export function effects() {
customElements.define(TableBlockComponentName, TableBlockComponent);
customElements.define(TableCellComponentName, TableCell);
customElements.define(AddButtonComponentName, AddButton);
customElements.define(SelectionLayerComponentName, SelectionLayer);
}

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,527 @@
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);
}
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, '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);
};
// eslint-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) {
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;
}
}

View File

@@ -0,0 +1,112 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
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 const SelectionLayerComponentName = 'affine-table-selection-layer';
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 {
[SelectionLayerComponentName]: SelectionLayer;
}
}

View File

@@ -0,0 +1,110 @@
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,
};
}
}
export const TableSelectionExtension = SelectionExtension(TableSelection);

View File

@@ -0,0 +1,42 @@
import { style } from '@vanilla-extract/css';
export const tableContainer = style({
display: 'block',
padding: '10px 0 18px 10px',
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,209 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import type { TableBlockModel } from '@blocksuite/affine-model';
import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
import { IS_MOBILE } from '@blocksuite/global/env';
import type { BlockComponent } from '@blocksuite/std';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
import { signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { ref } from 'lit/directives/ref.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { SelectionController } from './selection-controller';
import {
rowStyle,
table,
tableContainer,
tableWrapper,
} from './table-block.css';
import { TableDataManager } from './table-data-manager';
export const TableBlockComponentName = 'affine-table';
export class TableBlockComponent extends CaptionedBlockComponent<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');
this.style.position = 'relative';
}
override get topContenteditableElement() {
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
return this.closest<BlockComponent>(
EDGELESS_TOP_CONTENTEDITABLE_SELECTOR
);
}
return this.rootComponent;
}
private readonly virtualPaddingController: VirtualPaddingController =
new VirtualPaddingController(this);
table$ = signal<HTMLTableElement>();
public getScale(): number {
const table = this.table$.value;
if (!table) return 1;
return table.getBoundingClientRect().width / table.offsetWidth;
}
private readonly getRootRect = () => {
const table = this.table$.value;
if (!table) return;
return table.getBoundingClientRect();
};
private readonly getRowRect = (rowId: string) => {
const row = this.querySelector(`tr[data-row-id="${rowId}"]`);
const rootRect = this.getRootRect();
if (!row || !rootRect) return;
const rect = row.getBoundingClientRect();
const scale = this.getScale();
return {
top: (rect.top - rootRect.top) / scale,
left: (rect.left - rootRect.left) / scale,
width: rect.width / scale,
height: rect.height / scale,
};
};
private readonly getColumnRect = (columnId: string) => {
const columns = this.querySelectorAll(`td[data-column-id="${columnId}"]`);
const rootRect = this.getRootRect();
if (!rootRect) return;
const firstRect = columns.item(0)?.getBoundingClientRect();
const lastRect = columns.item(columns.length - 1)?.getBoundingClientRect();
if (!firstRect || !lastRect) return;
const scale = this.getScale();
return {
top: (firstRect.top - rootRect.top) / scale,
left: (firstRect.left - rootRect.left) / scale,
width: firstRect.width / scale,
height: (lastRect.bottom - firstRect.top) / scale,
};
};
private readonly getAreaRect = (
rowStartIndex: number,
rowEndIndex: number,
columnStartIndex: number,
columnEndIndex: number
) => {
const rootRect = this.getRootRect();
const rows = this.querySelectorAll('tr');
const startRow = rows.item(rowStartIndex);
const endRow = rows.item(rowEndIndex);
if (!startRow || !endRow || !rootRect) return;
const startCells = startRow.querySelectorAll('td');
const endCells = endRow.querySelectorAll('td');
const startCell = startCells.item(columnStartIndex);
const endCell = endCells.item(columnEndIndex);
if (!startCell || !endCell) return;
const startRect = startCell.getBoundingClientRect();
const endRect = endCell.getBoundingClientRect();
const scale = this.getScale();
return {
top: (startRect.top - rootRect.top) / scale,
left: (startRect.left - rootRect.left) / scale,
width: (endRect.right - startRect.left) / scale,
height: (endRect.bottom - startRect.top) / scale,
};
};
override renderBlock() {
const rows = this.dataManager.uiRows$.value;
const columns = this.dataManager.uiColumns$.value;
const virtualPadding = this.virtualPaddingController.virtualPadding$.value;
return html`
<div
contenteditable="false"
class=${tableContainer}
style=${styleMap({
marginLeft: `-${virtualPadding + 10}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 || this.dataManager.readonly$.value
? 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 {
[TableBlockComponentName]: TableBlockComponent;
}
}

View File

@@ -0,0 +1,159 @@
import { cssVar, cssVarV2 } from '@blocksuite/affine-shared/theme';
import { createVar, style } from '@vanilla-extract/css';
export const cellContainerStyle = style({
position: 'relative',
alignItems: 'center',
border: '1px solid',
borderColor: cssVarV2.table.border,
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',
});
const threePointerIconColorVar = createVar();
export const columnOptionsStyle = style({
cursor: 'pointer',
zIndex: 2,
width: '28px',
height: '16px',
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',
vars: {
[threePointerIconColorVar]: cssVarV2.icon.secondary,
},
selectors: {
'&:hover': {
opacity: 1,
},
'&.active': {
opacity: 1,
backgroundColor: cssVarV2.table.indicator.activated,
vars: {
[threePointerIconColorVar]: cssVarV2.table.indicator.pointerActive,
},
},
},
});
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: '16px',
height: '28px',
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',
vars: {
[threePointerIconColorVar]: cssVarV2.icon.secondary,
},
selectors: {
'&:hover': {
opacity: 1,
},
'&.active': {
opacity: 1,
backgroundColor: cssVarV2.table.indicator.activated,
vars: {
[threePointerIconColorVar]: cssVarV2.table.indicator.pointerActive,
},
},
},
});
export const threePointerIconStyle = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2px',
});
export const threePointerIconDotStyle = style({
width: '3px',
height: '3px',
backgroundColor: threePointerIconColorVar,
borderRadius: '50%',
});
export const indicatorStyle = style({
position: 'absolute',
backgroundColor: cssVarV2.table.indicator.activated,
zIndex: 2,
transition: 'opacity 0.2s ease-in-out',
pointerEvents: 'none',
});
export const columnIndicatorStyle = style([
indicatorStyle,
{
top: '-1px',
height: 'calc(100% + 2px)',
width: '5px',
},
]);
export const columnRightIndicatorStyle = style([
columnIndicatorStyle,
{
cursor: 'ew-resize',
right: '-3px',
pointerEvents: 'auto',
},
]);
export const columnLeftIndicatorStyle = style([
columnIndicatorStyle,
{
left: '-2px',
},
]);
export const rowIndicatorStyle = style([
indicatorStyle,
{
left: '-1px',
width: 'calc(100% + 2px)',
height: '5px',
},
]);
export const rowBottomIndicatorStyle = style([
rowIndicatorStyle,
{
bottom: '-3px',
},
]);
export const rowTopIndicatorStyle = style([
rowIndicatorStyle,
{
top: '-2px',
},
]);

View File

@@ -0,0 +1,852 @@
import {
menu,
popMenu,
type PopupTarget,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { TextBackgroundDuotoneIcon } from '@blocksuite/affine-components/icons';
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
import type { TableColumn, TableRow } from '@blocksuite/affine-model';
import { RichText } from '@blocksuite/affine-rich-text';
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import { IS_MAC } from '@blocksuite/global/env';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
ArrowDownBigIcon,
ArrowLeftBigIcon,
ArrowRightBigIcon,
ArrowUpBigIcon,
CloseIcon,
ColorPickerIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
InsertAboveIcon,
InsertBelowIcon,
InsertLeftIcon,
InsertRightIcon,
PasteIcon,
} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
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 > 0 ? columnIndex - 1 : undefined
);
},
}),
menu.action({
name: 'Insert Right',
prefix: InsertRightIcon(),
select: () => {
this.dataManager.insertColumn(columnIndex);
},
}),
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 > 0 ? rowIndex - 1 : undefined
);
},
}),
menu.action({
name: 'Insert Below',
prefix: InsertBelowIcon(),
select: () => {
this.dataManager.insertRow(rowIndex);
},
}),
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;
}
}

View File

@@ -0,0 +1,382 @@
import type { TableBlockModel, TableCell } from '@blocksuite/affine-model';
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
import { nanoid, Text } from '@blocksuite/store';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import type { TableAreaSelection } from './selection-schema';
export class TableDataManager {
constructor(private readonly model: TableBlockModel) {}
readonly readonly$: ReadonlySignal<boolean> = computed(() => {
return this.model.doc.readonly;
});
readonly ui = {
columnIndicatorIndex$: signal<number>(),
rowIndicatorIndex$: signal<number>(),
};
readonly hoverColumnIndex$ = signal<number>();
readonly hoverRowIndex$ = signal<number>();
readonly hoverDragHandleColumnId$ = signal<string>();
readonly widthAdjustColumnId$ = signal<string>();
readonly virtualColumnCount$ = signal<number>(0);
readonly virtualRowCount$ = signal<number>(0);
readonly virtualWidth$ = signal<
{ columnId: string; width: number } | undefined
>();
readonly cellCountTips$ = computed(
() =>
`${this.virtualRowCount$.value + this.rows$.value.length} x ${this.virtualColumnCount$.value + this.columns$.value.length}`
);
readonly rows$ = computed(() => {
return Object.values(this.model.props.rows$.value).sort((a, b) =>
a.order > b.order ? 1 : -1
);
});
readonly columns$ = computed(() => {
return Object.values(this.model.props.columns$.value).sort((a, b) =>
a.order > b.order ? 1 : -1
);
});
readonly 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);
});
readonly 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.props.cells$.value[`${rowId}:${columnId}`];
}
addRow(after?: number) {
const order = this.getOrder(this.rows$.value, after);
const rowId = nanoid();
this.model.doc.transact(() => {
this.model.props.rows[rowId] = {
rowId,
order,
};
this.columns$.value.forEach(column => {
this.model.props.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.props.columns[columnId] = {
columnId,
order,
};
this.rows$.value.forEach(row => {
this.model.props.cells[`${row.rowId}:${columnId}`] = {
text: new Text(),
};
});
});
return columnId;
}
deleteRow(rowId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.props.rows).forEach(id => {
if (id === rowId) {
delete this.model.props.rows[id];
}
});
Object.keys(this.model.props.cells).forEach(id => {
if (id.startsWith(rowId)) {
delete this.model.props.cells[id];
}
});
});
}
deleteColumn(columnId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.props.columns).forEach(id => {
if (id === columnId) {
delete this.model.props.columns[id];
}
});
Object.keys(this.model.props.cells).forEach(id => {
if (id.endsWith(`:${columnId}`)) {
delete this.model.props.cells[id];
}
});
});
}
updateRowOrder(rowId: string, newOrder: string) {
this.model.doc.transact(() => {
if (this.model.props.rows[rowId]) {
this.model.props.rows[rowId].order = newOrder;
}
});
}
updateColumnOrder(columnId: string, newOrder: string) {
this.model.doc.transact(() => {
if (this.model.props.columns[columnId]) {
this.model.props.columns[columnId].order = newOrder;
}
});
}
setRowBackgroundColor(rowId: string, color?: string) {
this.model.doc.transact(() => {
if (this.model.props.rows[rowId]) {
this.model.props.rows[rowId].backgroundColor = color;
}
});
}
setColumnBackgroundColor(columnId: string, color?: string) {
this.model.doc.transact(() => {
if (this.model.props.columns[columnId]) {
this.model.props.columns[columnId].backgroundColor = color;
}
});
}
setColumnWidth(columnId: string, width: number) {
this.model.doc.transact(() => {
if (this.model.props.columns[columnId]) {
this.model.props.columns[columnId].width = width;
}
});
}
clearRow(rowId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.props.cells).forEach(id => {
if (id.startsWith(rowId)) {
this.model.props.cells[id]?.text.replace(
0,
this.model.props.cells[id]?.text.length,
''
);
}
});
});
}
clearColumn(columnId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.props.cells).forEach(id => {
if (id.endsWith(`:${columnId}`)) {
this.model.props.cells[id]?.text.replace(
0,
this.model.props.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.props.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.props.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.props.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.props.columns[newColumnId] = {
...oldColumn,
columnId: newColumnId,
order,
};
this.rows$.value.forEach(row => {
this.model.props.cells[`${row.rowId}:${newColumnId}`] = {
text:
this.model.props.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.props.rows[newRowId] = {
...oldRow,
rowId: newRowId,
order,
};
this.columns$.value.forEach(column => {
this.model.props.cells[`${newRowId}:${column.columnId}`] = {
text:
this.model.props.cells[
`${oldRow.rowId}:${column.columnId}`
]?.text.clone() ?? new Text(),
};
});
});
return newRowId;
}
}

View File

@@ -0,0 +1,15 @@
import { TableModelFlavour } from '@blocksuite/affine-model';
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { TableBlockAdapterExtensions } from './adapters/extension.js';
import { tableSlashMenuConfig } from './configs/slash-menu.js';
export const TableBlockSpec: ExtensionType[] = [
FlavourExtension(TableModelFlavour),
BlockViewExtension(TableModelFlavour, literal`affine-table`),
TableBlockAdapterExtensions,
SlashMenuConfigExtension(TableModelFlavour, tableSlashMenuConfig),
].flat();

View File

@@ -0,0 +1,6 @@
export const cleanSelection = () => {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
};

View File

@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
"noUncheckedIndexedAccess": true
},
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../inlines/preset" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../widgets/slash-menu" },
{ "path": "../../data-view" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}