fix(editor): support markdown transform when using IME (#12778)

Fix #12284 
Close
[BS-3517](https://linear.app/affine-design/issue/BS-3517/微软新注音输入法无法使用markdown语法)

This PR refactor the markdown transform during inputting, including:
- Transfrom markdown syntax input in `inlineEditor.slots.inputting`,
where we can detect the space character inputed by IME like Microsoft
Bopomofo, but `keydown` event can't.
- Remove `markdown-input.ts` which was used in `KeymapExtension` of
paragraph, and refactor with `InlineMarkdownExtension`
- Adjust existing `InlineMarkdownExtension` since the space is included
in text.
- Add two `InlineMarkdownExtension` for paragraph and list to impl
Heading1-6, number, bullet, to-do list conversion.

Other changes:
- Improve type hint for parameter of `store.addBlock`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
- Added markdown shortcuts for creating code blocks and dividers in the
rich text editor.
- Introduced enhanced paragraph markdown support for headings and
blockquotes with inline markdown patterns.
- Integrated new list markdown extension supporting numbered, bulleted,
and todo lists with checked states.

- **Improvements**
- Updated markdown formatting patterns to require trailing spaces for
links, LaTeX, and inline styles, improving detection accuracy.
- Markdown transformations now respond to input events instead of
keydown for smoother editing experience.
- Added focus management after markdown transformations to maintain
seamless editing flow.

- **Bug Fixes**
- Removed unnecessary prevention of default behavior on space and
shift-space key presses in list and paragraph editors.

- **Refactor**
- Enhanced event handling and typing for editor input events, improving
reliability and maintainability.
- Refined internal prefix text extraction logic for markdown processing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
L-Sun
2025-06-11 14:12:28 +08:00
committed by GitHub
parent c846c57a12
commit 24448659a4
33 changed files with 440 additions and 593 deletions

View File

@@ -10,6 +10,5 @@ export {
onModelTextUpdated,
selectTextModel,
} from './dom';
export { markdownInput } from './markdown';
export { RichText } from './rich-text';
export * from './utils';

View File

@@ -1,42 +0,0 @@
import {
DividerBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
import { beforeConvert } from './utils.js';
export function toDivider(
std: BlockStdScope,
model: BlockModel,
prefix: string
) {
const { store: doc } = std;
if (
matchModels(model, [DividerBlockModel]) ||
(matchModels(model, [ParagraphBlockModel]) && model.props.type === 'quote')
) {
return;
}
const parent = doc.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
beforeConvert(std, model, prefix.length);
const blockProps = {
children: model.children,
};
doc.addBlock('affine:divider', blockProps, parent, index);
const nextBlock = parent.children[index + 1];
let id = nextBlock?.id;
if (!id) {
id = doc.addBlock('affine:paragraph', {}, parent);
}
focusTextModel(std, id);
return id;
}

View File

@@ -1 +0,0 @@
export { markdownInput } from './markdown-input.js';

View File

@@ -1,54 +0,0 @@
import {
type ListProps,
type ListType,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { matchModels, toNumberedList } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
import { beforeConvert } from './utils.js';
export function toList(
std: BlockStdScope,
model: BlockModel,
listType: ListType,
prefix: string,
otherProperties?: Partial<ListProps>
) {
if (!matchModels(model, [ParagraphBlockModel])) {
return;
}
const { store: doc } = std;
const parent = doc.getParent(model);
if (!parent) return;
beforeConvert(std, model, prefix.length);
if (listType !== 'numbered') {
const index = parent.children.indexOf(model);
const blockProps = {
type: listType,
text: model.text?.clone(),
children: model.children,
...otherProperties,
};
doc.deleteBlock(model, {
deleteChildren: false,
});
const id = doc.addBlock('affine:list', blockProps, parent, index);
focusTextModel(std, id);
return id;
}
let order = parseInt(prefix.slice(0, -1));
if (!Number.isInteger(order)) order = 1;
const id = toNumberedList(std, model, order);
if (!id) return;
focusTextModel(std, id);
return id;
}

View File

@@ -1,98 +0,0 @@
import {
CalloutBlockModel,
CodeBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import {
isHorizontalRuleMarkdown,
isMarkdownPrefix,
matchModels,
} from '@blocksuite/affine-shared/utils';
import { type BlockStdScope, TextSelection } from '@blocksuite/std';
import { getInlineEditorByModel } from '../dom.js';
import { toDivider } from './divider.js';
import { toList } from './list.js';
import { toParagraph } from './paragraph.js';
import { toCode } from './to-code.js';
import { getPrefixText } from './utils.js';
export function markdownInput(
std: BlockStdScope,
id?: string
): string | undefined {
if (!id) {
const selection = std.selection;
const text = selection.find(TextSelection);
id = text?.from.blockId;
}
if (!id) return;
const model = std.store.getBlock(id)?.model;
if (!model) return;
const inline = getInlineEditorByModel(std, model);
if (!inline) return;
const range = inline.getInlineRange();
if (!range) return;
const prefixText = getPrefixText(inline);
if (!isMarkdownPrefix(prefixText)) return;
const isParagraph = matchModels(model, [ParagraphBlockModel]);
const isHeading = isParagraph && model.props.type.startsWith('h');
const isParagraphQuoteBlock = isParagraph && model.props.type === 'quote';
const isCodeBlock = matchModels(model, [CodeBlockModel]);
if (
isHeading ||
isParagraphQuoteBlock ||
isCodeBlock ||
matchModels(model.parent, [CalloutBlockModel])
)
return;
const lineInfo = inline.getLine(range.index);
if (!lineInfo) return;
const { lineIndex, rangeIndexRelatedToLine } = lineInfo;
if (lineIndex !== 0 || rangeIndexRelatedToLine > prefixText.length) return;
// try to add code block
const codeMatch = prefixText.match(/^```([a-zA-Z0-9]*)$/g);
if (codeMatch) {
return toCode(std, model, prefixText, codeMatch[0].slice(3));
}
if (isHorizontalRuleMarkdown(prefixText.trim())) {
return toDivider(std, model, prefixText);
}
switch (prefixText.trim()) {
case '[]':
case '[ ]':
return toList(std, model, 'todo', prefixText, {
checked: false,
});
case '[x]':
return toList(std, model, 'todo', prefixText, {
checked: true,
});
case '-':
case '*':
return toList(std, model, 'bulleted', prefixText);
case '#':
return toParagraph(std, model, 'h1', prefixText);
case '##':
return toParagraph(std, model, 'h2', prefixText);
case '###':
return toParagraph(std, model, 'h3', prefixText);
case '####':
return toParagraph(std, model, 'h4', prefixText);
case '#####':
return toParagraph(std, model, 'h5', prefixText);
case '######':
return toParagraph(std, model, 'h6', prefixText);
case '>':
return toParagraph(std, model, 'quote', prefixText);
default:
return toList(std, model, 'numbered', prefixText);
}
}

View File

@@ -1,49 +0,0 @@
import {
ParagraphBlockModel,
type ParagraphType,
} from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
import { beforeConvert } from './utils.js';
export function toParagraph(
std: BlockStdScope,
model: BlockModel,
type: ParagraphType,
prefix: string
) {
const { store: doc } = std;
if (!matchModels(model, [ParagraphBlockModel])) {
const parent = doc.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
beforeConvert(std, model, prefix.length);
const blockProps = {
type: type,
text: model.text?.clone(),
children: model.children,
};
doc.deleteBlock(model, { deleteChildren: false });
const id = doc.addBlock('affine:paragraph', blockProps, parent, index);
focusTextModel(std, id);
return id;
}
if (matchModels(model, [ParagraphBlockModel]) && model.props.type !== type) {
beforeConvert(std, model, prefix.length);
doc.updateBlock(model, { type });
focusTextModel(std, model.id);
}
// If the model is already a paragraph with the same type, do nothing
return model.id;
}

View File

@@ -1,42 +0,0 @@
import { ParagraphBlockModel } from '@blocksuite/affine-model';
import { matchModels } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
export function toCode(
std: BlockStdScope,
model: BlockModel,
prefixText: string,
language: string | null
) {
if (
matchModels(model, [ParagraphBlockModel]) &&
model.props.type === 'quote'
) {
return;
}
const doc = model.store;
const parent = doc.getParent(model);
if (!parent) {
return;
}
doc.captureSync();
const index = parent.children.indexOf(model);
const codeId = doc.addBlock('affine:code', { language }, parent, index);
if (model.text && model.text.length > prefixText.length) {
const text = model.text.clone();
doc.addBlock('affine:paragraph', { text }, parent, index + 1);
text.delete(0, prefixText.length);
}
doc.deleteBlock(model, { bringChildrenTo: parent });
focusTextModel(std, codeId);
return codeId;
}

View File

@@ -1,39 +0,0 @@
import type { BlockStdScope } from '@blocksuite/std';
import type { InlineEditor } from '@blocksuite/std/inline';
import type { BlockModel } from '@blocksuite/store';
import { focusTextModel } from '../dom.js';
export function getPrefixText(inlineEditor: InlineEditor) {
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return '';
const firstLineEnd = inlineEditor.yTextString.search(/\n/);
if (firstLineEnd !== -1 && inlineRange.index > firstLineEnd) {
return '';
}
const textPoint = inlineEditor.getTextPoint(inlineRange.index);
if (!textPoint) return '';
const [leafStart, offsetStart] = textPoint;
return leafStart.textContent
? leafStart.textContent.slice(0, offsetStart)
: '';
}
export function beforeConvert(
std: BlockStdScope,
model: BlockModel,
index: number
) {
const { text } = model;
if (!text) return;
// Add a space after the text, then stop capturing
// So when the user undo, the prefix will be restored with a `space`
// Ex. (| is the cursor position)
// *| <- user input
// <space> -> bullet list
// *<space>| -> undo
text.insert(' ', index);
focusTextModel(std, model.id, index + 1);
std.store.captureSync();
text.delete(0, index + 1);
}

View File

@@ -22,6 +22,7 @@ import * as Y from 'yjs';
import { z } from 'zod';
import { onVBeforeinput, onVCompositionEnd } from './hooks.js';
import { getPrefixText } from './utils.js';
interface RichTextStackItem {
meta: Map<'richtext-v-range', InlineRange | null>;
@@ -186,38 +187,60 @@ export class RichText extends WithDisposable(ShadowlessElement) {
const markdownMatches = this.markdownMatches;
if (markdownMatches) {
inlineEditor.disposables.addFromEvent(
this.inlineEventSource ?? this.inlineEditorContainer,
'keydown',
(e: KeyboardEvent) => {
if (e.key !== ' ' && e.key !== 'Enter') return;
const markdownTransform = (isEnter: boolean = false) => {
let inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return false;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange || inlineRange.length > 0) return;
let prefixText = getPrefixText(inlineEditor);
if (isEnter) prefixText = `${prefixText} `;
const nearestLineBreakIndex = inlineEditor.yTextString
.slice(0, inlineRange.index)
.lastIndexOf('\n');
const prefixText = inlineEditor.yTextString.slice(
nearestLineBreakIndex + 1,
inlineRange.index
);
for (const match of markdownMatches) {
const { pattern, action } = match;
if (prefixText.match(pattern)) {
action({
inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager: this.undoManager,
for (const match of markdownMatches) {
const { pattern, action } = match;
if (prefixText.match(pattern)) {
if (isEnter) {
inlineEditor.insertText(
{
index: inlineRange.index,
length: 0,
},
' '
);
inlineEditor.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
e.preventDefault();
break;
inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return false;
}
action({
inlineEditor,
prefixText,
inlineRange,
pattern,
undoManager: this.undoManager,
});
return true;
}
}
return false;
};
inlineEditor.disposables.add(
inlineEditor.slots.inputting.subscribe(data => {
if (!inlineEditor.isComposing && data === ' ') {
markdownTransform();
}
})
);
inlineEditor.disposables.add(
inlineEditor.slots.keydown.subscribe(event => {
if (event.key === 'Enter' && markdownTransform(true)) {
event.stopPropagation();
event.preventDefault();
}
})
);
}

View File

@@ -52,3 +52,17 @@ export function clearMarksOnDiscontinuousInput(
}
});
}
export function getPrefixText(inlineEditor: InlineEditor) {
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange || inlineRange.length > 0) return '';
const nearestLineBreakIndex = inlineEditor.yTextString
.slice(0, inlineRange.index)
.lastIndexOf('\n');
const prefixText = inlineEditor.yTextString.slice(
nearestLineBreakIndex + 1,
inlineRange.index
);
return prefixText;
}