mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
44
blocksuite/affine/blocks/table/package.json
Normal file
44
blocksuite/affine/blocks/table/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-table",
|
||||
"description": "Table block for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-inline-preset": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.10",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
"lit": "^3.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"yjs": "^13.6.21",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.21.0"
|
||||
}
|
||||
13
blocksuite/affine/blocks/table/src/adapters/extension.ts
Normal file
13
blocksuite/affine/blocks/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,
|
||||
];
|
||||
129
blocksuite/affine/blocks/table/src/adapters/html.ts
Normal file
129
blocksuite/affine/blocks/table/src/adapters/html.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
type TableBlockPropsSerialized,
|
||||
TableBlockSchema,
|
||||
TableModelFlavour,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
type InlineHtmlAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Element } from 'hast';
|
||||
|
||||
import { DefaultColumnWidth } from '../consts';
|
||||
import { parseTableFromHtml, processTable } from './utils';
|
||||
|
||||
const TABLE_NODE_TYPES = new Set(['table', 'thead', 'tbody', 'th', 'tr']);
|
||||
|
||||
export const tableBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: TableBlockSchema.model.flavour,
|
||||
toMatch: o => {
|
||||
return HastUtils.isElement(o.node) && TABLE_NODE_TYPES.has(o.node.tagName);
|
||||
},
|
||||
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
if (o.node.tagName === 'table') {
|
||||
const astToDelta = context.deltaConverter.astToDelta.bind(
|
||||
context.deltaConverter
|
||||
);
|
||||
const tableProps = parseTableFromHtml(o.node, astToDelta);
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: TableModelFlavour,
|
||||
props: tableProps as unknown as Record<string, unknown>,
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
if (o.node.tagName === 'table') {
|
||||
walkerContext.closeNode();
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
const { columns, rows, cells } = o.node
|
||||
.props as unknown as TableBlockPropsSerialized;
|
||||
const table = processTable(columns, rows, cells);
|
||||
const createAstTableCell = (
|
||||
children: InlineHtmlAST[]
|
||||
): InlineHtmlAST => ({
|
||||
type: 'element',
|
||||
tagName: 'td',
|
||||
properties: Object.create(null),
|
||||
children: [
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: {
|
||||
style: `min-height: 22px;min-width:${DefaultColumnWidth}px;padding: 8px 12px;`,
|
||||
},
|
||||
children,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createAstTableRow = (cells: InlineHtmlAST[]): Element => ({
|
||||
type: 'element',
|
||||
tagName: 'tr',
|
||||
properties: Object.create(null),
|
||||
children: cells,
|
||||
});
|
||||
|
||||
const { deltaConverter } = context;
|
||||
|
||||
const tableBodyAst: Element = {
|
||||
type: 'element',
|
||||
tagName: 'tbody',
|
||||
properties: Object.create(null),
|
||||
children: table.rows.map(v => {
|
||||
return createAstTableRow(
|
||||
v.cells.map(cell => {
|
||||
return createAstTableCell(
|
||||
typeof cell.value === 'string'
|
||||
? [{ type: 'text', value: cell.value }]
|
||||
: deltaConverter.deltaToAST(cell.value.delta)
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'element',
|
||||
tagName: 'table',
|
||||
properties: {
|
||||
border: true,
|
||||
style: 'border-collapse: collapse;border-spacing: 0;',
|
||||
},
|
||||
children: [tableBodyAst],
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TableBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
tableBlockHtmlAdapterMatcher
|
||||
);
|
||||
4
blocksuite/affine/blocks/table/src/adapters/index.ts
Normal file
4
blocksuite/affine/blocks/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';
|
||||
82
blocksuite/affine/blocks/table/src/adapters/markdown.ts
Normal file
82
blocksuite/affine/blocks/table/src/adapters/markdown.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
type TableBlockPropsSerialized,
|
||||
TableBlockSchema,
|
||||
TableModelFlavour,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { TableRow } from 'mdast';
|
||||
|
||||
import { parseTableFromMarkdown, processTable } from './utils';
|
||||
|
||||
const TABLE_NODE_TYPES = new Set(['table', 'tableRow']);
|
||||
|
||||
const isTableNode = (node: MarkdownAST) => TABLE_NODE_TYPES.has(node.type);
|
||||
|
||||
export const tableBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
|
||||
flavour: TableBlockSchema.model.flavour,
|
||||
toMatch: o => isTableNode(o.node),
|
||||
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
if (o.node.type === 'table') {
|
||||
const astToDelta = context.deltaConverter.astToDelta.bind(
|
||||
context.deltaConverter
|
||||
);
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: TableModelFlavour,
|
||||
props: parseTableFromMarkdown(o.node, astToDelta),
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
if (o.node.type === 'table') {
|
||||
walkerContext.closeNode();
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const { columns, rows, cells } = o.node
|
||||
.props as unknown as TableBlockPropsSerialized;
|
||||
const table = processTable(columns, rows, cells);
|
||||
const result: TableRow[] = [];
|
||||
table.rows.forEach(v => {
|
||||
result.push({
|
||||
type: 'tableRow',
|
||||
children: v.cells.map(v => ({
|
||||
type: 'tableCell',
|
||||
children: deltaConverter.deltaToAST(v.value.delta),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'table',
|
||||
children: result,
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TableBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
|
||||
tableBlockMarkdownAdapterMatcher
|
||||
);
|
||||
21
blocksuite/affine/blocks/table/src/adapters/notion-html.ts
Normal file
21
blocksuite/affine/blocks/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);
|
||||
79
blocksuite/affine/blocks/table/src/adapters/plain-text.ts
Normal file
79
blocksuite/affine/blocks/table/src/adapters/plain-text.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
type TableBlockPropsSerialized,
|
||||
TableBlockSchema,
|
||||
TableModelFlavour,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
import { createTableProps, formatTable, processTable } from './utils.js';
|
||||
|
||||
export const tableBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = {
|
||||
flavour: TableBlockSchema.model.flavour,
|
||||
toMatch: () => true,
|
||||
fromMatch: o => o.node.flavour === TableBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
const text = o.node.content;
|
||||
const rowTexts = text.split('\n');
|
||||
if (rowTexts.length <= 1) return;
|
||||
const rowTextLists: DeltaInsert[][][] = [];
|
||||
let columnCount: number | null = null;
|
||||
for (const row of rowTexts) {
|
||||
const cells = row.split('\t').map<DeltaInsert[]>(text => [
|
||||
{
|
||||
insert: text,
|
||||
},
|
||||
]);
|
||||
if (cells.length <= 1) return;
|
||||
if (columnCount == null) {
|
||||
columnCount = cells.length;
|
||||
} else if (columnCount !== cells.length) {
|
||||
return;
|
||||
}
|
||||
rowTextLists.push(cells);
|
||||
}
|
||||
const tableProps = createTableProps(rowTextLists);
|
||||
walkerContext.openNode({
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: TableModelFlavour,
|
||||
props: tableProps,
|
||||
children: [],
|
||||
});
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
leave: (_, context) => {
|
||||
const { walkerContext } = context;
|
||||
walkerContext.closeNode();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const result: string[][] = [];
|
||||
const { columns, rows, cells } = o.node
|
||||
.props as unknown as TableBlockPropsSerialized;
|
||||
const table = processTable(columns, rows, cells);
|
||||
table.rows.forEach(v => {
|
||||
result.push(
|
||||
v.cells.map(v => deltaConverter.deltaToAST(v.value.delta).join(''))
|
||||
);
|
||||
});
|
||||
|
||||
const tableString = formatTable(result);
|
||||
|
||||
context.textBuffer.content += tableString;
|
||||
context.textBuffer.content += '\n';
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TableBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(tableBlockPlainTextAdapterMatcher);
|
||||
212
blocksuite/affine/blocks/table/src/adapters/utils.ts
Normal file
212
blocksuite/affine/blocks/table/src/adapters/utils.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type {
|
||||
TableBlockPropsSerialized,
|
||||
TableCellSerialized,
|
||||
TableColumn,
|
||||
TableRow,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
type HtmlAST,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { HastUtils } from '@blocksuite/affine-shared/adapters';
|
||||
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Element } from 'hast';
|
||||
import type { Table as MarkdownTable } from 'mdast';
|
||||
|
||||
type RichTextType = DeltaInsert[];
|
||||
const createRichText = (text: RichTextType) => {
|
||||
return {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: text,
|
||||
};
|
||||
};
|
||||
function calculateColumnWidths(rows: string[][]): number[] {
|
||||
return (
|
||||
rows[0]?.map((_, colIndex) =>
|
||||
Math.max(...rows.map(row => (row[colIndex] || '').length))
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function formatRow(
|
||||
row: string[],
|
||||
columnWidths: number[],
|
||||
isHeader: boolean
|
||||
): string {
|
||||
const cells = row.map((cell, colIndex) =>
|
||||
cell?.padEnd(columnWidths[colIndex] ?? 0, ' ')
|
||||
);
|
||||
const rowString = `| ${cells.join(' | ')} |`;
|
||||
return isHeader
|
||||
? `${rowString}\n${formatSeparator(columnWidths)}`
|
||||
: rowString;
|
||||
}
|
||||
|
||||
function formatSeparator(columnWidths: number[]): string {
|
||||
const separator = columnWidths.map(width => '-'.repeat(width)).join(' | ');
|
||||
return `| ${separator} |`;
|
||||
}
|
||||
|
||||
export function formatTable(rows: string[][]): string {
|
||||
const columnWidths = calculateColumnWidths(rows);
|
||||
const formattedRows = rows.map((row, index) =>
|
||||
formatRow(row, columnWidths, index === 0)
|
||||
);
|
||||
return formattedRows.join('\n');
|
||||
}
|
||||
type Table = {
|
||||
rows: Row[];
|
||||
};
|
||||
type Row = {
|
||||
cells: Cell[];
|
||||
};
|
||||
type Cell = {
|
||||
value: { delta: DeltaInsert[] };
|
||||
};
|
||||
export const processTable = (
|
||||
columns: Record<string, TableColumn>,
|
||||
rows: Record<string, TableRow>,
|
||||
cells: Record<string, TableCellSerialized>
|
||||
): Table => {
|
||||
const sortedColumns = Object.values(columns).sort((a, b) =>
|
||||
a.order.localeCompare(b.order)
|
||||
);
|
||||
const sortedRows = Object.values(rows).sort((a, b) =>
|
||||
a.order.localeCompare(b.order)
|
||||
);
|
||||
const table: Table = {
|
||||
rows: [],
|
||||
};
|
||||
sortedRows.forEach(r => {
|
||||
const row: Row = {
|
||||
cells: [],
|
||||
};
|
||||
sortedColumns.forEach(col => {
|
||||
const cell = cells[`${r.rowId}:${col.columnId}`];
|
||||
if (!cell) {
|
||||
row.cells.push({
|
||||
value: {
|
||||
delta: [],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
row.cells.push({
|
||||
value: cell.text,
|
||||
});
|
||||
});
|
||||
table.rows.push(row);
|
||||
});
|
||||
return table;
|
||||
};
|
||||
|
||||
const getAllTag = (node: Element | undefined, tagName: string): Element[] => {
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
if (HastUtils.isElement(node)) {
|
||||
if (node.tagName === tagName) {
|
||||
return [node];
|
||||
}
|
||||
return node.children.flatMap(child => {
|
||||
if (HastUtils.isElement(child)) {
|
||||
return getAllTag(child, tagName);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const createTableProps = (deltasLists: RichTextType[][]) => {
|
||||
const createIdAndOrder = (count: number) => {
|
||||
const result: { id: string; order: string }[] = Array.from({
|
||||
length: count,
|
||||
});
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = nanoid();
|
||||
const order = generateFractionalIndexingKeyBetween(
|
||||
result[i - 1]?.order ?? null,
|
||||
null
|
||||
);
|
||||
result[i] = { id, order };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const columnCount = Math.max(...deltasLists.map(row => row.length));
|
||||
const rowCount = deltasLists.length;
|
||||
|
||||
const columns: TableColumn[] = createIdAndOrder(columnCount).map(v => ({
|
||||
columnId: v.id,
|
||||
order: v.order,
|
||||
}));
|
||||
const rows: TableRow[] = createIdAndOrder(rowCount).map(v => ({
|
||||
rowId: v.id,
|
||||
order: v.order,
|
||||
}));
|
||||
|
||||
const cells: Record<string, TableCellSerialized> = {};
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
for (let j = 0; j < columnCount; j++) {
|
||||
const row = rows[i];
|
||||
const column = columns[j];
|
||||
if (!row || !column) {
|
||||
continue;
|
||||
}
|
||||
const cellId = `${row.rowId}:${column.columnId}`;
|
||||
const text = deltasLists[i]?.[j];
|
||||
cells[cellId] = {
|
||||
text: createRichText(text ?? []),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
columns: Object.fromEntries(
|
||||
columns.map(column => [column.columnId, column])
|
||||
),
|
||||
rows: Object.fromEntries(rows.map(row => [row.rowId, row])),
|
||||
cells,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseTableFromHtml = (
|
||||
element: Element,
|
||||
astToDelta: (ast: HtmlAST) => RichTextType
|
||||
): TableBlockPropsSerialized => {
|
||||
const headerRows = getAllTag(element, 'thead').flatMap(node =>
|
||||
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'th'))
|
||||
);
|
||||
const bodyRows = getAllTag(element, 'tbody').flatMap(node =>
|
||||
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'td'))
|
||||
);
|
||||
const footerRows = getAllTag(element, 'tfoot').flatMap(node =>
|
||||
getAllTag(node, 'tr').map(tr => getAllTag(tr, 'td'))
|
||||
);
|
||||
const allRows = [...headerRows, ...bodyRows, ...footerRows];
|
||||
const rowTextLists: RichTextType[][] = [];
|
||||
allRows.forEach(cells => {
|
||||
const row: RichTextType[] = [];
|
||||
cells.forEach(cell => {
|
||||
row.push(astToDelta(cell));
|
||||
});
|
||||
rowTextLists.push(row);
|
||||
});
|
||||
return createTableProps(rowTextLists);
|
||||
};
|
||||
|
||||
export const parseTableFromMarkdown = (
|
||||
node: MarkdownTable,
|
||||
astToDelta: (ast: MarkdownAST) => RichTextType
|
||||
) => {
|
||||
const rowTextLists: RichTextType[][] = [];
|
||||
node.children.forEach(row => {
|
||||
const rowText: RichTextType[] = [];
|
||||
row.children.forEach(cell => {
|
||||
rowText.push(astToDelta(cell));
|
||||
});
|
||||
rowTextLists.push(rowText);
|
||||
});
|
||||
return createTableProps(rowTextLists);
|
||||
};
|
||||
90
blocksuite/affine/blocks/table/src/add-button.css.ts
Normal file
90
blocksuite/affine/blocks/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: '16px',
|
||||
color: cssVarV2.icon.secondary,
|
||||
display: 'flex',
|
||||
width: '16px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: 'calc(100% + 2px)',
|
||||
height: '100%',
|
||||
transition:
|
||||
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
'&:hover, &.active': {
|
||||
backgroundColor: cssVarV2.table.indicator.drag,
|
||||
color: cssVarV2.icon.primary,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const addRowButtonStyle = style({
|
||||
cursor: 'row-resize',
|
||||
backgroundColor: cssVarV2.layer.background.hoverOverlay,
|
||||
fontSize: '16px',
|
||||
color: cssVarV2.icon.secondary,
|
||||
display: 'flex',
|
||||
height: '16px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 2px)',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
transition:
|
||||
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
'&:hover, &.active': {
|
||||
backgroundColor: cssVarV2.table.indicator.drag,
|
||||
color: cssVarV2.icon.primary,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const addRowColumnButtonStyle = style({
|
||||
cursor: 'nwse-resize',
|
||||
backgroundColor: cssVarV2.layer.background.hoverOverlay,
|
||||
fontSize: '16px',
|
||||
color: cssVarV2.icon.secondary,
|
||||
display: 'flex',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 2px)',
|
||||
left: 'calc(100% + 2px)',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
transition:
|
||||
'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&:hover, &.active': {
|
||||
backgroundColor: cssVarV2.table.indicator.drag,
|
||||
color: cssVarV2.icon.primary,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const cellCountTipsStyle = style({
|
||||
position: 'absolute',
|
||||
backgroundColor: cssVarV2.tooltips.background,
|
||||
borderRadius: '4px',
|
||||
padding: '4px',
|
||||
boxShadow: cssVar('buttonShadow'),
|
||||
color: cssVarV2.tooltips.foreground,
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
330
blocksuite/affine/blocks/table/src/add-button.ts
Normal file
330
blocksuite/affine/blocks/table/src/add-button.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { PlusIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import {
|
||||
autoPlacement,
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
offset,
|
||||
shift,
|
||||
} from '@floating-ui/dom';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
addColumnButtonStyle,
|
||||
addRowButtonStyle,
|
||||
addRowColumnButtonStyle,
|
||||
cellCountTipsStyle,
|
||||
} from './add-button.css';
|
||||
import { DefaultColumnWidth, DefaultRowHeight } from './consts';
|
||||
import type { TableDataManager } from './table-data-manager';
|
||||
|
||||
export const AddButtonComponentName = 'affine-table-add-button';
|
||||
export class AddButton extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
@property({ type: Boolean })
|
||||
accessor vertical = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor dataManager!: TableDataManager;
|
||||
|
||||
get hoverColumnIndex$() {
|
||||
return this.dataManager.hoverColumnIndex$;
|
||||
}
|
||||
|
||||
get hoverRowIndex$() {
|
||||
return this.dataManager.hoverRowIndex$;
|
||||
}
|
||||
|
||||
get columns$() {
|
||||
return this.dataManager.columns$;
|
||||
}
|
||||
|
||||
get rows$() {
|
||||
return this.dataManager.rows$;
|
||||
}
|
||||
|
||||
addColumnButtonRef$ = signal<HTMLDivElement>();
|
||||
addRowButtonRef$ = signal<HTMLDivElement>();
|
||||
addRowColumnButtonRef$ = signal<HTMLDivElement>();
|
||||
|
||||
columnDragging$ = signal(false);
|
||||
rowDragging$ = signal(false);
|
||||
rowColumnDragging$ = signal(false);
|
||||
|
||||
popCellCountTips = (ele: Element) => {
|
||||
const tip = document.createElement('div');
|
||||
tip.classList.add(cellCountTipsStyle);
|
||||
document.body.append(tip);
|
||||
const dispose = autoUpdate(ele, tip, () => {
|
||||
computePosition(ele, tip, {
|
||||
middleware: [
|
||||
autoPlacement({ allowedPlacements: ['bottom'] }),
|
||||
offset(4),
|
||||
shift(),
|
||||
],
|
||||
})
|
||||
.then(({ x, y }) => {
|
||||
tip.style.left = `${x}px`;
|
||||
tip.style.top = `${y}px`;
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
});
|
||||
return {
|
||||
tip,
|
||||
dispose: () => {
|
||||
dispose();
|
||||
tip.remove();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
getEmptyRows() {
|
||||
const rows = this.rows$.value;
|
||||
const columns = this.columns$.value;
|
||||
const rowWidths: number[] = [];
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const row = rows[i];
|
||||
if (!row) {
|
||||
break;
|
||||
}
|
||||
const hasText = columns.some(column => {
|
||||
const cell = this.dataManager.getCell(row.rowId, column.columnId);
|
||||
if (!cell) {
|
||||
return false;
|
||||
}
|
||||
return cell.text.length > 0;
|
||||
});
|
||||
if (hasText) {
|
||||
break;
|
||||
}
|
||||
rowWidths.push((rowWidths[rowWidths.length - 1] ?? 0) + DefaultRowHeight);
|
||||
}
|
||||
return rowWidths;
|
||||
}
|
||||
|
||||
getEmptyColumns() {
|
||||
const columns = this.columns$.value;
|
||||
const rows = this.rows$.value;
|
||||
const columnWidths: number[] = [];
|
||||
for (let i = columns.length - 1; i >= 0; i--) {
|
||||
const column = columns[i];
|
||||
if (!column) {
|
||||
break;
|
||||
}
|
||||
const hasText = rows.some(row => {
|
||||
const cell = this.dataManager.getCell(row.rowId, column.columnId);
|
||||
if (!cell) {
|
||||
return false;
|
||||
}
|
||||
return cell.text.length > 0;
|
||||
});
|
||||
if (hasText) {
|
||||
break;
|
||||
}
|
||||
columnWidths.push(
|
||||
(columnWidths[columnWidths.length - 1] ?? 0) +
|
||||
(column.width ?? DefaultColumnWidth)
|
||||
);
|
||||
}
|
||||
return columnWidths;
|
||||
}
|
||||
|
||||
onDragStart(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const initialX = e.clientX;
|
||||
const initialY = e.clientY;
|
||||
const target = e.target as HTMLElement;
|
||||
const isColumn = target.closest('.column-add');
|
||||
const isRow = target.closest('.row-add');
|
||||
const isRowColumn = target.closest('.row-column-add');
|
||||
const realTarget = isColumn || isRowColumn || isRow;
|
||||
if (!realTarget) {
|
||||
return;
|
||||
}
|
||||
const tipsHandler = this.popCellCountTips(realTarget);
|
||||
let emptyRows: number[] = [];
|
||||
let emptyColumns: number[] = [];
|
||||
if (isColumn) {
|
||||
this.columnDragging$.value = true;
|
||||
emptyColumns = this.getEmptyColumns();
|
||||
}
|
||||
if (isRow) {
|
||||
this.rowDragging$.value = true;
|
||||
emptyRows = this.getEmptyRows();
|
||||
}
|
||||
if (isRowColumn) {
|
||||
this.rowColumnDragging$.value = true;
|
||||
emptyRows = this.getEmptyRows();
|
||||
emptyColumns = this.getEmptyColumns();
|
||||
}
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - initialX;
|
||||
const deltaY = e.clientY - initialY;
|
||||
const addColumn = isColumn || isRowColumn;
|
||||
const addRow = isRow || isRowColumn;
|
||||
if (addColumn) {
|
||||
if (deltaX > 0) {
|
||||
this.dataManager.virtualColumnCount$.value = Math.floor(
|
||||
(deltaX + 30) / DefaultColumnWidth
|
||||
);
|
||||
} else {
|
||||
let count = 0;
|
||||
while (count < emptyColumns.length) {
|
||||
const emptyColumnWidth = emptyColumns[count];
|
||||
if (!emptyColumnWidth) {
|
||||
continue;
|
||||
}
|
||||
if (-deltaX > emptyColumnWidth) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.dataManager.virtualColumnCount$.value = -count;
|
||||
}
|
||||
}
|
||||
if (addRow) {
|
||||
if (deltaY > 0) {
|
||||
this.dataManager.virtualRowCount$.value = Math.floor(
|
||||
deltaY / DefaultRowHeight
|
||||
);
|
||||
} else {
|
||||
let count = 0;
|
||||
while (count < emptyRows.length) {
|
||||
const emptyRowHeight = emptyRows[count];
|
||||
if (!emptyRowHeight) {
|
||||
continue;
|
||||
}
|
||||
if (-deltaY > emptyRowHeight) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.dataManager.virtualRowCount$.value = -count;
|
||||
}
|
||||
}
|
||||
tipsHandler.tip.textContent = this.dataManager.cellCountTips$.value;
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
this.columnDragging$.value = false;
|
||||
this.rowDragging$.value = false;
|
||||
this.rowColumnDragging$.value = false;
|
||||
const rowCount = this.dataManager.virtualRowCount$.value;
|
||||
const columnCount = this.dataManager.virtualColumnCount$.value;
|
||||
this.dataManager.virtualColumnCount$.value = 0;
|
||||
this.dataManager.virtualRowCount$.value = 0;
|
||||
this.dataManager.addNRow(rowCount);
|
||||
this.dataManager.addNColumn(columnCount);
|
||||
|
||||
tipsHandler.dispose();
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.disposables.addFromEvent(this, 'mousedown', (e: MouseEvent) => {
|
||||
this.onDragStart(e);
|
||||
});
|
||||
}
|
||||
|
||||
renderAddColumnButton() {
|
||||
const hovered =
|
||||
this.hoverColumnIndex$.value === this.columns$.value.length - 1;
|
||||
const dragging = this.columnDragging$.value;
|
||||
return html` <div
|
||||
data-testid="add-column-button"
|
||||
class="${classMap({
|
||||
[addColumnButtonStyle]: true,
|
||||
active: dragging,
|
||||
'column-add': true,
|
||||
})}"
|
||||
${ref(this.addColumnButtonRef$)}
|
||||
style=${styleMap({
|
||||
opacity: hovered || dragging ? 1 : undefined,
|
||||
})}
|
||||
@click="${(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dataManager.addColumn(this.columns$.value.length - 1);
|
||||
}}"
|
||||
>
|
||||
${PlusIcon()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderAddRowButton() {
|
||||
const hovered = this.hoverRowIndex$.value === this.rows$.value.length - 1;
|
||||
const dragging = this.rowDragging$.value;
|
||||
return html` <div
|
||||
data-testid="add-row-button"
|
||||
class="${classMap({
|
||||
[addRowButtonStyle]: true,
|
||||
active: dragging,
|
||||
'row-add': true,
|
||||
})}"
|
||||
${ref(this.addRowButtonRef$)}
|
||||
style=${styleMap({
|
||||
opacity: hovered || dragging ? 1 : undefined,
|
||||
})}
|
||||
@click="${(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dataManager.addRow(this.rows$.value.length - 1);
|
||||
}}"
|
||||
>
|
||||
${PlusIcon()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderAddRowColumnButton() {
|
||||
const hovered =
|
||||
this.hoverRowIndex$.value === this.rows$.value.length - 1 &&
|
||||
this.hoverColumnIndex$.value === this.columns$.value.length - 1;
|
||||
const dragging = this.rowColumnDragging$.value;
|
||||
return html` <div
|
||||
class="${classMap({
|
||||
[addRowColumnButtonStyle]: true,
|
||||
active: dragging,
|
||||
'row-column-add': true,
|
||||
})}"
|
||||
${ref(this.addRowColumnButtonRef$)}
|
||||
style=${styleMap({
|
||||
opacity: hovered || dragging ? 1 : undefined,
|
||||
})}
|
||||
@click="${(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dataManager.addRow(this.rows$.value.length - 1);
|
||||
this.dataManager.addColumn(this.columns$.value.length - 1);
|
||||
}}"
|
||||
>
|
||||
${PlusIcon()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${this.renderAddColumnButton()} ${this.renderAddRowButton()}
|
||||
${this.renderAddRowColumnButton()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AddButtonComponentName]: AddButton;
|
||||
}
|
||||
}
|
||||
45
blocksuite/affine/blocks/table/src/color.ts
Normal file
45
blocksuite/affine/blocks/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;
|
||||
};
|
||||
52
blocksuite/affine/blocks/table/src/commands.ts
Normal file
52
blocksuite/affine/blocks/table/src/commands.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
type TableBlockModel,
|
||||
TableModelFlavour,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import { type BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { TableDataManager } from './table-data-manager';
|
||||
|
||||
export const insertTableBlockCommand: Command<
|
||||
{
|
||||
place?: 'after' | 'before';
|
||||
removeEmptyLine?: boolean;
|
||||
selectedModels?: BlockModel[];
|
||||
},
|
||||
{
|
||||
insertedTableBlockId: string;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { selectedModels, place, removeEmptyLine, std } = ctx;
|
||||
if (!selectedModels?.length) return;
|
||||
|
||||
const targetModel =
|
||||
place === 'before'
|
||||
? selectedModels[0]
|
||||
: selectedModels[selectedModels.length - 1];
|
||||
|
||||
if (!targetModel) return;
|
||||
|
||||
const result = std.store.addSiblingBlocks(
|
||||
targetModel,
|
||||
[{ flavour: TableModelFlavour }],
|
||||
place
|
||||
);
|
||||
const blockId = result[0];
|
||||
|
||||
if (blockId == null) return;
|
||||
|
||||
const model = std.store.getBlock(blockId)?.model as TableBlockModel;
|
||||
if (model == null) return;
|
||||
|
||||
const dataManager = new TableDataManager(model);
|
||||
|
||||
dataManager.addNRow(2);
|
||||
dataManager.addNColumn(2);
|
||||
|
||||
if (removeEmptyLine && targetModel.text?.length === 0) {
|
||||
std.store.deleteBlock(targetModel);
|
||||
}
|
||||
|
||||
next({ insertedTableBlockId: blockId });
|
||||
};
|
||||
44
blocksuite/affine/blocks/table/src/configs/slash-menu.ts
Normal file
44
blocksuite/affine/blocks/table/src/configs/slash-menu.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils';
|
||||
import type { SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { TableIcon } from '@blocksuite/icons/lit';
|
||||
|
||||
import { insertTableBlockCommand } from '../commands';
|
||||
import { tableTooltip } from './tooltips';
|
||||
|
||||
export const tableSlashMenuConfig: SlashMenuConfig = {
|
||||
disableWhen: ({ model }) => model.flavour === 'affine:table',
|
||||
items: [
|
||||
{
|
||||
name: 'Table',
|
||||
description: 'Create a simple table.',
|
||||
icon: TableIcon(),
|
||||
tooltip: {
|
||||
figure: tableTooltip,
|
||||
caption: 'Table',
|
||||
},
|
||||
group: '4_Content & Media@0',
|
||||
when: ({ model }) =>
|
||||
!isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text'),
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertTableBlockCommand, {
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
})
|
||||
.pipe(({ insertedTableBlockId }) => {
|
||||
if (insertedTableBlockId) {
|
||||
const telemetry = std.getOptional(TelemetryProvider);
|
||||
telemetry?.track('BlockCreated', {
|
||||
blockType: 'affine:table',
|
||||
});
|
||||
}
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
68
blocksuite/affine/blocks/table/src/configs/tooltips.ts
Normal file
68
blocksuite/affine/blocks/table/src/configs/tooltips.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
// prettier-ignore
|
||||
export const tableTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="170" height="106" fill="white"/>
|
||||
<mask id="path-1-inside-1_3479_144620" fill="white">
|
||||
<path d="M8 26H50V43.25H8V26Z"/>
|
||||
</mask>
|
||||
<path d="M8 26V25H7V26H8ZM50 26H51V25H50V26ZM50 43.25V44.25H51V43.25H50ZM8 43.25H7V44.25H8V43.25ZM8 27H50V25H8V27ZM49 26V43.25H51V26H49ZM50 42.25H8V44.25H50V42.25ZM9 43.25V26H7V43.25H9Z" fill="black" fill-opacity="0.1" mask="url(#path-1-inside-1_3479_144620)"/>
|
||||
<text fill="#7A7A7A" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="16" y="36.8068">Ranking</tspan></text>
|
||||
<mask id="path-4-inside-2_3479_144620" fill="white">
|
||||
<path d="M8 43.25H50V60.5H8V43.25Z"/>
|
||||
</mask>
|
||||
<path d="M50 60.5V61.5H51V60.5H50ZM8 60.5H7V61.5H8V60.5ZM49 43.25V60.5H51V43.25H49ZM50 59.5H8V61.5H50V59.5ZM9 60.5V43.25H7V60.5H9Z" fill="black" fill-opacity="0.1" mask="url(#path-4-inside-2_3479_144620)"/>
|
||||
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="14" y="54.0568">🥇 First</tspan></text>
|
||||
<mask id="path-7-inside-3_3479_144620" fill="white">
|
||||
<path d="M8 60.5H50V77.75H8V60.5Z"/>
|
||||
</mask>
|
||||
<path d="M50 77.75V78.75H51V77.75H50ZM8 77.75H7V78.75H8V77.75ZM49 60.5V77.75H51V60.5H49ZM50 76.75H8V78.75H50V76.75ZM9 77.75V60.5H7V77.75H9Z" fill="black" fill-opacity="0.1" mask="url(#path-7-inside-3_3479_144620)"/>
|
||||
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="14" y="71.3068">🥈 Second</tspan></text>
|
||||
<mask id="path-10-inside-4_3479_144620" fill="white">
|
||||
<path d="M8 77.75H50V95H8V77.75Z"/>
|
||||
</mask>
|
||||
<path d="M50 95V96H51V95H50ZM8 95H7V96H8V95ZM49 77.75V95H51V77.75H49ZM50 94H8V96H50V94ZM9 95V77.75H7V95H9Z" fill="black" fill-opacity="0.1" mask="url(#path-10-inside-4_3479_144620)"/>
|
||||
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="14" y="88.5568">🥉 Third</tspan></text>
|
||||
<mask id="path-13-inside-5_3479_144620" fill="white">
|
||||
<path d="M50 26H88V43.25H50V26Z"/>
|
||||
</mask>
|
||||
<path d="M88 26H89V25H88V26ZM88 43.25V44.25H89V43.25H88ZM50 27H88V25H50V27ZM87 26V43.25H89V26H87ZM88 42.25H50V44.25H88V42.25Z" fill="black" fill-opacity="0.1" mask="url(#path-13-inside-5_3479_144620)"/>
|
||||
<text fill="#7A7A7A" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="56" y="36.8068">Country</tspan></text>
|
||||
<mask id="path-16-inside-6_3479_144620" fill="white">
|
||||
<path d="M50 43.25H88V60.5H50V43.25Z"/>
|
||||
</mask>
|
||||
<path d="M88 60.5V61.5H89V60.5H88ZM87 43.25V60.5H89V43.25H87ZM88 59.5H50V61.5H88V59.5Z" fill="black" fill-opacity="0.1" mask="url(#path-16-inside-6_3479_144620)"/>
|
||||
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="56" y="54.0568">China</tspan></text>
|
||||
<mask id="path-19-inside-7_3479_144620" fill="white">
|
||||
<path d="M50 60.5H88V77.75H50V60.5Z"/>
|
||||
</mask>
|
||||
<path d="M88 77.75V78.75H89V77.75H88ZM87 60.5V77.75H89V60.5H87ZM88 76.75H50V78.75H88V76.75Z" fill="black" fill-opacity="0.1" mask="url(#path-19-inside-7_3479_144620)"/>
|
||||
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="56" y="71.3068">Japan</tspan></text>
|
||||
<mask id="path-22-inside-8_3479_144620" fill="white">
|
||||
<path d="M50 77.75H88V95H50V77.75Z"/>
|
||||
</mask>
|
||||
<path d="M88 95V96H89V95H88ZM87 77.75V95H89V77.75H87ZM88 94H50V96H88V94Z" fill="black" fill-opacity="0.1" mask="url(#path-22-inside-8_3479_144620)"/>
|
||||
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="56" y="88.5568">USA</tspan></text>
|
||||
<mask id="path-25-inside-9_3479_144620" fill="white">
|
||||
<path d="M88 26H164V43.25H88V26Z"/>
|
||||
</mask>
|
||||
<path d="M164 26H165V25H164V26ZM164 43.25V44.25H165V43.25H164ZM88 27H164V25H88V27ZM163 26V43.25H165V26H163ZM164 42.25H88V44.25H164V42.25Z" fill="black" fill-opacity="0.1" mask="url(#path-25-inside-9_3479_144620)"/>
|
||||
<text fill="#7A7A7A" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="94" y="36.8068">Weekly Overtime (Hrs)</tspan></text>
|
||||
<mask id="path-28-inside-10_3479_144620" fill="white">
|
||||
<path d="M88 43.25H164V60.5H88V43.25Z"/>
|
||||
</mask>
|
||||
<path d="M164 60.5V61.5H165V60.5H164ZM163 43.25V60.5H165V43.25H163ZM164 59.5H88V61.5H164V59.5Z" fill="black" fill-opacity="0.1" mask="url(#path-28-inside-10_3479_144620)"/>
|
||||
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="94" y="54.0568">10+</tspan></text>
|
||||
<mask id="path-31-inside-11_3479_144620" fill="white">
|
||||
<path d="M88 60.5H164V77.75H88V60.5Z"/>
|
||||
</mask>
|
||||
<path d="M164 77.75V78.75H165V77.75H164ZM163 60.5V77.75H165V60.5H163ZM164 76.75H88V78.75H164V76.75Z" fill="black" fill-opacity="0.1" mask="url(#path-31-inside-11_3479_144620)"/>
|
||||
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="94" y="71.3068">8</tspan></text>
|
||||
<mask id="path-34-inside-12_3479_144620" fill="white">
|
||||
<path d="M88 77.75H164V95H88V77.75Z"/>
|
||||
</mask>
|
||||
<path d="M164 95V96H165V95H164ZM163 77.75V95H165V77.75H163ZM164 94H88V96H164V94Z" fill="black" fill-opacity="0.1" mask="url(#path-34-inside-12_3479_144620)"/>
|
||||
<text fill="#141414" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="6" letter-spacing="0px"><tspan x="94" y="88.5568">3</tspan></text>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert editable tabular data.</tspan></text>
|
||||
</svg>
|
||||
`;
|
||||
4
blocksuite/affine/blocks/table/src/consts.ts
Normal file
4
blocksuite/affine/blocks/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;
|
||||
11
blocksuite/affine/blocks/table/src/effects.ts
Normal file
11
blocksuite/affine/blocks/table/src/effects.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AddButton, AddButtonComponentName } from './add-button';
|
||||
import { SelectionLayer, SelectionLayerComponentName } from './selection-layer';
|
||||
import { TableBlockComponent, TableBlockComponentName } from './table-block';
|
||||
import { TableCell, TableCellComponentName } from './table-cell';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(TableBlockComponentName, TableBlockComponent);
|
||||
customElements.define(TableCellComponentName, TableCell);
|
||||
customElements.define(AddButtonComponentName, AddButton);
|
||||
customElements.define(SelectionLayerComponentName, SelectionLayer);
|
||||
}
|
||||
5
blocksuite/affine/blocks/table/src/index.ts
Normal file
5
blocksuite/affine/blocks/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';
|
||||
527
blocksuite/affine/blocks/table/src/selection-controller.ts
Normal file
527
blocksuite/affine/blocks/table/src/selection-controller.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import {
|
||||
domToOffsets,
|
||||
getAreaByOffsets,
|
||||
getTargetIndexByDraggingOffset,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import type { UIEventStateContext } from '@blocksuite/std';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import type { ReactiveController } from 'lit';
|
||||
|
||||
import { ColumnMinWidth, DefaultColumnWidth } from './consts';
|
||||
import {
|
||||
type TableAreaSelection,
|
||||
TableSelection,
|
||||
TableSelectionData,
|
||||
} from './selection-schema';
|
||||
import type { TableBlockComponent } from './table-block';
|
||||
import {
|
||||
createColumnDragPreview,
|
||||
createRowDragPreview,
|
||||
type TableCell,
|
||||
TableCellComponentName,
|
||||
} from './table-cell';
|
||||
import { cleanSelection } from './utils';
|
||||
type Cells = string[][];
|
||||
const TEXT = 'text/plain';
|
||||
export class SelectionController implements ReactiveController {
|
||||
constructor(public readonly host: TableBlockComponent) {
|
||||
this.host.addController(this);
|
||||
}
|
||||
hostConnected() {
|
||||
this.dragListener();
|
||||
this.host.handleEvent('copy', this.onCopy);
|
||||
this.host.handleEvent('cut', this.onCut);
|
||||
this.host.handleEvent('paste', this.onPaste);
|
||||
}
|
||||
private get dataManager() {
|
||||
return this.host.dataManager;
|
||||
}
|
||||
private get clipboard() {
|
||||
return this.host.std.clipboard;
|
||||
}
|
||||
private get scale() {
|
||||
return this.host.getScale();
|
||||
}
|
||||
|
||||
widthAdjust(dragHandle: HTMLElement, event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const initialX = event.clientX;
|
||||
const currentWidth =
|
||||
dragHandle.closest('td')?.getBoundingClientRect().width ??
|
||||
DefaultColumnWidth;
|
||||
const adjustedWidth = currentWidth / this.scale;
|
||||
const columnId = dragHandle.dataset['widthAdjustColumnId'];
|
||||
if (!columnId) {
|
||||
return;
|
||||
}
|
||||
const onMove = (event: MouseEvent) => {
|
||||
this.dataManager.widthAdjustColumnId$.value = columnId;
|
||||
this.dataManager.virtualWidth$.value = {
|
||||
columnId,
|
||||
width: Math.max(
|
||||
ColumnMinWidth,
|
||||
(event.clientX - initialX) / this.scale + adjustedWidth
|
||||
),
|
||||
};
|
||||
};
|
||||
const onUp = () => {
|
||||
const width = this.dataManager.virtualWidth$.value?.width;
|
||||
this.dataManager.widthAdjustColumnId$.value = undefined;
|
||||
this.dataManager.virtualWidth$.value = undefined;
|
||||
if (width) {
|
||||
this.dataManager.setColumnWidth(columnId, width);
|
||||
}
|
||||
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
dragListener() {
|
||||
if (IS_MOBILE || this.dataManager.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
this.host.disposables.addFromEvent(this.host, 'mousedown', event => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const widthAdjustColumn = target.closest('[data-width-adjust-column-id]');
|
||||
if (widthAdjustColumn instanceof HTMLElement) {
|
||||
this.widthAdjust(widthAdjustColumn, event);
|
||||
return;
|
||||
}
|
||||
const columnDragHandle = target.closest('[data-drag-column-id]');
|
||||
if (columnDragHandle instanceof HTMLElement) {
|
||||
this.columnDrag(columnDragHandle, event);
|
||||
return;
|
||||
}
|
||||
const rowDragHandle = target.closest('[data-drag-row-id]');
|
||||
if (rowDragHandle instanceof HTMLElement) {
|
||||
this.rowDrag(rowDragHandle, event);
|
||||
return;
|
||||
}
|
||||
this.onDragStart(event);
|
||||
});
|
||||
}
|
||||
startColumnDrag(x: number, columnDragHandle: HTMLElement) {
|
||||
const columnId = columnDragHandle.dataset['dragColumnId'];
|
||||
if (!columnId) {
|
||||
return;
|
||||
}
|
||||
const cellRect = columnDragHandle.closest('td')?.getBoundingClientRect();
|
||||
const containerRect = this.host.getBoundingClientRect();
|
||||
if (!cellRect) {
|
||||
return;
|
||||
}
|
||||
const initialDiffX = x - cellRect.left;
|
||||
const cells = Array.from(
|
||||
this.host.querySelectorAll(`td[data-column-id="${columnId}"]`)
|
||||
).map(td => td.closest(TableCellComponentName) as TableCell);
|
||||
const firstCell = cells[0];
|
||||
if (!firstCell) {
|
||||
return;
|
||||
}
|
||||
const draggingIndex = firstCell.columnIndex;
|
||||
const columns = Array.from(
|
||||
this.host.querySelectorAll(`td[data-row-id="${firstCell?.row?.rowId}"]`)
|
||||
).map(td => td.getBoundingClientRect());
|
||||
const columnOffsets = columns.flatMap((column, index) =>
|
||||
index === columns.length - 1 ? [column.left, column.right] : [column.left]
|
||||
);
|
||||
const columnDragPreview = createColumnDragPreview(cells);
|
||||
columnDragPreview.style.top = `${cellRect.top - containerRect.top - 0.5}px`;
|
||||
columnDragPreview.style.left = `${cellRect.left - containerRect.left}px`;
|
||||
columnDragPreview.style.width = `${cellRect.width}px`;
|
||||
this.host.append(columnDragPreview);
|
||||
document.body.style.pointerEvents = 'none';
|
||||
const onMove = (x: number) => {
|
||||
const { targetIndex, isForward } = getTargetIndexByDraggingOffset(
|
||||
columnOffsets,
|
||||
draggingIndex,
|
||||
x - initialDiffX
|
||||
);
|
||||
if (targetIndex != null) {
|
||||
this.dataManager.ui.columnIndicatorIndex$.value = isForward
|
||||
? targetIndex + 1
|
||||
: targetIndex;
|
||||
} else {
|
||||
this.dataManager.ui.columnIndicatorIndex$.value = undefined;
|
||||
}
|
||||
columnDragPreview.style.left = `${x - initialDiffX - containerRect.left}px`;
|
||||
};
|
||||
const onEnd = () => {
|
||||
const targetIndex = this.dataManager.ui.columnIndicatorIndex$.value;
|
||||
this.dataManager.ui.columnIndicatorIndex$.value = undefined;
|
||||
document.body.style.pointerEvents = 'auto';
|
||||
columnDragPreview.remove();
|
||||
if (targetIndex != null) {
|
||||
this.dataManager.moveColumn(
|
||||
draggingIndex,
|
||||
targetIndex === 0 ? undefined : targetIndex - 1
|
||||
);
|
||||
}
|
||||
};
|
||||
return {
|
||||
onMove,
|
||||
onEnd,
|
||||
};
|
||||
}
|
||||
columnDrag(columnDragHandle: HTMLElement, event: MouseEvent) {
|
||||
let drag: { onMove: (x: number) => void; onEnd: () => void } | undefined =
|
||||
undefined;
|
||||
const initialX = event.clientX;
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const diffX = event.clientX - initialX;
|
||||
if (!drag && Math.abs(diffX) > 10) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cleanSelection();
|
||||
this.setSelected(undefined);
|
||||
drag = this.startColumnDrag(initialX, columnDragHandle);
|
||||
}
|
||||
drag?.onMove(event.clientX);
|
||||
};
|
||||
const onUp = () => {
|
||||
drag?.onEnd();
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
startRowDrag(y: number, rowDragHandle: HTMLElement) {
|
||||
const rowId = rowDragHandle.dataset['dragRowId'];
|
||||
if (!rowId) {
|
||||
return;
|
||||
}
|
||||
const cellRect = rowDragHandle.closest('td')?.getBoundingClientRect();
|
||||
const containerRect = this.host.getBoundingClientRect();
|
||||
if (!cellRect) {
|
||||
return;
|
||||
}
|
||||
const initialDiffY = y - cellRect.top;
|
||||
const cells = Array.from(
|
||||
this.host.querySelectorAll(`td[data-row-id="${rowId}"]`)
|
||||
).map(td => td.closest(TableCellComponentName) as TableCell);
|
||||
const firstCell = cells[0];
|
||||
if (!firstCell) {
|
||||
return;
|
||||
}
|
||||
const draggingIndex = firstCell.rowIndex;
|
||||
const rows = Array.from(
|
||||
this.host.querySelectorAll(
|
||||
`td[data-column-id="${firstCell?.column?.columnId}"]`
|
||||
)
|
||||
).map(td => td.getBoundingClientRect());
|
||||
const rowOffsets = rows.flatMap((row, index) =>
|
||||
index === rows.length - 1 ? [row.top, row.bottom] : [row.top]
|
||||
);
|
||||
const rowDragPreview = createRowDragPreview(cells);
|
||||
rowDragPreview.style.left = `${cellRect.left - containerRect.left}px`;
|
||||
rowDragPreview.style.top = `${cellRect.top - containerRect.top - 0.5}px`;
|
||||
rowDragPreview.style.height = `${cellRect.height}px`;
|
||||
this.host.append(rowDragPreview);
|
||||
document.body.style.pointerEvents = 'none';
|
||||
const onMove = (y: number) => {
|
||||
const { targetIndex, isForward } = getTargetIndexByDraggingOffset(
|
||||
rowOffsets,
|
||||
draggingIndex,
|
||||
y - initialDiffY
|
||||
);
|
||||
if (targetIndex != null) {
|
||||
this.dataManager.ui.rowIndicatorIndex$.value = isForward
|
||||
? targetIndex + 1
|
||||
: targetIndex;
|
||||
} else {
|
||||
this.dataManager.ui.rowIndicatorIndex$.value = undefined;
|
||||
}
|
||||
rowDragPreview.style.top = `${y - initialDiffY - containerRect.top}px`;
|
||||
};
|
||||
const onEnd = () => {
|
||||
const targetIndex = this.dataManager.ui.rowIndicatorIndex$.value;
|
||||
this.dataManager.ui.rowIndicatorIndex$.value = undefined;
|
||||
document.body.style.pointerEvents = 'auto';
|
||||
rowDragPreview.remove();
|
||||
if (targetIndex != null) {
|
||||
this.dataManager.moveRow(
|
||||
draggingIndex,
|
||||
targetIndex === 0 ? undefined : targetIndex - 1
|
||||
);
|
||||
}
|
||||
};
|
||||
return {
|
||||
onMove,
|
||||
onEnd,
|
||||
};
|
||||
}
|
||||
rowDrag(rowDragHandle: HTMLElement, event: MouseEvent) {
|
||||
let drag: { onMove: (x: number) => void; onEnd: () => void } | undefined =
|
||||
undefined;
|
||||
const initialY = event.clientY;
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const diffY = event.clientY - initialY;
|
||||
if (!drag && Math.abs(diffY) > 10) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cleanSelection();
|
||||
this.setSelected(undefined);
|
||||
drag = this.startRowDrag(initialY, rowDragHandle);
|
||||
}
|
||||
drag?.onMove(event.clientY);
|
||||
};
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
const onUp = () => {
|
||||
drag?.onEnd();
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
readonly doCopyOrCut = (selection: TableAreaSelection, isCut: boolean) => {
|
||||
const columns = this.dataManager.uiColumns$.value;
|
||||
const rows = this.dataManager.uiRows$.value;
|
||||
const cells: Cells = [];
|
||||
const deleteCells: { rowId: string; columnId: string }[] = [];
|
||||
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
|
||||
const row = rows[i];
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
const rowCells: string[] = [];
|
||||
for (
|
||||
let j = selection.columnStartIndex;
|
||||
j <= selection.columnEndIndex;
|
||||
j++
|
||||
) {
|
||||
const column = columns[j];
|
||||
if (!column) {
|
||||
continue;
|
||||
}
|
||||
const cell = this.dataManager.getCell(row.rowId, column.columnId);
|
||||
rowCells.push(cell?.text.toString() ?? '');
|
||||
if (isCut) {
|
||||
deleteCells.push({ rowId: row.rowId, columnId: column.columnId });
|
||||
}
|
||||
}
|
||||
cells.push(rowCells);
|
||||
}
|
||||
if (isCut) {
|
||||
this.dataManager.clearCells(deleteCells);
|
||||
}
|
||||
const text = cells.map(row => row.join('\t')).join('\n');
|
||||
|
||||
const htmlTable = `<table style="border-collapse: collapse;">
|
||||
<tbody>
|
||||
${cells
|
||||
.map(
|
||||
row => `
|
||||
<tr>
|
||||
${row
|
||||
.map(
|
||||
cell => `
|
||||
<td style="border: 1px solid var(--affine-border-color); padding: 8px 12px; min-width: ${DefaultColumnWidth}px; min-height: 22px;">${cell}</td>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
|
||||
this.clipboard
|
||||
.writeToClipboard(items => ({
|
||||
...items,
|
||||
[TEXT]: text,
|
||||
'text/html': htmlTable,
|
||||
}))
|
||||
.catch(console.error);
|
||||
};
|
||||
onCopy = () => {
|
||||
const selection = this.getSelected();
|
||||
if (!selection || selection.type !== 'area') {
|
||||
return false;
|
||||
}
|
||||
this.doCopyOrCut(selection, false);
|
||||
return true;
|
||||
};
|
||||
onCut = () => {
|
||||
const selection = this.getSelected();
|
||||
if (!selection || selection.type !== 'area') {
|
||||
return false;
|
||||
}
|
||||
this.doCopyOrCut(selection, true);
|
||||
return true;
|
||||
};
|
||||
doPaste = (plainText: string, selection: TableAreaSelection) => {
|
||||
try {
|
||||
const rowTextLists = plainText
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.split('\t').map(cell => cell.trim()))
|
||||
.filter(row => row.some(cell => cell !== '')); // Filter out empty rows
|
||||
const height = rowTextLists.length;
|
||||
const width = rowTextLists[0]?.length ?? 0;
|
||||
if (height > 0 && width > 0) {
|
||||
const columns = this.dataManager.uiColumns$.value;
|
||||
const rows = this.dataManager.uiRows$.value;
|
||||
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
|
||||
const row = rows[i];
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let j = selection.columnStartIndex;
|
||||
j <= selection.columnEndIndex;
|
||||
j++
|
||||
) {
|
||||
const column = columns[j];
|
||||
if (!column) {
|
||||
continue;
|
||||
}
|
||||
const text = this.dataManager.getCell(
|
||||
row.rowId,
|
||||
column.columnId
|
||||
)?.text;
|
||||
if (text) {
|
||||
const rowIndex = (i - selection.rowStartIndex) % height;
|
||||
const columnIndex = (j - selection.columnStartIndex) % width;
|
||||
text.replace(
|
||||
0,
|
||||
text.length,
|
||||
rowTextLists[rowIndex]?.[columnIndex] ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
onPaste = (_context: UIEventStateContext) => {
|
||||
const event = _context.get('clipboardState').raw;
|
||||
event.stopPropagation();
|
||||
const clipboardData = event.clipboardData;
|
||||
if (!clipboardData) return false;
|
||||
|
||||
const selection = this.getSelected();
|
||||
if (!selection || selection.type !== 'area') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = clipboardData.getData('text/html');
|
||||
if (html) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const table = doc.querySelector('table');
|
||||
if (table) {
|
||||
const rows: string[][] = [];
|
||||
table.querySelectorAll('tr').forEach(tr => {
|
||||
const rowData: string[] = [];
|
||||
tr.querySelectorAll('td,th').forEach(cell => {
|
||||
rowData.push(cell.textContent?.trim() ?? '');
|
||||
});
|
||||
if (rowData.length > 0) {
|
||||
rows.push(rowData);
|
||||
}
|
||||
});
|
||||
if (rows.length > 0) {
|
||||
this.doPaste(rows.map(row => row.join('\t')).join('\n'), selection);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no HTML format or parsing failed, try to read plain text
|
||||
const plainText = clipboardData.getData('text/plain');
|
||||
if (plainText) {
|
||||
this.doPaste(plainText, selection);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to paste:', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
onDragStart(event: MouseEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const offsets = domToOffsets(this.host, 'tr', 'td');
|
||||
if (!offsets) return;
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
let selected = false;
|
||||
const initCell = target.closest('affine-table-cell');
|
||||
if (!initCell) {
|
||||
selected = true;
|
||||
}
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement) {
|
||||
const cell = target.closest('affine-table-cell');
|
||||
if (!selected && initCell === cell) {
|
||||
return;
|
||||
}
|
||||
selected = true;
|
||||
const endX = event.clientX;
|
||||
const endY = event.clientY;
|
||||
const [left, right] = startX > endX ? [endX, startX] : [startX, endX];
|
||||
const [top, bottom] = startY > endY ? [endY, startY] : [startY, endY];
|
||||
const area = getAreaByOffsets(offsets, top, bottom, left, right);
|
||||
this.setSelected({
|
||||
type: 'area',
|
||||
rowStartIndex: area.top,
|
||||
rowEndIndex: area.bottom,
|
||||
columnStartIndex: area.left,
|
||||
columnEndIndex: area.right,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
setSelected(
|
||||
selection: TableSelectionData | undefined,
|
||||
removeNativeSelection = true
|
||||
) {
|
||||
if (selection) {
|
||||
const previous = this.getSelected();
|
||||
if (TableSelectionData.equals(previous, selection)) {
|
||||
return;
|
||||
}
|
||||
if (removeNativeSelection) {
|
||||
getSelection()?.removeAllRanges();
|
||||
}
|
||||
this.host.selection.set([
|
||||
new TableSelection({
|
||||
blockId: this.host.model.id,
|
||||
data: selection,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
this.host.selection.clear();
|
||||
}
|
||||
}
|
||||
selected$ = computed(() => this.getSelected());
|
||||
getSelected(): TableSelectionData | undefined {
|
||||
const selection = this.host.selection.value.find(
|
||||
selection => selection.blockId === this.host.model.id
|
||||
);
|
||||
return selection?.is(TableSelection) ? selection.data : undefined;
|
||||
}
|
||||
}
|
||||
112
blocksuite/affine/blocks/table/src/selection-layer.ts
Normal file
112
blocksuite/affine/blocks/table/src/selection-layer.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { computed, effect, signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { SelectionController } from './selection-controller';
|
||||
|
||||
type Rect = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const SelectionLayerComponentName = 'affine-table-selection-layer';
|
||||
export class SelectionLayer extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
@property({ attribute: false })
|
||||
accessor selectionController!: SelectionController;
|
||||
@property({ attribute: false })
|
||||
accessor getRowRect!: (rowId: string) => Rect;
|
||||
@property({ attribute: false })
|
||||
accessor getColumnRect!: (columnId: string) => Rect;
|
||||
@property({ attribute: false })
|
||||
accessor getAreaRect!: (
|
||||
rowStartIndex: number,
|
||||
rowEndIndex: number,
|
||||
columnStartIndex: number,
|
||||
columnEndIndex: number
|
||||
) => Rect;
|
||||
|
||||
selection$ = computed(() => {
|
||||
return this.selectionController.selected$.value;
|
||||
});
|
||||
|
||||
computeRect = () => {
|
||||
const selection = this.selection$.value;
|
||||
if (!selection) return;
|
||||
if (selection.type === 'row') {
|
||||
const rect = this.getRowRect(selection.rowId);
|
||||
return rect;
|
||||
}
|
||||
if (selection.type === 'column') {
|
||||
const rect = this.getColumnRect(selection.columnId);
|
||||
return rect;
|
||||
}
|
||||
if (selection.type === 'area') {
|
||||
const rect = this.getAreaRect(
|
||||
selection.rowStartIndex,
|
||||
selection.rowEndIndex,
|
||||
selection.columnStartIndex,
|
||||
selection.columnEndIndex
|
||||
);
|
||||
return rect;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
rect$ = signal<Rect>();
|
||||
|
||||
private getSelectionStyle() {
|
||||
const rect = this.rect$.value;
|
||||
if (!rect)
|
||||
return styleMap({
|
||||
display: 'none',
|
||||
});
|
||||
const border = '2px solid var(--affine-primary-color)';
|
||||
|
||||
return styleMap({
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
top: `${rect.top}px`,
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
height: `${rect.height}px`,
|
||||
borderRadius: '2px',
|
||||
border,
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const ob = new ResizeObserver(() => {
|
||||
this.rect$.value = this.computeRect();
|
||||
});
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
this.rect$.value = this.computeRect();
|
||||
})
|
||||
);
|
||||
const table = this.selectionController.host.querySelector('table');
|
||||
if (table) {
|
||||
ob.observe(table);
|
||||
this.disposables.add(() => {
|
||||
ob.unobserve(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html` <div style=${this.getSelectionStyle()}></div> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[SelectionLayerComponentName]: SelectionLayer;
|
||||
}
|
||||
}
|
||||
110
blocksuite/affine/blocks/table/src/selection-schema.ts
Normal file
110
blocksuite/affine/blocks/table/src/selection-schema.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
|
||||
import { z } from 'zod';
|
||||
|
||||
const TableAreaSelectionSchema = z.object({
|
||||
type: z.literal('area'),
|
||||
rowStartIndex: z.number(),
|
||||
rowEndIndex: z.number(),
|
||||
columnStartIndex: z.number(),
|
||||
columnEndIndex: z.number(),
|
||||
});
|
||||
|
||||
export type TableAreaSelection = z.TypeOf<typeof TableAreaSelectionSchema>;
|
||||
|
||||
const TableRowSelectionSchema = z.object({
|
||||
type: z.literal('row'),
|
||||
rowId: z.string(),
|
||||
});
|
||||
|
||||
const TableColumnSelectionSchema = z.object({
|
||||
type: z.literal('column'),
|
||||
columnId: z.string(),
|
||||
});
|
||||
|
||||
const TableSelectionDataSchema = z.union([
|
||||
TableAreaSelectionSchema,
|
||||
TableRowSelectionSchema,
|
||||
TableColumnSelectionSchema,
|
||||
]);
|
||||
|
||||
export type TableSelectionData = z.TypeOf<typeof TableSelectionDataSchema>;
|
||||
export const TableSelectionData = {
|
||||
equals(a?: TableSelectionData, b?: TableSelectionData) {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (a == null || b == null) {
|
||||
return a === b;
|
||||
}
|
||||
if (a.type !== b.type) {
|
||||
return false;
|
||||
}
|
||||
if (a.type === 'area' && b.type === 'area') {
|
||||
return (
|
||||
a.rowStartIndex === b.rowStartIndex &&
|
||||
a.rowEndIndex === b.rowEndIndex &&
|
||||
a.columnStartIndex === b.columnStartIndex &&
|
||||
a.columnEndIndex === b.columnEndIndex
|
||||
);
|
||||
}
|
||||
if (a.type === 'row' && b.type === 'row') {
|
||||
return a.rowId === b.rowId;
|
||||
}
|
||||
if (a.type === 'column' && b.type === 'column') {
|
||||
return a.columnId === b.columnId;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
const TableSelectionSchema = z.object({
|
||||
blockId: z.string(),
|
||||
data: TableSelectionDataSchema,
|
||||
});
|
||||
|
||||
export class TableSelection extends BaseSelection {
|
||||
static override group = 'note';
|
||||
|
||||
static override type = 'table';
|
||||
|
||||
readonly data: TableSelectionData;
|
||||
|
||||
constructor({
|
||||
blockId,
|
||||
data,
|
||||
}: {
|
||||
blockId: string;
|
||||
data: TableSelectionData;
|
||||
}) {
|
||||
super({
|
||||
blockId,
|
||||
});
|
||||
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
static override fromJSON(json: Record<string, unknown>): TableSelection {
|
||||
TableSelectionSchema.parse(json);
|
||||
return new TableSelection({
|
||||
blockId: json.blockId as string,
|
||||
data: json.data as TableSelectionData,
|
||||
});
|
||||
}
|
||||
|
||||
override equals(other: BaseSelection): boolean {
|
||||
if (!(other instanceof TableSelection)) {
|
||||
return false;
|
||||
}
|
||||
return this.blockId === other.blockId;
|
||||
}
|
||||
|
||||
override toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
type: 'table',
|
||||
blockId: this.blockId,
|
||||
data: this.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TableSelectionExtension = SelectionExtension(TableSelection);
|
||||
42
blocksuite/affine/blocks/table/src/table-block.css.ts
Normal file
42
blocksuite/affine/blocks/table/src/table-block.css.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const tableContainer = style({
|
||||
display: 'block',
|
||||
padding: '10px 0 18px 10px',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'visible',
|
||||
selectors: {
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:horizontal': {
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-track:horizontal': {
|
||||
backgroundColor: 'transparent',
|
||||
height: '8px',
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-thumb:horizontal': {
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--affine-black-30)',
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-track:horizontal': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
height: '8px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableWrapper = style({
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '8px',
|
||||
position: 'relative',
|
||||
width: 'max-content',
|
||||
});
|
||||
|
||||
export const table = style({});
|
||||
|
||||
export const rowStyle = style({});
|
||||
209
blocksuite/affine/blocks/table/src/table-block.ts
Normal file
209
blocksuite/affine/blocks/table/src/table-block.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import type { TableBlockModel } from '@blocksuite/affine-model';
|
||||
import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { SelectionController } from './selection-controller';
|
||||
import {
|
||||
rowStyle,
|
||||
table,
|
||||
tableContainer,
|
||||
tableWrapper,
|
||||
} from './table-block.css';
|
||||
import { TableDataManager } from './table-data-manager';
|
||||
|
||||
export const TableBlockComponentName = 'affine-table';
|
||||
export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel> {
|
||||
private _dataManager: TableDataManager | null = null;
|
||||
|
||||
get dataManager(): TableDataManager {
|
||||
if (!this._dataManager) {
|
||||
this._dataManager = new TableDataManager(this.model);
|
||||
}
|
||||
return this._dataManager;
|
||||
}
|
||||
|
||||
selectionController = new SelectionController(this);
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
this.style.position = 'relative';
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(
|
||||
EDGELESS_TOP_CONTENTEDITABLE_SELECTOR
|
||||
);
|
||||
}
|
||||
return this.rootComponent;
|
||||
}
|
||||
|
||||
private readonly virtualPaddingController: VirtualPaddingController =
|
||||
new VirtualPaddingController(this);
|
||||
|
||||
table$ = signal<HTMLTableElement>();
|
||||
|
||||
public getScale(): number {
|
||||
const table = this.table$.value;
|
||||
if (!table) return 1;
|
||||
return table.getBoundingClientRect().width / table.offsetWidth;
|
||||
}
|
||||
|
||||
private readonly getRootRect = () => {
|
||||
const table = this.table$.value;
|
||||
if (!table) return;
|
||||
return table.getBoundingClientRect();
|
||||
};
|
||||
|
||||
private readonly getRowRect = (rowId: string) => {
|
||||
const row = this.querySelector(`tr[data-row-id="${rowId}"]`);
|
||||
const rootRect = this.getRootRect();
|
||||
if (!row || !rootRect) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const scale = this.getScale();
|
||||
return {
|
||||
top: (rect.top - rootRect.top) / scale,
|
||||
left: (rect.left - rootRect.left) / scale,
|
||||
width: rect.width / scale,
|
||||
height: rect.height / scale,
|
||||
};
|
||||
};
|
||||
|
||||
private readonly getColumnRect = (columnId: string) => {
|
||||
const columns = this.querySelectorAll(`td[data-column-id="${columnId}"]`);
|
||||
const rootRect = this.getRootRect();
|
||||
if (!rootRect) return;
|
||||
const firstRect = columns.item(0)?.getBoundingClientRect();
|
||||
const lastRect = columns.item(columns.length - 1)?.getBoundingClientRect();
|
||||
if (!firstRect || !lastRect) return;
|
||||
const scale = this.getScale();
|
||||
return {
|
||||
top: (firstRect.top - rootRect.top) / scale,
|
||||
left: (firstRect.left - rootRect.left) / scale,
|
||||
width: firstRect.width / scale,
|
||||
height: (lastRect.bottom - firstRect.top) / scale,
|
||||
};
|
||||
};
|
||||
|
||||
private readonly getAreaRect = (
|
||||
rowStartIndex: number,
|
||||
rowEndIndex: number,
|
||||
columnStartIndex: number,
|
||||
columnEndIndex: number
|
||||
) => {
|
||||
const rootRect = this.getRootRect();
|
||||
const rows = this.querySelectorAll('tr');
|
||||
const startRow = rows.item(rowStartIndex);
|
||||
const endRow = rows.item(rowEndIndex);
|
||||
if (!startRow || !endRow || !rootRect) return;
|
||||
|
||||
const startCells = startRow.querySelectorAll('td');
|
||||
const endCells = endRow.querySelectorAll('td');
|
||||
const startCell = startCells.item(columnStartIndex);
|
||||
const endCell = endCells.item(columnEndIndex);
|
||||
if (!startCell || !endCell) return;
|
||||
|
||||
const startRect = startCell.getBoundingClientRect();
|
||||
const endRect = endCell.getBoundingClientRect();
|
||||
const scale = this.getScale();
|
||||
|
||||
return {
|
||||
top: (startRect.top - rootRect.top) / scale,
|
||||
left: (startRect.left - rootRect.left) / scale,
|
||||
width: (endRect.right - startRect.left) / scale,
|
||||
height: (endRect.bottom - startRect.top) / scale,
|
||||
};
|
||||
};
|
||||
|
||||
override renderBlock() {
|
||||
const rows = this.dataManager.uiRows$.value;
|
||||
const columns = this.dataManager.uiColumns$.value;
|
||||
const virtualPadding = this.virtualPaddingController.virtualPadding$.value;
|
||||
return html`
|
||||
<div
|
||||
contenteditable="false"
|
||||
class=${tableContainer}
|
||||
style=${styleMap({
|
||||
marginLeft: `-${virtualPadding + 10}px`,
|
||||
marginRight: `-${virtualPadding}px`,
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style=${styleMap({
|
||||
paddingLeft: `${virtualPadding}px`,
|
||||
paddingRight: `${virtualPadding}px`,
|
||||
width: 'max-content',
|
||||
})}
|
||||
>
|
||||
<table class=${tableWrapper} ${ref(this.table$)}>
|
||||
<tbody class=${table}>
|
||||
${repeat(
|
||||
rows,
|
||||
row => row.rowId,
|
||||
(row, rowIndex) => {
|
||||
return html`
|
||||
<tr class=${rowStyle} data-row-id=${row.rowId}>
|
||||
${repeat(
|
||||
columns,
|
||||
column => column.columnId,
|
||||
(column, columnIndex) => {
|
||||
const cell = this.dataManager.getCell(
|
||||
row.rowId,
|
||||
column.columnId
|
||||
);
|
||||
return html`
|
||||
<affine-table-cell
|
||||
style="display: contents;"
|
||||
.rowIndex=${rowIndex}
|
||||
.columnIndex=${columnIndex}
|
||||
.row=${row}
|
||||
.column=${column}
|
||||
.text=${cell?.text}
|
||||
.dataManager=${this.dataManager}
|
||||
.selectionController=${this.selectionController}
|
||||
></affine-table-cell>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</tbody>
|
||||
${IS_MOBILE || this.dataManager.readonly$.value
|
||||
? nothing
|
||||
: html`<affine-table-add-button
|
||||
style="display: contents;"
|
||||
.dataManager=${this.dataManager}
|
||||
></affine-table-add-button>`}
|
||||
${html`<affine-table-selection-layer
|
||||
style="display: contents;"
|
||||
.selectionController=${this.selectionController}
|
||||
.getRowRect=${this.getRowRect}
|
||||
.getColumnRect=${this.getColumnRect}
|
||||
.getAreaRect=${this.getAreaRect}
|
||||
></affine-table-selection-layer>`}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[TableBlockComponentName]: TableBlockComponent;
|
||||
}
|
||||
}
|
||||
159
blocksuite/affine/blocks/table/src/table-cell.css.ts
Normal file
159
blocksuite/affine/blocks/table/src/table-cell.css.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { cssVar, cssVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
|
||||
export const cellContainerStyle = style({
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: cssVarV2.table.border,
|
||||
borderCollapse: 'collapse',
|
||||
isolation: 'auto',
|
||||
textAlign: 'start',
|
||||
verticalAlign: 'top',
|
||||
});
|
||||
|
||||
export const columnOptionsCellStyle = style({
|
||||
position: 'absolute',
|
||||
height: '0',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
const threePointerIconColorVar = createVar();
|
||||
export const columnOptionsStyle = style({
|
||||
cursor: 'pointer',
|
||||
zIndex: 2,
|
||||
width: '28px',
|
||||
height: '16px',
|
||||
backgroundColor: cssVarV2.table.headerBackground.default,
|
||||
borderRadius: '8px',
|
||||
boxShadow: cssVar('buttonShadow'),
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
vars: {
|
||||
[threePointerIconColorVar]: cssVarV2.icon.secondary,
|
||||
},
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&.active': {
|
||||
opacity: 1,
|
||||
backgroundColor: cssVarV2.table.indicator.activated,
|
||||
vars: {
|
||||
[threePointerIconColorVar]: cssVarV2.table.indicator.pointerActive,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const rowOptionsCellStyle = style({
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
export const rowOptionsStyle = style({
|
||||
cursor: 'pointer',
|
||||
zIndex: 2,
|
||||
width: '16px',
|
||||
height: '28px',
|
||||
backgroundColor: cssVarV2.table.headerBackground.default,
|
||||
borderRadius: '8px',
|
||||
boxShadow: cssVar('buttonShadow'),
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
vars: {
|
||||
[threePointerIconColorVar]: cssVarV2.icon.secondary,
|
||||
},
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&.active': {
|
||||
opacity: 1,
|
||||
backgroundColor: cssVarV2.table.indicator.activated,
|
||||
vars: {
|
||||
[threePointerIconColorVar]: cssVarV2.table.indicator.pointerActive,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const threePointerIconStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2px',
|
||||
});
|
||||
|
||||
export const threePointerIconDotStyle = style({
|
||||
width: '3px',
|
||||
height: '3px',
|
||||
backgroundColor: threePointerIconColorVar,
|
||||
borderRadius: '50%',
|
||||
});
|
||||
export const indicatorStyle = style({
|
||||
position: 'absolute',
|
||||
backgroundColor: cssVarV2.table.indicator.activated,
|
||||
zIndex: 2,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
export const columnIndicatorStyle = style([
|
||||
indicatorStyle,
|
||||
{
|
||||
top: '-1px',
|
||||
height: 'calc(100% + 2px)',
|
||||
width: '5px',
|
||||
},
|
||||
]);
|
||||
export const columnRightIndicatorStyle = style([
|
||||
columnIndicatorStyle,
|
||||
{
|
||||
cursor: 'ew-resize',
|
||||
right: '-3px',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
]);
|
||||
export const columnLeftIndicatorStyle = style([
|
||||
columnIndicatorStyle,
|
||||
{
|
||||
left: '-2px',
|
||||
},
|
||||
]);
|
||||
export const rowIndicatorStyle = style([
|
||||
indicatorStyle,
|
||||
{
|
||||
left: '-1px',
|
||||
width: 'calc(100% + 2px)',
|
||||
height: '5px',
|
||||
},
|
||||
]);
|
||||
export const rowBottomIndicatorStyle = style([
|
||||
rowIndicatorStyle,
|
||||
{
|
||||
bottom: '-3px',
|
||||
},
|
||||
]);
|
||||
export const rowTopIndicatorStyle = style([
|
||||
rowIndicatorStyle,
|
||||
{
|
||||
top: '-2px',
|
||||
},
|
||||
]);
|
||||
852
blocksuite/affine/blocks/table/src/table-cell.ts
Normal file
852
blocksuite/affine/blocks/table/src/table-cell.ts
Normal file
@@ -0,0 +1,852 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { TextBackgroundDuotoneIcon } from '@blocksuite/affine-components/icons';
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
|
||||
import type { TableColumn, TableRow } from '@blocksuite/affine-model';
|
||||
import { RichText } from '@blocksuite/affine-rich-text';
|
||||
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
ArrowDownBigIcon,
|
||||
ArrowLeftBigIcon,
|
||||
ArrowRightBigIcon,
|
||||
ArrowUpBigIcon,
|
||||
CloseIcon,
|
||||
ColorPickerIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
InsertAboveIcon,
|
||||
InsertBelowIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
PasteIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import type { Text } from '@blocksuite/store';
|
||||
import { computed, effect, signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { colorList } from './color';
|
||||
import { ColumnMaxWidth, DefaultColumnWidth } from './consts';
|
||||
import type { SelectionController } from './selection-controller';
|
||||
import {
|
||||
type TableAreaSelection,
|
||||
TableSelectionData,
|
||||
} from './selection-schema';
|
||||
import type { TableBlockComponent } from './table-block';
|
||||
import {
|
||||
cellContainerStyle,
|
||||
columnLeftIndicatorStyle,
|
||||
columnOptionsCellStyle,
|
||||
columnOptionsStyle,
|
||||
columnRightIndicatorStyle,
|
||||
rowBottomIndicatorStyle,
|
||||
rowOptionsCellStyle,
|
||||
rowOptionsStyle,
|
||||
rowTopIndicatorStyle,
|
||||
threePointerIconDotStyle,
|
||||
threePointerIconStyle,
|
||||
} from './table-cell.css';
|
||||
import type { TableDataManager } from './table-data-manager';
|
||||
export const TableCellComponentName = 'affine-table-cell';
|
||||
export class TableCell extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
@property({ attribute: false })
|
||||
accessor text: Text | undefined = undefined;
|
||||
|
||||
get readonly() {
|
||||
return this.dataManager.readonly$.value;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor dataManager!: TableDataManager;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText: RichText | null = null;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor rowIndex = 0;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor columnIndex = 0;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor row: TableRow | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor column: TableColumn | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor selectionController!: SelectionController;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor height: number | undefined;
|
||||
|
||||
get hoverColumnIndex$() {
|
||||
return this.dataManager.hoverColumnIndex$;
|
||||
}
|
||||
get hoverRowIndex$() {
|
||||
return this.dataManager.hoverRowIndex$;
|
||||
}
|
||||
get inlineManager() {
|
||||
return this.closest<TableBlockComponent>('affine-table')?.std.get(
|
||||
DefaultInlineManagerExtension.identifier
|
||||
);
|
||||
}
|
||||
|
||||
get topContenteditableElement() {
|
||||
return this.closest<TableBlockComponent>('affine-table')
|
||||
?.topContenteditableElement;
|
||||
}
|
||||
|
||||
openColumnOptions(
|
||||
target: PopupTarget,
|
||||
column: TableColumn,
|
||||
columnIndex: number
|
||||
) {
|
||||
this.selectionController.setSelected({
|
||||
type: 'column',
|
||||
columnId: column.columnId,
|
||||
});
|
||||
popMenu(target, {
|
||||
options: {
|
||||
onClose: () => {
|
||||
this.selectionController.setSelected(undefined);
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.subMenu({
|
||||
name: 'Background color',
|
||||
prefix: ColorPickerIcon(),
|
||||
options: {
|
||||
items: [
|
||||
{ name: 'Default', color: undefined },
|
||||
...colorList,
|
||||
].map(item =>
|
||||
menu.action({
|
||||
prefix: html`<div
|
||||
style="color: ${item.color ??
|
||||
cssVarV2.layer.background
|
||||
.primary};display: flex;align-items: center;justify-content: center;"
|
||||
>
|
||||
${TextBackgroundDuotoneIcon}
|
||||
</div>`,
|
||||
name: item.name,
|
||||
isSelected: column.backgroundColor === item.color,
|
||||
select: () => {
|
||||
this.dataManager.setColumnBackgroundColor(
|
||||
column.columnId,
|
||||
item.color
|
||||
);
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
}),
|
||||
...(column.backgroundColor
|
||||
? [
|
||||
menu.action({
|
||||
name: 'Clear column style',
|
||||
prefix: CloseIcon(),
|
||||
select: () => {
|
||||
this.dataManager.setColumnBackgroundColor(
|
||||
column.columnId,
|
||||
undefined
|
||||
);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Insert Left',
|
||||
prefix: InsertLeftIcon(),
|
||||
select: () => {
|
||||
this.dataManager.insertColumn(
|
||||
columnIndex > 0 ? columnIndex - 1 : undefined
|
||||
);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Insert Right',
|
||||
prefix: InsertRightIcon(),
|
||||
select: () => {
|
||||
this.dataManager.insertColumn(columnIndex);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Left',
|
||||
prefix: ArrowLeftBigIcon(),
|
||||
select: () => {
|
||||
this.dataManager.moveColumn(columnIndex, columnIndex - 2);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Right',
|
||||
prefix: ArrowRightBigIcon(),
|
||||
select: () => {
|
||||
this.dataManager.moveColumn(columnIndex, columnIndex + 1);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
select: () => {
|
||||
this.dataManager.duplicateColumn(columnIndex);
|
||||
},
|
||||
}),
|
||||
|
||||
menu.action({
|
||||
name: 'Clear column contents',
|
||||
prefix: CloseIcon(),
|
||||
select: () => {
|
||||
this.dataManager.clearColumn(column.columnId);
|
||||
},
|
||||
}),
|
||||
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
class: {
|
||||
'delete-item': true,
|
||||
},
|
||||
prefix: DeleteIcon(),
|
||||
select: () => {
|
||||
this.dataManager.deleteColumn(column.columnId);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openRowOptions(target: PopupTarget, row: TableRow, rowIndex: number) {
|
||||
this.selectionController.setSelected({
|
||||
type: 'row',
|
||||
rowId: row.rowId,
|
||||
});
|
||||
popMenu(target, {
|
||||
options: {
|
||||
onClose: () => {
|
||||
this.selectionController.setSelected(undefined);
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.subMenu({
|
||||
name: 'Background color',
|
||||
prefix: ColorPickerIcon(),
|
||||
options: {
|
||||
items: [
|
||||
{ name: 'Default', color: undefined },
|
||||
...colorList,
|
||||
].map(item =>
|
||||
menu.action({
|
||||
prefix: html`<div
|
||||
style="color: ${item.color ??
|
||||
cssVarV2.layer.background
|
||||
.primary};display: flex;align-items: center;justify-content: center;"
|
||||
>
|
||||
${TextBackgroundDuotoneIcon}
|
||||
</div>`,
|
||||
name: item.name,
|
||||
isSelected: row.backgroundColor === item.color,
|
||||
select: () => {
|
||||
this.dataManager.setRowBackgroundColor(
|
||||
row.rowId,
|
||||
item.color
|
||||
);
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
}),
|
||||
...(row.backgroundColor
|
||||
? [
|
||||
menu.action({
|
||||
name: 'Clear row style',
|
||||
prefix: CloseIcon(),
|
||||
select: () => {
|
||||
this.dataManager.setRowBackgroundColor(
|
||||
row.rowId,
|
||||
undefined
|
||||
);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Insert Above',
|
||||
prefix: InsertAboveIcon(),
|
||||
select: () => {
|
||||
this.dataManager.insertRow(
|
||||
rowIndex > 0 ? rowIndex - 1 : undefined
|
||||
);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Insert Below',
|
||||
prefix: InsertBelowIcon(),
|
||||
select: () => {
|
||||
this.dataManager.insertRow(rowIndex);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Up',
|
||||
prefix: ArrowUpBigIcon(),
|
||||
select: () => {
|
||||
this.dataManager.moveRow(rowIndex, rowIndex - 1);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Down',
|
||||
prefix: ArrowDownBigIcon(),
|
||||
select: () => {
|
||||
this.dataManager.moveRow(rowIndex, rowIndex + 1);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
select: () => {
|
||||
this.dataManager.duplicateRow(rowIndex);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Clear row contents',
|
||||
prefix: CloseIcon(),
|
||||
select: () => {
|
||||
this.dataManager.clearRow(row.rowId);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
class: {
|
||||
'delete-item': true,
|
||||
},
|
||||
prefix: DeleteIcon(),
|
||||
select: () => {
|
||||
this.dataManager.deleteRow(row.rowId);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
createColorPickerMenu(
|
||||
currentColor: string | undefined,
|
||||
select: (color?: string) => void
|
||||
) {
|
||||
return menu.subMenu({
|
||||
name: 'Background color',
|
||||
prefix: ColorPickerIcon(),
|
||||
options: {
|
||||
items: [{ name: 'Default', color: undefined }, ...colorList].map(item =>
|
||||
menu.action({
|
||||
prefix: html`<div
|
||||
style="color: ${item.color ??
|
||||
cssVarV2.layer.background
|
||||
.primary};display: flex;align-items: center;justify-content: center;"
|
||||
>
|
||||
${TextBackgroundDuotoneIcon}
|
||||
</div>`,
|
||||
name: item.name,
|
||||
isSelected: currentColor === item.color,
|
||||
select: () => {
|
||||
select(item.color);
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onContextMenu(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selected = this.selectionController.selected$.value;
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
if (selected.type === 'area' && e.currentTarget instanceof HTMLElement) {
|
||||
const target = popupTargetFromElement(e.currentTarget);
|
||||
popMenu(target, {
|
||||
options: {
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Copy',
|
||||
prefix: CopyIcon(),
|
||||
select: () => {
|
||||
this.selectionController.doCopyOrCut(selected, false);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Paste',
|
||||
prefix: PasteIcon(),
|
||||
select: () => {
|
||||
navigator.clipboard.readText().then(text => {
|
||||
this.selectionController.doPaste(text, selected);
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Clear contents',
|
||||
prefix: CloseIcon(),
|
||||
select: () => {
|
||||
this.dataManager.clearCellsBySelection(selected);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderColumnOptions(column: TableColumn, columnIndex: number) {
|
||||
const openColumnOptions = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
const element = e.currentTarget;
|
||||
if (element instanceof HTMLElement) {
|
||||
this.openColumnOptions(
|
||||
popupTargetFromElement(element),
|
||||
column,
|
||||
columnIndex
|
||||
);
|
||||
}
|
||||
};
|
||||
return html`<div class=${columnOptionsCellStyle}>
|
||||
<div
|
||||
data-testid="drag-column-handle"
|
||||
data-drag-column-id=${column.columnId}
|
||||
class=${classMap({
|
||||
[columnOptionsStyle]: true,
|
||||
})}
|
||||
style=${styleMap({
|
||||
opacity: columnIndex === this.hoverColumnIndex$.value ? 1 : undefined,
|
||||
})}
|
||||
@click=${openColumnOptions}
|
||||
>
|
||||
${threePointerIcon()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderRowOptions(row: TableRow, rowIndex: number) {
|
||||
const openRowOptions = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
const element = e.currentTarget;
|
||||
if (element instanceof HTMLElement) {
|
||||
this.openRowOptions(popupTargetFromElement(element), row, rowIndex);
|
||||
}
|
||||
};
|
||||
return html`<div class=${rowOptionsCellStyle}>
|
||||
<div
|
||||
data-testid="drag-row-handle"
|
||||
data-drag-row-id=${row.rowId}
|
||||
class=${classMap({
|
||||
[rowOptionsStyle]: true,
|
||||
})}
|
||||
style=${styleMap({
|
||||
opacity: rowIndex === this.hoverRowIndex$.value ? 1 : undefined,
|
||||
})}
|
||||
@click=${openRowOptions}
|
||||
>
|
||||
${threePointerIcon(true)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
renderOptionsButton() {
|
||||
if (this.readonly || !this.row || !this.column) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
${this.rowIndex === 0
|
||||
? this.renderColumnOptions(this.column, this.columnIndex)
|
||||
: nothing}
|
||||
${this.columnIndex === 0
|
||||
? this.renderRowOptions(this.row, this.rowIndex)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
tdMouseEnter(rowIndex: number, columnIndex: number) {
|
||||
this.hoverColumnIndex$.value = columnIndex;
|
||||
this.hoverRowIndex$.value = rowIndex;
|
||||
}
|
||||
|
||||
tdMouseLeave() {
|
||||
this.hoverColumnIndex$.value = undefined;
|
||||
this.hoverRowIndex$.value = undefined;
|
||||
}
|
||||
|
||||
virtualWidth$ = computed(() => {
|
||||
const virtualWidth = this.dataManager.virtualWidth$.value;
|
||||
if (!virtualWidth || this.column?.columnId !== virtualWidth.columnId) {
|
||||
return undefined;
|
||||
}
|
||||
return virtualWidth.width;
|
||||
});
|
||||
|
||||
tdStyle() {
|
||||
const columnWidth = this.virtualWidth$.value ?? this.column?.width;
|
||||
const backgroundColor =
|
||||
this.column?.backgroundColor ?? this.row?.backgroundColor ?? undefined;
|
||||
return styleMap({
|
||||
backgroundColor,
|
||||
minWidth: columnWidth ? `${columnWidth}px` : `${DefaultColumnWidth}px`,
|
||||
maxWidth: columnWidth ? `${columnWidth}px` : `${ColumnMaxWidth}px`,
|
||||
});
|
||||
}
|
||||
|
||||
showColumnIndicator$ = computed(() => {
|
||||
const indicatorIndex =
|
||||
this.dataManager.ui.columnIndicatorIndex$.value ?? -1;
|
||||
if (indicatorIndex === 0 && this.columnIndex === 0) {
|
||||
return 'left';
|
||||
}
|
||||
if (indicatorIndex - 1 === this.columnIndex) {
|
||||
return 'right';
|
||||
}
|
||||
return;
|
||||
});
|
||||
showRowIndicator$ = computed(() => {
|
||||
const indicatorIndex = this.dataManager.ui.rowIndicatorIndex$.value ?? -1;
|
||||
if (indicatorIndex === 0 && this.rowIndex === 0) {
|
||||
return 'top';
|
||||
}
|
||||
if (indicatorIndex - 1 === this.rowIndex) {
|
||||
return 'bottom';
|
||||
}
|
||||
return;
|
||||
});
|
||||
renderRowIndicator() {
|
||||
if (this.readonly) {
|
||||
return nothing;
|
||||
}
|
||||
const columnIndex = this.columnIndex;
|
||||
const isFirstColumn = columnIndex === 0;
|
||||
const isLastColumn =
|
||||
columnIndex === this.dataManager.uiColumns$.value.length - 1;
|
||||
const showIndicator = this.showRowIndicator$.value;
|
||||
const style = (show: boolean) =>
|
||||
styleMap({
|
||||
opacity: show ? 1 : 0,
|
||||
borderRadius: isFirstColumn
|
||||
? '3px 0 0 3px'
|
||||
: isLastColumn
|
||||
? '0 3px 3px 0'
|
||||
: '0',
|
||||
});
|
||||
const indicator0 =
|
||||
this.rowIndex === 0
|
||||
? html`
|
||||
<div
|
||||
style=${style(showIndicator === 'top')}
|
||||
class=${rowTopIndicatorStyle}
|
||||
></div>
|
||||
`
|
||||
: nothing;
|
||||
return html`
|
||||
${indicator0}
|
||||
<div
|
||||
style=${style(showIndicator === 'bottom')}
|
||||
class=${rowBottomIndicatorStyle}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
renderColumnIndicator() {
|
||||
if (this.readonly) {
|
||||
return nothing;
|
||||
}
|
||||
const hoverColumnId$ = this.dataManager.hoverDragHandleColumnId$;
|
||||
const draggingColumnId$ = this.dataManager.widthAdjustColumnId$;
|
||||
const rowIndex = this.rowIndex;
|
||||
const isFirstRow = rowIndex === 0;
|
||||
const isLastRow = rowIndex === this.dataManager.uiRows$.value.length - 1;
|
||||
const showWidthAdjustIndicator =
|
||||
draggingColumnId$.value === this.column?.columnId ||
|
||||
hoverColumnId$.value === this.column?.columnId;
|
||||
const showIndicator = this.showColumnIndicator$.value;
|
||||
const style = (show: boolean) =>
|
||||
styleMap({
|
||||
opacity: show ? 1 : 0,
|
||||
borderRadius: isFirstRow
|
||||
? '3px 3px 0 0'
|
||||
: isLastRow
|
||||
? '0 0 3px 3px'
|
||||
: '0',
|
||||
});
|
||||
const indicator0 =
|
||||
this.columnIndex === 0
|
||||
? html`
|
||||
<div
|
||||
style=${style(showIndicator === 'left')}
|
||||
class=${columnLeftIndicatorStyle}
|
||||
></div>
|
||||
`
|
||||
: nothing;
|
||||
const mouseEnter = () => {
|
||||
hoverColumnId$.value = this.column?.columnId;
|
||||
};
|
||||
const mouseLeave = () => {
|
||||
hoverColumnId$.value = undefined;
|
||||
};
|
||||
return html` ${indicator0}
|
||||
<div
|
||||
@mouseenter=${mouseEnter}
|
||||
@mouseleave=${mouseLeave}
|
||||
style=${style(showWidthAdjustIndicator || showIndicator === 'right')}
|
||||
data-width-adjust-column-id=${this.column?.columnId}
|
||||
class=${columnRightIndicatorStyle}
|
||||
></div>`;
|
||||
}
|
||||
|
||||
richText$ = signal<RichText>();
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText$.value?.inlineEditor;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
const selectAll = (e: KeyboardEvent) => {
|
||||
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.inlineEditor?.selectAll();
|
||||
}
|
||||
};
|
||||
this.addEventListener('keydown', selectAll);
|
||||
this.disposables.add(() => {
|
||||
this.removeEventListener('keydown', selectAll);
|
||||
});
|
||||
this.disposables.addFromEvent(this, 'click', (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.inlineEditor?.inlineRange$.value) {
|
||||
this.inlineEditor?.focusEnd();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
this.richText$.value?.updateComplete
|
||||
.then(() => {
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const richText = this.richText$.value;
|
||||
if (!richText) {
|
||||
return;
|
||||
}
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) {
|
||||
return;
|
||||
}
|
||||
const inlineRange = inlineEditor.inlineRange$.value;
|
||||
const targetSelection: TableAreaSelection = {
|
||||
type: 'area',
|
||||
rowStartIndex: this.rowIndex,
|
||||
rowEndIndex: this.rowIndex,
|
||||
columnStartIndex: this.columnIndex,
|
||||
columnEndIndex: this.columnIndex,
|
||||
};
|
||||
const currentSelection = this.selectionController.selected$.peek();
|
||||
if (
|
||||
inlineRange &&
|
||||
!TableSelectionData.equals(targetSelection, currentSelection)
|
||||
) {
|
||||
this.selectionController.setSelected(targetSelection, false);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.text) {
|
||||
return html`<td class=${cellContainerStyle} style=${this.tdStyle()}>
|
||||
<div
|
||||
style=${styleMap({
|
||||
padding: '8px 12px',
|
||||
})}
|
||||
>
|
||||
<div style="height:22px"></div>
|
||||
</div>
|
||||
</td>`;
|
||||
}
|
||||
return html`
|
||||
<td
|
||||
data-row-id=${this.row?.rowId}
|
||||
data-column-id=${this.column?.columnId}
|
||||
@mouseenter=${() => {
|
||||
this.tdMouseEnter(this.rowIndex, this.columnIndex);
|
||||
}}
|
||||
@mouseleave=${() => {
|
||||
this.tdMouseLeave();
|
||||
}}
|
||||
@contextmenu=${this.onContextMenu}
|
||||
class=${cellContainerStyle}
|
||||
style=${this.tdStyle()}
|
||||
>
|
||||
<rich-text
|
||||
${ref(this.richText$)}
|
||||
data-disable-ask-ai
|
||||
data-not-block-text
|
||||
style=${styleMap({
|
||||
minHeight: '22px',
|
||||
padding: '8px 12px',
|
||||
})}
|
||||
.yText="${this.text}"
|
||||
.inlineEventSource="${this.topContenteditableElement}"
|
||||
.attributesSchema="${this.inlineManager?.getSchema()}"
|
||||
.attributeRenderer="${this.inlineManager?.getRenderer()}"
|
||||
.embedChecker="${this.inlineManager?.embedChecker}"
|
||||
.markdownMatches="${this.inlineManager?.markdownMatches}"
|
||||
.readonly="${this.readonly}"
|
||||
.enableClipboard="${true}"
|
||||
.verticalScrollContainerGetter="${() =>
|
||||
this.topContenteditableElement?.host
|
||||
? getViewportElement(this.topContenteditableElement.host)
|
||||
: null}"
|
||||
data-parent-flavour="affine:table"
|
||||
></rich-text>
|
||||
${this.renderOptionsButton()} ${this.renderColumnIndicator()}
|
||||
${this.renderRowIndicator()}
|
||||
</td>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const createColumnDragPreview = (cells: TableCell[]) => {
|
||||
const container = document.createElement('div');
|
||||
container.style.position = 'absolute';
|
||||
container.style.opacity = '0.8';
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
container.style.zIndex = '1000';
|
||||
container.style.boxShadow = '0 0 10px 0 rgba(0, 0, 0, 0.1)';
|
||||
container.style.backgroundColor = cssVarV2.layer.background.primary;
|
||||
cells.forEach((cell, index) => {
|
||||
const div = document.createElement('div');
|
||||
const td = cell.querySelector('td');
|
||||
if (index !== 0) {
|
||||
div.style.borderTop = `1px solid ${cssVarV2.layer.insideBorder.border}`;
|
||||
}
|
||||
if (td) {
|
||||
div.style.height = `${td.getBoundingClientRect().height}px`;
|
||||
}
|
||||
if (cell.text) {
|
||||
const text = new RichText();
|
||||
text.style.padding = '8px 12px';
|
||||
text.yText = cell.text;
|
||||
text.readonly = true;
|
||||
text.attributesSchema = cell.inlineManager?.getSchema();
|
||||
text.attributeRenderer = cell.inlineManager?.getRenderer();
|
||||
text.embedChecker = cell.inlineManager?.embedChecker ?? (() => false);
|
||||
div.append(text);
|
||||
}
|
||||
container.append(div);
|
||||
});
|
||||
return container;
|
||||
};
|
||||
|
||||
export const createRowDragPreview = (cells: TableCell[]) => {
|
||||
const container = document.createElement('div');
|
||||
container.style.position = 'absolute';
|
||||
container.style.opacity = '0.8';
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'row';
|
||||
container.style.zIndex = '1000';
|
||||
container.style.boxShadow = '0 0 10px 0 rgba(0, 0, 0, 0.1)';
|
||||
container.style.backgroundColor = cssVarV2.layer.background.primary;
|
||||
cells.forEach((cell, index) => {
|
||||
const div = document.createElement('div');
|
||||
const td = cell.querySelector('td');
|
||||
if (index !== 0) {
|
||||
div.style.borderLeft = `1px solid ${cssVarV2.layer.insideBorder.border}`;
|
||||
}
|
||||
if (td) {
|
||||
div.style.width = `${td.getBoundingClientRect().width}px`;
|
||||
}
|
||||
if (cell.text) {
|
||||
const text = new RichText();
|
||||
text.style.padding = '8px 12px';
|
||||
text.yText = cell.text;
|
||||
text.readonly = true;
|
||||
text.attributesSchema = cell.inlineManager?.getSchema();
|
||||
text.attributeRenderer = cell.inlineManager?.getRenderer();
|
||||
text.embedChecker = cell.inlineManager?.embedChecker ?? (() => false);
|
||||
div.append(text);
|
||||
}
|
||||
container.append(div);
|
||||
});
|
||||
return container;
|
||||
};
|
||||
|
||||
const threePointerIcon = (vertical: boolean = false) => {
|
||||
return html`
|
||||
<div
|
||||
class=${threePointerIconStyle}
|
||||
style=${styleMap({
|
||||
transform: vertical ? 'rotate(90deg)' : undefined,
|
||||
})}
|
||||
>
|
||||
<div class=${threePointerIconDotStyle}></div>
|
||||
<div class=${threePointerIconDotStyle}></div>
|
||||
<div class=${threePointerIconDotStyle}></div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[TableCellComponentName]: TableCell;
|
||||
}
|
||||
}
|
||||
382
blocksuite/affine/blocks/table/src/table-data-manager.ts
Normal file
382
blocksuite/affine/blocks/table/src/table-data-manager.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import type { TableBlockModel, TableCell } from '@blocksuite/affine-model';
|
||||
import { generateFractionalIndexingKeyBetween } from '@blocksuite/affine-shared/utils';
|
||||
import { nanoid, Text } from '@blocksuite/store';
|
||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||
|
||||
import type { TableAreaSelection } from './selection-schema';
|
||||
|
||||
export class TableDataManager {
|
||||
constructor(private readonly model: TableBlockModel) {}
|
||||
readonly readonly$: ReadonlySignal<boolean> = computed(() => {
|
||||
return this.model.doc.readonly;
|
||||
});
|
||||
readonly ui = {
|
||||
columnIndicatorIndex$: signal<number>(),
|
||||
rowIndicatorIndex$: signal<number>(),
|
||||
};
|
||||
readonly hoverColumnIndex$ = signal<number>();
|
||||
readonly hoverRowIndex$ = signal<number>();
|
||||
readonly hoverDragHandleColumnId$ = signal<string>();
|
||||
readonly widthAdjustColumnId$ = signal<string>();
|
||||
readonly virtualColumnCount$ = signal<number>(0);
|
||||
readonly virtualRowCount$ = signal<number>(0);
|
||||
readonly virtualWidth$ = signal<
|
||||
{ columnId: string; width: number } | undefined
|
||||
>();
|
||||
readonly cellCountTips$ = computed(
|
||||
() =>
|
||||
`${this.virtualRowCount$.value + this.rows$.value.length} x ${this.virtualColumnCount$.value + this.columns$.value.length}`
|
||||
);
|
||||
readonly rows$ = computed(() => {
|
||||
return Object.values(this.model.props.rows$.value).sort((a, b) =>
|
||||
a.order > b.order ? 1 : -1
|
||||
);
|
||||
});
|
||||
|
||||
readonly columns$ = computed(() => {
|
||||
return Object.values(this.model.props.columns$.value).sort((a, b) =>
|
||||
a.order > b.order ? 1 : -1
|
||||
);
|
||||
});
|
||||
|
||||
readonly uiRows$ = computed(() => {
|
||||
const virtualRowCount = this.virtualRowCount$.value;
|
||||
const rows = this.rows$.value;
|
||||
if (virtualRowCount === 0) {
|
||||
return rows;
|
||||
}
|
||||
if (virtualRowCount > 0) {
|
||||
return [
|
||||
...rows,
|
||||
...Array.from({ length: virtualRowCount }, (_, i) => ({
|
||||
rowId: `${i}`,
|
||||
backgroundColor: undefined,
|
||||
})),
|
||||
];
|
||||
}
|
||||
return rows.slice(0, rows.length + virtualRowCount);
|
||||
});
|
||||
|
||||
readonly uiColumns$ = computed(() => {
|
||||
const virtualColumnCount = this.virtualColumnCount$.value;
|
||||
const columns = this.columns$.value;
|
||||
if (virtualColumnCount === 0) {
|
||||
return columns;
|
||||
}
|
||||
if (virtualColumnCount > 0) {
|
||||
return [
|
||||
...columns,
|
||||
...Array.from({ length: virtualColumnCount }, (_, i) => ({
|
||||
columnId: `${i}`,
|
||||
backgroundColor: undefined,
|
||||
width: undefined,
|
||||
})),
|
||||
];
|
||||
}
|
||||
return columns.slice(0, columns.length + virtualColumnCount);
|
||||
});
|
||||
|
||||
getCell(rowId: string, columnId: string): TableCell | undefined {
|
||||
return this.model.props.cells$.value[`${rowId}:${columnId}`];
|
||||
}
|
||||
|
||||
addRow(after?: number) {
|
||||
const order = this.getOrder(this.rows$.value, after);
|
||||
const rowId = nanoid();
|
||||
this.model.doc.transact(() => {
|
||||
this.model.props.rows[rowId] = {
|
||||
rowId,
|
||||
order,
|
||||
};
|
||||
|
||||
this.columns$.value.forEach(column => {
|
||||
this.model.props.cells[`${rowId}:${column.columnId}`] = {
|
||||
text: new Text(),
|
||||
};
|
||||
});
|
||||
});
|
||||
return rowId;
|
||||
}
|
||||
addNRow(count: number) {
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
if (count > 0) {
|
||||
this.model.doc.transact(() => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.addRow(this.rows$.value.length - 1);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const rows = this.rows$.value;
|
||||
const rowCount = rows.length;
|
||||
this.model.doc.transact(() => {
|
||||
rows.slice(rowCount + count, rowCount).forEach(row => {
|
||||
this.deleteRow(row.rowId);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addNColumn(count: number) {
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
if (count > 0) {
|
||||
this.model.doc.transact(() => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.addColumn(this.columns$.value.length - 1);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const columns = this.columns$.value;
|
||||
const columnCount = columns.length;
|
||||
this.model.doc.transact(() => {
|
||||
columns.slice(columnCount + count, columnCount).forEach(column => {
|
||||
this.deleteColumn(column.columnId);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getOrder<T extends { order: string }>(array: T[], after?: number) {
|
||||
after = after != null ? (after < 0 ? undefined : after) : undefined;
|
||||
const prevOrder = after == null ? null : array[after]?.order;
|
||||
const nextOrder = after == null ? array[0]?.order : array[after + 1]?.order;
|
||||
const order = generateFractionalIndexingKeyBetween(
|
||||
prevOrder ?? null,
|
||||
nextOrder ?? null
|
||||
);
|
||||
return order;
|
||||
}
|
||||
|
||||
addColumn(after?: number) {
|
||||
const order = this.getOrder(this.columns$.value, after);
|
||||
const columnId = nanoid();
|
||||
this.model.doc.transact(() => {
|
||||
this.model.props.columns[columnId] = {
|
||||
columnId,
|
||||
order,
|
||||
};
|
||||
this.rows$.value.forEach(row => {
|
||||
this.model.props.cells[`${row.rowId}:${columnId}`] = {
|
||||
text: new Text(),
|
||||
};
|
||||
});
|
||||
});
|
||||
return columnId;
|
||||
}
|
||||
|
||||
deleteRow(rowId: string) {
|
||||
this.model.doc.transact(() => {
|
||||
Object.keys(this.model.props.rows).forEach(id => {
|
||||
if (id === rowId) {
|
||||
delete this.model.props.rows[id];
|
||||
}
|
||||
});
|
||||
Object.keys(this.model.props.cells).forEach(id => {
|
||||
if (id.startsWith(rowId)) {
|
||||
delete this.model.props.cells[id];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deleteColumn(columnId: string) {
|
||||
this.model.doc.transact(() => {
|
||||
Object.keys(this.model.props.columns).forEach(id => {
|
||||
if (id === columnId) {
|
||||
delete this.model.props.columns[id];
|
||||
}
|
||||
});
|
||||
Object.keys(this.model.props.cells).forEach(id => {
|
||||
if (id.endsWith(`:${columnId}`)) {
|
||||
delete this.model.props.cells[id];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateRowOrder(rowId: string, newOrder: string) {
|
||||
this.model.doc.transact(() => {
|
||||
if (this.model.props.rows[rowId]) {
|
||||
this.model.props.rows[rowId].order = newOrder;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateColumnOrder(columnId: string, newOrder: string) {
|
||||
this.model.doc.transact(() => {
|
||||
if (this.model.props.columns[columnId]) {
|
||||
this.model.props.columns[columnId].order = newOrder;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setRowBackgroundColor(rowId: string, color?: string) {
|
||||
this.model.doc.transact(() => {
|
||||
if (this.model.props.rows[rowId]) {
|
||||
this.model.props.rows[rowId].backgroundColor = color;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setColumnBackgroundColor(columnId: string, color?: string) {
|
||||
this.model.doc.transact(() => {
|
||||
if (this.model.props.columns[columnId]) {
|
||||
this.model.props.columns[columnId].backgroundColor = color;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setColumnWidth(columnId: string, width: number) {
|
||||
this.model.doc.transact(() => {
|
||||
if (this.model.props.columns[columnId]) {
|
||||
this.model.props.columns[columnId].width = width;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearRow(rowId: string) {
|
||||
this.model.doc.transact(() => {
|
||||
Object.keys(this.model.props.cells).forEach(id => {
|
||||
if (id.startsWith(rowId)) {
|
||||
this.model.props.cells[id]?.text.replace(
|
||||
0,
|
||||
this.model.props.cells[id]?.text.length,
|
||||
''
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clearColumn(columnId: string) {
|
||||
this.model.doc.transact(() => {
|
||||
Object.keys(this.model.props.cells).forEach(id => {
|
||||
if (id.endsWith(`:${columnId}`)) {
|
||||
this.model.props.cells[id]?.text.replace(
|
||||
0,
|
||||
this.model.props.cells[id]?.text.length,
|
||||
''
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clearCellsBySelection(selection: TableAreaSelection) {
|
||||
const columns = this.uiColumns$.value;
|
||||
const rows = this.uiRows$.value;
|
||||
const deleteCells: { rowId: string; columnId: string }[] = [];
|
||||
for (let i = selection.rowStartIndex; i <= selection.rowEndIndex; i++) {
|
||||
const row = rows[i];
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let j = selection.columnStartIndex;
|
||||
j <= selection.columnEndIndex;
|
||||
j++
|
||||
) {
|
||||
const column = columns[j];
|
||||
if (!column) {
|
||||
continue;
|
||||
}
|
||||
deleteCells.push({ rowId: row.rowId, columnId: column.columnId });
|
||||
}
|
||||
}
|
||||
this.clearCells(deleteCells);
|
||||
}
|
||||
|
||||
clearCells(cells: { rowId: string; columnId: string }[]) {
|
||||
this.model.doc.transact(() => {
|
||||
cells.forEach(({ rowId, columnId }) => {
|
||||
const text = this.model.props.cells[`${rowId}:${columnId}`]?.text;
|
||||
if (text) {
|
||||
text.replace(0, text.length, '');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
insertColumn(after?: number) {
|
||||
this.addColumn(after);
|
||||
}
|
||||
|
||||
insertRow(after?: number) {
|
||||
this.addRow(after);
|
||||
}
|
||||
|
||||
moveColumn(from: number, after?: number) {
|
||||
const columns = this.columns$.value;
|
||||
const column = columns[from];
|
||||
if (!column) return;
|
||||
const order = this.getOrder(columns, after);
|
||||
this.model.doc.transact(() => {
|
||||
const realColumn = this.model.props.columns[column.columnId];
|
||||
if (realColumn) {
|
||||
realColumn.order = order;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
moveRow(from: number, after?: number) {
|
||||
const rows = this.rows$.value;
|
||||
const row = rows[from];
|
||||
if (!row) return;
|
||||
const order = this.getOrder(rows, after);
|
||||
this.model.doc.transact(() => {
|
||||
const realRow = this.model.props.rows[row.rowId];
|
||||
if (realRow) {
|
||||
realRow.order = order;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
duplicateColumn(index: number) {
|
||||
const oldColumn = this.columns$.value[index];
|
||||
if (!oldColumn) return;
|
||||
const order = this.getOrder(this.columns$.value, index);
|
||||
const newColumnId = nanoid();
|
||||
this.model.doc.transact(() => {
|
||||
this.model.props.columns[newColumnId] = {
|
||||
...oldColumn,
|
||||
columnId: newColumnId,
|
||||
order,
|
||||
};
|
||||
this.rows$.value.forEach(row => {
|
||||
this.model.props.cells[`${row.rowId}:${newColumnId}`] = {
|
||||
text:
|
||||
this.model.props.cells[
|
||||
`${row.rowId}:${oldColumn.columnId}`
|
||||
]?.text.clone() ?? new Text(),
|
||||
};
|
||||
});
|
||||
});
|
||||
return newColumnId;
|
||||
}
|
||||
|
||||
duplicateRow(index: number) {
|
||||
const oldRow = this.rows$.value[index];
|
||||
if (!oldRow) return;
|
||||
const order = this.getOrder(this.rows$.value, index);
|
||||
const newRowId = nanoid();
|
||||
this.model.doc.transact(() => {
|
||||
this.model.props.rows[newRowId] = {
|
||||
...oldRow,
|
||||
rowId: newRowId,
|
||||
order,
|
||||
};
|
||||
this.columns$.value.forEach(column => {
|
||||
this.model.props.cells[`${newRowId}:${column.columnId}`] = {
|
||||
text:
|
||||
this.model.props.cells[
|
||||
`${oldRow.rowId}:${column.columnId}`
|
||||
]?.text.clone() ?? new Text(),
|
||||
};
|
||||
});
|
||||
});
|
||||
return newRowId;
|
||||
}
|
||||
}
|
||||
15
blocksuite/affine/blocks/table/src/table-spec.ts
Normal file
15
blocksuite/affine/blocks/table/src/table-spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { TableModelFlavour } from '@blocksuite/affine-model';
|
||||
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { TableBlockAdapterExtensions } from './adapters/extension.js';
|
||||
import { tableSlashMenuConfig } from './configs/slash-menu.js';
|
||||
|
||||
export const TableBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension(TableModelFlavour),
|
||||
BlockViewExtension(TableModelFlavour, literal`affine-table`),
|
||||
TableBlockAdapterExtensions,
|
||||
SlashMenuConfigExtension(TableModelFlavour, tableSlashMenuConfig),
|
||||
].flat();
|
||||
6
blocksuite/affine/blocks/table/src/utils.ts
Normal file
6
blocksuite/affine/blocks/table/src/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const cleanSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
};
|
||||
22
blocksuite/affine/blocks/table/tsconfig.json
Normal file
22
blocksuite/affine/blocks/table/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../inlines/preset" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/slash-menu" },
|
||||
{ "path": "../../data-view" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user