Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 55 additions & 7 deletions docs/content/docs/features/extensions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,21 @@ type Extension = {
match: RegExpMatchArray;
range: { from: number; to: number };
editor: BlockNoteEditor;
}) => PartialBlock | undefined;
}) =>
| PartialBlock
| { text: string; styles: Record<string, string | boolean> }
| PartialInlineContent
| undefined;
}[];
plugins?: Plugin[];
prosemirrorPlugins?: Plugin[];
tiptapExtensions?: AnyExtension[];
}

const CustomExtension = createExtension({
key: "customBlockExtension",
keyboardShortcuts: ...,
inputRules: ...,
plugins: ...,
prosemirrorPlugins: ...,
tiptapExtensions: ...,
});
```
Expand All @@ -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).
Expand Down
35 changes: 22 additions & 13 deletions packages/core/src/api/nodeConversions/blockToNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends StyleSchema>(
styledText: StyledText<T>,
/** Convert BlockNote styles to their schema's ProseMirror marks. */
export function stylesToMarks(
styles: Readonly<Record<string, string | boolean | undefined>>,
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`);
Expand All @@ -56,6 +52,18 @@ function styledTextToNodes<T extends StyleSchema>(
}
}

return marks;
}

/** Convert styled text to nodes without discarding its existing styles. */
function styledTextToNodes<T extends StyleSchema>(
styledText: StyledText<T>,
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.
Expand Down Expand Up @@ -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];
Expand Down
129 changes: 94 additions & 35 deletions packages/core/src/blocks/defaultBlocks.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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: /(?<!`)`([^`]+)`(?!`)$/,
type: this.type,
}),
// Extends the Code mark with an extra input rule that fires when a space is
// typed after the closing backtick. The default rule only fires when typing
// the closing backtick itself, so it misses the case where the user adds
// both backticks first, then writes content between them.
new InputRule({
find: /(^|[^`])`([^`]+)`(?!`) $/,
handler: ({ state, range, match }) => {
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: /(?<!`)`([^`]+)`(?!`)$/,
replace: ({ match }) => ({
text: match[1],
styles: { code: true },
}),
},
// Also convert pre-existing delimiters when a space follows them.
{
find: /(?<!`)`([^`]+)`(?!`) $/,
replace: ({ match }) => ({
text: match[1],
styles: { code: true },
}),
},
],
}),
],
),
textColor: TextColor,
backgroundColor: BackgroundColor,
Expand Down
25 changes: 18 additions & 7 deletions packages/core/src/editor/BlockNoteExtension.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -48,8 +51,10 @@ export interface Extension<State = any, Key extends string = string> {
readonly runsBefore?: ReadonlyArray<string>;

/**
* 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<InputRule>;

Expand Down Expand Up @@ -103,16 +108,18 @@ export interface Extension<State = any, Key extends string = string> {

/**
* 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: {
/**
Expand All @@ -128,7 +135,11 @@ type InputRule = {
* The editor instance
*/
editor: BlockNoteEditor<any, any, any>;
}) => undefined | PartialBlockNoDefaults<any, any, any>;
}) =>
| undefined
| PartialBlockNoDefaults<any, any, any>
| { text: string; styles: Record<string, string | boolean> }
| PartialInlineContent<any, any>;
};

/**
Expand Down
Loading
Loading