@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
695 lines (615 loc) • 21.7 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 PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP = 0.1;
function getBlockFromCoords(
view: EditorView,
coords: { left: number; top: number },
sideMenuDetection: "viewport" | "editor",
adjustForColumns = true,
) {
const elements = view.root.elementsFromPoint(
// bit hacky - offset x position to right to account for the width of sidemenu itself
coords.left + (sideMenuDetection === "editor" ? 50 : 0),
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,
{
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,
},
sideMenuDetection,
false,
);
}
}
return getDraggableBlockFromElement(element, view);
}
return undefined;
}
function getBlockFromMousePos(
mousePos: {
x: number;
y: number;
},
view: EditorView,
sideMenuDetection: "viewport" | "editor",
): { 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();
// this.horizontalPosAnchor = editorBoundingBox.x;
// Gets block at mouse cursor's position.
const coords = {
left: mousePos.x,
top: mousePos.y,
};
const mouseLeftOfEditor = coords.left < editorBoundingBox.left;
const mouseRightOfEditor = coords.left > editorBoundingBox.right;
// Clamps the x position to the editor's bounding box.
if (sideMenuDetection === "viewport") {
if (mouseLeftOfEditor) {
coords.left = editorBoundingBox.left + 10;
}
if (mouseRightOfEditor) {
coords.left = editorBoundingBox.right - 10;
}
}
let block = getBlockFromCoords(view, coords, sideMenuDetection);
if (!mouseRightOfEditor && block) {
// note: this case is not necessary when we're on the right side of the editor
/* Now, because blocks can be nested
| BlockA |
x | BlockB y|
hovering over position x (the "margin of block B") will return block A instead of block B.
to fix this, we get the block from the right side of block A (position y, which will fall in BlockB correctly)
*/
const rect = block.node.getBoundingClientRect();
coords.left = rect.right - 10;
block = getBlockFromCoords(view, coords, "viewport", false);
}
return block;
}
/**
* 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 sideMenuDetection: "viewport" | "editor",
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 block = getBlockFromMousePos(
this.mousePos,
this.pmView,
this.sideMenuDetection,
);
// 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;
// Gets the block's content node, which lets to ignore child blocks when determining the block menu's position.
// TODO: needed?
const blockContent = block.node.firstChild as HTMLElement;
if (!blockContent) {
return;
}
// TODO: needed?
// Shows or updates elements.
if (this.editor.isEditable) {
const blockContentBoundingBox = blockContent.getBoundingClientRect();
const column = block.node.closest("[data-node-type=column]");
this.updateState({
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")!,
)!,
});
}
};
onDrop = (event: DragEvent) => {
// Content from outside a BlockNote editor is being dropped - just let
// ProseMirror's default behaviour handle it.
if (this.pmView.dragging === null) {
return;
}
this.editor._tiptapEditor.commands.blur();
// Finds the BlockNote editor element that the drop event occurred in (if
// any).
const parentEditorElement =
event.target instanceof Node
? (event.target instanceof HTMLElement
? event.target
: event.target.parentElement
)?.closest(".bn-editor") || null
: null;
// Drop event occurred within an editor.
if (parentEditorElement) {
// When ProseMirror handles a drop event on the editor while
// `view.dragging` is set, it deletes the selected content. However, if
// a block from a different editor is being dropped, this causes some
// issues that the code below fixes:
if (!this.isDragOrigin && this.pmView.dom === parentEditorElement) {
// 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.to,
),
),
);
} else if (this.isDragOrigin && this.pmView.dom !== parentEditorElement) {
// 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,
);
}
}
if (
this.sideMenuDetection === "editor" ||
(event as any).synthetic ||
!event.dataTransfer?.types.includes("blocknote/html")
) {
return;
}
const pos = this.pmView.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!pos || pos.inside === -1) {
/**
* When `this.sideMenuSelection === "viewport"`, if the event is outside the
* editor contents, we dispatch a fake event, so that we can still drop the
* content when dragging / dropping to the side of the editor
*/
const evt = this.createSyntheticEvent(event);
// console.log("dispatch fake drop");
this.pmView.dom.dispatchEvent(evt);
}
};
onDragEnd = () => {
// 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;
};
/**
* 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) {
throw new Error("New drag was started while an existing drag is ongoing");
}
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,
};
};
/**
* If the event is outside the editor contents,
* we dispatch a fake event, so that we can still drop the content
* when dragging / dropping to the side of the editor
*/
onDragOver = (event: DragEvent) => {
if (
this.sideMenuDetection === "editor" ||
(event as any).synthetic ||
!event.dataTransfer?.types.includes("blocknote/html")
) {
return;
}
const pos = this.pmView.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!pos || (pos.inside === -1 && this.pmView.dom.firstChild)) {
const evt = this.createSyntheticEvent(event);
// console.log("dispatch fake dragover");
this.pmView.dom.dispatchEvent(evt);
}
};
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 createSyntheticEvent(event: DragEvent) {
const evt = new Event(event.type, event) as any;
const editorBoundingBox = (
this.pmView.dom.firstChild as HTMLElement
).getBoundingClientRect();
evt.clientX = event.clientX;
evt.clientY = event.clientY;
if (
event.clientX < editorBoundingBox.left &&
event.clientX >
editorBoundingBox.left -
editorBoundingBox.width *
PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP
) {
// when we're slightly left of the editor, we can drop to the side of the block
evt.clientX =
editorBoundingBox.left +
(editorBoundingBox.width *
PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP) /
2;
} else if (
event.clientX > editorBoundingBox.right &&
event.clientX <
editorBoundingBox.right +
editorBoundingBox.width *
PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP
) {
// when we're slightly right of the editor, we can drop to the side of the block
evt.clientX =
editorBoundingBox.right -
(editorBoundingBox.width *
PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP) /
2;
} else if (
event.clientX < editorBoundingBox.left ||
event.clientX > editorBoundingBox.right
) {
// when mouse is outside of the editor on x axis, drop it somewhere safe (but not to the side of a block)
evt.clientX =
editorBoundingBox.left +
PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP *
editorBoundingBox.width *
2; // put it somewhere in first block, but safe outside of the PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP margin
}
evt.clientY = Math.min(
Math.max(event.clientY, editorBoundingBox.top),
editorBoundingBox.top + editorBoundingBox.height,
);
evt.dataTransfer = event.dataTransfer;
evt.preventDefault = () => event.preventDefault();
evt.synthetic = true; // prevent recursion
return evt;
}
onScroll = () => {
if (this.state?.show) {
this.state.referencePos = this.hoveredBlock!.getBoundingClientRect();
this.emitUpdate(this.state);
}
};
// 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>,
sideMenuDetection: "viewport" | "editor",
) {
super();
this.addProsemirrorPlugin(
new Plugin({
key: sideMenuPluginKey,
view: (editorView) => {
this.view = new SideMenuView(
editor,
sideMenuDetection,
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!);
};
}