diff --git a/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx b/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx
index eeb8e38887..155318652d 100644
--- a/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx
+++ b/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx
@@ -38,7 +38,7 @@ const CustomFormattingToolbar = () => {
// Sets the items in the Block Type Select.
blockTypeSelectItems={[
// Gets the default Block Type Select items.
- ...blockTypeSelectItems(editor.dictionary),
+ ...blockTypeSelectItems(editor),
// Adds an item for the Alert block.
{
name: "Alert",
diff --git a/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx b/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx
index 7a1b7d6b6d..b4833393a2 100644
--- a/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx
+++ b/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx
@@ -115,8 +115,8 @@ function MUIBlockTypeSelect() {
// Gets the default items for the select.
const defaultBlockTypeSelectItems = useMemo(
- () => blockTypeSelectItems(editor.dictionary),
- [editor.dictionary],
+ () => blockTypeSelectItems(editor),
+ [editor],
);
// Gets the selected item.
diff --git a/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx b/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx
index 931c0e1169..409d7a1e97 100644
--- a/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx
+++ b/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx
@@ -42,7 +42,7 @@ const CustomFormattingToolbar = () => {
// Sets the items in the Block Type Select.
blockTypeSelectItems={[
// Gets the default Block Type Select items.
- ...blockTypeSelectItems(editor.dictionary),
+ ...blockTypeSelectItems(editor),
// Adds an item for the Alert block.
{
name: "Alert",
diff --git a/examples/06-custom-schema/09-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx
index 554b3402bb..def4ecf3b1 100644
--- a/examples/06-custom-schema/09-math-block/src/App.tsx
+++ b/examples/06-custom-schema/09-math-block/src/App.tsx
@@ -84,7 +84,7 @@ export default function App() {
formattingToolbar={() => (
diff --git a/examples/06-custom-schema/10-diagram-block/src/App.tsx b/examples/06-custom-schema/10-diagram-block/src/App.tsx
index c96af84261..57998b3f8f 100644
--- a/examples/06-custom-schema/10-diagram-block/src/App.tsx
+++ b/examples/06-custom-schema/10-diagram-block/src/App.tsx
@@ -70,7 +70,7 @@ export default function App() {
formattingToolbar={() => (
diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts
index 4e84967d9e..3aaad271d1 100644
--- a/packages/core/src/blocks/Heading/block.ts
+++ b/packages/core/src/blocks/Heading/block.ts
@@ -73,6 +73,9 @@ export const createHeadingBlockSpec = createBlockSpec(
({ allowToggleHeadings = true }: HeadingOptions = {}) => ({
meta: {
isolating: false,
+ // A block dragged onto a toggle heading becomes its first child.
+ dropsIntoChildren: (block) =>
+ allowToggleHeadings && block.props.isToggleable === true,
},
// A toggle heading resets to a regular heading, which in turn resets to a
// paragraph. While a toggle heading is open, Enter in its text starts its
diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts
index bc4c0a8d95..cda0d7e88f 100644
--- a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts
+++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts
@@ -43,6 +43,8 @@ export const createToggleListItemBlockSpec = createBlockSpec(
},
meta: {
isolating: false,
+ // A block dragged onto the toggle becomes its first child.
+ dropsIntoChildren: () => true,
},
parse(element) {
if (element.tagName === "DETAILS") {
diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts
index 0907ea8a34..f77b837eee 100644
--- a/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts
+++ b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts
@@ -107,14 +107,13 @@ export function createToggleFrame(
dom,
slot,
// Keeps the frame, and so its open state, when the block changes. Adding
- // a child opens the toggle, and removing the last one closes it.
+ // a child opens the toggle. Removing the last child keeps it open, showing
+ // the "Add block" button, as in Notion.
update(updated: Block) {
const newChildCount = updated.children.length;
const wasOpen = open;
if (newChildCount > childCount) {
open = true;
- } else if (newChildCount === 0 && childCount > 0) {
- open = false;
}
if (open !== wasOpen) {
toggledState.set(updated, open);
diff --git a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts
index 2b15ae1bfa..b8d932d189 100644
--- a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts
+++ b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts
@@ -1,3 +1,4 @@
+import { Fragment, Slice } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";
@@ -144,6 +145,52 @@ async function open(id: string) {
await userEvent.click(toggleButton(id)!);
}
+/**
+ * Drags block `id` over the center of `target`, as the side menu starts a
+ * block drag. The events are synthetic: an emulated mouse drag does not reach
+ * every target reliably. `drop()` drops the block there.
+ */
+function dragBlockOver(id: string, target: Element) {
+ const view = editor.prosemirrorView;
+ const { node } = getNodeById(id, view.state.doc)!;
+ view.dragging = { slice: new Slice(Fragment.from(node), 0, 0), move: true };
+ // The side menu also puts the blocks in the drag data, which marks the drop
+ // as a block drop for the editor's drop handlers.
+ const dataTransfer = new DataTransfer();
+ dataTransfer.setData("blocknote/html", "");
+ const rect = target.getBoundingClientRect();
+ const init: DragEventInit = {
+ bubbles: true,
+ cancelable: true,
+ clientX: rect.x + rect.width / 2,
+ clientY: rect.y + rect.height / 2,
+ dataTransfer,
+ };
+ const element = document.elementFromPoint(init.clientX!, init.clientY!)!;
+ element.dispatchEvent(new DragEvent("dragenter", init));
+ element.dispatchEvent(new DragEvent("dragover", init));
+ return {
+ // At the element under the point at drop time, as the browser does.
+ drop: () =>
+ document
+ .elementFromPoint(init.clientX!, init.clientY!)!
+ .dispatchEvent(new DragEvent("drop", init)),
+ };
+}
+
+function dropCursor() {
+ return document.querySelector('[class*="prosemirror-dropcursor"]');
+}
+
+/** The id of the block highlighted as the one a drop goes into. */
+function highlightedDropTarget() {
+ return (
+ root
+ .querySelector('[data-drop-target="true"] > .bn-block')
+ ?.getAttribute("data-id") ?? null
+ );
+}
+
/** Puts the caret in the block and presses the keys. */
async function press(
keys: string,
@@ -271,7 +318,7 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => {
});
// Notion keeps the toggle open, showing its empty-toggle placeholder.
- it.fails("stays open, as an empty toggle, when its last child is removed", async () => {
+ it("stays open, as an empty toggle, when its last child is removed", async () => {
mount([toggle("t", "Title", [{ id: "c1", type: "paragraph" }])]);
await open("t");
@@ -293,6 +340,23 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => {
});
});
+ describe("layout", () => {
+ // The children span the frame below the title. When they fell into the
+ // chevron's grid column instead, they widened it and pushed the title far
+ // to the right.
+ it("keeps the title next to the chevron and the children below it", async () => {
+ mount(withChildren());
+ await open("t");
+
+ const frame = own("t", ".bn-toggle-frame")!.getBoundingClientRect();
+ const title = own("t", ".bn-inline-content")!.getBoundingClientRect();
+ const group = own("t", ".bn-block-group")!.getBoundingClientRect();
+ expect(title.left - frame.left).toBeLessThan(40);
+ expect(group.top).toBeGreaterThanOrEqual(title.bottom - 1);
+ expect(group.left).toBeLessThanOrEqual(title.left);
+ });
+ });
+
describe("empty toggle", () => {
it('shows an "Add block" button when open, which adds a child and puts the caret in it', async () => {
mount([toggle("t", "Title")]);
@@ -521,7 +585,7 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => {
expect(childrenAreVisible("t")).toBe(true);
});
- it.fails("keeps a toggle open when its last child is moved out", async () => {
+ it("keeps a toggle open when its last child is moved out", async () => {
mount([
toggle("t", "Title", [{ id: "c1", type: "paragraph", content: "One" }]),
]);
@@ -578,6 +642,95 @@ describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => {
});
});
+ // A block dragged onto a toggle becomes its first child, as in Notion. For
+ // an empty toggle, this is the only way to drop a block into it (BLO-956).
+ describe("drop onto the toggle", () => {
+ it("onto the title: shows the place above the first child, and drops it there", async () => {
+ mount(withChildren());
+ await open("t");
+
+ const drag = dragBlockOver("after", own("t", ".bn-inline-content")!);
+
+ const cursor = dropCursor()!.getBoundingClientRect();
+ expect(
+ Math.abs(cursor.top - blockElement("c1").getBoundingClientRect().top),
+ ).toBeLessThan(6);
+ expect(highlightedDropTarget()).toBe("t");
+
+ drag.drop();
+
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "after",
+ "c1",
+ "c2",
+ ]);
+ await expect.poll(dropCursor).toBeNull();
+ expect(highlightedDropTarget()).toBeNull();
+ });
+
+ it("onto the chevron of a closed toggle: drops it as the first child, and opens the toggle", async () => {
+ mount(withChildren());
+
+ dragBlockOver("after", toggleButton("t")!).drop();
+
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "after",
+ "c1",
+ "c2",
+ ]);
+ expect(isOpen("t")).toBe(true);
+ });
+
+ it("onto 'Add block' of an empty toggle: shows the place below the title, and drops it there (BLO-956)", async () => {
+ mount([
+ toggle("t", "Title"),
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ await open("t");
+
+ const drag = dragBlockOver("after", addBlockButton("t")!);
+
+ const cursor = dropCursor()!.getBoundingClientRect();
+ const title = own("t", ".bn-block-content")!.getBoundingClientRect();
+ expect(Math.abs(cursor.top - title.bottom)).toBeLessThan(6);
+ expect(highlightedDropTarget()).toBe("t");
+
+ drag.drop();
+
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "after",
+ ]);
+ });
+
+ it("drops it as usual when the toggle is removed during the drag", async () => {
+ mount(withChildren());
+ await open("t");
+
+ const drag = dragBlockOver("after", own("t", ".bn-inline-content")!);
+ expect(highlightedDropTarget()).toBe("t");
+ // E.g. a collaborator removes the toggle.
+ editor.removeBlocks(["t"]);
+ drag.drop();
+
+ expect(editor.getBlock("t")).toBeUndefined();
+ expect(editor.getBlock("after")).toBeDefined();
+ expect(highlightedDropTarget()).toBeNull();
+ });
+
+ it("onto a child: drops it between the children, as usual", async () => {
+ mount(withChildren());
+ await open("t");
+
+ const drag = dragBlockOver("after", own("c2", ".bn-inline-content")!);
+ expect(highlightedDropTarget()).toBeNull();
+ drag.drop();
+
+ const children = editor.getBlock("t")!.children.map((child) => child.id);
+ expect(children).toHaveLength(3);
+ expect(children[0]).toBe("c1");
+ });
+ });
+
describe("indentation", () => {
it("Shift-Tab moves a child out of the toggle", async () => {
mount(withChildren());
diff --git a/packages/core/src/blocks/defaultBlockTypeItems.test.ts b/packages/core/src/blocks/defaultBlockTypeItems.test.ts
new file mode 100644
index 0000000000..97a4f23c93
--- /dev/null
+++ b/packages/core/src/blocks/defaultBlockTypeItems.test.ts
@@ -0,0 +1,70 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from "vite-plus/test";
+
+import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
+import { BlockNoteSchema } from "./BlockNoteSchema.js";
+import { createHeadingBlockSpec } from "./Heading/block.js";
+import { defaultBlockSpecs } from "./defaultBlocks.js";
+import { getDefaultBlockTypeItems } from "./defaultBlockTypeItems.js";
+
+/** The items for a schema whose heading is `heading`. */
+function items(heading = createHeadingBlockSpec()) {
+ const schema = BlockNoteSchema.create({
+ blockSpecs: { ...defaultBlockSpecs, heading },
+ });
+ // Only the schema is read.
+ return getDefaultBlockTypeItems({ schema } as unknown as BlockNoteEditor);
+}
+
+describe("getDefaultBlockTypeItems", () => {
+ it("offers every default block type, in the block type select's order", () => {
+ expect(items().map((item) => item.key)).toEqual([
+ "paragraph",
+ "heading",
+ "heading_2",
+ "heading_3",
+ "heading_4",
+ "heading_5",
+ "heading_6",
+ "toggle_heading",
+ "toggle_heading_2",
+ "toggle_heading_3",
+ "quote",
+ "toggle_list",
+ "bullet_list",
+ "numbered_list",
+ "check_list",
+ ]);
+ });
+
+ it("makes a regular heading turn a toggle heading into a regular one (BLO-959)", () => {
+ expect(items().find((item) => item.key === "heading_2")?.props).toEqual({
+ level: 2,
+ isToggleable: false,
+ });
+ });
+
+ it("offers regular headings when toggle headings are disabled (BLO-1236)", () => {
+ const offered = items(
+ createHeadingBlockSpec({ allowToggleHeadings: false }),
+ );
+ expect(offered.filter((item) => item.type === "heading")).toEqual([
+ { key: "heading", type: "heading", props: { level: 1 } },
+ { key: "heading_2", type: "heading", props: { level: 2 } },
+ { key: "heading_3", type: "heading", props: { level: 3 } },
+ { key: "heading_4", type: "heading", props: { level: 4 } },
+ { key: "heading_5", type: "heading", props: { level: 5 } },
+ { key: "heading_6", type: "heading", props: { level: 6 } },
+ ]);
+ });
+
+ it("offers only the configured heading levels (BLO-990)", () => {
+ expect(
+ items(createHeadingBlockSpec({ levels: [2, 4] }))
+ .filter((item) => item.type === "heading")
+ .map((item) => item.key),
+ ).toEqual(["heading_2", "heading_4", "toggle_heading_2"]);
+ });
+});
diff --git a/packages/core/src/blocks/defaultBlockTypeItems.ts b/packages/core/src/blocks/defaultBlockTypeItems.ts
new file mode 100644
index 0000000000..6d5e1b3996
--- /dev/null
+++ b/packages/core/src/blocks/defaultBlockTypeItems.ts
@@ -0,0 +1,99 @@
+import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
+import { editorHasBlockWithType } from "./defaultBlockTypeGuards.js";
+
+const HEADING_KEYS = {
+ 1: "heading",
+ 2: "heading_2",
+ 3: "heading_3",
+ 4: "heading_4",
+ 5: "heading_5",
+ 6: "heading_6",
+} as const;
+
+const TOGGLE_HEADING_KEYS = {
+ 1: "toggle_heading",
+ 2: "toggle_heading_2",
+ 3: "toggle_heading_3",
+} as const;
+
+/**
+ * A block type that the default menus (the slash menu and the block type
+ * select) offer, with the props that choosing it sets.
+ */
+export type DefaultBlockTypeItem = {
+ /** The item's key in `dictionary.slash_menu`, which both menus share. */
+ key:
+ | (typeof HEADING_KEYS)[keyof typeof HEADING_KEYS]
+ | (typeof TOGGLE_HEADING_KEYS)[keyof typeof TOGGLE_HEADING_KEYS]
+ | "paragraph"
+ | "quote"
+ | "toggle_list"
+ | "bullet_list"
+ | "numbered_list"
+ | "check_list";
+ type: string;
+ props?: Record;
+};
+
+/**
+ * The block types that the editor's schema supports, out of the ones that the
+ * default menus offer: paragraph, headings, toggle headings, quote and lists.
+ * Each menu places them in its own order, by key; this list has the block type
+ * select's order.
+ *
+ * Headings are offered for the heading's configured `levels` (BLO-990).
+ * Toggle headings are offered only when the heading has `isToggleable`, and a
+ * regular heading then sets `isToggleable: false`, so that choosing it turns a
+ * toggle heading into a regular one (BLO-959, BLO-1236).
+ */
+export function getDefaultBlockTypeItems(
+ editor: BlockNoteEditor,
+): DefaultBlockTypeItem[] {
+ const items: DefaultBlockTypeItem[] = [];
+
+ if (editorHasBlockWithType(editor, "paragraph")) {
+ items.push({ key: "paragraph", type: "paragraph" });
+ }
+
+ if (editorHasBlockWithType(editor, "heading", { level: "number" })) {
+ const levels = editor.schema.blockSchema.heading.propSchema.level.values;
+ const hasToggles = editorHasBlockWithType(editor, "heading", {
+ level: "number",
+ isToggleable: "boolean",
+ });
+ for (const level of [1, 2, 3, 4, 5, 6] as const) {
+ if (levels?.includes(level)) {
+ items.push({
+ key: HEADING_KEYS[level],
+ type: "heading",
+ props: hasToggles ? { level, isToggleable: false } : { level },
+ });
+ }
+ }
+ if (hasToggles) {
+ for (const level of [1, 2, 3] as const) {
+ if (levels?.includes(level)) {
+ items.push({
+ key: TOGGLE_HEADING_KEYS[level],
+ type: "heading",
+ props: { level, isToggleable: true },
+ });
+ }
+ }
+ }
+ }
+
+ for (const [key, type] of [
+ ["quote", "quote"],
+ ["toggle_list", "toggleListItem"],
+ ["bullet_list", "bulletListItem"],
+ ["numbered_list", "numberedListItem"],
+ ["check_list", "checkListItem"],
+ ] as const) {
+ if (editorHasBlockWithType(editor, type)) {
+ items.push({ key, type });
+ }
+ }
+
+ return items;
+}
diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts
index 2ba1252acc..72cfa6e7a5 100644
--- a/packages/core/src/blocks/index.ts
+++ b/packages/core/src/blocks/index.ts
@@ -26,4 +26,5 @@ export * from "./BlockNoteSchema.js";
export * from "./defaultBlockHelpers.js";
export * from "./defaultBlocks.js";
export * from "./defaultBlockTypeGuards.js";
+export * from "./defaultBlockTypeItems.js";
export * from "./defaultProps.js";
diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css
index 7809ea8d5b..d12187f19a 100644
--- a/packages/core/src/editor/Block.css
+++ b/packages/core/src/editor/Block.css
@@ -25,7 +25,9 @@ BASIC STYLES
.ProseMirror-selectednode > .bn-block-content > *,
/* Case for blocks/inline content where class is set manually */
.bn-block-content .ProseMirror-selectednode,
-.bn-inline-content .ProseMirror-selectednode {
+.bn-inline-content .ProseMirror-selectednode,
+/* The block a dragged block drops into (set by the drop cursor) */
+.bn-block-outer[data-drop-target="true"] {
/* Anchor for the `::after` highlight overlay below. */
position: relative;
}
@@ -35,7 +37,8 @@ BASIC STYLES
.bn-block-content.ProseMirror-selectednode > *::after,
.ProseMirror-selectednode > .bn-block-content > *::after,
.bn-block-content .ProseMirror-selectednode::after,
-.bn-inline-content .ProseMirror-selectednode::after {
+.bn-inline-content .ProseMirror-selectednode::after,
+.bn-block-outer[data-drop-target="true"]::after {
content: "";
position: absolute;
inset: 0;
diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts
index 77d24cc114..01017ca24a 100644
--- a/packages/core/src/extensions/DropCursor/DropCursor.ts
+++ b/packages/core/src/extensions/DropCursor/DropCursor.ts
@@ -1,3 +1,4 @@
+import { Plugin } from "prosemirror-state";
import { dropPoint } from "prosemirror-transform";
import type { EditorView } from "prosemirror-view";
import {
@@ -10,6 +11,7 @@ import {
} from "./utils.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { createExtension } from "../../editor/BlockNoteExtension.js";
+import { getDropIntoChildren } from "./dropIntoChildren.js";
export const DRAG_EXCLUSION_CLASSNAME = "bn-drag-exclude";
@@ -64,6 +66,11 @@ export const DropCursorExtension = createExtension<
let element: HTMLElement | null = null;
let timeout = -1;
let dragSourceElement: Element | null = null;
+ // The block the drop goes into, if any, as the cursor shows it. Its
+ // element is highlighted like a selected block.
+ let dropInto:
+ | { blockId: string; draggedBlocks: any[]; element: HTMLElement }
+ | undefined;
const config = {
width: options.dropCursor?.width ?? 5,
@@ -73,6 +80,14 @@ export const DropCursorExtension = createExtension<
} as const;
// Helper functions
+ const setDropInto = (into: typeof dropInto) => {
+ delete dropInto?.element.dataset.dropTarget;
+ dropInto = into;
+ if (dropInto) {
+ dropInto.element.dataset.dropTarget = "true";
+ }
+ };
+
const setCursor = (pos: DropCursorPosition | null) => {
if (
pos?.pos === cursorPos?.pos &&
@@ -83,6 +98,7 @@ export const DropCursorExtension = createExtension<
cursorPos = pos;
if (pos == null) {
+ setDropInto(undefined);
if (element && element.parentNode) {
element.parentNode.removeChild(element);
}
@@ -189,13 +205,17 @@ export const DropCursorExtension = createExtension<
}
}
- // Compute default position
+ // Compute default position. A block dragged onto a block with
+ // `meta.dropsIntoChildren` goes to the start of its children.
const $pos = view.state.doc.resolve(target);
const isBlock = !$pos.parent.inlineContent;
- const defaultPosition: DropCursorPosition = {
- pos: target,
- orientation: isBlock ? "block-horizontal" : "inline",
- };
+ const intoChildren = getDropIntoChildren(editor, view, {
+ left: e.clientX,
+ top: e.clientY,
+ });
+ const defaultPosition: DropCursorPosition = intoChildren
+ ? { pos: intoChildren.pos, orientation: "block-horizontal" }
+ : { pos: target, orientation: isBlock ? "block-horizontal" : "inline" };
// Allow hook to override position
let finalPosition = defaultPosition;
@@ -215,6 +235,26 @@ export const DropCursorExtension = createExtension<
}
setCursor(finalPosition);
+ // The drop goes into the block while the cursor shows the place of its
+ // children. A hook may show another place instead (e.g. a new column).
+ if (
+ intoChildren &&
+ finalPosition.pos === intoChildren.pos &&
+ finalPosition.orientation === "block-horizontal"
+ ) {
+ const { block } = intoChildren.blockInfo;
+ const element = view.nodeDOM(block.beforePos);
+ if (!(element instanceof HTMLElement)) {
+ throw new Error("A block in the document must have an element");
+ }
+ setDropInto({
+ blockId: block.node.attrs.id,
+ draggedBlocks: intoChildren.draggedBlocks,
+ element,
+ });
+ } else {
+ setDropInto(undefined);
+ }
scheduleRemoval(5000);
}
};
@@ -238,8 +278,49 @@ export const DropCursorExtension = createExtension<
dragSourceElement = null;
};
+ // Drops the blocks where the cursor shows them: as the first children of
+ // the block it goes into. Other drops are ProseMirror's.
+ const dropIntoChildrenPlugin = new Plugin({
+ props: {
+ handleDrop(_view, _event, _slice, moved) {
+ const into = dropInto;
+ // The drop ends the drag, and with it the highlight.
+ setDropInto(undefined);
+ // The block may be gone since the last `dragover`, e.g. removed by a
+ // collaborator. ProseMirror then drops the blocks as usual.
+ if (!into || !editor.getBlock(into.blockId)) {
+ return false;
+ }
+ const { blockId, draggedBlocks } = into;
+ editor.transact(() => {
+ if (moved) {
+ // A drag from another editor leaves its blocks there.
+ editor.removeBlocks(
+ draggedBlocks.filter((block) => editor.getBlock(block.id)),
+ );
+ }
+ // The dragged blocks never contain the target (see
+ // `getDropIntoChildren`), so removing them keeps it.
+ const target = editor.getBlock(blockId);
+ if (!target) {
+ throw new Error(
+ "The drop target was removed with the dragged blocks",
+ );
+ }
+ if (target.children.length > 0) {
+ editor.insertBlocks(draggedBlocks, target.children[0], "before");
+ } else {
+ editor.updateBlock(target, { children: draggedBlocks });
+ }
+ });
+ return true;
+ },
+ },
+ });
+
return {
key: "dropCursor",
+ prosemirrorPlugins: [dropIntoChildrenPlugin],
mount({ signal, dom, root }) {
// Track drag source at document level
root.addEventListener("dragstart", onDragStart, {
diff --git a/packages/core/src/extensions/DropCursor/dropIntoChildren.ts b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts
new file mode 100644
index 0000000000..453c704c81
--- /dev/null
+++ b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts
@@ -0,0 +1,80 @@
+import type { EditorView } from "prosemirror-view";
+
+import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js";
+import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js";
+import { getBlockInfoFromNode } from "../../api/getBlockInfoFromPos.js";
+import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+
+/**
+ * Where a block drag at `coords` drops, when it is over the content or frame
+ * chrome of a block with `meta.dropsIntoChildren`: the start of that block's
+ * children, or its end when it has no children yet.
+ *
+ * @returns `undefined` when the drop goes before or after a block, as usual:
+ * for a text drag, over a block's children, over a block without the
+ * setting, or over one of the dragged blocks.
+ */
+export function getDropIntoChildren(
+ editor: BlockNoteEditor,
+ view: EditorView,
+ coords: { left: number; top: number },
+) {
+ const slice = view.dragging?.slice;
+ // A block drag holds complete blocks (see `SideMenu.onDragStart`).
+ if (!slice || slice.openStart > 0) {
+ return undefined;
+ }
+ const draggedBlocks = fragmentToBlocks(slice.content);
+ const pos = view.posAtCoords(coords);
+ if (draggedBlocks.length === 0 || !pos || pos.inside < 0) {
+ return undefined;
+ }
+
+ // The innermost block around the pointer. Over its children group (e.g. a
+ // gap between two children), the drop goes between them.
+ const $inside = view.state.doc.resolve(pos.inside + 1);
+ let depth = $inside.depth;
+ while (depth > 0 && !$inside.node(depth).type.isInGroup("bnBlock")) {
+ if ($inside.node(depth).type.name === "blockGroup") {
+ return undefined;
+ }
+ depth--;
+ }
+ if (depth === 0) {
+ return undefined;
+ }
+ const blockInfo = getBlockInfoFromNode(
+ $inside.node(depth),
+ $inside.before(depth),
+ );
+ if (!blockInfo.hasContent) {
+ return undefined;
+ }
+
+ const dropsIntoChildren = editor.schema.blockSpecs[
+ blockInfo.blockNoteType
+ ]?.implementation.meta?.dropsIntoChildren?.(
+ nodeToBlock(blockInfo.block.node, view.state.doc),
+ );
+ if (!dropsIntoChildren) {
+ return undefined;
+ }
+
+ // A block can't be dropped into itself or into one of its descendants.
+ const targetId = blockInfo.block.node.attrs.id;
+ const containsTarget = (block: {
+ id: string;
+ children: { id: string; children: any[] }[];
+ }): boolean => block.id === targetId || block.children.some(containsTarget);
+ if (draggedBlocks.some(containsTarget)) {
+ return undefined;
+ }
+
+ return {
+ blockInfo,
+ draggedBlocks,
+ pos: blockInfo.children
+ ? blockInfo.children.childrenStart
+ : blockInfo.block.afterPos - 1,
+ };
+}
diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
index 6c0682f012..30497bb8de 100644
--- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
+++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
@@ -1,5 +1,9 @@
import { Block, PartialBlock } from "../../blocks/defaultBlocks.js";
import { editorHasBlockWithType } from "../../blocks/defaultBlockTypeGuards.js";
+import {
+ type DefaultBlockTypeItem,
+ getDefaultBlockTypeItems,
+} from "../../blocks/defaultBlockTypeItems.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockSchema,
@@ -92,21 +96,6 @@ export function insertOrUpdateBlockForSlashMenu<
return newBlock;
}
-// A regular heading's props. With toggle headings in the schema, this sets
-// `isToggleable: false`, so that updating a toggle heading turns it into a
-// regular heading (BLO-959).
-function regularHeadingProps(
- editor: BlockNoteEditor,
- level: number,
-) {
- return editorHasBlockWithType(editor, "heading", {
- level: "number",
- isToggleable: "boolean",
- })
- ? { level, isToggleable: false }
- : { level };
-}
-
export function getDefaultSlashMenuItems<
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -114,103 +103,39 @@ export function getDefaultSlashMenuItems<
>(editor: BlockNoteEditor) {
const items: DefaultSuggestionItem[] = [];
- if (editorHasBlockWithType(editor, "heading", { level: "number" })) {
- (editor.schema.blockSchema.heading.propSchema.level.values || [])
- .filter((level): level is 1 | 2 | 3 => level <= 3)
- .forEach((level) => {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "heading",
- props: regularHeadingProps(editor, level),
- });
- },
- badge: formatKeyboardShortcut(`Mod-Alt-${level}`),
- key:
- level === 1 ? ("heading" as const) : (`heading_${level}` as const),
- ...editor.dictionary.slash_menu[
- level === 1 ? ("heading" as const) : (`heading_${level}` as const)
- ],
- });
- });
- }
-
- if (editorHasBlockWithType(editor, "quote")) {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "quote",
- });
- },
- key: "quote",
- ...editor.dictionary.slash_menu.quote,
- });
- }
-
- if (editorHasBlockWithType(editor, "toggleListItem")) {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "toggleListItem",
- });
- },
- badge: formatKeyboardShortcut("Mod-Shift-6"),
- key: "toggle_list",
- ...editor.dictionary.slash_menu.toggle_list,
- });
- }
-
- if (editorHasBlockWithType(editor, "numberedListItem")) {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "numberedListItem",
- });
- },
- badge: formatKeyboardShortcut("Mod-Shift-7"),
- key: "numbered_list",
- ...editor.dictionary.slash_menu.numbered_list,
- });
- }
-
- if (editorHasBlockWithType(editor, "bulletListItem")) {
+ // The block types come from `getDefaultBlockTypeItems`, which the block
+ // type select also uses. The slash menu places them in its own order.
+ const blockTypes = new Map(
+ getDefaultBlockTypeItems(editor).map((item) => [item.key, item]),
+ );
+ function pushBlockType(key: DefaultBlockTypeItem["key"], badge?: string) {
+ const blockType = blockTypes.get(key);
+ if (!blockType) {
+ return;
+ }
items.push({
onItemClick: () => {
+ // The schema supports the block type (see `getDefaultBlockTypeItems`).
insertOrUpdateBlockForSlashMenu(editor, {
- type: "bulletListItem",
- });
+ type: blockType.type,
+ props: blockType.props,
+ } as PartialBlock);
},
- badge: formatKeyboardShortcut("Mod-Shift-8"),
- key: "bullet_list",
- ...editor.dictionary.slash_menu.bullet_list,
+ badge,
+ key,
+ ...editor.dictionary.slash_menu[key],
});
}
- if (editorHasBlockWithType(editor, "checkListItem")) {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "checkListItem",
- });
- },
- badge: formatKeyboardShortcut("Mod-Shift-9"),
- key: "check_list",
- ...editor.dictionary.slash_menu.check_list,
- });
- }
-
- if (editorHasBlockWithType(editor, "paragraph")) {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "paragraph",
- });
- },
- badge: formatKeyboardShortcut("Mod-Alt-0"),
- key: "paragraph",
- ...editor.dictionary.slash_menu.paragraph,
- });
- }
+ pushBlockType("heading", formatKeyboardShortcut("Mod-Alt-1"));
+ pushBlockType("heading_2", formatKeyboardShortcut("Mod-Alt-2"));
+ pushBlockType("heading_3", formatKeyboardShortcut("Mod-Alt-3"));
+ pushBlockType("quote");
+ pushBlockType("toggle_list", formatKeyboardShortcut("Mod-Shift-6"));
+ pushBlockType("numbered_list", formatKeyboardShortcut("Mod-Shift-7"));
+ pushBlockType("bullet_list", formatKeyboardShortcut("Mod-Shift-8"));
+ pushBlockType("check_list", formatKeyboardShortcut("Mod-Shift-9"));
+ pushBlockType("paragraph", formatKeyboardShortcut("Mod-Alt-0"));
if (editorHasBlockWithType(editor, "codeBlock")) {
items.push({
@@ -339,52 +264,12 @@ export function getDefaultSlashMenuItems<
});
}
- if (
- editorHasBlockWithType(editor, "heading", {
- level: "number",
- isToggleable: "boolean",
- })
- ) {
- (editor.schema.blockSchema.heading.propSchema.level.values || [])
- .filter((level): level is 1 | 2 | 3 => level <= 3)
- .forEach((level) => {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "heading",
- props: { level: level, isToggleable: true },
- });
- },
- key:
- level === 1
- ? ("toggle_heading" as const)
- : (`toggle_heading_${level}` as const),
- ...editor.dictionary.slash_menu[
- level === 1
- ? ("toggle_heading" as const)
- : (`toggle_heading_${level}` as const)
- ],
- });
- });
- }
-
- if (editorHasBlockWithType(editor, "heading", { level: "number" })) {
- (editor.schema.blockSchema.heading.propSchema.level.values || [])
- .filter((level): level is 4 | 5 | 6 => level > 3)
- .forEach((level) => {
- items.push({
- onItemClick: () => {
- insertOrUpdateBlockForSlashMenu(editor, {
- type: "heading",
- props: regularHeadingProps(editor, level),
- });
- },
- badge: formatKeyboardShortcut(`Mod-Alt-${level}`),
- key: `heading_${level}`,
- ...editor.dictionary.slash_menu[`heading_${level}`],
- });
- });
- }
+ pushBlockType("toggle_heading");
+ pushBlockType("toggle_heading_2");
+ pushBlockType("toggle_heading_3");
+ pushBlockType("heading_4", formatKeyboardShortcut("Mod-Alt-4"));
+ pushBlockType("heading_5", formatKeyboardShortcut("Mod-Alt-5"));
+ pushBlockType("heading_6", formatKeyboardShortcut("Mod-Alt-6"));
items.push({
onItemClick: () => {
diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts
index cafc675fd7..0fb1258e18 100644
--- a/packages/core/src/pm-nodes/BlockContainer.ts
+++ b/packages/core/src/pm-nodes/BlockContainer.ts
@@ -13,6 +13,8 @@ import { ignoreFrameChromeMutations } from "../schema/nodeViewMutations.js";
import { mergeCSSClasses } from "../util/browser.js";
import { suggestionMarks } from "./suggestionMarks.js";
+const dropEvents = new Set(["dragenter", "dragover", "dragleave", "drop"]);
+
/** Adapts vanilla frames to the same lifecycle as framework node views. */
function createFrameView(
props: NodeViewRendererProps,
@@ -206,11 +208,14 @@ export const BlockContainer = Node.create<{
},
stopEvent(event) {
// Author chrome handles its own events; the slot remains editable.
+ // Drag-and-drop events still go to ProseMirror, so a block dropped
+ // on the chrome drops where the drop cursor shows it.
const target = event.target;
return (
(target instanceof globalThis.Node &&
frameView.dom.contains(target) &&
- !nodeView.contentDOM?.contains(target)) ||
+ !nodeView.contentDOM?.contains(target) &&
+ !dropEvents.has(event.type)) ||
(frameView.stopEvent?.(event) ?? false)
);
},
diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts
index ba0c304956..d69bd6ab17 100644
--- a/packages/core/src/schema/blocks/types.ts
+++ b/packages/core/src/schema/blocks/types.ts
@@ -81,6 +81,14 @@ export interface BlockConfigMeta<
*/
draggable?: boolean;
+ /**
+ * Whether a block dragged onto this block's content or frame chrome becomes
+ * its first child (as in Notion's toggles), instead of going before or after
+ * it. A block dragged onto its children still goes between them.
+ * @default false
+ */
+ dropsIntoChildren?(block: { type: TName; props: Props }): boolean;
+
/**
* Enables syntax highlighting of the contents of the block with the result of this callback
*/
diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.test.ts b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.test.ts
new file mode 100644
index 0000000000..f7b2071eae
--- /dev/null
+++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.test.ts
@@ -0,0 +1,46 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ type BlockNoteEditor,
+ BlockNoteSchema,
+ createHeadingBlockSpec,
+ defaultBlockSpecs,
+} from "@blocknote/core";
+import { en } from "@blocknote/core/locales";
+import { describe, expect, it } from "vite-plus/test";
+
+import { blockTypeSelectItems } from "./BlockTypeSelect.js";
+
+function schemaWith(heading = createHeadingBlockSpec()) {
+ return BlockNoteSchema.create({
+ blockSpecs: { ...defaultBlockSpecs, heading },
+ });
+}
+
+describe("blockTypeSelectItems", () => {
+ it("lists the default items in the same order as before", () => {
+ // Only the schema and the dictionary are read.
+ const editor = {
+ schema: schemaWith(),
+ dictionary: en,
+ } as unknown as BlockNoteEditor;
+ expect(blockTypeSelectItems(editor).map((item) => item.name)).toEqual([
+ "Paragraph",
+ "Heading 1",
+ "Heading 2",
+ "Heading 3",
+ "Heading 4",
+ "Heading 5",
+ "Heading 6",
+ "Toggle Heading 1",
+ "Toggle Heading 2",
+ "Toggle Heading 3",
+ "Quote",
+ "Toggle List",
+ "Bullet List",
+ "Numbered List",
+ "Check List",
+ ]);
+ });
+});
diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx
index ba66847b02..0faa196988 100644
--- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx
+++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx
@@ -1,7 +1,9 @@
import {
+ BlockNoteEditor,
BlockSchema,
- Dictionary,
+ DefaultBlockTypeItem,
editorHasBlockWithType,
+ getDefaultBlockTypeItems,
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
@@ -38,94 +40,37 @@ export type BlockTypeSelectItem = {
icon: IconType;
};
+const icons: Record = {
+ paragraph: RiText,
+ heading: RiH1,
+ heading_2: RiH2,
+ heading_3: RiH3,
+ heading_4: RiH4,
+ heading_5: RiH5,
+ heading_6: RiH6,
+ toggle_heading: RiH1,
+ toggle_heading_2: RiH2,
+ toggle_heading_3: RiH3,
+ quote: RiQuoteText,
+ toggle_list: RiPlayList2Fill,
+ bullet_list: RiListUnordered,
+ numbered_list: RiListOrdered,
+ check_list: RiListCheck3,
+};
+
+/**
+ * The default block type select items: the block types of the editor's schema
+ * that the default menus offer (see `getDefaultBlockTypeItems`).
+ */
export const blockTypeSelectItems = (
- dict: Dictionary,
-): BlockTypeSelectItem[] => [
- {
- name: dict.slash_menu.paragraph.title,
- type: "paragraph",
- icon: RiText,
- },
- {
- name: dict.slash_menu.heading.title,
- type: "heading",
- props: { level: 1, isToggleable: false },
- icon: RiH1,
- },
- {
- name: dict.slash_menu.heading_2.title,
- type: "heading",
- props: { level: 2, isToggleable: false },
- icon: RiH2,
- },
- {
- name: dict.slash_menu.heading_3.title,
- type: "heading",
- props: { level: 3, isToggleable: false },
- icon: RiH3,
- },
- {
- name: dict.slash_menu.heading_4.title,
- type: "heading",
- props: { level: 4, isToggleable: false },
- icon: RiH4,
- },
- {
- name: dict.slash_menu.heading_5.title,
- type: "heading",
- props: { level: 5, isToggleable: false },
- icon: RiH5,
- },
- {
- name: dict.slash_menu.heading_6.title,
- type: "heading",
- props: { level: 6, isToggleable: false },
- icon: RiH6,
- },
- {
- name: dict.slash_menu.toggle_heading.title,
- type: "heading",
- props: { level: 1, isToggleable: true },
- icon: RiH1,
- },
- {
- name: dict.slash_menu.toggle_heading_2.title,
- type: "heading",
- props: { level: 2, isToggleable: true },
- icon: RiH2,
- },
- {
- name: dict.slash_menu.toggle_heading_3.title,
- type: "heading",
- props: { level: 3, isToggleable: true },
- icon: RiH3,
- },
- {
- name: dict.slash_menu.quote.title,
- type: "quote",
- icon: RiQuoteText,
- },
- {
- name: dict.slash_menu.toggle_list.title,
- type: "toggleListItem",
- icon: RiPlayList2Fill,
- },
- {
- name: dict.slash_menu.bullet_list.title,
- type: "bulletListItem",
- icon: RiListUnordered,
- },
- {
- name: dict.slash_menu.numbered_list.title,
- type: "numberedListItem",
- icon: RiListOrdered,
- },
- {
- name: dict.slash_menu.check_list.title,
- type: "checkListItem",
- icon: RiListCheck3,
- },
-];
+ editor: BlockNoteEditor,
+): BlockTypeSelectItem[] =>
+ getDefaultBlockTypeItems(editor).map(({ key, type, props }) => ({
+ name: editor.dictionary.slash_menu[key].title,
+ type,
+ props,
+ icon: icons[key],
+ }));
export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => {
const Components = useComponentsContext()!;
@@ -149,7 +94,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => {
// the schema.
const filteredItems = useMemo(
() =>
- (props.items || blockTypeSelectItems(editor.dictionary)).filter((item) =>
+ (props.items || blockTypeSelectItems(editor)).filter((item) =>
editorHasBlockWithType(
editor,
item.type,
diff --git a/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx b/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx
index 79531ae543..bc2ac6e0ad 100644
--- a/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx
+++ b/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx
@@ -15,12 +15,12 @@ import { beforeEach, describe, expect, test } from "vite-plus/test";
import { render } from "vitest-browser-react";
import {
DRAG_HANDLE_ADD_SELECTOR,
+ DRAG_HANDLE_SELECTOR,
EDITOR_SELECTOR,
} from "../../utils/const.js";
import { browserName, page, userEvent } from "../../utils/context.js";
-import { waitForSelector } from "../../utils/editor.js";
+import { sleep, waitForSelector } from "../../utils/editor.js";
import {
- dragAndDropBlock,
getRect,
mouseSequence,
moveMouseOverElement,
@@ -94,38 +94,94 @@ async function openToggle() {
await userEvent.click(button);
}
+const DROP_CURSOR_SELECTOR = '[class*="prosemirror-dropcursor"]';
+
+/** An element of the toggle itself, not of a toggle nested in it. */
+async function ownElement(selector: string) {
+ const block = await waitForSelector(`.bn-block[data-id="t"]`);
+ const element = [...block.querySelectorAll(selector)].find(
+ (candidate) => candidate.closest(".bn-block") === block,
+ );
+ if (!element) {
+ throw new Error(`The toggle has no ${selector}`);
+ }
+ return element;
+}
+
+/**
+ * Drags block `drag` by its handle to the center of `target`, and holds it
+ * there. `mouseSequence([{ type: "up" }])` drops it.
+ */
+async function dragOnto(target: Element) {
+ // Not `getByText`: a previous drag leaves its drag image, a copy of the
+ // dragged block, in the document.
+ await moveMouseOverElement(
+ await waitForSelector(`.bn-block[data-id="drag"] .bn-inline-content`),
+ );
+ const handle = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR));
+ const rect = getRect(target);
+ // The pauses let the browser start the drag, as in `dragAndDropBlock`.
+ await mouseSequence([
+ {
+ type: "move",
+ x: handle.x + handle.width / 2,
+ y: handle.y + handle.height / 2,
+ steps: 5,
+ },
+ ]);
+ await sleep(100);
+ await mouseSequence([{ type: "down" }]);
+ await sleep(100);
+ await mouseSequence([
+ {
+ type: "move",
+ x: rect.x + rect.width / 2,
+ y: rect.y + rect.height / 2,
+ steps: 5,
+ },
+ ]);
+}
+
+async function dropCursorTop() {
+ return getRect(await waitForSelector(DROP_CURSOR_SELECTOR)).top;
+}
+
describe.each(kinds)("$name", ({ toggle }) => {
- // BLO-956: an empty toggle has no place to drop a block into. The only way
- // to fill it is to click "Add block" first.
- test
- .skipIf(browserName === "firefox")
- .fails(
- "accepts a block dropped into it while it is open and empty (BLO-956)",
- async () => {
- await render(
- ,
- );
- await waitForSelector(EDITOR_SELECTOR);
- await openToggle();
-
- await dragAndDropBlock(
- page.getByText("Drag me").element(),
- await waitForSelector(
- `.bn-block[data-id="t"] .bn-toggle-add-block-button`,
- ),
- true,
- );
-
- expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual(
- ["drag"],
- );
- },
- );
+ // BLO-956: the only way to drop a block into an empty toggle is onto the
+ // toggle itself, e.g. its "Add block" button. This test uses a real mouse
+ // drag, which Playwright only emulates reliably in Chromium. All drop
+ // targets are tested in every browser in `toggleBlocks.browser.test.ts`,
+ // with synthetic drag events.
+ describe.skipIf(browserName !== "chromium")("drop onto the toggle", () => {
+ test("onto 'Add block', into the empty toggle (BLO-956)", async () => {
+ await render(
+ ,
+ );
+ await waitForSelector(EDITOR_SELECTOR);
+ await openToggle();
+
+ await dragOnto(await ownElement(".bn-toggle-add-block-button"));
+ // The drop cursor shows the place of the children: below the title.
+ const title = getRect(await ownElement(".bn-block-content"));
+ await expect.poll(dropCursorTop).toBeGreaterThan(title.bottom - 6);
+ expect(await dropCursorTop()).toBeLessThan(title.bottom + 6);
+
+ await mouseSequence([{ type: "up" }]);
+
+ // The drop cursor does not stay after the drop.
+ await expect
+ .poll(() => document.querySelector(DROP_CURSOR_SELECTOR))
+ .toBeNull();
+ expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([
+ "drag",
+ ]);
+ });
+ });
// BLO-1030: the side menu of a block in the left column of a column list
// inside a toggle was reported to disappear when the mouse moves onto it.
diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json
index 2f68ea16fa..4804f7b7a8 100644
--- a/tests/src/unit/core/schema/__snapshots__/blocks.json
+++ b/tests/src/unit/core/schema/__snapshots__/blocks.json
@@ -316,6 +316,7 @@
"implementation": {
"keyboard": [Function],
"meta": {
+ "dropsIntoChildren": [Function],
"isolating": false,
},
"node": null,
@@ -626,6 +627,7 @@
"implementation": {
"keyboard": [Function],
"meta": {
+ "dropsIntoChildren": [Function],
"isolating": false,
},
"node": null,