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:
@@ -10,6 +10,5 @@ export {
|
||||
onModelTextUpdated,
|
||||
selectTextModel,
|
||||
} from './dom';
|
||||
export { markdownInput } from './markdown';
|
||||
export { RichText } from './rich-text';
|
||||
export * from './utils';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { markdownInput } from './markdown-input.js';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user