diff --git a/blocksuite/affine/block-database/src/adapters/html.ts b/blocksuite/affine/block-database/src/adapters/html.ts index 012a28fa6b..9a000406a3 100644 --- a/blocksuite/affine/block-database/src/adapters/html.ts +++ b/blocksuite/affine/block-database/src/adapters/html.ts @@ -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 - ).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; diff --git a/blocksuite/affine/block-database/src/adapters/markdown.ts b/blocksuite/affine/block-database/src/adapters/markdown.ts index f94c80a84e..ec059a73e7 100644 --- a/blocksuite/affine/block-database/src/adapters/markdown.ts +++ b/blocksuite/affine/block-database/src/adapters/markdown.ts @@ -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 - ).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; diff --git a/blocksuite/affine/block-database/src/database-block.ts b/blocksuite/affine/block-database/src/database-block.ts index f14c0c5c93..226d61ffad 100644 --- a/blocksuite/affine/block-database/src/database-block.ts +++ b/blocksuite/affine/block-database/src/database-block.ts @@ -334,11 +334,6 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent { + 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, + 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 +); diff --git a/blocksuite/affine/block-table/src/adapters/index.ts b/blocksuite/affine/block-table/src/adapters/index.ts new file mode 100644 index 0000000000..9c9650bbf3 --- /dev/null +++ b/blocksuite/affine/block-table/src/adapters/index.ts @@ -0,0 +1,4 @@ +export * from './html'; +export * from './markdown'; +export * from './notion-html'; +export * from './plain-text'; diff --git a/blocksuite/affine/block-table/src/adapters/markdown.ts b/blocksuite/affine/block-table/src/adapters/markdown.ts new file mode 100644 index 0000000000..d5c981be43 --- /dev/null +++ b/blocksuite/affine/block-table/src/adapters/markdown.ts @@ -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 +); diff --git a/blocksuite/affine/block-table/src/adapters/notion-html.ts b/blocksuite/affine/block-table/src/adapters/notion-html.ts new file mode 100644 index 0000000000..c16e4a6179 --- /dev/null +++ b/blocksuite/affine/block-table/src/adapters/notion-html.ts @@ -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); diff --git a/blocksuite/affine/block-table/src/adapters/plain-text.ts b/blocksuite/affine/block-table/src/adapters/plain-text.ts new file mode 100644 index 0000000000..47f005d4dd --- /dev/null +++ b/blocksuite/affine/block-table/src/adapters/plain-text.ts @@ -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); diff --git a/blocksuite/affine/block-table/src/adapters/utils.ts b/blocksuite/affine/block-table/src/adapters/utils.ts new file mode 100644 index 0000000000..43262a386b --- /dev/null +++ b/blocksuite/affine/block-table/src/adapters/utils.ts @@ -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, + rows: Record, + cells: Record +): 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 = {}; + 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); +}; diff --git a/blocksuite/affine/block-table/src/add-button.css.ts b/blocksuite/affine/block-table/src/add-button.css.ts new file mode 100644 index 0000000000..67e7768e3e --- /dev/null +++ b/blocksuite/affine/block-table/src/add-button.css.ts @@ -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', +}); diff --git a/blocksuite/affine/block-table/src/add-button.ts b/blocksuite/affine/block-table/src/add-button.ts new file mode 100644 index 0000000000..eb3fc579c0 --- /dev/null +++ b/blocksuite/affine/block-table/src/add-button.ts @@ -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(); + addRowButtonRef$ = signal(); + addRowColumnButtonRef$ = signal(); + + 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`
+ ${PlusIcon()} +
`; + } + + renderAddRowButton() { + const hovered = this.hoverRowIndex$.value === this.rows$.value.length - 1; + const dragging = this.rowDragging$.value; + return html`
+ ${PlusIcon()} +
`; + } + + 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`
+ ${PlusIcon()} +
`; + } + + override render() { + return html` + ${this.renderAddColumnButton()} ${this.renderAddRowButton()} + ${this.renderAddRowColumnButton()} + `; + } +} +declare global { + interface HTMLElementTagNameMap { + 'affine-table-add-button': AddButton; + } +} diff --git a/blocksuite/affine/block-table/src/color.ts b/blocksuite/affine/block-table/src/color.ts new file mode 100644 index 0000000000..3dc70783e8 --- /dev/null +++ b/blocksuite/affine/block-table/src/color.ts @@ -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; +}; diff --git a/blocksuite/affine/block-table/src/commands.ts b/blocksuite/affine/block-table/src/commands.ts new file mode 100644 index 0000000000..aa7c7cc3de --- /dev/null +++ b/blocksuite/affine/block-table/src/commands.ts @@ -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, +}; diff --git a/blocksuite/affine/block-table/src/consts.ts b/blocksuite/affine/block-table/src/consts.ts new file mode 100644 index 0000000000..d35491d967 --- /dev/null +++ b/blocksuite/affine/block-table/src/consts.ts @@ -0,0 +1,4 @@ +export const ColumnMinWidth = 60; +export const ColumnMaxWidth = 240; +export const DefaultColumnWidth = 120; +export const DefaultRowHeight = 39; diff --git a/blocksuite/affine/block-table/src/effects.ts b/blocksuite/affine/block-table/src/effects.ts new file mode 100644 index 0000000000..f3eb0bfe14 --- /dev/null +++ b/blocksuite/affine/block-table/src/effects.ts @@ -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; + } + } +} diff --git a/blocksuite/affine/block-table/src/index.ts b/blocksuite/affine/block-table/src/index.ts new file mode 100644 index 0000000000..c8e53e5683 --- /dev/null +++ b/blocksuite/affine/block-table/src/index.ts @@ -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'; diff --git a/blocksuite/affine/block-table/src/selection-controller.ts b/blocksuite/affine/block-table/src/selection-controller.ts new file mode 100644 index 0000000000..7918e42ff9 --- /dev/null +++ b/blocksuite/affine/block-table/src/selection-controller.ts @@ -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; + } +} diff --git a/blocksuite/affine/block-table/src/selection-layer.ts b/blocksuite/affine/block-table/src/selection-layer.ts new file mode 100644 index 0000000000..a93f0badf3 --- /dev/null +++ b/blocksuite/affine/block-table/src/selection-layer.ts @@ -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(); + + 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`
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-table-selection-layer': SelectionLayer; + } +} diff --git a/blocksuite/affine/block-table/src/selection-schema.ts b/blocksuite/affine/block-table/src/selection-schema.ts new file mode 100644 index 0000000000..134a414546 --- /dev/null +++ b/blocksuite/affine/block-table/src/selection-schema.ts @@ -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; + +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; +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): 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 { + return { + type: 'table', + blockId: this.blockId, + data: this.data, + }; + } +} + +declare global { + namespace BlockSuite { + interface Selection { + table: typeof TableSelection; + } + } +} + +export const TableSelectionExtension = SelectionExtension(TableSelection); diff --git a/blocksuite/affine/block-table/src/table-block.css.ts b/blocksuite/affine/block-table/src/table-block.css.ts new file mode 100644 index 0000000000..ca4eae7fcb --- /dev/null +++ b/blocksuite/affine/block-table/src/table-block.css.ts @@ -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({}); diff --git a/blocksuite/affine/block-table/src/table-block.ts b/blocksuite/affine/block-table/src/table-block.ts new file mode 100644 index 0000000000..33a0f8bb8c --- /dev/null +++ b/blocksuite/affine/block-table/src/table-block.ts @@ -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 { + 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(NOTE_SELECTOR); + } + return this.rootComponent; + } + + private readonly virtualPaddingController: VirtualPaddingController = + new VirtualPaddingController(this); + + table$ = signal(); + + 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` +
+
+ + + ${repeat( + rows, + row => row.rowId, + (row, rowIndex) => { + return html` + + ${repeat( + columns, + column => column.columnId, + (column, columnIndex) => { + const cell = this.dataManager.getCell( + row.rowId, + column.columnId + ); + return html` + + `; + } + )} + + `; + } + )} + + ${IS_MOBILE + ? nothing + : html``} + ${html``} +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-table': TableBlockComponent; + } +} diff --git a/blocksuite/affine/block-table/src/table-cell.css.ts b/blocksuite/affine/block-table/src/table-cell.css.ts new file mode 100644 index 0000000000..2034644bd0 --- /dev/null +++ b/blocksuite/affine/block-table/src/table-cell.css.ts @@ -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', +}); diff --git a/blocksuite/affine/block-table/src/table-cell.ts b/blocksuite/affine/block-table/src/table-cell.ts new file mode 100644 index 0000000000..2974e21d90 --- /dev/null +++ b/blocksuite/affine/block-table/src/table-cell.ts @@ -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('affine-table')?.std.get( + DefaultInlineManagerExtension.identifier + ); + } + + get topContenteditableElement() { + return this.closest('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`
+ ${TextBackgroundDuotoneIcon} +
`, + 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`
+ ${TextBackgroundDuotoneIcon} +
`, + 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`
+ ${TextBackgroundDuotoneIcon} +
`, + 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`
+
+ ${threePointerIcon()} +
+
`; + } + + 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`
+
+ ${threePointerIcon(true)} +
+
`; + } + 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`
{ + 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} + >
`; + } + + richText$ = signal(); + + 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` +
+
+
+ `; + } + return html` + { + this.tdMouseEnter(this.rowIndex, this.columnIndex); + }} + @mouseleave=${() => { + this.tdMouseLeave(); + }} + @contextmenu=${this.onContextMenu} + class=${cellContainerStyle} + style=${this.tdStyle()} + > + + ${this.renderOptionsButton()} ${this.renderWidthDragHandle()} + + `; + } +} + +const threePointerIcon = (vertical: boolean = false) => { + return html` +
+
+
+
+
+ `; +}; +declare global { + interface HTMLElementTagNameMap { + 'affine-table-cell': TableCell; + } +} diff --git a/blocksuite/affine/block-table/src/table-data-manager.ts b/blocksuite/affine/block-table/src/table-data-manager.ts new file mode 100644 index 0000000000..34b0a71c04 --- /dev/null +++ b/blocksuite/affine/block-table/src/table-data-manager.ts @@ -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(); + hoverRowIndex$ = signal(); + hoverDragHandleColumnId$ = signal(); + draggingColumnId$ = signal(); + virtualColumnCount$ = signal(0); + virtualRowCount$ = signal(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(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; + } +} diff --git a/blocksuite/affine/block-table/src/table-spec.ts b/blocksuite/affine/block-table/src/table-spec.ts new file mode 100644 index 0000000000..1b37fd50a5 --- /dev/null +++ b/blocksuite/affine/block-table/src/table-spec.ts @@ -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(); diff --git a/blocksuite/affine/block-table/tsconfig.json b/blocksuite/affine/block-table/tsconfig.json new file mode 100644 index 0000000000..950cead263 --- /dev/null +++ b/blocksuite/affine/block-table/tsconfig.json @@ -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" } + ] +} diff --git a/blocksuite/affine/components/src/rich-text/format/consts.ts b/blocksuite/affine/components/src/rich-text/format/consts.ts index 8c91daa9ad..7641468ddf 100644 --- a/blocksuite/affine/components/src/rich-text/format/consts.ts +++ b/blocksuite/affine/components/src/rich-text/format/consts.ts @@ -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, +]; diff --git a/blocksuite/affine/model/src/blocks/index.ts b/blocksuite/affine/model/src/blocks/index.ts index 67d6f1e4bb..6e06e5ec32 100644 --- a/blocksuite/affine/model/src/blocks/index.ts +++ b/blocksuite/affine/model/src/blocks/index.ts @@ -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'; diff --git a/blocksuite/affine/model/src/blocks/note/note-model.ts b/blocksuite/affine/model/src/blocks/note/note-model.ts index cd0c143adb..a3f4116092 100644 --- a/blocksuite/affine/model/src/blocks/note/note-model.ts +++ b/blocksuite/affine/model/src/blocks/note/note-model.ts @@ -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: () => { diff --git a/blocksuite/affine/model/src/blocks/table/index.ts b/blocksuite/affine/model/src/blocks/table/index.ts new file mode 100644 index 0000000000..2c65da2049 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/table/index.ts @@ -0,0 +1 @@ +export * from './table-model'; diff --git a/blocksuite/affine/model/src/blocks/table/table-model.ts b/blocksuite/affine/model/src/blocks/table/table-model.ts new file mode 100644 index 0000000000..6946ca271b --- /dev/null +++ b/blocksuite/affine/model/src/blocks/table/table-model.ts @@ -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; + columns: Record; + // key = `${rowId}:${columnId}` + cells: Record; +} + +export interface TableCellSerialized { + text: { + delta: DeltaInsert[]; + }; +} + +export interface TableBlockPropsSerialized { + rows: Record; + columns: Record; + cells: Record; +} + +export class TableBlockModel extends BlockModel {} +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; + } + } +} diff --git a/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json index 3b698b8621..602fd17a40 100644 --- a/blocksuite/affine/shared/package.json +++ b/blocksuite/affine/shared/package.json @@ -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", diff --git a/blocksuite/affine/shared/src/services/theme-service.ts b/blocksuite/affine/shared/src/services/theme-service.ts index ab62a3baf3..170d5abcdb 100644 --- a/blocksuite/affine/shared/src/services/theme-service.ts +++ b/blocksuite/affine/shared/src/services/theme-service.ts @@ -14,7 +14,7 @@ import { combinedLightCssVariables, } from '@toeverything/theme'; -import { isInsideEdgelessEditor } from '../utils/index.js'; +import { isInsideEdgelessEditor } from '../utils/dom'; export const ThemeExtensionIdentifier = createIdentifier( 'AffineThemeExtension' diff --git a/blocksuite/affine/shared/src/theme/css-variables.ts b/blocksuite/affine/shared/src/theme/css-variables.ts index e609ef0fc0..580b60a88b 100644 --- a/blocksuite/affine/shared/src/theme/css-variables.ts +++ b/blocksuite/affine/shared/src/theme/css-variables.ts @@ -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', diff --git a/blocksuite/affine/shared/src/utils/cell-select.ts b/blocksuite/affine/shared/src/utils/cell-select.ts new file mode 100644 index 0000000000..57bb895ac4 --- /dev/null +++ b/blocksuite/affine/shared/src/utils/cell-select.ts @@ -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, + }; +}; diff --git a/blocksuite/affine/shared/src/utils/fractional-indexing.ts b/blocksuite/affine/shared/src/utils/fractional-indexing.ts new file mode 100644 index 0000000000..37f74d4a35 --- /dev/null +++ b/blocksuite/affine/shared/src/utils/fractional-indexing.ts @@ -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'); +} diff --git a/blocksuite/affine/shared/src/utils/index.ts b/blocksuite/affine/shared/src/utils/index.ts index c858a727af..7bdda68948 100644 --- a/blocksuite/affine/shared/src/utils/index.ts +++ b/blocksuite/affine/shared/src/utils/index.ts @@ -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'; diff --git a/blocksuite/affine/shared/src/utils/virtual-padding.ts b/blocksuite/affine/shared/src/utils/virtual-padding.ts new file mode 100644 index 0000000000..35720939fe --- /dev/null +++ b/blocksuite/affine/shared/src/utils/virtual-padding.ts @@ -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); + }) + ); + } +} diff --git a/blocksuite/blocks/package.json b/blocksuite/blocks/package.json index 639bbae14c..6611bf355c 100644 --- a/blocksuite/blocks/package.json +++ b/blocksuite/blocks/package.json @@ -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:*", diff --git a/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts b/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts index ae29fe95f7..ea71a2570b 100644 --- a/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts +++ b/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts @@ -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: [], }, ], }; diff --git a/blocksuite/blocks/src/_common/adapters/html/block-matcher.ts b/blocksuite/blocks/src/_common/adapters/html/block-matcher.ts index 3d6be980e3..b8a5bf8a5f 100644 --- a/blocksuite/blocks/src/_common/adapters/html/block-matcher.ts +++ b/blocksuite/blocks/src/_common/adapters/html/block-matcher.ts @@ -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, ]; diff --git a/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts b/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts index be8b9b84bf..eaa943d61b 100644 --- a/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts +++ b/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts @@ -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, diff --git a/blocksuite/blocks/src/_common/test-utils/test-utils.ts b/blocksuite/blocks/src/_common/test-utils/test-utils.ts index 23ae27d90d..981e5be702 100644 --- a/blocksuite/blocks/src/_common/test-utils/test-utils.ts +++ b/blocksuite/blocks/src/_common/test-utils/test-utils.ts @@ -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; diff --git a/blocksuite/blocks/src/_specs/common.ts b/blocksuite/blocks/src/_specs/common.ts index cd925ae912..b748ea5cbb 100644 --- a/blocksuite/blocks/src/_specs/common.ts +++ b/blocksuite/blocks/src/_specs/common.ts @@ -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, ]; diff --git a/blocksuite/blocks/src/effects.ts b/blocksuite/blocks/src/effects.ts index d3524020ad..e6a139b82e 100644 --- a/blocksuite/blocks/src/effects.ts +++ b/blocksuite/blocks/src/effects.ts @@ -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(); diff --git a/blocksuite/blocks/src/index.ts b/blocksuite/blocks/src/index.ts index 5a20fd6418..2414fb9aca 100644 --- a/blocksuite/blocks/src/index.ts +++ b/blocksuite/blocks/src/index.ts @@ -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, diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/config.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/config.ts index 4f0a109668..08c7d73a74 100644 --- a/blocksuite/blocks/src/root-block/widgets/format-bar/config.ts +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/config.ts @@ -439,6 +439,8 @@ export const BUILT_IN_GROUPS: MenuItemGroup[] = [ ]; 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) { `; } +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'); +}; diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/format-bar.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/format-bar.ts index 6524831521..a972487ba9 100644 --- a/blocksuite/blocks/src/root-block/widgets/format-bar/format-bar.ts +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/format-bar.ts @@ -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` ${items} - - ${toolbarMoreButton(this)} + ${moreButton + ? html` + + ${moreButton} + ` + : nothing} `; } diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts index 53a57d2ffd..c23dc33e40 100644 --- a/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts @@ -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.', diff --git a/blocksuite/blocks/src/schemas.ts b/blocksuite/blocks/src/schemas.ts index c878e0cee0..c44bcf33b3 100644 --- a/blocksuite/blocks/src/schemas.ts +++ b/blocksuite/blocks/src/schemas.ts @@ -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[] = [ EmbedLoomBlockSchema, EdgelessTextBlockSchema, LatexBlockSchema, + TableBlockSchema, ]; diff --git a/blocksuite/blocks/tsconfig.json b/blocksuite/blocks/tsconfig.json index 06299daf0f..d6d8bb1e61 100644 --- a/blocksuite/blocks/tsconfig.json +++ b/blocksuite/blocks/tsconfig.json @@ -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" }, diff --git a/blocksuite/tests-legacy/selection/native.spec.ts b/blocksuite/tests-legacy/selection/native.spec.ts index d3da556ed1..7976aa8229 100644 --- a/blocksuite/tests-legacy/selection/native.spec.ts +++ b/blocksuite/tests-legacy/selection/native.spec.ts @@ -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); diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts index e606710564..25052bb48d 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts @@ -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` { + 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'); +}; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts index b3a7af2c30..cc77296a06 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts @@ -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, diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 7b43cc9cb0..e1f3f2db50 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -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' diff --git a/tsconfig.json b/tsconfig.json index e8b6b2477b..ae5f869fe2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" }, diff --git a/yarn.lock b/yarn.lock index 1916a54ebe..99a73f3a46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: