@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
767 lines (679 loc) • 24.6 kB
text/typescript
import { DOMParser, Slice } from "@tiptap/pm/model";
import {
EditorState,
Plugin,
PluginKey,
PluginView,
TextSelection,
} from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
import { Block } from "../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js";
import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js";
import {
BlockSchema,
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
import { initializeESMDependencies } from "../../util/esmDependencies.js";
import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
import { dragStart, unsetDragImage } from "./dragging.js";
export type SideMenuState<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
> = UiElementPosition & {
// The block that the side menu is attached to.
block: Block<BSchema, I, S>;
};
const DISTANCE_TO_CONSIDER_EDITOR_BOUNDS = 250;
function getBlockFromCoords(
view: EditorView,
coords: { left: number; top: number },
adjustForColumns = true,
) {
const elements = view.root.elementsFromPoint(coords.left, coords.top);
for (const element of elements) {
if (!view.dom.contains(element)) {
// probably a ui overlay like formatting toolbar etc
continue;
}
if (adjustForColumns) {
const column = element.closest("[data-node-type=columnList]");
if (column) {
return getBlockFromCoords(
view,
{
// TODO can we do better than this?
left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself
top: coords.top,
},
false,
);
}
}
return getDraggableBlockFromElement(element, view);
}
return undefined;
}
function getBlockFromMousePos(
mousePos: {
x: number;
y: number;
},
view: EditorView,
): { node: HTMLElement; id: string } | undefined {
// Editor itself may have padding or other styling which affects
// size/position, so we get the boundingRect of the first child (i.e. the
// blockGroup that wraps all blocks in the editor) for more accurate side
// menu placement.
if (!view.dom.firstChild) {
return;
}
const editorBoundingBox = (
view.dom.firstChild as HTMLElement
).getBoundingClientRect();
// Gets block at mouse cursor's position.
const coords = {
// Clamps the x position to the editor's bounding box.
left: Math.min(
Math.max(editorBoundingBox.left + 10, mousePos.x),
editorBoundingBox.right - 10,
),
top: mousePos.y,
};
const referenceBlock = getBlockFromCoords(view, coords);
if (!referenceBlock) {
// could not find the reference block
return undefined;
}
/**
* Because blocks may be nested, we need to check the right edge of the parent block:
* ```
* | BlockA |
* x | BlockB y|
* ```
* Hovering at position x (left edge of BlockB) would return BlockA.
* Instead, we check at position y (right edge of BlockA) to correctly identify BlockB.
*/
const referenceBlocksBoundingBox =
referenceBlock.node.getBoundingClientRect();
return getBlockFromCoords(
view,
{
left: referenceBlocksBoundingBox.right - 10,
top: mousePos.y,
},
false,
);
}
/**
* With the sidemenu plugin we can position a menu next to a hovered block.
*/
export class SideMenuView<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
> implements PluginView
{
public state?: SideMenuState<BSchema, I, S>;
public readonly emitUpdate: (state: SideMenuState<BSchema, I, S>) => void;
private mousePos: { x: number; y: number } | undefined;
private hoveredBlock: HTMLElement | undefined;
public menuFrozen = false;
public isDragOrigin = false;
constructor(
private readonly editor: BlockNoteEditor<BSchema, I, S>,
private readonly pmView: EditorView,
emitUpdate: (state: SideMenuState<BSchema, I, S>) => void,
) {
this.emitUpdate = () => {
if (!this.state) {
throw new Error("Attempting to update uninitialized side menu");
}
emitUpdate(this.state);
};
this.pmView.root.addEventListener(
"dragstart",
this.onDragStart as EventListener,
);
this.pmView.root.addEventListener(
"dragover",
this.onDragOver as EventListener,
);
this.pmView.root.addEventListener(
"drop",
this.onDrop as EventListener,
true,
);
this.pmView.root.addEventListener(
"dragend",
this.onDragEnd as EventListener,
true,
);
initializeESMDependencies();
// Shows or updates menu position whenever the cursor moves, if the menu isn't frozen.
this.pmView.root.addEventListener(
"mousemove",
this.onMouseMove as EventListener,
true,
);
// Hides and unfreezes the menu whenever the user presses a key.
this.pmView.root.addEventListener(
"keydown",
this.onKeyDown as EventListener,
true,
);
// Setting capture=true ensures that any parent container of the editor that
// gets scrolled will trigger the scroll event. Scroll events do not bubble
// and so won't propagate to the document by default.
pmView.root.addEventListener("scroll", this.onScroll, true);
}
updateState = (state: SideMenuState<BSchema, I, S>) => {
this.state = state;
this.emitUpdate(this.state);
};
updateStateFromMousePos = () => {
if (this.menuFrozen || !this.mousePos) {
return;
}
const closestEditor = this.findClosestEditorElement({
clientX: this.mousePos.x,
clientY: this.mousePos.y,
});
if (
closestEditor?.element !== this.pmView.dom ||
closestEditor.distance > DISTANCE_TO_CONSIDER_EDITOR_BOUNDS
) {
if (this.state?.show) {
this.state.show = false;
this.updateState(this.state);
}
return;
}
const block = getBlockFromMousePos(this.mousePos, this.pmView);
// Closes the menu if the mouse cursor is beyond the editor vertically.
if (!block || !this.editor.isEditable) {
if (this.state?.show) {
this.state.show = false;
this.updateState(this.state);
}
return;
}
// Doesn't update if the menu is already open and the mouse cursor is still hovering the same block.
if (
this.state?.show &&
this.hoveredBlock?.hasAttribute("data-id") &&
this.hoveredBlock?.getAttribute("data-id") === block.id
) {
return;
}
this.hoveredBlock = block.node;
// Shows or updates elements.
if (this.editor.isEditable) {
const blockContentBoundingBox = block.node.getBoundingClientRect();
const column = block.node.closest("[data-node-type=column]");
this.state = {
show: true,
referencePos: new DOMRect(
column
? // We take the first child as column elements have some default
// padding. This is a little weird since this child element will
// be the first block, but since it's always non-nested and we
// only take the x coordinate, it's ok.
column.firstElementChild!.getBoundingClientRect().x
: (
this.pmView.dom.firstChild as HTMLElement
).getBoundingClientRect().x,
blockContentBoundingBox.y,
blockContentBoundingBox.width,
blockContentBoundingBox.height,
),
block: this.editor.getBlock(
this.hoveredBlock!.getAttribute("data-id")!,
)!,
};
this.updateState(this.state);
}
};
/**
* If a block is being dragged, ProseMirror usually gets the context of what's
* being dragged from `view.dragging`, which is automatically set when a
* `dragstart` event fires in the editor. However, if the user tries to drag
* and drop blocks between multiple editors, only the one in which the drag
* began has that context, so we need to set it on the others manually. This
* ensures that PM always drops the blocks in between other blocks, and not
* inside them.
*
* After the `dragstart` event fires on the drag handle, it sets
* `blocknote/html` data on the clipboard. This handler fires right after,
* parsing the `blocknote/html` data into nodes and setting them on
* `view.dragging`.
*
* Note: Setting `view.dragging` on `dragover` would be better as the user
* could then drag between editors in different windows, but you can only
* access `dataTransfer` contents on `dragstart` and `drop` events.
*/
onDragStart = (event: DragEvent) => {
const html = event.dataTransfer?.getData("blocknote/html");
if (!html) {
return;
}
if (this.pmView.dragging) {
// already dragging, so no-op
return;
}
const element = document.createElement("div");
element.innerHTML = html;
const parser = DOMParser.fromSchema(this.pmView.state.schema);
const node = parser.parse(element, {
topNode: this.pmView.state.schema.nodes["blockGroup"].create(),
});
this.pmView.dragging = {
slice: new Slice(node.content, 0, 0),
move: true,
};
};
/**
* Finds the closest editor visually to the given coordinates
*/
private findClosestEditorElement = (coords: {
clientX: number;
clientY: number;
}) => {
// Get all editor elements in the document
const editors = Array.from(this.pmView.root.querySelectorAll(".bn-editor"));
if (editors.length === 0) {
return null;
}
// Find the editor with the smallest distance to the coordinates
let closestEditor = editors[0];
let minDistance = Number.MAX_VALUE;
editors.forEach((editor) => {
const rect = editor
.querySelector(".bn-block-group")!
.getBoundingClientRect();
const distanceX =
coords.clientX < rect.left
? rect.left - coords.clientX
: coords.clientX > rect.right
? coords.clientX - rect.right
: 0;
const distanceY =
coords.clientY < rect.top
? rect.top - coords.clientY
: coords.clientY > rect.bottom
? coords.clientY - rect.bottom
: 0;
const distance = Math.sqrt(
Math.pow(distanceX, 2) + Math.pow(distanceY, 2),
);
if (distance < minDistance) {
minDistance = distance;
closestEditor = editor;
}
});
return {
element: closestEditor,
distance: minDistance,
};
};
/**
* This dragover event handler listens at the document level,
* and is trying to handle dragover events for all editors.
*
* It specifically is trying to handle the following cases:
* - If the dragover event is within the bounds of any editor, then it does nothing
* - If the dragover event is outside the bounds of any editor, but close enough (within DISTANCE_TO_CONSIDER_EDITOR_BOUNDS) to the closest editor,
* then it dispatches a synthetic dragover event to the closest editor (which will trigger the drop-cursor to be shown on that editor)
* - If the dragover event is outside the bounds of the current editor, then it will dispatch a synthetic dragleave event to the current editor
* (which will trigger the drop-cursor to be removed from the current editor)
*
* The synthetic event is a necessary evil because we do not control prosemirror-dropcursor to be able to show the drop-cursor within the range we want
*/
onDragOver = (event: DragEvent) => {
if ((event as any).synthetic) {
return;
}
const dragEventContext = this.getDragEventContext(event);
if (!dragEventContext || !dragEventContext.isDropPoint) {
// This is not a drag event that we are interested in
// so, we close the drop-cursor
this.closeDropCursor();
return;
}
if (
dragEventContext.isDropPoint &&
!dragEventContext.isDropWithinEditorBounds
) {
// we are the drop point, but the drag over event is not within the bounds of this editor instance
// so, we need to dispatch an event that is in the bounds of this editor instance
this.dispatchSyntheticEvent(event);
}
};
/**
* Closes the drop-cursor for the current editor
*/
private closeDropCursor = () => {
const evt = new Event("dragleave", { bubbles: false });
// It needs to be synthetic, so we don't accidentally think it is a real dragend event
(evt as any).synthetic = true;
// We dispatch the event to the current editor, so that the drop-cursor is removed for it
this.pmView.dom.dispatchEvent(evt);
};
/**
* It is surprisingly difficult to determine the information we need to know about a drag event
*
* This function is trying to determine the following:
* - Whether the current editor instance is the drop point
* - Whether the current editor instance is the drag origin
* - Whether the drop event is within the bounds of the current editor instance
*/
getDragEventContext = (event: DragEvent) => {
// We need to check if there is text content that is being dragged (select some text & just drag it)
const textContentIsBeingDragged =
!event.dataTransfer?.types.includes("blocknote/html") &&
!!this.pmView.dragging;
// This is the side menu drag from this plugin
const sideMenuIsBeingDragged = !!this.isDragOrigin;
// Tells us that the current editor instance has a drag ongoing (either text or side menu)
const isDragOrigin = textContentIsBeingDragged || sideMenuIsBeingDragged;
// Tells us which editor instance is the closest to the drag event (whether or not it is actually reasonably close)
const closestEditor = this.findClosestEditorElement(event);
// We arbitrarily decide how far is "too far" from the closest editor to be considered a drop point
if (
!closestEditor ||
closestEditor.distance > DISTANCE_TO_CONSIDER_EDITOR_BOUNDS
) {
// we are too far from the closest editor, or no editor was found
return undefined;
}
// We check if the closest editor is the same as the current editor instance (which is the drop point)
const isDropPoint = closestEditor.element === this.pmView.dom;
// We check if the current editor instance is the same as the editor instance that the drag event is happening within
const isDropWithinEditorBounds =
isDropPoint && closestEditor.distance === 0;
// We never want to handle drop events that are not related to us
if (!isDropPoint && !isDragOrigin) {
// we are not the drop point or drag origin, so not relevant to us
return undefined;
}
return {
isDropPoint,
isDropWithinEditorBounds,
isDragOrigin,
};
};
/**
* The drop event handler listens at the document level,
* and handles drop events for all editors.
*
* It specifically handles the following cases:
* - If we are both the drag origin and drop point:
* - Let normal drop handling take over
* - If we are the drop point but not the drag origin:
* - Collapse selection to prevent PM from deleting unrelated content
* - If drop event is outside our editor bounds, dispatch synthetic drop event to our editor
* - If we are the drag origin but not the drop point:
* - Delete the dragged content from our editor after a delay
*/
onDrop = (event: DragEvent) => {
if ((event as any).synthetic) {
return;
}
const context = this.getDragEventContext(event);
if (!context) {
this.closeDropCursor();
// This is not a drag event that we are interested in
return;
}
const { isDropPoint, isDropWithinEditorBounds, isDragOrigin } = context;
if (!isDropWithinEditorBounds && isDropPoint) {
// Any time that the drop event is outside of the editor bounds (but still close to an editor instance)
// We dispatch a synthetic event that is in the bounds of the editor instance, to have the correct drop point
this.dispatchSyntheticEvent(event);
}
if (isDropPoint) {
// The current instance is the drop point
if (this.pmView.dragging) {
// Do not collapse selection when text content is being dragged
return;
}
// Because the editor selection is unrelated to the dragged content, we
// don't want PM to delete its content. Therefore, we collapse the
// selection.
this.pmView.dispatch(
this.pmView.state.tr.setSelection(
TextSelection.create(
this.pmView.state.tr.doc,
this.pmView.state.tr.selection.anchor,
),
),
);
return;
} else if (isDragOrigin) {
// The current instance is the drag origin, but not the drop point
// our content got dropped somewhere else
// Because the editor from which the block originates doesn't get a drop
// event on it, PM doesn't delete its selected content. Therefore, we
// need to do so manually.
//
// Note: Deleting the selected content from the editor from which the
// block originates, may change its height. This can cause the position of
// the editor in which the block is being dropping to shift, before it
// can handle the drop event. That in turn can cause the drop to happen
// somewhere other than the user intended. To get around this, we delay
// deleting the selected content until all editors have had the chance to
// handle the event.
setTimeout(
() => this.pmView.dispatch(this.pmView.state.tr.deleteSelection()),
0,
);
return;
}
};
onDragEnd = (event: DragEvent) => {
if ((event as any).synthetic) {
return;
}
// When the user starts dragging a block, `view.dragging` is set on all
// BlockNote editors. However, when the drag ends, only the editor that the
// drag originated in automatically clears `view.dragging`. Therefore, we
// have to manually clear it on all editors.
this.pmView.dragging = null;
};
onKeyDown = (_event: KeyboardEvent) => {
if (this.state?.show && this.editor.isFocused()) {
// Typing in editor should hide side menu
this.state.show = false;
this.emitUpdate(this.state);
}
};
onMouseMove = (event: MouseEvent) => {
if (this.menuFrozen) {
return;
}
this.mousePos = { x: event.clientX, y: event.clientY };
// We want the full area of the editor to check if the cursor is hovering
// above it though.
const editorOuterBoundingBox = this.pmView.dom.getBoundingClientRect();
const cursorWithinEditor =
this.mousePos.x > editorOuterBoundingBox.left &&
this.mousePos.x < editorOuterBoundingBox.right &&
this.mousePos.y > editorOuterBoundingBox.top &&
this.mousePos.y < editorOuterBoundingBox.bottom;
// TODO: remove parentElement, but then we need to remove padding from boundingbox or find a different solution
const editorWrapper = this.pmView.dom!.parentElement!;
// Doesn't update if the mouse hovers an element that's over the editor but
// isn't a part of it or the side menu.
if (
// Cursor is within the editor area
cursorWithinEditor &&
// An element is hovered
event &&
event.target &&
// Element is outside the editor
!(
editorWrapper === event.target ||
editorWrapper.contains(event.target as HTMLElement)
)
) {
if (this.state?.show) {
this.state.show = false;
this.emitUpdate(this.state);
}
return;
}
this.updateStateFromMousePos();
};
private dispatchSyntheticEvent(event: DragEvent) {
const evt = new Event(event.type as "dragover", event) as any;
const dropPointBoundingBox = (
this.pmView.dom.firstChild as HTMLElement
).getBoundingClientRect();
evt.clientX = event.clientX;
evt.clientY = event.clientY;
evt.clientX = Math.min(
Math.max(event.clientX, dropPointBoundingBox.left),
dropPointBoundingBox.left + dropPointBoundingBox.width,
);
evt.clientY = Math.min(
Math.max(event.clientY, dropPointBoundingBox.top),
dropPointBoundingBox.top + dropPointBoundingBox.height,
);
evt.dataTransfer = event.dataTransfer;
evt.preventDefault = () => event.preventDefault();
evt.synthetic = true; // prevent recursion
this.pmView.dom.dispatchEvent(evt);
}
onScroll = () => {
if (this.state?.show) {
this.state.referencePos = this.hoveredBlock!.getBoundingClientRect();
this.emitUpdate(this.state);
}
this.updateStateFromMousePos();
};
// Needed in cases where the editor state updates without the mouse cursor
// moving, as some state updates can require a side menu update. For example,
// adding a button to the side menu which removes the block can cause the
// block below to jump up into the place of the removed block when clicked,
// allowing the user to click the button again without moving the cursor. This
// would otherwise not update the side menu, and so clicking the button again
// would attempt to remove the same block again, causing an error.
update(_view: EditorView, prevState: EditorState) {
const docChanged = !prevState.doc.eq(this.pmView.state.doc);
if (docChanged && this.state?.show) {
this.updateStateFromMousePos();
}
}
destroy() {
if (this.state?.show) {
this.state.show = false;
this.emitUpdate(this.state);
}
this.pmView.root.removeEventListener(
"mousemove",
this.onMouseMove as EventListener,
true,
);
this.pmView.root.removeEventListener(
"dragstart",
this.onDragStart as EventListener,
);
this.pmView.root.removeEventListener(
"dragover",
this.onDragOver as EventListener,
);
this.pmView.root.removeEventListener(
"drop",
this.onDrop as EventListener,
true,
);
this.pmView.root.removeEventListener(
"dragend",
this.onDragEnd as EventListener,
true,
);
this.pmView.root.removeEventListener(
"keydown",
this.onKeyDown as EventListener,
true,
);
this.pmView.root.removeEventListener("scroll", this.onScroll, true);
}
}
export const sideMenuPluginKey = new PluginKey("SideMenuPlugin");
export class SideMenuProsemirrorPlugin<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
> extends BlockNoteExtension {
public static key() {
return "sideMenu";
}
public view: SideMenuView<BSchema, I, S> | undefined;
constructor(private readonly editor: BlockNoteEditor<BSchema, I, S>) {
super();
this.addProsemirrorPlugin(
new Plugin({
key: sideMenuPluginKey,
view: (editorView) => {
this.view = new SideMenuView(editor, editorView, (state) => {
this.emit("update", state);
});
return this.view;
},
}),
);
}
public onUpdate(callback: (state: SideMenuState<BSchema, I, S>) => void) {
return this.on("update", callback);
}
/**
* Handles drag & drop events for blocks.
*/
blockDragStart = (
event: {
dataTransfer: DataTransfer | null;
clientY: number;
},
block: Block<BSchema, I, S>,
) => {
if (this.view) {
this.view.isDragOrigin = true;
}
dragStart(event, block, this.editor);
};
/**
* Handles drag & drop events for blocks.
*/
blockDragEnd = () => {
if (this.editor.prosemirrorView) {
unsetDragImage(this.editor.prosemirrorView.root);
}
if (this.view) {
this.view.isDragOrigin = false;
}
};
/**
* Freezes the side menu. When frozen, the side menu will stay
* attached to the same block regardless of which block is hovered by the
* mouse cursor.
*/
freezeMenu = () => {
this.view!.menuFrozen = true;
this.view!.state!.show = true;
this.view!.emitUpdate(this.view!.state!);
};
/**
* Unfreezes the side menu. When frozen, the side menu will stay
* attached to the same block regardless of which block is hovered by the
* mouse cursor.
*/
unfreezeMenu = () => {
this.view!.menuFrozen = false;
this.view!.state!.show = false;
this.view!.emitUpdate(this.view!.state!);
};
}