From bfec5dd594258be65804fc46daea673630cb587b Mon Sep 17 00:00:00 2001 From: donteatfriedrice Date: Wed, 16 Apr 2025 04:27:39 +0000 Subject: [PATCH] fix(editor): markdown html and image import (#11712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close [BS-3145](https://linear.app/affine-design/issue/BS-3145/markdown-adapter-html-标签导入成-code-block) [BS-3154](https://linear.app/affine-design/issue/BS-3154/[bug]-使用-markdown-with-files-导入到-affine-图片丢失) --- .../__tests__/adapters/markdown.unit.spec.ts | 471 +++++++++--------- .../code/src/adapters/markdown/markdown.ts | 34 +- .../blocks/paragraph/src/adapters/markdown.ts | 41 +- .../affine/shared/src/adapters/index.ts | 1 + .../shared/src/adapters/markdown/type.ts | 1 + 5 files changed, 285 insertions(+), 263 deletions(-) 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 b6a4a36fe..1b4a6b373 100644 --- a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts +++ b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts @@ -2448,203 +2448,262 @@ World! }); describe('markdown to snapshot', () => { - test('code', async () => { - const markdown = '```python\nimport this\n```\n'; + describe('code', () => { + test('markdown code block', async () => { + const markdown = '```python\nimport this\n```\n'; - const blockSnapshot: 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:code', - props: { - language: 'python', - wrap: false, - text: { - '$blocksuite:internal:text$': true, - delta: [ - { - insert: 'import this', - }, - ], - }, - }, - children: [], + const blockSnapshot: 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, }, - ], - }; - - const mdAdapter = new MarkdownAdapter(createJob(), provider); - const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ - file: markdown, - }); - expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); - }); - - test('code with indentation 1 - slice', async () => { - const markdown = '```python\n import this\n```'; - - const sliceSnapshot: SliceSnapshot = { - type: 'slice', - content: [ - { - type: 'block', - id: 'matchesReplaceMap[0]', - flavour: 'affine:note', - props: { - xywh: '[0,0,800,95]', - background: DefaultTheme.noteBackgrounColor, - index: 'a0', - hidden: false, - displayMode: 'both', - }, - children: [ - { - type: 'block', - id: 'matchesReplaceMap[1]', - flavour: 'affine:code', - props: { - language: 'python', - wrap: false, - text: { - '$blocksuite:internal:text$': true, - delta: [ - { - insert: ' import this', - }, - ], - }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:code', + props: { + language: 'python', + wrap: false, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'import this', + }, + ], }, - children: [], }, - ], - }, - ], - workspaceId: '', - pageId: '', - }; - - const mdAdapter = new MarkdownAdapter(createJob(), provider); - const rawSliceSnapshot = await mdAdapter.toSliceSnapshot({ - file: markdown, - workspaceId: '', - pageId: '', - }); - expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); - }); - - test('code with indentation 2 - slice', async () => { - const markdown = '````python\n```python\n import this\n```\n````'; - - const sliceSnapshot: SliceSnapshot = { - type: 'slice', - content: [ - { - type: 'block', - id: 'matchesReplaceMap[0]', - flavour: 'affine:note', - props: { - xywh: '[0,0,800,95]', - background: DefaultTheme.noteBackgrounColor, - index: 'a0', - hidden: false, - displayMode: 'both', + children: [], }, - children: [ - { - type: 'block', - id: 'matchesReplaceMap[1]', - flavour: 'affine:code', - props: { - language: 'python', - wrap: false, - text: { - '$blocksuite:internal:text$': true, - delta: [ - { - insert: '```python\n import this\n```', - }, - ], - }, - }, - children: [], - }, - ], - }, - ], - workspaceId: '', - pageId: '', - }; + ], + }; - const mdAdapter = new MarkdownAdapter(createJob(), provider); - const rawSliceSnapshot = await mdAdapter.toSliceSnapshot({ - file: markdown, - workspaceId: '', - pageId: '', + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); }); - expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); - }); - test('code with indentation 3 - slice', async () => { - const markdown = '~~~~python\n````python\n import this\n````\n~~~~'; + test('code with indentation 1 - slice', async () => { + const markdown = '```python\n import this\n```'; - const sliceSnapshot: SliceSnapshot = { - type: 'slice', - content: [ - { - type: 'block', - id: 'matchesReplaceMap[0]', - flavour: 'affine:note', - props: { - xywh: '[0,0,800,95]', - background: DefaultTheme.noteBackgrounColor, - index: 'a0', - hidden: false, - displayMode: 'both', + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:code', + props: { + language: 'python', + wrap: false, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' import this', + }, + ], + }, + }, + children: [], + }, + ], }, - children: [ - { - type: 'block', - id: 'matchesReplaceMap[1]', - flavour: 'affine:code', - props: { - language: 'python', - wrap: false, - text: { - '$blocksuite:internal:text$': true, - delta: [ - { - insert: '````python\n import this\n````', - }, - ], - }, - }, - children: [], - }, - ], - }, - ], - workspaceId: '', - pageId: '', - }; + ], + workspaceId: '', + pageId: '', + }; - const mdAdapter = new MarkdownAdapter(createJob(), provider); - const rawSliceSnapshot = await mdAdapter.toSliceSnapshot({ - file: markdown, - workspaceId: '', - pageId: '', + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const rawSliceSnapshot = await mdAdapter.toSliceSnapshot({ + file: markdown, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); + }); + + test('code with indentation 2 - slice', async () => { + const markdown = '````python\n```python\n import this\n```\n````'; + + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:code', + props: { + language: 'python', + wrap: false, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '```python\n import this\n```', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + workspaceId: '', + pageId: '', + }; + + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const rawSliceSnapshot = await mdAdapter.toSliceSnapshot({ + file: markdown, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); + }); + + test('code with indentation 3 - slice', async () => { + const markdown = '~~~~python\n````python\n import this\n````\n~~~~'; + + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:code', + props: { + language: 'python', + wrap: false, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '````python\n import this\n````', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + workspaceId: '', + pageId: '', + }; + + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const rawSliceSnapshot = await mdAdapter.toSliceSnapshot({ + file: markdown, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); + }); + + test('html block import as code block', async () => { + const markdown = `
+
+

Welcome to My Page

+ +
+
+

This is a sample HTML content

+
+
`; + + const blockSnapshot: 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:code', + props: { + language: 'html', + wrap: false, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: + '
\n
\n

Welcome to My Page

\n \n
\n
\n

This is a sample HTML content

\n
\n
', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); }); - expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); }); test('paragraph', async () => { @@ -3638,48 +3697,6 @@ bbb expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); }); - test('html tag', async () => { - const markdown = `\n`; - - const blockSnapshot: 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:paragraph', - props: { - text: { - '$blocksuite:internal:text$': true, - delta: [ - { - insert: '', - }, - ], - }, - type: 'text', - }, - children: [], - }, - ], - }; - - const mdAdapter = new MarkdownAdapter(createJob(), provider); - const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ - file: markdown, - }); - expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); - }); - describe('inline latex', () => { test.each([ ['dollar sign syntax', 'inline $E=mc^2$ latex\n'], diff --git a/blocksuite/affine/blocks/code/src/adapters/markdown/markdown.ts b/blocksuite/affine/blocks/code/src/adapters/markdown/markdown.ts index b9b470d04..d24e58b92 100644 --- a/blocksuite/affine/blocks/code/src/adapters/markdown/markdown.ts +++ b/blocksuite/affine/blocks/code/src/adapters/markdown/markdown.ts @@ -3,25 +3,51 @@ import { BlockMarkdownAdapterExtension, type BlockMarkdownAdapterMatcher, CODE_BLOCK_WRAP_KEY, + IN_PARAGRAPH_NODE_CONTEXT_KEY, type MarkdownAST, } from '@blocksuite/affine-shared/adapters'; import type { DeltaInsert } from '@blocksuite/store'; import { nanoid } from '@blocksuite/store'; -import type { Code } from 'mdast'; +import type { Code, Html } from 'mdast'; const isCodeNode = (node: MarkdownAST): node is Code => node.type === 'code'; +const isHtmlNode = (node: MarkdownAST): node is Html => node.type === 'html'; + +const isCodeOrHtmlNode = (node: MarkdownAST): node is Code | Html => + isCodeNode(node) || isHtmlNode(node); export const codeBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { flavour: CodeBlockSchema.model.flavour, - toMatch: o => isCodeNode(o.node), + toMatch: o => isCodeOrHtmlNode(o.node), fromMatch: o => o.node.flavour === 'affine:code', toBlockSnapshot: { enter: (o, context) => { - if (!isCodeNode(o.node)) { + if (!isCodeOrHtmlNode(o.node)) { return; } + const { walkerContext, configs } = context; const wrap = configs.get(CODE_BLOCK_WRAP_KEY) === 'true'; + let language = 'plain text'; + switch (o.node.type) { + case 'code': { + if (o.node.lang) { + language = o.node.lang; + } + break; + } + case 'html': { + const inParagraphNode = !!walkerContext.getGlobalContext( + IN_PARAGRAPH_NODE_CONTEXT_KEY + ); + // only handle top level html node + if (inParagraphNode) { + return; + } + language = 'html'; + break; + } + } walkerContext .openNode( { @@ -29,7 +55,7 @@ export const codeBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { id: nanoid(), flavour: 'affine:code', props: { - language: o.node.lang ?? 'Plain Text', + language, wrap, text: { '$blocksuite:internal:text$': true, diff --git a/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts b/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts index c7faae1a5..8179cba17 100644 --- a/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts +++ b/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts @@ -2,18 +2,14 @@ import { ParagraphBlockSchema } from '@blocksuite/affine-model'; import { BlockMarkdownAdapterExtension, type BlockMarkdownAdapterMatcher, + IN_PARAGRAPH_NODE_CONTEXT_KEY, type MarkdownAST, } from '@blocksuite/affine-shared/adapters'; import type { DeltaInsert } from '@blocksuite/store'; import { nanoid } from '@blocksuite/store'; import type { Heading } from 'mdast'; -const PARAGRAPH_MDAST_TYPE = new Set([ - 'paragraph', - 'html', - 'heading', - 'blockquote', -]); +const PARAGRAPH_MDAST_TYPE = new Set(['paragraph', 'heading', 'blockquote']); const isParagraphMDASTType = (node: MarkdownAST) => PARAGRAPH_MDAST_TYPE.has(node.type); @@ -27,32 +23,8 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = enter: (o, context) => { const { walkerContext, deltaConverter } = context; switch (o.node.type) { - case 'html': { - walkerContext - .openNode( - { - type: 'block', - id: nanoid(), - flavour: 'affine:paragraph', - props: { - type: 'text', - text: { - '$blocksuite:internal:text$': true, - delta: [ - { - insert: o.node.value, - }, - ], - }, - }, - children: [], - }, - 'children' - ) - .closeNode(); - break; - } case 'paragraph': { + walkerContext.setGlobalContext(IN_PARAGRAPH_NODE_CONTEXT_KEY, true); walkerContext .openNode( { @@ -71,7 +43,6 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = 'children' ) .closeNode(); - walkerContext.skipAllChildren(); break; } case 'heading': { @@ -119,6 +90,12 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = } } }, + leave: (o, context) => { + if (o.node.type === 'paragraph') { + const { walkerContext } = context; + walkerContext.setGlobalContext(IN_PARAGRAPH_NODE_CONTEXT_KEY, false); + } + }, }, fromBlockSnapshot: { enter: (o, context) => { diff --git a/blocksuite/affine/shared/src/adapters/index.ts b/blocksuite/affine/shared/src/adapters/index.ts index 016ee0c3c..90f90b077 100644 --- a/blocksuite/affine/shared/src/adapters/index.ts +++ b/blocksuite/affine/shared/src/adapters/index.ts @@ -22,6 +22,7 @@ export { type BlockMarkdownAdapterMatcher, BlockMarkdownAdapterMatcherIdentifier, FOOTNOTE_DEFINITION_PREFIX, + IN_PARAGRAPH_NODE_CONTEXT_KEY, InlineDeltaToMarkdownAdapterExtension, type InlineDeltaToMarkdownAdapterMatcher, InlineDeltaToMarkdownAdapterMatcherIdentifier, diff --git a/blocksuite/affine/shared/src/adapters/markdown/type.ts b/blocksuite/affine/shared/src/adapters/markdown/type.ts index f41a51742..70a006ab0 100644 --- a/blocksuite/affine/shared/src/adapters/markdown/type.ts +++ b/blocksuite/affine/shared/src/adapters/markdown/type.ts @@ -17,3 +17,4 @@ export const isMarkdownAST = (node: unknown): node is MarkdownAST => (node as MarkdownAST).type !== undefined; export const FOOTNOTE_DEFINITION_PREFIX = 'footnoteDefinition:'; +export const IN_PARAGRAPH_NODE_CONTEXT_KEY = 'mdast:paragraph';