@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
520 lines (468 loc) • 17.4 kB
text/typescript
import {
InputRule,
inputRules as inputRulesPlugin,
} from "@handlewithcare/prosemirror-inputrules";
import {
AnyExtension as AnyTiptapExtension,
Extension as TiptapExtension,
} from "@tiptap/core";
import { keymap } from "@tiptap/pm/keymap";
import { Plugin } from "prosemirror-state";
import { updateBlockTr } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js";
import { getBlockInfoFromTransaction } from "../../../api/getBlockInfoFromPos.js";
import { sortByDependencies } from "../../../util/topo-sort.js";
import type {
BlockNoteEditor,
BlockNoteEditorOptions,
} from "../../BlockNoteEditor.js";
import type {
Extension,
ExtensionFactoryInstance,
ExtensionFactory,
} from "../../BlockNoteExtension.js";
import { originalFactorySymbol } from "./symbol.js";
import {
getDefaultExtensions,
getDefaultTiptapExtensions,
} from "./extensions.js";
export class ExtensionManager {
/**
* A set of extension keys which are disabled by the options
*/
private disabledExtensions = new Set<string>();
/**
* A list of all the extensions that are registered to the editor
*/
private extensions: Extension[] = [];
/**
* A map of all the abort controllers for each extension that has an init method defined
*/
private abortMap = new Map<Extension, AbortController>();
/**
* A map of all the extension factories that are registered to the editor
*/
private extensionFactories = new Map<ExtensionFactory, Extension>();
/**
* Because a single blocknote extension can both have it's own prosemirror plugins & additional generated ones (e.g. keymap & input rules plugins)
* We need to keep track of all the plugins for each extension, so that we can remove them when the extension is unregistered
*/
private extensionPlugins: Map<Extension, Plugin[]> = new Map();
constructor(
private editor: BlockNoteEditor<any, any, any>,
private options: BlockNoteEditorOptions<any, any, any>,
) {
/**
* When the editor is first mounted, we need to initialize all the extensions
*/
editor.onMount(() => {
for (const extension of this.extensions) {
// If the extension has an init function, we can initialize it, otherwise, it is already added to the editor
if (extension.mount) {
// We create an abort controller for each extension, so that we can abort the extension when the editor is unmounted
const abortController = new window.AbortController();
const unmountCallback = extension.mount({
dom: editor.prosemirrorView.dom,
root: editor.prosemirrorView.root,
signal: abortController.signal,
});
// If the extension returns a method to unmount it, we can register it to be called when the abort controller is aborted
if (unmountCallback) {
abortController.signal.addEventListener("abort", () => {
unmountCallback();
});
}
// Keep track of the abort controller for each extension, so that we can abort it when the editor is unmounted
this.abortMap.set(extension, abortController);
}
}
});
/**
* When the editor is unmounted, we need to abort all the extensions' abort controllers
*/
editor.onUnmount(() => {
for (const [extension, abortController] of this.abortMap.entries()) {
// No longer track the abort controller for this extension
this.abortMap.delete(extension);
// Abort each extension's abort controller
abortController.abort();
}
});
// TODO do disabled extensions need to be only for editor base extensions? Or all of them?
this.disabledExtensions = new Set(options.disableExtensions || []);
// Add the default extensions
for (const extension of getDefaultExtensions(this.editor, this.options)) {
this.addExtension(extension);
}
// Add the extensions from the options
for (const extension of this.options.extensions ?? []) {
this.addExtension(extension);
}
// Add the extensions from blocks specs
for (const block of Object.values(this.editor.schema.blockSpecs)) {
for (const extension of block.extensions ?? []) {
this.addExtension(extension);
}
}
}
/**
* Register one or more extensions to the editor after the editor is initialized.
*
* This allows users to switch on & off extensions "at runtime".
*/
public registerExtension(
extension:
| Extension
| ExtensionFactoryInstance
| (Extension | ExtensionFactoryInstance)[],
): void {
const extensions = ([] as (Extension | ExtensionFactoryInstance)[])
.concat(extension)
.filter(Boolean) as (Extension | ExtensionFactoryInstance)[];
if (!extensions.length) {
// eslint-disable-next-line no-console
console.warn(`No extensions found to register`, extension);
return;
}
const registeredExtensions = extensions
.map((extension) => this.addExtension(extension))
.filter(Boolean) as Extension[];
const pluginsToAdd = new Set<Plugin>();
for (const extension of registeredExtensions) {
if (extension?.tiptapExtensions) {
// This is necessary because this can only switch out prosemirror plugins at runtime,
// it can't switch out Tiptap extensions since that can have more widespread effects (since a Tiptap extension can even add/remove to the schema).
// eslint-disable-next-line no-console
console.warn(
`Extension ${extension.key} has tiptap extensions, but these cannot be changed after initializing the editor. Please separate the extension into multiple extensions if you want to add them, or re-initialize the editor.`,
extension,
);
}
if (extension?.inputRules?.length) {
// This is necessary because input rules are defined in a single prosemirror plugin which cannot be re-initialized.
// eslint-disable-next-line no-console
console.warn(
`Extension ${extension.key} has input rules, but these cannot be changed after initializing the editor. Please separate the extension into multiple extensions if you want to add them, or re-initialize the editor.`,
extension,
);
}
this.getProsemirrorPluginsFromExtension(extension).plugins.forEach(
(plugin) => {
pluginsToAdd.add(plugin);
},
);
}
// TODO there isn't a great way to do sorting right now. This is something that should be improved in the future.
// So, we just append to the end of the list for now.
this.updatePlugins((plugins) => [...plugins, ...pluginsToAdd]);
}
/**
* Register an extension to the editor
* @param extension - The extension to register
* @returns The extension instance
*/
private addExtension(
extension: Extension | ExtensionFactoryInstance,
): Extension | undefined {
let instance: Extension;
if (typeof extension === "function") {
instance = extension({ editor: this.editor });
} else {
instance = extension;
}
if (!instance || this.disabledExtensions.has(instance.key)) {
return undefined as any;
}
// Now that we know that the extension is not disabled, we can add it to the extension factories
if (typeof extension === "function") {
const originalFactory = (instance as any)[originalFactorySymbol] as (
...args: any[]
) => ExtensionFactoryInstance;
if (typeof originalFactory === "function") {
this.extensionFactories.set(originalFactory, instance);
}
}
this.extensions.push(instance);
if (instance.blockNoteExtensions) {
for (const extension of instance.blockNoteExtensions) {
this.addExtension(extension);
}
}
return instance as any;
}
/**
* Resolve an extension or a list of extensions into a list of extension instances
* @param toResolve - The extension or list of extensions to resolve
* @returns A list of extension instances
*/
private resolveExtensions(
toResolve:
| undefined
| string
| Extension
| ExtensionFactory
| (Extension | ExtensionFactory | string | undefined)[],
): Extension[] {
const extensions = [] as Extension[];
if (typeof toResolve === "function") {
const instance = this.extensionFactories.get(toResolve);
if (instance) {
extensions.push(instance);
}
} else if (Array.isArray(toResolve)) {
for (const extension of toResolve) {
extensions.push(...this.resolveExtensions(extension));
}
} else if (typeof toResolve === "object" && "key" in toResolve) {
extensions.push(toResolve);
} else if (typeof toResolve === "string") {
const instance = this.extensions.find((e) => e.key === toResolve);
if (instance) {
extensions.push(instance);
}
}
return extensions;
}
/**
* Unregister an extension from the editor
* @param toUnregister - The extension to unregister
* @returns void
*/
public unregisterExtension(
toUnregister:
| undefined
| string
| Extension
| ExtensionFactory
| (Extension | ExtensionFactory | string | undefined)[],
): void {
const extensions = this.resolveExtensions(toUnregister);
if (!extensions.length) {
// eslint-disable-next-line no-console
console.warn(`No extensions found to unregister`, toUnregister);
return;
}
let didWarn = false;
const pluginsToRemove = new Set<Plugin>();
for (const extension of extensions) {
this.extensions = this.extensions.filter((e) => e !== extension);
this.extensionFactories.forEach((instance, factory) => {
if (instance === extension) {
this.extensionFactories.delete(factory);
}
});
this.abortMap.get(extension)?.abort();
this.abortMap.delete(extension);
const plugins = this.extensionPlugins.get(extension);
plugins?.forEach((plugin) => {
pluginsToRemove.add(plugin);
});
this.extensionPlugins.delete(extension);
if (extension.tiptapExtensions && !didWarn) {
didWarn = true;
// eslint-disable-next-line no-console
console.warn(
`Extension ${extension.key} has tiptap extensions, but they will not be removed. Please separate the extension into multiple extensions if you want to remove them, or re-initialize the editor.`,
toUnregister,
);
}
}
this.updatePlugins((plugins) =>
plugins.filter((plugin) => !pluginsToRemove.has(plugin)),
);
}
/**
* Allows resetting the current prosemirror state's plugins
* @param update - A function that takes the current plugins and returns the new plugins
* @returns void
*/
private updatePlugins(update: (plugins: Plugin[]) => Plugin[]): void {
const currentState = this.editor.prosemirrorState;
const state = currentState.reconfigure({
plugins: update(currentState.plugins.slice()),
});
this.editor.prosemirrorView.updateState(state);
}
/**
* Get all the extensions that are registered to the editor
*/
public getTiptapExtensions(): AnyTiptapExtension[] {
// Start with the default tiptap extensions
const tiptapExtensions = getDefaultTiptapExtensions(
this.editor,
this.options,
).filter((extension) => !this.disabledExtensions.has(extension.name));
const getPriority = sortByDependencies(this.extensions);
const inputRulesByPriority = new Map<number, InputRule[]>();
for (const extension of this.extensions) {
if (extension.tiptapExtensions) {
tiptapExtensions.push(...extension.tiptapExtensions);
}
const priority = getPriority(extension.key);
const { plugins: prosemirrorPlugins, inputRules } =
this.getProsemirrorPluginsFromExtension(extension);
// Sometimes a blocknote extension might need to make additional prosemirror plugins, so we generate them here
if (prosemirrorPlugins.length) {
tiptapExtensions.push(
TiptapExtension.create({
name: extension.key,
priority,
addProseMirrorPlugins: () => prosemirrorPlugins,
}),
);
}
if (inputRules.length) {
if (!inputRulesByPriority.has(priority)) {
inputRulesByPriority.set(priority, []);
}
inputRulesByPriority.get(priority)!.push(...inputRules);
}
}
// Collect all input rules into 1 extension to reduce conflicts
tiptapExtensions.push(
TiptapExtension.create({
name: "blocknote-input-rules",
addProseMirrorPlugins() {
const rules = [] as InputRule[];
Array.from(inputRulesByPriority.keys())
// We sort the rules by their priority (the key)
.sort()
.reverse()
.forEach((priority) => {
// Append in reverse priority order
rules.push(...inputRulesByPriority.get(priority)!);
});
return [inputRulesPlugin({ rules })];
},
}),
);
// Add any tiptap extensions from the `_tiptapOptions`
for (const extension of this.options._tiptapOptions?.extensions ?? []) {
tiptapExtensions.push(extension);
}
return tiptapExtensions;
}
/**
* This maps a blocknote extension into an array of Prosemirror plugins if it has any of the following:
* - plugins
* - keyboard shortcuts
* - input rules
*/
private getProsemirrorPluginsFromExtension(extension: Extension): {
plugins: Plugin[];
inputRules: InputRule[];
} {
const plugins: Plugin[] = [...(extension.prosemirrorPlugins ?? [])];
const inputRules: InputRule[] = [];
if (
!extension.prosemirrorPlugins?.length &&
!Object.keys(extension.keyboardShortcuts || {}).length &&
!extension.inputRules?.length
) {
// We can bail out early if the extension has no features to add to the tiptap editor
return { plugins, inputRules };
}
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 cursorPosition = this.editor.getTextCursorPosition();
if (
this.editor.schema.blockSchema[cursorPosition.block.type]
.content !== "inline"
) {
return null;
}
const blockInfo = getBlockInfoFromTransaction(state.tr);
const tr = state.tr.deleteRange(start, end);
updateBlockTr(tr, blockInfo.bnBlock.beforePos, replaceWith);
return tr;
}
return null;
});
}),
);
}
if (Object.keys(extension.keyboardShortcuts || {}).length) {
plugins.push(
keymap(
Object.fromEntries(
Object.entries(extension.keyboardShortcuts!).map(([key, value]) => [
key,
() => value({ editor: this.editor }),
]),
),
),
);
}
return { plugins, inputRules };
}
/**
* Get all extensions
*/
public getExtensions(): Map<string, Extension> {
return new Map(
this.extensions.map((extension) => [extension.key, extension]),
);
}
/**
* Get a specific extension by it's instance
*/
public getExtension<
const Ext extends Extension | ExtensionFactory = Extension,
>(
extension: string,
):
| (Ext extends Extension
? Ext
: Ext extends ExtensionFactory
? ReturnType<ReturnType<Ext>>
: never)
| undefined;
public getExtension<const T extends ExtensionFactory>(
extension: T,
): ReturnType<ReturnType<T>> | undefined;
public getExtension<const T extends ExtensionFactory | string = string>(
extension: T,
):
| (T extends ExtensionFactory
? ReturnType<ReturnType<T>>
: T extends string
? Extension
: never)
| undefined {
if (typeof extension === "string") {
const instance = this.extensions.find((e) => e.key === extension);
if (!instance) {
return undefined;
}
return instance as any;
} else if (typeof extension === "function") {
const instance = this.extensionFactories.get(extension);
if (!instance) {
return undefined;
}
return instance as any;
}
throw new Error(`Invalid extension type: ${typeof extension}`);
}
/**
* Check if an extension exists
*/
public hasExtension(key: string | Extension | ExtensionFactory): boolean {
if (typeof key === "string") {
return this.extensions.some((e) => e.key === key);
} else if (typeof key === "object" && "key" in key) {
return this.extensions.some((e) => e.key === key.key);
} else if (typeof key === "function") {
return this.extensionFactories.has(key);
}
return false;
}
}