Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion examples/06-custom-schema/09-math-block/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default function App() {
formattingToolbar={() => (
<FormattingToolbar
blockTypeSelectItems={[
...blockTypeSelectItems(editor.dictionary),
...blockTypeSelectItems(editor),
...getMathBlockTypeSelectItems(editor),
]}
/>
Expand Down
2 changes: 1 addition & 1 deletion examples/06-custom-schema/10-diagram-block/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default function App() {
formattingToolbar={() => (
<FormattingToolbar
blockTypeSelectItems={[
...blockTypeSelectItems(editor.dictionary),
...blockTypeSelectItems(editor),
...getDiagramBlockTypeSelectItems(editor),
]}
/>
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/blocks/Heading/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/blocks/ListItem/ToggleListItem/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, any, any>) {
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);
Expand Down
157 changes: 155 additions & 2 deletions packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");

Expand All @@ -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")]);
Expand Down Expand Up @@ -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" }]),
]);
Expand Down Expand Up @@ -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());
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/blocks/defaultBlockTypeItems.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Loading
Loading