@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
251 lines (225 loc) • 7.83 kB
text/typescript
import { Command, Transaction } from "prosemirror-state";
import type { YUndoExtension } from "../../extensions/Collaboration/YUndo.js";
import type { HistoryExtension } from "../../extensions/History/History.js";
import { BlockNoteEditor } from "../BlockNoteEditor.js";
export class StateManager {
constructor(private editor: BlockNoteEditor<any, any, any>) {}
/**
* Stores the currently active transaction, which is the accumulated transaction from all {@link dispatch} calls during a {@link transact} calls
*/
private activeTransaction: Transaction | null = null;
/**
* For any command that can be executed, you can check if it can be executed by calling `editor.can(command)`.
* @example
* ```ts
* if (editor.can(editor.undo)) {
* // show button
* } else {
* // hide button
* }
*/
public can(cb: () => boolean) {
try {
this.isInCan = true;
return cb();
} finally {
this.isInCan = false;
}
}
// Flag to indicate if we're in a `can` call
private isInCan = false;
/**
* Execute a prosemirror command. This is mostly for backwards compatibility with older code.
*
* @note You should prefer the {@link transact} method when possible, as it will automatically handle the dispatching of the transaction and work across blocknote transactions.
*
* @example
* ```ts
* editor.exec((state, dispatch, view) => {
* dispatch(state.tr.insertText("Hello, world!"));
* });
* ```
*/
public exec(command: Command) {
if (this.activeTransaction) {
throw new Error(
"`exec` should not be called within a `transact` call, move the `exec` call outside of the `transact` call",
);
}
if (this.isInCan) {
return this.canExec(command);
}
const state = this.prosemirrorState;
const view = this.prosemirrorView;
const dispatch = (tr: Transaction) => this.prosemirrorView.dispatch(tr);
return command(state, dispatch, view);
}
/**
* Check if a command can be executed. A command should return `false` if it is not valid in the current state.
*
* @example
* ```ts
* if (editor.canExec(command)) {
* // show button
* } else {
* // hide button
* }
* ```
*/
public canExec(command: Command): boolean {
if (this.activeTransaction) {
throw new Error(
"`canExec` should not be called within a `transact` call, move the `canExec` call outside of the `transact` call",
);
}
const state = this.prosemirrorState;
const view = this.prosemirrorView;
return command(state, undefined, view);
}
/**
* Execute a function within a "blocknote transaction".
* All changes to the editor within the transaction will be grouped together, so that
* we can dispatch them as a single operation (thus creating only a single undo step)
*
* @note There is no need to dispatch the transaction, as it will be automatically dispatched when the callback is complete.
*
* @example
* ```ts
* // All changes to the editor will be grouped together
* editor.transact((tr) => {
* tr.insertText("Hello, world!");
* // These two operations will be grouped together in a single undo step
* editor.transact((tr) => {
* tr.insertText("Hello, world!");
* });
* });
* ```
*/
public transact<T>(
callback: (
/**
* The current active transaction, this will automatically be dispatched to the editor when the callback is complete
* If another `transact` call is made within the callback, it will be passed the same transaction as the parent call.
*/
tr: Transaction,
) => T,
): T {
if (this.activeTransaction) {
// Already in a transaction, so we can just callback immediately
return callback(this.activeTransaction);
}
try {
// Enter transaction mode, by setting a starting transaction
this.activeTransaction = this.editor._tiptapEditor.state.tr;
// Capture all dispatch'd transactions
const result = callback(this.activeTransaction);
// Any transactions captured by the `dispatch` call will be stored in `this.activeTransaction`
const activeTr = this.activeTransaction;
this.activeTransaction = null;
if (
activeTr &&
// Only dispatch if the transaction was actually modified in some way
(activeTr.docChanged ||
activeTr.selectionSet ||
activeTr.scrolledIntoView ||
activeTr.storedMarksSet ||
!activeTr.isGeneric)
) {
// Dispatch the transaction if it was modified
this.prosemirrorView.dispatch(activeTr);
}
return result;
} finally {
// We wrap this in a finally block to ensure we don't disable future transactions just because of an error in the callback
this.activeTransaction = null;
}
}
/**
* Get the underlying prosemirror state
* @note Prefer using `editor.transact` to read the current editor state, as that will ensure the state is up to date
* @see https://prosemirror.net/docs/ref/#state.EditorState
*/
public get prosemirrorState() {
if (this.activeTransaction) {
throw new Error(
"`prosemirrorState` should not be called within a `transact` call, move the `prosemirrorState` call outside of the `transact` call or use `editor.transact` to read the current editor state",
);
}
return this.editor._tiptapEditor.state;
}
/**
* Get the underlying prosemirror view
* @see https://prosemirror.net/docs/ref/#view.EditorView
*/
public get prosemirrorView() {
return this.editor._tiptapEditor.view;
}
public isFocused() {
return this.prosemirrorView?.hasFocus() || false;
}
public focus() {
this.prosemirrorView?.focus();
}
/**
* Checks if the editor is currently editable, or if it's locked.
* @returns True if the editor is editable, false otherwise.
*/
public get isEditable(): boolean {
if (!this.editor._tiptapEditor) {
if (!this.editor.headless) {
throw new Error("no editor, but also not headless?");
}
return false;
}
return this.editor._tiptapEditor.isEditable === undefined
? true
: this.editor._tiptapEditor.isEditable;
}
/**
* Makes the editor editable or locks it, depending on the argument passed.
* @param editable True to make the editor editable, or false to lock it.
*/
public set isEditable(editable: boolean) {
if (!this.editor._tiptapEditor) {
if (!this.editor.headless) {
throw new Error("no editor, but also not headless?");
}
// not relevant on headless
return;
}
if (this.editor._tiptapEditor.options.editable !== editable) {
this.editor._tiptapEditor.setEditable(editable);
}
}
/**
* Undo the last action.
*/
public undo(): boolean {
// Purposefully not using the UndoPlugin to not import y-prosemirror when not needed
const undoPlugin = this.editor.getExtension<typeof YUndoExtension>("yUndo");
if (undoPlugin) {
return this.exec(undoPlugin.undoCommand);
}
const historyPlugin =
this.editor.getExtension<typeof HistoryExtension>("history");
if (historyPlugin) {
return this.exec(historyPlugin.undoCommand);
}
throw new Error("No undo plugin found");
}
/**
* Redo the last action.
*/
public redo() {
const undoPlugin = this.editor.getExtension<typeof YUndoExtension>("yUndo");
if (undoPlugin) {
return this.exec(undoPlugin.redoCommand);
}
const historyPlugin =
this.editor.getExtension<typeof HistoryExtension>("history");
if (historyPlugin) {
return this.exec(historyPlugin.redoCommand);
}
throw new Error("No redo plugin found");
}
}