diff --git a/docs/content/docs/features/extensions.mdx b/docs/content/docs/features/extensions.mdx index 0af2d62094..4cef887b29 100644 --- a/docs/content/docs/features/extensions.mdx +++ b/docs/content/docs/features/extensions.mdx @@ -29,9 +29,13 @@ type Extension = { match: RegExpMatchArray; range: { from: number; to: number }; editor: BlockNoteEditor; - }) => PartialBlock | undefined; + }) => + | PartialBlock + | { text: string; styles: Record } + | PartialInlineContent + | undefined; }[]; - plugins?: Plugin[]; + prosemirrorPlugins?: Plugin[]; tiptapExtensions?: AnyExtension[]; } @@ -39,7 +43,7 @@ const CustomExtension = createExtension({ key: "customBlockExtension", keyboardShortcuts: ..., inputRules: ..., - plugins: ..., + prosemirrorPlugins: ..., tiptapExtensions: ..., }); ``` @@ -52,14 +56,58 @@ Let's go over the options that can be passed into `createExtension`: - `ctx:` An object containing the BlockNote editor instance. -`inputRules?:` Input rules update blocks when given regular expressions are found in them. Takes an array of objects. Each object has a `find` field for the regular expression to find, and a `replace` field, for a function that should run on a match. The function should return a [`PartialBlock`](docs/reference/editor/manipulating-content#partial-blocks) which specifies how the block should be updated, or avoid updating it. It also has a single argument: +`inputRules?:` An array of declarative input rules for blocks, styles, or inline content. See [Input rules](#input-rules). -- `props:` An object containing the result of the regular expression match, a range for the [Prosemirror position indices](https://prosemirror.net/docs/guide/#doc.indexing) spanned by the match, and the BlockNote editor instance. - -`plugins?:` An array of [ProseMirror plugins](https://prosemirror.net/docs/ref/#state.Plugin_System). +`prosemirrorPlugins?:` An array of [ProseMirror plugins](https://prosemirror.net/docs/ref/#state.Plugin_System). `tiptapExtensions?:` An array of [TipTap extensions](https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new). +## Input rules + +Input rules are plain objects with `find` and `replace`. For example, convert a paragraph starting with `# ` into a heading: + +```typescript +import { createExtension } from "@blocknote/core"; + +const HeadingShortcut = createExtension({ + key: "heading-shortcut", + inputRules: [ + { + find: /^#\s$/, + replace: () => ({ type: "heading", props: { level: 1 } }), + }, + ], +}); +``` + +The `replace` callback receives `{ editor, match, range }`. Return a [`PartialBlock`](/docs/reference/editor/manipulating-content#partial-blocks) to convert the current block, or `undefined` to leave it unchanged. Undo restores the complete literal text, including the trigger. + +The returned shape determines the action: `{ text, styles }` applies styles, a string or array replaces inline content, and a partial block updates the current block: + +```typescript +import { createExtension } from "@blocknote/core"; + +const TextShortcuts = createExtension({ + key: "text-shortcuts", + inputRules: [ + { + find: /\*\*([^*]+)\*\*$/, + replace: ({ match }) => ({ text: match[1], styles: { bold: true } }), + }, + { + find: /->$/, + replace: () => "→", + }, + ], +}); +``` + +Style rules retain the returned `text` from the match, remove its surrounding delimiters (preserving leading whitespace), and apply `styles` without discarding existing formatting. Inline rules replace the whole match with a string or an array of BlockNote inline content, including custom inline content. Either kind can return `undefined` to decline the match. Style-associated extensions can be colocated with their style spec. + +Block rules also match plain Enter as a newline. Style and inline rules run only on text input. Register rules when creating the editor; adding input rules at runtime is not supported. + +Custom TipTap extensions can still use `addInputRules`, but those rules keep TipTap's own input-rule and undo behavior rather than the shared BlockNote engine. + ## Adding extensions to the editor Extensions can be added to the editor on their own via the [editor options](/docs/reference/editor/overview#options) or as part of [custom blocks](/docs/features/custom-schemas/custom-blocks). diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index cf6fff3d50..de7789a2a3 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -25,19 +25,15 @@ import { UnreachableCaseError } from "../../util/typescript.js"; import { getRelativeToAbsoluteTableCellMapping } from "../blockManipulation/tables/tables.js"; import { getStyleSchema, isPlainContentNodeType } from "../pmUtil.js"; -/** - * Convert a StyledText inline element to a - * prosemirror text node with the appropriate marks - */ -function styledTextToNodes( - styledText: StyledText, +/** Convert BlockNote styles to their schema's ProseMirror marks. */ +export function stylesToMarks( + styles: Readonly>, schema: Schema, - styleSchema: T, - blockType?: string, -): Node[] { + styleSchema: StyleSchema, +): Mark[] { const marks: Mark[] = []; - for (const [style, value] of Object.entries(styledText.styles || {})) { + for (const [style, value] of Object.entries(styles)) { const config = styleSchema[style]; if (!config) { throw new Error(`style ${style} not found in styleSchema`); @@ -56,6 +52,18 @@ function styledTextToNodes( } } + return marks; +} + +/** Convert styled text to nodes without discarding its existing styles. */ +function styledTextToNodes( + styledText: StyledText, + schema: Schema, + styleSchema: T, + blockType?: string, +): Node[] { + const marks = stylesToMarks(styledText.styles || {}, schema, styleSchema); + // Backwards compat: old BlockNote JSON may carry formatting marks (e.g. bold) // on a block whose content type is now "plain". Those marks aren't allowed on // the node, and would make `createChecked` throw when the block is assembled. @@ -215,9 +223,10 @@ export function tableContentToNodes< // each cell's relative index to its absolute column index. The occupancy // grid is built and scanned once per table, since doing so for every cell // makes the conversion quadratic in the number of cells. - const getAbsolute = getRelativeToAbsoluteTableCellMapping( - { type: "table", content: tableContent } as any, - ); + const getAbsolute = getRelativeToAbsoluteTableCellMapping({ + type: "table", + content: tableContent, + } as any); for (let rowIndex = 0; rowIndex < tableContent.rows.length; rowIndex++) { const row = tableContent.rows[rowIndex]; diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index 9f54bec092..5dd4cb33ba 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -1,9 +1,9 @@ -import { InputRule, markInputRule } from "@tiptap/core"; import Bold from "@tiptap/extension-bold"; import Code from "@tiptap/extension-code"; import Italic from "@tiptap/extension-italic"; import Strike from "@tiptap/extension-strike"; import Underline from "@tiptap/extension-underline"; +import { createExtension } from "../editor/BlockNoteExtension.js"; import { COLORS_DEFAULT } from "../editor/defaultColors.js"; import { BlockNoDefaults, @@ -136,48 +136,107 @@ const BackgroundColor = createStyleSpec( ); export const defaultStyleSpecs = { - bold: createStyleSpecFromTipTapMark(Bold, "boolean"), - italic: createStyleSpecFromTipTapMark(Italic, "boolean"), + bold: createStyleSpecFromTipTapMark( + Bold.extend({ addInputRules: () => [] }), + "boolean", + [ + createExtension({ + key: "bold-input-rules", + inputRules: [ + { + find: /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/, + replace: ({ match }) => ({ + text: match[2], + styles: { bold: true }, + }), + }, + { + find: /(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))$/, + replace: ({ match }) => ({ + text: match[2], + styles: { bold: true }, + }), + }, + ], + }), + ], + ), + italic: createStyleSpecFromTipTapMark( + Italic.extend({ addInputRules: () => [] }), + "boolean", + [ + createExtension({ + key: "italic-input-rules", + inputRules: [ + { + find: /(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/, + replace: ({ match }) => ({ + text: match[2], + styles: { italic: true }, + }), + }, + { + find: /(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/, + replace: ({ match }) => ({ + text: match[2], + styles: { italic: true }, + }), + }, + ], + }), + ], + ), underline: createStyleSpecFromTipTapMark(Underline, "boolean"), - strike: createStyleSpecFromTipTapMark(Strike, "boolean"), + strike: createStyleSpecFromTipTapMark( + Strike.extend({ addInputRules: () => [] }), + "boolean", + [ + createExtension({ + key: "strike-input-rules", + inputRules: [ + { + find: /(?:^|\s)(~~(?!\s+~~)((?:[^~]+))~~(?!\s+~~))$/, + replace: ({ match }) => ({ + text: match[2], + styles: { strike: true }, + }), + }, + ], + }), + ], + ), code: createStyleSpecFromTipTapMark( Code.extend({ + // Built-in shortcuts use BlockNote's shared input-rule engine instead. + addInputRules: () => [], excludes() { return marksExcludingNonFormattingMarks(this.editor); }, - addInputRules() { - return [ - // Matches any string that starts with a backtick, ends with a - // backtick, and has any non-backtick characters in between. Adapted - // from the original input rule: - // https://github.com/ueberdosis/tiptap/blob/c27661c148cdbea9e1c80107e10d0a9d1775c4ec/packages/extension-code/src/code.ts#L116 - // The original matches the character before the opening backtick, - // which `markInputRule` then deletes, so a lookbehind checks it - // instead. - markInputRule({ - find: /(? { - const { tr, schema } = state; - const leadingChar = match[1]; - const content = match[2]; - tr.replaceWith(range.from + leadingChar.length, range.to, [ - schema.text(content, [this.type.create()]), - schema.text(" "), - ]); - }, - }), - ]; - }, }), "boolean", + [ + createExtension({ + key: "code-input-rules", + inputRules: [ + // Look behind the opening delimiter to preserve the preceding character. + { + find: /(? ({ + text: match[1], + styles: { code: true }, + }), + }, + // Also convert pre-existing delimiters when a space follows them. + { + find: /(? ({ + text: match[1], + styles: { code: true }, + }), + }, + ], + }), + ], ), textColor: TextColor, backgroundColor: BackgroundColor, diff --git a/packages/core/src/editor/BlockNoteExtension.ts b/packages/core/src/editor/BlockNoteExtension.ts index d99dd5ee7c..1c9044f417 100644 --- a/packages/core/src/editor/BlockNoteExtension.ts +++ b/packages/core/src/editor/BlockNoteExtension.ts @@ -1,6 +1,9 @@ import { type AnyExtension } from "@tiptap/core"; import type { Plugin as ProsemirrorPlugin } from "prosemirror-state"; -import type { PartialBlockNoDefaults } from "../schema/index.js"; +import type { + PartialBlockNoDefaults, + PartialInlineContent, +} from "../schema/index.js"; import { Store, StoreOptions } from "../util/Store.js"; import type { BlockNoteEditor } from "./BlockNoteEditor.js"; import { originalFactorySymbol } from "./managers/ExtensionManager/symbol.js"; @@ -48,8 +51,10 @@ export interface Extension { readonly runsBefore?: ReadonlyArray; /** - * Input rules for a block: An input rule is what is used to replace text in a block when a regular expression match is found. - * As an example, typing `#` in a paragraph block will trigger an input rule to replace the text with a heading block. + * Declarative input rules for blocks, styles, and inline content. + * Return a replacement from `replace`, or undefined to leave the match unchanged. + * The returned shape determines whether to update the block, apply styles, + * or replace inline content. */ readonly inputRules?: ReadonlyArray; @@ -103,16 +108,18 @@ export interface Extension { /** * An input rule is what is used to replace text in a block when a regular expression match is found. - * As an example, typing `#` in a paragraph block will trigger an input rule to replace the text with a heading block. + * As an example, typing `# ` in a paragraph block will trigger an input rule to replace the text with a heading block. */ -type InputRule = { +export type InputRule = { /** * The regex to match when to trigger the input rule */ find: RegExp; /** * The function to call when the input rule is matched - * @returns undefined if the input rule should not be triggered, or an object with the type and props to update the block + * @returns undefined if the input rule should not be triggered, an object with + * the type and props to update the block, an object with text and styles to + * apply formatting, or inline content to replace the matched text. */ replace: (props: { /** @@ -128,7 +135,11 @@ type InputRule = { * The editor instance */ editor: BlockNoteEditor; - }) => undefined | PartialBlockNoDefaults; + }) => + | undefined + | PartialBlockNoDefaults + | { text: string; styles: Record } + | PartialInlineContent; }; /** diff --git a/packages/core/src/editor/inputRules.test.ts b/packages/core/src/editor/inputRules.test.ts new file mode 100644 index 0000000000..10a84b909f --- /dev/null +++ b/packages/core/src/editor/inputRules.test.ts @@ -0,0 +1,497 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { ySyncPluginKey, yUndoPluginKey } from "y-prosemirror"; +import * as Y from "yjs"; +import { + Extension as TiptapExtension, + InputRule as TiptapInputRule, +} from "@tiptap/core"; +import { TextSelection } from "prosemirror-state"; +import { Node as TiptapNode } from "@tiptap/core"; +import { CommentMark } from "../comments/mark.js"; +import { createExtension, type InputRule } from "./BlockNoteExtension.js"; +import { + BlockNoteEditor, + type BlockNoteEditorOptions, +} from "./BlockNoteEditor.js"; +import type { + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "../blocks/defaultBlocks.js"; +import { createInputRulesPlugins, hasInputRule } from "./inputRules.js"; +import { withCollaboration } from "../yjs/index.js"; +import { blocksToYXmlFragment } from "../yjs/utils.js"; + +const editors: BlockNoteEditor[] = []; +const cleanups: (() => void)[] = []; + +afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) { + cleanup(); + } + for (const editor of editors.splice(0)) { + editor._tiptapEditor.destroy(); + } + vi.restoreAllMocks(); +}); + +// Install the real editor plugins on Tiptap's headless view. Input rules and +// history need state/dispatch, not layout or a DOM-backed EditorView. +function createEditor( + options: Partial< + BlockNoteEditorOptions< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema + > + > = {}, +) { + const editor = BlockNoteEditor.create({ + initialContent: [{ id: "paragraph", type: "paragraph", content: [] }], + ...options, + }); + editors.push(editor); + const view = editor.prosemirrorView; + view.updateState( + view.state.reconfigure({ + plugins: editor._tiptapEditor.extensionManager.plugins, + }), + ); + return editor; +} + +function createCollaborativeEditor() { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("document"); + const editor = createEditor( + withCollaboration({ + collaboration: { + fragment, + user: { name: "Test", color: "red" }, + provider: undefined, + }, + }), + ); + blocksToYXmlFragment(editor, editor.document, fragment); + const view = editor.prosemirrorView; + // Drive only Yjs's sync view lifecycle. It needs state/dispatch and an + // unfocused view, not DOM rendering, cursor geometry, or awareness UI. + const syncView = new Proxy(view, { + get(target, key) { + return key === "hasFocus" ? () => false : Reflect.get(target, key); + }, + }); + const sync = view.state.plugins.find( + (plugin) => plugin.spec.key === ySyncPluginKey, + ); + const syncPluginView = sync?.spec.view?.(syncView); + if (!syncPluginView) { + throw new Error("Missing Yjs sync plugin"); + } + const updateSync = () => syncPluginView.update?.(syncView, view.state); + editor._tiptapEditor.on("transaction", updateSync); + cleanups.push(() => { + editor._tiptapEditor.off("transaction", updateSync); + syncPluginView.destroy?.(); + doc.destroy(); + }); + const undo = yUndoPluginKey.getState(view.state)?.undoManager; + if (!undo) { + throw new Error("Missing Yjs undo manager"); + } + undo.clear(); + undo.stopCapturing(); + return editor; +} + +function keyDown( + editor: BlockNoteEditor, + key: string, + modifiers: Partial = {}, +) { + // Only the key fields are read by ProseMirror's keymaps; no DOM is involved. + const event = { + key, + keyCode: key === "Backspace" ? 8 : 13, + ...modifiers, + } as KeyboardEvent; + const view = editor.prosemirrorView; + return view.state.plugins.some((plugin) => + plugin.props.handleKeyDown?.call(plugin, view, event), + ); +} + +function typeText(editor: BlockNoteEditor, text: string) { + const view = editor.prosemirrorView; + for (const char of text) { + const { from, to } = view.state.selection; + const insert = () => view.state.tr.insertText(char, from, to); + const handled = view.state.plugins.some((plugin) => + plugin.props.handleTextInput?.call(plugin, view, from, to, char, insert), + ); + if (!handled) { + view.dispatch(insert()); + } + } +} + +describe("input-rule history", () => { + describe.each(["standalone", "yjs"] as const)("%s history", (backend) => { + it.each([ + ["*ab*", "italic"], + ["_ab_", "italic"], + ["**ab**", "bold"], + ["__ab__", "bold"], + ["~~ab~~", "strike"], + ["`ab`", "code"], + ])("records the literal %s before conversion", (text, style) => { + const editor = + backend === "yjs" ? createCollaborativeEditor() : createEditor(); + // All typing and conversion happen inside one capture window. Separation + // must come from closeHistory, not incidental test execution time. + vi.spyOn(Date, "now").mockReturnValue(Date.now()); + typeText(editor, text); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "ab", styles: { [style]: true } }, + ]); + + expect(editor.undo()).toBe(true); + expect(editor.document[0].content).toEqual([ + { type: "text", text, styles: {} }, + ]); + + expect(editor.redo()).toBe(true); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "ab", styles: { [style]: true } }, + ]); + }); + }); + + it.each([ + "*ab*", + "**ab**", + "~~ab~~", + "`ab`", + "# ", + "- ", + "3. ", + "[x] ", + "> ", + "``` ", + "---", + ])("Backspace reverses %s without duplicating its trigger", (text) => { + const editor = createEditor(); + typeText(editor, text); + expect(keyDown(editor, "Backspace")).toBe(true); + expect(editor.document[0].type).toBe("paragraph"); + expect(editor.document[0].content).toEqual([ + { type: "text", text, styles: {} }, + ]); + // The conversion metadata must be consumed by this document change. + typeText(editor, "x"); + expect(editor.document[0].content).toEqual([ + { type: "text", text: text + "x", styles: {} }, + ]); + }); + + it.each(["standalone", "yjs"])( + "Backspace uses %s history so the conversion can be redone", + (history) => { + const editor = + history === "yjs" ? createCollaborativeEditor() : createEditor(); + typeText(editor, "**ab**"); + const converted = editor.document; + expect(keyDown(editor, "Backspace")).toBe(true); + expect(editor.prosemirrorState.doc.textContent).toBe("**ab**"); + expect(editor.redo()).toBe(true); + expect(editor.document).toEqual(converted); + }, + ); + + it("does not run inline rules on plain Enter", () => { + const editor = createEditor(); + typeText(editor, "inline"); + const [input, sidecar] = createInputRulesPlugins( + [{ find: /inline\s$/, replace: () => "converted" }], + editor, + ); + const view = editor.prosemirrorView; + view.updateState(view.state.reconfigure({ plugins: [input, sidecar] })); + expect(keyDown(editor, "Enter")).toBe(false); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "inline", styles: {} }, + ]); + }); + + it("converts a block on Enter and keeps the cursor inside it", () => { + const editor = createEditor(); + typeText(editor, "```ts"); + expect(keyDown(editor, "Enter")).toBe(true); + expect(editor.document[0]).toMatchObject({ + type: "codeBlock", + content: [], + props: { language: "ts" }, + }); + typeText(editor, "code"); + expect(editor.prosemirrorState.doc.textContent).toBe("code"); + }); + + it("does not undo a stale conversion after the selection moves", () => { + const editor = createEditor(); + typeText(editor, "**ab**"); + const view = editor.prosemirrorView; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, view.state.selection.from - 1), + ), + ); + expect(hasInputRule(view.state)).toBe(false); + expect(editor.undo()).toBe(true); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "**ab**", styles: {} }, + ]); + }); + + it.each(["**ab**", "---"])( + "Backspace reverses %s immediately with Yjs", + (text) => { + const editor = createCollaborativeEditor(); + typeText(editor, text); + expect(keyDown(editor, "Backspace")).toBe(true); + expect(editor.document[0].type).toBe("paragraph"); + expect(editor.document[0].content).toEqual([ + { type: "text", text, styles: {} }, + ]); + }, + ); + + it("preserves custom Tiptap input rules and their Backspace undo", () => { + const editor = createEditor({ + _tiptapOptions: { + extensions: [ + TiptapExtension.create({ + name: "arrowConversion", + addInputRules() { + return [ + new TiptapInputRule({ + find: /->$/, + handler: ({ state, range }) => { + state.tr.insertText("→", range.from, range.to); + }, + }), + ]; + }, + }), + ], + }, + }); + typeText(editor, "->"); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "→", styles: {} }, + ]); + expect(keyDown(editor, "Backspace")).toBe(true); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "->", styles: {} }, + ]); + }); + + it("orders custom rules by priority and falls through declined matches", () => { + const editor = createEditor({ + extensions: [ + createExtension({ + key: "low", + inputRules: [{ find: /->$/, replace: () => "low" }], + }), + createExtension({ + key: "high", + runsBefore: ["low"], + inputRules: [ + { find: /->$/, replace: () => undefined }, + { find: /->$/, replace: () => "→" }, + ], + }), + ], + }); + typeText(editor, "->"); + expect(editor.prosemirrorState.doc.textContent).toBe("→"); + expect(editor.undo()).toBe(true); + expect(editor.prosemirrorState.doc.textContent).toBe("->"); + }); + + it("applies declarative styles without discarding existing formatting", () => { + const editor = createEditor({ + extensions: [ + createExtension({ + key: "custom-style", + inputRules: [ + { + find: /!(ab)!$/, + replace: ({ match }) => ({ + text: match[1], + styles: { bold: true, textColor: "red" }, + }), + }, + ], + }), + ], + }); + editor.addStyles({ italic: true }); + typeText(editor, "!ab!"); + expect(editor.document[0].content).toEqual([ + { + type: "text", + text: "ab", + styles: { italic: true, bold: true, textColor: "red" }, + }, + ]); + expect(keyDown(editor, "Backspace")).toBe(true); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "!ab!", styles: { italic: true } }, + ]); + }); + + it.each([" foo", " "])( + "retains whitespace when applying styles to %j", + (text) => { + const editor = createEditor({ + extensions: [ + createExtension({ + key: "whitespace-style", + inputRules: [ + { + find: /(?: foo| {2})$/, + replace: ({ match }) => ({ + text: match[0], + styles: { bold: true }, + }), + }, + ], + }), + ], + }); + typeText(editor, text); + expect(editor.document[0].content).toEqual([ + { type: "text", text, styles: { bold: true } }, + ]); + expect(keyDown(editor, "Backspace")).toBe(true); + expect(editor.document[0].content).toEqual([ + { type: "text", text, styles: {} }, + ]); + }, + ); + + it("preserves preceding formatting when a space triggers inline code", () => { + const editor = createEditor({ + initialContent: [ + { + id: "paragraph", + type: "paragraph", + content: [ + { type: "text", text: "(", styles: { bold: true } }, + { type: "text", text: "`ab`", styles: {} }, + ], + }, + ], + }); + editor.setTextCursorPosition("paragraph", "end"); + typeText(editor, " "); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "(", styles: { bold: true } }, + { type: "text", text: "ab", styles: { code: true } }, + { type: "text", text: " ", styles: {} }, + ]); + expect(keyDown(editor, "Backspace")).toBe(true); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "(", styles: { bold: true } }, + { type: "text", text: "`ab` ", styles: {} }, + ]); + }); + + it("preserves comment annotations when a space triggers inline code", () => { + const editor = createEditor({ + initialContent: [{ id: "paragraph", type: "paragraph", content: "`ab`" }], + _tiptapOptions: { extensions: [CommentMark] }, + }); + const view = editor.prosemirrorView; + const comment = view.state.schema.marks.comment.create({ + threadId: "thread", + }); + view.dispatch(view.state.tr.addMark(4, 6, comment)); + editor.setTextCursorPosition("paragraph", "end"); + typeText(editor, " "); + expect(view.state.doc.textContent).toBe("ab "); + expect(view.state.doc.rangeHasMark(3, 5, comment)).toBe(true); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "ab", styles: { code: true } }, + { type: "text", text: " ", styles: {} }, + ]); + expect(keyDown(editor, "Backspace")).toBe(true); + expect(view.state.doc.textContent).toBe("`ab` "); + expect(view.state.doc.rangeHasMark(4, 6, comment)).toBe(true); + }); + + it("declines inline nodes that do not fit the current textblock", () => { + const editor = createEditor({ + _tiptapOptions: { + extensions: [ + TiptapNode.create({ + name: "plainText", + group: "blockContent", + content: "text*", + }), + ], + }, + extensions: [ + createExtension({ + key: "incompatible-inline", + inputRules: [{ find: /->$/, replace: () => "\n" }], + }), + ], + }); + const view = editor.prosemirrorView; + view.dispatch( + view.state.tr.setNodeMarkup(2, view.state.schema.nodes.plainText), + ); + typeText(editor, "->"); + expect(view.state.doc.textContent).toBe("->"); + expect(view.state.doc.nodeAt(2)?.type.name).toBe("plainText"); + }); + + it("keeps the original block rule callback contract", () => { + const replace = vi.fn(({ editor, match, range }) => { + expect(editor).toBeInstanceOf(BlockNoteEditor); + expect(match[0]).toBe("! "); + expect(range.to - range.from).toBe(2); + return { type: "heading", props: { level: 2 } }; + }); + const editor = createEditor({ + extensions: [ + createExtension({ + key: "simple-block-rule", + inputRules: [{ find: /^!\s$/, replace }], + }), + ], + }); + typeText(editor, "! "); + expect(replace).toHaveBeenCalledOnce(); + expect(editor.document[0]).toMatchObject({ + type: "heading", + props: { level: 2 }, + }); + expect(keyDown(editor, "Backspace")).toBe(true); + expect(editor.prosemirrorState.doc.textContent).toBe("! "); + }); + + it.each(["*ab*", "**ab**", "~~ab~~"])( + "does not format %s inside code", + (text) => { + const editor = createEditor(); + editor.addStyles({ code: true }); + typeText(editor, text); + expect(editor.document[0].content).toEqual([ + { type: "text", text, styles: { code: true } }, + ]); + }, + ); +}); diff --git a/packages/core/src/editor/inputRules.ts b/packages/core/src/editor/inputRules.ts new file mode 100644 index 0000000000..ab4abcf531 --- /dev/null +++ b/packages/core/src/editor/inputRules.ts @@ -0,0 +1,209 @@ +import { + InputRule as ProseMirrorInputRule, + inputRules, +} from "@handlewithcare/prosemirror-inputrules"; +import { Fragment, type Mark } from "prosemirror-model"; +import { + type EditorState, + Plugin, + PluginKey, + TextSelection, +} from "prosemirror-state"; +import { updateBlockTr } from "../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { + inlineContentToNodes, + stylesToMarks, +} from "../api/nodeConversions/blockToNode.js"; +import { setTextCursorPosition } from "../api/blockManipulation/selections/textCursorPosition.js"; +import { + getBlockInfoFromSelection, + getNodeId, +} from "../api/getBlockInfoFromPos.js"; +import type { BlockNoteEditor } from "./BlockNoteEditor.js"; +import type { InputRule } from "./BlockNoteExtension.js"; + +/** Compile BlockNote's declarative rules into the shared engine. */ +function toInputRule( + config: InputRule, + editor: BlockNoteEditor, + isEnter: () => boolean, +) { + return new ProseMirrorInputRule( + config.find, + (state, match, start, end) => { + const replacement = config.replace({ + editor, + match, + range: { from: start, to: end }, + }); + if (replacement === undefined) { + return null; + } + if (typeof replacement === "string" || Array.isArray(replacement)) { + if (isEnter()) { + return null; + } + const nodes = inlineContentToNodes( + replacement, + state.schema, + undefined, + editor.schema.styleSchema, + ); + const $start = state.doc.resolve(start); + const $end = state.doc.resolve(end); + return $start.sameParent($end) && + $start.parent.canReplace( + $start.index(), + $end.index(), + Fragment.from(nodes), + ) + ? state.tr.replaceWith(start, end, nodes) + : null; + } + if ("text" in replacement && "styles" in replacement) { + return isEnter() || !replacement.text + ? null + : applyStyles( + state, + match[0], + start, + replacement.text, + stylesToMarks( + replacement.styles, + state.schema, + editor.schema.styleSchema, + ), + ); + } + const blockInfo = getBlockInfoFromSelection(state); + if ( + !blockInfo.hasContent || + editor.schema.blockSchema[blockInfo.blockNoteType].content !== "inline" + ) { + return null; + } + const tr = state.tr.deleteRange(start, end); + updateBlockTr(tr, blockInfo.block.beforePos, replacement); + setTextCursorPosition( + tr, + getNodeId(blockInfo.block.node, tr.doc), + "start", + ); + return tr; + }, + { + inCodeMark: false, + }, + ); +} + +/** + * Retain the requested text within the match. Optional surrounding whitespace is + * preserved; delimiters are deleted without replacing the marked content. + */ +function applyStyles( + state: EditorState, + match: string, + start: number, + content: string, + marks: readonly Mark[], +) { + const offset = match.lastIndexOf(content); + if (offset < 0) { + return null; + } + const contentStart = start + offset; + const contentEnd = contentStart + content.length; + // Preserve whitespace outside the content, but never skip retained content. + const markStart = start + Math.min(offset, Math.max(0, match.search(/\S/))); + if (!state.doc.resolve(contentStart).parent.type.allowsMarks(marks)) { + return null; + } + let excluded = false; + state.doc.nodesBetween(contentStart, contentEnd, (node) => { + if ( + node.marks.some((existing) => + marks.some( + (mark) => + existing.type !== mark.type && existing.type.excludes(mark.type), + ), + ) + ) { + excluded = true; + } + }); + if (excluded) { + return null; + } + const delimiterEnd = Math.max(contentEnd, start + match.trimEnd().length); + const tr = state.tr + .delete(contentEnd, delimiterEnd) + .delete(markStart, contentStart); + for (const mark of marks) { + tr.addMark(markStart, markStart + content.length, mark); + tr.removeStoredMark(mark.type); + } + return tr; +} + +const inputRuleKey = new PluginKey("blocknote-input-rules"); + +/** Backspace may undo the conversion only while it is the latest input rule. */ +export function hasInputRule(state: EditorState): boolean { + return inputRuleKey.getState(state) === true; +} + +/** @internal Shared engine for BlockNote's declarative input rules. */ +export function createInputRulesPlugins( + rules: readonly InputRule[], + editor: BlockNoteEditor, +) { + let handlingEnter = false; + const input = inputRules({ + rules: rules.map((rule) => toInputRule(rule, editor, () => handlingEnter)), + }); + const sidecar = new Plugin({ + key: inputRuleKey, + // Keep the engine's private metadata access in this adapter. Because input + // precedes this plugin, its state is already available in newState. + state: { + init: () => false, + apply: (_tr, _previous, _oldState, newState) => + !!input.getState(newState), + }, + props: { + handleKeyDown(view, event) { + if ( + event.key !== "Enter" || + event.shiftKey || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return false; + } + if (!(view.state.selection instanceof TextSelection)) { + return false; + } + const { $cursor } = view.state.selection; + if (!$cursor) { + return false; + } + handlingEnter = true; + try { + return !!input.props.handleTextInput?.call( + input, + view, + $cursor.pos, + $cursor.pos, + "\n", + () => view.state.tr.insertText("\n", $cursor.pos, $cursor.pos), + ); + } finally { + handlingEnter = false; + } + }, + }, + }); + return [input, sidecar]; +} diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 4b19e8d17d..faf3154e17 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -1,24 +1,10 @@ -import { - InputRule, - inputRules as inputRulesPlugin, -} from "@handlewithcare/prosemirror-inputrules"; import { AnyExtension as AnyTiptapExtension, Extension as TiptapExtension, } from "@tiptap/core"; import { keydownHandler } from "@tiptap/pm/keymap"; -import { - type EditorState, - Plugin, - PluginKey, - TextSelection, -} from "prosemirror-state"; -import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { setTextCursorPosition } from "../../../api/blockManipulation/selections/textCursorPosition.js"; -import { - getBlockInfoFromSelection, - getNodeId, -} from "../../../api/getBlockInfoFromPos.js"; +import { type EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { createInputRulesPlugins } from "../../inputRules.js"; import { sortByDependencies } from "../../../util/topo-sort.js"; import type { BlockNoteEditor, @@ -28,6 +14,7 @@ import type { Extension, ExtensionFactoryInstance, ExtensionFactory, + InputRule, } from "../../BlockNoteExtension.js"; import { originalFactorySymbol } from "./symbol.js"; import { @@ -614,11 +601,12 @@ export class ExtensionManager { ); // Collect all input rules into 1 extension to reduce conflicts + const editor = this.editor; tiptapExtensions.push( TiptapExtension.create({ name: "blocknote-input-rules", addProseMirrorPlugins() { - const rules = [] as InputRule[]; + const rules: InputRule[] = []; Array.from(inputRulesByPriority.keys()) // We sort the rules by their priority (the key) .sort((a, b) => a - b) @@ -627,49 +615,7 @@ export class ExtensionManager { // Append in reverse priority order rules.push(...inputRulesByPriority.get(priority)!); }); - const inputRules = inputRulesPlugin({ rules }); - // Sidecar plugin: triggers the same input rules on Enter by - // delegating to the inputRules plugin's handleTextInput with a - // synthetic "\n" insertion. The handlewithcare regex `\s$` already - // matches `\n`, so any rule that fires on space fires on Enter too. - // We call its handleTextInput directly (rather than via - // view.someProp) so other plugins don't observe the synthetic input, - // and so the rule's undo metadata is keyed to the same plugin - // instance that Tiptap's `commands.undoInputRule` reads from. - const inputRulesEnter = new Plugin({ - props: { - handleKeyDown(view, event) { - if (event.key !== "Enter") { - return false; - } - // Only trigger on plain Enter — modifier combos like - // Shift/Cmd/Ctrl/Alt+Enter are reserved for other handlers - // (e.g. soft-break, submit) and should fall through. - if ( - event.shiftKey || - event.ctrlKey || - event.metaKey || - event.altKey - ) { - return false; - } - const { $cursor } = view.state.selection as TextSelection; - if (!$cursor) { - return false; - } - return !!inputRules.props.handleTextInput?.call( - inputRules, - view, - $cursor.pos, - $cursor.pos, - "\n", - () => - view.state.tr.insertText("\n", $cursor.pos, $cursor.pos), - ); - }, - }, - }); - return [inputRules, inputRulesEnter]; + return createInputRulesPlugins(rules, editor); }, }), ); @@ -690,10 +636,10 @@ export class ExtensionManager { */ private getProsemirrorPluginsFromExtension(extension: Extension): { plugins: Plugin[]; - inputRules: InputRule[]; + inputRules: readonly InputRule[]; } { const plugins: Plugin[] = [...(extension.prosemirrorPlugins ?? [])]; - const inputRules: InputRule[] = []; + const inputRules = extension.inputRules ?? []; if ( !extension.prosemirrorPlugins?.length && !Object.keys(extension.keyboardShortcuts || {}).length && @@ -705,50 +651,6 @@ export class ExtensionManager { this.extensionPlugins.set(extension, plugins); - if (extension.inputRules?.length) { - inputRules.push( - ...extension.inputRules.map((inputRule) => { - return new InputRule( - inputRule.find, - (state, match, start, end) => { - const replaceWith = inputRule.replace({ - match, - range: { from: start, to: end }, - editor: this.editor, - }); - if (replaceWith) { - const tr = state.tr; - const blockInfo = getBlockInfoFromSelection(tr); - - if ( - !blockInfo.hasContent || - this.editor.schema.blockSchema[blockInfo.blockNoteType] - ?.content !== "inline" - ) { - return null; - } - - tr.deleteRange(start, end); - updateBlockTr(tr, blockInfo.block.beforePos, replaceWith); - // updateBlockTr's replaceWith path leaves the selection after - // the new block when the content is replaced wholesale (e.g. - // when the rule returns content: []). Move the cursor back - // inside the new block so the user can keep typing. - setTextCursorPosition( - tr, - getNodeId(blockInfo.block.node, tr.doc), - "start", - ); - return tr; - } - return null; - }, - { undoable: true }, - ); - }), - ); - } - if (Object.keys(extension.keyboardShortcuts || {}).length) { let currentEvent: KeyboardEvent | undefined; const handleKeyDown = keydownHandler( diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index b770e038a3..07f5d3fa18 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -37,6 +37,7 @@ import { blockEdgeSelection, } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { hasInputRule } from "../../../editor/inputRules.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js"; @@ -103,10 +104,13 @@ export const KeyboardShortcutsExtension = Extension.create<{ } // handleBackspace is partially adapted from https://github.com/ueberdosis/tiptap/blob/ed56337470efb4fd277128ab7ef792b37cfae992/packages/core/src/extensions/keymap.ts const handleBackspace = () => + // Undo a fresh conversion before deleting its selection (e.g. a divider). + // Run outside the Tiptap chain: history dispatches its own transaction. + (hasInputRule(this.editor.state) && bnEditor.undo()) || this.editor.commands.first(({ chain, commands }) => [ // Deletes the selection if it's not empty. () => commands.deleteSelection(), - // Undoes an input rule if one was triggered in the last editor state change. + // Preserve input-rule undo for custom Tiptap extensions. () => commands.undoInputRule(), // Resets the block (`keyboard.resetsTo`, a paragraph by default) if the // selection is at the start of the block and the block isn't already diff --git a/packages/core/src/schema/styles/internal.ts b/packages/core/src/schema/styles/internal.ts index e47ec1c39b..dc47b3288c 100644 --- a/packages/core/src/schema/styles/internal.ts +++ b/packages/core/src/schema/styles/internal.ts @@ -84,7 +84,11 @@ export function createInternalStyleSpec( export function createStyleSpecFromTipTapMark< T extends Mark, P extends StylePropSchema, ->(mark: T, propSchema: P) { +>( + mark: T, + propSchema: P, + extensions?: (Extension | ExtensionFactoryInstance)[], +) { return createInternalStyleSpec( { type: mark.name as T["name"], @@ -151,6 +155,7 @@ export function createStyleSpecFromTipTapMark< }; }, }, + extensions, ); } diff --git a/packages/math-block/package.json b/packages/math-block/package.json index 551d8e8883..91073ac9a6 100644 --- a/packages/math-block/package.json +++ b/packages/math-block/package.json @@ -97,7 +97,6 @@ "dependencies": { "@blocknote/core": "workspace:^", "@blocknote/react": "workspace:^", - "@handlewithcare/prosemirror-inputrules": "^0.1.4", "katex": "^0.18.9", "prosemirror-model": "^1.25.11", "prosemirror-state": "^1.4.4" diff --git a/packages/math-block/src/inlineContent/helpers/extensions/MathInlineInputRulesExtension.test.ts b/packages/math-block/src/inlineContent/helpers/extensions/MathInlineInputRulesExtension.test.ts new file mode 100644 index 0000000000..346239dbe6 --- /dev/null +++ b/packages/math-block/src/inlineContent/helpers/extensions/MathInlineInputRulesExtension.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { expect, it } from "vite-plus/test"; +import type { EditorView } from "prosemirror-view"; +import { createReactMathBlockSpec } from "../../../block/createReactMathBlockSpec.js"; +import { createReactInlineMathSpec } from "../../createReactMathInlineContentSpec.js"; +import { MathInlineInputRulesExtension } from "./MathInlineInputRulesExtension.js"; + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { mathBlock: createReactMathBlockSpec() }, + inlineContentSpecs: { math: createReactInlineMathSpec() }, +}); + +function typeText(view: EditorView, value: string) { + for (const char of value) { + const { from, to } = view.state.selection; + const insert = () => view.state.tr.insertText(char, from, to); + if ( + !view.state.plugins.some((plugin) => + plugin.props.handleTextInput?.call( + plugin, + view, + from, + to, + char, + insert, + ), + ) + ) { + view.dispatch(insert()); + } + } +} + +it.each(["$x$", "\\(x\\)", "$$ ", "\\[ "])( + "Backspace restores the literal %s through the shared input-rule engine", + (text) => { + const editor = BlockNoteEditor.create({ schema }); + try { + // Input rules and keymaps need real state and dispatch, not DOM rendering. + const view = editor.prosemirrorView; + view.updateState( + view.state.reconfigure({ + plugins: editor._tiptapEditor.extensionManager.plugins, + }), + ); + typeText(view, text); + if (text.endsWith(" ")) { + expect(editor.document[0].type).toBe("mathBlock"); + } else { + expect(editor.document[0].content).toMatchObject([{ type: "math" }]); + } + const event = { key: "Backspace", keyCode: 8 } as KeyboardEvent; + expect( + view.state.plugins.some((plugin) => + plugin.props.handleKeyDown?.call(plugin, view, event), + ), + ).toBe(true); + expect(editor.document[0].type).toBe("paragraph"); + expect(editor.document[0].content).toEqual([ + { type: "text", text, styles: {} }, + ]); + typeText(view, "x"); + expect(view.state.doc.textContent).toBe(text + "x"); + } finally { + editor._tiptapEditor.destroy(); + } + }, +); + +it("retains literal delimiters when registered without the math inline spec", () => { + const editor = BlockNoteEditor.create({ + extensions: [MathInlineInputRulesExtension], + }); + try { + const view = editor.prosemirrorView; + view.updateState( + view.state.reconfigure({ + plugins: editor._tiptapEditor.extensionManager.plugins, + }), + ); + typeText(view, "$x$ \\(y\\)"); + expect(view.state.doc.textContent).toBe("$x$ \\(y\\)"); + } finally { + editor._tiptapEditor.destroy(); + } +}); diff --git a/packages/math-block/src/inlineContent/helpers/extensions/MathInlineInputRulesExtension.ts b/packages/math-block/src/inlineContent/helpers/extensions/MathInlineInputRulesExtension.ts index c93f200549..0874c792b4 100644 --- a/packages/math-block/src/inlineContent/helpers/extensions/MathInlineInputRulesExtension.ts +++ b/packages/math-block/src/inlineContent/helpers/extensions/MathInlineInputRulesExtension.ts @@ -1,8 +1,4 @@ import { createExtension } from "@blocknote/core"; -import { - InputRule, - inputRules as inputRulesPlugin, -} from "@handlewithcare/prosemirror-inputrules"; import { mathInlineContentConfig } from "../../createReactMathInlineContentSpec.js"; @@ -17,26 +13,14 @@ import { mathInlineContentConfig } from "../../createReactMathInlineContentSpec. */ export const MathInlineInputRulesExtension = createExtension({ key: "math-inline-input-rules", - // Cannot use the `inputRules` field as it only allows for converting matched - // content to blocks. - prosemirrorPlugins: [ - inputRulesPlugin({ - rules: [/\$([^$]+)\$$/, /\\\((.+?)\\\)$/].map( - (find) => - new InputRule(find, (state, match, start, end) => { - const source = match[1]?.trim(); - const nodeType = state.schema.nodes[mathInlineContentConfig.type]; - if (!source || !nodeType) { - return null; - } - - return state.tr.replaceRangeWith( - start, - end, - nodeType.create(null, state.schema.text(source)), - ); - }), - ), - }), - ], + inputRules: [/\$([^$]+)\$$/, /\\\((.+?)\\\)$/].map((find) => ({ + find, + replace: ({ editor, match }) => { + const source = match[1]?.trim(); + return source && + editor.schema.inlineContentSchema[mathInlineContentConfig.type] + ? [{ type: mathInlineContentConfig.type, content: source }] + : undefined; + }, + })), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1df42384e4..926e7330d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5662,9 +5662,6 @@ importers: '@blocknote/react': specifier: workspace:^ version: link:../react - '@handlewithcare/prosemirror-inputrules': - specifier: ^0.1.4 - version: 0.1.4(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5) katex: specifier: ^0.18.9 version: 0.18.9 diff --git a/tests/src/unit/core/schema/__snapshots__/styles.json b/tests/src/unit/core/schema/__snapshots__/styles.json index 258208471c..786d4cad33 100644 --- a/tests/src/unit/core/schema/__snapshots__/styles.json +++ b/tests/src/unit/core/schema/__snapshots__/styles.json @@ -17,7 +17,9 @@ "propSchema": "boolean", "type": "bold", }, - "extensions": undefined, + "extensions": [ + [Function], + ], "implementation": { "mark": null, "render": [Function], @@ -29,7 +31,9 @@ "propSchema": "boolean", "type": "code", }, - "extensions": undefined, + "extensions": [ + [Function], + ], "implementation": { "mark": null, "render": [Function], @@ -53,7 +57,9 @@ "propSchema": "boolean", "type": "italic", }, - "extensions": undefined, + "extensions": [ + [Function], + ], "implementation": { "mark": null, "render": [Function], @@ -77,7 +83,9 @@ "propSchema": "boolean", "type": "strike", }, - "extensions": undefined, + "extensions": [ + [Function], + ], "implementation": { "mark": null, "render": [Function],