feat(editor): support text highlight html adapter (#9632)

[BS-2061](https://linear.app/affine-design/issue/BS-2061/html-adapter-支持-text-highlight-样式)
This commit is contained in:
donteatfriedrice
2025-01-13 02:20:58 +00:00
parent 76895e29d8
commit 5c4e87ddb5
38 changed files with 216 additions and 144 deletions

View File

@@ -25,11 +25,15 @@
"@lottiefiles/dotlottie-wc": "^0.4.0",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.3",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
"katex": "^0.16.11",
"lit": "^3.2.0",
"lit-html": "^3.2.1",
"lodash.clonedeep": "^4.5.0",
"remark-math": "^6.0.0",
"shiki": "^1.12.0",
"yjs": "^13.6.21",
"zod": "^3.23.8"

View File

@@ -6,6 +6,7 @@ import {
BoldInlineSpecExtension,
CodeInlineSpecExtension,
ColorInlineSpecExtension,
InlineAdapterExtensions,
InlineSpecExtensions,
ItalicInlineSpecExtension,
LatexInlineSpecExtension,
@@ -38,4 +39,5 @@ export const RichTextExtensions: ExtensionType[] = [
MarkdownExtensions,
LatexEditorInlineManagerExtension,
DefaultInlineManagerExtension,
InlineAdapterExtensions,
].flat();

View File

@@ -0,0 +1,17 @@
import type { ExtensionType } from '@blocksuite/store';
import { HtmlInlineToDeltaAdapterExtensions } from './html/html-inline';
import { InlineDeltaToHtmlAdapterExtensions } from './html/inline-delta';
import { InlineDeltaToMarkdownAdapterExtensions } from './markdown/inline-delta';
import { MarkdownInlineToDeltaAdapterExtensions } from './markdown/markdown-inline';
import { NotionHtmlInlineToDeltaAdapterExtensions } from './notion-html/html-inline';
import { InlineDeltaToPlainTextAdapterExtensions } from './plain-text/inline-delta';
export const InlineAdapterExtensions: ExtensionType[] = [
HtmlInlineToDeltaAdapterExtensions,
InlineDeltaToHtmlAdapterExtensions,
InlineDeltaToPlainTextAdapterExtensions,
NotionHtmlInlineToDeltaAdapterExtensions,
InlineDeltaToMarkdownAdapterExtensions,
MarkdownInlineToDeltaAdapterExtensions,
].flat();

View File

@@ -0,0 +1,235 @@
import {
type HtmlAST,
HtmlASTToDeltaExtension,
} from '@blocksuite/affine-shared/adapters';
import { collapseWhiteSpace } from 'collapse-white-space';
import type { Element } from 'hast';
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
const textLikeElementTags = new Set(['span', 'bdi', 'bdo', 'ins']);
const listElementTags = new Set(['ol', 'ul']);
const strongElementTags = new Set(['strong', 'b']);
const italicElementTags = new Set(['i', 'em']);
export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'text',
match: ast => ast.type === 'text',
toDelta: (ast, context) => {
if (!('value' in ast)) {
return [];
}
const { options } = context;
options.trim ??= true;
if (options.pre) {
return [{ insert: ast.value }];
}
const value = options.trim
? collapseWhiteSpace(ast.value, { trim: options.trim })
: collapseWhiteSpace(ast.value);
return value ? [{ insert: value }] : [];
},
});
export const htmlTextLikeElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'text-like-element',
match: ast => isElement(ast) && textLikeElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false })
);
},
});
export const htmlListToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'list-element',
match: ast => isElement(ast) && listElementTags.has(ast.tagName),
toDelta: () => {
return [];
},
});
export const htmlStrongElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'strong-element',
match: ast => isElement(ast) && strongElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, bold: true };
return delta;
})
);
},
});
export const htmlItalicElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'italic-element',
match: ast => isElement(ast) && italicElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, italic: true };
return delta;
})
);
},
});
export const htmlCodeElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'code-element',
match: ast => isElement(ast) && ast.tagName === 'code',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, code: true };
return delta;
})
);
},
});
export const htmlDelElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'del-element',
match: ast => isElement(ast) && ast.tagName === 'del',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, strike: true };
return delta;
})
);
},
});
export const htmlUnderlineElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'underline-element',
match: ast => isElement(ast) && ast.tagName === 'u',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes, underline: true };
return delta;
})
);
},
});
export const htmlLinkElementToDeltaMatcher = HtmlASTToDeltaExtension({
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 { configs } = context;
const baseUrl = configs.get('docLinkBaseUrl') ?? '';
if (baseUrl && href.startsWith(baseUrl)) {
const path = href.substring(baseUrl.length);
// ^ - /{pageId}?mode={mode}&blockIds={blockIds}&elementIds={elementIds}
const match = path.match(/^\/([^?]+)(\?.*)?$/);
if (match) {
const pageId = match?.[1];
const search = match?.[2];
const searchParams = search ? new URLSearchParams(search) : undefined;
const mode = searchParams?.get('mode');
const blockIds = searchParams?.get('blockIds')?.split(',');
const elementIds = searchParams?.get('elementIds')?.split(',');
return [
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId,
params: {
mode:
mode && ['edgeless', 'page'].includes(mode)
? (mode as 'edgeless' | 'page')
: undefined,
blockIds,
elementIds,
},
},
},
},
];
}
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
if (href.startsWith('http')) {
delta.attributes = {
...delta.attributes,
link: href,
};
return delta;
}
return delta;
})
);
},
});
export const htmlMarkElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'mark-element',
match: ast => isElement(ast) && ast.tagName === 'mark',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes };
return delta;
})
);
},
});
export const htmlBrElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'br-element',
match: ast => isElement(ast) && ast.tagName === 'br',
toDelta: () => {
return [{ insert: '\n' }];
},
});
export const HtmlInlineToDeltaAdapterExtensions = [
htmlTextToDeltaMatcher,
htmlTextLikeElementToDeltaMatcher,
htmlStrongElementToDeltaMatcher,
htmlItalicElementToDeltaMatcher,
htmlCodeElementToDeltaMatcher,
htmlDelElementToDeltaMatcher,
htmlUnderlineElementToDeltaMatcher,
htmlLinkElementToDeltaMatcher,
htmlMarkElementToDeltaMatcher,
htmlBrElementToDeltaMatcher,
];

View File

@@ -0,0 +1,219 @@
import type { InlineHtmlAST } from '@blocksuite/affine-shared/adapters';
import {
InlineDeltaToHtmlAdapterExtension,
TextUtils,
} from '@blocksuite/affine-shared/adapters';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
export const boldDeltaToHtmlAdapterMatcher = InlineDeltaToHtmlAdapterExtension({
name: 'bold',
match: delta => !!delta.attributes?.bold,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'strong',
properties: {},
children: [context.current],
};
},
});
export const italicDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'italic',
match: delta => !!delta.attributes?.italic,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'em',
properties: {},
children: [context.current],
};
},
});
export const strikeDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'strike',
match: delta => !!delta.attributes?.strike,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'del',
properties: {},
children: [context.current],
};
},
});
export const inlineCodeDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'inlineCode',
match: delta => !!delta.attributes?.code,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'code',
properties: {},
children: [context.current],
};
},
});
export const underlineDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'underline',
match: delta => !!delta.attributes?.underline,
toAST: (_, context) => {
return {
type: 'element',
tagName: 'u',
properties: {},
children: [context.current],
};
},
});
export const referenceDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'reference',
match: delta => !!delta.attributes?.reference,
toAST: (delta, context) => {
let hast: InlineHtmlAST = {
type: 'text',
value: delta.insert,
};
const reference = delta.attributes?.reference;
if (!reference) {
return hast;
}
const { configs } = context;
const title = configs.get(`title:${reference.pageId}`);
const url = TextUtils.generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(reference.pageId),
reference.params ?? Object.create(null)
);
if (title) {
hast.value = title;
}
hast = {
type: 'element',
tagName: 'a',
properties: {
href: url,
},
children: [hast],
};
return hast;
},
});
export const linkDeltaToHtmlAdapterMatcher = InlineDeltaToHtmlAdapterExtension({
name: 'link',
match: delta => !!delta.attributes?.link,
toAST: (delta, _) => {
const hast: InlineHtmlAST = {
type: 'text',
value: delta.insert,
};
const link = delta.attributes?.link;
if (!link) {
return hast;
}
return {
type: 'element',
tagName: 'a',
properties: {
href: link,
},
children: [hast],
};
},
});
export const highlightBackgroundDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'highlight-background',
match: delta => !!delta.attributes?.background,
toAST: (delta, context, provider) => {
const hast: InlineHtmlAST = {
type: 'element',
tagName: 'span',
properties: {},
children: [context.current],
};
if (!provider || !delta.attributes?.background) {
return hast;
}
const theme = provider.getOptional(ThemeProvider);
if (!theme) {
return hast;
}
const backgroundVar = delta.attributes?.background.substring(
'var('.length,
delta.attributes?.background.indexOf(')')
);
const background = theme.getCssVariableColor(backgroundVar);
return {
type: 'element',
tagName: 'mark',
properties: {
style: `background-color: ${background};`,
},
children: [context.current],
};
},
});
export const highlightColorDeltaToHtmlAdapterMatcher =
InlineDeltaToHtmlAdapterExtension({
name: 'highlight-color',
match: delta => !!delta.attributes?.color,
toAST: (delta, context, provider) => {
const hast: InlineHtmlAST = {
type: 'element',
tagName: 'span',
properties: {},
children: [context.current],
};
if (!provider || !delta.attributes?.color) {
return hast;
}
const theme = provider.getOptional(ThemeProvider);
if (!theme) {
return hast;
}
const colorVar = delta.attributes?.color.substring(
'var('.length,
delta.attributes?.color.indexOf(')')
);
const color = theme.getCssVariableColor(colorVar);
return {
type: 'element',
tagName: 'mark',
properties: {
style: `color: ${color};background-color: transparent`,
},
children: [context.current],
};
},
});
export const InlineDeltaToHtmlAdapterExtensions = [
boldDeltaToHtmlAdapterMatcher,
italicDeltaToHtmlAdapterMatcher,
strikeDeltaToHtmlAdapterMatcher,
underlineDeltaToHtmlAdapterMatcher,
highlightBackgroundDeltaToHtmlAdapterMatcher,
highlightColorDeltaToHtmlAdapterMatcher,
inlineCodeDeltaToHtmlAdapterMatcher,
referenceDeltaToHtmlAdapterMatcher,
linkDeltaToHtmlAdapterMatcher,
];

View File

@@ -0,0 +1,157 @@
import {
InlineDeltaToMarkdownAdapterExtension,
TextUtils,
} from '@blocksuite/affine-shared/adapters';
import type { PhrasingContent } from 'mdast';
import type RemarkMath from 'remark-math';
declare type _GLOBAL_ = typeof RemarkMath;
export const boldDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'bold',
match: delta => !!delta.attributes?.bold,
toAST: (_, context) => {
const { current: currentMdast } = context;
return {
type: 'strong',
children: [currentMdast],
};
},
});
export const italicDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'italic',
match: delta => !!delta.attributes?.italic,
toAST: (_, context) => {
const { current: currentMdast } = context;
return {
type: 'emphasis',
children: [currentMdast],
};
},
});
export const strikeDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'strike',
match: delta => !!delta.attributes?.strike,
toAST: (_, context) => {
const { current: currentMdast } = context;
return {
type: 'delete',
children: [currentMdast],
};
},
});
export const inlineCodeDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'inlineCode',
match: delta => !!delta.attributes?.code,
toAST: delta => ({
type: 'inlineCode',
value: delta.insert,
}),
});
export const referenceDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'reference',
match: delta => !!delta.attributes?.reference,
toAST: (delta, context) => {
let mdast: PhrasingContent = {
type: 'text',
value: delta.insert,
};
const reference = delta.attributes?.reference;
if (!reference) {
return mdast;
}
const { configs } = context;
const title = configs.get(`title:${reference.pageId}`);
const params = reference.params ?? {};
const url = TextUtils.generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(reference.pageId),
params
);
mdast = {
type: 'link',
url,
children: [
{
type: 'text',
value: title ?? '',
},
],
};
return mdast;
},
});
export const linkDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'link',
match: delta => !!delta.attributes?.link,
toAST: (delta, context) => {
const mdast: PhrasingContent = {
type: 'text',
value: delta.insert,
};
const link = delta.attributes?.link;
if (!link) {
return mdast;
}
const { current: currentMdast } = context;
if ('value' in currentMdast) {
if (currentMdast.value === '') {
return {
type: 'text',
value: link,
};
}
if (mdast.value !== link) {
return {
type: 'link',
url: link,
children: [currentMdast],
};
}
}
return mdast;
},
});
export const latexDeltaToMarkdownAdapterMatcher =
InlineDeltaToMarkdownAdapterExtension({
name: 'inlineLatex',
match: delta => !!delta.attributes?.latex,
toAST: delta => {
const mdast: PhrasingContent = {
type: 'text',
value: delta.insert,
};
if (delta.attributes?.latex) {
return {
type: 'inlineMath',
value: delta.attributes.latex,
};
}
return mdast;
},
});
export const InlineDeltaToMarkdownAdapterExtensions = [
referenceDeltaToMarkdownAdapterMatcher,
linkDeltaToMarkdownAdapterMatcher,
inlineCodeDeltaToMarkdownAdapterMatcher,
boldDeltaToMarkdownAdapterMatcher,
italicDeltaToMarkdownAdapterMatcher,
strikeDeltaToMarkdownAdapterMatcher,
latexDeltaToMarkdownAdapterMatcher,
];

View File

@@ -0,0 +1,150 @@
import { MarkdownASTToDeltaExtension } from '@blocksuite/affine-shared/adapters';
export const markdownTextToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'text',
match: ast => ast.type === 'text',
toDelta: ast => {
if (!('value' in ast)) {
return [];
}
return [{ insert: ast.value }];
},
});
export const markdownInlineCodeToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'inlineCode',
match: ast => ast.type === 'inlineCode',
toDelta: ast => {
if (!('value' in ast)) {
return [];
}
return [{ insert: ast.value, attributes: { code: true } }];
},
});
export const markdownStrongToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'strong',
match: ast => ast.type === 'strong',
toDelta: (ast, context) => {
if (!('children' in ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, bold: true };
return delta;
})
);
},
});
export const markdownEmphasisToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'emphasis',
match: ast => ast.type === 'emphasis',
toDelta: (ast, context) => {
if (!('children' in ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, italic: true };
return delta;
})
);
},
});
export const markdownDeleteToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'delete',
match: ast => ast.type === 'delete',
toDelta: (ast, context) => {
if (!('children' in ast)) {
return [];
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, strike: true };
return delta;
})
);
},
});
export const markdownLinkToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'link',
match: ast => ast.type === 'link',
toDelta: (ast, context) => {
if (!('children' in ast) || !('url' in ast)) {
return [];
}
const { configs } = context;
const baseUrl = configs.get('docLinkBaseUrl') ?? '';
if (baseUrl && ast.url.startsWith(baseUrl)) {
const path = ast.url.substring(baseUrl.length);
// ^ - /{pageId}?mode={mode}&blockIds={blockIds}&elementIds={elementIds}
const match = path.match(/^\/([^?]+)(\?.*)?$/);
if (match) {
const pageId = match?.[1];
const search = match?.[2];
const searchParams = search ? new URLSearchParams(search) : undefined;
const mode = searchParams?.get('mode');
const blockIds = searchParams?.get('blockIds')?.split(',');
const elementIds = searchParams?.get('elementIds')?.split(',');
return [
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId,
params: {
mode:
mode && ['edgeless', 'page'].includes(mode)
? (mode as 'edgeless' | 'page')
: undefined,
blockIds,
elementIds,
},
},
},
},
];
}
}
return ast.children.flatMap(child =>
context.toDelta(child).map(delta => {
delta.attributes = { ...delta.attributes, link: ast.url };
return delta;
})
);
},
});
export const markdownListToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'list',
match: ast => ast.type === 'list',
toDelta: () => [],
});
export const markdownInlineMathToDeltaMatcher = MarkdownASTToDeltaExtension({
name: 'inlineMath',
match: ast => ast.type === 'inlineMath',
toDelta: ast => {
if (!('value' in ast)) {
return [];
}
return [{ insert: ' ', attributes: { latex: ast.value } }];
},
});
export const MarkdownInlineToDeltaAdapterExtensions = [
markdownTextToDeltaMatcher,
markdownInlineCodeToDeltaMatcher,
markdownStrongToDeltaMatcher,
markdownEmphasisToDeltaMatcher,
markdownDeleteToDeltaMatcher,
markdownLinkToDeltaMatcher,
markdownInlineMathToDeltaMatcher,
markdownListToDeltaMatcher,
];

View File

@@ -0,0 +1,300 @@
import {
HastUtils,
type HtmlAST,
NotionHtmlASTToDeltaExtension,
} from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/store';
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 = NotionHtmlASTToDeltaExtension({
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 =
NotionHtmlASTToDeltaExtension({
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 = NotionHtmlASTToDeltaExtension({
name: 'list-element',
match: ast => isElement(ast) && listElementTags.has(ast.tagName),
toDelta: () => {
return [];
},
});
export const notionHtmlStrongElementToDeltaMatcher =
NotionHtmlASTToDeltaExtension({
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 =
NotionHtmlASTToDeltaExtension({
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 =
NotionHtmlASTToDeltaExtension({
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 = NotionHtmlASTToDeltaExtension(
{
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 =
NotionHtmlASTToDeltaExtension({
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 =
NotionHtmlASTToDeltaExtension({
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 =
NotionHtmlASTToDeltaExtension({
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 = NotionHtmlASTToDeltaExtension({
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 = NotionHtmlASTToDeltaExtension({
name: 'br-element',
match: ast => isElement(ast) && ast.tagName === 'br',
toDelta: () => {
return [{ insert: '\n' }];
},
});
export const notionHtmlStyleElementToDeltaMatcher =
NotionHtmlASTToDeltaExtension({
name: 'style-element',
match: ast => isElement(ast) && ast.tagName === 'style',
toDelta: () => {
return [];
},
});
export const NotionHtmlInlineToDeltaAdapterExtensions: ExtensionType[] = [
notionHtmlTextToDeltaMatcher,
notionHtmlSpanElementToDeltaMatcher,
notionHtmlStrongElementToDeltaMatcher,
notionHtmlItalicElementToDeltaMatcher,
notionHtmlCodeElementToDeltaMatcher,
notionHtmlDelElementToDeltaMatcher,
notionHtmlUnderlineElementToDeltaMatcher,
notionHtmlLinkElementToDeltaMatcher,
notionHtmlMarkElementToDeltaMatcher,
notionHtmlListToDeltaMatcher,
notionHtmlLiElementToDeltaMatcher,
notionHtmlBrElementToDeltaMatcher,
notionHtmlStyleElementToDeltaMatcher,
];

View File

@@ -0,0 +1,78 @@
import {
InlineDeltaToPlainTextAdapterExtension,
type TextBuffer,
TextUtils,
} from '@blocksuite/affine-shared/adapters';
import type { ExtensionType } from '@blocksuite/store';
export const referenceDeltaMarkdownAdapterMatch =
InlineDeltaToPlainTextAdapterExtension({
name: 'reference',
match: delta => !!delta.attributes?.reference,
toAST: (delta, context) => {
const node: TextBuffer = {
content: delta.insert,
};
const reference = delta.attributes?.reference;
if (!reference) {
return node;
}
const { configs } = context;
const title = configs.get(`title:${reference.pageId}`) ?? '';
const url = TextUtils.generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(reference.pageId),
reference.params ?? Object.create(null)
);
const content = `${title ? `${title}: ` : ''}${url}`;
return {
content,
};
},
});
export const linkDeltaMarkdownAdapterMatch =
InlineDeltaToPlainTextAdapterExtension({
name: 'link',
match: delta => !!delta.attributes?.link,
toAST: delta => {
const linkText = delta.insert;
const node: TextBuffer = {
content: linkText,
};
const link = delta.attributes?.link;
if (!link) {
return node;
}
const content = `${linkText ? `${linkText}: ` : ''}${link}`;
return {
content,
};
},
});
export const latexDeltaMarkdownAdapterMatch =
InlineDeltaToPlainTextAdapterExtension({
name: 'inlineLatex',
match: delta => !!delta.attributes?.latex,
toAST: delta => {
const node: TextBuffer = {
content: delta.insert,
};
if (!delta.attributes?.latex) {
return node;
}
return {
content: delta.attributes?.latex,
};
},
});
export const InlineDeltaToPlainTextAdapterExtensions: ExtensionType[] = [
referenceDeltaMarkdownAdapterMatch,
linkDeltaMarkdownAdapterMatch,
latexDeltaMarkdownAdapterMatch,
];

View File

@@ -1,3 +1,10 @@
export * from './presets/affine-inline-specs.js';
export * from './presets/markdown.js';
export * from './presets/nodes/index.js';
export * from './adapters/extensions';
export * from './adapters/html/html-inline';
export * from './adapters/html/inline-delta';
export * from './adapters/markdown/inline-delta';
export * from './adapters/markdown/markdown-inline';
export * from './adapters/notion-html/html-inline';
export * from './adapters/plain-text/inline-delta';
export * from './presets/affine-inline-specs';
export * from './presets/markdown';
export * from './presets/nodes/index';