feat(editor): support footnote adapter (#9844)

[BS-2373](https://linear.app/affine-design/issue/BS-2373/适配-footnote-adapter)
This commit is contained in:
donteatfriedrice
2025-01-22 06:42:35 +00:00
parent a5025cf470
commit bf797c7a0c
15 changed files with 385 additions and 11 deletions

View File

@@ -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"

View File

@@ -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,

View File

@@ -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);
}
});
},
},
});

View File

@@ -0,0 +1,2 @@
export * from './inline-delta';
export * from './markdown-inline';

View File

@@ -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,
];

View File

@@ -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,
];

View File

@@ -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';

View File

@@ -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",

View File

@@ -20,6 +20,7 @@ export {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
BlockMarkdownAdapterMatcherIdentifier,
FOOTNOTE_DEFINITION_PREFIX,
InlineDeltaToMarkdownAdapterExtension,
type InlineDeltaToMarkdownAdapterMatcher,
InlineDeltaToMarkdownAdapterMatcherIdentifier,

View File

@@ -4,9 +4,12 @@ MIT License
Copyright (c) 2020 Titus Wormer <tituswormer@gmail.com>
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(),
],
};
}

View File

@@ -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:';

View File

@@ -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);
});
});

View File

@@ -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,
];

View File

@@ -26,6 +26,7 @@ const escapedSnapshotAttributes = new Set([
'"background"',
'"LinkedPage"',
'"elementIds"',
'"attachment"',
]);
function nanoidReplacementString(snapshotString: string) {