Skip to content

Commit 13755aa

Browse files
refactor: clean sidemenuplugin (#1132)
* clean sidemenu * Removed redundant listeners * fix * Cleanup * remove isDragging * Rewrote add block button * Cleaned `AddBlockButton` * merge --------- Co-authored-by: yousefed <[email protected]>
1 parent ecddc65 commit 13755aa

File tree

8 files changed

+345
-420
lines changed

8 files changed

+345
-420
lines changed

packages/core/src/extensions/SideMenu/SideMenuPlugin.ts

Lines changed: 63 additions & 401 deletions
Large diffs are not rendered by default.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { Node } from "prosemirror-model";
2+
import { NodeSelection, Selection } from "prosemirror-state";
3+
import * as pmView from "prosemirror-view";
4+
import { EditorView } from "prosemirror-view";
5+
6+
import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter.js";
7+
import { cleanHTMLToMarkdown } from "../../api/exporters/markdown/markdownExporter.js";
8+
import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js";
9+
import { Block } from "../../blocks/defaultBlocks.js";
10+
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
11+
import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js";
12+
import {
13+
BlockSchema,
14+
InlineContentSchema,
15+
StyleSchema,
16+
} from "../../schema/index.js";
17+
import { MultipleNodeSelection } from "./MultipleNodeSelection.js";
18+
19+
let dragImageElement: Element | undefined;
20+
21+
export type SideMenuState<
22+
BSchema extends BlockSchema,
23+
I extends InlineContentSchema,
24+
S extends StyleSchema
25+
> = UiElementPosition & {
26+
// The block that the side menu is attached to.
27+
block: Block<BSchema, I, S>;
28+
};
29+
30+
export function getDraggableBlockFromElement(
31+
element: Element,
32+
view: EditorView
33+
) {
34+
while (
35+
element &&
36+
element.parentElement &&
37+
element.parentElement !== view.dom &&
38+
element.getAttribute?.("data-node-type") !== "blockContainer"
39+
) {
40+
element = element.parentElement;
41+
}
42+
if (element.getAttribute?.("data-node-type") !== "blockContainer") {
43+
return undefined;
44+
}
45+
return { node: element as HTMLElement, id: element.getAttribute("data-id")! };
46+
}
47+
48+
function blockPositionFromElement(element: Element, view: EditorView) {
49+
const block = getDraggableBlockFromElement(element, view);
50+
51+
if (block && block.node.nodeType === 1) {
52+
// TODO: this uses undocumented PM APIs? do we need this / let's add docs?
53+
const docView = (view as any).docView;
54+
const desc = docView.nearestDesc(block.node, true);
55+
if (!desc || desc === docView) {
56+
return null;
57+
}
58+
return desc.posBefore;
59+
}
60+
return null;
61+
}
62+
63+
function blockPositionsFromSelection(selection: Selection, doc: Node) {
64+
// Absolute positions just before the first block spanned by the selection, and just after the last block. Having the
65+
// selection start and end just before and just after the target blocks ensures no whitespace/line breaks are left
66+
// behind after dragging & dropping them.
67+
let beforeFirstBlockPos: number;
68+
let afterLastBlockPos: number;
69+
70+
// Even the user starts dragging blocks but drops them in the same place, the selection will still be moved just
71+
// before & just after the blocks spanned by the selection, and therefore doesn't need to change if they try to drag
72+
// the same blocks again. If this happens, the anchor & head move out of the block content node they were originally
73+
// in. If the anchor should update but the head shouldn't and vice versa, it means the user selection is outside a
74+
// block content node, which should never happen.
75+
const selectionStartInBlockContent =
76+
doc.resolve(selection.from).node().type.spec.group === "blockContent";
77+
const selectionEndInBlockContent =
78+
doc.resolve(selection.to).node().type.spec.group === "blockContent";
79+
80+
// Ensures that entire outermost nodes are selected if the selection spans multiple nesting levels.
81+
const minDepth = Math.min(selection.$anchor.depth, selection.$head.depth);
82+
83+
if (selectionStartInBlockContent && selectionEndInBlockContent) {
84+
// Absolute positions at the start of the first block in the selection and at the end of the last block. User
85+
// selections will always start and end in block content nodes, but we want the start and end positions of their
86+
// parent block nodes, which is why minDepth - 1 is used.
87+
const startFirstBlockPos = selection.$from.start(minDepth - 1);
88+
const endLastBlockPos = selection.$to.end(minDepth - 1);
89+
90+
// Shifting start and end positions by one moves them just outside the first and last selected blocks.
91+
beforeFirstBlockPos = doc.resolve(startFirstBlockPos - 1).pos;
92+
afterLastBlockPos = doc.resolve(endLastBlockPos + 1).pos;
93+
} else {
94+
beforeFirstBlockPos = selection.from;
95+
afterLastBlockPos = selection.to;
96+
}
97+
98+
return { from: beforeFirstBlockPos, to: afterLastBlockPos };
99+
}
100+
101+
function setDragImage(view: EditorView, from: number, to = from) {
102+
if (from === to) {
103+
// Moves to position to be just after the first (and only) selected block.
104+
to += view.state.doc.resolve(from + 1).node().nodeSize;
105+
}
106+
107+
// Parent element is cloned to remove all unselected children without affecting the editor content.
108+
const parentClone = view.domAtPos(from).node.cloneNode(true) as Element;
109+
const parent = view.domAtPos(from).node as Element;
110+
111+
const getElementIndex = (parentElement: Element, targetElement: Element) =>
112+
Array.prototype.indexOf.call(parentElement.children, targetElement);
113+
114+
const firstSelectedBlockIndex = getElementIndex(
115+
parent,
116+
// Expects from position to be just before the first selected block.
117+
view.domAtPos(from + 1).node.parentElement!
118+
);
119+
const lastSelectedBlockIndex = getElementIndex(
120+
parent,
121+
// Expects to position to be just after the last selected block.
122+
view.domAtPos(to - 1).node.parentElement!
123+
);
124+
125+
for (let i = parent.childElementCount - 1; i >= 0; i--) {
126+
if (i > lastSelectedBlockIndex || i < firstSelectedBlockIndex) {
127+
parentClone.removeChild(parentClone.children[i]);
128+
}
129+
}
130+
131+
// dataTransfer.setDragImage(element) only works if element is attached to the DOM.
132+
unsetDragImage(view.root);
133+
dragImageElement = parentClone;
134+
135+
// TODO: This is hacky, need a better way of assigning classes to the editor so that they can also be applied to the
136+
// drag preview.
137+
const classes = view.dom.className.split(" ");
138+
const inheritedClasses = classes
139+
.filter(
140+
(className) =>
141+
className !== "ProseMirror" &&
142+
className !== "bn-root" &&
143+
className !== "bn-editor"
144+
)
145+
.join(" ");
146+
147+
dragImageElement.className =
148+
dragImageElement.className + " bn-drag-preview " + inheritedClasses;
149+
150+
if (view.root instanceof ShadowRoot) {
151+
view.root.appendChild(dragImageElement);
152+
} else {
153+
view.root.body.appendChild(dragImageElement);
154+
}
155+
}
156+
157+
export function unsetDragImage(rootEl: Document | ShadowRoot) {
158+
if (dragImageElement !== undefined) {
159+
if (rootEl instanceof ShadowRoot) {
160+
rootEl.removeChild(dragImageElement);
161+
} else {
162+
rootEl.body.removeChild(dragImageElement);
163+
}
164+
165+
dragImageElement = undefined;
166+
}
167+
}
168+
169+
export function dragStart<
170+
BSchema extends BlockSchema,
171+
I extends InlineContentSchema,
172+
S extends StyleSchema
173+
>(
174+
e: { dataTransfer: DataTransfer | null; clientY: number },
175+
editor: BlockNoteEditor<BSchema, I, S>
176+
) {
177+
if (!e.dataTransfer) {
178+
return;
179+
}
180+
181+
const view = editor.prosemirrorView;
182+
183+
const editorBoundingBox = view.dom.getBoundingClientRect();
184+
185+
const coords = {
186+
left: editorBoundingBox.left + editorBoundingBox.width / 2, // take middle of editor
187+
top: e.clientY,
188+
};
189+
190+
const elements = view.root.elementsFromPoint(coords.left, coords.top);
191+
let blockEl = undefined;
192+
193+
for (const element of elements) {
194+
if (view.dom.contains(element)) {
195+
blockEl = getDraggableBlockFromElement(element, view);
196+
break;
197+
}
198+
}
199+
200+
if (!blockEl) {
201+
return;
202+
}
203+
204+
const pos = blockPositionFromElement(blockEl.node, view);
205+
if (pos != null) {
206+
const selection = view.state.selection;
207+
const doc = view.state.doc;
208+
209+
const { from, to } = blockPositionsFromSelection(selection, doc);
210+
211+
const draggedBlockInSelection = from <= pos && pos < to;
212+
const multipleBlocksSelected =
213+
selection.$anchor.node() !== selection.$head.node() ||
214+
selection instanceof MultipleNodeSelection;
215+
216+
if (draggedBlockInSelection && multipleBlocksSelected) {
217+
view.dispatch(
218+
view.state.tr.setSelection(MultipleNodeSelection.create(doc, from, to))
219+
);
220+
setDragImage(view, from, to);
221+
} else {
222+
view.dispatch(
223+
view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos))
224+
);
225+
setDragImage(view, pos);
226+
}
227+
228+
const selectedSlice = view.state.selection.content();
229+
const schema = editor.pmSchema;
230+
231+
const clipboardHTML = (pmView as any).__serializeForClipboard(
232+
view,
233+
selectedSlice
234+
).dom.innerHTML;
235+
236+
const externalHTMLExporter = createExternalHTMLExporter(schema, editor);
237+
238+
const blocks = fragmentToBlocks(selectedSlice.content, editor.schema);
239+
const externalHTML = externalHTMLExporter.exportBlocks(blocks, {});
240+
241+
const plainText = cleanHTMLToMarkdown(externalHTML);
242+
243+
e.dataTransfer.clearData();
244+
e.dataTransfer.setData("blocknote/html", clipboardHTML);
245+
e.dataTransfer.setData("text/html", externalHTML);
246+
e.dataTransfer.setData("text/plain", plainText);
247+
e.dataTransfer.effectAllowed = "move";
248+
e.dataTransfer.setDragImage(dragImageElement!, 0, 0);
249+
view.dragging = { slice: selectedSlice, move: true };
250+
}
251+
}

packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
StyleSchema,
1212
} from "../../schema/index.js";
1313
import { EventEmitter } from "../../util/EventEmitter.js";
14-
import { getDraggableBlockFromElement } from "../SideMenu/SideMenuPlugin.js";
14+
import { getDraggableBlockFromElement } from "../SideMenu/dragging.js";
1515

1616
let dragImageElement: HTMLElement | undefined;
1717

packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
} from "@blocknote/core";
99
import { AiOutlinePlus } from "react-icons/ai";
1010

11+
import { useCallback } from "react";
1112
import { useComponentsContext } from "../../../editor/ComponentsContext.js";
13+
import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js";
1214
import { useDictionary } from "../../../i18n/dictionary.js";
1315
import { SideMenuProps } from "../SideMenuProps.js";
1416

@@ -17,21 +19,40 @@ export const AddBlockButton = <
1719
I extends InlineContentSchema = DefaultInlineContentSchema,
1820
S extends StyleSchema = DefaultStyleSchema
1921
>(
20-
props: Pick<SideMenuProps<BSchema, I, S>, "addBlock">
22+
props: Pick<SideMenuProps<BSchema, I, S>, "block">
2123
) => {
2224
const Components = useComponentsContext()!;
2325
const dict = useDictionary();
2426

27+
const editor = useBlockNoteEditor<BSchema, I, S>();
28+
29+
const onClick = useCallback(() => {
30+
const blockContent = props.block.content;
31+
const isBlockEmpty =
32+
blockContent !== undefined &&
33+
Array.isArray(blockContent) &&
34+
blockContent.length === 0;
35+
36+
if (isBlockEmpty) {
37+
editor.setTextCursorPosition(props.block);
38+
editor.openSuggestionMenu("/");
39+
} else {
40+
const insertedBlock = editor.insertBlocks(
41+
[{ type: "paragraph" }],
42+
props.block,
43+
"after"
44+
)[0];
45+
editor.setTextCursorPosition(insertedBlock);
46+
editor.openSuggestionMenu("/");
47+
}
48+
}, [editor, props.block]);
49+
2550
return (
2651
<Components.SideMenu.Button
2752
className={"bn-button"}
2853
label={dict.side_menu.add_block_label}
2954
icon={
30-
<AiOutlinePlus
31-
size={24}
32-
onClick={props.addBlock}
33-
data-test="dragHandleAdd"
34-
/>
55+
<AiOutlinePlus size={24} onClick={onClick} data-test="dragHandleAdd" />
3556
}
3657
/>
3758
);

packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@ export const DragHandleButton = <
3232
props.freezeMenu();
3333
} else {
3434
props.unfreezeMenu();
35-
// TODO
36-
props.editor.focus();
3735
}
3836
}}
3937
position={"left"}>

packages/react/src/components/SideMenu/SideMenu.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ export const SideMenu = <
3131
) => {
3232
const Components = useComponentsContext()!;
3333

34-
const { addBlock, ...rest } = props;
35-
3634
const dataAttributes = useMemo(() => {
3735
const attrs: Record<string, string> = {
3836
"data-block-type": props.block.type,
@@ -57,8 +55,8 @@ export const SideMenu = <
5755
<Components.SideMenu.Root className={"bn-side-menu"} {...dataAttributes}>
5856
{props.children || (
5957
<>
60-
<AddBlockButton addBlock={addBlock} />
61-
<DragHandleButton {...rest} />
58+
<AddBlockButton {...props} />
59+
<DragHandleButton {...props} />
6260
</>
6361
)}
6462
</Components.SideMenu.Root>

packages/react/src/components/SideMenu/SideMenuController.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ export const SideMenuController = <
2424
const editor = useBlockNoteEditor<BSchema, I, S>();
2525

2626
const callbacks = {
27-
addBlock: editor.sideMenu.addBlock,
2827
blockDragStart: editor.sideMenu.blockDragStart,
2928
blockDragEnd: editor.sideMenu.blockDragEnd,
3029
freezeMenu: editor.sideMenu.freezeMenu,

packages/react/src/components/SideMenu/SideMenuProps.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,5 @@ export type SideMenuProps<
2323
} & Omit<SideMenuState<BSchema, I, S>, keyof UiElementPosition> &
2424
Pick<
2525
BlockNoteEditor<BSchema, I, S>["sideMenu"],
26-
| "addBlock"
27-
| "blockDragStart"
28-
| "blockDragEnd"
29-
| "freezeMenu"
30-
| "unfreezeMenu"
26+
"blockDragStart" | "blockDragEnd" | "freezeMenu" | "unfreezeMenu"
3127
>;

0 commit comments

Comments
 (0)