@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
322 lines (291 loc) • 10.8 kB
text/typescript
import { AnyExtension, Extension, extensions } from "@tiptap/core";
import { Gapcursor } from "@tiptap/extension-gapcursor";
import { History } from "@tiptap/extension-history";
import { Link } from "@tiptap/extension-link";
import { Text } from "@tiptap/extension-text";
import { Plugin } from "prosemirror-state";
import * as Y from "yjs";
import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDropExtension.js";
import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension.js";
import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension.js";
import type { ThreadStore } from "../comments/index.js";
import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension.js";
import { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js";
import { SyncPlugin } from "../extensions/Collaboration/SyncPlugin.js";
import { UndoPlugin } from "../extensions/Collaboration/UndoPlugin.js";
import { CommentMark } from "../extensions/Comments/CommentMark.js";
import { CommentsPlugin } from "../extensions/Comments/CommentsPlugin.js";
import { FilePanelProsemirrorPlugin } from "../extensions/FilePanel/FilePanelPlugin.js";
import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin.js";
import { HardBreak } from "../extensions/HardBreak/HardBreak.js";
import { KeyboardShortcutsExtension } from "../extensions/KeyboardShortcuts/KeyboardShortcutsExtension.js";
import { LinkToolbarProsemirrorPlugin } from "../extensions/LinkToolbar/LinkToolbarPlugin.js";
import {
DEFAULT_LINK_PROTOCOL,
VALID_LINK_PROTOCOLS,
} from "../extensions/LinkToolbar/protocols.js";
import { NodeSelectionKeyboardPlugin } from "../extensions/NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.js";
import { PlaceholderPlugin } from "../extensions/Placeholder/PlaceholderPlugin.js";
import { PreviousBlockTypePlugin } from "../extensions/PreviousBlockType/PreviousBlockTypePlugin.js";
import { ShowSelectionPlugin } from "../extensions/ShowSelection/ShowSelectionPlugin.js";
import { SideMenuProsemirrorPlugin } from "../extensions/SideMenu/SideMenuPlugin.js";
import { SuggestionMenuProseMirrorPlugin } from "../extensions/SuggestionMenu/SuggestionPlugin.js";
import {
SuggestionAddMark,
SuggestionDeleteMark,
SuggestionModificationMark,
} from "../extensions/Suggestions/SuggestionMarks.js";
import { TableHandlesProsemirrorPlugin } from "../extensions/TableHandles/TableHandlesPlugin.js";
import { TextAlignmentExtension } from "../extensions/TextAlignment/TextAlignmentExtension.js";
import { TextColorExtension } from "../extensions/TextColor/TextColorExtension.js";
import { TrailingNode } from "../extensions/TrailingNode/TrailingNodeExtension.js";
import UniqueID from "../extensions/UniqueID/UniqueID.js";
import { BlockContainer, BlockGroup, Doc } from "../pm-nodes/index.js";
import {
BlockNoteDOMAttributes,
BlockSchema,
BlockSpecs,
InlineContentSchema,
InlineContentSpecs,
StyleSchema,
StyleSpecs,
} from "../schema/index.js";
import type {
BlockNoteEditor,
BlockNoteEditorOptions,
SupportedExtension,
} from "./BlockNoteEditor.js";
import { ForkYDocPlugin } from "../extensions/Collaboration/ForkYDocPlugin.js";
type ExtensionOptions<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
> = {
editor: BlockNoteEditor<BSchema, I, S>;
domAttributes: Partial<BlockNoteDOMAttributes>;
blockSpecs: BlockSpecs;
inlineContentSpecs: InlineContentSpecs;
styleSpecs: StyleSpecs;
trailingBlock: boolean | undefined;
collaboration?: {
fragment: Y.XmlFragment;
user: {
name: string;
color: string;
[key: string]: string;
};
provider: any;
renderCursor?: (user: any) => HTMLElement;
showCursorLabels?: "always" | "activity";
};
disableExtensions: string[] | undefined;
setIdAttribute?: boolean;
animations: boolean;
tableHandles: boolean;
dropCursor: (opts: any) => Plugin;
placeholders: Record<
string | "default" | "emptyDocument",
string | undefined
>;
tabBehavior?: "prefer-navigate-ui" | "prefer-indent";
sideMenuDetection: "viewport" | "editor";
comments?: {
threadStore: ThreadStore;
};
pasteHandler: BlockNoteEditorOptions<any, any, any>["pasteHandler"];
};
/**
* Get all the Tiptap extensions BlockNote is configured with by default
*/
export const getBlockNoteExtensions = <
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(
opts: ExtensionOptions<BSchema, I, S>,
) => {
const ret: Record<string, SupportedExtension> = {};
const tiptapExtensions = getTipTapExtensions(opts);
for (const ext of tiptapExtensions) {
ret[ext.name] = ext;
}
if (opts.collaboration) {
ret["ySyncPlugin"] = new SyncPlugin(opts.collaboration.fragment);
ret["yUndoPlugin"] = new UndoPlugin();
if (opts.collaboration.provider?.awareness) {
ret["yCursorPlugin"] = new CursorPlugin(opts.collaboration);
}
ret["forkYDocPlugin"] = new ForkYDocPlugin({
editor: opts.editor,
collaboration: opts.collaboration,
});
}
// Note: this is pretty hardcoded and will break when user provides plugins with same keys.
// Define name on plugins instead and not make this a map?
ret["formattingToolbar"] = new FormattingToolbarProsemirrorPlugin(
opts.editor,
);
ret["linkToolbar"] = new LinkToolbarProsemirrorPlugin(opts.editor);
ret["sideMenu"] = new SideMenuProsemirrorPlugin(
opts.editor,
opts.sideMenuDetection,
);
ret["suggestionMenus"] = new SuggestionMenuProseMirrorPlugin(opts.editor);
ret["filePanel"] = new FilePanelProsemirrorPlugin(opts.editor as any);
ret["placeholder"] = new PlaceholderPlugin(opts.editor, opts.placeholders);
if (opts.animations ?? true) {
ret["animations"] = new PreviousBlockTypePlugin();
}
if (opts.tableHandles) {
ret["tableHandles"] = new TableHandlesProsemirrorPlugin(opts.editor as any);
}
ret["nodeSelectionKeyboard"] = new NodeSelectionKeyboardPlugin();
ret["showSelection"] = new ShowSelectionPlugin(opts.editor);
if (opts.comments) {
ret["comments"] = new CommentsPlugin(
opts.editor,
opts.comments.threadStore,
CommentMark.name,
);
}
const disableExtensions: string[] = opts.disableExtensions || [];
for (const ext of disableExtensions) {
delete ret[ext];
}
return ret;
};
let LINKIFY_INITIALIZED = false;
/**
* Get all the Tiptap extensions BlockNote is configured with by default
*/
const getTipTapExtensions = <
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(
opts: ExtensionOptions<BSchema, I, S>,
) => {
const tiptapExtensions: AnyExtension[] = [
extensions.ClipboardTextSerializer,
extensions.Commands,
extensions.Editable,
extensions.FocusEvents,
extensions.Tabindex,
// DevTools,
Gapcursor,
// DropCursor,
Extension.create({
name: "dropCursor",
addProseMirrorPlugins: () => [
opts.dropCursor({
width: 5,
color: "#ddeeff",
editor: opts.editor,
}),
],
}),
UniqueID.configure({
// everything from bnBlock group (nodes that represent a BlockNote block should have an id)
types: ["blockContainer", "columnList", "column"],
setIdAttribute: opts.setIdAttribute,
}),
HardBreak,
// Comments,
// basics:
Text,
// marks:
SuggestionAddMark,
SuggestionDeleteMark,
SuggestionModificationMark,
Link.extend({
inclusive: false,
}).configure({
defaultProtocol: DEFAULT_LINK_PROTOCOL,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
protocols: LINKIFY_INITIALIZED ? [] : VALID_LINK_PROTOCOLS,
}),
...Object.values(opts.styleSpecs).map((styleSpec) => {
return styleSpec.implementation.mark.configure({
editor: opts.editor as any,
});
}),
TextColorExtension,
BackgroundColorExtension,
TextAlignmentExtension,
// make sure escape blurs editor, so that we can tab to other elements in the host page (accessibility)
Extension.create({
name: "OverrideEscape",
addKeyboardShortcuts() {
return {
Escape: () => {
if (opts.editor.suggestionMenus.shown) {
// escape is handled by suggestionmenu
return false;
}
return this.editor.commands.blur();
},
};
},
}),
// nodes
Doc,
BlockContainer.configure({
editor: opts.editor,
domAttributes: opts.domAttributes,
}),
KeyboardShortcutsExtension.configure({
editor: opts.editor,
tabBehavior: opts.tabBehavior,
}),
BlockGroup.configure({
domAttributes: opts.domAttributes,
}),
...Object.values(opts.inlineContentSpecs)
.filter((a) => a.config !== "link" && a.config !== "text")
.map((inlineContentSpec) => {
return inlineContentSpec.implementation!.node.configure({
editor: opts.editor as any,
});
}),
...Object.values(opts.blockSpecs).flatMap((blockSpec) => {
return [
// dependent nodes (e.g.: tablecell / row)
...(blockSpec.implementation.requiredExtensions || []).map((ext) =>
ext.configure({
editor: opts.editor,
domAttributes: opts.domAttributes,
}),
),
// the actual node itself
blockSpec.implementation.node.configure({
editor: opts.editor,
domAttributes: opts.domAttributes,
}),
];
}),
createCopyToClipboardExtension(opts.editor),
createPasteFromClipboardExtension(
opts.editor,
opts.pasteHandler ||
((context: {
defaultPasteHandler: (context?: {
prioritizeMarkdownOverHTML?: boolean;
plainTextAsMarkdown?: boolean;
}) => boolean | undefined;
}) => context.defaultPasteHandler()),
),
createDropFileExtension(opts.editor),
// This needs to be at the bottom of this list, because Key events (such as enter, when selecting a /command),
// should be handled before Enter handlers in other components like splitListItem
...(opts.trailingBlock === undefined || opts.trailingBlock
? [TrailingNode]
: []),
...(opts.comments ? [CommentMark] : []),
];
LINKIFY_INITIALIZED = true;
if (!opts.collaboration) {
// disable history extension when collaboration is enabled as y-prosemirror takes care of undo / redo
tiptapExtensions.push(History);
}
return tiptapExtensions;
};