Skip to content
464 changes: 63 additions & 401 deletions packages/core/src/extensions/SideMenu/SideMenuPlugin.ts

Large diffs are not rendered by default.

251 changes: 251 additions & 0 deletions packages/core/src/extensions/SideMenu/dragging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { Node } from "prosemirror-model";
import { NodeSelection, Selection } from "prosemirror-state";
import * as pmView from "prosemirror-view";
import { EditorView } from "prosemirror-view";

import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter.js";
import { cleanHTMLToMarkdown } from "../../api/exporters/markdown/markdownExporter.js";
import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js";
import { Block } from "../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js";
import {
BlockSchema,
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
import { MultipleNodeSelection } from "./MultipleNodeSelection.js";

let dragImageElement: Element | undefined;

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>;
};

export function getDraggableBlockFromElement(
element: Element,
view: EditorView
) {
while (
element &&
element.parentElement &&
element.parentElement !== view.dom &&
element.getAttribute?.("data-node-type") !== "blockContainer"
) {
element = element.parentElement;
}
if (element.getAttribute?.("data-node-type") !== "blockContainer") {
return undefined;
}
return { node: element as HTMLElement, id: element.getAttribute("data-id")! };
}

function blockPositionFromElement(element: Element, view: EditorView) {
const block = getDraggableBlockFromElement(element, view);

if (block && block.node.nodeType === 1) {
// TODO: this uses undocumented PM APIs? do we need this / let's add docs?
const docView = (view as any).docView;
const desc = docView.nearestDesc(block.node, true);
if (!desc || desc === docView) {
return null;
}
return desc.posBefore;
}
return null;
}

function blockPositionsFromSelection(selection: Selection, doc: Node) {
// Absolute positions just before the first block spanned by the selection, and just after the last block. Having the
// selection start and end just before and just after the target blocks ensures no whitespace/line breaks are left
// behind after dragging & dropping them.
let beforeFirstBlockPos: number;
let afterLastBlockPos: number;

// Even the user starts dragging blocks but drops them in the same place, the selection will still be moved just
// before & just after the blocks spanned by the selection, and therefore doesn't need to change if they try to drag
// the same blocks again. If this happens, the anchor & head move out of the block content node they were originally
// in. If the anchor should update but the head shouldn't and vice versa, it means the user selection is outside a
// block content node, which should never happen.
const selectionStartInBlockContent =
doc.resolve(selection.from).node().type.spec.group === "blockContent";
const selectionEndInBlockContent =
doc.resolve(selection.to).node().type.spec.group === "blockContent";

// Ensures that entire outermost nodes are selected if the selection spans multiple nesting levels.
const minDepth = Math.min(selection.$anchor.depth, selection.$head.depth);

if (selectionStartInBlockContent && selectionEndInBlockContent) {
// Absolute positions at the start of the first block in the selection and at the end of the last block. User
// selections will always start and end in block content nodes, but we want the start and end positions of their
// parent block nodes, which is why minDepth - 1 is used.
const startFirstBlockPos = selection.$from.start(minDepth - 1);
const endLastBlockPos = selection.$to.end(minDepth - 1);

// Shifting start and end positions by one moves them just outside the first and last selected blocks.
beforeFirstBlockPos = doc.resolve(startFirstBlockPos - 1).pos;
afterLastBlockPos = doc.resolve(endLastBlockPos + 1).pos;
} else {
beforeFirstBlockPos = selection.from;
afterLastBlockPos = selection.to;
}

return { from: beforeFirstBlockPos, to: afterLastBlockPos };
}

function setDragImage(view: EditorView, from: number, to = from) {
if (from === to) {
// Moves to position to be just after the first (and only) selected block.
to += view.state.doc.resolve(from + 1).node().nodeSize;
}

// Parent element is cloned to remove all unselected children without affecting the editor content.
const parentClone = view.domAtPos(from).node.cloneNode(true) as Element;
const parent = view.domAtPos(from).node as Element;

const getElementIndex = (parentElement: Element, targetElement: Element) =>
Array.prototype.indexOf.call(parentElement.children, targetElement);

const firstSelectedBlockIndex = getElementIndex(
parent,
// Expects from position to be just before the first selected block.
view.domAtPos(from + 1).node.parentElement!
);
const lastSelectedBlockIndex = getElementIndex(
parent,
// Expects to position to be just after the last selected block.
view.domAtPos(to - 1).node.parentElement!
);

for (let i = parent.childElementCount - 1; i >= 0; i--) {
if (i > lastSelectedBlockIndex || i < firstSelectedBlockIndex) {
parentClone.removeChild(parentClone.children[i]);
}
}

// dataTransfer.setDragImage(element) only works if element is attached to the DOM.
unsetDragImage(view.root);
dragImageElement = parentClone;

// TODO: This is hacky, need a better way of assigning classes to the editor so that they can also be applied to the
// drag preview.
const classes = view.dom.className.split(" ");
const inheritedClasses = classes
.filter(
(className) =>
className !== "ProseMirror" &&
className !== "bn-root" &&
className !== "bn-editor"
)
.join(" ");

dragImageElement.className =
dragImageElement.className + " bn-drag-preview " + inheritedClasses;

if (view.root instanceof ShadowRoot) {
view.root.appendChild(dragImageElement);
} else {
view.root.body.appendChild(dragImageElement);
}
}

export function unsetDragImage(rootEl: Document | ShadowRoot) {
if (dragImageElement !== undefined) {
if (rootEl instanceof ShadowRoot) {
rootEl.removeChild(dragImageElement);
} else {
rootEl.body.removeChild(dragImageElement);
}

dragImageElement = undefined;
}
}

export function dragStart<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema
>(
e: { dataTransfer: DataTransfer | null; clientY: number },
editor: BlockNoteEditor<BSchema, I, S>
) {
if (!e.dataTransfer) {
return;
}

const view = editor.prosemirrorView;

const editorBoundingBox = view.dom.getBoundingClientRect();

const coords = {
left: editorBoundingBox.left + editorBoundingBox.width / 2, // take middle of editor
top: e.clientY,
};

const elements = view.root.elementsFromPoint(coords.left, coords.top);
let blockEl = undefined;

for (const element of elements) {
if (view.dom.contains(element)) {
blockEl = getDraggableBlockFromElement(element, view);
break;
}
}

if (!blockEl) {
return;
}

const pos = blockPositionFromElement(blockEl.node, view);
if (pos != null) {
const selection = view.state.selection;
const doc = view.state.doc;

const { from, to } = blockPositionsFromSelection(selection, doc);

const draggedBlockInSelection = from <= pos && pos < to;
const multipleBlocksSelected =
selection.$anchor.node() !== selection.$head.node() ||
selection instanceof MultipleNodeSelection;

if (draggedBlockInSelection && multipleBlocksSelected) {
view.dispatch(
view.state.tr.setSelection(MultipleNodeSelection.create(doc, from, to))
);
setDragImage(view, from, to);
} else {
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos))
);
setDragImage(view, pos);
}

const selectedSlice = view.state.selection.content();
const schema = editor.pmSchema;

const clipboardHTML = (pmView as any).__serializeForClipboard(
view,
selectedSlice
).dom.innerHTML;

const externalHTMLExporter = createExternalHTMLExporter(schema, editor);

const blocks = fragmentToBlocks(selectedSlice.content, editor.schema);
const externalHTML = externalHTMLExporter.exportBlocks(blocks, {});

const plainText = cleanHTMLToMarkdown(externalHTML);

e.dataTransfer.clearData();
e.dataTransfer.setData("blocknote/html", clipboardHTML);
e.dataTransfer.setData("text/html", externalHTML);
e.dataTransfer.setData("text/plain", plainText);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(dragImageElement!, 0, 0);
view.dragging = { slice: selectedSlice, move: true };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
StyleSchema,
} from "../../schema/index.js";
import { EventEmitter } from "../../util/EventEmitter.js";
import { getDraggableBlockFromElement } from "../SideMenu/SideMenuPlugin.js";
import { getDraggableBlockFromElement } from "../SideMenu/dragging.js";

let dragImageElement: HTMLElement | undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
} from "@blocknote/core";
import { AiOutlinePlus } from "react-icons/ai";

import { useCallback } from "react";
import { useComponentsContext } from "../../../editor/ComponentsContext.js";
import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js";
import { useDictionary } from "../../../i18n/dictionary.js";
import { SideMenuProps } from "../SideMenuProps.js";

Expand All @@ -17,21 +19,40 @@ export const AddBlockButton = <
I extends InlineContentSchema = DefaultInlineContentSchema,
S extends StyleSchema = DefaultStyleSchema
>(
props: Pick<SideMenuProps<BSchema, I, S>, "addBlock">
props: Pick<SideMenuProps<BSchema, I, S>, "block">
) => {
const Components = useComponentsContext()!;
const dict = useDictionary();

const editor = useBlockNoteEditor<BSchema, I, S>();

const onClick = useCallback(() => {
const blockContent = props.block.content;
const isBlockEmpty =
blockContent !== undefined &&
Array.isArray(blockContent) &&
blockContent.length === 0;

if (isBlockEmpty) {
editor.setTextCursorPosition(props.block);
editor.openSuggestionMenu("/");
} else {
const insertedBlock = editor.insertBlocks(
[{ type: "paragraph" }],
props.block,
"after"
)[0];
editor.setTextCursorPosition(insertedBlock);
editor.openSuggestionMenu("/");
}
}, [editor, props.block]);

return (
<Components.SideMenu.Button
className={"bn-button"}
label={dict.side_menu.add_block_label}
icon={
<AiOutlinePlus
size={24}
onClick={props.addBlock}
data-test="dragHandleAdd"
/>
<AiOutlinePlus size={24} onClick={onClick} data-test="dragHandleAdd" />
}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ export const DragHandleButton = <
props.freezeMenu();
} else {
props.unfreezeMenu();
// TODO
props.editor.focus();
}
}}
position={"left"}>
Expand Down
6 changes: 2 additions & 4 deletions packages/react/src/components/SideMenu/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ export const SideMenu = <
) => {
const Components = useComponentsContext()!;

const { addBlock, ...rest } = props;

const dataAttributes = useMemo(() => {
const attrs: Record<string, string> = {
"data-block-type": props.block.type,
Expand All @@ -57,8 +55,8 @@ export const SideMenu = <
<Components.SideMenu.Root className={"bn-side-menu"} {...dataAttributes}>
{props.children || (
<>
<AddBlockButton addBlock={addBlock} />
<DragHandleButton {...rest} />
<AddBlockButton {...props} />
<DragHandleButton {...props} />
</>
)}
</Components.SideMenu.Root>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export const SideMenuController = <
const editor = useBlockNoteEditor<BSchema, I, S>();

const callbacks = {
addBlock: editor.sideMenu.addBlock,
blockDragStart: editor.sideMenu.blockDragStart,
blockDragEnd: editor.sideMenu.blockDragEnd,
freezeMenu: editor.sideMenu.freezeMenu,
Expand Down
6 changes: 1 addition & 5 deletions packages/react/src/components/SideMenu/SideMenuProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,5 @@ export type SideMenuProps<
} & Omit<SideMenuState<BSchema, I, S>, keyof UiElementPosition> &
Pick<
BlockNoteEditor<BSchema, I, S>["sideMenu"],
| "addBlock"
| "blockDragStart"
| "blockDragEnd"
| "freezeMenu"
| "unfreezeMenu"
"blockDragStart" | "blockDragEnd" | "freezeMenu" | "unfreezeMenu"
>;
Loading