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

close: BS-2122, BS-2125, BS-2124, BS-2420, PD-2073, BS-2126, BS-2469, BS-2470, BS-2478, BS-2471
This commit is contained in:
zzj3720
2025-01-24 10:07:57 +00:00
parent 3f4311ff1c
commit 5a5779c05a
61 changed files with 3577 additions and 381 deletions
@@ -6,162 +6,17 @@ import {
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
HastUtils,
type InlineHtmlAST,
TextUtils,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
import type { Element } from 'hast';
import { processTable } from './utils';
const DATABASE_NODE_TYPES = new Set(['table', 'thead', 'tbody', 'th', 'tr']);
export const databaseBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: DatabaseBlockSchema.model.flavour,
toMatch: o =>
HastUtils.isElement(o.node) && DATABASE_NODE_TYPES.has(o.node.tagName),
toMatch: () => false,
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
if (o.node.tagName === 'table') {
const tableHeader = HastUtils.querySelector(o.node, 'thead');
if (!tableHeader) {
return;
}
const tableHeaderRow = HastUtils.querySelector(tableHeader, 'tr');
if (!tableHeaderRow) {
return;
}
// Table header row as database header row
const viewsColumns = tableHeaderRow.children.map(() => {
return {
id: nanoid(),
hide: false,
width: 180,
};
});
// Build database cells from table body rows
const cells = Object.create(null);
const tableBody = HastUtils.querySelector(o.node, 'tbody');
tableBody?.children.forEach(row => {
const rowId = nanoid();
cells[rowId] = Object.create(null);
(row as Element).children.forEach((cell, index) => {
const column = viewsColumns[index];
if (!column) {
return;
}
cells[rowId][column.id] = {
columnId: column.id,
value: TextUtils.createText(
(cell as Element).children
.map(child => ('value' in child ? child.value : ''))
.join('')
),
};
});
});
// Build database columns from table header row
const columns = tableHeaderRow.children.flatMap((_child, index) => {
const column = viewsColumns[index];
if (!column) {
return [];
}
return {
type: index === 0 ? 'title' : 'rich-text',
name: (_child as Element).children
.map(child => ('value' in child ? child.value : ''))
.join(''),
data: {},
id: column.id,
};
});
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:database',
props: {
views: [
{
id: nanoid(),
name: 'Table View',
mode: 'table',
columns: [],
filter: {
type: 'group',
op: 'and',
conditions: [],
},
header: {
titleColumn: viewsColumns[0]?.id,
iconColumn: 'type',
},
},
],
title: {
'$blocksuite:internal:text$': true,
delta: [],
},
cells,
columns,
},
children: [],
},
'children'
);
walkerContext.setNodeContext('affine:table:rowid', Object.keys(cells));
walkerContext.skipChildren(1);
}
// The first child of each table body row is the database title cell
if (o.node.tagName === 'tr') {
const { deltaConverter } = context;
const firstChild = o.node.children[0];
if (!firstChild) {
return;
}
walkerContext
.openNode({
type: 'block',
id:
(
walkerContext.getNodeContext(
'affine:table:rowid'
) as Array<string>
).shift() ?? nanoid(),
flavour: 'affine:paragraph',
props: {
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(firstChild),
},
type: 'text',
},
children: [],
})
.closeNode();
walkerContext.skipAllChildren();
}
},
leave: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
if (o.node.tagName === 'table') {
walkerContext.closeNode();
}
},
},
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext } = context;
@@ -7,9 +7,7 @@ import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
type MarkdownAST,
TextUtils,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
import type { TableRow } from 'mdast';
import { processTable } from './utils';
@@ -24,123 +22,7 @@ export const databaseBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
flavour: DatabaseBlockSchema.model.flavour,
toMatch: o => isDatabaseNode(o.node),
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
const { walkerContext } = context;
if (o.node.type === 'table') {
const viewsColumns = o.node.children[0]?.children.map(() => {
return {
id: nanoid(),
hide: false,
width: 180,
};
});
const cells = Object.create(null);
o.node.children.slice(1).forEach(row => {
const rowId = nanoid();
cells[rowId] = Object.create(null);
row.children.slice(1).forEach((cell, index) => {
const column = viewsColumns?.[index + 1];
if (!column) {
return;
}
cells[rowId][column.id] = {
columnId: column.id,
value: TextUtils.createText(
cell.children
.map(child => ('value' in child ? child.value : ''))
.join('')
),
};
});
});
const columns = o.node.children[0]?.children.map((_child, index) => {
return {
type: index === 0 ? 'title' : 'rich-text',
name: _child.children
.map(child => ('value' in child ? child.value : ''))
.join(''),
data: {},
id: viewsColumns?.[index]?.id,
};
});
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:database',
props: {
views: [
{
id: nanoid(),
name: 'Table View',
mode: 'table',
columns: [],
filter: {
type: 'group',
op: 'and',
conditions: [],
},
header: {
titleColumn: viewsColumns?.[0]?.id,
iconColumn: 'type',
},
},
],
title: {
'$blocksuite:internal:text$': true,
delta: [],
},
cells,
columns,
},
children: [],
},
'children'
);
walkerContext.setNodeContext(
'affine:table:rowid',
Object.keys(cells)
);
walkerContext.skipChildren(1);
}
if (o.node.type === 'tableRow') {
const { deltaConverter } = context;
const firstChild = o.node.children[0];
if (!firstChild) {
return;
}
walkerContext
.openNode({
type: 'block',
id:
(
walkerContext.getNodeContext(
'affine:table:rowid'
) as Array<string>
).shift() ?? nanoid(),
flavour: 'affine:paragraph',
props: {
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(firstChild),
},
type: 'text',
},
children: [],
})
.closeNode();
walkerContext.skipAllChildren();
}
},
leave: (o, context) => {
const { walkerContext } = context;
if (o.node.type === 'table') {
walkerContext.closeNode();
}
},
},
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext, deltaConverter } = context;
@@ -334,11 +334,6 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
this._dataSource.contextSet(HostContextKey, this.host);
const id = currentViewStorage.getCurrentView(this.model.id);
if (id && this.dataSource.viewManager.viewGet(id)) {
console.log(
'set current view',
id,
this._dataSource.viewManager.viewGet(id)
);
this.dataSource.viewManager.setCurrentView(id);
}
}
@@ -518,6 +518,8 @@ export class RichTextCellEditing extends BaseRichTextCell {
override render() {
return html`<rich-text
data-disable-ask-ai
data-not-block-text
.yText=${this.value}
.inlineEventSource=${this.topContenteditableElement}
.attributesSchema=${this.attributesSchema}
@@ -388,6 +388,8 @@ export class HeaderAreaTextCellEditing extends BaseTextCell {
override renderBlockText() {
return html` <rich-text
data-disable-ask-ai
data-not-block-text
.yText="${this.value}"
.inlineEventSource="${this.topContenteditableElement}"
.attributesSchema="${this.attributesSchema}"
@@ -407,6 +409,8 @@ export class HeaderAreaTextCellEditing extends BaseTextCell {
override renderLinkedDoc(): TemplateResult {
return html` <rich-text
data-disable-ask-ai
data-not-block-text
.yText="${this.linkedDocTitle$.value}"
.inlineEventSource="${this.topContenteditableElement}"
.readonly="${this.readonly}"
@@ -0,0 +1,43 @@
{
"name": "@blocksuite/affine-block-table",
"description": "Table block for BlockSuite.",
"type": "module",
"scripts": {
"build": "tsc",
"test:unit": "nx vite:test --run --passWithNoTests",
"test:unit:coverage": "nx vite:test --run --coverage",
"test:e2e": "playwright test"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/data-view": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.1",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.10",
"@preact/signals-core": "^1.8.0",
"@vanilla-extract/css": "^1.14.0",
"lit": "^3.2.0",
"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.19.0"
}
@@ -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,
];
@@ -0,0 +1,126 @@
import {
type TableBlockPropsSerialized,
TableBlockSchema,
TableModelFlavour,
} from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
HastUtils,
type InlineHtmlAST,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
import type { Element } from 'hast';
import { DefaultColumnWidth } from '../consts';
import { parseTableFromHtml, processTable } from './utils';
const TABLE_NODE_TYPES = new Set(['table', 'thead', 'tbody', 'th', 'tr']);
export const tableBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: TableBlockSchema.model.flavour,
toMatch: o => {
return HastUtils.isElement(o.node) && TABLE_NODE_TYPES.has(o.node.tagName);
},
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
if (o.node.tagName === 'table') {
const tableProps = parseTableFromHtml(o.node);
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: TableModelFlavour,
props: tableProps as unknown as Record<string, unknown>,
children: [],
},
'children'
);
walkerContext.skipChildren();
}
},
leave: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const { walkerContext } = context;
if (o.node.tagName === 'table') {
walkerContext.closeNode();
}
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext } = context;
const { columns, rows, cells } = o.node
.props as unknown as TableBlockPropsSerialized;
const table = processTable(columns, rows, cells);
const createAstTableCell = (
children: InlineHtmlAST[]
): InlineHtmlAST => ({
type: 'element',
tagName: 'td',
properties: Object.create(null),
children: [
{
type: 'element',
tagName: 'div',
properties: {
style: `min-height: 22px;min-width:${DefaultColumnWidth}px;padding: 8px 12px;`,
},
children,
},
],
});
const createAstTableRow = (cells: InlineHtmlAST[]): Element => ({
type: 'element',
tagName: 'tr',
properties: Object.create(null),
children: cells,
});
const { deltaConverter } = context;
const tableBodyAst: Element = {
type: 'element',
tagName: 'tbody',
properties: Object.create(null),
children: table.rows.map(v => {
return createAstTableRow(
v.cells.map(cell => {
return createAstTableCell(
typeof cell.value === 'string'
? [{ type: 'text', value: cell.value }]
: deltaConverter.deltaToAST(cell.value.delta)
);
})
);
}),
};
walkerContext
.openNode({
type: 'element',
tagName: 'table',
properties: {
border: true,
style: 'border-collapse: collapse;border-spacing: 0;',
},
children: [tableBodyAst],
})
.closeNode();
walkerContext.skipAllChildren();
},
},
};
export const TableBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
tableBlockHtmlAdapterMatcher
);
@@ -0,0 +1,4 @@
export * from './html';
export * from './markdown';
export * from './notion-html';
export * from './plain-text';
@@ -0,0 +1,79 @@
import {
type TableBlockPropsSerialized,
TableBlockSchema,
TableModelFlavour,
} from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
import type { TableRow } from 'mdast';
import { parseTableFromMarkdown, processTable } from './utils';
const TABLE_NODE_TYPES = new Set(['table', 'tableRow']);
const isTableNode = (node: MarkdownAST) => TABLE_NODE_TYPES.has(node.type);
export const tableBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
flavour: TableBlockSchema.model.flavour,
toMatch: o => isTableNode(o.node),
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
const { walkerContext } = context;
if (o.node.type === 'table') {
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: TableModelFlavour,
props: parseTableFromMarkdown(o.node),
children: [],
},
'children'
);
walkerContext.skipChildren();
}
},
leave: (o, context) => {
const { walkerContext } = context;
if (o.node.type === 'table') {
walkerContext.closeNode();
}
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext, deltaConverter } = context;
const { columns, rows, cells } = o.node
.props as unknown as TableBlockPropsSerialized;
const table = processTable(columns, rows, cells);
const result: TableRow[] = [];
table.rows.forEach(v => {
result.push({
type: 'tableRow',
children: v.cells.map(v => ({
type: 'tableCell',
children: deltaConverter.deltaToAST(v.value.delta),
})),
});
});
walkerContext
.openNode({
type: 'table',
children: result,
})
.closeNode();
walkerContext.skipAllChildren();
},
},
};
export const TableBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
tableBlockMarkdownAdapterMatcher
);
@@ -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);
@@ -0,0 +1,74 @@
import {
type TableBlockPropsSerialized,
TableBlockSchema,
TableModelFlavour,
} from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
import { createTableProps, formatTable, processTable } from './utils.js';
export const tableBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = {
flavour: TableBlockSchema.model.flavour,
toMatch: () => true,
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
const { walkerContext } = context;
const text = o.node.content;
const rowTexts = text.split('\n');
if (rowTexts.length <= 1) return;
const rowTextLists: string[][] = [];
let columnCount: number | null = null;
for (const row of rowTexts) {
const cells = row.split('\t');
if (cells.length <= 1) return;
if (columnCount == null) {
columnCount = cells.length;
} else if (columnCount !== cells.length) {
return;
}
rowTextLists.push(cells);
}
const tableProps = createTableProps(rowTextLists);
walkerContext.openNode({
type: 'block',
id: nanoid(),
flavour: TableModelFlavour,
props: tableProps,
children: [],
});
walkerContext.skipAllChildren();
},
leave: (_, context) => {
const { walkerContext } = context;
walkerContext.closeNode();
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext, deltaConverter } = context;
const result: string[][] = [];
const { columns, rows, cells } = o.node
.props as unknown as TableBlockPropsSerialized;
const table = processTable(columns, rows, cells);
table.rows.forEach(v => {
result.push(
v.cells.map(v => deltaConverter.deltaToAST(v.value.delta).join(''))
);
});
const tableString = formatTable(result);
context.textBuffer.content += tableString;
context.textBuffer.content += '\n';
walkerContext.skipAllChildren();
},
},
};
export const TableBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(tableBlockPlainTextAdapterMatcher);
@@ -0,0 +1,215 @@
import type {
TableBlockPropsSerialized,
TableCellSerialized,
TableColumn,
TableRow,
} from '@blocksuite/affine-model';
import { HastUtils, TextUtils } from '@blocksuite/affine-shared/adapters';
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
import type { DeltaInsert } from '@blocksuite/inline';
import { nanoid } from '@blocksuite/store';
import type { Element, ElementContent } from 'hast';
import type { PhrasingContent, Table as MarkdownTable, TableCell } from 'mdast';
function calculateColumnWidths(rows: string[][]): number[] {
return (
rows[0]?.map((_, colIndex) =>
Math.max(...rows.map(row => (row[colIndex] || '').length))
) ?? []
);
}
function formatRow(
row: string[],
columnWidths: number[],
isHeader: boolean
): string {
const cells = row.map((cell, colIndex) =>
cell?.padEnd(columnWidths[colIndex] ?? 0, ' ')
);
const rowString = `| ${cells.join(' | ')} |`;
return isHeader
? `${rowString}\n${formatSeparator(columnWidths)}`
: rowString;
}
function formatSeparator(columnWidths: number[]): string {
const separator = columnWidths.map(width => '-'.repeat(width)).join(' | ');
return `| ${separator} |`;
}
export function formatTable(rows: string[][]): string {
const columnWidths = calculateColumnWidths(rows);
const formattedRows = rows.map((row, index) =>
formatRow(row, columnWidths, index === 0)
);
return formattedRows.join('\n');
}
type Table = {
rows: Row[];
};
type Row = {
cells: Cell[];
};
type Cell = {
value: { delta: DeltaInsert[] };
};
export const processTable = (
columns: Record<string, TableColumn>,
rows: Record<string, TableRow>,
cells: Record<string, TableCellSerialized>
): Table => {
const sortedColumns = Object.values(columns).sort((a, b) =>
a.order.localeCompare(b.order)
);
const sortedRows = Object.values(rows).sort((a, b) =>
a.order.localeCompare(b.order)
);
const table: Table = {
rows: [],
};
sortedRows.forEach(r => {
const row: Row = {
cells: [],
};
sortedColumns.forEach(col => {
const cell = cells[`${r.rowId}:${col.columnId}`];
if (!cell) {
row.cells.push({
value: {
delta: [],
},
});
return;
}
row.cells.push({
value: cell.text,
});
});
table.rows.push(row);
});
return table;
};
const getTextFromElement = (element: ElementContent): string => {
if (element.type === 'text') {
return element.value;
}
if (element.type === 'element') {
return element.children.map(child => getTextFromElement(child)).join('');
}
return '';
};
const getAllTag = (node: Element | undefined, tagName: string): Element[] => {
if (!node) {
return [];
}
if (HastUtils.isElement(node)) {
if (node.tagName === tagName) {
return [node];
}
return node.children.flatMap(child => {
if (HastUtils.isElement(child)) {
return getAllTag(child, tagName);
}
return [];
});
}
return [];
};
export const createTableProps = (rowTextLists: string[][]) => {
const createIdAndOrder = (count: number) => {
const result: { id: string; order: string }[] = Array.from({
length: count,
});
for (let i = 0; i < count; i++) {
const id = nanoid();
const order = generateFractionalIndexingKeyBetween(
result[i - 1]?.order ?? null,
null
);
result[i] = { id, order };
}
return result;
};
const columnCount = Math.max(...rowTextLists.map(row => row.length));
const rowCount = rowTextLists.length;
const columns: TableColumn[] = createIdAndOrder(columnCount).map(v => ({
columnId: v.id,
order: v.order,
}));
const rows: TableRow[] = createIdAndOrder(rowCount).map(v => ({
rowId: v.id,
order: v.order,
}));
const cells: Record<string, TableCellSerialized> = {};
for (let i = 0; i < rowCount; i++) {
for (let j = 0; j < columnCount; j++) {
const row = rows[i];
const column = columns[j];
if (!row || !column) {
continue;
}
const cellId = `${row.rowId}:${column.columnId}`;
const text = rowTextLists[i]?.[j];
cells[cellId] = {
text: TextUtils.createText(text ?? ''),
};
}
}
return {
columns: Object.fromEntries(
columns.map(column => [column.columnId, column])
),
rows: Object.fromEntries(rows.map(row => [row.rowId, row])),
cells,
};
};
export const parseTableFromHtml = (
element: Element
): TableBlockPropsSerialized => {
const headerRows = getAllTag(element, 'thead').flatMap(node =>
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'th'))
);
const bodyRows = getAllTag(element, 'tbody').flatMap(node =>
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'td'))
);
const footerRows = getAllTag(element, 'tfoot').flatMap(node =>
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'td'))
);
const allRows = [...headerRows, ...bodyRows, ...footerRows];
const rowTextLists: string[][] = [];
allRows.forEach(cells => {
const row: string[] = [];
cells.forEach(cell => {
row.push(getTextFromElement(cell));
});
rowTextLists.push(row);
});
return createTableProps(rowTextLists);
};
const getTextFromTableCell = (node: TableCell) => {
const getTextFromPhrasingContent = (node: PhrasingContent) => {
if (node.type === 'text') {
return node.value;
}
return '';
};
return node.children.map(child => getTextFromPhrasingContent(child)).join('');
};
export const parseTableFromMarkdown = (node: MarkdownTable) => {
const rowTextLists: string[][] = [];
node.children.forEach(row => {
const rowText: string[] = [];
row.children.forEach(cell => {
rowText.push(getTextFromTableCell(cell));
});
rowTextLists.push(rowText);
});
return createTableProps(rowTextLists);
};
@@ -0,0 +1,90 @@
import { cssVar, cssVarV2 } from '@blocksuite/affine-shared/theme';
import { style } from '@vanilla-extract/css';
export const addColumnButtonStyle = style({
cursor: 'col-resize',
backgroundColor: cssVarV2.layer.background.hoverOverlay,
fontSize: '10px',
color: cssVarV2.icon.secondary,
display: 'flex',
width: '12px',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
top: '0',
left: 'calc(100% + 2px)',
height: '100%',
transition:
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
borderRadius: '2px',
opacity: 0,
selectors: {
'&:hover, &.active': {
backgroundColor: cssVarV2.table.indicator.drag,
color: cssVarV2.icon.primary,
opacity: 1,
},
},
});
export const addRowButtonStyle = style({
cursor: 'row-resize',
backgroundColor: cssVarV2.layer.background.hoverOverlay,
fontSize: '10px',
color: cssVarV2.icon.secondary,
display: 'flex',
height: '12px',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
top: 'calc(100% + 2px)',
left: '0',
width: '100%',
transition:
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
borderRadius: '2px',
opacity: 0,
selectors: {
'&:hover, &.active': {
backgroundColor: cssVarV2.table.indicator.drag,
color: cssVarV2.icon.primary,
opacity: 1,
},
},
});
export const addRowColumnButtonStyle = style({
cursor: 'nwse-resize',
backgroundColor: cssVarV2.layer.background.hoverOverlay,
fontSize: '10px',
color: cssVarV2.icon.secondary,
display: 'flex',
width: '12px',
height: '12px',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
top: 'calc(100% + 2px)',
left: 'calc(100% + 2px)',
borderRadius: '2px',
opacity: 0,
transition:
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
selectors: {
'&:hover, &.active': {
backgroundColor: cssVarV2.table.indicator.drag,
color: cssVarV2.icon.primary,
opacity: 1,
},
},
});
export const cellCountTipsStyle = style({
position: 'absolute',
backgroundColor: cssVarV2.tooltips.background,
borderRadius: '4px',
padding: '4px',
boxShadow: cssVar('buttonShadow'),
color: cssVarV2.tooltips.foreground,
whiteSpace: 'nowrap',
});
@@ -0,0 +1,327 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { PlusIcon } from '@blocksuite/icons/lit';
import {
autoPlacement,
autoUpdate,
computePosition,
offset,
shift,
} from '@floating-ui/dom';
import { signal } from '@preact/signals-core';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import {
addColumnButtonStyle,
addRowButtonStyle,
addRowColumnButtonStyle,
cellCountTipsStyle,
} from './add-button.css';
import { DefaultColumnWidth, DefaultRowHeight } from './consts';
import type { TableDataManager } from './table-data-manager';
export class AddButton extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
@property({ type: Boolean })
accessor vertical = false;
@property({ attribute: false })
accessor dataManager!: TableDataManager;
get hoverColumnIndex$() {
return this.dataManager.hoverColumnIndex$;
}
get hoverRowIndex$() {
return this.dataManager.hoverRowIndex$;
}
get columns$() {
return this.dataManager.columns$;
}
get rows$() {
return this.dataManager.rows$;
}
addColumnButtonRef$ = signal<HTMLDivElement>();
addRowButtonRef$ = signal<HTMLDivElement>();
addRowColumnButtonRef$ = signal<HTMLDivElement>();
columnDragging$ = signal(false);
rowDragging$ = signal(false);
rowColumnDragging$ = signal(false);
popCellCountTips = (ele: Element) => {
const tip = document.createElement('div');
tip.classList.add(cellCountTipsStyle);
document.body.append(tip);
const dispose = autoUpdate(ele, tip, () => {
computePosition(ele, tip, {
middleware: [
autoPlacement({ allowedPlacements: ['bottom'] }),
offset(4),
shift(),
],
})
.then(({ x, y }) => {
tip.style.left = `${x}px`;
tip.style.top = `${y}px`;
})
.catch(e => {
console.error(e);
});
});
return {
tip,
dispose: () => {
dispose();
tip.remove();
},
};
};
getEmptyRows() {
const rows = this.rows$.value;
const columns = this.columns$.value;
const rowWidths: number[] = [];
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (!row) {
break;
}
const hasText = columns.some(column => {
const cell = this.dataManager.getCell(row.rowId, column.columnId);
if (!cell) {
return false;
}
return cell.text.length > 0;
});
if (hasText) {
break;
}
rowWidths.push((rowWidths[rowWidths.length - 1] ?? 0) + DefaultRowHeight);
}
return rowWidths;
}
getEmptyColumns() {
const columns = this.columns$.value;
const rows = this.rows$.value;
const columnWidths: number[] = [];
for (let i = columns.length - 1; i >= 0; i--) {
const column = columns[i];
if (!column) {
break;
}
const hasText = rows.some(row => {
const cell = this.dataManager.getCell(row.rowId, column.columnId);
if (!cell) {
return false;
}
return cell.text.length > 0;
});
if (hasText) {
break;
}
columnWidths.push(
(columnWidths[columnWidths.length - 1] ?? 0) +
(column.width ?? DefaultColumnWidth)
);
}
return columnWidths;
}
onDragStart(e: MouseEvent) {
e.stopPropagation();
const initialX = e.clientX;
const initialY = e.clientY;
const target = e.target as HTMLElement;
const isColumn = target.closest('.column-add');
const isRow = target.closest('.row-add');
const isRowColumn = target.closest('.row-column-add');
const realTarget = isColumn || isRowColumn || isRow;
if (!realTarget) {
return;
}
const tipsHandler = this.popCellCountTips(realTarget);
let emptyRows: number[] = [];
let emptyColumns: number[] = [];
if (isColumn) {
this.columnDragging$.value = true;
emptyColumns = this.getEmptyColumns();
}
if (isRow) {
this.rowDragging$.value = true;
emptyRows = this.getEmptyRows();
}
if (isRowColumn) {
this.rowColumnDragging$.value = true;
emptyRows = this.getEmptyRows();
emptyColumns = this.getEmptyColumns();
}
const onMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - initialX;
const deltaY = e.clientY - initialY;
const addColumn = isColumn || isRowColumn;
const addRow = isRow || isRowColumn;
if (addColumn) {
if (deltaX > 0) {
this.dataManager.virtualColumnCount$.value = Math.floor(
(deltaX + 30) / DefaultColumnWidth
);
} else {
let count = 0;
while (count < emptyColumns.length) {
const emptyColumnWidth = emptyColumns[count];
if (!emptyColumnWidth) {
continue;
}
if (-deltaX > emptyColumnWidth) {
count++;
} else {
break;
}
}
this.dataManager.virtualColumnCount$.value = -count;
}
}
if (addRow) {
if (deltaY > 0) {
this.dataManager.virtualRowCount$.value = Math.floor(
deltaY / DefaultRowHeight
);
} else {
let count = 0;
while (count < emptyRows.length) {
const emptyRowHeight = emptyRows[count];
if (!emptyRowHeight) {
continue;
}
if (-deltaY > emptyRowHeight) {
count++;
} else {
break;
}
}
this.dataManager.virtualRowCount$.value = -count;
}
}
tipsHandler.tip.textContent = this.dataManager.cellCountTips$.value;
};
const onMouseUp = () => {
this.columnDragging$.value = false;
this.rowDragging$.value = false;
this.rowColumnDragging$.value = false;
const rowCount = this.dataManager.virtualRowCount$.value;
const columnCount = this.dataManager.virtualColumnCount$.value;
this.dataManager.virtualColumnCount$.value = 0;
this.dataManager.virtualRowCount$.value = 0;
this.dataManager.addNRow(rowCount);
this.dataManager.addNColumn(columnCount);
tipsHandler.dispose();
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
override connectedCallback(): void {
super.connectedCallback();
this.disposables.addFromEvent(this, 'mousedown', (e: MouseEvent) => {
this.onDragStart(e);
});
}
renderAddColumnButton() {
const hovered =
this.hoverColumnIndex$.value === this.columns$.value.length - 1;
const dragging = this.columnDragging$.value;
return html` <div
class="${classMap({
[addColumnButtonStyle]: true,
active: dragging,
'column-add': true,
})}"
${ref(this.addColumnButtonRef$)}
style=${styleMap({
opacity: hovered || dragging ? 1 : undefined,
})}
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.dataManager.addColumn(this.columns$.value.length - 1);
}}"
>
${PlusIcon()}
</div>`;
}
renderAddRowButton() {
const hovered = this.hoverRowIndex$.value === this.rows$.value.length - 1;
const dragging = this.rowDragging$.value;
return html` <div
class="${classMap({
[addRowButtonStyle]: true,
active: dragging,
'row-add': true,
})}"
${ref(this.addRowButtonRef$)}
style=${styleMap({
opacity: hovered || dragging ? 1 : undefined,
})}
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.dataManager.addRow(this.rows$.value.length - 1);
}}"
>
${PlusIcon()}
</div>`;
}
renderAddRowColumnButton() {
const hovered =
this.hoverRowIndex$.value === this.rows$.value.length - 1 &&
this.hoverColumnIndex$.value === this.columns$.value.length - 1;
const dragging = this.rowColumnDragging$.value;
return html` <div
class="${classMap({
[addRowColumnButtonStyle]: true,
active: dragging,
'row-column-add': true,
})}"
${ref(this.addRowColumnButtonRef$)}
style=${styleMap({
opacity: hovered || dragging ? 1 : undefined,
})}
@click="${(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.dataManager.addRow(this.rows$.value.length - 1);
this.dataManager.addColumn(this.columns$.value.length - 1);
}}"
>
${PlusIcon()}
</div>`;
}
override render() {
return html`
${this.renderAddColumnButton()} ${this.renderAddRowButton()}
${this.renderAddRowColumnButton()}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-table-add-button': AddButton;
}
}
@@ -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;
};
@@ -0,0 +1,67 @@
import '@blocksuite/affine-shared/commands';
import { TableModelFlavour } from '@blocksuite/affine-model';
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
import type { BlockCommands, Command } from '@blocksuite/block-std';
import { nanoid, Text } from '@blocksuite/store';
export const insertTableBlockCommand: Command<
'selectedModels',
'insertedTableBlockId',
{
place?: 'after' | 'before';
removeEmptyLine?: boolean;
}
> = (ctx, next) => {
const { selectedModels, place, removeEmptyLine, std } = ctx;
if (!selectedModels?.length) return;
const targetModel =
place === 'before'
? selectedModels[0]
: selectedModels[selectedModels.length - 1];
if (!targetModel) return;
const row1Id = nanoid();
const row2Id = nanoid();
const col1Id = nanoid();
const col2Id = nanoid();
const order1 = generateFractionalIndexingKeyBetween(null, null);
const order2 = generateFractionalIndexingKeyBetween(order1, null);
const initialTableData = {
rows: {
[row1Id]: { rowId: row1Id, order: order1 },
[row2Id]: { rowId: row2Id, order: order2 },
},
columns: {
[col1Id]: { columnId: col1Id, order: order1 },
[col2Id]: { columnId: col2Id, order: order2 },
},
cells: {
[`${row1Id}:${col1Id}`]: { text: new Text() },
[`${row1Id}:${col2Id}`]: { text: new Text() },
[`${row2Id}:${col1Id}`]: { text: new Text() },
[`${row2Id}:${col2Id}`]: { text: new Text() },
},
};
const result = std.store.addSiblingBlocks(
targetModel,
[{ flavour: TableModelFlavour, ...initialTableData }],
place
);
const blockId = result[0];
if (blockId == null) return;
if (removeEmptyLine && targetModel.text?.length === 0) {
std.store.deleteBlock(targetModel);
}
next({ insertedTableBlockId: blockId });
};
export const tableCommands: BlockCommands = {
insertTableBlock: insertTableBlockCommand,
};
@@ -0,0 +1,4 @@
export const ColumnMinWidth = 60;
export const ColumnMaxWidth = 240;
export const DefaultColumnWidth = 120;
export const DefaultRowHeight = 39;
@@ -0,0 +1,24 @@
import { AddButton } from './add-button';
import type { insertTableBlockCommand } from './commands';
import { SelectionLayer } from './selection-layer';
import { TableBlockComponent } from './table-block';
import { TableCell } from './table-cell';
export function effects() {
customElements.define('affine-table', TableBlockComponent);
customElements.define('affine-table-cell', TableCell);
customElements.define('affine-table-add-button', AddButton);
customElements.define('affine-table-selection-layer', SelectionLayer);
}
declare global {
namespace BlockSuite {
interface CommandContext {
insertedTableBlockId?: string;
}
interface Commands {
insertTableBlock: typeof insertTableBlockCommand;
}
}
}
@@ -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';
@@ -0,0 +1,276 @@
import {
domToOffsets,
getAreaByOffsets,
} from '@blocksuite/affine-shared/utils';
import type { UIEventStateContext } from '@blocksuite/block-std';
import { IS_MOBILE } from '@blocksuite/global/env';
import { computed } from '@preact/signals-core';
import type { ReactiveController } from 'lit';
import { ColumnMinWidth, DefaultColumnWidth } from './consts';
import {
type TableAreaSelection,
TableSelection,
TableSelectionData,
} from './selection-schema';
import type { TableBlockComponent } from './table-block';
type Cells = string[][];
const TEXT = 'text/plain';
export class SelectionController implements ReactiveController {
constructor(public readonly host: TableBlockComponent) {
this.host.addController(this);
}
hostConnected() {
this.dragListener();
this.host.handleEvent('copy', this.onCopy);
this.host.handleEvent('cut', this.onCut);
this.host.handleEvent('paste', this.onPaste);
}
private get dataManager() {
return this.host.dataManager;
}
private get clipboard() {
return this.host.std.clipboard;
}
widthAdjust(dragHandle: HTMLElement, event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
const initialX = event.clientX;
const currentWidth =
dragHandle.closest('td')?.getBoundingClientRect().width ??
DefaultColumnWidth;
const columnId = dragHandle.dataset['widthAdjustColumnId'];
if (!columnId) {
return;
}
const onMove = (event: MouseEvent) => {
this.dataManager.draggingColumnId$.value = columnId;
this.dataManager.virtualWidth$.value = {
columnId,
width: Math.max(
ColumnMinWidth,
event.clientX - initialX + currentWidth
),
};
};
const onUp = () => {
const width = this.dataManager.virtualWidth$.value?.width;
this.dataManager.draggingColumnId$.value = undefined;
this.dataManager.virtualWidth$.value = undefined;
if (width) {
this.dataManager.setColumnWidth(columnId, width);
}
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
dragListener() {
if (IS_MOBILE) {
return;
}
this.host.disposables.addFromEvent(this.host, 'mousedown', event => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const dragHandle = target.closest('[data-width-adjust-column-id]');
if (dragHandle instanceof HTMLElement) {
this.widthAdjust(dragHandle, event);
return;
}
this.onDragStart(event);
});
}
readonly doCopyOrCut = (selection: TableAreaSelection, isCut: boolean) => {
const columns = this.dataManager.uiColumns$.value;
const rows = this.dataManager.uiRows$.value;
const cells: Cells = [];
const deleteCells: { rowId: string; columnId: string }[] = [];
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
const row = rows[i];
if (!row) {
continue;
}
const rowCells: string[] = [];
for (
let j = selection.columnStartIndex;
j <= selection.columnEndIndex;
j++
) {
const column = columns[j];
if (!column) {
continue;
}
const cell = this.dataManager.getCell(row.rowId, column.columnId);
rowCells.push(cell?.text.toString() ?? '');
if (isCut) {
deleteCells.push({ rowId: row.rowId, columnId: column.columnId });
}
}
cells.push(rowCells);
}
if (isCut) {
this.dataManager.clearCells(deleteCells);
}
const text = cells.map(row => row.join('\t')).join('\n');
this.clipboard
.writeToClipboard(items => ({
...items,
[TEXT]: text,
}))
.catch(console.error);
};
onCopy = () => {
const selection = this.getSelected();
if (!selection || selection.type !== 'area') {
return false;
}
this.doCopyOrCut(selection, false);
return true;
};
onCut = () => {
const selection = this.getSelected();
if (!selection || selection.type !== 'area') {
return false;
}
this.doCopyOrCut(selection, true);
return true;
};
doPaste = (plainText: string, selection: TableAreaSelection) => {
try {
const rowTextLists = plainText
.split(/\r?\n/)
.map(line => line.split('\t').map(cell => cell.trim()))
.filter(row => row.some(cell => cell !== '')); // Filter out empty rows
const height = rowTextLists.length;
const width = rowTextLists[0]?.length ?? 0;
if (height > 0 && width > 0) {
const columns = this.dataManager.uiColumns$.value;
const rows = this.dataManager.uiRows$.value;
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
const row = rows[i];
if (!row) {
continue;
}
for (
let j = selection.columnStartIndex;
j <= selection.columnEndIndex;
j++
) {
const column = columns[j];
if (!column) {
continue;
}
const text = this.dataManager.getCell(
row.rowId,
column.columnId
)?.text;
if (text) {
const rowIndex = (i - selection.rowStartIndex) % height;
const columnIndex = (j - selection.columnStartIndex) % width;
text.replace(
0,
text.length,
rowTextLists[rowIndex]?.[columnIndex] ?? ''
);
}
}
}
}
} catch (error) {
console.error(error);
}
};
onPaste = (_context: UIEventStateContext) => {
const event = _context.get('clipboardState').raw;
event.stopPropagation();
const clipboardData = event.clipboardData;
if (!clipboardData) return false;
const selection = this.getSelected();
if (!selection || selection.type !== 'area') {
return false;
}
const plainText = clipboardData.getData('text/plain');
this.doPaste(plainText, selection);
return true;
};
onDragStart(event: MouseEvent) {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const offsets = domToOffsets(this.host, 'tr', 'td');
if (!offsets) return;
const startX = event.clientX;
const startY = event.clientY;
let selected = false;
const initCell = target.closest('affine-table-cell');
if (!initCell) {
selected = true;
}
const onMove = (event: MouseEvent) => {
const target = event.target;
if (target instanceof HTMLElement) {
const cell = target.closest('affine-table-cell');
if (!selected && initCell === cell) {
return;
}
selected = true;
const endX = event.clientX;
const endY = event.clientY;
const [left, right] = startX > endX ? [endX, startX] : [startX, endX];
const [top, bottom] = startY > endY ? [endY, startY] : [startY, endY];
const area = getAreaByOffsets(offsets, top, bottom, left, right);
this.setSelected({
type: 'area',
rowStartIndex: area.top,
rowEndIndex: area.bottom,
columnStartIndex: area.left,
columnEndIndex: area.right,
});
}
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
setSelected(
selection: TableSelectionData | undefined,
removeNativeSelection = true
) {
if (selection) {
const previous = this.getSelected();
if (TableSelectionData.equals(previous, selection)) {
return;
}
if (removeNativeSelection) {
getSelection()?.removeAllRanges();
}
this.host.selection.set([
new TableSelection({
blockId: this.host.model.id,
data: selection,
}),
]);
} else {
this.host.selection.clear();
}
}
selected$ = computed(() => this.getSelected());
getSelected(): TableSelectionData | undefined {
const selected = this.host.selected;
if (selected instanceof TableSelection) {
return selected.data;
}
return undefined;
}
}
@@ -0,0 +1,110 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { computed, effect, signal } from '@preact/signals-core';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { SelectionController } from './selection-controller';
type Rect = {
top: number;
left: number;
width: number;
height: number;
};
export class SelectionLayer extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
@property({ attribute: false })
accessor selectionController!: SelectionController;
@property({ attribute: false })
accessor getRowRect!: (rowId: string) => Rect;
@property({ attribute: false })
accessor getColumnRect!: (columnId: string) => Rect;
@property({ attribute: false })
accessor getAreaRect!: (
rowStartIndex: number,
rowEndIndex: number,
columnStartIndex: number,
columnEndIndex: number
) => Rect;
selection$ = computed(() => {
return this.selectionController.selected$.value;
});
computeRect = () => {
const selection = this.selection$.value;
if (!selection) return;
if (selection.type === 'row') {
const rect = this.getRowRect(selection.rowId);
return rect;
}
if (selection.type === 'column') {
const rect = this.getColumnRect(selection.columnId);
return rect;
}
if (selection.type === 'area') {
const rect = this.getAreaRect(
selection.rowStartIndex,
selection.rowEndIndex,
selection.columnStartIndex,
selection.columnEndIndex
);
return rect;
}
return;
};
rect$ = signal<Rect>();
private getSelectionStyle() {
const rect = this.rect$.value;
if (!rect)
return styleMap({
display: 'none',
});
const border = '2px solid var(--affine-primary-color)';
return styleMap({
position: 'absolute',
pointerEvents: 'none',
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
borderRadius: '2px',
border,
});
}
override connectedCallback() {
super.connectedCallback();
const ob = new ResizeObserver(() => {
this.rect$.value = this.computeRect();
});
this.disposables.add(
effect(() => {
this.rect$.value = this.computeRect();
})
);
const table = this.selectionController.host.querySelector('table');
if (table) {
ob.observe(table);
this.disposables.add(() => {
ob.unobserve(table);
});
}
}
override render() {
return html` <div style=${this.getSelectionStyle()}></div> `;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-table-selection-layer': SelectionLayer;
}
}
@@ -0,0 +1,118 @@
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
import { z } from 'zod';
const TableAreaSelectionSchema = z.object({
type: z.literal('area'),
rowStartIndex: z.number(),
rowEndIndex: z.number(),
columnStartIndex: z.number(),
columnEndIndex: z.number(),
});
export type TableAreaSelection = z.TypeOf<typeof TableAreaSelectionSchema>;
const TableRowSelectionSchema = z.object({
type: z.literal('row'),
rowId: z.string(),
});
const TableColumnSelectionSchema = z.object({
type: z.literal('column'),
columnId: z.string(),
});
const TableSelectionDataSchema = z.union([
TableAreaSelectionSchema,
TableRowSelectionSchema,
TableColumnSelectionSchema,
]);
export type TableSelectionData = z.TypeOf<typeof TableSelectionDataSchema>;
export const TableSelectionData = {
equals(a?: TableSelectionData, b?: TableSelectionData) {
if (a === b) {
return true;
}
if (a == null || b == null) {
return a === b;
}
if (a.type !== b.type) {
return false;
}
if (a.type === 'area' && b.type === 'area') {
return (
a.rowStartIndex === b.rowStartIndex &&
a.rowEndIndex === b.rowEndIndex &&
a.columnStartIndex === b.columnStartIndex &&
a.columnEndIndex === b.columnEndIndex
);
}
if (a.type === 'row' && b.type === 'row') {
return a.rowId === b.rowId;
}
if (a.type === 'column' && b.type === 'column') {
return a.columnId === b.columnId;
}
return false;
},
};
const TableSelectionSchema = z.object({
blockId: z.string(),
data: TableSelectionDataSchema,
});
export class TableSelection extends BaseSelection {
static override group = 'note';
static override type = 'table';
readonly data: TableSelectionData;
constructor({
blockId,
data,
}: {
blockId: string;
data: TableSelectionData;
}) {
super({
blockId,
});
this.data = data;
}
static override fromJSON(json: Record<string, unknown>): TableSelection {
TableSelectionSchema.parse(json);
return new TableSelection({
blockId: json.blockId as string,
data: json.data as TableSelectionData,
});
}
override equals(other: BaseSelection): boolean {
if (!(other instanceof TableSelection)) {
return false;
}
return this.blockId === other.blockId;
}
override toJSON(): Record<string, unknown> {
return {
type: 'table',
blockId: this.blockId,
data: this.data,
};
}
}
declare global {
namespace BlockSuite {
interface Selection {
table: typeof TableSelection;
}
}
}
export const TableSelectionExtension = SelectionExtension(TableSelection);
@@ -0,0 +1,43 @@
import { style } from '@vanilla-extract/css';
export const tableContainer = style({
display: 'block',
backgroundColor: 'var(--affine-background-primary-color)',
padding: '10px 0 18px',
overflowX: 'auto',
overflowY: 'visible',
selectors: {
'&::-webkit-scrollbar': {
height: '8px',
},
'&::-webkit-scrollbar-thumb:horizontal': {
borderRadius: '4px',
backgroundColor: 'transparent',
},
'&::-webkit-scrollbar-track:horizontal': {
backgroundColor: 'transparent',
height: '8px',
},
'&:hover::-webkit-scrollbar-thumb:horizontal': {
borderRadius: '4px',
backgroundColor: 'var(--affine-black-30)',
},
'&:hover::-webkit-scrollbar-track:horizontal': {
backgroundColor: 'var(--affine-hover-color)',
height: '8px',
},
},
});
export const tableWrapper = style({
overflow: 'visible',
display: 'flex',
flexDirection: 'row',
gap: '8px',
position: 'relative',
width: 'max-content',
});
export const table = style({});
export const rowStyle = style({});
@@ -0,0 +1,196 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import type { TableBlockModel } from '@blocksuite/affine-model';
import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
RANGE_SYNC_EXCLUDE_ATTR,
} from '@blocksuite/block-std';
import { IS_MOBILE } from '@blocksuite/global/env';
import { signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { ref } from 'lit/directives/ref.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { SelectionController } from './selection-controller';
import {
rowStyle,
table,
tableContainer,
tableWrapper,
} from './table-block.css';
import { TableDataManager } from './table-data-manager';
export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel> {
private _dataManager: TableDataManager | null = null;
get dataManager(): TableDataManager {
if (!this._dataManager) {
this._dataManager = new TableDataManager(this.model);
}
return this._dataManager;
}
selectionController = new SelectionController(this);
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override get topContenteditableElement() {
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
return this.closest<BlockComponent>(NOTE_SELECTOR);
}
return this.rootComponent;
}
private readonly virtualPaddingController: VirtualPaddingController =
new VirtualPaddingController(this);
table$ = signal<HTMLTableElement>();
private readonly getRootRect = () => {
const table = this.table$.value;
if (!table) return;
return table.getBoundingClientRect();
};
private readonly getRowRect = (rowId: string) => {
const row = this.querySelector(`tr[data-row-id="${rowId}"]`);
const rootRect = this.getRootRect();
if (!row || !rootRect) return;
const rect = row.getBoundingClientRect();
return {
top: rect.top - rootRect.top,
left: rect.left - rootRect.left,
width: rect.width,
height: rect.height,
};
};
private readonly getColumnRect = (columnId: string) => {
const columns = this.querySelectorAll(`td[data-column-id="${columnId}"]`);
const rootRect = this.getRootRect();
if (!rootRect) return;
const firstRect = columns.item(0)?.getBoundingClientRect();
const lastRect = columns.item(columns.length - 1)?.getBoundingClientRect();
if (!firstRect || !lastRect) return;
return {
top: firstRect.top - rootRect.top,
left: firstRect.left - rootRect.left,
width: firstRect.width,
height: lastRect.bottom - firstRect.top,
};
};
private readonly getAreaRect = (
rowStartIndex: number,
rowEndIndex: number,
columnStartIndex: number,
columnEndIndex: number
) => {
const rootRect = this.getRootRect();
const rows = this.querySelectorAll('tr');
const startRow = rows.item(rowStartIndex);
const endRow = rows.item(rowEndIndex);
if (!startRow || !endRow || !rootRect) return;
const columns = startRow.querySelectorAll('td');
const startColumn = columns.item(columnStartIndex);
const endColumn = columns.item(columnEndIndex);
if (!startColumn || !endColumn) return;
const startRect = startRow.getBoundingClientRect();
const endRect = endRow.getBoundingClientRect();
const startColumnRect = startColumn.getBoundingClientRect();
const endColumnRect = endColumn.getBoundingClientRect();
return {
top: startRect.top - rootRect.top,
left: startColumnRect.left - rootRect.left,
width: endColumnRect.right - startColumnRect.left,
height: endRect.bottom - startRect.top,
};
};
override renderBlock() {
const rows = this.dataManager.uiRows$.value;
const columns = this.dataManager.uiColumns$.value;
const virtualPadding = this.virtualPaddingController.virtualPadding$.value;
return html`
<div
contenteditable="false"
class=${tableContainer}
style=${styleMap({
marginLeft: `-${virtualPadding}px`,
marginRight: `-${virtualPadding}px`,
position: 'relative',
})}
>
<div
style=${styleMap({
paddingLeft: `${virtualPadding}px`,
paddingRight: `${virtualPadding}px`,
width: 'max-content',
})}
>
<table class=${tableWrapper} ${ref(this.table$)}>
<tbody class=${table}>
${repeat(
rows,
row => row.rowId,
(row, rowIndex) => {
return html`
<tr class=${rowStyle} data-row-id=${row.rowId}>
${repeat(
columns,
column => column.columnId,
(column, columnIndex) => {
const cell = this.dataManager.getCell(
row.rowId,
column.columnId
);
return html`
<affine-table-cell
style="display: contents;"
.rowIndex=${rowIndex}
.columnIndex=${columnIndex}
.row=${row}
.column=${column}
.text=${cell?.text}
.dataManager=${this.dataManager}
.selectionController=${this.selectionController}
></affine-table-cell>
`;
}
)}
</tr>
`;
}
)}
</tbody>
${IS_MOBILE
? nothing
: html`<affine-table-add-button
style="display: contents;"
.dataManager=${this.dataManager}
></affine-table-add-button>`}
${html`<affine-table-selection-layer
style="display: contents;"
.selectionController=${this.selectionController}
.getRowRect=${this.getRowRect}
.getColumnRect=${this.getColumnRect}
.getAreaRect=${this.getAreaRect}
></affine-table-selection-layer>`}
</table>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-table': TableBlockComponent;
}
}
@@ -0,0 +1,109 @@
import { cssVar, cssVarV2 } from '@blocksuite/affine-shared/theme';
import { style } from '@vanilla-extract/css';
export const cellContainerStyle = style({
position: 'relative',
alignItems: 'center',
border: '1px solid var(--affine-border-color)',
borderCollapse: 'collapse',
isolation: 'auto',
textAlign: 'start',
verticalAlign: 'top',
});
export const columnOptionsCellStyle = style({
position: 'absolute',
height: '0',
top: '0',
left: '0',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const columnOptionsStyle = style({
cursor: 'pointer',
zIndex: 2,
width: '22px',
height: '12px',
backgroundColor: cssVarV2.table.headerBackground.default,
borderRadius: '8px',
boxShadow: cssVar('buttonShadow'),
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
selectors: {
'&:hover': {
opacity: 1,
},
'&.active': {
opacity: 1,
backgroundColor: cssVarV2.table.indicator.activated,
},
},
});
export const rowOptionsCellStyle = style({
position: 'absolute',
top: '0',
left: '0',
width: '0',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
});
export const rowOptionsStyle = style({
cursor: 'pointer',
zIndex: 2,
width: '12px',
height: '22px',
backgroundColor: cssVarV2.table.headerBackground.default,
borderRadius: '8px',
boxShadow: cssVar('buttonShadow'),
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
selectors: {
'&:hover': {
opacity: 1,
},
'&.active': {
opacity: 1,
backgroundColor: cssVarV2.table.indicator.activated,
},
},
});
export const threePointerIconStyle = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2px',
});
export const threePointerIconDotStyle = style({
width: '2px',
height: '2px',
backgroundColor: cssVarV2.icon.secondary,
borderRadius: '50%',
});
export const widthDragHandleStyle = style({
position: 'absolute',
top: '-1px',
height: 'calc(100% + 2px)',
right: '-3px',
width: '5px',
backgroundColor: cssVarV2.table.indicator.activated,
cursor: 'ew-resize',
zIndex: 2,
transition: 'opacity 0.2s ease-in-out',
});
@@ -0,0 +1,691 @@
import {
menu,
popMenu,
type PopupTarget,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { TextBackgroundDuotoneIcon } from '@blocksuite/affine-components/icons';
import {
DefaultInlineManagerExtension,
type RichText,
} from '@blocksuite/affine-components/rich-text';
import type { TableColumn, TableRow } from '@blocksuite/affine-model';
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import { ShadowlessElement } from '@blocksuite/block-std';
import { IS_MAC } from '@blocksuite/global/env';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import {
ArrowDownBigIcon,
ArrowLeftBigIcon,
ArrowRightBigIcon,
ArrowUpBigIcon,
CloseIcon,
ColorPickerIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
InsertAboveIcon,
InsertBelowIcon,
InsertLeftIcon,
InsertRightIcon,
PasteIcon,
} from '@blocksuite/icons/lit';
import type { Text } from '@blocksuite/store';
import { computed, effect, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import { colorList } from './color';
import { ColumnMaxWidth, DefaultColumnWidth } from './consts';
import type { SelectionController } from './selection-controller';
import {
type TableAreaSelection,
TableSelectionData,
} from './selection-schema';
import type { TableBlockComponent } from './table-block';
import {
cellContainerStyle,
columnOptionsCellStyle,
columnOptionsStyle,
rowOptionsCellStyle,
rowOptionsStyle,
threePointerIconDotStyle,
threePointerIconStyle,
widthDragHandleStyle,
} from './table-cell.css';
import type { TableDataManager } from './table-data-manager';
export class TableCell extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
@property({ attribute: false })
accessor text: Text | undefined = undefined;
@property({ type: Boolean })
accessor readonly = false;
@property({ attribute: false })
accessor dataManager!: TableDataManager;
@query('rich-text')
accessor richText: RichText | null = null;
@property({ type: Number })
accessor rowIndex = 0;
@property({ type: Number })
accessor columnIndex = 0;
@property({ attribute: false })
accessor row: TableRow | undefined = undefined;
@property({ attribute: false })
accessor column: TableColumn | undefined = undefined;
@property({ attribute: false })
accessor selectionController!: SelectionController;
get hoverColumnIndex$() {
return this.dataManager.hoverColumnIndex$;
}
get hoverRowIndex$() {
return this.dataManager.hoverRowIndex$;
}
get inlineManager() {
return this.closest<TableBlockComponent>('affine-table')?.std.get(
DefaultInlineManagerExtension.identifier
);
}
get topContenteditableElement() {
return this.closest<TableBlockComponent>('affine-table')
?.topContenteditableElement;
}
openColumnOptions(
target: PopupTarget,
column: TableColumn,
columnIndex: number
) {
this.selectionController.setSelected({
type: 'column',
columnId: column.columnId,
});
popMenu(target, {
options: {
onClose: () => {
this.selectionController.setSelected(undefined);
},
items: [
menu.group({
items: [
menu.subMenu({
name: 'Background color',
prefix: ColorPickerIcon(),
options: {
items: [
{ name: 'Default', color: undefined },
...colorList,
].map(item =>
menu.action({
prefix: html`<div
style="color: ${item.color ??
cssVarV2.layer.background
.primary};display: flex;align-items: center;justify-content: center;"
>
${TextBackgroundDuotoneIcon}
</div>`,
name: item.name,
isSelected: column.backgroundColor === item.color,
select: () => {
this.dataManager.setColumnBackgroundColor(
column.columnId,
item.color
);
},
})
),
},
}),
...(column.backgroundColor
? [
menu.action({
name: 'Clear column style',
prefix: CloseIcon(),
select: () => {
this.dataManager.setColumnBackgroundColor(
column.columnId,
undefined
);
},
}),
]
: []),
],
}),
menu.group({
items: [
menu.action({
name: 'Insert Left',
prefix: InsertLeftIcon(),
select: () => {
this.dataManager.insertColumn(columnIndex - 1);
},
}),
menu.action({
name: 'Insert Right',
prefix: InsertRightIcon(),
select: () => {
this.dataManager.insertColumn(columnIndex + 1);
},
}),
menu.action({
name: 'Move Left',
prefix: ArrowLeftBigIcon(),
select: () => {
this.dataManager.moveColumn(columnIndex, columnIndex - 2);
},
}),
menu.action({
name: 'Move Right',
prefix: ArrowRightBigIcon(),
select: () => {
this.dataManager.moveColumn(columnIndex, columnIndex + 1);
},
}),
],
}),
menu.group({
items: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
select: () => {
this.dataManager.duplicateColumn(columnIndex);
},
}),
menu.action({
name: 'Clear column contents',
prefix: CloseIcon(),
select: () => {
this.dataManager.clearColumn(column.columnId);
},
}),
menu.action({
name: 'Delete',
class: {
'delete-item': true,
},
prefix: DeleteIcon(),
select: () => {
this.dataManager.deleteColumn(column.columnId);
},
}),
],
}),
],
},
});
}
openRowOptions(target: PopupTarget, row: TableRow, rowIndex: number) {
this.selectionController.setSelected({
type: 'row',
rowId: row.rowId,
});
popMenu(target, {
options: {
onClose: () => {
this.selectionController.setSelected(undefined);
},
items: [
menu.group({
items: [
menu.subMenu({
name: 'Background color',
prefix: ColorPickerIcon(),
options: {
items: [
{ name: 'Default', color: undefined },
...colorList,
].map(item =>
menu.action({
prefix: html`<div
style="color: ${item.color ??
cssVarV2.layer.background
.primary};display: flex;align-items: center;justify-content: center;"
>
${TextBackgroundDuotoneIcon}
</div>`,
name: item.name,
isSelected: row.backgroundColor === item.color,
select: () => {
this.dataManager.setRowBackgroundColor(
row.rowId,
item.color
);
},
})
),
},
}),
...(row.backgroundColor
? [
menu.action({
name: 'Clear row style',
prefix: CloseIcon(),
select: () => {
this.dataManager.setRowBackgroundColor(
row.rowId,
undefined
);
},
}),
]
: []),
],
}),
menu.group({
items: [
menu.action({
name: 'Insert Above',
prefix: InsertAboveIcon(),
select: () => {
this.dataManager.insertRow(rowIndex - 1);
},
}),
menu.action({
name: 'Insert Below',
prefix: InsertBelowIcon(),
select: () => {
this.dataManager.insertRow(rowIndex + 1);
},
}),
menu.action({
name: 'Move Up',
prefix: ArrowUpBigIcon(),
select: () => {
this.dataManager.moveRow(rowIndex, rowIndex - 1);
},
}),
menu.action({
name: 'Move Down',
prefix: ArrowDownBigIcon(),
select: () => {
this.dataManager.moveRow(rowIndex, rowIndex + 1);
},
}),
],
}),
menu.group({
items: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
select: () => {
this.dataManager.duplicateRow(rowIndex);
},
}),
menu.action({
name: 'Clear row contents',
prefix: CloseIcon(),
select: () => {
this.dataManager.clearRow(row.rowId);
},
}),
menu.action({
name: 'Delete',
class: {
'delete-item': true,
},
prefix: DeleteIcon(),
select: () => {
this.dataManager.deleteRow(row.rowId);
},
}),
],
}),
],
},
});
}
createColorPickerMenu(
currentColor: string | undefined,
select: (color?: string) => void
) {
return menu.subMenu({
name: 'Background color',
prefix: ColorPickerIcon(),
options: {
items: [{ name: 'Default', color: undefined }, ...colorList].map(item =>
menu.action({
prefix: html`<div
style="color: ${item.color ??
cssVarV2.layer.background
.primary};display: flex;align-items: center;justify-content: center;"
>
${TextBackgroundDuotoneIcon}
</div>`,
name: item.name,
isSelected: currentColor === item.color,
select: () => {
select(item.color);
},
})
),
},
});
}
onContextMenu(e: Event) {
e.preventDefault();
e.stopPropagation();
const selected = this.selectionController.selected$.value;
if (!selected) {
return;
}
if (selected.type === 'area' && e.currentTarget instanceof HTMLElement) {
const target = popupTargetFromElement(e.currentTarget);
popMenu(target, {
options: {
items: [
menu.group({
items: [
menu.action({
name: 'Copy',
prefix: CopyIcon(),
select: () => {
this.selectionController.doCopyOrCut(selected, false);
},
}),
menu.action({
name: 'Paste',
prefix: PasteIcon(),
select: () => {
navigator.clipboard.readText().then(text => {
this.selectionController.doPaste(text, selected);
});
},
}),
],
}),
menu.group({
items: [
menu.action({
name: 'Clear contents',
prefix: CloseIcon(),
select: () => {
this.dataManager.clearCellsBySelection(selected);
},
}),
],
}),
],
},
});
}
}
renderColumnOptions(column: TableColumn, columnIndex: number) {
const openColumnOptions = (e: Event) => {
const element = e.currentTarget;
if (element instanceof HTMLElement) {
this.openColumnOptions(
popupTargetFromElement(element),
column,
columnIndex
);
}
};
return html`<div class=${columnOptionsCellStyle}>
<div
class=${classMap({
[columnOptionsStyle]: true,
})}
style=${styleMap({
opacity: columnIndex === this.hoverColumnIndex$.value ? 1 : undefined,
})}
@click=${openColumnOptions}
>
${threePointerIcon()}
</div>
</div>`;
}
renderRowOptions(row: TableRow, rowIndex: number) {
const openRowOptions = (e: Event) => {
const element = e.currentTarget;
if (element instanceof HTMLElement) {
this.openRowOptions(popupTargetFromElement(element), row, rowIndex);
}
};
return html`<div class=${rowOptionsCellStyle}>
<div
class=${classMap({
[rowOptionsStyle]: true,
})}
style=${styleMap({
opacity: rowIndex === this.hoverRowIndex$.value ? 1 : undefined,
})}
@click=${openRowOptions}
>
${threePointerIcon(true)}
</div>
</div>`;
}
renderOptionsButton() {
if (!this.row || !this.column) {
return nothing;
}
return html`
${this.rowIndex === 0
? this.renderColumnOptions(this.column, this.columnIndex)
: nothing}
${this.columnIndex === 0
? this.renderRowOptions(this.row, this.rowIndex)
: nothing}
`;
}
tdMouseEnter(rowIndex: number, columnIndex: number) {
this.hoverColumnIndex$.value = columnIndex;
this.hoverRowIndex$.value = rowIndex;
}
tdMouseLeave() {
this.hoverColumnIndex$.value = undefined;
this.hoverRowIndex$.value = undefined;
}
virtualWidth$ = computed(() => {
const virtualWidth = this.dataManager.virtualWidth$.value;
if (!virtualWidth || this.column?.columnId !== virtualWidth.columnId) {
return undefined;
}
return virtualWidth.width;
});
tdStyle() {
const columnWidth = this.virtualWidth$.value ?? this.column?.width;
const backgroundColor =
this.column?.backgroundColor ?? this.row?.backgroundColor ?? undefined;
return styleMap({
backgroundColor,
minWidth: columnWidth ? `${columnWidth}px` : `${DefaultColumnWidth}px`,
maxWidth: columnWidth ? `${columnWidth}px` : `${ColumnMaxWidth}px`,
});
}
renderWidthDragHandle() {
const hoverColumnId$ = this.dataManager.hoverDragHandleColumnId$;
const draggingColumnId$ = this.dataManager.draggingColumnId$;
const rowIndex = this.rowIndex;
const isFirstRow = rowIndex === 0;
const isLastRow = rowIndex === this.dataManager.uiRows$.value.length - 1;
const show =
draggingColumnId$.value === this.column?.columnId ||
hoverColumnId$.value === this.column?.columnId;
return html`<div
@mouseenter=${() => {
hoverColumnId$.value = this.column?.columnId;
}}
@mouseleave=${() => {
hoverColumnId$.value = undefined;
}}
style=${styleMap({
opacity: show ? 1 : 0,
borderRadius: isFirstRow
? '3px 3px 0 0'
: isLastRow
? '0 0 3px 3px'
: '0',
})}
data-width-adjust-column-id=${this.column?.columnId}
class=${widthDragHandleStyle}
></div>`;
}
richText$ = signal<RichText>();
get inlineEditor() {
return this.richText$.value?.inlineEditor;
}
override connectedCallback() {
super.connectedCallback();
const selectAll = (e: KeyboardEvent) => {
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
e.stopPropagation();
e.preventDefault();
this.inlineEditor?.selectAll();
}
};
this.addEventListener('keydown', selectAll);
this.disposables.add(() => {
this.removeEventListener('keydown', selectAll);
});
this.disposables.addFromEvent(this, 'click', (e: MouseEvent) => {
e.stopPropagation();
requestAnimationFrame(() => {
if (!this.inlineEditor?.inlineRange$.value) {
this.inlineEditor?.focusEnd();
}
});
});
}
override firstUpdated() {
this.richText$.value?.updateComplete
.then(() => {
this.disposables.add(
effect(() => {
const richText = this.richText$.value;
if (!richText) {
return;
}
const inlineEditor = this.inlineEditor;
if (!inlineEditor) {
return;
}
const inlineRange = inlineEditor.inlineRange$.value;
const targetSelection: TableAreaSelection = {
type: 'area',
rowStartIndex: this.rowIndex,
rowEndIndex: this.rowIndex,
columnStartIndex: this.columnIndex,
columnEndIndex: this.columnIndex,
};
const currentSelection = this.selectionController.selected$.peek();
if (
inlineRange &&
!TableSelectionData.equals(targetSelection, currentSelection)
) {
this.selectionController.setSelected(targetSelection, false);
}
})
);
})
.catch(console.error);
}
override render() {
if (!this.text) {
return html`<td class=${cellContainerStyle} style=${this.tdStyle()}>
<div
style=${styleMap({
padding: '8px 12px',
})}
>
<div style="height:22px"></div>
</div>
</td>`;
}
return html`
<td
data-row-id=${this.row?.rowId}
data-column-id=${this.column?.columnId}
@mouseenter=${() => {
this.tdMouseEnter(this.rowIndex, this.columnIndex);
}}
@mouseleave=${() => {
this.tdMouseLeave();
}}
@contextmenu=${this.onContextMenu}
class=${cellContainerStyle}
style=${this.tdStyle()}
>
<rich-text
${ref(this.richText$)}
data-disable-ask-ai
data-not-block-text
style=${styleMap({
minHeight: '22px',
padding: '8px 12px',
})}
.yText="${this.text}"
.inlineEventSource="${this.topContenteditableElement}"
.attributesSchema="${this.inlineManager?.getSchema()}"
.attributeRenderer="${this.inlineManager?.getRenderer()}"
.embedChecker="${this.inlineManager?.embedChecker}"
.markdownShortcutHandler="${this.inlineManager
?.markdownShortcutHandler}"
.readonly="${this.readonly}"
.enableClipboard="${true}"
.verticalScrollContainerGetter="${() =>
this.topContenteditableElement?.host
? getViewportElement(this.topContenteditableElement.host)
: null}"
data-parent-flavour="affine:table"
></rich-text>
${this.renderOptionsButton()} ${this.renderWidthDragHandle()}
</td>
`;
}
}
const threePointerIcon = (vertical: boolean = false) => {
return html`
<div
class=${threePointerIconStyle}
style=${styleMap({
transform: vertical ? 'rotate(90deg)' : undefined,
})}
>
<div class=${threePointerIconDotStyle}></div>
<div class=${threePointerIconDotStyle}></div>
<div class=${threePointerIconDotStyle}></div>
</div>
`;
};
declare global {
interface HTMLElementTagNameMap {
'affine-table-cell': TableCell;
}
}
@@ -0,0 +1,373 @@
import type { TableBlockModel, TableCell } from '@blocksuite/affine-model';
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
import { nanoid, Text } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import type { TableAreaSelection } from './selection-schema';
export class TableDataManager {
constructor(private readonly model: TableBlockModel) {}
hoverColumnIndex$ = signal<number>();
hoverRowIndex$ = signal<number>();
hoverDragHandleColumnId$ = signal<string>();
draggingColumnId$ = signal<string>();
virtualColumnCount$ = signal<number>(0);
virtualRowCount$ = signal<number>(0);
virtualWidth$ = signal<{ columnId: string; width: number } | undefined>();
cellCountTips$ = computed(
() =>
`${this.virtualRowCount$.value + this.rows$.value.length} x ${this.virtualColumnCount$.value + this.columns$.value.length}`
);
rows$ = computed(() => {
return Object.values(this.model.rows$.value).sort((a, b) =>
a.order > b.order ? 1 : -1
);
});
columns$ = computed(() => {
return Object.values(this.model.columns$.value).sort((a, b) =>
a.order > b.order ? 1 : -1
);
});
uiRows$ = computed(() => {
const virtualRowCount = this.virtualRowCount$.value;
const rows = this.rows$.value;
if (virtualRowCount === 0) {
return rows;
}
if (virtualRowCount > 0) {
return [
...rows,
...Array.from({ length: virtualRowCount }, (_, i) => ({
rowId: `${i}`,
backgroundColor: undefined,
})),
];
}
return rows.slice(0, rows.length + virtualRowCount);
});
uiColumns$ = computed(() => {
const virtualColumnCount = this.virtualColumnCount$.value;
const columns = this.columns$.value;
if (virtualColumnCount === 0) {
return columns;
}
if (virtualColumnCount > 0) {
return [
...columns,
...Array.from({ length: virtualColumnCount }, (_, i) => ({
columnId: `${i}`,
backgroundColor: undefined,
width: undefined,
})),
];
}
return columns.slice(0, columns.length + virtualColumnCount);
});
getCell(rowId: string, columnId: string): TableCell | undefined {
return this.model.cells$.value[`${rowId}:${columnId}`];
}
addRow(after?: number) {
const order = this.getOrder(this.rows$.value, after);
const rowId = nanoid();
this.model.doc.transact(() => {
this.model.rows[rowId] = {
rowId,
order,
};
this.columns$.value.forEach(column => {
this.model.cells[`${rowId}:${column.columnId}`] = {
text: new Text(),
};
});
});
return rowId;
}
addNRow(count: number) {
if (count === 0) {
return;
}
if (count > 0) {
this.model.doc.transact(() => {
for (let i = 0; i < count; i++) {
this.addRow(this.rows$.value.length - 1);
}
});
} else {
const rows = this.rows$.value;
const rowCount = rows.length;
this.model.doc.transact(() => {
rows.slice(rowCount + count, rowCount).forEach(row => {
this.deleteRow(row.rowId);
});
});
}
}
addNColumn(count: number) {
if (count === 0) {
return;
}
if (count > 0) {
this.model.doc.transact(() => {
for (let i = 0; i < count; i++) {
this.addColumn(this.columns$.value.length - 1);
}
});
} else {
const columns = this.columns$.value;
const columnCount = columns.length;
this.model.doc.transact(() => {
columns.slice(columnCount + count, columnCount).forEach(column => {
this.deleteColumn(column.columnId);
});
});
}
}
private getOrder<T extends { order: string }>(array: T[], after?: number) {
after = after != null ? (after < 0 ? undefined : after) : undefined;
const prevOrder = after == null ? null : array[after]?.order;
const nextOrder = after == null ? array[0]?.order : array[after + 1]?.order;
const order = generateFractionalIndexingKeyBetween(
prevOrder ?? null,
nextOrder ?? null
);
return order;
}
addColumn(after?: number) {
const order = this.getOrder(this.columns$.value, after);
const columnId = nanoid();
this.model.doc.transact(() => {
this.model.columns[columnId] = {
columnId,
order,
};
this.rows$.value.forEach(row => {
this.model.cells[`${row.rowId}:${columnId}`] = {
text: new Text(),
};
});
});
return columnId;
}
deleteRow(rowId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.rows).forEach(id => {
if (id === rowId) {
delete this.model.rows[id];
}
});
Object.keys(this.model.cells).forEach(id => {
if (id.startsWith(rowId)) {
delete this.model.cells[id];
}
});
});
}
deleteColumn(columnId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.columns).forEach(id => {
if (id === columnId) {
delete this.model.columns[id];
}
});
Object.keys(this.model.cells).forEach(id => {
if (id.endsWith(`:${columnId}`)) {
delete this.model.cells[id];
}
});
});
}
updateRowOrder(rowId: string, newOrder: string) {
this.model.doc.transact(() => {
if (this.model.rows[rowId]) {
this.model.rows[rowId].order = newOrder;
}
});
}
updateColumnOrder(columnId: string, newOrder: string) {
this.model.doc.transact(() => {
if (this.model.columns[columnId]) {
this.model.columns[columnId].order = newOrder;
}
});
}
setRowBackgroundColor(rowId: string, color?: string) {
this.model.doc.transact(() => {
if (this.model.rows[rowId]) {
this.model.rows[rowId].backgroundColor = color;
}
});
}
setColumnBackgroundColor(columnId: string, color?: string) {
this.model.doc.transact(() => {
if (this.model.columns[columnId]) {
this.model.columns[columnId].backgroundColor = color;
}
});
}
setColumnWidth(columnId: string, width: number) {
this.model.doc.transact(() => {
if (this.model.columns[columnId]) {
this.model.columns[columnId].width = width;
}
});
}
clearRow(rowId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.cells).forEach(id => {
if (id.startsWith(rowId)) {
this.model.cells[id]?.text.replace(
0,
this.model.cells[id]?.text.length,
''
);
}
});
});
}
clearColumn(columnId: string) {
this.model.doc.transact(() => {
Object.keys(this.model.cells).forEach(id => {
if (id.endsWith(`:${columnId}`)) {
this.model.cells[id]?.text.replace(
0,
this.model.cells[id]?.text.length,
''
);
}
});
});
}
clearCellsBySelection(selection: TableAreaSelection) {
const columns = this.uiColumns$.value;
const rows = this.uiRows$.value;
const deleteCells: { rowId: string; columnId: string }[] = [];
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
const row = rows[i];
if (!row) {
continue;
}
for (
let j = selection.columnStartIndex;
j <= selection.columnEndIndex;
j++
) {
const column = columns[j];
if (!column) {
continue;
}
deleteCells.push({ rowId: row.rowId, columnId: column.columnId });
}
}
this.clearCells(deleteCells);
}
clearCells(cells: { rowId: string; columnId: string }[]) {
this.model.doc.transact(() => {
cells.forEach(({ rowId, columnId }) => {
const text = this.model.cells[`${rowId}:${columnId}`]?.text;
if (text) {
text.replace(0, text.length, '');
}
});
});
}
insertColumn(after?: number) {
this.addColumn(after);
}
insertRow(after?: number) {
this.addRow(after);
}
moveColumn(from: number, after?: number) {
const columns = this.columns$.value;
const column = columns[from];
if (!column) return;
const order = this.getOrder(columns, after);
this.model.doc.transact(() => {
const realColumn = this.model.columns[column.columnId];
if (realColumn) {
realColumn.order = order;
}
});
}
moveRow(from: number, after?: number) {
const rows = this.rows$.value;
const row = rows[from];
if (!row) return;
const order = this.getOrder(rows, after);
this.model.doc.transact(() => {
const realRow = this.model.rows[row.rowId];
if (realRow) {
realRow.order = order;
}
});
}
duplicateColumn(index: number) {
const oldColumn = this.columns$.value[index];
if (!oldColumn) return;
const order = this.getOrder(this.columns$.value, index);
const newColumnId = nanoid();
this.model.doc.transact(() => {
this.model.columns[newColumnId] = {
...oldColumn,
columnId: newColumnId,
order,
};
this.rows$.value.forEach(row => {
this.model.cells[`${row.rowId}:${newColumnId}`] = {
text:
this.model.cells[
`${row.rowId}:${oldColumn.columnId}`
]?.text.clone() ?? new Text(),
};
});
});
return newColumnId;
}
duplicateRow(index: number) {
const oldRow = this.rows$.value[index];
if (!oldRow) return;
const order = this.getOrder(this.rows$.value, index);
const newRowId = nanoid();
this.model.doc.transact(() => {
this.model.rows[newRowId] = {
...oldRow,
rowId: newRowId,
order,
};
this.columns$.value.forEach(column => {
this.model.cells[`${newRowId}:${column.columnId}`] = {
text:
this.model.cells[
`${oldRow.rowId}:${column.columnId}`
]?.text.clone() ?? new Text(),
};
});
});
return newRowId;
}
}
@@ -0,0 +1,18 @@
import { TableModelFlavour } from '@blocksuite/affine-model';
import {
BlockViewExtension,
CommandExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { TableBlockAdapterExtensions } from './adapters/extension.js';
import { tableCommands } from './commands.js';
export const TableBlockSpec: ExtensionType[] = [
FlavourExtension(TableModelFlavour),
CommandExtension(tableCommands),
BlockViewExtension(TableModelFlavour, literal`affine-table`),
TableBlockAdapterExtensions,
].flat();
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
"noUncheckedIndexedAccess": true
},
"include": ["./src"],
"references": [
{ "path": "../components" },
{ "path": "../model" },
{ "path": "../shared" },
{ "path": "../../framework/block-std" },
{ "path": "../data-view" },
{ "path": "../../framework/global" },
{ "path": "../../framework/store" }
]
}
@@ -1,4 +1,6 @@
// corresponding to `formatText` command
import { TableModelFlavour } from '@blocksuite/affine-model';
export const FORMAT_TEXT_SUPPORT_FLAVOURS = [
'affine:paragraph',
'affine:list',
@@ -11,4 +13,7 @@ export const FORMAT_BLOCK_SUPPORT_FLAVOURS = [
'affine:code',
];
// corresponding to `formatNative` command
export const FORMAT_NATIVE_SUPPORT_FLAVOURS = ['affine:database'];
export const FORMAT_NATIVE_SUPPORT_FLAVOURS = [
'affine:database',
TableModelFlavour,
];
@@ -13,3 +13,4 @@ export * from './note/index.js';
export * from './paragraph/index.js';
export * from './root/index.js';
export * from './surface-ref/index.js';
export * from './table';
@@ -47,6 +47,7 @@ export const NoteZodSchema = z
},
},
});
import { TableModelFlavour } from '../table';
export const NoteBlockSchema = defineBlockSchema({
flavour: 'affine:note',
@@ -83,6 +84,7 @@ export const NoteBlockSchema = defineBlockSchema({
'affine:surface-ref',
'affine:embed-*',
'affine:latex',
TableModelFlavour,
],
},
toModel: () => {
@@ -0,0 +1 @@
export * from './table-model';
@@ -0,0 +1,65 @@
import type { DeltaInsert } from '@blocksuite/inline';
import type { Text } from '@blocksuite/store';
import { BlockModel, defineBlockSchema } from '@blocksuite/store';
export type TableCell = {
text: Text;
};
export interface TableRow {
rowId: string;
order: string;
backgroundColor?: string;
}
export interface TableColumn {
columnId: string;
order: string;
backgroundColor?: string;
width?: number;
}
export interface TableBlockProps {
rows: Record<string, TableRow>;
columns: Record<string, TableColumn>;
// key = `${rowId}:${columnId}`
cells: Record<string, TableCell>;
}
export interface TableCellSerialized {
text: {
delta: DeltaInsert[];
};
}
export interface TableBlockPropsSerialized {
rows: Record<string, TableRow>;
columns: Record<string, TableColumn>;
cells: Record<string, TableCellSerialized>;
}
export class TableBlockModel extends BlockModel<TableBlockProps> {}
export const TableModelFlavour = 'affine:table-test1-flavour';
export const TableBlockSchema = defineBlockSchema({
flavour: TableModelFlavour,
props: (): TableBlockProps => ({
rows: {},
columns: {},
cells: {},
}),
metadata: {
role: 'content',
version: 1,
parent: ['affine:note'],
children: [],
},
toModel: () => new TableBlockModel(),
});
declare global {
namespace BlockSuite {
interface BlockModels {
[TableModelFlavour]: TableBlockModel;
}
}
}
+1
View File
@@ -25,6 +25,7 @@
"@toeverything/theme": "^1.1.7",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
"lodash.clonedeep": "^4.5.0",
"lodash.mergewith": "^4.6.2",
@@ -14,7 +14,7 @@ import {
combinedLightCssVariables,
} from '@toeverything/theme';
import { isInsideEdgelessEditor } from '../utils/index.js';
import { isInsideEdgelessEditor } from '../utils/dom';
export const ThemeExtensionIdentifier = createIdentifier<ThemeExtension>(
'AffineThemeExtension'
@@ -6,9 +6,10 @@ import {
type AffineTheme,
cssVar,
} from '@toeverything/theme';
export { cssVar } from '@toeverything/theme';
import { type AffineThemeKeyV2, cssVarV2 } from '@toeverything/theme/v2';
import { unsafeCSS } from 'lit';
export { cssVarV2 } from '@toeverything/theme/v2';
export const ColorVariables = [
'--affine-brand-color',
'--affine-primary-color',
@@ -0,0 +1,82 @@
type OffsetList = number[];
type CellOffsets = {
rows: OffsetList;
columns: OffsetList;
};
export const domToOffsets = (
element: HTMLElement,
rowSelector: string,
cellSelector: string
): CellOffsets | undefined => {
const rowDoms = Array.from(element.querySelectorAll(rowSelector));
const firstRowDom = rowDoms[0];
if (!firstRowDom) return;
const columnDoms = Array.from(firstRowDom.querySelectorAll(cellSelector));
const rows: OffsetList = [];
const columns: OffsetList = [];
for (let i = 0; i < rowDoms.length; i++) {
const rect = rowDoms[i].getBoundingClientRect();
if (!rect) continue;
if (i === 0) {
rows.push(rect.top);
}
rows.push(rect.bottom);
}
for (let i = 0; i < columnDoms.length; i++) {
const rect = columnDoms[i].getBoundingClientRect();
if (!rect) continue;
if (i === 0) {
columns.push(rect.left);
}
columns.push(rect.right);
}
return {
rows,
columns,
};
};
export const getIndexByPosition = (
positions: OffsetList,
offset: number,
reverse = false
) => {
if (reverse) {
return positions.slice(1).findIndex(p => offset <= p);
}
return positions.slice(0, -1).findLastIndex(p => offset >= p);
};
export const getRangeByPositions = (
positions: OffsetList,
start: number,
end: number
) => {
const startIndex = getIndexByPosition(positions, start, true);
const endIndex = getIndexByPosition(positions, end);
return {
start: startIndex,
end: endIndex,
};
};
export const getAreaByOffsets = (
offsets: CellOffsets,
top: number,
bottom: number,
left: number,
right: number
) => {
const { rows, columns } = offsets;
const startRow = getIndexByPosition(rows, top, true);
const endRow = getIndexByPosition(rows, bottom);
const startColumn = getIndexByPosition(columns, left, true);
const endColumn = getIndexByPosition(columns, right);
return {
top: startRow,
bottom: endRow,
left: startColumn,
right: endColumn,
};
};
@@ -0,0 +1,70 @@
import { generateKeyBetween } from 'fractional-indexing';
/**
* generate a key between a and b, the result key is always satisfied with a < result < b.
* the key always has a random suffix, so there is no need to worry about collision.
*
* make sure a and b are generated by this function.
*
* @param customPostfix custom postfix for the key, only letters and numbers are allowed
*/
export function generateFractionalIndexingKeyBetween(
a: string | null,
b: string | null
) {
const randomSize = 32;
function postfix(length: number = randomSize) {
const chars =
'123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const values = new Uint8Array(length);
crypto.getRandomValues(values);
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(values[i] % chars.length);
}
return result;
}
if (a !== null && b !== null && a >= b) {
throw new Error('a should be smaller than b');
}
// get the subkey in full key
// e.g.
// a0xxxx -> a
// a0x0xxxx -> a0x
function subkey(key: string | null) {
if (key === null) {
return null;
}
if (key.length <= randomSize + 1) {
// no subkey
return key;
}
const splitAt = key.substring(0, key.length - randomSize - 1);
return splitAt;
}
const aSubkey = subkey(a);
const bSubkey = subkey(b);
if (aSubkey === null && bSubkey === null) {
// generate a new key
return generateKeyBetween(null, null) + '0' + postfix();
} else if (aSubkey === null && bSubkey !== null) {
// generate a key before b
return generateKeyBetween(null, bSubkey) + '0' + postfix();
} else if (bSubkey === null && aSubkey !== null) {
// generate a key after a
return generateKeyBetween(aSubkey, null) + '0' + postfix();
} else if (aSubkey !== null && bSubkey !== null) {
// generate a key between a and b
if (aSubkey === bSubkey && a !== null && b !== null) {
// conflict, if the subkeys are the same, generate a key between fullkeys
return generateKeyBetween(a, b) + '0' + postfix();
} else {
return generateKeyBetween(aSubkey, bSubkey) + '0' + postfix();
}
}
throw new Error('Never reach here');
}
@@ -1,11 +1,13 @@
export * from './auto-scroll';
export * from './button-popper';
export * from './cell-select';
export * from './collapsed';
export * from './dnd';
export * from './dom';
export * from './edgeless';
export * from './event';
export * from './file';
export * from './fractional-indexing';
export * from './insert';
export * from './is-abort-error';
export * from './math';
@@ -18,4 +20,5 @@ export * from './spec';
export * from './string';
export * from './title';
export * from './url';
export * from './virtual-padding';
export * from './zod-schema';
@@ -0,0 +1,35 @@
import type { BlockComponent } from '@blocksuite/block-std';
import { autoUpdate } from '@floating-ui/dom';
import { signal } from '@preact/signals-core';
import type { ReactiveController } from 'lit';
import { DocModeProvider } from '../services/doc-mode-service';
export class VirtualPaddingController implements ReactiveController {
public readonly virtualPadding$ = signal(0);
constructor(private readonly block: BlockComponent) {
block.addController(this);
}
get std() {
return this.host.std;
}
get host() {
return this.block.host;
}
hostConnected(): void {
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
return;
}
this.block.disposables.add(
autoUpdate(this.host, this.block, () => {
const padding =
this.block.getBoundingClientRect().left -
this.host.getBoundingClientRect().left;
this.virtualPadding$.value = Math.max(0, padding - 72);
})
);
}
}
+1
View File
@@ -30,6 +30,7 @@
"@blocksuite/affine-block-paragraph": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-block-surface-ref": "workspace:*",
"@blocksuite/affine-block-table": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
@@ -2,7 +2,11 @@ import {
InlineDeltaToMarkdownAdapterExtensions,
MarkdownInlineToDeltaAdapterExtensions,
} from '@blocksuite/affine-components/rich-text';
import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model';
import {
DefaultTheme,
NoteDisplayMode,
TableModelFlavour,
} from '@blocksuite/affine-model';
import { MarkdownAdapter } from '@blocksuite/affine-shared/adapters';
import { Container } from '@blocksuite/global/di';
import type {
@@ -3529,91 +3533,76 @@ bbb
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
background: {
dark: '#000000',
light: '#ffffff',
},
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
displayMode: 'both',
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:database',
flavour: TableModelFlavour,
props: {
views: [
{
id: 'matchesReplaceMap[2]',
name: 'Table View',
mode: 'table',
columns: [],
filter: {
type: 'group',
op: 'and',
conditions: [],
},
header: {
titleColumn: 'matchesReplaceMap[9]',
iconColumn: 'type',
},
columns: {
'matchesReplaceMap[3]': {
columnId: 'matchesReplaceMap[3]',
order: 'matchesReplaceMap[4]',
},
'matchesReplaceMap[6]': {
columnId: 'matchesReplaceMap[6]',
order: 'matchesReplaceMap[7]',
},
'matchesReplaceMap[9]': {
columnId: 'matchesReplaceMap[9]',
order: 'matchesReplaceMap[10]',
},
},
rows: {
'matchesReplaceMap[12]': {
rowId: 'matchesReplaceMap[12]',
order: 'matchesReplaceMap[13]',
},
'matchesReplaceMap[15]': {
rowId: 'matchesReplaceMap[15]',
order: 'matchesReplaceMap[16]',
},
],
title: {
'$blocksuite:internal:text$': true,
delta: [],
},
cells: {
'matchesReplaceMap[12]': {
'matchesReplaceMap[10]': {
columnId: 'matchesReplaceMap[10]',
value: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'eee',
},
],
},
},
'matchesReplaceMap[11]': {
columnId: 'matchesReplaceMap[11]',
value: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'fff',
},
],
},
'matchesReplaceMap[17]': {
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'aaa',
},
],
},
},
},
columns: [
{
type: 'title',
name: 'aaa',
data: {},
id: 'matchesReplaceMap[9]',
'matchesReplaceMap[18]': {
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'bbb',
},
],
},
},
{
type: 'rich-text',
name: 'bbb',
data: {},
id: 'matchesReplaceMap[10]',
'matchesReplaceMap[19]': {
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'ccc',
},
],
},
},
{
type: 'rich-text',
name: 'ccc',
data: {},
id: 'matchesReplaceMap[11]',
},
],
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[12]',
flavour: 'affine:paragraph',
props: {
'matchesReplaceMap[20]': {
text: {
'$blocksuite:internal:text$': true,
delta: [
@@ -3622,11 +3611,30 @@ bbb
},
],
},
type: 'text',
},
children: [],
'matchesReplaceMap[21]': {
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'eee',
},
],
},
},
'matchesReplaceMap[22]': {
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'fff',
},
],
},
},
},
],
},
children: [],
},
],
};
@@ -13,6 +13,7 @@ import {
import { ImageBlockHtmlAdapterExtension } from '@blocksuite/affine-block-image';
import { ListBlockHtmlAdapterExtension } from '@blocksuite/affine-block-list';
import { ParagraphBlockHtmlAdapterExtension } from '@blocksuite/affine-block-paragraph';
import { TableBlockHtmlAdapterExtension } from '@blocksuite/affine-block-table';
import { RootBlockHtmlAdapterExtension } from '../../../root-block/adapters/html.js';
@@ -29,6 +30,7 @@ export const defaultBlockHtmlAdapterMatchers = [
EmbedGithubBlockHtmlAdapterExtension,
BookmarkBlockHtmlAdapterExtension,
DatabaseBlockHtmlAdapterExtension,
TableBlockHtmlAdapterExtension,
EmbedLinkedDocHtmlAdapterExtension,
EmbedSyncedDocBlockHtmlAdapterExtension,
];
@@ -15,6 +15,7 @@ import { LatexBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-lat
import { ListBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-list';
import { DocNoteBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-note';
import { ParagraphBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-paragraph';
import { TableBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-table';
import { RootBlockMarkdownAdapterExtension } from '../../../root-block/adapters/markdown.js';
@@ -32,6 +33,7 @@ export const defaultBlockMarkdownAdapterMatchers = [
BookmarkBlockMarkdownAdapterExtension,
CodeBlockMarkdownAdapterExtension,
DatabaseBlockMarkdownAdapterExtension,
TableBlockMarkdownAdapterExtension,
DividerBlockMarkdownAdapterExtension,
ImageBlockMarkdownAdapterExtension,
LatexBlockMarkdownAdapterExtension,
@@ -18,7 +18,6 @@ export class TestUtils {
export function nanoidReplacement(snapshot: BlockSnapshot | SliceSnapshot) {
return JSON.parse(nanoidReplacementString(JSON.stringify(snapshot)));
}
const escapedSnapshotAttributes = new Set([
'"attributes"',
'"conditions"',
@@ -31,7 +30,7 @@ const escapedSnapshotAttributes = new Set([
function nanoidReplacementString(snapshotString: string) {
const re =
/("block:[A-Za-z0-9-_]{10}")|("[A-Za-z0-9-_]{10}")|("var\(--affine-v2-chip-label-[a-z]{3,10}\)")|("[A-Za-z0-9-_=]{44}")/g;
/("block:[A-Za-z0-9-_]{10}")|("[A-Za-z0-9-_]{10}")|("[A-Za-z0-9-_]{35}")|("[A-Za-z0-9-_]{10}:[A-Za-z0-9-_]{10}")|("var\(--affine-v2-chip-label-[a-z]{3,10}\)")|("[A-Za-z0-9-_=]{44}")/g;
const matches = snapshotString.matchAll(re);
const matchesReplaceMap = new Map();
let escapedNumber = 0;
+6
View File
@@ -26,6 +26,10 @@ import {
EdgelessSurfaceRefBlockSpec,
PageSurfaceRefBlockSpec,
} from '@blocksuite/affine-block-surface-ref';
import {
TableBlockSpec,
TableSelectionExtension,
} from '@blocksuite/affine-block-table';
import {
RefNodeSlotsExtension,
RichTextExtensions,
@@ -60,6 +64,7 @@ export const CommonBlockSpecs: ExtensionType[] = [
LatexBlockSpec,
ListBlockSpec,
DatabaseBlockSpec,
TableBlockSpec,
DataViewBlockSpec,
DividerBlockSpec,
BookmarkBlockSpec,
@@ -100,5 +105,6 @@ export const StoreExtensions: ExtensionType[] = [
HighlightSelectionExtension,
ImageSelectionExtension,
DatabaseSelectionExtension,
TableSelectionExtension,
LinkPreviewerService,
];
+2
View File
@@ -14,6 +14,7 @@ import { effects as blockNoteEffects } from '@blocksuite/affine-block-note/effec
import { effects as blockParagraphEffects } from '@blocksuite/affine-block-paragraph/effects';
import { effects as blockSurfaceEffects } from '@blocksuite/affine-block-surface/effects';
import { effects as blockSurfaceRefEffects } from '@blocksuite/affine-block-surface-ref/effects';
import { effects as blockTableEffects } from '@blocksuite/affine-block-table/effects';
import { effects as componentAiItemEffects } from '@blocksuite/affine-components/ai-item';
import { BlockSelection } from '@blocksuite/affine-components/block-selection';
import { BlockZeroWidth } from '@blocksuite/affine-components/block-zero-width';
@@ -204,6 +205,7 @@ export function effects() {
blockDividerEffects();
blockDataViewEffects();
blockCodeEffects();
blockTableEffects();
componentCaptionEffects();
componentContextMenuEffects();
+1
View File
@@ -48,6 +48,7 @@ export * from '@blocksuite/affine-block-note';
export * from '@blocksuite/affine-block-paragraph';
export * from '@blocksuite/affine-block-surface';
export * from '@blocksuite/affine-block-surface-ref';
export * from '@blocksuite/affine-block-table';
export {
type AIError,
type AIItemConfig,
@@ -439,6 +439,8 @@ export const BUILT_IN_GROUPS: MenuItemGroup<FormatBarContext>[] = [
];
export function toolbarMoreButton(toolbar: AffineFormatBarWidget) {
const richText = getRichText();
if (richText?.dataset.disableAskAi !== undefined) return null;
const context = new FormatBarContext(toolbar);
const actions = renderGroups(toolbar.moreGroups, context);
@@ -455,3 +457,15 @@ export function toolbarMoreButton(toolbar: AffineFormatBarWidget) {
</editor-menu-button>
`;
}
const getRichText = () => {
const selection = getSelection();
if (!selection) return null;
if (selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const commonAncestorContainer =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!commonAncestorContainer) return null;
return commonAncestorContainer.closest('rich-text');
};
@@ -1,7 +1,8 @@
import { DatabaseSelection } from '@blocksuite/affine-block-database';
import { HoverController } from '@blocksuite/affine-components/hover';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import { isFormatSupported } from '@blocksuite/affine-components/rich-text';
import {
isFormatSupported,
type RichText,
} from '@blocksuite/affine-components/rich-text';
import {
cloneGroups,
getMoreMenuConfig,
@@ -205,34 +206,20 @@ export class AffineFormatBarWidget extends WidgetComponent {
);
this.disposables.addFromEvent(document, 'selectionchange', () => {
if (!this.host.event.active) return;
const databaseSelection = this.host.selection.find(DatabaseSelection);
if (!databaseSelection) {
return;
}
const reset = () => {
this.reset();
this.requestUpdate();
};
const viewSelection = databaseSelection.viewSelection;
// check table selection
if (
viewSelection.type === 'table' &&
(viewSelection.selectionType !== 'area' || !viewSelection.isEditing)
)
return reset();
// check kanban selection
if (
(viewSelection.type === 'kanban' &&
viewSelection.selectionType !== 'cell') ||
!viewSelection.isEditing
)
return reset();
const range = this.nativeRange;
if (!range || range.collapsed) return reset();
if (!range) return;
const container =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!container) return;
const notBlockText = container.closest('rich-text')?.dataset.notBlockText;
if (notBlockText == null) return;
if (range.collapsed) return reset();
this._displayType = 'native';
this.requestUpdate();
});
@@ -551,12 +538,16 @@ export class AffineFormatBarWidget extends WidgetComponent {
}
const items = ConfigRenderer(this);
const moreButton = toolbarMoreButton(this);
return html`
<editor-toolbar class="${AFFINE_FORMAT_BAR_WIDGET}">
${items}
<editor-toolbar-separator></editor-toolbar-separator>
${toolbarMoreButton(this)}
${moreButton
? html`
<editor-toolbar-separator></editor-toolbar-separator>
${moreButton}
`
: nothing}
</editor-toolbar>
`;
}
@@ -246,6 +246,23 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
// ---------------------------------------------------------
{ groupName: 'Content & Media' },
{
name: 'Table',
description: 'Create a table block.',
icon: DatabaseTableViewIcon20,
tooltip: slashMenuToolTips['Table View'],
showWhen: ({ model }) => !insideEdgelessText(model),
action: ({ rootComponent }) => {
rootComponent.std.command
.chain()
.getSelectedModels()
.insertTableBlock({
place: 'after',
removeEmptyLine: true,
})
.run();
},
},
{
name: 'Image',
description: 'Insert an image.',
+2
View File
@@ -23,6 +23,7 @@ import {
ParagraphBlockSchema,
RootBlockSchema,
SurfaceRefBlockSchema,
TableBlockSchema,
} from '@blocksuite/affine-model';
import type { BlockSchema } from '@blocksuite/store';
import type { z } from 'zod';
@@ -52,4 +53,5 @@ export const AffineSchemas: z.infer<typeof BlockSchema>[] = [
EmbedLoomBlockSchema,
EdgelessTextBlockSchema,
LatexBlockSchema,
TableBlockSchema,
];
+1
View File
@@ -23,6 +23,7 @@
{ "path": "../affine/block-paragraph" },
{ "path": "../affine/block-surface" },
{ "path": "../affine/block-surface-ref" },
{ "path": "../affine/block-table" },
{ "path": "../affine/components" },
{ "path": "../affine/model" },
{ "path": "../affine/shared" },
@@ -1767,7 +1767,7 @@ test('unexpected scroll when clicking padding area', async ({ page }) => {
expect(listRect).toEqual(newListRect);
await pressArrowUp(page, 4);
await type(page, '/table\n');
await type(page, '/tableview\n');
const database = page.locator('affine-database');
const databaseRect = await database.boundingBox();
assertExists(databaseRect);
@@ -15,6 +15,8 @@ export function setupFormatBarAIEntry(formatBar: AffineFormatBarWidget) {
{
type: 'custom' as const,
render(formatBar: AffineFormatBarWidget): TemplateResult | null {
const richText = getRichText();
if (richText?.dataset.disableAskAi !== undefined) return null;
return html`
<ask-ai-toolbar-button
.host=${formatBar.host}
@@ -28,3 +30,15 @@ export function setupFormatBarAIEntry(formatBar: AffineFormatBarWidget) {
0
);
}
const getRichText = () => {
const selection = getSelection();
if (!selection) return null;
if (selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const commonAncestorContainer =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!commonAncestorContainer) return null;
return commonAncestorContainer.closest('rich-text');
};
@@ -22,6 +22,7 @@ import {
ParagraphBlockSpec,
RefNodeSlotsExtension,
RichTextExtensions,
TableBlockSpec,
} from '@blocksuite/affine/blocks';
import type { ExtensionType } from '@blocksuite/affine/store';
@@ -32,6 +33,7 @@ const CommonBlockSpecs: ExtensionType[] = [
LatexBlockSpec,
ListBlockSpec,
DatabaseBlockSpec,
TableBlockSpec,
DataViewBlockSpec,
DividerBlockSpec,
EmbedExtensions,
+15
View File
@@ -236,6 +236,19 @@ export const PackageList = [
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/block-table',
name: '@blocksuite/affine-block-table',
workspaceDependencies: [
'blocksuite/affine/components',
'blocksuite/affine/model',
'blocksuite/affine/shared',
'blocksuite/framework/block-std',
'blocksuite/affine/data-view',
'blocksuite/framework/global',
'blocksuite/framework/store',
],
},
{
location: 'blocksuite/affine/components',
name: '@blocksuite/affine-components',
@@ -350,6 +363,7 @@ export const PackageList = [
'blocksuite/affine/block-paragraph',
'blocksuite/affine/block-surface',
'blocksuite/affine/block-surface-ref',
'blocksuite/affine/block-table',
'blocksuite/affine/components',
'blocksuite/affine/model',
'blocksuite/affine/shared',
@@ -726,6 +740,7 @@ export type PackageName =
| '@blocksuite/affine-block-paragraph'
| '@blocksuite/affine-block-surface'
| '@blocksuite/affine-block-surface-ref'
| '@blocksuite/affine-block-table'
| '@blocksuite/affine-components'
| '@blocksuite/data-view'
| '@blocksuite/affine-model'
+1
View File
@@ -67,6 +67,7 @@
{ "path": "./blocksuite/affine/block-paragraph" },
{ "path": "./blocksuite/affine/block-surface" },
{ "path": "./blocksuite/affine/block-surface-ref" },
{ "path": "./blocksuite/affine/block-table" },
{ "path": "./blocksuite/affine/components" },
{ "path": "./blocksuite/affine/data-view" },
{ "path": "./blocksuite/affine/model" },
+25 -1
View File
@@ -3647,6 +3647,28 @@ __metadata:
languageName: unknown
linkType: soft
"@blocksuite/affine-block-table@workspace:*, @blocksuite/affine-block-table@workspace:blocksuite/affine/block-table":
version: 0.0.0-use.local
resolution: "@blocksuite/affine-block-table@workspace:blocksuite/affine/block-table"
dependencies:
"@atlaskit/pragmatic-drag-and-drop": "npm:^1.4.0"
"@blocksuite/affine-components": "workspace:*"
"@blocksuite/affine-model": "workspace:*"
"@blocksuite/affine-shared": "workspace:*"
"@blocksuite/block-std": "workspace:*"
"@blocksuite/data-view": "workspace:*"
"@blocksuite/global": "workspace:*"
"@blocksuite/icons": "npm:^2.2.1"
"@blocksuite/store": "workspace:*"
"@floating-ui/dom": "npm:^1.6.10"
"@preact/signals-core": "npm:^1.8.0"
"@vanilla-extract/css": "npm:^1.14.0"
lit: "npm:^3.2.0"
yjs: "npm:^13.6.21"
zod: "npm:^3.24.1"
languageName: unknown
linkType: soft
"@blocksuite/affine-components@workspace:*, @blocksuite/affine-components@workspace:blocksuite/affine/components":
version: 0.0.0-use.local
resolution: "@blocksuite/affine-components@workspace:blocksuite/affine/components"
@@ -3713,6 +3735,7 @@ __metadata:
"@types/lodash.clonedeep": "npm:^4.5.9"
"@types/lodash.mergewith": "npm:^4"
"@types/mdast": "npm:^4.0.4"
fractional-indexing: "npm:^3.2.0"
lit: "npm:^3.2.0"
lodash.clonedeep: "npm:^4.5.0"
lodash.mergewith: "npm:^4.6.2"
@@ -3873,6 +3896,7 @@ __metadata:
"@blocksuite/affine-block-paragraph": "workspace:*"
"@blocksuite/affine-block-surface": "workspace:*"
"@blocksuite/affine-block-surface-ref": "workspace:*"
"@blocksuite/affine-block-table": "workspace:*"
"@blocksuite/affine-components": "workspace:*"
"@blocksuite/affine-model": "workspace:*"
"@blocksuite/affine-shared": "workspace:*"
@@ -15931,7 +15955,7 @@ __metadata:
languageName: node
linkType: hard
"@vanilla-extract/css@npm:^1.16.1, @vanilla-extract/css@npm:^1.17.0":
"@vanilla-extract/css@npm:^1.14.0, @vanilla-extract/css@npm:^1.16.1, @vanilla-extract/css@npm:^1.17.0":
version: 1.17.0
resolution: "@vanilla-extract/css@npm:1.17.0"
dependencies: