diff --git a/blocksuite/affine/block-note/package.json b/blocksuite/affine/block-note/package.json index ea75af589..fd0b90bdb 100644 --- a/blocksuite/affine/block-note/package.json +++ b/blocksuite/affine/block-note/package.json @@ -27,6 +27,7 @@ "@lit/context": "^1.1.2", "@preact/signals-core": "^1.8.0", "@toeverything/theme": "^1.1.7", + "@types/mdast": "^4.0.4", "lit": "^3.2.0", "minimatch": "^10.0.1", "zod": "^3.23.8" diff --git a/blocksuite/affine/block-note/src/adapters/index.ts b/blocksuite/affine/block-note/src/adapters/index.ts index 11801748b..3fe945702 100644 --- a/blocksuite/affine/block-note/src/adapters/index.ts +++ b/blocksuite/affine/block-note/src/adapters/index.ts @@ -3,15 +3,19 @@ import type { ExtensionType } from '@blocksuite/store'; import { DocNoteBlockHtmlAdapterExtension, EdgelessNoteBlockHtmlAdapterExtension, -} from './html.js'; +} from './html'; import { DocNoteBlockMarkdownAdapterExtension, EdgelessNoteBlockMarkdownAdapterExtension, -} from './markdown.js'; +} from './markdown'; import { DocNoteBlockPlainTextAdapterExtension, EdgelessNoteBlockPlainTextAdapterExtension, -} from './plain-text.js'; +} from './plain-text'; + +export * from './html'; +export * from './markdown'; +export * from './plain-text'; export const DocNoteBlockAdapterExtensions: ExtensionType[] = [ DocNoteBlockMarkdownAdapterExtension, diff --git a/blocksuite/affine/block-note/src/adapters/markdown.ts b/blocksuite/affine/block-note/src/adapters/markdown.ts index f0d0bdd6c..879c645d0 100644 --- a/blocksuite/affine/block-note/src/adapters/markdown.ts +++ b/blocksuite/affine/block-note/src/adapters/markdown.ts @@ -2,7 +2,35 @@ import { NoteBlockSchema, NoteDisplayMode } from '@blocksuite/affine-model'; import { BlockMarkdownAdapterExtension, type BlockMarkdownAdapterMatcher, + FOOTNOTE_DEFINITION_PREFIX, + type MarkdownAST, } from '@blocksuite/affine-shared/adapters'; +import type { FootnoteDefinition, Root } from 'mdast'; + +const isRootNode = (node: MarkdownAST): node is Root => node.type === 'root'; +const isFootnoteDefinitionNode = ( + node: MarkdownAST +): node is FootnoteDefinition => node.type === 'footnoteDefinition'; + +const createFootnoteDefinition = ( + identifier: string, + content: string +): MarkdownAST => ({ + type: 'footnoteDefinition', + label: identifier, + identifier, + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: content, + }, + ], + }, + ], +}); /** * Create a markdown adapter matcher for note block. @@ -15,9 +43,36 @@ const createNoteBlockMarkdownAdapterMatcher = ( displayModeToSkip: NoteDisplayMode ): BlockMarkdownAdapterMatcher => ({ flavour: NoteBlockSchema.model.flavour, - toMatch: () => false, + toMatch: o => isRootNode(o.node), fromMatch: o => o.node.flavour === NoteBlockSchema.model.flavour, - toBlockSnapshot: {}, + toBlockSnapshot: { + enter: (o, context) => { + if (!isRootNode(o.node)) { + return; + } + const noteAst = o.node; + // Find all the footnoteDefinition in the noteAst + const { configs } = context; + noteAst.children.forEach(child => { + if (isFootnoteDefinitionNode(child)) { + const identifier = child.identifier; + const definitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${identifier}`; + // Get the text content of the footnoteDefinition + const textContent = child.children + .find(child => child.type === 'paragraph') + ?.children.find(child => child.type === 'text')?.value; + if (textContent) { + configs.set(definitionKey, textContent); + } + } + }); + + // Remove the footnoteDefinition node from the noteAst + noteAst.children = noteAst.children.filter( + child => !isFootnoteDefinitionNode(child) + ); + }, + }, fromBlockSnapshot: { enter: (o, context) => { const node = o.node; @@ -25,6 +80,33 @@ const createNoteBlockMarkdownAdapterMatcher = ( context.walkerContext.skipAllChildren(); } }, + leave: (_, context) => { + const { walkerContext, configs } = context; + // Get all the footnote definitions config starts with FOOTNOTE_DEFINITION_PREFIX + // And create footnoteDefinition AST node for each of them + Array.from(configs.keys()) + .filter(key => key.startsWith(FOOTNOTE_DEFINITION_PREFIX)) + .forEach(key => { + const hasFootnoteDefinition = !!walkerContext.getGlobalContext(key); + // If the footnoteDefinition node is already in md ast, skip it + // In markdown file, we only need to create footnoteDefinition once + if (hasFootnoteDefinition) { + return; + } + const definition = configs.get(key); + const identifier = key.slice(FOOTNOTE_DEFINITION_PREFIX.length); + if (definition && identifier) { + walkerContext + .openNode( + createFootnoteDefinition(identifier, definition), + 'children' + ) + .closeNode(); + // Set the footnoteDefinition node as global context to avoid duplicate creation + walkerContext.setGlobalContext(key, true); + } + }); + }, }, }); diff --git a/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/index.ts b/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/index.ts new file mode 100644 index 000000000..e435ccda1 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/index.ts @@ -0,0 +1,2 @@ +export * from './inline-delta'; +export * from './markdown-inline'; diff --git a/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/inline-delta.ts b/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/inline-delta.ts index 5fbc4bebb..10729b320 100644 --- a/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/inline-delta.ts +++ b/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/inline-delta.ts @@ -1,4 +1,5 @@ import { + FOOTNOTE_DEFINITION_PREFIX, InlineDeltaToMarkdownAdapterExtension, TextUtils, } from '@blocksuite/affine-shared/adapters'; @@ -146,6 +147,46 @@ export const latexDeltaToMarkdownAdapterMatcher = }, }); +export const footnoteReferenceDeltaToMarkdownAdapterMatcher = + InlineDeltaToMarkdownAdapterExtension({ + name: 'footnote-reference', + match: delta => !!delta.attributes?.footnote, + toAST: (delta, context) => { + const mdast: PhrasingContent = { + type: 'text', + value: delta.insert, + }; + const footnote = delta.attributes?.footnote; + if (!footnote) { + return mdast; + } + const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${footnote.label}`; + const { configs } = context; + // FootnoteReference should be paired with FootnoteDefinition + // If the footnoteDefinition is not in the configs, set it to configs + // We should add the footnoteDefinition markdown ast nodes to tree after all the footnoteReference markdown ast nodes are added + if (!configs.has(footnoteDefinitionKey)) { + // clone the footnote reference + const clonedFootnoteReference = { ...footnote.reference }; + // If the footnote reference contains url, encode it + if (clonedFootnoteReference.url) { + clonedFootnoteReference.url = encodeURIComponent( + clonedFootnoteReference.url + ); + } + configs.set( + footnoteDefinitionKey, + JSON.stringify(clonedFootnoteReference) + ); + } + return { + type: 'footnoteReference', + label: footnote.label, + identifier: footnote.label, + }; + }, + }); + export const InlineDeltaToMarkdownAdapterExtensions = [ referenceDeltaToMarkdownAdapterMatcher, linkDeltaToMarkdownAdapterMatcher, @@ -154,4 +195,5 @@ export const InlineDeltaToMarkdownAdapterExtensions = [ italicDeltaToMarkdownAdapterMatcher, strikeDeltaToMarkdownAdapterMatcher, latexDeltaToMarkdownAdapterMatcher, + footnoteReferenceDeltaToMarkdownAdapterMatcher, ]; diff --git a/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/markdown-inline.ts b/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/markdown-inline.ts index 701d5298c..c82ca5f25 100644 --- a/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/markdown-inline.ts +++ b/blocksuite/affine/components/src/rich-text/inline/adapters/markdown/markdown-inline.ts @@ -1,4 +1,8 @@ -import { MarkdownASTToDeltaExtension } from '@blocksuite/affine-shared/adapters'; +import { FootNoteReferenceParamsSchema } from '@blocksuite/affine-model'; +import { + FOOTNOTE_DEFINITION_PREFIX, + MarkdownASTToDeltaExtension, +} from '@blocksuite/affine-shared/adapters'; export const markdownTextToDeltaMatcher = MarkdownASTToDeltaExtension({ name: 'text', @@ -138,6 +142,43 @@ export const markdownInlineMathToDeltaMatcher = MarkdownASTToDeltaExtension({ }, }); +export const markdownFootnoteReferenceToDeltaMatcher = + MarkdownASTToDeltaExtension({ + name: 'footnote-reference', + match: ast => ast.type === 'footnoteReference', + toDelta: (ast, context) => { + if (ast.type !== 'footnoteReference') { + return []; + } + try { + const { configs } = context; + const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${ast.identifier}`; + const footnoteDefinition = configs.get(footnoteDefinitionKey); + if (!footnoteDefinition) { + return []; + } + const footnoteDefinitionJson = JSON.parse(footnoteDefinition); + // If the footnote definition contains url, decode it + if (footnoteDefinitionJson.url) { + footnoteDefinitionJson.url = decodeURIComponent( + footnoteDefinitionJson.url + ); + } + const footnoteReference = FootNoteReferenceParamsSchema.parse( + footnoteDefinitionJson + ); + const footnote = { + label: ast.identifier, + reference: footnoteReference, + }; + return [{ insert: ' ', attributes: { footnote } }]; + } catch (error) { + console.error('Error parsing footnote reference', error); + return []; + } + }, + }); + export const MarkdownInlineToDeltaAdapterExtensions = [ markdownTextToDeltaMatcher, markdownInlineCodeToDeltaMatcher, @@ -147,4 +188,5 @@ export const MarkdownInlineToDeltaAdapterExtensions = [ markdownLinkToDeltaMatcher, markdownInlineMathToDeltaMatcher, markdownListToDeltaMatcher, + markdownFootnoteReferenceToDeltaMatcher, ]; diff --git a/blocksuite/affine/components/src/rich-text/inline/index.ts b/blocksuite/affine/components/src/rich-text/inline/index.ts index e1bf94bad..7f2178a2f 100644 --- a/blocksuite/affine/components/src/rich-text/inline/index.ts +++ b/blocksuite/affine/components/src/rich-text/inline/index.ts @@ -1,8 +1,7 @@ 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/markdown'; export * from './adapters/notion-html/html-inline'; export * from './adapters/plain-text/inline-delta'; export * from './presets/affine-inline-specs'; diff --git a/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json index 9c6d3c32e..3b698b862 100644 --- a/blocksuite/affine/shared/package.json +++ b/blocksuite/affine/shared/package.json @@ -29,10 +29,12 @@ "lodash.clonedeep": "^4.5.0", "lodash.mergewith": "^4.6.2", "mdast-util-gfm-autolink-literal": "^2.0.1", + "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "micromark-extension-gfm-autolink-literal": "^2.1.0", + "micromark-extension-gfm-footnote": "^2.1.0", "micromark-extension-gfm-strikethrough": "^2.1.0", "micromark-extension-gfm-table": "^2.1.0", "micromark-extension-gfm-task-list-item": "^2.1.0", diff --git a/blocksuite/affine/shared/src/adapters/index.ts b/blocksuite/affine/shared/src/adapters/index.ts index 8f60f4312..0a7e8ac74 100644 --- a/blocksuite/affine/shared/src/adapters/index.ts +++ b/blocksuite/affine/shared/src/adapters/index.ts @@ -20,6 +20,7 @@ export { BlockMarkdownAdapterExtension, type BlockMarkdownAdapterMatcher, BlockMarkdownAdapterMatcherIdentifier, + FOOTNOTE_DEFINITION_PREFIX, InlineDeltaToMarkdownAdapterExtension, type InlineDeltaToMarkdownAdapterMatcher, InlineDeltaToMarkdownAdapterMatcherIdentifier, diff --git a/blocksuite/affine/shared/src/adapters/markdown/gfm.ts b/blocksuite/affine/shared/src/adapters/markdown/gfm.ts index 833384120..f8148585b 100644 --- a/blocksuite/affine/shared/src/adapters/markdown/gfm.ts +++ b/blocksuite/affine/shared/src/adapters/markdown/gfm.ts @@ -4,9 +4,12 @@ MIT License Copyright (c) 2020 Titus Wormer mdast-util-gfm-autolink-literal is from markdown only. -mdast-util-gfm-footnote is not included. */ import { gfmAutolinkLiteralFromMarkdown } from 'mdast-util-gfm-autolink-literal'; +import { + gfmFootnoteFromMarkdown, + gfmFootnoteToMarkdown, +} from 'mdast-util-gfm-footnote'; import { gfmStrikethroughFromMarkdown, gfmStrikethroughToMarkdown, @@ -17,6 +20,7 @@ import { gfmTaskListItemToMarkdown, } from 'mdast-util-gfm-task-list-item'; import { gfmAutolinkLiteral } from 'micromark-extension-gfm-autolink-literal'; +import { gfmFootnote } from 'micromark-extension-gfm-footnote'; import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough'; import { gfmTable } from 'micromark-extension-gfm-table'; import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item'; @@ -29,6 +33,7 @@ export function gfm() { gfmStrikethrough(), gfmTable(), gfmTaskListItem(), + gfmFootnote(), ]); } @@ -38,6 +43,7 @@ function gfmFromMarkdown() { gfmTableFromMarkdown(), gfmTaskListItemFromMarkdown(), gfmAutolinkLiteralFromMarkdown(), + gfmFootnoteFromMarkdown(), ]; } @@ -47,6 +53,7 @@ function gfmToMarkdown() { gfmStrikethroughToMarkdown(), gfmTableToMarkdown(), gfmTaskListItemToMarkdown(), + gfmFootnoteToMarkdown(), ], }; } diff --git a/blocksuite/affine/shared/src/adapters/markdown/type.ts b/blocksuite/affine/shared/src/adapters/markdown/type.ts index f3200215a..f41a51742 100644 --- a/blocksuite/affine/shared/src/adapters/markdown/type.ts +++ b/blocksuite/affine/shared/src/adapters/markdown/type.ts @@ -15,3 +15,5 @@ export const isMarkdownAST = (node: unknown): node is MarkdownAST => !Array.isArray(node) && 'type' in (node as object) && (node as MarkdownAST).type !== undefined; + +export const FOOTNOTE_DEFINITION_PREFIX = 'footnoteDefinition:'; diff --git a/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts b/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts index c0b7819d9..ae29fe95f 100644 --- a/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts +++ b/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts @@ -2350,6 +2350,109 @@ World! }); expect(target.file).toBe(docMd); }); + + test('footnote', 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:zxDyvrg1Mh', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: ' ', + attributes: { + footnote: { + label: '1', + reference: { + type: 'url', + url: 'https://www.example.com', + }, + }, + }, + }, + { + insert: ' ', + attributes: { + footnote: { + label: '2', + reference: { + type: 'doc', + docId: 'deadbeef', + }, + }, + }, + }, + { + insert: ' ', + attributes: { + footnote: { + label: '3', + reference: { + type: 'attachment', + blobId: 'abcdefg', + fileName: 'test.txt', + fileType: 'text/plain', + }, + }, + }, + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const markdown = + 'aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"https%3A%2F%2Fwww.example.com"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\n'; + + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); }); describe('markdown to snapshot', () => { @@ -3858,4 +3961,85 @@ hhh }); expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); }); + + test('without footnote middleware', async () => { + const markdown = + 'aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"https%3A%2F%2Fwww.example.com"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\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: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: ' ', + attributes: { + footnote: { + label: '1', + reference: { + type: 'url', + url: 'https://www.example.com', + }, + }, + }, + }, + { + insert: ' ', + attributes: { + footnote: { + label: '2', + reference: { + type: 'doc', + docId: 'deadbeef', + }, + }, + }, + }, + { + insert: ' ', + attributes: { + footnote: { + label: '3', + reference: { + type: 'attachment', + blobId: 'abcdefg', + fileName: 'test.txt', + fileType: 'text/plain', + }, + }, + }, + }, + ], + }, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); }); diff --git a/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts b/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts index f398be7e1..be8b9b84b 100644 --- a/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts +++ b/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts @@ -13,11 +13,14 @@ import { import { ImageBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-image'; import { LatexBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-latex'; import { ListBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-list'; +import { DocNoteBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-note'; import { ParagraphBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-paragraph'; import { RootBlockMarkdownAdapterExtension } from '../../../root-block/adapters/markdown.js'; export const defaultBlockMarkdownAdapterMatchers = [ + RootBlockMarkdownAdapterExtension, + DocNoteBlockMarkdownAdapterExtension, EmbedFigmaMarkdownAdapterExtension, EmbedGithubMarkdownAdapterExtension, EmbedLinkedDocMarkdownAdapterExtension, @@ -32,5 +35,4 @@ export const defaultBlockMarkdownAdapterMatchers = [ DividerBlockMarkdownAdapterExtension, ImageBlockMarkdownAdapterExtension, LatexBlockMarkdownAdapterExtension, - RootBlockMarkdownAdapterExtension, ]; diff --git a/blocksuite/blocks/src/_common/test-utils/test-utils.ts b/blocksuite/blocks/src/_common/test-utils/test-utils.ts index 6646d6f53..23ae27d90 100644 --- a/blocksuite/blocks/src/_common/test-utils/test-utils.ts +++ b/blocksuite/blocks/src/_common/test-utils/test-utils.ts @@ -26,6 +26,7 @@ const escapedSnapshotAttributes = new Set([ '"background"', '"LinkedPage"', '"elementIds"', + '"attachment"', ]); function nanoidReplacementString(snapshotString: string) { diff --git a/yarn.lock b/yarn.lock index 7ec992490..6c854ca78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3569,6 +3569,7 @@ __metadata: "@lit/context": "npm:^1.1.2" "@preact/signals-core": "npm:^1.8.0" "@toeverything/theme": "npm:^1.1.7" + "@types/mdast": "npm:^4.0.4" lit: "npm:^3.2.0" minimatch: "npm:^10.0.1" zod: "npm:^3.23.8" @@ -3716,10 +3717,12 @@ __metadata: lodash.clonedeep: "npm:^4.5.0" lodash.mergewith: "npm:^4.6.2" mdast-util-gfm-autolink-literal: "npm:^2.0.1" + mdast-util-gfm-footnote: "npm:^2.0.0" mdast-util-gfm-strikethrough: "npm:^2.0.0" mdast-util-gfm-table: "npm:^2.0.0" mdast-util-gfm-task-list-item: "npm:^2.0.0" micromark-extension-gfm-autolink-literal: "npm:^2.1.0" + micromark-extension-gfm-footnote: "npm:^2.1.0" micromark-extension-gfm-strikethrough: "npm:^2.1.0" micromark-extension-gfm-table: "npm:^2.1.0" micromark-extension-gfm-task-list-item: "npm:^2.1.0" @@ -26903,7 +26906,7 @@ __metadata: languageName: node linkType: hard -"micromark-extension-gfm-footnote@npm:^2.0.0": +"micromark-extension-gfm-footnote@npm:^2.0.0, micromark-extension-gfm-footnote@npm:^2.1.0": version: 2.1.0 resolution: "micromark-extension-gfm-footnote@npm:2.1.0" dependencies: