feat(editor): std inline extensions (#11038)

This commit is contained in:
Saul-Mirone
2025-03-20 10:40:11 +00:00
parent b24376a9f7
commit 77e659d0b0
16 changed files with 552 additions and 501 deletions

View File

@@ -3,39 +3,44 @@ import {
BoldInlineSpecExtension, BoldInlineSpecExtension,
CodeInlineSpecExtension, CodeInlineSpecExtension,
ColorInlineSpecExtension, ColorInlineSpecExtension,
InlineManagerExtension,
InlineSpecExtension,
ItalicInlineSpecExtension, ItalicInlineSpecExtension,
LatexInlineSpecExtension, LatexInlineSpecExtension,
LinkInlineSpecExtension, LinkInlineSpecExtension,
StrikeInlineSpecExtension, StrikeInlineSpecExtension,
UnderlineInlineSpecExtension, UnderlineInlineSpecExtension,
} from '@blocksuite/affine-rich-text'; } from '@blocksuite/affine-rich-text';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
InlineManagerExtension,
InlineSpecExtension,
} from '@blocksuite/block-std/inline';
import { html } from 'lit'; import { html } from 'lit';
import { z } from 'zod'; import { z } from 'zod';
export const CodeBlockUnitSpecExtension = InlineSpecExtension({ export const CodeBlockUnitSpecExtension =
name: 'code-block-unit', InlineSpecExtension<AffineTextAttributes>({
schema: z.undefined(), name: 'code-block-unit',
match: () => true, schema: z.undefined(),
renderer: ({ delta }) => { match: () => true,
return html`<affine-code-unit .delta=${delta}></affine-code-unit>`; renderer: ({ delta }) => {
}, return html`<affine-code-unit .delta=${delta}></affine-code-unit>`;
}); },
});
export const CodeBlockInlineManagerExtension = InlineManagerExtension({ export const CodeBlockInlineManagerExtension =
id: 'CodeBlockInlineManager', InlineManagerExtension<AffineTextAttributes>({
enableMarkdown: false, id: 'CodeBlockInlineManager',
specs: [ enableMarkdown: false,
BoldInlineSpecExtension.identifier, specs: [
ItalicInlineSpecExtension.identifier, BoldInlineSpecExtension.identifier,
UnderlineInlineSpecExtension.identifier, ItalicInlineSpecExtension.identifier,
StrikeInlineSpecExtension.identifier, UnderlineInlineSpecExtension.identifier,
CodeInlineSpecExtension.identifier, StrikeInlineSpecExtension.identifier,
BackgroundInlineSpecExtension.identifier, CodeInlineSpecExtension.identifier,
ColorInlineSpecExtension.identifier, BackgroundInlineSpecExtension.identifier,
LatexInlineSpecExtension.identifier, ColorInlineSpecExtension.identifier,
LinkInlineSpecExtension.identifier, LatexInlineSpecExtension.identifier,
CodeBlockUnitSpecExtension.identifier, LinkInlineSpecExtension.identifier,
], CodeBlockUnitSpecExtension.identifier,
}); ],
});

View File

@@ -1,6 +1,7 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { InlineManagerExtension } from '@blocksuite/block-std/inline';
import type { ExtensionType } from '@blocksuite/store'; import type { ExtensionType } from '@blocksuite/store';
import { InlineManagerExtension } from './extension/index.js';
import { import {
BackgroundInlineSpecExtension, BackgroundInlineSpecExtension,
BoldInlineSpecExtension, BoldInlineSpecExtension,
@@ -19,22 +20,23 @@ import {
} from './inline/index.js'; } from './inline/index.js';
import { LatexEditorInlineManagerExtension } from './inline/presets/nodes/latex-node/latex-editor-menu.js'; import { LatexEditorInlineManagerExtension } from './inline/presets/nodes/latex-node/latex-editor-menu.js';
export const DefaultInlineManagerExtension = InlineManagerExtension({ export const DefaultInlineManagerExtension =
id: 'DefaultInlineManager', InlineManagerExtension<AffineTextAttributes>({
specs: [ id: 'DefaultInlineManager',
BoldInlineSpecExtension.identifier, specs: [
ItalicInlineSpecExtension.identifier, BoldInlineSpecExtension.identifier,
UnderlineInlineSpecExtension.identifier, ItalicInlineSpecExtension.identifier,
StrikeInlineSpecExtension.identifier, UnderlineInlineSpecExtension.identifier,
CodeInlineSpecExtension.identifier, StrikeInlineSpecExtension.identifier,
BackgroundInlineSpecExtension.identifier, CodeInlineSpecExtension.identifier,
ColorInlineSpecExtension.identifier, BackgroundInlineSpecExtension.identifier,
LatexInlineSpecExtension.identifier, ColorInlineSpecExtension.identifier,
ReferenceInlineSpecExtension.identifier, LatexInlineSpecExtension.identifier,
LinkInlineSpecExtension.identifier, ReferenceInlineSpecExtension.identifier,
FootNoteInlineSpecExtension.identifier, LinkInlineSpecExtension.identifier,
], FootNoteInlineSpecExtension.identifier,
}); ],
});
export const RichTextExtensions: ExtensionType[] = [ export const RichTextExtensions: ExtensionType[] = [
InlineSpecExtensions, InlineSpecExtensions,

View File

@@ -1,5 +1 @@
export * from './inline-manager.js';
export * from './inline-spec.js';
export * from './markdown-matcher.js';
export * from './ref-node-slots.js'; export * from './ref-node-slots.js';
export * from './type.js';

View File

@@ -1,106 +0,0 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/block-std';
import {
type AttributeRenderer,
getDefaultAttributeRenderer,
} from '@blocksuite/block-std/inline';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import {
baseTextAttributes,
type DeltaInsert,
type ExtensionType,
} from '@blocksuite/store';
import { z, type ZodObject, type ZodTypeAny } from 'zod';
import { MarkdownMatcherIdentifier } from './markdown-matcher.js';
import type { InlineMarkdownMatch, InlineSpecs } from './type.js';
export class InlineManager {
embedChecker = (delta: DeltaInsert<AffineTextAttributes>) => {
for (const spec of this.specs) {
if (spec.embed && spec.match(delta)) {
return true;
}
}
return false;
};
getRenderer = (): AttributeRenderer<AffineTextAttributes> => {
const defaultRenderer = getDefaultAttributeRenderer<AffineTextAttributes>();
const renderer: AttributeRenderer<AffineTextAttributes> = props => {
// Priority increases from front to back
for (const spec of this.specs.toReversed()) {
if (spec.match(props.delta)) {
return spec.renderer(props);
}
}
return defaultRenderer(props);
};
return renderer;
};
getSchema = (): ZodObject<Record<keyof AffineTextAttributes, ZodTypeAny>> => {
const defaultSchema = baseTextAttributes as unknown as ZodObject<
Record<keyof AffineTextAttributes, ZodTypeAny>
>;
const schema: ZodObject<Record<keyof AffineTextAttributes, ZodTypeAny>> =
this.specs.reduce((acc, cur) => {
const currentSchema = z.object({
[cur.name]: cur.schema,
}) as ZodObject<Record<keyof AffineTextAttributes, ZodTypeAny>>;
return acc.merge(currentSchema) as ZodObject<
Record<keyof AffineTextAttributes, ZodTypeAny>
>;
}, defaultSchema);
return schema;
};
readonly specs: Array<InlineSpecs<AffineTextAttributes>>;
constructor(
readonly std: BlockStdScope,
readonly markdownMatches: InlineMarkdownMatch<AffineTextAttributes>[],
...specs: Array<InlineSpecs<AffineTextAttributes>>
) {
this.specs = specs;
}
}
export const InlineManagerIdentifier = createIdentifier<InlineManager>(
'AffineInlineManager'
);
export type InlineManagerExtensionConfig = {
id: string;
enableMarkdown?: boolean;
specs: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>[];
};
export function InlineManagerExtension({
id,
enableMarkdown = true,
specs,
}: InlineManagerExtensionConfig): ExtensionType & {
identifier: ServiceIdentifier<InlineManager>;
} {
const identifier = InlineManagerIdentifier(id);
return {
setup: di => {
di.addImpl(identifier, provider => {
return new InlineManager(
provider.get(StdIdentifier),
enableMarkdown
? Array.from(provider.getAll(MarkdownMatcherIdentifier).values())
: [],
...specs.map(spec => provider.get(spec))
);
});
},
identifier,
};
}

View File

@@ -1,47 +0,0 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
createIdentifier,
type ServiceIdentifier,
type ServiceProvider,
} from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { InlineSpecs } from './type.js';
export const InlineSpecIdentifier =
createIdentifier<InlineSpecs<AffineTextAttributes>>('AffineInlineSpec');
export function InlineSpecExtension(
name: string,
getSpec: (provider: ServiceProvider) => InlineSpecs<AffineTextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>;
};
export function InlineSpecExtension(
spec: InlineSpecs<AffineTextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>;
};
export function InlineSpecExtension(
nameOrSpec: string | InlineSpecs<AffineTextAttributes>,
getSpec?: (provider: ServiceProvider) => InlineSpecs<AffineTextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineSpecs<AffineTextAttributes>>;
} {
if (typeof nameOrSpec === 'string') {
const identifier = InlineSpecIdentifier(nameOrSpec);
return {
identifier,
setup: di => {
di.addImpl(identifier, provider => getSpec!(provider));
},
};
}
const identifier = InlineSpecIdentifier(nameOrSpec.name);
return {
identifier,
setup: di => {
di.addImpl(identifier, nameOrSpec);
},
};
}

View File

@@ -1,27 +0,0 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { InlineMarkdownMatch } from './type.js';
export const MarkdownMatcherIdentifier = createIdentifier<
InlineMarkdownMatch<AffineTextAttributes>
>('AffineMarkdownMatcher');
export function InlineMarkdownExtension(
matcher: InlineMarkdownMatch<AffineTextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineMarkdownMatch<AffineTextAttributes>>;
} {
const identifier = MarkdownMatcherIdentifier(matcher.name);
return {
setup: di => {
di.addImpl(identifier, () => ({ ...matcher }));
},
identifier,
};
}

View File

@@ -2,14 +2,14 @@ import { FootNoteSchema, ReferenceInfoSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { BlockFlavourIdentifier, StdIdentifier } from '@blocksuite/block-std'; import { BlockFlavourIdentifier, StdIdentifier } from '@blocksuite/block-std';
import type { import {
InlineEditor, type InlineEditor,
InlineRootElement, type InlineRootElement,
InlineSpecExtension,
} from '@blocksuite/block-std/inline'; } from '@blocksuite/block-std/inline';
import { html } from 'lit'; import { html } from 'lit';
import { z } from 'zod'; import { z } from 'zod';
import { InlineSpecExtension } from '../../extension/index.js';
import { FootNoteNodeConfigIdentifier } from './nodes/footnote-node/footnote-config.js'; import { FootNoteNodeConfigIdentifier } from './nodes/footnote-node/footnote-config.js';
import { builtinInlineLinkToolbarConfig } from './nodes/link-node/configs/toolbar.js'; import { builtinInlineLinkToolbarConfig } from './nodes/link-node/configs/toolbar.js';
import { builtinInlineReferenceToolbarConfig } from './nodes/reference-node/configs/toolbar.js'; import { builtinInlineReferenceToolbarConfig } from './nodes/reference-node/configs/toolbar.js';
@@ -21,86 +21,92 @@ import {
export type AffineInlineEditor = InlineEditor<AffineTextAttributes>; export type AffineInlineEditor = InlineEditor<AffineTextAttributes>;
export type AffineInlineRootElement = InlineRootElement<AffineTextAttributes>; export type AffineInlineRootElement = InlineRootElement<AffineTextAttributes>;
export const BoldInlineSpecExtension = InlineSpecExtension({ export const BoldInlineSpecExtension =
name: 'bold', InlineSpecExtension<AffineTextAttributes>({
schema: z.literal(true).optional().nullable().catch(undefined), name: 'bold',
match: delta => { schema: z.literal(true).optional().nullable().catch(undefined),
return !!delta.attributes?.bold; match: delta => {
}, return !!delta.attributes?.bold;
renderer: ({ delta }) => { },
return html`<affine-text .delta=${delta}></affine-text>`; renderer: ({ delta }) => {
}, return html`<affine-text .delta=${delta}></affine-text>`;
}); },
});
export const ItalicInlineSpecExtension = InlineSpecExtension({ export const ItalicInlineSpecExtension =
name: 'italic', InlineSpecExtension<AffineTextAttributes>({
schema: z.literal(true).optional().nullable().catch(undefined), name: 'italic',
match: delta => { schema: z.literal(true).optional().nullable().catch(undefined),
return !!delta.attributes?.italic; match: delta => {
}, return !!delta.attributes?.italic;
renderer: ({ delta }) => { },
return html`<affine-text .delta=${delta}></affine-text>`; renderer: ({ delta }) => {
}, return html`<affine-text .delta=${delta}></affine-text>`;
}); },
});
export const UnderlineInlineSpecExtension = InlineSpecExtension({ export const UnderlineInlineSpecExtension =
name: 'underline', InlineSpecExtension<AffineTextAttributes>({
schema: z.literal(true).optional().nullable().catch(undefined), name: 'underline',
match: delta => { schema: z.literal(true).optional().nullable().catch(undefined),
return !!delta.attributes?.underline; match: delta => {
}, return !!delta.attributes?.underline;
renderer: ({ delta }) => { },
return html`<affine-text .delta=${delta}></affine-text>`; renderer: ({ delta }) => {
}, return html`<affine-text .delta=${delta}></affine-text>`;
}); },
});
export const StrikeInlineSpecExtension = InlineSpecExtension({ export const StrikeInlineSpecExtension =
name: 'strike', InlineSpecExtension<AffineTextAttributes>({
schema: z.literal(true).optional().nullable().catch(undefined), name: 'strike',
match: delta => { schema: z.literal(true).optional().nullable().catch(undefined),
return !!delta.attributes?.strike; match: delta => {
}, return !!delta.attributes?.strike;
renderer: ({ delta }) => { },
return html`<affine-text .delta=${delta}></affine-text>`; renderer: ({ delta }) => {
}, return html`<affine-text .delta=${delta}></affine-text>`;
}); },
});
export const CodeInlineSpecExtension = InlineSpecExtension({ export const CodeInlineSpecExtension =
name: 'code', InlineSpecExtension<AffineTextAttributes>({
schema: z.literal(true).optional().nullable().catch(undefined), name: 'code',
match: delta => { schema: z.literal(true).optional().nullable().catch(undefined),
return !!delta.attributes?.code; match: delta => {
}, return !!delta.attributes?.code;
renderer: ({ delta }) => { },
return html`<affine-text .delta=${delta}></affine-text>`; renderer: ({ delta }) => {
}, return html`<affine-text .delta=${delta}></affine-text>`;
}); },
});
export const BackgroundInlineSpecExtension = InlineSpecExtension({ export const BackgroundInlineSpecExtension =
name: 'background', InlineSpecExtension<AffineTextAttributes>({
schema: z.string().optional().nullable().catch(undefined), name: 'background',
match: delta => { schema: z.string().optional().nullable().catch(undefined),
return !!delta.attributes?.background; match: delta => {
}, return !!delta.attributes?.background;
renderer: ({ delta }) => { },
return html`<affine-text .delta=${delta}></affine-text>`; renderer: ({ delta }) => {
}, return html`<affine-text .delta=${delta}></affine-text>`;
}); },
});
export const ColorInlineSpecExtension = InlineSpecExtension({ export const ColorInlineSpecExtension =
name: 'color', InlineSpecExtension<AffineTextAttributes>({
schema: z.string().optional().nullable().catch(undefined), name: 'color',
match: delta => { schema: z.string().optional().nullable().catch(undefined),
return !!delta.attributes?.color; match: delta => {
}, return !!delta.attributes?.color;
renderer: ({ delta }) => { },
return html`<affine-text .delta=${delta}></affine-text>`; renderer: ({ delta }) => {
}, return html`<affine-text .delta=${delta}></affine-text>`;
}); },
});
export const LatexInlineSpecExtension = InlineSpecExtension( export const LatexInlineSpecExtension =
'latex', InlineSpecExtension<AffineTextAttributes>('latex', provider => {
provider => {
const std = provider.get(StdIdentifier); const std = provider.get(StdIdentifier);
return { return {
name: 'latex', name: 'latex',
@@ -118,12 +124,10 @@ export const LatexInlineSpecExtension = InlineSpecExtension(
}, },
embed: true, embed: true,
}; };
} });
);
export const ReferenceInlineSpecExtension = InlineSpecExtension( export const ReferenceInlineSpecExtension =
'reference', InlineSpecExtension<AffineTextAttributes>('reference', provider => {
provider => {
const std = provider.get(StdIdentifier); const std = provider.get(StdIdentifier);
const configProvider = new ReferenceNodeConfigProvider(std); const configProvider = new ReferenceNodeConfigProvider(std);
const config = const config =
@@ -164,35 +168,35 @@ export const ReferenceInlineSpecExtension = InlineSpecExtension(
}, },
embed: true, embed: true,
}; };
} });
);
export const LinkInlineSpecExtension = InlineSpecExtension('link', provider => { export const LinkInlineSpecExtension =
const std = provider.get(StdIdentifier); InlineSpecExtension<AffineTextAttributes>('link', provider => {
return { const std = provider.get(StdIdentifier);
name: 'link', return {
schema: z.string().optional().nullable().catch(undefined), name: 'link',
match: delta => { schema: z.string().optional().nullable().catch(undefined),
return !!delta.attributes?.link; match: delta => {
}, return !!delta.attributes?.link;
},
renderer: ({ delta }) => {
return html`<affine-link .std=${std} .delta=${delta}></affine-link>`;
},
};
});
export const LatexEditorUnitSpecExtension =
InlineSpecExtension<AffineTextAttributes>({
name: 'latex-editor-unit',
schema: z.undefined(),
match: () => true,
renderer: ({ delta }) => { renderer: ({ delta }) => {
return html`<affine-link .std=${std} .delta=${delta}></affine-link>`; return html`<latex-editor-unit .delta=${delta}></latex-editor-unit>`;
}, },
}; });
});
export const LatexEditorUnitSpecExtension = InlineSpecExtension({ export const FootNoteInlineSpecExtension =
name: 'latex-editor-unit', InlineSpecExtension<AffineTextAttributes>('footnote', provider => {
schema: z.undefined(),
match: () => true,
renderer: ({ delta }) => {
return html`<latex-editor-unit .delta=${delta}></latex-editor-unit>`;
},
});
export const FootNoteInlineSpecExtension = InlineSpecExtension(
'footnote',
provider => {
const std = provider.get(StdIdentifier); const std = provider.get(StdIdentifier);
const config = const config =
provider.getOptional(FootNoteNodeConfigIdentifier) ?? undefined; provider.getOptional(FootNoteNodeConfigIdentifier) ?? undefined;
@@ -211,8 +215,7 @@ export const FootNoteInlineSpecExtension = InlineSpecExtension(
}, },
embed: true, embed: true,
}; };
} });
);
export const InlineSpecExtensions = [ export const InlineSpecExtensions = [
BoldInlineSpecExtension, BoldInlineSpecExtension,

View File

@@ -1,8 +1,8 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { BlockComponent } from '@blocksuite/block-std'; import type { BlockComponent } from '@blocksuite/block-std';
import { InlineMarkdownExtension } from '@blocksuite/block-std/inline';
import type { ExtensionType } from '@blocksuite/store'; import type { ExtensionType } from '@blocksuite/store';
import { InlineMarkdownExtension } from '../../extension/markdown-matcher.js';
// inline markdown match rules: // inline markdown match rules:
// covert: ***test*** + space // covert: ***test*** + space
// covert: ***t est*** + space // covert: ***t est*** + space
@@ -10,63 +10,71 @@ import { InlineMarkdownExtension } from '../../extension/markdown-matcher.js';
// not convert: ***test *** + space // not convert: ***test *** + space
// not convert: *** test *** + space // not convert: *** test *** + space
export const BoldItalicMarkdown = InlineMarkdownExtension({ export const BoldItalicMarkdown = InlineMarkdownExtension<AffineTextAttributes>(
name: 'bolditalic', {
pattern: /.*\*{3}([^\s*][^*]*[^\s*])\*{3}$|.*\*{3}([^\s*])\*{3}$/, name: 'bolditalic',
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { pattern: /.*\*{3}([^\s*][^*]*[^\s*])\*{3}$|.*\*{3}([^\s*])\*{3}$/,
const match = prefixText.match(pattern); action: ({
if (!match) return; inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager,
}) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2]; const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-targetText.length - 3 * 2); const annotatedText = match[0].slice(-targetText.length - 3 * 2);
const startIndex = inlineRange.index - annotatedText.length; const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText( inlineEditor.insertText(
{ {
index: startIndex + annotatedText.length, index: startIndex + annotatedText.length,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0, length: 0,
}, });
' '
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0,
});
undoManager.stopCapturing(); undoManager.stopCapturing();
inlineEditor.formatText( inlineEditor.formatText(
{ {
index: startIndex,
length: annotatedText.length,
},
{
bold: true,
italic: true,
}
);
inlineEditor.deleteText({
index: startIndex + annotatedText.length,
length: 1,
});
inlineEditor.deleteText({
index: startIndex + annotatedText.length - 3,
length: 3,
});
inlineEditor.deleteText({
index: startIndex, index: startIndex,
length: annotatedText.length, length: 3,
}, });
{
bold: true,
italic: true,
}
);
inlineEditor.deleteText({ inlineEditor.setInlineRange({
index: startIndex + annotatedText.length, index: startIndex + annotatedText.length - 6,
length: 1, length: 0,
}); });
inlineEditor.deleteText({ },
index: startIndex + annotatedText.length - 3, }
length: 3, );
});
inlineEditor.deleteText({
index: startIndex,
length: 3,
});
inlineEditor.setInlineRange({ export const BoldMarkdown = InlineMarkdownExtension<AffineTextAttributes>({
index: startIndex + annotatedText.length - 6,
length: 0,
});
},
});
export const BoldMarkdown = InlineMarkdownExtension({
name: 'bold', name: 'bold',
pattern: /.*\*{2}([^\s][^*]*[^\s*])\*{2}$|.*\*{2}([^\s*])\*{2}$/, pattern: /.*\*{2}([^\s][^*]*[^\s*])\*{2}$|.*\*{2}([^\s*])\*{2}$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
@@ -121,7 +129,7 @@ export const BoldMarkdown = InlineMarkdownExtension({
}, },
}); });
export const ItalicExtension = InlineMarkdownExtension({ export const ItalicExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'italic', name: 'italic',
pattern: /.*\*{1}([^\s][^*]*[^\s*])\*{1}$|.*\*{1}([^\s*])\*{1}$/, pattern: /.*\*{1}([^\s][^*]*[^\s*])\*{1}$|.*\*{1}([^\s*])\*{1}$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
@@ -176,117 +184,131 @@ export const ItalicExtension = InlineMarkdownExtension({
}, },
}); });
export const StrikethroughExtension = InlineMarkdownExtension({ export const StrikethroughExtension =
name: 'strikethrough', InlineMarkdownExtension<AffineTextAttributes>({
pattern: /.*~{2}([^\s][^~]*[^\s])~{2}$|.*~{2}([^\s~])~{2}$/, name: 'strikethrough',
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { pattern: /.*~{2}([^\s][^~]*[^\s])~{2}$|.*~{2}([^\s~])~{2}$/,
const match = prefixText.match(pattern); action: ({
if (!match) return; inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager,
}) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2]; const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-targetText.length - 2 * 2); const annotatedText = match[0].slice(-targetText.length - 2 * 2);
const startIndex = inlineRange.index - annotatedText.length; const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText( inlineEditor.insertText(
{ {
index: startIndex + annotatedText.length, index: startIndex + annotatedText.length,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0, length: 0,
}, });
' '
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0,
});
undoManager.stopCapturing(); undoManager.stopCapturing();
inlineEditor.formatText( inlineEditor.formatText(
{ {
index: startIndex, index: startIndex,
length: annotatedText.length, length: annotatedText.length,
}, },
{ {
strike: true, strike: true,
} }
); );
inlineEditor.deleteText({ inlineEditor.deleteText({
index: startIndex + annotatedText.length,
length: 1,
});
inlineEditor.deleteText({
index: startIndex + annotatedText.length - 2,
length: 2,
});
inlineEditor.deleteText({
index: startIndex,
length: 2,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 4,
length: 0,
});
},
});
export const UnderthroughExtension = InlineMarkdownExtension({
name: 'underthrough',
pattern: /.*~{1}([^\s][^~]*[^\s~])~{1}$|.*~{1}([^\s~])~{1}$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
const match = prefixText.match(pattern);
if (!match) return;
const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-targetText.length - 1 * 2);
const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText(
{
index: startIndex + annotatedText.length, index: startIndex + annotatedText.length,
length: 0, length: 1,
}, });
' ' inlineEditor.deleteText({
); index: startIndex + annotatedText.length - 2,
inlineEditor.setInlineRange({ length: 2,
index: startIndex + annotatedText.length + 1, });
length: 0, inlineEditor.deleteText({
});
undoManager.stopCapturing();
inlineEditor.formatText(
{
index: startIndex, index: startIndex,
length: annotatedText.length, length: 2,
}, });
{
underline: true,
}
);
inlineEditor.deleteText({ inlineEditor.setInlineRange({
index: startIndex + annotatedText.length, index: startIndex + annotatedText.length - 4,
length: 1, length: 0,
}); });
inlineEditor.deleteText({ },
index: inlineRange.index - 1, });
length: 1,
});
inlineEditor.deleteText({
index: startIndex,
length: 1,
});
inlineEditor.setInlineRange({ export const UnderthroughExtension =
index: startIndex + annotatedText.length - 2, InlineMarkdownExtension<AffineTextAttributes>({
length: 0, name: 'underthrough',
}); pattern: /.*~{1}([^\s][^~]*[^\s~])~{1}$|.*~{1}([^\s~])~{1}$/,
}, action: ({
}); inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager,
}) => {
const match = prefixText.match(pattern);
if (!match) return;
export const CodeExtension = InlineMarkdownExtension({ const targetText = match[1] ?? match[2];
const annotatedText = match[0].slice(-targetText.length - 1 * 2);
const startIndex = inlineRange.index - annotatedText.length;
inlineEditor.insertText(
{
index: startIndex + annotatedText.length,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length + 1,
length: 0,
});
undoManager.stopCapturing();
inlineEditor.formatText(
{
index: startIndex,
length: annotatedText.length,
},
{
underline: true,
}
);
inlineEditor.deleteText({
index: startIndex + annotatedText.length,
length: 1,
});
inlineEditor.deleteText({
index: inlineRange.index - 1,
length: 1,
});
inlineEditor.deleteText({
index: startIndex,
length: 1,
});
inlineEditor.setInlineRange({
index: startIndex + annotatedText.length - 2,
length: 0,
});
},
});
export const CodeExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'code', name: 'code',
pattern: /.*`([^\s][^`]*[^\s])`$|.*`([^\s`])`$/, pattern: /.*`([^\s][^`]*[^\s])`$|.*`([^\s`])`$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
@@ -341,7 +363,7 @@ export const CodeExtension = InlineMarkdownExtension({
}, },
}); });
export const LinkExtension = InlineMarkdownExtension({ export const LinkExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'link', name: 'link',
pattern: /.*\[(.+?)\]\((.+?)\)$/, pattern: /.*\[(.+?)\]\((.+?)\)$/,
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
@@ -401,7 +423,7 @@ export const LinkExtension = InlineMarkdownExtension({
}, },
}); });
export const LatexExtension = InlineMarkdownExtension({ export const LatexExtension = InlineMarkdownExtension<AffineTextAttributes>({
name: 'latex', name: 'latex',
pattern: pattern:

View File

@@ -1,7 +1,9 @@
import { ColorScheme } from '@blocksuite/affine-model'; import { ColorScheme } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme'; import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std'; import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
import { InlineManagerExtension } from '@blocksuite/block-std/inline';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { noop } from '@blocksuite/global/utils'; import { noop } from '@blocksuite/global/utils';
import { DoneIcon } from '@blocksuite/icons/lit'; import { DoneIcon } from '@blocksuite/icons/lit';
@@ -11,14 +13,14 @@ import { property } from 'lit/decorators.js';
import { codeToTokensBase, type ThemedToken } from 'shiki'; import { codeToTokensBase, type ThemedToken } from 'shiki';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { InlineManagerExtension } from '../../../../extension/index.js';
import { LatexEditorUnitSpecExtension } from '../../affine-inline-specs.js'; import { LatexEditorUnitSpecExtension } from '../../affine-inline-specs.js';
export const LatexEditorInlineManagerExtension = InlineManagerExtension({ export const LatexEditorInlineManagerExtension =
id: 'latex-inline-editor', InlineManagerExtension<AffineTextAttributes>({
enableMarkdown: false, id: 'latex-inline-editor',
specs: [LatexEditorUnitSpecExtension.identifier], enableMarkdown: false,
}); specs: [LatexEditorUnitSpecExtension.identifier],
});
export class LatexEditorMenu extends SignalWatcher( export class LatexEditorMenu extends SignalWatcher(
WithDisposable(ShadowlessElement) WithDisposable(ShadowlessElement)

View File

@@ -3,6 +3,7 @@ import { ShadowlessElement } from '@blocksuite/block-std';
import { import {
type AttributeRenderer, type AttributeRenderer,
InlineEditor, InlineEditor,
type InlineMarkdownMatch,
type InlineRange, type InlineRange,
type InlineRangeProvider, type InlineRangeProvider,
type VLine, type VLine,
@@ -17,7 +18,6 @@ import { classMap } from 'lit/directives/class-map.js';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { z } from 'zod'; import { z } from 'zod';
import type { InlineMarkdownMatch } from './extension/type.js';
import { onVBeforeinput, onVCompositionEnd } from './hooks.js'; import { onVBeforeinput, onVCompositionEnd } from './hooks.js';
import type { AffineInlineEditor } from './inline/index.js'; import type { AffineInlineEditor } from './inline/index.js';

View File

@@ -0,0 +1,4 @@
export * from './inline-manager';
export * from './inline-spec';
export * from './markdown-matcher';
export * from './type';

View File

@@ -0,0 +1,117 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import {
type BaseTextAttributes,
baseTextAttributes,
type DeltaInsert,
type ExtensionType,
} from '@blocksuite/store';
import { z, type ZodObject, type ZodTypeAny } from 'zod';
import { StdIdentifier } from '../../identifier.js';
import type { BlockStdScope } from '../../scope/index.js';
import type { AttributeRenderer } from '../types.js';
import { getDefaultAttributeRenderer } from '../utils/attribute-renderer.js';
import { MarkdownMatcherIdentifier } from './markdown-matcher.js';
import type { InlineMarkdownMatch, InlineSpecs } from './type.js';
export class InlineManager<TextAttributes extends BaseTextAttributes> {
embedChecker = (delta: DeltaInsert<TextAttributes>) => {
for (const spec of this.specs) {
if (spec.embed && spec.match(delta)) {
return true;
}
}
return false;
};
getRenderer = (): AttributeRenderer<TextAttributes> => {
const defaultRenderer = getDefaultAttributeRenderer<TextAttributes>();
const renderer: AttributeRenderer<TextAttributes> = props => {
// Priority increases from front to back
for (const spec of this.specs.toReversed()) {
if (spec.match(props.delta)) {
return spec.renderer(props);
}
}
return defaultRenderer(props);
};
return renderer;
};
getSchema = (): ZodObject<Record<keyof TextAttributes, ZodTypeAny>> => {
const defaultSchema = baseTextAttributes as unknown as ZodObject<
Record<keyof TextAttributes, ZodTypeAny>
>;
const schema: ZodObject<Record<keyof TextAttributes, ZodTypeAny>> =
this.specs.reduce((acc, cur) => {
const currentSchema = z.object({
[cur.name]: cur.schema,
}) as ZodObject<Record<keyof TextAttributes, ZodTypeAny>>;
return acc.merge(currentSchema) as ZodObject<
Record<keyof TextAttributes, ZodTypeAny>
>;
}, defaultSchema);
return schema;
};
get markdownMatches(): InlineMarkdownMatch<TextAttributes>[] {
if (!this.enableMarkdown) {
return [];
}
const matches = Array.from(
this.std.provider.getAll(MarkdownMatcherIdentifier).values()
);
return matches as InlineMarkdownMatch<TextAttributes>[];
}
readonly specs: Array<InlineSpecs<TextAttributes>>;
constructor(
readonly std: BlockStdScope,
readonly enableMarkdown: boolean,
...specs: Array<InlineSpecs<TextAttributes>>
) {
this.specs = specs;
}
}
export type InlineManagerExtensionConfig<
TextAttributes extends BaseTextAttributes,
> = {
id: string;
enableMarkdown?: boolean;
specs: ServiceIdentifier<InlineSpecs<TextAttributes>>[];
};
const InlineManagerIdentifier = createIdentifier<unknown>(
'AffineInlineManager'
);
export function InlineManagerExtension<
TextAttributes extends BaseTextAttributes,
>({
id,
enableMarkdown = true,
specs,
}: InlineManagerExtensionConfig<TextAttributes>): ExtensionType & {
identifier: ServiceIdentifier<InlineManager<TextAttributes>>;
} {
const identifier = InlineManagerIdentifier<InlineManager<TextAttributes>>(id);
return {
setup: di => {
di.addImpl(identifier, provider => {
return new InlineManager(
provider.get(StdIdentifier),
enableMarkdown,
...specs.map(spec => provider.get(spec))
);
});
},
identifier,
};
}

View File

@@ -0,0 +1,49 @@
import {
createIdentifier,
type ServiceIdentifier,
type ServiceProvider,
} from '@blocksuite/global/di';
import type { BaseTextAttributes, ExtensionType } from '@blocksuite/store';
import type { InlineSpecs } from './type.js';
export const InlineSpecIdentifier =
createIdentifier<unknown>('AffineInlineSpec');
export function InlineSpecExtension<TextAttributes extends BaseTextAttributes>(
name: string,
getSpec: (provider: ServiceProvider) => InlineSpecs<TextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineSpecs<TextAttributes>>;
};
export function InlineSpecExtension<TextAttributes extends BaseTextAttributes>(
spec: InlineSpecs<TextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineSpecs<TextAttributes>>;
};
export function InlineSpecExtension<TextAttributes extends BaseTextAttributes>(
nameOrSpec: string | InlineSpecs<TextAttributes>,
getSpec?: (provider: ServiceProvider) => InlineSpecs<TextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineSpecs<TextAttributes>>;
} {
if (typeof nameOrSpec === 'string') {
const identifier =
InlineSpecIdentifier<InlineSpecs<TextAttributes>>(nameOrSpec);
return {
identifier,
setup: di => {
di.addImpl(identifier, provider => getSpec!(provider));
},
};
}
const identifier = InlineSpecIdentifier<InlineSpecs<TextAttributes>>(
nameOrSpec.name as string
);
return {
identifier,
setup: di => {
di.addImpl(identifier, nameOrSpec);
},
};
}

View File

@@ -0,0 +1,30 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { BaseTextAttributes, ExtensionType } from '@blocksuite/store';
import type { InlineMarkdownMatch } from './type.js';
export const MarkdownMatcherIdentifier = createIdentifier<unknown>(
'AffineMarkdownMatcher'
);
export function InlineMarkdownExtension<
TextAttributes extends BaseTextAttributes,
>(
matcher: InlineMarkdownMatch<TextAttributes>
): ExtensionType & {
identifier: ServiceIdentifier<InlineMarkdownMatch<TextAttributes>>;
} {
const identifier = MarkdownMatcherIdentifier<
InlineMarkdownMatch<TextAttributes>
>(matcher.name);
return {
setup: di => {
di.addImpl(identifier, () => ({ ...matcher }));
},
identifier,
};
}

View File

@@ -8,12 +8,12 @@ import type * as Y from 'yjs';
import type { ZodTypeAny } from 'zod'; import type { ZodTypeAny } from 'zod';
export type InlineSpecs< export type InlineSpecs<
AffineTextAttributes extends BaseTextAttributes = BaseTextAttributes, TextAttributes extends BaseTextAttributes = BaseTextAttributes,
> = { > = {
name: keyof AffineTextAttributes | string; name: keyof TextAttributes | string;
schema: ZodTypeAny; schema: ZodTypeAny;
match: (delta: DeltaInsert<AffineTextAttributes>) => boolean; match: (delta: DeltaInsert<TextAttributes>) => boolean;
renderer: AttributeRenderer<AffineTextAttributes>; renderer: AttributeRenderer<TextAttributes>;
embed?: boolean; embed?: boolean;
}; };

View File

@@ -1,5 +1,6 @@
export * from './components'; export * from './components';
export * from './consts'; export * from './consts';
export * from './extensions';
export * from './inline-editor'; export * from './inline-editor';
export * from './range'; export * from './range';
export * from './services'; export * from './services';