From 9a721c65b5c38808d43142078e7cf4256b8133f9 Mon Sep 17 00:00:00 2001 From: donteatfriedrice Date: Wed, 30 Apr 2025 05:40:07 +0000 Subject: [PATCH] feat(editor): add callout block markdown adapter (#12070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: [BS-3358](https://linear.app/affine-design/issue/BS-3358/remark-callout-plugin) Closes: [BS-3247](https://linear.app/affine-design/issue/BS-3247/callout-markdown-adapter-适配) ## Summary by CodeRabbit - **New Features** - Added support for callout blocks in Markdown, enabling recognition and conversion of callout syntax (e.g., `[!emoji]`) to and from block structures. - **Bug Fixes** - Improved handling to distinguish callout blocks from regular blockquotes and paragraphs during Markdown processing. - **Tests** - Introduced comprehensive tests for callout block serialization, deserialization, and plugin behavior to ensure correct Markdown handling. - **Chores** - Added a new dependency for Markdown AST traversal. --- .../__tests__/adapters/markdown.unit.spec.ts | 173 ++++++++++++++++++ .../blocks/callout/src/adapters/markdown.ts | 88 +++++++++ .../affine/blocks/callout/src/callout-spec.ts | 2 + blocksuite/affine/blocks/callout/src/store.ts | 3 + .../blocks/paragraph/src/adapters/markdown.ts | 7 +- blocksuite/affine/shared/package.json | 1 + .../markdown/remark-callout.unit.spec.ts | 149 +++++++++++++++ .../affine/shared/src/adapters/index.ts | 2 + .../shared/src/adapters/markdown/markdown.ts | 7 +- .../adapters/markdown/remark-plugins/index.ts | 1 + .../markdown/remark-plugins/remark-callout.ts | 70 +++++++ .../shared/src/adapters/markdown/type.ts | 15 +- yarn.lock | 1 + 13 files changed, 515 insertions(+), 4 deletions(-) create mode 100644 blocksuite/affine/blocks/callout/src/adapters/markdown.ts create mode 100644 blocksuite/affine/shared/src/__tests__/adapters/markdown/remark-callout.unit.spec.ts create mode 100644 blocksuite/affine/shared/src/adapters/markdown/remark-plugins/index.ts create mode 100644 blocksuite/affine/shared/src/adapters/markdown/remark-plugins/remark-callout.ts diff --git a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts index 16b9321423..bfb1377f78 100644 --- a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts +++ b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts @@ -2448,6 +2448,121 @@ World! }); expect(target.file).toBe(markdown); }); + + test('callout', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:8hOLxad5Fv', + flavour: 'affine:callout', + props: { + emoji: '💡', + }, + children: [ + { + type: 'block', + id: 'block:8hOLxad5Fv', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: 'First callout' }], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:8hOLxadvdv', + flavour: 'affine:callout', + props: { + emoji: '', + }, + children: [ + { + type: 'block', + id: 'block:8hOLxad5Fv', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: 'Second callout without emoji' }], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:8hOLxbfdb', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: 'This is a regular blockquote' }], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const markdown = `> \\[!💡] +> +> First callout + +> \\[!] +> +> Second callout without emoji + +> This is a regular blockquote +`; + + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); }); describe('markdown to snapshot', () => { @@ -4182,4 +4297,62 @@ hhh }); expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); }); + + describe('callout', () => { + const calloutBlockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:callout', + props: { + emoji: '💬', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: 'This is a callout' }], + }, + }, + children: [], + }, + ], + }, + ], + }; + + test('callout start with escape character', async () => { + const markdown = '> \\[!💬]\n> This is a callout'; + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(calloutBlockSnapshot); + }); + + test('callout start without escape character', async () => { + const markdown = '> [!💬]\n> This is a callout'; + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(calloutBlockSnapshot); + }); + }); }); diff --git a/blocksuite/affine/blocks/callout/src/adapters/markdown.ts b/blocksuite/affine/blocks/callout/src/adapters/markdown.ts new file mode 100644 index 0000000000..2606506b7a --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/adapters/markdown.ts @@ -0,0 +1,88 @@ +import { CalloutBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + getCalloutEmoji, + isCalloutNode, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +// Currently, the callout block children can only be paragraph block or list block +// In mdast, the node types are `paragraph`, `list`, `heading`, `blockquote` +const CALLOUT_BLOCK_CHILDREN_TYPES = new Set([ + 'paragraph', + 'list', + 'heading', + 'blockquote', +]); + +export const calloutBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { + flavour: CalloutBlockSchema.model.flavour, + toMatch: o => isCalloutNode(o.node), + fromMatch: o => o.node.flavour === CalloutBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + if (!o.node.data || !isCalloutNode(o.node)) { + return; + } + + // Currently, the callout block children can only be a paragraph or a list + // So we should filter out the other children + o.node.children = o.node.children.filter(child => + CALLOUT_BLOCK_CHILDREN_TYPES.has(child.type) + ); + + const { walkerContext } = context; + const calloutEmoji = getCalloutEmoji(o.node); + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: CalloutBlockSchema.model.flavour, + props: { + emoji: calloutEmoji, + }, + children: [], + }, + 'children' + ); + }, + leave: (o, context) => { + const { walkerContext } = context; + if (isCalloutNode(o.node)) { + walkerContext.closeNode(); + } + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const emoji = o.node.props.emoji as string; + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'blockquote', + children: [], + }, + 'children' + ) + .openNode({ + type: 'paragraph', + children: [ + { + type: 'text', + value: `[!${emoji}]`, + }, + ], + }) + .closeNode(); + }, + leave: (_, context) => { + const { walkerContext } = context; + walkerContext.closeNode(); + }, + }, +}; + +export const CalloutBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(calloutBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/blocks/callout/src/callout-spec.ts b/blocksuite/affine/blocks/callout/src/callout-spec.ts index 920228da88..fba0036ce1 100644 --- a/blocksuite/affine/blocks/callout/src/callout-spec.ts +++ b/blocksuite/affine/blocks/callout/src/callout-spec.ts @@ -3,6 +3,7 @@ import { BlockViewExtension, FlavourExtension } from '@blocksuite/std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; +import { CalloutBlockMarkdownAdapterExtension } from './adapters/markdown'; import { CalloutKeymapExtension } from './callout-keymap'; import { calloutSlashMenuConfig } from './configs/slash-menu'; @@ -11,4 +12,5 @@ export const CalloutBlockSpec: ExtensionType[] = [ BlockViewExtension('affine:callout', literal`affine-callout`), CalloutKeymapExtension, SlashMenuConfigExtension('affine:callout', calloutSlashMenuConfig), + CalloutBlockMarkdownAdapterExtension, ]; diff --git a/blocksuite/affine/blocks/callout/src/store.ts b/blocksuite/affine/blocks/callout/src/store.ts index 491b110ab8..7f65c54dd3 100644 --- a/blocksuite/affine/blocks/callout/src/store.ts +++ b/blocksuite/affine/blocks/callout/src/store.ts @@ -4,11 +4,14 @@ import { } from '@blocksuite/affine-ext-loader'; import { CalloutBlockSchemaExtension } from '@blocksuite/affine-model'; +import { CalloutBlockMarkdownAdapterExtension } from './adapters/markdown'; + export class CalloutStoreExtension extends StoreExtensionProvider { override name = 'affine-callout-block'; override setup(context: StoreExtensionContext) { super.setup(context); context.register(CalloutBlockSchemaExtension); + context.register(CalloutBlockMarkdownAdapterExtension); } } diff --git a/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts b/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts index 5abdbd5b9a..4569a18b08 100644 --- a/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts +++ b/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts @@ -3,6 +3,7 @@ import { BlockMarkdownAdapterExtension, type BlockMarkdownAdapterMatcher, IN_PARAGRAPH_NODE_CONTEXT_KEY, + isCalloutNode, type MarkdownAST, } from '@blocksuite/affine-shared/adapters'; import type { DeltaInsert } from '@blocksuite/store'; @@ -26,7 +27,7 @@ const isParagraphMDASTType = (node: MarkdownAST) => export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { flavour: ParagraphBlockSchema.model.flavour, - toMatch: o => isParagraphMDASTType(o.node), + toMatch: o => isParagraphMDASTType(o.node) && !isCalloutNode(o.node), fromMatch: o => o.node.flavour === ParagraphBlockSchema.model.flavour, toBlockSnapshot: { enter: (o, context) => { @@ -78,6 +79,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = break; } case 'blockquote': { + if (isCalloutNode(o.node)) { + return; + } + walkerContext .openNode( { diff --git a/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json index 63d1a812d1..e4b8bad349 100644 --- a/blocksuite/affine/shared/package.json +++ b/blocksuite/affine/shared/package.json @@ -45,6 +45,7 @@ "remark-stringify": "^11.0.0", "rxjs": "^7.8.1", "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", "yjs": "^13.6.21", "zod": "^3.23.8" }, diff --git a/blocksuite/affine/shared/src/__tests__/adapters/markdown/remark-callout.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/adapters/markdown/remark-callout.unit.spec.ts new file mode 100644 index 0000000000..1183827e9a --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/adapters/markdown/remark-callout.unit.spec.ts @@ -0,0 +1,149 @@ +import type { Blockquote, Paragraph } from 'mdast'; +import remarkMath from 'remark-math'; +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; +import { describe, expect, it } from 'vitest'; + +import { remarkGfm } from '../../../adapters/markdown/gfm'; +import { remarkCallout } from '../../../adapters/markdown/remark-plugins'; +import type { MarkdownAST } from '../../../adapters/markdown/type'; + +describe('remarkCallout plugin', () => { + function isBlockQuote(node: MarkdownAST): node is Blockquote { + return node.type === 'blockquote'; + } + + function isParagraph(node: MarkdownAST): node is Paragraph { + return node.type === 'paragraph'; + } + + const process = (content: string) => { + const processor = unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkMath) + .use(remarkCallout); + const ast = processor.parse(content); + return processor.runSync(ast); + }; + + const assertCallout = ( + root: any, + expectedEmoji: string, + expectedText?: string + ) => { + const firstChild = root.children[0]; + expect(isBlockQuote(firstChild)).toBe(true); + expect(firstChild.data).toEqual({ + isCallout: true, + calloutEmoji: expectedEmoji, + }); + + if (expectedText !== undefined) { + if (expectedText === '') { + // if expectedText is empty, the callout should not have any children + expect(firstChild.children).toHaveLength(0); + } else { + const firstParagraph = firstChild.children[0]; + expect(isParagraph(firstParagraph)).toBe(true); + expect(firstParagraph.children[0].value).toBe(expectedText); + } + } + }; + + const assertRegularBlockquote = (root: any, expectedText: string) => { + const firstChild = root.children[0]; + expect(isBlockQuote(firstChild)).toBe(true); + expect(firstChild.data).toBeUndefined(); + + const firstParagraph = firstChild.children[0]; + expect(isParagraph(firstParagraph)).toBe(true); + expect(firstParagraph.children[0].value).toBe(expectedText); + }; + + it('should transform callout with emoji and text in the same line', async () => { + const root = process('> [!💡] This is a callout with emoji'); + assertCallout(root, '💡', 'This is a callout with emoji'); + }); + + it('should transform callout without emoji and text in the same line', async () => { + const root = process('> [!] This is a callout without emoji'); + assertCallout(root, '', 'This is a callout without emoji'); + }); + + it('should handle callout with multiple lines and text in the different line', async () => { + const root = process('> [!💡]\n> with multiple lines'); + assertCallout(root, '💡', 'with multiple lines'); + }); + + it('should handle empty callout', async () => { + const root = process('> [!💡]'); + assertCallout(root, '💡', ''); + }); + + it('should handle callout with leading whitespace', async () => { + const root = process( + '> [!💡]\n> This is a callout with leading whitespace\n ' + ); + assertCallout(root, '💡', 'This is a callout with leading whitespace'); + }); + + it('should handle callout with trailing whitespace', async () => { + const root = process( + '> [!💡]\n> This is a callout with trailing whitespace\n ' + ); + assertCallout(root, '💡', 'This is a callout with trailing whitespace'); + }); + + it('should not transform regular blockquote', async () => { + const root = process('> This is a regular blockquote'); + assertRegularBlockquote(root, 'This is a regular blockquote'); + }); + + it('should not transform regular blockquote when the emoji is not in the start of the line', async () => { + const root = process('> This is a regular blockquote [!💡]'); + assertRegularBlockquote(root, 'This is a regular blockquote [!💡]'); + }); + + it('should not transform when callout marker is in the middle of text', async () => { + const root = process( + '> This is a regular blockquote with [!💡] in the middle' + ); + assertRegularBlockquote( + root, + 'This is a regular blockquote with [!💡] in the middle' + ); + }); + + it('should handle multiple callouts in the same document', async () => { + const root = process( + `> [!💡] First callout\n\n> [!] Second callout without emoji` + ); + expect(root.children).toHaveLength(2); + + assertCallout({ children: [root.children[0]] }, '💡', 'First callout'); + assertCallout( + { children: [root.children[1]] }, + '', + 'Second callout without emoji' + ); + }); + + it('should handle multiple callouts and regular blockquote in the same document', async () => { + const root = process( + `> [!💡] First callout\n\n> [!] Second callout without emoji\n\n> This is a regular blockquote` + ); + expect(root.children).toHaveLength(3); + + assertCallout({ children: [root.children[0]] }, '💡', 'First callout'); + assertCallout( + { children: [root.children[1]] }, + '', + 'Second callout without emoji' + ); + assertRegularBlockquote( + { children: [root.children[2]] }, + 'This is a regular blockquote' + ); + }); +}); diff --git a/blocksuite/affine/shared/src/adapters/index.ts b/blocksuite/affine/shared/src/adapters/index.ts index 7f828f421a..dab76bd0f3 100644 --- a/blocksuite/affine/shared/src/adapters/index.ts +++ b/blocksuite/affine/shared/src/adapters/index.ts @@ -22,11 +22,13 @@ export { type BlockMarkdownAdapterMatcher, BlockMarkdownAdapterMatcherIdentifier, FOOTNOTE_DEFINITION_PREFIX, + getCalloutEmoji, getFootnoteDefinitionText, IN_PARAGRAPH_NODE_CONTEXT_KEY, InlineDeltaToMarkdownAdapterExtension, type InlineDeltaToMarkdownAdapterMatcher, InlineDeltaToMarkdownAdapterMatcherIdentifier, + isCalloutNode, isFootnoteDefinitionNode, isMarkdownAST, type Markdown, diff --git a/blocksuite/affine/shared/src/adapters/markdown/markdown.ts b/blocksuite/affine/shared/src/adapters/markdown/markdown.ts index 1788bd8940..a1c3d2baf9 100644 --- a/blocksuite/affine/shared/src/adapters/markdown/markdown.ts +++ b/blocksuite/affine/shared/src/adapters/markdown/markdown.ts @@ -38,6 +38,7 @@ import { } from './delta-converter'; import { remarkGfm } from './gfm'; import { MarkdownPreprocessorManager } from './preprocessor'; +import { remarkCallout } from './remark-plugins/remark-callout'; import type { Markdown, MarkdownAST } from './type'; type MarkdownToSliceSnapshotPayload = { @@ -204,11 +205,13 @@ export class MarkdownAdapter extends BaseAdapter { } private _markdownToAst(markdown: Markdown) { - return unified() + const processor = unified() .use(remarkParse) .use(remarkGfm) .use(remarkMath) - .parse(markdown); + .use(remarkCallout); + const ast = processor.parse(markdown); + return processor.runSync(ast); } async fromBlockSnapshot({ diff --git a/blocksuite/affine/shared/src/adapters/markdown/remark-plugins/index.ts b/blocksuite/affine/shared/src/adapters/markdown/remark-plugins/index.ts new file mode 100644 index 0000000000..0ab8701c54 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/markdown/remark-plugins/index.ts @@ -0,0 +1 @@ +export * from './remark-callout'; diff --git a/blocksuite/affine/shared/src/adapters/markdown/remark-plugins/remark-callout.ts b/blocksuite/affine/shared/src/adapters/markdown/remark-plugins/remark-callout.ts new file mode 100644 index 0000000000..014662e42e --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/markdown/remark-plugins/remark-callout.ts @@ -0,0 +1,70 @@ +import type { Root } from 'mdast'; +import type { Plugin } from 'unified'; +import { visit } from 'unist-util-visit'; + +/** + * The regex for the callout + * The emoji is optional, so we use `[\p{Extended_Pictographic}]?` to match it + * The `u` flag is for Unicode support + * And only match the line start + * @example + * ```md + * [!💡] + * ``` + */ +const calloutRegex = /^\[!([\p{Extended_Pictographic}]?)\]/u; + +export const remarkCallout: Plugin<[], Root> = () => { + return tree => { + visit(tree, 'blockquote', node => { + // Only process the first child of the blockquote + const firstChild = node.children[0]; + let children = node.children; + if (firstChild?.type === 'paragraph') { + const firstNode = firstChild.children[0]; + if (firstNode?.type === 'text') { + const text = firstNode.value; + const match = text.match(calloutRegex); + if (match) { + const calloutEmoji = match[1]; + // Set the callout data + node.data = { + isCallout: true, + calloutEmoji, + }; + + // Remove the matched callout pattern + const currentText = text + .replace(calloutRegex, '') + .replace(/^\n/, ''); + + // If only one child node and it's empty text, remove all children + if (firstChild.children.length === 1 && currentText.length === 0) { + firstChild.children = []; + // If the first child only has one text node, and the text is only whitespace, remove the first child of blockquote + children = children.slice(1); + } else { + // Otherwise keep remaining children with the callout pattern removed + firstChild.children[0] = { + type: 'text', + value: currentText.trim(), + }; + } + } + } + } + + node.children = [...children]; + }); + }; +}; + +/** + * Extend the BlockquoteData interface to include isCallout and calloutEmoji properties + */ +declare module 'mdast' { + interface BlockquoteData { + isCallout?: boolean; + calloutEmoji?: string; + } +} diff --git a/blocksuite/affine/shared/src/adapters/markdown/type.ts b/blocksuite/affine/shared/src/adapters/markdown/type.ts index be1cad15b7..2f954e08f7 100644 --- a/blocksuite/affine/shared/src/adapters/markdown/type.ts +++ b/blocksuite/affine/shared/src/adapters/markdown/type.ts @@ -1,4 +1,9 @@ -import type { FootnoteDefinition, Root, RootContentMap } from 'mdast'; +import type { + Blockquote, + FootnoteDefinition, + Root, + RootContentMap, +} from 'mdast'; export type Markdown = string; @@ -28,5 +33,13 @@ export const getFootnoteDefinitionText = (node: FootnoteDefinition) => { return paragraph.value; }; +export const isCalloutNode = (node: MarkdownAST): node is Blockquote => { + return node.type === 'blockquote' && !!node.data?.isCallout; +}; + +export const getCalloutEmoji = (node: Blockquote) => { + return node.data?.calloutEmoji ?? ''; +}; + export const FOOTNOTE_DEFINITION_PREFIX = 'footnoteDefinition:'; export const IN_PARAGRAPH_NODE_CONTEXT_KEY = 'mdast:paragraph'; diff --git a/yarn.lock b/yarn.lock index 19dd8b3376..7e87938581 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3702,6 +3702,7 @@ __metadata: remark-stringify: "npm:^11.0.0" rxjs: "npm:^7.8.1" unified: "npm:^11.0.5" + unist-util-visit: "npm:^5.0.0" vitest: "npm:3.1.2" yjs: "npm:^13.6.21" zod: "npm:^3.23.8"