@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
335 lines (296 loc) • 10.3 kB
text/typescript
import {
Editor,
EditorOptions,
Editor as TiptapEditor,
createDocument,
} from "@tiptap/core";
import { Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { EditorState, Transaction } from "@tiptap/pm/state";
import { blockToNode } from "../api/nodeConversions/blockToNode.js";
import { PartialBlock } from "../blocks/defaultBlocks.js";
import { StyleSchema } from "../schema/index.js";
import type { BlockNoteEditor } from "./BlockNoteEditor.js";
export type BlockNoteTipTapEditorOptions = Partial<
Omit<EditorOptions, "content">
> & {
content: PartialBlock<any, any, any>[];
};
/**
* Custom Editor class that extends TiptapEditor and separates
* the creation of the view from the constructor.
*/
export class BlockNoteTipTapEditor extends TiptapEditor {
private _state: EditorState;
public static create = (
options: BlockNoteTipTapEditorOptions,
styleSchema: StyleSchema,
) => {
// because we separate the constructor from the creation of the view,
// we need to patch setTimeout to prevent this code from having any effect:
// https://github.com/ueberdosis/tiptap/blob/45bac803283446795ad1b03f43d3746fa54a68ff/packages/core/src/Editor.ts#L117
const oldSetTimeout = globalThis?.window?.setTimeout;
if (typeof globalThis?.window?.setTimeout !== "undefined") {
globalThis.window.setTimeout = (() => {
return 0;
}) as any;
}
try {
return new BlockNoteTipTapEditor(options, styleSchema);
} finally {
if (oldSetTimeout) {
globalThis.window.setTimeout = oldSetTimeout;
}
}
};
protected constructor(
options: BlockNoteTipTapEditorOptions,
styleSchema: StyleSchema,
) {
// possible fix for next.js server side rendering
// const d = globalThis.document;
// const w = globalThis.window;
// if (!globalThis.document) {
// globalThis.document = {
// createElement: () => {},
// };
// }
// options.injectCSS = false
super({ ...options, content: undefined });
// try {
// globalThis.window = w;
// } catch(e) {}
// try {
// globalThis.document = d;
// } catch(e) {}
// This is a hack to make "initial content detection" by y-prosemirror (and also tiptap isEmpty)
// properly detect whether or not the document has changed.
// We change the doc.createAndFill function to make sure the initial block id is set, instead of null
const schema = this.schema;
let cache: any;
const oldCreateAndFill = schema.nodes.doc.createAndFill;
(schema.nodes.doc as any).createAndFill = (...args: any) => {
if (cache) {
return cache;
}
const ret = oldCreateAndFill.apply(schema.nodes.doc, args);
// create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state)
const jsonNode = JSON.parse(JSON.stringify(ret!.toJSON()));
jsonNode.content[0].content[0].attrs.id = "initialBlockId";
cache = Node.fromJSON(schema, jsonNode);
return cache;
};
let doc: Node;
try {
const pmNodes = options?.content.map((b) =>
blockToNode(b, this.schema, styleSchema).toJSON(),
);
doc = createDocument(
{
type: "doc",
content: [
{
type: "blockGroup",
content: pmNodes,
},
],
},
this.schema,
this.options.parseOptions,
);
} catch (e) {
// eslint-disable-next-line no-console
console.error(
"Error creating document from blocks passed as `initialContent`. Caused by exception: ",
e,
);
throw new Error(
"Error creating document from blocks passed as `initialContent`:\n" +
+JSON.stringify(options.content),
);
}
// Create state immediately, so that it's available independently from the View,
// the way Prosemirror "intends it to be". This also makes sure that we can access
// the state before the view is created / mounted.
this._state = EditorState.create({
doc,
schema: this.schema,
// selection: selection || undefined,
});
}
get state() {
if (this.view) {
this._state = this.view.state;
}
return this._state;
}
dispatch(transaction: Transaction) {
if (!this.view) {
// before view has been initialized
this._state = this.state.apply(transaction);
this.emit("transaction", {
editor: this,
transaction,
});
return;
}
// This is a verbatim copy of the default dispatch method, but with the following changes:
// - We provide the appendedTransactions to a new `v3-update` event
// In the future, we can remove this dispatch method entirely and rely on the new `update` event signature which does what we want by providing the appendedTransactions
////////////////////////////////////////////////////////////////////////////////
// if the editor / the view of the editor was destroyed
// the transaction should not be dispatched as there is no view anymore.
if (this.view.isDestroyed) {
return;
}
if (this.isCapturingTransaction) {
// Do the default capture behavior
(this as any).dispatchTransaction(transaction);
return;
}
const { state, transactions: appendedTransactions } =
this.state.applyTransaction(transaction);
const selectionHasChanged = !this.state.selection.eq(state.selection);
this.emit("beforeTransaction", {
editor: this,
transaction,
nextState: state,
});
this.view.updateState(state);
this.emit("transaction", {
editor: this,
transaction,
});
if (selectionHasChanged) {
this.emit("selectionUpdate", {
editor: this,
transaction,
});
}
const focus = transaction.getMeta("focus");
const blur = transaction.getMeta("blur");
if (focus) {
this.emit("focus", {
editor: this,
event: focus.event,
transaction,
});
}
if (blur) {
this.emit("blur", {
editor: this,
event: blur.event,
transaction,
});
}
if (!transaction.docChanged || transaction.getMeta("preventUpdate")) {
return;
}
this.emit("update", {
editor: this,
transaction,
});
this.emit("v3-update", {
editor: this,
transaction,
appendedTransactions: appendedTransactions.slice(1),
});
}
// a helper method that can enable plugins before the view has been initialized
// currently only used for testing
forceEnablePlugins() {
if (this.view) {
throw new Error(
"forcePluginsEnabled called after view has been initialized",
);
}
this._state = this.state.reconfigure({
plugins: this.extensionManager.plugins,
});
}
/**
* Replace the default `createView` method with a custom one - which we call on mount
*/
private createViewAlternative(
blockNoteEditor: BlockNoteEditor<any, any, any>,
contentComponent?: any,
) {
(this as any).contentComponent = contentComponent;
const markViews: any = {};
this.extensionManager.extensions.forEach((extension) => {
if (extension.type === "mark" && extension.config.addMarkView) {
// Note: migrate to using `addMarkView` from tiptap as soon as this lands
// (currently tiptap doesn't support markviews)
markViews[extension.name] =
extension.config.addMarkView(blockNoteEditor);
}
});
this.view = new EditorView(
{ mount: this.options.element as any }, // use mount option so that we reuse the existing element instead of creating a new one
{
...this.options.editorProps,
// @ts-ignore
dispatchTransaction: this.dispatch.bind(this),
state: this.state,
markViews,
nodeViews: this.extensionManager.nodeViews,
},
);
// `editor.view` is not yet available at this time.
// Therefore we will add all plugins directly afterwards.
//
// To research: this is the default tiptap behavior, but might actually not be necessary
// it feels like it's a workaround for plugins that don't account for the view not being available yet
const newState = this.state.reconfigure({
plugins: this.extensionManager.plugins,
});
this.view.updateState(newState);
// emit the created event, call here manually because we blocked the default call in the constructor
// (https://github.com/ueberdosis/tiptap/blob/45bac803283446795ad1b03f43d3746fa54a68ff/packages/core/src/Editor.ts#L117)
this.commands.focus(
this.options.autofocus ||
this.options.element.getAttribute("data-bn-autofocus") === "true",
{ scrollIntoView: false },
);
this.emit("create", { editor: this });
this.isInitialized = true;
}
/**
* Mounts / unmounts the editor to a dom element
*
* @param element DOM element to mount to, ur null / undefined to destroy
*/
public mount = (
blockNoteEditor: BlockNoteEditor<any, any, any>,
element?: HTMLElement | null,
contentComponent?: any,
) => {
if (!element) {
this.destroy();
} else {
this.options.element = element;
this.createViewAlternative(blockNoteEditor, contentComponent);
}
};
}
(BlockNoteTipTapEditor.prototype as any).createView = function () {
// no-op
// Disable default call to `createView` in the Editor constructor.
// We should call `createView` manually only when a DOM element is available
// additional fix because onPaste and onDrop depend on installing plugins in constructor which we don't support
// (note: can probably be removed after tiptap upgrade fixed in 2.8.0)
this.options.onPaste = this.options.onDrop = undefined;
};
declare module "@tiptap/core" {
interface EditorEvents {
/**
* This is a custom event that will be emitted in Tiptap V3.
* We use it to provide the appendedTransactions, until Tiptap V3 is released.
*/
"v3-update": {
editor: Editor;
transaction: Transaction;
appendedTransactions: Transaction[];
};
}
}