mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 18:02:47 +08:00
feat(editor): simple table block (#9740)
close: BS-2122, BS-2125, BS-2124, BS-2420, PD-2073, BS-2126, BS-2469, BS-2470, BS-2478, BS-2471
This commit is contained in:
13
blocksuite/affine/block-table/src/adapters/extension.ts
Normal file
13
blocksuite/affine/block-table/src/adapters/extension.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { TableBlockHtmlAdapterExtension } from './html.js';
|
||||
import { TableBlockMarkdownAdapterExtension } from './markdown.js';
|
||||
import { TableBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { TableBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const TableBlockAdapterExtensions: ExtensionType[] = [
|
||||
TableBlockHtmlAdapterExtension,
|
||||
TableBlockMarkdownAdapterExtension,
|
||||
TableBlockNotionHtmlAdapterExtension,
|
||||
TableBlockPlainTextAdapterExtension,
|
||||
];
|
||||
126
blocksuite/affine/block-table/src/adapters/html.ts
Normal file
126
blocksuite/affine/block-table/src/adapters/html.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
type TableBlockPropsSerialized,
|
||||
TableBlockSchema,
|
||||
TableModelFlavour,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
type InlineHtmlAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Element } from 'hast';
|
||||
|
||||
import { DefaultColumnWidth } from '../consts';
|
||||
import { parseTableFromHtml, processTable } from './utils';
|
||||
|
||||
const TABLE_NODE_TYPES = new Set(['table', 'thead', 'tbody', 'th', 'tr']);
|
||||
|
||||
export const tableBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: TableBlockSchema.model.flavour,
|
||||
toMatch: o => {
|
||||
return HastUtils.isElement(o.node) && TABLE_NODE_TYPES.has(o.node.tagName);
|
||||
},
|
||||
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
if (o.node.tagName === 'table') {
|
||||
const tableProps = parseTableFromHtml(o.node);
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: TableModelFlavour,
|
||||
props: tableProps as unknown as Record<string, unknown>,
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
walkerContext.skipChildren();
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
if (o.node.tagName === 'table') {
|
||||
walkerContext.closeNode();
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
const { columns, rows, cells } = o.node
|
||||
.props as unknown as TableBlockPropsSerialized;
|
||||
const table = processTable(columns, rows, cells);
|
||||
const createAstTableCell = (
|
||||
children: InlineHtmlAST[]
|
||||
): InlineHtmlAST => ({
|
||||
type: 'element',
|
||||
tagName: 'td',
|
||||
properties: Object.create(null),
|
||||
children: [
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: {
|
||||
style: `min-height: 22px;min-width:${DefaultColumnWidth}px;padding: 8px 12px;`,
|
||||
},
|
||||
children,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createAstTableRow = (cells: InlineHtmlAST[]): Element => ({
|
||||
type: 'element',
|
||||
tagName: 'tr',
|
||||
properties: Object.create(null),
|
||||
children: cells,
|
||||
});
|
||||
|
||||
const { deltaConverter } = context;
|
||||
|
||||
const tableBodyAst: Element = {
|
||||
type: 'element',
|
||||
tagName: 'tbody',
|
||||
properties: Object.create(null),
|
||||
children: table.rows.map(v => {
|
||||
return createAstTableRow(
|
||||
v.cells.map(cell => {
|
||||
return createAstTableCell(
|
||||
typeof cell.value === 'string'
|
||||
? [{ type: 'text', value: cell.value }]
|
||||
: deltaConverter.deltaToAST(cell.value.delta)
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'element',
|
||||
tagName: 'table',
|
||||
properties: {
|
||||
border: true,
|
||||
style: 'border-collapse: collapse;border-spacing: 0;',
|
||||
},
|
||||
children: [tableBodyAst],
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TableBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
tableBlockHtmlAdapterMatcher
|
||||
);
|
||||
4
blocksuite/affine/block-table/src/adapters/index.ts
Normal file
4
blocksuite/affine/block-table/src/adapters/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './html';
|
||||
export * from './markdown';
|
||||
export * from './notion-html';
|
||||
export * from './plain-text';
|
||||
79
blocksuite/affine/block-table/src/adapters/markdown.ts
Normal file
79
blocksuite/affine/block-table/src/adapters/markdown.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
type TableBlockPropsSerialized,
|
||||
TableBlockSchema,
|
||||
TableModelFlavour,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { TableRow } from 'mdast';
|
||||
|
||||
import { parseTableFromMarkdown, processTable } from './utils';
|
||||
|
||||
const TABLE_NODE_TYPES = new Set(['table', 'tableRow']);
|
||||
|
||||
const isTableNode = (node: MarkdownAST) => TABLE_NODE_TYPES.has(node.type);
|
||||
|
||||
export const tableBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
|
||||
flavour: TableBlockSchema.model.flavour,
|
||||
toMatch: o => isTableNode(o.node),
|
||||
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
if (o.node.type === 'table') {
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: TableModelFlavour,
|
||||
props: parseTableFromMarkdown(o.node),
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
walkerContext.skipChildren();
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
if (o.node.type === 'table') {
|
||||
walkerContext.closeNode();
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const { columns, rows, cells } = o.node
|
||||
.props as unknown as TableBlockPropsSerialized;
|
||||
const table = processTable(columns, rows, cells);
|
||||
const result: TableRow[] = [];
|
||||
table.rows.forEach(v => {
|
||||
result.push({
|
||||
type: 'tableRow',
|
||||
children: v.cells.map(v => ({
|
||||
type: 'tableCell',
|
||||
children: deltaConverter.deltaToAST(v.value.delta),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'table',
|
||||
children: result,
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TableBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
|
||||
tableBlockMarkdownAdapterMatcher
|
||||
);
|
||||
21
blocksuite/affine/block-table/src/adapters/notion-html.ts
Normal file
21
blocksuite/affine/block-table/src/adapters/notion-html.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { TableBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
const TABLE_NODE_TYPES = new Set(['table', 'th', 'tr']);
|
||||
|
||||
export const tableBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
|
||||
{
|
||||
flavour: TableBlockSchema.model.flavour,
|
||||
toMatch: o =>
|
||||
HastUtils.isElement(o.node) && TABLE_NODE_TYPES.has(o.node.tagName),
|
||||
fromMatch: () => false,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const TableBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(tableBlockNotionHtmlAdapterMatcher);
|
||||
74
blocksuite/affine/block-table/src/adapters/plain-text.ts
Normal file
74
blocksuite/affine/block-table/src/adapters/plain-text.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
type TableBlockPropsSerialized,
|
||||
TableBlockSchema,
|
||||
TableModelFlavour,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
import { createTableProps, formatTable, processTable } from './utils.js';
|
||||
|
||||
export const tableBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = {
|
||||
flavour: TableBlockSchema.model.flavour,
|
||||
toMatch: () => true,
|
||||
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
const text = o.node.content;
|
||||
const rowTexts = text.split('\n');
|
||||
if (rowTexts.length <= 1) return;
|
||||
const rowTextLists: string[][] = [];
|
||||
let columnCount: number | null = null;
|
||||
for (const row of rowTexts) {
|
||||
const cells = row.split('\t');
|
||||
if (cells.length <= 1) return;
|
||||
if (columnCount == null) {
|
||||
columnCount = cells.length;
|
||||
} else if (columnCount !== cells.length) {
|
||||
return;
|
||||
}
|
||||
rowTextLists.push(cells);
|
||||
}
|
||||
const tableProps = createTableProps(rowTextLists);
|
||||
walkerContext.openNode({
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: TableModelFlavour,
|
||||
props: tableProps,
|
||||
children: [],
|
||||
});
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
leave: (_, context) => {
|
||||
const { walkerContext } = context;
|
||||
walkerContext.closeNode();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const result: string[][] = [];
|
||||
const { columns, rows, cells } = o.node
|
||||
.props as unknown as TableBlockPropsSerialized;
|
||||
const table = processTable(columns, rows, cells);
|
||||
table.rows.forEach(v => {
|
||||
result.push(
|
||||
v.cells.map(v => deltaConverter.deltaToAST(v.value.delta).join(''))
|
||||
);
|
||||
});
|
||||
|
||||
const tableString = formatTable(result);
|
||||
|
||||
context.textBuffer.content += tableString;
|
||||
context.textBuffer.content += '\n';
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TableBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(tableBlockPlainTextAdapterMatcher);
|
||||
215
blocksuite/affine/block-table/src/adapters/utils.ts
Normal file
215
blocksuite/affine/block-table/src/adapters/utils.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type {
|
||||
TableBlockPropsSerialized,
|
||||
TableCellSerialized,
|
||||
TableColumn,
|
||||
TableRow,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { HastUtils, TextUtils } from '@blocksuite/affine-shared/adapters';
|
||||
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Element, ElementContent } from 'hast';
|
||||
import type { PhrasingContent, Table as MarkdownTable, TableCell } from 'mdast';
|
||||
function calculateColumnWidths(rows: string[][]): number[] {
|
||||
return (
|
||||
rows[0]?.map((_, colIndex) =>
|
||||
Math.max(...rows.map(row => (row[colIndex] || '').length))
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function formatRow(
|
||||
row: string[],
|
||||
columnWidths: number[],
|
||||
isHeader: boolean
|
||||
): string {
|
||||
const cells = row.map((cell, colIndex) =>
|
||||
cell?.padEnd(columnWidths[colIndex] ?? 0, ' ')
|
||||
);
|
||||
const rowString = `| ${cells.join(' | ')} |`;
|
||||
return isHeader
|
||||
? `${rowString}\n${formatSeparator(columnWidths)}`
|
||||
: rowString;
|
||||
}
|
||||
|
||||
function formatSeparator(columnWidths: number[]): string {
|
||||
const separator = columnWidths.map(width => '-'.repeat(width)).join(' | ');
|
||||
return `| ${separator} |`;
|
||||
}
|
||||
|
||||
export function formatTable(rows: string[][]): string {
|
||||
const columnWidths = calculateColumnWidths(rows);
|
||||
const formattedRows = rows.map((row, index) =>
|
||||
formatRow(row, columnWidths, index === 0)
|
||||
);
|
||||
return formattedRows.join('\n');
|
||||
}
|
||||
type Table = {
|
||||
rows: Row[];
|
||||
};
|
||||
type Row = {
|
||||
cells: Cell[];
|
||||
};
|
||||
type Cell = {
|
||||
value: { delta: DeltaInsert[] };
|
||||
};
|
||||
export const processTable = (
|
||||
columns: Record<string, TableColumn>,
|
||||
rows: Record<string, TableRow>,
|
||||
cells: Record<string, TableCellSerialized>
|
||||
): Table => {
|
||||
const sortedColumns = Object.values(columns).sort((a, b) =>
|
||||
a.order.localeCompare(b.order)
|
||||
);
|
||||
const sortedRows = Object.values(rows).sort((a, b) =>
|
||||
a.order.localeCompare(b.order)
|
||||
);
|
||||
const table: Table = {
|
||||
rows: [],
|
||||
};
|
||||
sortedRows.forEach(r => {
|
||||
const row: Row = {
|
||||
cells: [],
|
||||
};
|
||||
sortedColumns.forEach(col => {
|
||||
const cell = cells[`${r.rowId}:${col.columnId}`];
|
||||
if (!cell) {
|
||||
row.cells.push({
|
||||
value: {
|
||||
delta: [],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
row.cells.push({
|
||||
value: cell.text,
|
||||
});
|
||||
});
|
||||
table.rows.push(row);
|
||||
});
|
||||
return table;
|
||||
};
|
||||
const getTextFromElement = (element: ElementContent): string => {
|
||||
if (element.type === 'text') {
|
||||
return element.value;
|
||||
}
|
||||
if (element.type === 'element') {
|
||||
return element.children.map(child => getTextFromElement(child)).join('');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const getAllTag = (node: Element | undefined, tagName: string): Element[] => {
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
if (HastUtils.isElement(node)) {
|
||||
if (node.tagName === tagName) {
|
||||
return [node];
|
||||
}
|
||||
return node.children.flatMap(child => {
|
||||
if (HastUtils.isElement(child)) {
|
||||
return getAllTag(child, tagName);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const createTableProps = (rowTextLists: string[][]) => {
|
||||
const createIdAndOrder = (count: number) => {
|
||||
const result: { id: string; order: string }[] = Array.from({
|
||||
length: count,
|
||||
});
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = nanoid();
|
||||
const order = generateFractionalIndexingKeyBetween(
|
||||
result[i - 1]?.order ?? null,
|
||||
null
|
||||
);
|
||||
result[i] = { id, order };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const columnCount = Math.max(...rowTextLists.map(row => row.length));
|
||||
const rowCount = rowTextLists.length;
|
||||
|
||||
const columns: TableColumn[] = createIdAndOrder(columnCount).map(v => ({
|
||||
columnId: v.id,
|
||||
order: v.order,
|
||||
}));
|
||||
const rows: TableRow[] = createIdAndOrder(rowCount).map(v => ({
|
||||
rowId: v.id,
|
||||
order: v.order,
|
||||
}));
|
||||
|
||||
const cells: Record<string, TableCellSerialized> = {};
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
for (let j = 0; j < columnCount; j++) {
|
||||
const row = rows[i];
|
||||
const column = columns[j];
|
||||
if (!row || !column) {
|
||||
continue;
|
||||
}
|
||||
const cellId = `${row.rowId}:${column.columnId}`;
|
||||
const text = rowTextLists[i]?.[j];
|
||||
cells[cellId] = {
|
||||
text: TextUtils.createText(text ?? ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
columns: Object.fromEntries(
|
||||
columns.map(column => [column.columnId, column])
|
||||
),
|
||||
rows: Object.fromEntries(rows.map(row => [row.rowId, row])),
|
||||
cells,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseTableFromHtml = (
|
||||
element: Element
|
||||
): TableBlockPropsSerialized => {
|
||||
const headerRows = getAllTag(element, 'thead').flatMap(node =>
|
||||
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'th'))
|
||||
);
|
||||
const bodyRows = getAllTag(element, 'tbody').flatMap(node =>
|
||||
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'td'))
|
||||
);
|
||||
const footerRows = getAllTag(element, 'tfoot').flatMap(node =>
|
||||
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'td'))
|
||||
);
|
||||
const allRows = [...headerRows, ...bodyRows, ...footerRows];
|
||||
const rowTextLists: string[][] = [];
|
||||
allRows.forEach(cells => {
|
||||
const row: string[] = [];
|
||||
cells.forEach(cell => {
|
||||
row.push(getTextFromElement(cell));
|
||||
});
|
||||
rowTextLists.push(row);
|
||||
});
|
||||
return createTableProps(rowTextLists);
|
||||
};
|
||||
|
||||
const getTextFromTableCell = (node: TableCell) => {
|
||||
const getTextFromPhrasingContent = (node: PhrasingContent) => {
|
||||
if (node.type === 'text') {
|
||||
return node.value;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
return node.children.map(child => getTextFromPhrasingContent(child)).join('');
|
||||
};
|
||||
|
||||
export const parseTableFromMarkdown = (node: MarkdownTable) => {
|
||||
const rowTextLists: string[][] = [];
|
||||
node.children.forEach(row => {
|
||||
const rowText: string[] = [];
|
||||
row.children.forEach(cell => {
|
||||
rowText.push(getTextFromTableCell(cell));
|
||||
});
|
||||
rowTextLists.push(rowText);
|
||||
});
|
||||
return createTableProps(rowTextLists);
|
||||
};
|
||||
90
blocksuite/affine/block-table/src/add-button.css.ts
Normal file
90
blocksuite/affine/block-table/src/add-button.css.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { cssVar, cssVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const addColumnButtonStyle = style({
|
||||
cursor: 'col-resize',
|
||||
backgroundColor: cssVarV2.layer.background.hoverOverlay,
|
||||
fontSize: '10px',
|
||||
color: cssVarV2.icon.secondary,
|
||||
display: 'flex',
|
||||
width: '12px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: 'calc(100% + 2px)',
|
||||
height: '100%',
|
||||
transition:
|
||||
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
'&:hover, &.active': {
|
||||
backgroundColor: cssVarV2.table.indicator.drag,
|
||||
color: cssVarV2.icon.primary,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const addRowButtonStyle = style({
|
||||
cursor: 'row-resize',
|
||||
backgroundColor: cssVarV2.layer.background.hoverOverlay,
|
||||
fontSize: '10px',
|
||||
color: cssVarV2.icon.secondary,
|
||||
display: 'flex',
|
||||
height: '12px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 2px)',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
transition:
|
||||
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
'&:hover, &.active': {
|
||||
backgroundColor: cssVarV2.table.indicator.drag,
|
||||
color: cssVarV2.icon.primary,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const addRowColumnButtonStyle = style({
|
||||
cursor: 'nwse-resize',
|
||||
backgroundColor: cssVarV2.layer.background.hoverOverlay,
|
||||
fontSize: '10px',
|
||||
color: cssVarV2.icon.secondary,
|
||||
display: 'flex',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 2px)',
|
||||
left: 'calc(100% + 2px)',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
transition:
|
||||
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&:hover, &.active': {
|
||||
backgroundColor: cssVarV2.table.indicator.drag,
|
||||
color: cssVarV2.icon.primary,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const cellCountTipsStyle = style({
|
||||
position: 'absolute',
|
||||
backgroundColor: cssVarV2.tooltips.background,
|
||||
borderRadius: '4px',
|
||||
padding: '4px',
|
||||
boxShadow: cssVar('buttonShadow'),
|
||||
color: cssVarV2.tooltips.foreground,
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
327
blocksuite/affine/block-table/src/add-button.ts
Normal file
327
blocksuite/affine/block-table/src/add-button.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { PlusIcon } from '@blocksuite/icons/lit';
|
||||
import {
|
||||
autoPlacement,
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
offset,
|
||||
shift,
|
||||
} from '@floating-ui/dom';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
addColumnButtonStyle,
|
||||
addRowButtonStyle,
|
||||
addRowColumnButtonStyle,
|
||||
cellCountTipsStyle,
|
||||
} from './add-button.css';
|
||||
import { DefaultColumnWidth, DefaultRowHeight } from './consts';
|
||||
import type { TableDataManager } from './table-data-manager';
|
||||
|
||||
export class AddButton extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
@property({ type: Boolean })
|
||||
accessor vertical = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor dataManager!: TableDataManager;
|
||||
|
||||
get hoverColumnIndex$() {
|
||||
return this.dataManager.hoverColumnIndex$;
|
||||
}
|
||||
|
||||
get hoverRowIndex$() {
|
||||
return this.dataManager.hoverRowIndex$;
|
||||
}
|
||||
|
||||
get columns$() {
|
||||
return this.dataManager.columns$;
|
||||
}
|
||||
|
||||
get rows$() {
|
||||
return this.dataManager.rows$;
|
||||
}
|
||||
|
||||
addColumnButtonRef$ = signal<HTMLDivElement>();
|
||||
addRowButtonRef$ = signal<HTMLDivElement>();
|
||||
addRowColumnButtonRef$ = signal<HTMLDivElement>();
|
||||
|
||||
columnDragging$ = signal(false);
|
||||
rowDragging$ = signal(false);
|
||||
rowColumnDragging$ = signal(false);
|
||||
|
||||
popCellCountTips = (ele: Element) => {
|
||||
const tip = document.createElement('div');
|
||||
tip.classList.add(cellCountTipsStyle);
|
||||
document.body.append(tip);
|
||||
const dispose = autoUpdate(ele, tip, () => {
|
||||
computePosition(ele, tip, {
|
||||
middleware: [
|
||||
autoPlacement({ allowedPlacements: ['bottom'] }),
|
||||
offset(4),
|
||||
shift(),
|
||||
],
|
||||
})
|
||||
.then(({ x, y }) => {
|
||||
tip.style.left = `${x}px`;
|
||||
tip.style.top = `${y}px`;
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
});
|
||||
return {
|
||||
tip,
|
||||
dispose: () => {
|
||||
dispose();
|
||||
tip.remove();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getEmptyRows() {
|
||||
const rows = this.rows$.value;
|
||||
const columns = this.columns$.value;
|
||||
const rowWidths: number[] = [];
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const row = rows[i];
|
||||
if (!row) {
|
||||
break;
|
||||
}
|
||||
const hasText = columns.some(column => {
|
||||
const cell = this.dataManager.getCell(row.rowId, column.columnId);
|
||||
if (!cell) {
|
||||
return false;
|
||||
}
|
||||
return cell.text.length > 0;
|
||||
});
|
||||
if (hasText) {
|
||||
break;
|
||||
}
|
||||
rowWidths.push((rowWidths[rowWidths.length - 1] ?? 0) + DefaultRowHeight);
|
||||
}
|
||||
return rowWidths;
|
||||
}
|
||||
|
||||
getEmptyColumns() {
|
||||
const columns = this.columns$.value;
|
||||
const rows = this.rows$.value;
|
||||
const columnWidths: number[] = [];
|
||||
for (let i = columns.length - 1; i >= 0; i--) {
|
||||
const column = columns[i];
|
||||
if (!column) {
|
||||
break;
|
||||
}
|
||||
const hasText = rows.some(row => {
|
||||
const cell = this.dataManager.getCell(row.rowId, column.columnId);
|
||||
if (!cell) {
|
||||
return false;
|
||||
}
|
||||
return cell.text.length > 0;
|
||||
});
|
||||
if (hasText) {
|
||||
break;
|
||||
}
|
||||
columnWidths.push(
|
||||
(columnWidths[columnWidths.length - 1] ?? 0) +
|
||||
(column.width ?? DefaultColumnWidth)
|
||||
);
|
||||
}
|
||||
return columnWidths;
|
||||
}
|
||||
|
||||
onDragStart(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const initialX = e.clientX;
|
||||
const initialY = e.clientY;
|
||||
const target = e.target as HTMLElement;
|
||||
const isColumn = target.closest('.column-add');
|
||||
const isRow = target.closest('.row-add');
|
||||
const isRowColumn = target.closest('.row-column-add');
|
||||
const realTarget = isColumn || isRowColumn || isRow;
|
||||
if (!realTarget) {
|
||||
return;
|
||||
}
|
||||
const tipsHandler = this.popCellCountTips(realTarget);
|
||||
let emptyRows: number[] = [];
|
||||
let emptyColumns: number[] = [];
|
||||
if (isColumn) {
|
||||
this.columnDragging$.value = true;
|
||||
emptyColumns = this.getEmptyColumns();
|
||||
}
|
||||
if (isRow) {
|
||||
this.rowDragging$.value = true;
|
||||
emptyRows = this.getEmptyRows();
|
||||
}
|
||||
if (isRowColumn) {
|
||||
this.rowColumnDragging$.value = true;
|
||||
emptyRows = this.getEmptyRows();
|
||||
emptyColumns = this.getEmptyColumns();
|
||||
}
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - initialX;
|
||||
const deltaY = e.clientY - initialY;
|
||||
const addColumn = isColumn || isRowColumn;
|
||||
const addRow = isRow || isRowColumn;
|
||||
if (addColumn) {
|
||||
if (deltaX > 0) {
|
||||
this.dataManager.virtualColumnCount$.value = Math.floor(
|
||||
(deltaX + 30) / DefaultColumnWidth
|
||||
);
|
||||
} else {
|
||||
let count = 0;
|
||||
while (count < emptyColumns.length) {
|
||||
const emptyColumnWidth = emptyColumns[count];
|
||||
if (!emptyColumnWidth) {
|
||||
continue;
|
||||
}
|
||||
if (-deltaX > emptyColumnWidth) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.dataManager.virtualColumnCount$.value = -count;
|
||||
}
|
||||
}
|
||||
if (addRow) {
|
||||
if (deltaY > 0) {
|
||||
this.dataManager.virtualRowCount$.value = Math.floor(
|
||||
deltaY / DefaultRowHeight
|
||||
);
|
||||
} else {
|
||||
let count = 0;
|
||||
while (count < emptyRows.length) {
|
||||
const emptyRowHeight = emptyRows[count];
|
||||
if (!emptyRowHeight) {
|
||||
continue;
|
||||
}
|
||||
if (-deltaY > emptyRowHeight) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.dataManager.virtualRowCount$.value = -count;
|
||||
}
|
||||
}
|
||||
tipsHandler.tip.textContent = this.dataManager.cellCountTips$.value;
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
this.columnDragging$.value = false;
|
||||
this.rowDragging$.value = false;
|
||||
this.rowColumnDragging$.value = false;
|
||||
const rowCount = this.dataManager.virtualRowCount$.value;
|
||||
const columnCount = this.dataManager.virtualColumnCount$.value;
|
||||
this.dataManager.virtualColumnCount$.value = 0;
|
||||
this.dataManager.virtualRowCount$.value = 0;
|
||||
this.dataManager.addNRow(rowCount);
|
||||
this.dataManager.addNColumn(columnCount);
|
||||
|
||||
tipsHandler.dispose();
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.disposables.addFromEvent(this, 'mousedown', (e: MouseEvent) => {
|
||||
this.onDragStart(e);
|
||||
});
|
||||
}
|
||||
|
||||
renderAddColumnButton() {
|
||||
const hovered =
|
||||
this.hoverColumnIndex$.value === this.columns$.value.length - 1;
|
||||
const dragging = this.columnDragging$.value;
|
||||
return html` <div
|
||||
class="${classMap({
|
||||
[addColumnButtonStyle]: true,
|
||||
active: dragging,
|
||||
'column-add': true,
|
||||
})}"
|
||||
${ref(this.addColumnButtonRef$)}
|
||||
style=${styleMap({
|
||||
opacity: hovered || dragging ? 1 : undefined,
|
||||
})}
|
||||
@click="${(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dataManager.addColumn(this.columns$.value.length - 1);
|
||||
}}"
|
||||
>
|
||||
${PlusIcon()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderAddRowButton() {
|
||||
const hovered = this.hoverRowIndex$.value === this.rows$.value.length - 1;
|
||||
const dragging = this.rowDragging$.value;
|
||||
return html` <div
|
||||
class="${classMap({
|
||||
[addRowButtonStyle]: true,
|
||||
active: dragging,
|
||||
'row-add': true,
|
||||
})}"
|
||||
${ref(this.addRowButtonRef$)}
|
||||
style=${styleMap({
|
||||
opacity: hovered || dragging ? 1 : undefined,
|
||||
})}
|
||||
@click="${(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dataManager.addRow(this.rows$.value.length - 1);
|
||||
}}"
|
||||
>
|
||||
${PlusIcon()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderAddRowColumnButton() {
|
||||
const hovered =
|
||||
this.hoverRowIndex$.value === this.rows$.value.length - 1 &&
|
||||
this.hoverColumnIndex$.value === this.columns$.value.length - 1;
|
||||
const dragging = this.rowColumnDragging$.value;
|
||||
return html` <div
|
||||
class="${classMap({
|
||||
[addRowColumnButtonStyle]: true,
|
||||
active: dragging,
|
||||
'row-column-add': true,
|
||||
})}"
|
||||
${ref(this.addRowColumnButtonRef$)}
|
||||
style=${styleMap({
|
||||
opacity: hovered || dragging ? 1 : undefined,
|
||||
})}
|
||||
@click="${(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dataManager.addRow(this.rows$.value.length - 1);
|
||||
this.dataManager.addColumn(this.columns$.value.length - 1);
|
||||
}}"
|
||||
>
|
||||
${PlusIcon()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${this.renderAddColumnButton()} ${this.renderAddRowButton()}
|
||||
${this.renderAddRowColumnButton()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-table-add-button': AddButton;
|
||||
}
|
||||
}
|
||||
45
blocksuite/affine/block-table/src/color.ts
Normal file
45
blocksuite/affine/block-table/src/color.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
type Color = {
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
export const colorList: Color[] = [
|
||||
{
|
||||
name: 'Blue',
|
||||
color: cssVarV2.table.headerBackground.blue,
|
||||
},
|
||||
{
|
||||
name: 'Green',
|
||||
color: cssVarV2.table.headerBackground.green,
|
||||
},
|
||||
{
|
||||
name: 'Grey',
|
||||
color: cssVarV2.table.headerBackground.grey,
|
||||
},
|
||||
{
|
||||
name: 'Orange',
|
||||
color: cssVarV2.table.headerBackground.orange,
|
||||
},
|
||||
{
|
||||
name: 'Purple',
|
||||
color: cssVarV2.table.headerBackground.purple,
|
||||
},
|
||||
{
|
||||
name: 'Red',
|
||||
color: cssVarV2.table.headerBackground.red,
|
||||
},
|
||||
{
|
||||
name: 'Teal',
|
||||
color: cssVarV2.table.headerBackground.teal,
|
||||
},
|
||||
{
|
||||
name: 'Yellow',
|
||||
color: cssVarV2.table.headerBackground.yellow,
|
||||
},
|
||||
];
|
||||
|
||||
const colorMap = Object.fromEntries(colorList.map(item => [item.color, item]));
|
||||
|
||||
export const getColorByColor = (color: string): Color | undefined => {
|
||||
return colorMap[color] ?? undefined;
|
||||
};
|
||||
67
blocksuite/affine/block-table/src/commands.ts
Normal file
67
blocksuite/affine/block-table/src/commands.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import '@blocksuite/affine-shared/commands';
|
||||
|
||||
import { TableModelFlavour } from '@blocksuite/affine-model';
|
||||
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockCommands, Command } from '@blocksuite/block-std';
|
||||
import { nanoid, Text } from '@blocksuite/store';
|
||||
export const insertTableBlockCommand: Command<
|
||||
'selectedModels',
|
||||
'insertedTableBlockId',
|
||||
{
|
||||
place?: 'after' | 'before';
|
||||
removeEmptyLine?: boolean;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { selectedModels, place, removeEmptyLine, std } = ctx;
|
||||
if (!selectedModels?.length) return;
|
||||
|
||||
const targetModel =
|
||||
place === 'before'
|
||||
? selectedModels[0]
|
||||
: selectedModels[selectedModels.length - 1];
|
||||
|
||||
if (!targetModel) return;
|
||||
|
||||
const row1Id = nanoid();
|
||||
const row2Id = nanoid();
|
||||
const col1Id = nanoid();
|
||||
const col2Id = nanoid();
|
||||
const order1 = generateFractionalIndexingKeyBetween(null, null);
|
||||
const order2 = generateFractionalIndexingKeyBetween(order1, null);
|
||||
|
||||
const initialTableData = {
|
||||
rows: {
|
||||
[row1Id]: { rowId: row1Id, order: order1 },
|
||||
[row2Id]: { rowId: row2Id, order: order2 },
|
||||
},
|
||||
columns: {
|
||||
[col1Id]: { columnId: col1Id, order: order1 },
|
||||
[col2Id]: { columnId: col2Id, order: order2 },
|
||||
},
|
||||
cells: {
|
||||
[`${row1Id}:${col1Id}`]: { text: new Text() },
|
||||
[`${row1Id}:${col2Id}`]: { text: new Text() },
|
||||
[`${row2Id}:${col1Id}`]: { text: new Text() },
|
||||
[`${row2Id}:${col2Id}`]: { text: new Text() },
|
||||
},
|
||||
};
|
||||
|
||||
const result = std.store.addSiblingBlocks(
|
||||
targetModel,
|
||||
[{ flavour: TableModelFlavour, ...initialTableData }],
|
||||
place
|
||||
);
|
||||
const blockId = result[0];
|
||||
|
||||
if (blockId == null) return;
|
||||
|
||||
if (removeEmptyLine && targetModel.text?.length === 0) {
|
||||
std.store.deleteBlock(targetModel);
|
||||
}
|
||||
|
||||
next({ insertedTableBlockId: blockId });
|
||||
};
|
||||
|
||||
export const tableCommands: BlockCommands = {
|
||||
insertTableBlock: insertTableBlockCommand,
|
||||
};
|
||||
4
blocksuite/affine/block-table/src/consts.ts
Normal file
4
blocksuite/affine/block-table/src/consts.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const ColumnMinWidth = 60;
|
||||
export const ColumnMaxWidth = 240;
|
||||
export const DefaultColumnWidth = 120;
|
||||
export const DefaultRowHeight = 39;
|
||||
24
blocksuite/affine/block-table/src/effects.ts
Normal file
24
blocksuite/affine/block-table/src/effects.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AddButton } from './add-button';
|
||||
import type { insertTableBlockCommand } from './commands';
|
||||
import { SelectionLayer } from './selection-layer';
|
||||
import { TableBlockComponent } from './table-block';
|
||||
import { TableCell } from './table-cell';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-table', TableBlockComponent);
|
||||
customElements.define('affine-table-cell', TableCell);
|
||||
customElements.define('affine-table-add-button', AddButton);
|
||||
customElements.define('affine-table-selection-layer', SelectionLayer);
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface CommandContext {
|
||||
insertedTableBlockId?: string;
|
||||
}
|
||||
|
||||
interface Commands {
|
||||
insertTableBlock: typeof insertTableBlockCommand;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
blocksuite/affine/block-table/src/index.ts
Normal file
5
blocksuite/affine/block-table/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './adapters';
|
||||
export * from './commands.js';
|
||||
export * from './selection-schema.js';
|
||||
export * from './table-data-manager.js';
|
||||
export * from './table-spec.js';
|
||||
276
blocksuite/affine/block-table/src/selection-controller.ts
Normal file
276
blocksuite/affine/block-table/src/selection-controller.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
domToOffsets,
|
||||
getAreaByOffsets,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { UIEventStateContext } from '@blocksuite/block-std';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import type { ReactiveController } from 'lit';
|
||||
|
||||
import { ColumnMinWidth, DefaultColumnWidth } from './consts';
|
||||
import {
|
||||
type TableAreaSelection,
|
||||
TableSelection,
|
||||
TableSelectionData,
|
||||
} from './selection-schema';
|
||||
import type { TableBlockComponent } from './table-block';
|
||||
type Cells = string[][];
|
||||
const TEXT = 'text/plain';
|
||||
export class SelectionController implements ReactiveController {
|
||||
constructor(public readonly host: TableBlockComponent) {
|
||||
this.host.addController(this);
|
||||
}
|
||||
hostConnected() {
|
||||
this.dragListener();
|
||||
this.host.handleEvent('copy', this.onCopy);
|
||||
this.host.handleEvent('cut', this.onCut);
|
||||
this.host.handleEvent('paste', this.onPaste);
|
||||
}
|
||||
private get dataManager() {
|
||||
return this.host.dataManager;
|
||||
}
|
||||
private get clipboard() {
|
||||
return this.host.std.clipboard;
|
||||
}
|
||||
widthAdjust(dragHandle: HTMLElement, event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const initialX = event.clientX;
|
||||
const currentWidth =
|
||||
dragHandle.closest('td')?.getBoundingClientRect().width ??
|
||||
DefaultColumnWidth;
|
||||
const columnId = dragHandle.dataset['widthAdjustColumnId'];
|
||||
if (!columnId) {
|
||||
return;
|
||||
}
|
||||
const onMove = (event: MouseEvent) => {
|
||||
this.dataManager.draggingColumnId$.value = columnId;
|
||||
this.dataManager.virtualWidth$.value = {
|
||||
columnId,
|
||||
width: Math.max(
|
||||
ColumnMinWidth,
|
||||
event.clientX - initialX + currentWidth
|
||||
),
|
||||
};
|
||||
};
|
||||
const onUp = () => {
|
||||
const width = this.dataManager.virtualWidth$.value?.width;
|
||||
this.dataManager.draggingColumnId$.value = undefined;
|
||||
this.dataManager.virtualWidth$.value = undefined;
|
||||
if (width) {
|
||||
this.dataManager.setColumnWidth(columnId, width);
|
||||
}
|
||||
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
dragListener() {
|
||||
if (IS_MOBILE) {
|
||||
return;
|
||||
}
|
||||
this.host.disposables.addFromEvent(this.host, 'mousedown', event => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const dragHandle = target.closest('[data-width-adjust-column-id]');
|
||||
if (dragHandle instanceof HTMLElement) {
|
||||
this.widthAdjust(dragHandle, event);
|
||||
return;
|
||||
}
|
||||
this.onDragStart(event);
|
||||
});
|
||||
}
|
||||
readonly doCopyOrCut = (selection: TableAreaSelection, isCut: boolean) => {
|
||||
const columns = this.dataManager.uiColumns$.value;
|
||||
const rows = this.dataManager.uiRows$.value;
|
||||
const cells: Cells = [];
|
||||
const deleteCells: { rowId: string; columnId: string }[] = [];
|
||||
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
|
||||
const row = rows[i];
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
const rowCells: string[] = [];
|
||||
for (
|
||||
let j = selection.columnStartIndex;
|
||||
j <= selection.columnEndIndex;
|
||||
j++
|
||||
) {
|
||||
const column = columns[j];
|
||||
if (!column) {
|
||||
continue;
|
||||
}
|
||||
const cell = this.dataManager.getCell(row.rowId, column.columnId);
|
||||
rowCells.push(cell?.text.toString() ?? '');
|
||||
if (isCut) {
|
||||
deleteCells.push({ rowId: row.rowId, columnId: column.columnId });
|
||||
}
|
||||
}
|
||||
cells.push(rowCells);
|
||||
}
|
||||
if (isCut) {
|
||||
this.dataManager.clearCells(deleteCells);
|
||||
}
|
||||
const text = cells.map(row => row.join('\t')).join('\n');
|
||||
this.clipboard
|
||||
.writeToClipboard(items => ({
|
||||
...items,
|
||||
[TEXT]: text,
|
||||
}))
|
||||
.catch(console.error);
|
||||
};
|
||||
onCopy = () => {
|
||||
const selection = this.getSelected();
|
||||
if (!selection || selection.type !== 'area') {
|
||||
return false;
|
||||
}
|
||||
this.doCopyOrCut(selection, false);
|
||||
return true;
|
||||
};
|
||||
onCut = () => {
|
||||
const selection = this.getSelected();
|
||||
if (!selection || selection.type !== 'area') {
|
||||
return false;
|
||||
}
|
||||
this.doCopyOrCut(selection, true);
|
||||
return true;
|
||||
};
|
||||
doPaste = (plainText: string, selection: TableAreaSelection) => {
|
||||
try {
|
||||
const rowTextLists = plainText
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.split('\t').map(cell => cell.trim()))
|
||||
.filter(row => row.some(cell => cell !== '')); // Filter out empty rows
|
||||
const height = rowTextLists.length;
|
||||
const width = rowTextLists[0]?.length ?? 0;
|
||||
if (height > 0 && width > 0) {
|
||||
const columns = this.dataManager.uiColumns$.value;
|
||||
const rows = this.dataManager.uiRows$.value;
|
||||
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
|
||||
const row = rows[i];
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let j = selection.columnStartIndex;
|
||||
j <= selection.columnEndIndex;
|
||||
j++
|
||||
) {
|
||||
const column = columns[j];
|
||||
if (!column) {
|
||||
continue;
|
||||
}
|
||||
const text = this.dataManager.getCell(
|
||||
row.rowId,
|
||||
column.columnId
|
||||
)?.text;
|
||||
if (text) {
|
||||
const rowIndex = (i - selection.rowStartIndex) % height;
|
||||
const columnIndex = (j - selection.columnStartIndex) % width;
|
||||
text.replace(
|
||||
0,
|
||||
text.length,
|
||||
rowTextLists[rowIndex]?.[columnIndex] ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
onPaste = (_context: UIEventStateContext) => {
|
||||
const event = _context.get('clipboardState').raw;
|
||||
event.stopPropagation();
|
||||
const clipboardData = event.clipboardData;
|
||||
if (!clipboardData) return false;
|
||||
|
||||
const selection = this.getSelected();
|
||||
if (!selection || selection.type !== 'area') {
|
||||
return false;
|
||||
}
|
||||
const plainText = clipboardData.getData('text/plain');
|
||||
this.doPaste(plainText, selection);
|
||||
return true;
|
||||
};
|
||||
onDragStart(event: MouseEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const offsets = domToOffsets(this.host, 'tr', 'td');
|
||||
if (!offsets) return;
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
let selected = false;
|
||||
const initCell = target.closest('affine-table-cell');
|
||||
if (!initCell) {
|
||||
selected = true;
|
||||
}
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement) {
|
||||
const cell = target.closest('affine-table-cell');
|
||||
if (!selected && initCell === cell) {
|
||||
return;
|
||||
}
|
||||
selected = true;
|
||||
const endX = event.clientX;
|
||||
const endY = event.clientY;
|
||||
const [left, right] = startX > endX ? [endX, startX] : [startX, endX];
|
||||
const [top, bottom] = startY > endY ? [endY, startY] : [startY, endY];
|
||||
const area = getAreaByOffsets(offsets, top, bottom, left, right);
|
||||
this.setSelected({
|
||||
type: 'area',
|
||||
rowStartIndex: area.top,
|
||||
rowEndIndex: area.bottom,
|
||||
columnStartIndex: area.left,
|
||||
columnEndIndex: area.right,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
setSelected(
|
||||
selection: TableSelectionData | undefined,
|
||||
removeNativeSelection = true
|
||||
) {
|
||||
if (selection) {
|
||||
const previous = this.getSelected();
|
||||
if (TableSelectionData.equals(previous, selection)) {
|
||||
return;
|
||||
}
|
||||
if (removeNativeSelection) {
|
||||
getSelection()?.removeAllRanges();
|
||||
}
|
||||
this.host.selection.set([
|
||||
new TableSelection({
|
||||
blockId: this.host.model.id,
|
||||
data: selection,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
this.host.selection.clear();
|
||||
}
|
||||
}
|
||||
selected$ = computed(() => this.getSelected());
|
||||
getSelected(): TableSelectionData | undefined {
|
||||
const selected = this.host.selected;
|
||||
|
||||
if (selected instanceof TableSelection) {
|
||||
return selected.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
110
blocksuite/affine/block-table/src/selection-layer.ts
Normal file
110
blocksuite/affine/block-table/src/selection-layer.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { computed, effect, signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { SelectionController } from './selection-controller';
|
||||
|
||||
type Rect = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export class SelectionLayer extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
@property({ attribute: false })
|
||||
accessor selectionController!: SelectionController;
|
||||
@property({ attribute: false })
|
||||
accessor getRowRect!: (rowId: string) => Rect;
|
||||
@property({ attribute: false })
|
||||
accessor getColumnRect!: (columnId: string) => Rect;
|
||||
@property({ attribute: false })
|
||||
accessor getAreaRect!: (
|
||||
rowStartIndex: number,
|
||||
rowEndIndex: number,
|
||||
columnStartIndex: number,
|
||||
columnEndIndex: number
|
||||
) => Rect;
|
||||
|
||||
selection$ = computed(() => {
|
||||
return this.selectionController.selected$.value;
|
||||
});
|
||||
|
||||
computeRect = () => {
|
||||
const selection = this.selection$.value;
|
||||
if (!selection) return;
|
||||
if (selection.type === 'row') {
|
||||
const rect = this.getRowRect(selection.rowId);
|
||||
return rect;
|
||||
}
|
||||
if (selection.type === 'column') {
|
||||
const rect = this.getColumnRect(selection.columnId);
|
||||
return rect;
|
||||
}
|
||||
if (selection.type === 'area') {
|
||||
const rect = this.getAreaRect(
|
||||
selection.rowStartIndex,
|
||||
selection.rowEndIndex,
|
||||
selection.columnStartIndex,
|
||||
selection.columnEndIndex
|
||||
);
|
||||
return rect;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
rect$ = signal<Rect>();
|
||||
|
||||
private getSelectionStyle() {
|
||||
const rect = this.rect$.value;
|
||||
if (!rect)
|
||||
return styleMap({
|
||||
display: 'none',
|
||||
});
|
||||
const border = '2px solid var(--affine-primary-color)';
|
||||
return styleMap({
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
top: `${rect.top}px`,
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
height: `${rect.height}px`,
|
||||
borderRadius: '2px',
|
||||
border,
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const ob = new ResizeObserver(() => {
|
||||
this.rect$.value = this.computeRect();
|
||||
});
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
this.rect$.value = this.computeRect();
|
||||
})
|
||||
);
|
||||
const table = this.selectionController.host.querySelector('table');
|
||||
if (table) {
|
||||
ob.observe(table);
|
||||
this.disposables.add(() => {
|
||||
ob.unobserve(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html` <div style=${this.getSelectionStyle()}></div> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-table-selection-layer': SelectionLayer;
|
||||
}
|
||||
}
|
||||
118
blocksuite/affine/block-table/src/selection-schema.ts
Normal file
118
blocksuite/affine/block-table/src/selection-schema.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
|
||||
import { z } from 'zod';
|
||||
|
||||
const TableAreaSelectionSchema = z.object({
|
||||
type: z.literal('area'),
|
||||
rowStartIndex: z.number(),
|
||||
rowEndIndex: z.number(),
|
||||
columnStartIndex: z.number(),
|
||||
columnEndIndex: z.number(),
|
||||
});
|
||||
|
||||
export type TableAreaSelection = z.TypeOf<typeof TableAreaSelectionSchema>;
|
||||
|
||||
const TableRowSelectionSchema = z.object({
|
||||
type: z.literal('row'),
|
||||
rowId: z.string(),
|
||||
});
|
||||
|
||||
const TableColumnSelectionSchema = z.object({
|
||||
type: z.literal('column'),
|
||||
columnId: z.string(),
|
||||
});
|
||||
|
||||
const TableSelectionDataSchema = z.union([
|
||||
TableAreaSelectionSchema,
|
||||
TableRowSelectionSchema,
|
||||
TableColumnSelectionSchema,
|
||||
]);
|
||||
|
||||
export type TableSelectionData = z.TypeOf<typeof TableSelectionDataSchema>;
|
||||
export const TableSelectionData = {
|
||||
equals(a?: TableSelectionData, b?: TableSelectionData) {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (a == null || b == null) {
|
||||
return a === b;
|
||||
}
|
||||
if (a.type !== b.type) {
|
||||
return false;
|
||||
}
|
||||
if (a.type === 'area' && b.type === 'area') {
|
||||
return (
|
||||
a.rowStartIndex === b.rowStartIndex &&
|
||||
a.rowEndIndex === b.rowEndIndex &&
|
||||
a.columnStartIndex === b.columnStartIndex &&
|
||||
a.columnEndIndex === b.columnEndIndex
|
||||
);
|
||||
}
|
||||
if (a.type === 'row' && b.type === 'row') {
|
||||
return a.rowId === b.rowId;
|
||||
}
|
||||
if (a.type === 'column' && b.type === 'column') {
|
||||
return a.columnId === b.columnId;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
const TableSelectionSchema = z.object({
|
||||
blockId: z.string(),
|
||||
data: TableSelectionDataSchema,
|
||||
});
|
||||
|
||||
export class TableSelection extends BaseSelection {
|
||||
static override group = 'note';
|
||||
|
||||
static override type = 'table';
|
||||
|
||||
readonly data: TableSelectionData;
|
||||
|
||||
constructor({
|
||||
blockId,
|
||||
data,
|
||||
}: {
|
||||
blockId: string;
|
||||
data: TableSelectionData;
|
||||
}) {
|
||||
super({
|
||||
blockId,
|
||||
});
|
||||
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
static override fromJSON(json: Record<string, unknown>): TableSelection {
|
||||
TableSelectionSchema.parse(json);
|
||||
return new TableSelection({
|
||||
blockId: json.blockId as string,
|
||||
data: json.data as TableSelectionData,
|
||||
});
|
||||
}
|
||||
|
||||
override equals(other: BaseSelection): boolean {
|
||||
if (!(other instanceof TableSelection)) {
|
||||
return false;
|
||||
}
|
||||
return this.blockId === other.blockId;
|
||||
}
|
||||
|
||||
override toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
type: 'table',
|
||||
blockId: this.blockId,
|
||||
data: this.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface Selection {
|
||||
table: typeof TableSelection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const TableSelectionExtension = SelectionExtension(TableSelection);
|
||||
43
blocksuite/affine/block-table/src/table-block.css.ts
Normal file
43
blocksuite/affine/block-table/src/table-block.css.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const tableContainer = style({
|
||||
display: 'block',
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
padding: '10px 0 18px',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'visible',
|
||||
selectors: {
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:horizontal': {
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-track:horizontal': {
|
||||
backgroundColor: 'transparent',
|
||||
height: '8px',
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-thumb:horizontal': {
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--affine-black-30)',
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-track:horizontal': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
height: '8px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableWrapper = style({
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '8px',
|
||||
position: 'relative',
|
||||
width: 'max-content',
|
||||
});
|
||||
|
||||
export const table = style({});
|
||||
|
||||
export const rowStyle = style({});
|
||||
196
blocksuite/affine/block-table/src/table-block.ts
Normal file
196
blocksuite/affine/block-table/src/table-block.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import type { TableBlockModel } from '@blocksuite/affine-model';
|
||||
import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type BlockComponent,
|
||||
RANGE_SYNC_EXCLUDE_ATTR,
|
||||
} from '@blocksuite/block-std';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { SelectionController } from './selection-controller';
|
||||
import {
|
||||
rowStyle,
|
||||
table,
|
||||
tableContainer,
|
||||
tableWrapper,
|
||||
} from './table-block.css';
|
||||
import { TableDataManager } from './table-data-manager';
|
||||
|
||||
export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel> {
|
||||
private _dataManager: TableDataManager | null = null;
|
||||
|
||||
get dataManager(): TableDataManager {
|
||||
if (!this._dataManager) {
|
||||
this._dataManager = new TableDataManager(this.model);
|
||||
}
|
||||
return this._dataManager;
|
||||
}
|
||||
|
||||
selectionController = new SelectionController(this);
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(NOTE_SELECTOR);
|
||||
}
|
||||
return this.rootComponent;
|
||||
}
|
||||
|
||||
private readonly virtualPaddingController: VirtualPaddingController =
|
||||
new VirtualPaddingController(this);
|
||||
|
||||
table$ = signal<HTMLTableElement>();
|
||||
|
||||
private readonly getRootRect = () => {
|
||||
const table = this.table$.value;
|
||||
if (!table) return;
|
||||
return table.getBoundingClientRect();
|
||||
};
|
||||
|
||||
private readonly getRowRect = (rowId: string) => {
|
||||
const row = this.querySelector(`tr[data-row-id="${rowId}"]`);
|
||||
const rootRect = this.getRootRect();
|
||||
if (!row || !rootRect) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top - rootRect.top,
|
||||
left: rect.left - rootRect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
};
|
||||
|
||||
private readonly getColumnRect = (columnId: string) => {
|
||||
const columns = this.querySelectorAll(`td[data-column-id="${columnId}"]`);
|
||||
const rootRect = this.getRootRect();
|
||||
if (!rootRect) return;
|
||||
const firstRect = columns.item(0)?.getBoundingClientRect();
|
||||
const lastRect = columns.item(columns.length - 1)?.getBoundingClientRect();
|
||||
if (!firstRect || !lastRect) return;
|
||||
return {
|
||||
top: firstRect.top - rootRect.top,
|
||||
left: firstRect.left - rootRect.left,
|
||||
width: firstRect.width,
|
||||
height: lastRect.bottom - firstRect.top,
|
||||
};
|
||||
};
|
||||
|
||||
private readonly getAreaRect = (
|
||||
rowStartIndex: number,
|
||||
rowEndIndex: number,
|
||||
columnStartIndex: number,
|
||||
columnEndIndex: number
|
||||
) => {
|
||||
const rootRect = this.getRootRect();
|
||||
const rows = this.querySelectorAll('tr');
|
||||
const startRow = rows.item(rowStartIndex);
|
||||
const endRow = rows.item(rowEndIndex);
|
||||
if (!startRow || !endRow || !rootRect) return;
|
||||
const columns = startRow.querySelectorAll('td');
|
||||
const startColumn = columns.item(columnStartIndex);
|
||||
const endColumn = columns.item(columnEndIndex);
|
||||
if (!startColumn || !endColumn) return;
|
||||
const startRect = startRow.getBoundingClientRect();
|
||||
const endRect = endRow.getBoundingClientRect();
|
||||
const startColumnRect = startColumn.getBoundingClientRect();
|
||||
const endColumnRect = endColumn.getBoundingClientRect();
|
||||
return {
|
||||
top: startRect.top - rootRect.top,
|
||||
left: startColumnRect.left - rootRect.left,
|
||||
width: endColumnRect.right - startColumnRect.left,
|
||||
height: endRect.bottom - startRect.top,
|
||||
};
|
||||
};
|
||||
|
||||
override renderBlock() {
|
||||
const rows = this.dataManager.uiRows$.value;
|
||||
const columns = this.dataManager.uiColumns$.value;
|
||||
const virtualPadding = this.virtualPaddingController.virtualPadding$.value;
|
||||
return html`
|
||||
<div
|
||||
contenteditable="false"
|
||||
class=${tableContainer}
|
||||
style=${styleMap({
|
||||
marginLeft: `-${virtualPadding}px`,
|
||||
marginRight: `-${virtualPadding}px`,
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style=${styleMap({
|
||||
paddingLeft: `${virtualPadding}px`,
|
||||
paddingRight: `${virtualPadding}px`,
|
||||
width: 'max-content',
|
||||
})}
|
||||
>
|
||||
<table class=${tableWrapper} ${ref(this.table$)}>
|
||||
<tbody class=${table}>
|
||||
${repeat(
|
||||
rows,
|
||||
row => row.rowId,
|
||||
(row, rowIndex) => {
|
||||
return html`
|
||||
<tr class=${rowStyle} data-row-id=${row.rowId}>
|
||||
${repeat(
|
||||
columns,
|
||||
column => column.columnId,
|
||||
(column, columnIndex) => {
|
||||
const cell = this.dataManager.getCell(
|
||||
row.rowId,
|
||||
column.columnId
|
||||
);
|
||||
return html`
|
||||
<affine-table-cell
|
||||
style="display: contents;"
|
||||
.rowIndex=${rowIndex}
|
||||
.columnIndex=${columnIndex}
|
||||
.row=${row}
|
||||
.column=${column}
|
||||
.text=${cell?.text}
|
||||
.dataManager=${this.dataManager}
|
||||
.selectionController=${this.selectionController}
|
||||
></affine-table-cell>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</tbody>
|
||||
${IS_MOBILE
|
||||
? nothing
|
||||
: html`<affine-table-add-button
|
||||
style="display: contents;"
|
||||
.dataManager=${this.dataManager}
|
||||
></affine-table-add-button>`}
|
||||
${html`<affine-table-selection-layer
|
||||
style="display: contents;"
|
||||
.selectionController=${this.selectionController}
|
||||
.getRowRect=${this.getRowRect}
|
||||
.getColumnRect=${this.getColumnRect}
|
||||
.getAreaRect=${this.getAreaRect}
|
||||
></affine-table-selection-layer>`}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-table': TableBlockComponent;
|
||||
}
|
||||
}
|
||||
109
blocksuite/affine/block-table/src/table-cell.css.ts
Normal file
109
blocksuite/affine/block-table/src/table-cell.css.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { cssVar, cssVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const cellContainerStyle = style({
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
borderCollapse: 'collapse',
|
||||
isolation: 'auto',
|
||||
textAlign: 'start',
|
||||
verticalAlign: 'top',
|
||||
});
|
||||
|
||||
export const columnOptionsCellStyle = style({
|
||||
position: 'absolute',
|
||||
height: '0',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const columnOptionsStyle = style({
|
||||
cursor: 'pointer',
|
||||
zIndex: 2,
|
||||
width: '22px',
|
||||
height: '12px',
|
||||
backgroundColor: cssVarV2.table.headerBackground.default,
|
||||
borderRadius: '8px',
|
||||
boxShadow: cssVar('buttonShadow'),
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&.active': {
|
||||
opacity: 1,
|
||||
backgroundColor: cssVarV2.table.indicator.activated,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const rowOptionsCellStyle = style({
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const rowOptionsStyle = style({
|
||||
cursor: 'pointer',
|
||||
zIndex: 2,
|
||||
width: '12px',
|
||||
height: '22px',
|
||||
backgroundColor: cssVarV2.table.headerBackground.default,
|
||||
borderRadius: '8px',
|
||||
boxShadow: cssVar('buttonShadow'),
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&.active': {
|
||||
opacity: 1,
|
||||
backgroundColor: cssVarV2.table.indicator.activated,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const threePointerIconStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2px',
|
||||
});
|
||||
|
||||
export const threePointerIconDotStyle = style({
|
||||
width: '2px',
|
||||
height: '2px',
|
||||
backgroundColor: cssVarV2.icon.secondary,
|
||||
borderRadius: '50%',
|
||||
});
|
||||
|
||||
export const widthDragHandleStyle = style({
|
||||
position: 'absolute',
|
||||
top: '-1px',
|
||||
height: 'calc(100% + 2px)',
|
||||
right: '-3px',
|
||||
width: '5px',
|
||||
backgroundColor: cssVarV2.table.indicator.activated,
|
||||
cursor: 'ew-resize',
|
||||
zIndex: 2,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
});
|
||||
691
blocksuite/affine/block-table/src/table-cell.ts
Normal file
691
blocksuite/affine/block-table/src/table-cell.ts
Normal file
@@ -0,0 +1,691 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { TextBackgroundDuotoneIcon } from '@blocksuite/affine-components/icons';
|
||||
import {
|
||||
DefaultInlineManagerExtension,
|
||||
type RichText,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import type { TableColumn, TableRow } from '@blocksuite/affine-model';
|
||||
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import {
|
||||
ArrowDownBigIcon,
|
||||
ArrowLeftBigIcon,
|
||||
ArrowRightBigIcon,
|
||||
ArrowUpBigIcon,
|
||||
CloseIcon,
|
||||
ColorPickerIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
InsertAboveIcon,
|
||||
InsertBelowIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
PasteIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { Text } from '@blocksuite/store';
|
||||
import { computed, effect, signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { colorList } from './color';
|
||||
import { ColumnMaxWidth, DefaultColumnWidth } from './consts';
|
||||
import type { SelectionController } from './selection-controller';
|
||||
import {
|
||||
type TableAreaSelection,
|
||||
TableSelectionData,
|
||||
} from './selection-schema';
|
||||
import type { TableBlockComponent } from './table-block';
|
||||
import {
|
||||
cellContainerStyle,
|
||||
columnOptionsCellStyle,
|
||||
columnOptionsStyle,
|
||||
rowOptionsCellStyle,
|
||||
rowOptionsStyle,
|
||||
threePointerIconDotStyle,
|
||||
threePointerIconStyle,
|
||||
widthDragHandleStyle,
|
||||
} from './table-cell.css';
|
||||
import type { TableDataManager } from './table-data-manager';
|
||||
|
||||
export class TableCell extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
@property({ attribute: false })
|
||||
accessor text: Text | undefined = undefined;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor readonly = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor dataManager!: TableDataManager;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText: RichText | null = null;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor rowIndex = 0;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor columnIndex = 0;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor row: TableRow | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor column: TableColumn | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor selectionController!: SelectionController;
|
||||
|
||||
get hoverColumnIndex$() {
|
||||
return this.dataManager.hoverColumnIndex$;
|
||||
}
|
||||
get hoverRowIndex$() {
|
||||
return this.dataManager.hoverRowIndex$;
|
||||
}
|
||||
get inlineManager() {
|
||||
return this.closest<TableBlockComponent>('affine-table')?.std.get(
|
||||
DefaultInlineManagerExtension.identifier
|
||||
);
|
||||
}
|
||||
|
||||
get topContenteditableElement() {
|
||||
return this.closest<TableBlockComponent>('affine-table')
|
||||
?.topContenteditableElement;
|
||||
}
|
||||
|
||||
openColumnOptions(
|
||||
target: PopupTarget,
|
||||
column: TableColumn,
|
||||
columnIndex: number
|
||||
) {
|
||||
this.selectionController.setSelected({
|
||||
type: 'column',
|
||||
columnId: column.columnId,
|
||||
});
|
||||
popMenu(target, {
|
||||
options: {
|
||||
onClose: () => {
|
||||
this.selectionController.setSelected(undefined);
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.subMenu({
|
||||
name: 'Background color',
|
||||
prefix: ColorPickerIcon(),
|
||||
options: {
|
||||
items: [
|
||||
{ name: 'Default', color: undefined },
|
||||
...colorList,
|
||||
].map(item =>
|
||||
menu.action({
|
||||
prefix: html`<div
|
||||
style="color: ${item.color ??
|
||||
cssVarV2.layer.background
|
||||
.primary};display: flex;align-items: center;justify-content: center;"
|
||||
>
|
||||
${TextBackgroundDuotoneIcon}
|
||||
</div>`,
|
||||
name: item.name,
|
||||
isSelected: column.backgroundColor === item.color,
|
||||
select: () => {
|
||||
this.dataManager.setColumnBackgroundColor(
|
||||
column.columnId,
|
||||
item.color
|
||||
);
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
}),
|
||||
...(column.backgroundColor
|
||||
? [
|
||||
menu.action({
|
||||
name: 'Clear column style',
|
||||
prefix: CloseIcon(),
|
||||
select: () => {
|
||||
this.dataManager.setColumnBackgroundColor(
|
||||
column.columnId,
|
||||
undefined
|
||||
);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Insert Left',
|
||||
prefix: InsertLeftIcon(),
|
||||
select: () => {
|
||||
this.dataManager.insertColumn(columnIndex - 1);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Insert Right',
|
||||
prefix: InsertRightIcon(),
|
||||
select: () => {
|
||||
this.dataManager.insertColumn(columnIndex + 1);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Left',
|
||||
prefix: ArrowLeftBigIcon(),
|
||||
select: () => {
|
||||
this.dataManager.moveColumn(columnIndex, columnIndex - 2);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Right',
|
||||
prefix: ArrowRightBigIcon(),
|
||||
select: () => {
|
||||
this.dataManager.moveColumn(columnIndex, columnIndex + 1);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
select: () => {
|
||||
this.dataManager.duplicateColumn(columnIndex);
|
||||
},
|
||||
}),
|
||||
|
||||
menu.action({
|
||||
name: 'Clear column contents',
|
||||
prefix: CloseIcon(),
|
||||
select: () => {
|
||||
this.dataManager.clearColumn(column.columnId);
|
||||
},
|
||||
}),
|
||||
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
class: {
|
||||
'delete-item': true,
|
||||
},
|
||||
prefix: DeleteIcon(),
|
||||
select: () => {
|
||||
this.dataManager.deleteColumn(column.columnId);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openRowOptions(target: PopupTarget, row: TableRow, rowIndex: number) {
|
||||
this.selectionController.setSelected({
|
||||
type: 'row',
|
||||
rowId: row.rowId,
|
||||
});
|
||||
popMenu(target, {
|
||||
options: {
|
||||
onClose: () => {
|
||||
this.selectionController.setSelected(undefined);
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.subMenu({
|
||||
name: 'Background color',
|
||||
prefix: ColorPickerIcon(),
|
||||
options: {
|
||||
items: [
|
||||
{ name: 'Default', color: undefined },
|
||||
...colorList,
|
||||
].map(item =>
|
||||
menu.action({
|
||||
prefix: html`<div
|
||||
style="color: ${item.color ??
|
||||
cssVarV2.layer.background
|
||||
.primary};display: flex;align-items: center;justify-content: center;"
|
||||
>
|
||||
${TextBackgroundDuotoneIcon}
|
||||
</div>`,
|
||||
name: item.name,
|
||||
isSelected: row.backgroundColor === item.color,
|
||||
select: () => {
|
||||
this.dataManager.setRowBackgroundColor(
|
||||
row.rowId,
|
||||
item.color
|
||||
);
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
}),
|
||||
...(row.backgroundColor
|
||||
? [
|
||||
menu.action({
|
||||
name: 'Clear row style',
|
||||
prefix: CloseIcon(),
|
||||
select: () => {
|
||||
this.dataManager.setRowBackgroundColor(
|
||||
row.rowId,
|
||||
undefined
|
||||
);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Insert Above',
|
||||
prefix: InsertAboveIcon(),
|
||||
select: () => {
|
||||
this.dataManager.insertRow(rowIndex - 1);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Insert Below',
|
||||
prefix: InsertBelowIcon(),
|
||||
select: () => {
|
||||
this.dataManager.insertRow(rowIndex + 1);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Up',
|
||||
prefix: ArrowUpBigIcon(),
|
||||
select: () => {
|
||||
this.dataManager.moveRow(rowIndex, rowIndex - 1);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Down',
|
||||
prefix: ArrowDownBigIcon(),
|
||||
select: () => {
|
||||
this.dataManager.moveRow(rowIndex, rowIndex + 1);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
select: () => {
|
||||
this.dataManager.duplicateRow(rowIndex);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Clear row contents',
|
||||
prefix: CloseIcon(),
|
||||
select: () => {
|
||||
this.dataManager.clearRow(row.rowId);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
class: {
|
||||
'delete-item': true,
|
||||
},
|
||||
prefix: DeleteIcon(),
|
||||
select: () => {
|
||||
this.dataManager.deleteRow(row.rowId);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
createColorPickerMenu(
|
||||
currentColor: string | undefined,
|
||||
select: (color?: string) => void
|
||||
) {
|
||||
return menu.subMenu({
|
||||
name: 'Background color',
|
||||
prefix: ColorPickerIcon(),
|
||||
options: {
|
||||
items: [{ name: 'Default', color: undefined }, ...colorList].map(item =>
|
||||
menu.action({
|
||||
prefix: html`<div
|
||||
style="color: ${item.color ??
|
||||
cssVarV2.layer.background
|
||||
.primary};display: flex;align-items: center;justify-content: center;"
|
||||
>
|
||||
${TextBackgroundDuotoneIcon}
|
||||
</div>`,
|
||||
name: item.name,
|
||||
isSelected: currentColor === item.color,
|
||||
select: () => {
|
||||
select(item.color);
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onContextMenu(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selected = this.selectionController.selected$.value;
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
if (selected.type === 'area' && e.currentTarget instanceof HTMLElement) {
|
||||
const target = popupTargetFromElement(e.currentTarget);
|
||||
popMenu(target, {
|
||||
options: {
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Copy',
|
||||
prefix: CopyIcon(),
|
||||
select: () => {
|
||||
this.selectionController.doCopyOrCut(selected, false);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Paste',
|
||||
prefix: PasteIcon(),
|
||||
select: () => {
|
||||
navigator.clipboard.readText().then(text => {
|
||||
this.selectionController.doPaste(text, selected);
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Clear contents',
|
||||
prefix: CloseIcon(),
|
||||
select: () => {
|
||||
this.dataManager.clearCellsBySelection(selected);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderColumnOptions(column: TableColumn, columnIndex: number) {
|
||||
const openColumnOptions = (e: Event) => {
|
||||
const element = e.currentTarget;
|
||||
if (element instanceof HTMLElement) {
|
||||
this.openColumnOptions(
|
||||
popupTargetFromElement(element),
|
||||
column,
|
||||
columnIndex
|
||||
);
|
||||
}
|
||||
};
|
||||
return html`<div class=${columnOptionsCellStyle}>
|
||||
<div
|
||||
class=${classMap({
|
||||
[columnOptionsStyle]: true,
|
||||
})}
|
||||
style=${styleMap({
|
||||
opacity: columnIndex === this.hoverColumnIndex$.value ? 1 : undefined,
|
||||
})}
|
||||
@click=${openColumnOptions}
|
||||
>
|
||||
${threePointerIcon()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderRowOptions(row: TableRow, rowIndex: number) {
|
||||
const openRowOptions = (e: Event) => {
|
||||
const element = e.currentTarget;
|
||||
if (element instanceof HTMLElement) {
|
||||
this.openRowOptions(popupTargetFromElement(element), row, rowIndex);
|
||||
}
|
||||
};
|
||||
return html`<div class=${rowOptionsCellStyle}>
|
||||
<div
|
||||
class=${classMap({
|
||||
[rowOptionsStyle]: true,
|
||||
})}
|
||||
style=${styleMap({
|
||||
opacity: rowIndex === this.hoverRowIndex$.value ? 1 : undefined,
|
||||
})}
|
||||
@click=${openRowOptions}
|
||||
>
|
||||
${threePointerIcon(true)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
renderOptionsButton() {
|
||||
if (!this.row || !this.column) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
${this.rowIndex === 0
|
||||
? this.renderColumnOptions(this.column, this.columnIndex)
|
||||
: nothing}
|
||||
${this.columnIndex === 0
|
||||
? this.renderRowOptions(this.row, this.rowIndex)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
tdMouseEnter(rowIndex: number, columnIndex: number) {
|
||||
this.hoverColumnIndex$.value = columnIndex;
|
||||
this.hoverRowIndex$.value = rowIndex;
|
||||
}
|
||||
|
||||
tdMouseLeave() {
|
||||
this.hoverColumnIndex$.value = undefined;
|
||||
this.hoverRowIndex$.value = undefined;
|
||||
}
|
||||
|
||||
virtualWidth$ = computed(() => {
|
||||
const virtualWidth = this.dataManager.virtualWidth$.value;
|
||||
if (!virtualWidth || this.column?.columnId !== virtualWidth.columnId) {
|
||||
return undefined;
|
||||
}
|
||||
return virtualWidth.width;
|
||||
});
|
||||
|
||||
tdStyle() {
|
||||
const columnWidth = this.virtualWidth$.value ?? this.column?.width;
|
||||
const backgroundColor =
|
||||
this.column?.backgroundColor ?? this.row?.backgroundColor ?? undefined;
|
||||
return styleMap({
|
||||
backgroundColor,
|
||||
minWidth: columnWidth ? `${columnWidth}px` : `${DefaultColumnWidth}px`,
|
||||
maxWidth: columnWidth ? `${columnWidth}px` : `${ColumnMaxWidth}px`,
|
||||
});
|
||||
}
|
||||
|
||||
renderWidthDragHandle() {
|
||||
const hoverColumnId$ = this.dataManager.hoverDragHandleColumnId$;
|
||||
const draggingColumnId$ = this.dataManager.draggingColumnId$;
|
||||
const rowIndex = this.rowIndex;
|
||||
const isFirstRow = rowIndex === 0;
|
||||
const isLastRow = rowIndex === this.dataManager.uiRows$.value.length - 1;
|
||||
const show =
|
||||
draggingColumnId$.value === this.column?.columnId ||
|
||||
hoverColumnId$.value === this.column?.columnId;
|
||||
return html`<div
|
||||
@mouseenter=${() => {
|
||||
hoverColumnId$.value = this.column?.columnId;
|
||||
}}
|
||||
@mouseleave=${() => {
|
||||
hoverColumnId$.value = undefined;
|
||||
}}
|
||||
style=${styleMap({
|
||||
opacity: show ? 1 : 0,
|
||||
borderRadius: isFirstRow
|
||||
? '3px 3px 0 0'
|
||||
: isLastRow
|
||||
? '0 0 3px 3px'
|
||||
: '0',
|
||||
})}
|
||||
data-width-adjust-column-id=${this.column?.columnId}
|
||||
class=${widthDragHandleStyle}
|
||||
></div>`;
|
||||
}
|
||||
|
||||
richText$ = signal<RichText>();
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText$.value?.inlineEditor;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const selectAll = (e: KeyboardEvent) => {
|
||||
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.inlineEditor?.selectAll();
|
||||
}
|
||||
};
|
||||
this.addEventListener('keydown', selectAll);
|
||||
this.disposables.add(() => {
|
||||
this.removeEventListener('keydown', selectAll);
|
||||
});
|
||||
this.disposables.addFromEvent(this, 'click', (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.inlineEditor?.inlineRange$.value) {
|
||||
this.inlineEditor?.focusEnd();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this.richText$.value?.updateComplete
|
||||
.then(() => {
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const richText = this.richText$.value;
|
||||
if (!richText) {
|
||||
return;
|
||||
}
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) {
|
||||
return;
|
||||
}
|
||||
const inlineRange = inlineEditor.inlineRange$.value;
|
||||
const targetSelection: TableAreaSelection = {
|
||||
type: 'area',
|
||||
rowStartIndex: this.rowIndex,
|
||||
rowEndIndex: this.rowIndex,
|
||||
columnStartIndex: this.columnIndex,
|
||||
columnEndIndex: this.columnIndex,
|
||||
};
|
||||
const currentSelection = this.selectionController.selected$.peek();
|
||||
if (
|
||||
inlineRange &&
|
||||
!TableSelectionData.equals(targetSelection, currentSelection)
|
||||
) {
|
||||
this.selectionController.setSelected(targetSelection, false);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.text) {
|
||||
return html`<td class=${cellContainerStyle} style=${this.tdStyle()}>
|
||||
<div
|
||||
style=${styleMap({
|
||||
padding: '8px 12px',
|
||||
})}
|
||||
>
|
||||
<div style="height:22px"></div>
|
||||
</div>
|
||||
</td>`;
|
||||
}
|
||||
return html`
|
||||
<td
|
||||
data-row-id=${this.row?.rowId}
|
||||
data-column-id=${this.column?.columnId}
|
||||
@mouseenter=${() => {
|
||||
this.tdMouseEnter(this.rowIndex, this.columnIndex);
|
||||
}}
|
||||
@mouseleave=${() => {
|
||||
this.tdMouseLeave();
|
||||
}}
|
||||
@contextmenu=${this.onContextMenu}
|
||||
class=${cellContainerStyle}
|
||||
style=${this.tdStyle()}
|
||||
>
|
||||
<rich-text
|
||||
${ref(this.richText$)}
|
||||
data-disable-ask-ai
|
||||
data-not-block-text
|
||||
style=${styleMap({
|
||||
minHeight: '22px',
|
||||
padding: '8px 12px',
|
||||
})}
|
||||
.yText="${this.text}"
|
||||
.inlineEventSource="${this.topContenteditableElement}"
|
||||
.attributesSchema="${this.inlineManager?.getSchema()}"
|
||||
.attributeRenderer="${this.inlineManager?.getRenderer()}"
|
||||
.embedChecker="${this.inlineManager?.embedChecker}"
|
||||
.markdownShortcutHandler="${this.inlineManager
|
||||
?.markdownShortcutHandler}"
|
||||
.readonly="${this.readonly}"
|
||||
.enableClipboard="${true}"
|
||||
.verticalScrollContainerGetter="${() =>
|
||||
this.topContenteditableElement?.host
|
||||
? getViewportElement(this.topContenteditableElement.host)
|
||||
: null}"
|
||||
data-parent-flavour="affine:table"
|
||||
></rich-text>
|
||||
${this.renderOptionsButton()} ${this.renderWidthDragHandle()}
|
||||
</td>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const threePointerIcon = (vertical: boolean = false) => {
|
||||
return html`
|
||||
<div
|
||||
class=${threePointerIconStyle}
|
||||
style=${styleMap({
|
||||
transform: vertical ? 'rotate(90deg)' : undefined,
|
||||
})}
|
||||
>
|
||||
<div class=${threePointerIconDotStyle}></div>
|
||||
<div class=${threePointerIconDotStyle}></div>
|
||||
<div class=${threePointerIconDotStyle}></div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-table-cell': TableCell;
|
||||
}
|
||||
}
|
||||
373
blocksuite/affine/block-table/src/table-data-manager.ts
Normal file
373
blocksuite/affine/block-table/src/table-data-manager.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import type { TableBlockModel, TableCell } from '@blocksuite/affine-model';
|
||||
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
|
||||
import { nanoid, Text } from '@blocksuite/store';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
|
||||
import type { TableAreaSelection } from './selection-schema';
|
||||
|
||||
export class TableDataManager {
|
||||
constructor(private readonly model: TableBlockModel) {}
|
||||
|
||||
hoverColumnIndex$ = signal<number>();
|
||||
hoverRowIndex$ = signal<number>();
|
||||
hoverDragHandleColumnId$ = signal<string>();
|
||||
draggingColumnId$ = signal<string>();
|
||||
virtualColumnCount$ = signal<number>(0);
|
||||
virtualRowCount$ = signal<number>(0);
|
||||
virtualWidth$ = signal<{ columnId: string; width: number } | undefined>();
|
||||
cellCountTips$ = computed(
|
||||
() =>
|
||||
`${this.virtualRowCount$.value + this.rows$.value.length} x ${this.virtualColumnCount$.value + this.columns$.value.length}`
|
||||
);
|
||||
rows$ = computed(() => {
|
||||
return Object.values(this.model.rows$.value).sort((a, b) =>
|
||||
a.order > b.order ? 1 : -1
|
||||
);
|
||||
});
|
||||
|
||||
columns$ = computed(() => {
|
||||
return Object.values(this.model.columns$.value).sort((a, b) =>
|
||||
a.order > b.order ? 1 : -1
|
||||
);
|
||||
});
|
||||
|
||||
uiRows$ = computed(() => {
|
||||
const virtualRowCount = this.virtualRowCount$.value;
|
||||
const rows = this.rows$.value;
|
||||
if (virtualRowCount === 0) {
|
||||
return rows;
|
||||
}
|
||||
if (virtualRowCount > 0) {
|
||||
return [
|
||||
...rows,
|
||||
...Array.from({ length: virtualRowCount }, (_, i) => ({
|
||||
rowId: `${i}`,
|
||||
backgroundColor: undefined,
|
||||
})),
|
||||
];
|
||||
}
|
||||
return rows.slice(0, rows.length + virtualRowCount);
|
||||
});
|
||||
|
||||
uiColumns$ = computed(() => {
|
||||
const virtualColumnCount = this.virtualColumnCount$.value;
|
||||
const columns = this.columns$.value;
|
||||
if (virtualColumnCount === 0) {
|
||||
return columns;
|
||||
}
|
||||
if (virtualColumnCount > 0) {
|
||||
return [
|
||||
...columns,
|
||||
...Array.from({ length: virtualColumnCount }, (_, i) => ({
|
||||
columnId: `${i}`,
|
||||
backgroundColor: undefined,
|
||||
width: undefined,
|
||||
})),
|
||||
];
|
||||
}
|
||||
return columns.slice(0, columns.length + virtualColumnCount);
|
||||
});
|
||||
|
||||
getCell(rowId: string, columnId: string): TableCell | undefined {
|
||||
return this.model.cells$.value[`${rowId}:${columnId}`];
|
||||
}
|
||||
|
||||
addRow(after?: number) {
|
||||
const order = this.getOrder(this.rows$.value, after);
|
||||
const rowId = nanoid();
|
||||
this.model.doc.transact(() => {
|
||||
this.model.rows[rowId] = {
|
||||
rowId,
|
||||
order,
|
||||
};
|
||||
this.columns$.value.forEach(column => {
|
||||
this.model.cells[`${rowId}:${column.columnId}`] = {
|
||||
text: new Text(),
|
||||
};
|
||||
});
|
||||
});
|
||||
return rowId;
|
||||
}
|
||||
addNRow(count: number) {
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
if (count > 0) {
|
||||
this.model.doc.transact(() => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.addRow(this.rows$.value.length - 1);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const rows = this.rows$.value;
|
||||
const rowCount = rows.length;
|
||||
this.model.doc.transact(() => {
|
||||
rows.slice(rowCount + count, rowCount).forEach(row => {
|
||||
this.deleteRow(row.rowId);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addNColumn(count: number) {
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
if (count > 0) {
|
||||
this.model.doc.transact(() => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.addColumn(this.columns$.value.length - 1);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const columns = this.columns$.value;
|
||||
const columnCount = columns.length;
|
||||
this.model.doc.transact(() => {
|
||||
columns.slice(columnCount + count, columnCount).forEach(column => {
|
||||
this.deleteColumn(column.columnId);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getOrder<T extends { order: string }>(array: T[], after?: number) {
|
||||
after = after != null ? (after < 0 ? undefined : after) : undefined;
|
||||
const prevOrder = after == null ? null : array[after]?.order;
|
||||
const nextOrder = after == null ? array[0]?.order : array[after + 1]?.order;
|
||||
const order = generateFractionalIndexingKeyBetween(
|
||||
prevOrder ?? null,
|
||||
nextOrder ?? null
|
||||
);
|
||||
return order;
|
||||
}
|
||||
|
||||
addColumn(after?: number) {
|
||||
const order = this.getOrder(this.columns$.value, after);
|
||||
const columnId = nanoid();
|
||||
this.model.doc.transact(() => {
|
||||
this.model.columns[columnId] = {
|
||||
columnId,
|
||||
order,
|
||||
};
|
||||
this.rows$.value.forEach(row => {
|
||||
this.model.cells[`${row.rowId}:${columnId}`] = {
|
||||
text: new Text(),
|
||||
};
|
||||
});
|
||||
});
|
||||
return columnId;
|
||||
}
|
||||
|
||||
deleteRow(rowId: string) {
|
||||
this.model.doc.transact(() => {
|
||||
Object.keys(this.model.rows).forEach(id => {
|
||||
if (id === rowId) {
|
||||
delete this.model.rows[id];
|
||||
}
|
||||
});
|
||||
Object.keys(this.model.cells).forEach(id => {
|
||||
if (id.startsWith(rowId)) {
|
||||
delete this.model.cells[id];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deleteColumn(columnId: string) {
|
||||
this.model.doc.transact(() => {
|
||||
Object.keys(this.model.columns).forEach(id => {
|
||||
if (id === columnId) {
|
||||
delete this.model.columns[id];
|
||||
}
|
||||
});
|
||||
Object.keys(this.model.cells).forEach(id => {
|
||||
if (id.endsWith(`:${columnId}`)) {
|
||||
delete this.model.cells[id];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateRowOrder(rowId: string, newOrder: string) {
|
||||
this.model.doc.transact(() => {
|
||||
if (this.model.rows[rowId]) {
|
||||
this.model.rows[rowId].order = newOrder;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateColumnOrder(columnId: string, newOrder: string) {
|
||||
this.model.doc.transact(() => {
|
||||
if (this.model.columns[columnId]) {
|
||||
this.model.columns[columnId].order = newOrder;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setRowBackgroundColor(rowId: string, color?: string) {
|
||||
this.model.doc.transact(() => {
|
||||
if (this.model.rows[rowId]) {
|
||||
this.model.rows[rowId].backgroundColor = color;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setColumnBackgroundColor(columnId: string, color?: string) {
|
||||
this.model.doc.transact(() => {
|
||||
if (this.model.columns[columnId]) {
|
||||
this.model.columns[columnId].backgroundColor = color;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setColumnWidth(columnId: string, width: number) {
|
||||
this.model.doc.transact(() => {
|
||||
if (this.model.columns[columnId]) {
|
||||
this.model.columns[columnId].width = width;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearRow(rowId: string) {
|
||||
this.model.doc.transact(() => {
|
||||
Object.keys(this.model.cells).forEach(id => {
|
||||
if (id.startsWith(rowId)) {
|
||||
this.model.cells[id]?.text.replace(
|
||||
0,
|
||||
this.model.cells[id]?.text.length,
|
||||
''
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clearColumn(columnId: string) {
|
||||
this.model.doc.transact(() => {
|
||||
Object.keys(this.model.cells).forEach(id => {
|
||||
if (id.endsWith(`:${columnId}`)) {
|
||||
this.model.cells[id]?.text.replace(
|
||||
0,
|
||||
this.model.cells[id]?.text.length,
|
||||
''
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clearCellsBySelection(selection: TableAreaSelection) {
|
||||
const columns = this.uiColumns$.value;
|
||||
const rows = this.uiRows$.value;
|
||||
const deleteCells: { rowId: string; columnId: string }[] = [];
|
||||
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
|
||||
const row = rows[i];
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let j = selection.columnStartIndex;
|
||||
j <= selection.columnEndIndex;
|
||||
j++
|
||||
) {
|
||||
const column = columns[j];
|
||||
if (!column) {
|
||||
continue;
|
||||
}
|
||||
deleteCells.push({ rowId: row.rowId, columnId: column.columnId });
|
||||
}
|
||||
}
|
||||
this.clearCells(deleteCells);
|
||||
}
|
||||
|
||||
clearCells(cells: { rowId: string; columnId: string }[]) {
|
||||
this.model.doc.transact(() => {
|
||||
cells.forEach(({ rowId, columnId }) => {
|
||||
const text = this.model.cells[`${rowId}:${columnId}`]?.text;
|
||||
if (text) {
|
||||
text.replace(0, text.length, '');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
insertColumn(after?: number) {
|
||||
this.addColumn(after);
|
||||
}
|
||||
|
||||
insertRow(after?: number) {
|
||||
this.addRow(after);
|
||||
}
|
||||
|
||||
moveColumn(from: number, after?: number) {
|
||||
const columns = this.columns$.value;
|
||||
const column = columns[from];
|
||||
if (!column) return;
|
||||
const order = this.getOrder(columns, after);
|
||||
this.model.doc.transact(() => {
|
||||
const realColumn = this.model.columns[column.columnId];
|
||||
if (realColumn) {
|
||||
realColumn.order = order;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
moveRow(from: number, after?: number) {
|
||||
const rows = this.rows$.value;
|
||||
const row = rows[from];
|
||||
if (!row) return;
|
||||
const order = this.getOrder(rows, after);
|
||||
this.model.doc.transact(() => {
|
||||
const realRow = this.model.rows[row.rowId];
|
||||
if (realRow) {
|
||||
realRow.order = order;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
duplicateColumn(index: number) {
|
||||
const oldColumn = this.columns$.value[index];
|
||||
if (!oldColumn) return;
|
||||
const order = this.getOrder(this.columns$.value, index);
|
||||
const newColumnId = nanoid();
|
||||
this.model.doc.transact(() => {
|
||||
this.model.columns[newColumnId] = {
|
||||
...oldColumn,
|
||||
columnId: newColumnId,
|
||||
order,
|
||||
};
|
||||
this.rows$.value.forEach(row => {
|
||||
this.model.cells[`${row.rowId}:${newColumnId}`] = {
|
||||
text:
|
||||
this.model.cells[
|
||||
`${row.rowId}:${oldColumn.columnId}`
|
||||
]?.text.clone() ?? new Text(),
|
||||
};
|
||||
});
|
||||
});
|
||||
return newColumnId;
|
||||
}
|
||||
|
||||
duplicateRow(index: number) {
|
||||
const oldRow = this.rows$.value[index];
|
||||
if (!oldRow) return;
|
||||
const order = this.getOrder(this.rows$.value, index);
|
||||
const newRowId = nanoid();
|
||||
this.model.doc.transact(() => {
|
||||
this.model.rows[newRowId] = {
|
||||
...oldRow,
|
||||
rowId: newRowId,
|
||||
order,
|
||||
};
|
||||
this.columns$.value.forEach(column => {
|
||||
this.model.cells[`${newRowId}:${column.columnId}`] = {
|
||||
text:
|
||||
this.model.cells[
|
||||
`${oldRow.rowId}:${column.columnId}`
|
||||
]?.text.clone() ?? new Text(),
|
||||
};
|
||||
});
|
||||
});
|
||||
return newRowId;
|
||||
}
|
||||
}
|
||||
18
blocksuite/affine/block-table/src/table-spec.ts
Normal file
18
blocksuite/affine/block-table/src/table-spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { TableModelFlavour } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockViewExtension,
|
||||
CommandExtension,
|
||||
FlavourExtension,
|
||||
} from '@blocksuite/block-std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { TableBlockAdapterExtensions } from './adapters/extension.js';
|
||||
import { tableCommands } from './commands.js';
|
||||
|
||||
export const TableBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension(TableModelFlavour),
|
||||
CommandExtension(tableCommands),
|
||||
BlockViewExtension(TableModelFlavour, literal`affine-table`),
|
||||
TableBlockAdapterExtensions,
|
||||
].flat();
|
||||
Reference in New Issue
Block a user