chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,70 @@
import {
ListBlockNotionHtmlAdapterExtension,
listBlockNotionHtmlAdapterMatcher,
} from '@blocksuite/affine-block-list';
import {
ParagraphBlockNotionHtmlAdapterExtension,
paragraphBlockNotionHtmlAdapterMatcher,
} from '@blocksuite/affine-block-paragraph';
import type { BlockNotionHtmlAdapterMatcher } from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/block-std';
import {
AttachmentBlockNotionHtmlAdapterExtension,
attachmentBlockNotionHtmlAdapterMatcher,
} from '../../../attachment-block/adapters/notion-html.js';
import {
BookmarkBlockNotionHtmlAdapterExtension,
bookmarkBlockNotionHtmlAdapterMatcher,
} from '../../../bookmark-block/adapters/notion-html.js';
import {
CodeBlockNotionHtmlAdapterExtension,
codeBlockNotionHtmlAdapterMatcher,
} from '../../../code-block/adapters/notion-html.js';
import {
DatabaseBlockNotionHtmlAdapterExtension,
databaseBlockNotionHtmlAdapterMatcher,
} from '../../../database-block/adapters/notion-html.js';
import {
DividerBlockNotionHtmlAdapterExtension,
dividerBlockNotionHtmlAdapterMatcher,
} from '../../../divider-block/adapters/notion-html.js';
import {
ImageBlockNotionHtmlAdapterExtension,
imageBlockNotionHtmlAdapterMatcher,
} from '../../../image-block/adapters/notion-html.js';
import {
LatexBlockNotionHtmlAdapterExtension,
latexBlockNotionHtmlAdapterMatcher,
} from '../../../latex-block/adapters/notion-html.js';
import {
RootBlockNotionHtmlAdapterExtension,
rootBlockNotionHtmlAdapterMatcher,
} from '../../../root-block/adapters/notion-html.js';
export const defaultBlockNotionHtmlAdapterMatchers: BlockNotionHtmlAdapterMatcher[] =
[
listBlockNotionHtmlAdapterMatcher,
paragraphBlockNotionHtmlAdapterMatcher,
codeBlockNotionHtmlAdapterMatcher,
dividerBlockNotionHtmlAdapterMatcher,
imageBlockNotionHtmlAdapterMatcher,
rootBlockNotionHtmlAdapterMatcher,
bookmarkBlockNotionHtmlAdapterMatcher,
databaseBlockNotionHtmlAdapterMatcher,
attachmentBlockNotionHtmlAdapterMatcher,
latexBlockNotionHtmlAdapterMatcher,
];
export const BlockNotionHtmlAdapterExtensions: ExtensionType[] = [
ListBlockNotionHtmlAdapterExtension,
ParagraphBlockNotionHtmlAdapterExtension,
CodeBlockNotionHtmlAdapterExtension,
DividerBlockNotionHtmlAdapterExtension,
ImageBlockNotionHtmlAdapterExtension,
RootBlockNotionHtmlAdapterExtension,
BookmarkBlockNotionHtmlAdapterExtension,
DatabaseBlockNotionHtmlAdapterExtension,
AttachmentBlockNotionHtmlAdapterExtension,
LatexBlockNotionHtmlAdapterExtension,
];
@@ -0,0 +1,296 @@
import {
HastUtils,
type HtmlAST,
type NotionHtmlASTToDeltaMatcher,
} from '@blocksuite/affine-shared/adapters';
import { collapseWhiteSpace } from 'collapse-white-space';
import type { Element, Text } from 'hast';
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
const isText = (ast: HtmlAST): ast is Text => {
return ast.type === 'text';
};
const listElementTags = new Set(['ol', 'ul']);
const strongElementTags = new Set(['strong', 'b']);
const italicElementTags = new Set(['i', 'em']);
const NotionInlineEquationToken = 'notion-text-equation-token';
const NotionUnderlineStyleToken = 'border-bottom:0.05em solid';
export const notionHtmlTextToDeltaMatcher: NotionHtmlASTToDeltaMatcher = {
name: 'text',
match: ast => isText(ast),
toDelta: (ast, context) => {
if (!isText(ast)) {
return [];
}
const { options } = context;
options.trim ??= true;
if (options.pre || ast.value === ' ') {
return [{ insert: ast.value }];
}
if (options.trim) {
const value = collapseWhiteSpace(ast.value, { trim: options.trim });
if (value) {
return [{ insert: value }];
}
return [];
}
if (ast.value) {
return [{ insert: collapseWhiteSpace(ast.value) }];
}
return [];
},
};
export const notionHtmlSpanElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'span-element',
match: ast => isElement(ast) && ast.tagName === 'span',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
if (
Array.isArray(ast.properties?.className) &&
ast.properties?.className.includes(NotionInlineEquationToken)
) {
const latex = HastUtils.getTextContent(
HastUtils.querySelector(ast, 'annotation')
);
return [{ insert: ' ', attributes: { latex } }];
}
// Add underline style detection
if (
typeof ast.properties?.style === 'string' &&
ast.properties?.style?.includes(NotionUnderlineStyleToken)
) {
return ast.children.flatMap(child =>
context.toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, underline: true };
return delta;
})
);
}
return ast.children.flatMap(child => toDelta(child, options));
},
};
export const notionHtmlListToDeltaMatcher: NotionHtmlASTToDeltaMatcher = {
name: 'list-element',
match: ast => isElement(ast) && listElementTags.has(ast.tagName),
toDelta: () => {
return [];
},
};
export const notionHtmlStrongElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'strong-element',
match: ast => isElement(ast) && strongElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, bold: true };
return delta;
})
);
},
};
export const notionHtmlItalicElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'italic-element',
match: ast => isElement(ast) && italicElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, italic: true };
return delta;
})
);
},
};
export const notionHtmlCodeElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'code-element',
match: ast => isElement(ast) && ast.tagName === 'code',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, code: true };
return delta;
})
);
},
};
export const notionHtmlDelElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = {
name: 'del-element',
match: ast => isElement(ast) && ast.tagName === 'del',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, strike: true };
return delta;
})
);
},
};
export const notionHtmlUnderlineElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'underline-element',
match: ast => isElement(ast) && ast.tagName === 'u',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes, underline: true };
return delta;
})
);
},
};
export const notionHtmlLinkElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'link-element',
match: ast => isElement(ast) && ast.tagName === 'a',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const href = ast.properties?.href;
if (typeof href !== 'string') {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
if (options.pageMap) {
const pageId = options.pageMap.get(decodeURIComponent(href));
if (pageId) {
delta.attributes = {
...delta.attributes,
reference: {
type: 'LinkedPage',
pageId,
},
};
delta.insert = ' ';
return delta;
}
}
if (href.startsWith('http')) {
delta.attributes = {
...delta.attributes,
link: href,
};
return delta;
}
return delta;
})
);
},
};
export const notionHtmlMarkElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'mark-element',
match: ast => isElement(ast) && ast.tagName === 'mark',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
const { toDelta, options } = context;
return ast.children.flatMap(child =>
toDelta(child, options).map(delta => {
delta.attributes = { ...delta.attributes };
return delta;
})
);
},
};
export const notionHtmlLiElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = {
name: 'li-element',
match: ast =>
isElement(ast) &&
ast.tagName === 'li' &&
!!HastUtils.querySelector(ast, '.checkbox'),
toDelta: (ast, context) => {
if (!isElement(ast) || !HastUtils.querySelector(ast, '.checkbox')) {
return [];
}
const { toDelta, options } = context;
// Should ignore the children of to do list which is the checkbox and the space following it
const checkBox = HastUtils.querySelector(ast, '.checkbox');
const checkBoxIndex = ast.children.findIndex(child => child === checkBox);
return ast.children
.slice(checkBoxIndex + 2)
.flatMap(child => toDelta(child, options));
},
};
export const notionHtmlBrElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = {
name: 'br-element',
match: ast => isElement(ast) && ast.tagName === 'br',
toDelta: () => {
return [{ insert: '\n' }];
},
};
export const notionHtmlStyleElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher =
{
name: 'style-element',
match: ast => isElement(ast) && ast.tagName === 'style',
toDelta: () => {
return [];
},
};
export const notionHtmlInlineToDeltaMatchers: NotionHtmlASTToDeltaMatcher[] = [
notionHtmlTextToDeltaMatcher,
notionHtmlSpanElementToDeltaMatcher,
notionHtmlStrongElementToDeltaMatcher,
notionHtmlItalicElementToDeltaMatcher,
notionHtmlCodeElementToDeltaMatcher,
notionHtmlDelElementToDeltaMatcher,
notionHtmlUnderlineElementToDeltaMatcher,
notionHtmlLinkElementToDeltaMatcher,
notionHtmlMarkElementToDeltaMatcher,
notionHtmlListToDeltaMatcher,
notionHtmlLiElementToDeltaMatcher,
notionHtmlBrElementToDeltaMatcher,
notionHtmlStyleElementToDeltaMatcher,
];
@@ -0,0 +1,9 @@
export {
BlockNotionHtmlAdapterExtensions,
defaultBlockNotionHtmlAdapterMatchers,
} from './block-matcher.js';
export {
NotionHtmlAdapter,
NotionHtmlAdapterFactoryExtension,
NotionHtmlAdapterFactoryIdentifier,
} from './notion-html.js';
@@ -0,0 +1,299 @@
import {
DEFAULT_NOTE_BACKGROUND_COLOR,
NoteDisplayMode,
} from '@blocksuite/affine-model';
import {
type AdapterContext,
type BlockNotionHtmlAdapterMatcher,
BlockNotionHtmlAdapterMatcherIdentifier,
HastUtils,
type HtmlAST,
type NotionHtml,
NotionHtmlDeltaConverter,
} from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import {
type AssetsManager,
ASTWalker,
BaseAdapter,
type BlockSnapshot,
type DocSnapshot,
type FromBlockSnapshotPayload,
type FromBlockSnapshotResult,
type FromDocSnapshotPayload,
type FromDocSnapshotResult,
type FromSliceSnapshotPayload,
type FromSliceSnapshotResult,
type Job,
nanoid,
type SliceSnapshot,
} from '@blocksuite/store';
import rehypeParse from 'rehype-parse';
import { unified } from 'unified';
import { AdapterFactoryIdentifier } from '../type.js';
import { defaultBlockNotionHtmlAdapterMatchers } from './block-matcher.js';
import { notionHtmlInlineToDeltaMatchers } from './delta-converter/html-inline.js';
type NotionHtmlToSliceSnapshotPayload = {
file: NotionHtml;
assets?: AssetsManager;
blockVersions: Record<string, number>;
workspaceId: string;
pageId: string;
};
type NotionHtmlToDocSnapshotPayload = {
file: NotionHtml;
assets?: AssetsManager;
pageId?: string;
pageMap?: Map<string, string>;
};
type NotionHtmlToBlockSnapshotPayload = NotionHtmlToDocSnapshotPayload;
export class NotionHtmlAdapter extends BaseAdapter<NotionHtml> {
private _traverseNotionHtml = async (
html: HtmlAST,
snapshot: BlockSnapshot,
assets?: AssetsManager,
pageMap?: Map<string, string>
) => {
const walker = new ASTWalker<HtmlAST, BlockSnapshot>();
walker.setONodeTypeGuard(
(node): node is HtmlAST =>
'type' in (node as object) && (node as HtmlAST).type !== undefined
);
walker.setEnter(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
HtmlAST,
BlockSnapshot,
NotionHtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
pageMap,
};
await matcher.toBlockSnapshot.enter?.(o, adapterContext);
}
}
});
walker.setLeave(async (o, context) => {
for (const matcher of this.blockMatchers) {
if (matcher.toMatch(o)) {
const adapterContext: AdapterContext<
HtmlAST,
BlockSnapshot,
NotionHtmlDeltaConverter
> = {
walker,
walkerContext: context,
configs: this.configs,
job: this.job,
deltaConverter: this.deltaConverter,
textBuffer: { content: '' },
assets,
pageMap,
};
await matcher.toBlockSnapshot.leave?.(o, adapterContext);
}
}
});
return walker.walk(html, snapshot);
};
deltaConverter: NotionHtmlDeltaConverter;
constructor(
job: Job,
readonly blockMatchers: BlockNotionHtmlAdapterMatcher[] = defaultBlockNotionHtmlAdapterMatchers
) {
super(job);
this.deltaConverter = new NotionHtmlDeltaConverter(
job.adapterConfigs,
[],
notionHtmlInlineToDeltaMatchers
);
}
private _htmlToAst(notionHtml: NotionHtml) {
return unified().use(rehypeParse).parse(notionHtml);
}
override fromBlockSnapshot(
_payload: FromBlockSnapshotPayload
): Promise<FromBlockSnapshotResult<NotionHtml>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionHtmlAdapter.fromBlockSnapshot is not implemented'
);
}
override fromDocSnapshot(
_payload: FromDocSnapshotPayload
): Promise<FromDocSnapshotResult<NotionHtml>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionHtmlAdapter.fromDocSnapshot is not implemented'
);
}
override fromSliceSnapshot(
_payload: FromSliceSnapshotPayload
): Promise<FromSliceSnapshotResult<NotionHtml>> {
throw new BlockSuiteError(
ErrorCode.TransformerNotImplementedError,
'NotionHtmlAdapter.fromSliceSnapshot is not implemented'
);
}
override toBlockSnapshot(
payload: NotionHtmlToBlockSnapshotPayload
): Promise<BlockSnapshot> {
const notionHtmlAst = this._htmlToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return this._traverseNotionHtml(
notionHtmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets,
payload.pageMap
);
}
override async toDoc(payload: NotionHtmlToDocSnapshotPayload) {
const snapshot = await this.toDocSnapshot(payload);
return this.job.snapshotToDoc(snapshot);
}
override async toDocSnapshot(
payload: NotionHtmlToDocSnapshotPayload
): Promise<DocSnapshot> {
const notionHtmlAst = this._htmlToAst(payload.file);
const titleAst = HastUtils.querySelector(notionHtmlAst, 'title');
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
return {
type: 'page',
meta: {
id: payload.pageId ?? nanoid(),
title: HastUtils.getTextContent(titleAst, ''),
createDate: Date.now(),
tags: [],
},
blocks: {
type: 'block',
id: nanoid(),
flavour: 'affine:page',
props: {
title: {
'$blocksuite:internal:text$': true,
delta: this.deltaConverter.astToDelta(
titleAst ?? {
type: 'text',
value: '',
}
),
},
},
children: [
{
type: 'block',
id: nanoid(),
flavour: 'affine:surface',
props: {
elements: {},
},
children: [],
},
await this._traverseNotionHtml(
notionHtmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets,
payload.pageMap
),
],
},
};
}
override async toSliceSnapshot(
payload: NotionHtmlToSliceSnapshotPayload
): Promise<SliceSnapshot | null> {
const notionHtmlAst = this._htmlToAst(payload.file);
const blockSnapshotRoot = {
type: 'block',
id: nanoid(),
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DEFAULT_NOTE_BACKGROUND_COLOR,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [],
};
const contentSlice = (await this._traverseNotionHtml(
notionHtmlAst,
blockSnapshotRoot as BlockSnapshot,
payload.assets
)) as BlockSnapshot;
if (contentSlice.children.length === 0) {
return null;
}
return {
type: 'slice',
content: [contentSlice],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
}
}
export const NotionHtmlAdapterFactoryIdentifier =
AdapterFactoryIdentifier('NotionHtml');
export const NotionHtmlAdapterFactoryExtension: ExtensionType = {
setup: di => {
di.addImpl(NotionHtmlAdapterFactoryIdentifier, provider => ({
get: (job: Job) =>
new NotionHtmlAdapter(
job,
Array.from(
provider.getAll(BlockNotionHtmlAdapterMatcherIdentifier).values()
)
),
}));
},
};