Files
AFFiNE-Mirror/blocksuite/affine/block-database/src/adapters/html.ts
T

293 lines
8.9 KiB
TypeScript

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
);