mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-10 05:29:08 +08:00
refactor(editor): extract database block (#9435)
Part of: [BS-2269](https://linear.app/affine-design/issue/BS-2269/%E8%BF%81%E7%A7%BB-database-block-%E5%88%B0-affine-%E6%96%87%E4%BB%B6%E5%A4%B9%E4%B8%8B%E5%B9%B6%E5%BC%80%E5%90%AF-nouncheckedindexedaccess)
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/block-std';
|
||||
|
||||
import { DatabaseBlockHtmlAdapterExtension } from './html.js';
|
||||
import { DatabaseBlockMarkdownAdapterExtension } from './markdown.js';
|
||||
import { DatabaseBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { DatabaseBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const DatabaseBlockAdapterExtensions: ExtensionType[] = [
|
||||
DatabaseBlockHtmlAdapterExtension,
|
||||
DatabaseBlockMarkdownAdapterExtension,
|
||||
DatabaseBlockNotionHtmlAdapterExtension,
|
||||
DatabaseBlockPlainTextAdapterExtension,
|
||||
];
|
||||
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
type Column,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
type InlineHtmlAST,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { type BlockSnapshot, nanoid } from '@blocksuite/store';
|
||||
import { format } from 'date-fns/format';
|
||||
import type { Element } from 'hast';
|
||||
|
||||
const DATABASE_NODE_TYPES = new Set(['table', 'thead', 'tbody', 'th', 'tr']);
|
||||
|
||||
export const databaseBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: o =>
|
||||
HastUtils.isElement(o.node) && DATABASE_NODE_TYPES.has(o.node.tagName),
|
||||
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
if (o.node.tagName === 'table') {
|
||||
const tableHeader = HastUtils.querySelector(o.node, 'thead');
|
||||
if (!tableHeader) {
|
||||
return;
|
||||
}
|
||||
const tableHeaderRow = HastUtils.querySelector(tableHeader, 'tr');
|
||||
if (!tableHeaderRow) {
|
||||
return;
|
||||
}
|
||||
// Table header row as database header row
|
||||
const viewsColumns = tableHeaderRow.children.map(() => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
hide: false,
|
||||
width: 180,
|
||||
};
|
||||
});
|
||||
|
||||
// Build database cells from table body rows
|
||||
const cells = Object.create(null);
|
||||
const tableBody = HastUtils.querySelector(o.node, 'tbody');
|
||||
tableBody?.children.forEach(row => {
|
||||
const rowId = nanoid();
|
||||
cells[rowId] = Object.create(null);
|
||||
(row as Element).children.forEach((cell, index) => {
|
||||
cells[rowId][viewsColumns[index].id] = {
|
||||
columnId: viewsColumns[index].id,
|
||||
value: TextUtils.createText(
|
||||
(cell as Element).children
|
||||
.map(child => ('value' in child ? child.value : ''))
|
||||
.join('')
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Build database columns from table header row
|
||||
const columns = tableHeaderRow.children.map((_child, index) => {
|
||||
return {
|
||||
type: index === 0 ? 'title' : 'rich-text',
|
||||
name: (_child as Element).children
|
||||
.map(child => ('value' in child ? child.value : ''))
|
||||
.join(''),
|
||||
data: {},
|
||||
id: viewsColumns[index].id,
|
||||
};
|
||||
});
|
||||
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:database',
|
||||
props: {
|
||||
views: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Table View',
|
||||
mode: 'table',
|
||||
columns: [],
|
||||
filter: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
header: {
|
||||
titleColumn: viewsColumns[0]?.id,
|
||||
iconColumn: 'type',
|
||||
},
|
||||
},
|
||||
],
|
||||
title: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [],
|
||||
},
|
||||
cells,
|
||||
columns,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
walkerContext.setNodeContext('affine:table:rowid', Object.keys(cells));
|
||||
walkerContext.skipChildren(1);
|
||||
}
|
||||
|
||||
// The first child of each table body row is the database title cell
|
||||
if (o.node.tagName === 'tr') {
|
||||
const { deltaConverter } = context;
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'block',
|
||||
id:
|
||||
(
|
||||
walkerContext.getNodeContext(
|
||||
'affine:table:rowid'
|
||||
) as Array<string>
|
||||
).shift() ?? nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node.children[0]),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
})
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
if (o.node.tagName === 'table') {
|
||||
walkerContext.closeNode();
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
const columns = o.node.props.columns as Array<Column>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
|
||||
const createAstTableCell = (
|
||||
children: InlineHtmlAST[]
|
||||
): InlineHtmlAST => ({
|
||||
type: 'element',
|
||||
tagName: 'td',
|
||||
properties: Object.create(null),
|
||||
children,
|
||||
});
|
||||
|
||||
const createAstTableHeaderCell = (
|
||||
children: InlineHtmlAST[]
|
||||
): InlineHtmlAST => ({
|
||||
type: 'element',
|
||||
tagName: 'th',
|
||||
properties: Object.create(null),
|
||||
children,
|
||||
});
|
||||
|
||||
const createAstTableRow = (cells: InlineHtmlAST[]): Element => ({
|
||||
type: 'element',
|
||||
tagName: 'tr',
|
||||
properties: Object.create(null),
|
||||
children: cells,
|
||||
});
|
||||
|
||||
const { deltaConverter } = context;
|
||||
const htmlAstRows = Array.prototype.map.call(
|
||||
children,
|
||||
(v: BlockSnapshot) => {
|
||||
const rowCells = Array.prototype.map.call(columns, col => {
|
||||
const cell = cells[v.id]?.[col.id];
|
||||
if (!cell && col.type !== 'title') {
|
||||
return createAstTableCell([{ type: 'text', value: '' }]);
|
||||
}
|
||||
switch (col.type) {
|
||||
case 'rich-text':
|
||||
return createAstTableCell(
|
||||
deltaConverter.deltaToAST(
|
||||
(cell.value as { delta: DeltaInsert[] }).delta
|
||||
)
|
||||
);
|
||||
case 'title':
|
||||
return createAstTableCell(
|
||||
deltaConverter.deltaToAST(
|
||||
(v.props.text as { delta: DeltaInsert[] }).delta
|
||||
)
|
||||
);
|
||||
case 'date':
|
||||
return createAstTableCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: format(new Date(cell.value as number), 'yyyy-MM-dd'),
|
||||
},
|
||||
]);
|
||||
case 'select': {
|
||||
const value =
|
||||
(col.data.options.find(
|
||||
(opt: Record<string, string>) => opt.id === cell.value
|
||||
)?.value as string) ?? '';
|
||||
return createAstTableCell([{ type: 'text', value }]);
|
||||
}
|
||||
case 'multi-select': {
|
||||
const value = Array.prototype.map
|
||||
.call(
|
||||
cell.value,
|
||||
val =>
|
||||
col.data.options.find(
|
||||
(opt: Record<string, string>) => val === opt.id
|
||||
).value ?? ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
return createAstTableCell([{ type: 'text', value }]);
|
||||
}
|
||||
case 'checkbox': {
|
||||
return createAstTableCell([
|
||||
{ type: 'text', value: String(cell.value) },
|
||||
]);
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
default:
|
||||
return createAstTableCell([
|
||||
{ type: 'text', value: String(cell.value) },
|
||||
]);
|
||||
}
|
||||
}) as InlineHtmlAST[];
|
||||
return createAstTableRow(rowCells);
|
||||
}
|
||||
) as Element[];
|
||||
|
||||
// Handle first row (header).
|
||||
const headerRow = createAstTableRow(
|
||||
Array.prototype.map.call(columns, v =>
|
||||
createAstTableHeaderCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: v.name ?? '',
|
||||
},
|
||||
])
|
||||
) as Element[]
|
||||
);
|
||||
|
||||
const tableHeaderAst: Element = {
|
||||
type: 'element',
|
||||
tagName: 'thead',
|
||||
properties: Object.create(null),
|
||||
children: [headerRow],
|
||||
};
|
||||
|
||||
const tableBodyAst: Element = {
|
||||
type: 'element',
|
||||
tagName: 'tbody',
|
||||
properties: Object.create(null),
|
||||
children: [...htmlAstRows],
|
||||
};
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'element',
|
||||
tagName: 'table',
|
||||
properties: Object.create(null),
|
||||
children: [tableHeaderAst, tableBodyAst],
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
databaseBlockHtmlAdapterMatcher
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './html';
|
||||
export * from './markdown';
|
||||
export * from './notion-html';
|
||||
export * from './plain-text';
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
type Column,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
type MarkdownAST,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { type BlockSnapshot, nanoid } from '@blocksuite/store';
|
||||
import { format } from 'date-fns/format';
|
||||
import type { TableRow } from 'mdast';
|
||||
|
||||
const DATABASE_NODE_TYPES = new Set(['table', 'tableRow']);
|
||||
|
||||
const isDatabaseNode = (node: MarkdownAST) =>
|
||||
DATABASE_NODE_TYPES.has(node.type);
|
||||
|
||||
export const databaseBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
{
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: o => isDatabaseNode(o.node),
|
||||
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
if (o.node.type === 'table') {
|
||||
const viewsColumns = o.node.children[0].children.map(() => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
hide: false,
|
||||
width: 180,
|
||||
};
|
||||
});
|
||||
const cells = Object.create(null);
|
||||
o.node.children.slice(1).forEach(row => {
|
||||
const rowId = nanoid();
|
||||
cells[rowId] = Object.create(null);
|
||||
row.children.slice(1).forEach((cell, index) => {
|
||||
cells[rowId][viewsColumns[index + 1].id] = {
|
||||
columnId: viewsColumns[index + 1].id,
|
||||
value: TextUtils.createText(
|
||||
cell.children
|
||||
.map(child => ('value' in child ? child.value : ''))
|
||||
.join('')
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
const columns = o.node.children[0].children.map((_child, index) => {
|
||||
return {
|
||||
type: index === 0 ? 'title' : 'rich-text',
|
||||
name: _child.children
|
||||
.map(child => ('value' in child ? child.value : ''))
|
||||
.join(''),
|
||||
data: {},
|
||||
id: viewsColumns[index].id,
|
||||
};
|
||||
});
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:database',
|
||||
props: {
|
||||
views: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Table View',
|
||||
mode: 'table',
|
||||
columns: [],
|
||||
filter: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
header: {
|
||||
titleColumn: viewsColumns[0]?.id,
|
||||
iconColumn: 'type',
|
||||
},
|
||||
},
|
||||
],
|
||||
title: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [],
|
||||
},
|
||||
cells,
|
||||
columns,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
walkerContext.setNodeContext(
|
||||
'affine:table:rowid',
|
||||
Object.keys(cells)
|
||||
);
|
||||
walkerContext.skipChildren(1);
|
||||
}
|
||||
|
||||
if (o.node.type === 'tableRow') {
|
||||
const { deltaConverter } = context;
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'block',
|
||||
id:
|
||||
(
|
||||
walkerContext.getNodeContext(
|
||||
'affine:table:rowid'
|
||||
) as Array<string>
|
||||
).shift() ?? nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node.children[0]),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
})
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
if (o.node.type === 'table') {
|
||||
walkerContext.closeNode();
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const rows: TableRow[] = [];
|
||||
const columns = o.node.props.columns as Array<Column>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const createAstCell = (children: MarkdownAST[]) => ({
|
||||
type: 'tableCell',
|
||||
children,
|
||||
});
|
||||
const mdAstCells = Array.prototype.map.call(
|
||||
children,
|
||||
(v: BlockSnapshot) =>
|
||||
Array.prototype.map.call(columns, col => {
|
||||
const cell = cells[v.id]?.[col.id];
|
||||
if (!cell && col.type !== 'title') {
|
||||
return createAstCell([{ type: 'text', value: '' }]);
|
||||
}
|
||||
switch (col.type) {
|
||||
case 'link':
|
||||
case 'progress':
|
||||
case 'number':
|
||||
return createAstCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: cell.value as string,
|
||||
},
|
||||
]);
|
||||
case 'rich-text':
|
||||
return createAstCell(
|
||||
deltaConverter.deltaToAST(
|
||||
(cell.value as { delta: DeltaInsert[] }).delta
|
||||
)
|
||||
);
|
||||
case 'title':
|
||||
return createAstCell(
|
||||
deltaConverter.deltaToAST(
|
||||
(v.props.text as { delta: DeltaInsert[] }).delta
|
||||
)
|
||||
);
|
||||
case 'date':
|
||||
return createAstCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: format(
|
||||
new Date(cell.value as number),
|
||||
'yyyy-MM-dd'
|
||||
),
|
||||
},
|
||||
]);
|
||||
case 'select': {
|
||||
const value = col.data.options.find(
|
||||
(opt: Record<string, string>) => opt.id === cell.value
|
||||
)?.value;
|
||||
return createAstCell([{ type: 'text', value }]);
|
||||
}
|
||||
case 'multi-select': {
|
||||
const value = Array.prototype.map
|
||||
.call(
|
||||
cell.value,
|
||||
val =>
|
||||
col.data.options.find(
|
||||
(opt: Record<string, string>) => val === opt.id
|
||||
).value
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
return createAstCell([{ type: 'text', value }]);
|
||||
}
|
||||
case 'checkbox': {
|
||||
return createAstCell([
|
||||
{ type: 'text', value: cell.value as string },
|
||||
]);
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
default:
|
||||
return createAstCell([
|
||||
{ type: 'text', value: cell.value as string },
|
||||
]);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Handle first row.
|
||||
if (Array.isArray(columns)) {
|
||||
rows.push({
|
||||
type: 'tableRow',
|
||||
children: Array.prototype.map.call(columns, v =>
|
||||
createAstCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: v.name,
|
||||
},
|
||||
])
|
||||
) as [],
|
||||
});
|
||||
}
|
||||
|
||||
// Handle 2-... rows
|
||||
Array.prototype.forEach.call(mdAstCells, children => {
|
||||
rows.push({ type: 'tableRow', children });
|
||||
});
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'table',
|
||||
children: rows,
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockMarkdownAdapterExtension =
|
||||
BlockMarkdownAdapterExtension(databaseBlockMarkdownAdapterMatcher);
|
||||
@@ -0,0 +1,348 @@
|
||||
import { DatabaseBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { getTagColor } from '@blocksuite/data-view';
|
||||
import { type BlockSnapshot, nanoid } from '@blocksuite/store';
|
||||
|
||||
const ColumnClassMap: Record<string, string> = {
|
||||
typesSelect: 'select',
|
||||
typesMultipleSelect: 'multi-select',
|
||||
typesNumber: 'number',
|
||||
typesCheckbox: 'checkbox',
|
||||
typesText: 'rich-text',
|
||||
typesTitle: 'title',
|
||||
};
|
||||
|
||||
const NotionDatabaseToken = '.collection-content';
|
||||
const NotionDatabaseTitleToken = '.collection-title';
|
||||
|
||||
type BlocksuiteTableColumn = {
|
||||
type: string;
|
||||
name: string;
|
||||
data: {
|
||||
options?: {
|
||||
id: string;
|
||||
value: string;
|
||||
color: string;
|
||||
}[];
|
||||
};
|
||||
id: string;
|
||||
};
|
||||
|
||||
type BlocksuiteTableRow = Record<
|
||||
string,
|
||||
{
|
||||
columnId: string;
|
||||
value: unknown;
|
||||
}
|
||||
>;
|
||||
|
||||
const DATABASE_NODE_TYPES = new Set(['table', 'th', 'tr']);
|
||||
|
||||
export const databaseBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
|
||||
{
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: o =>
|
||||
HastUtils.isElement(o.node) && DATABASE_NODE_TYPES.has(o.node.tagName),
|
||||
fromMatch: () => false,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext, deltaConverter, pageMap } = context;
|
||||
switch (o.node.tagName) {
|
||||
case 'th': {
|
||||
const columnId = nanoid();
|
||||
const columnTypeClass = HastUtils.querySelector(o.node, 'svg')
|
||||
?.properties?.className;
|
||||
const columnType = Array.isArray(columnTypeClass)
|
||||
? (ColumnClassMap[columnTypeClass[0]] ?? 'rich-text')
|
||||
: 'rich-text';
|
||||
walkerContext.pushGlobalContextStack<BlocksuiteTableColumn>(
|
||||
'hast:table:column',
|
||||
{
|
||||
type: columnType,
|
||||
name: HastUtils.getTextContent(
|
||||
HastUtils.getTextChildrenOnlyAst(o.node)
|
||||
),
|
||||
data: Object.create(null),
|
||||
id: columnId,
|
||||
}
|
||||
);
|
||||
// disable icon img in th
|
||||
walkerContext.setGlobalContext('hast:disableimg', true);
|
||||
break;
|
||||
}
|
||||
case 'tr': {
|
||||
if (
|
||||
o.parent?.node.type === 'element' &&
|
||||
o.parent.node.tagName === 'tbody'
|
||||
) {
|
||||
const columns =
|
||||
walkerContext.getGlobalContextStack<BlocksuiteTableColumn>(
|
||||
'hast:table:column'
|
||||
);
|
||||
const row = Object.create(null);
|
||||
let plainTable = false;
|
||||
HastUtils.getElementChildren(o.node).forEach((child, index) => {
|
||||
if (plainTable || columns[index] === undefined) {
|
||||
plainTable = true;
|
||||
if (columns[index] === undefined) {
|
||||
columns.push({
|
||||
type: 'rich-text',
|
||||
name: '',
|
||||
data: Object.create(null),
|
||||
id: nanoid(),
|
||||
});
|
||||
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children',
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(child),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children',
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(child),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
);
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: HastUtils.getTextContent(child),
|
||||
};
|
||||
} else if (HastUtils.querySelector(child, '.cell-title')) {
|
||||
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children',
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(child, { pageMap }),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
);
|
||||
columns[index].type = 'title';
|
||||
return;
|
||||
}
|
||||
const optionIds: string[] = [];
|
||||
if (HastUtils.querySelector(child, '.selected-value')) {
|
||||
if (!('options' in columns[index].data)) {
|
||||
columns[index].data.options = [];
|
||||
}
|
||||
if (
|
||||
!['multi-select', 'select'].includes(columns[index].type)
|
||||
) {
|
||||
columns[index].type = 'select';
|
||||
}
|
||||
if (
|
||||
columns[index].type === 'select' &&
|
||||
child.type === 'element' &&
|
||||
child.children.length > 1
|
||||
) {
|
||||
columns[index].type = 'multi-select';
|
||||
}
|
||||
child.type === 'element' &&
|
||||
child.children.forEach(span => {
|
||||
const filteredArray = columns[index].data.options?.filter(
|
||||
option =>
|
||||
option.value === HastUtils.getTextContent(span)
|
||||
);
|
||||
const id = filteredArray?.length
|
||||
? filteredArray[0].id
|
||||
: nanoid();
|
||||
if (!filteredArray?.length) {
|
||||
columns[index].data.options?.push({
|
||||
id,
|
||||
value: HastUtils.getTextContent(span),
|
||||
color: getTagColor(),
|
||||
});
|
||||
}
|
||||
optionIds.push(id);
|
||||
});
|
||||
// Expand will be done when leaving the table
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: optionIds,
|
||||
};
|
||||
} else if (HastUtils.querySelector(child, '.checkbox')) {
|
||||
if (columns[index].type !== 'checkbox') {
|
||||
columns[index].type = 'checkbox';
|
||||
}
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: HastUtils.querySelector(child, '.checkbox-on')
|
||||
? true
|
||||
: false,
|
||||
};
|
||||
} else if (columns[index].type === 'number') {
|
||||
const text = HastUtils.getTextContent(child);
|
||||
const number = Number(text);
|
||||
if (Number.isNaN(number)) {
|
||||
columns[index].type = 'rich-text';
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: TextUtils.createText(text),
|
||||
};
|
||||
} else {
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: number,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: HastUtils.getTextContent(child),
|
||||
};
|
||||
}
|
||||
if (
|
||||
columns[index].type === 'rich-text' &&
|
||||
!TextUtils.isText(row[columns[index].id].value)
|
||||
) {
|
||||
row[columns[index].id] = {
|
||||
columnId: columns[index].id,
|
||||
value: TextUtils.createText(row[columns[index].id].value),
|
||||
};
|
||||
}
|
||||
});
|
||||
walkerContext.setGlobalContextStack('hast:table:column', columns);
|
||||
walkerContext.pushGlobalContextStack('hast:table:rows', row);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
switch (o.node.tagName) {
|
||||
case 'table': {
|
||||
const columns =
|
||||
walkerContext.getGlobalContextStack<BlocksuiteTableColumn>(
|
||||
'hast:table:column'
|
||||
);
|
||||
walkerContext.setGlobalContextStack('hast:table:column', []);
|
||||
const children = walkerContext.getGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children'
|
||||
);
|
||||
walkerContext.setGlobalContextStack('hast:table:children', []);
|
||||
const cells = Object.create(null);
|
||||
walkerContext
|
||||
.getGlobalContextStack<BlocksuiteTableRow>('hast:table:rows')
|
||||
.forEach((row, i) => {
|
||||
Object.keys(row).forEach(columnId => {
|
||||
if (
|
||||
columns.find(column => column.id === columnId)?.type ===
|
||||
'select'
|
||||
) {
|
||||
row[columnId].value = (row[columnId].value as string[])[0];
|
||||
}
|
||||
});
|
||||
cells[children.at(i)?.id ?? nanoid()] = row;
|
||||
});
|
||||
walkerContext.setGlobalContextStack('hast:table:cells', []);
|
||||
let databaseTitle = '';
|
||||
if (
|
||||
o.parent?.node.type === 'element' &&
|
||||
HastUtils.querySelector(o.parent.node, NotionDatabaseToken)
|
||||
) {
|
||||
databaseTitle = HastUtils.getTextContent(
|
||||
HastUtils.querySelector(o.parent.node, NotionDatabaseTitleToken)
|
||||
);
|
||||
}
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
props: {
|
||||
views: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Table View',
|
||||
mode: 'table',
|
||||
columns: [],
|
||||
filter: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
header: {
|
||||
titleColumn:
|
||||
columns.find(column => column.type === 'title')?.id ??
|
||||
'',
|
||||
iconColumn: 'type',
|
||||
},
|
||||
},
|
||||
],
|
||||
title: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: databaseTitle
|
||||
? [
|
||||
{
|
||||
insert: databaseTitle,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
columns,
|
||||
cells,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
children.forEach(child => {
|
||||
walkerContext.openNode(child, 'children').closeNode();
|
||||
});
|
||||
walkerContext.closeNode();
|
||||
walkerContext.cleanGlobalContextStack('hast:table:column');
|
||||
walkerContext.cleanGlobalContextStack('hast:table:rows');
|
||||
walkerContext.cleanGlobalContextStack('hast:table:children');
|
||||
break;
|
||||
}
|
||||
case 'th': {
|
||||
walkerContext.setGlobalContext('hast:disableimg', false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const DatabaseBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(databaseBlockNotionHtmlAdapterMatcher);
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
type Column,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import type { BlockSnapshot } from '@blocksuite/store';
|
||||
import { format } from 'date-fns/format';
|
||||
|
||||
import { formatTable } from './utils.js';
|
||||
|
||||
export const databaseBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
|
||||
{
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const rows: string[][] = [];
|
||||
const columns = o.node.props.columns as Array<Column>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const tableCells = children.map((v: BlockSnapshot) =>
|
||||
columns.map(col => {
|
||||
const cell = cells[v.id]?.[col.id];
|
||||
if (!cell && col.type !== 'title') {
|
||||
return '';
|
||||
}
|
||||
switch (col.type) {
|
||||
case 'rich-text':
|
||||
return deltaConverter
|
||||
.deltaToAST((cell.value as { delta: DeltaInsert[] }).delta)
|
||||
.join('');
|
||||
case 'title':
|
||||
return deltaConverter
|
||||
.deltaToAST((v.props.text as { delta: DeltaInsert[] }).delta)
|
||||
.join('');
|
||||
case 'date':
|
||||
return format(new Date(cell.value as number), 'yyyy-MM-dd');
|
||||
case 'select': {
|
||||
const value = (
|
||||
col.data as { options: Array<Record<string, string>> }
|
||||
).options.find(opt => opt.id === cell.value)?.value;
|
||||
return value || '';
|
||||
}
|
||||
case 'multi-select': {
|
||||
const value = (cell.value as string[])
|
||||
.map(
|
||||
val =>
|
||||
(
|
||||
col.data as { options: Array<Record<string, string>> }
|
||||
).options.find(opt => val === opt.id)?.value
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
return value || '';
|
||||
}
|
||||
default:
|
||||
return String(cell.value);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Handle first row.
|
||||
if (Array.isArray(columns)) {
|
||||
rows.push(columns.map(col => col.name));
|
||||
}
|
||||
|
||||
// Handle 2-... rows
|
||||
tableCells.forEach(children => {
|
||||
rows.push(children);
|
||||
});
|
||||
|
||||
// Convert rows to table string
|
||||
const tableString = formatTable(rows);
|
||||
|
||||
context.textBuffer.content += tableString;
|
||||
context.textBuffer.content += '\n';
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(databaseBlockPlainTextAdapterMatcher);
|
||||
@@ -0,0 +1,32 @@
|
||||
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], ' ')
|
||||
);
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user