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
121 changes: 30 additions & 91 deletions docs/content/docs/getting-started/vanilla-js.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,112 +28,51 @@ This is how to create a new BlockNote editor:

```typescript
import { BlockNoteEditor } from "@blocknote/core";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/core/style.css";

const editor = BlockNoteEditor.create();

editor.mount(document.getElementById("root")); // element to append the editor to
editor.mount(document.getElementById("root")!); // element to append the editor to
```

Make sure to import both `@blocknote/core/style.css` (the editor's styles) and `@blocknote/core/fonts/inter.css` (the default font).

Now, you'll have a plain BlockNote instance on your page. However, it's missing some menus and other UI elements.

## Creating your own UI elements

Because you can't use the built-in React [UI Components](/docs/react/components), you'll need to create and register your own UI elements.

While it's up to you to decide how you want the elements to be rendered, BlockNote provides methods for updating the visibility, position, and state of your elements:
Each UI element is backed by an [extension](/docs/features/extensions). You can retrieve an extension instance from the editor with `editor.getExtension(...)`, and each one exposes a [store](https://tanstack.com/store) that holds its current state (visibility, position, and any element-specific data). The available UI element extensions are:

```typescript
type UIElement =
| "formattingToolbar"
| "linkToolbar"
| "filePanel"
| "sideMenu"
| "suggestionMenu"
| "tableHandles"

const uiElement: UIElement = ...;

editor[uiElement].onUpdate((uiElementState: ...) => {
...;
})
```
| UI element | Extension |
| ------------------ | ----------------------------- |
| Formatting Toolbar | `FormattingToolbarExtension` |
| Link Toolbar | `LinkToolbarExtension` |
| Side Menu | `SideMenuExtension` |
| Suggestion Menu | `SuggestionMenu` |
| File Panel | `FilePanelExtension` |
| Table Handles | `TableHandlesExtension` |

Let's look at how you could add the [Side Menu]() to your editor:
While it's up to you to decide how you want the elements to be rendered, the store lets you react to state changes so you can update the visibility, position, and contents of your elements:

```typescript
import { BlockNoteEditor } from "@blocknote/core";
import { SideMenuExtension } from "@blocknote/core";

const editor = BlockNoteEditor.create();
editor.mount(document.getElementById("root"));

export function createButton(text: string, onClick?: () => void) {
const element = document.createElement("a");
element.href = "#";
element.text = text;
element.style.margin = "10px";

if (onClick) {
element.addEventListener("click", (e) => {
onClick();
e.preventDefault();
});
}

return element;
}

let element: HTMLElement;

editor.sideMenu.onUpdate((sideMenuState) => {
if (!element) {
element = document.createElement("div");
element.style.background = "gray";
element.style.position = "absolute";
element.style.padding = "10px";
element.style.opacity = "0.8";
const addBtn = createButton("+", () => {
const blockContent = sideMenuState.block.content;
const isBlockEmpty =
blockContent !== undefined &&
Array.isArray(blockContent) &&
blockContent.length === 0;

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

const dragBtn = createButton("::", () => {});

dragBtn.addEventListener("dragstart", (evt) =>
editor.sideMenu.blockDragStart(evt, sideMenuState.block),
);
dragBtn.addEventListener("dragend", editor.sideMenu.blockDragEnd);
dragBtn.draggable = true;
element.style.display = "none";
element.appendChild(dragBtn);

document.getElementById("root")!.appendChild(element);
}

if (sideMenuState.show) {
element.style.display = "block";

element.style.top = sideMenuState.referencePos.top + "px";
element.style.left =
sideMenuState.referencePos.x - element.offsetWidth + "px";
} else {
element.style.display = "none";
}
const extension = editor.getExtension(SideMenuExtension)!;

// Read the current state at any time:
const state = extension.store.state;

// Subscribe to state changes, e.g. to reposition or show/hide your element:
const unsubscribe = extension.store.subscribe(() => {
const state = extension.store.state;

// ...update your UI element based on `state`
});
```

Let's look at how you could add the [Side Menu](/docs/react/components/side-menu) to your editor. The example below mounts a plain editor and builds a custom Side Menu from scratch, using the extension's store to position it and its `blockDragStart`/`blockDragEnd` methods to handle dragging:

<Example name="vanilla-js/vanilla-custom-side-menu" />
3 changes: 2 additions & 1 deletion docs/content/examples/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"custom-schema",
"collaboration",
"extensions",
"ai"
"ai",
"vanilla-js"
]
}
6 changes: 6 additions & 0 deletions examples/vanilla-js/vanilla-custom-side-menu/.bnexample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"playground": true,
"docs": true,
"author": "matthewlipski",
"tags": ["Advanced", "UI Components", "Block Side Menu"]
}
11 changes: 11 additions & 0 deletions examples/vanilla-js/vanilla-custom-side-menu/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Custom Side Menu (Vanilla JS)

This example uses the vanilla JS API to create a plain BlockNote editor without `@blocknote/react`, mounting it manually and building a custom Side Menu from scratch.

**Try it out:** Hover over a block to reveal the custom Side Menu, then use the `+` button to add a block or the `::` handle to drag it!

**Relevant Docs:**

- [Getting Started with Vanilla JS](/docs/getting-started/vanilla-js)
- [Editor Setup](/docs/getting-started/editor-setup)
- [Extensions](/docs/features/extensions)
14 changes: 14 additions & 0 deletions examples/vanilla-js/vanilla-custom-side-menu/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Custom Side Menu (Vanilla JS)</title>
<script>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</script>
</head>
Comment on lines +1 to +9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add <!DOCTYPE html>.

Static analysis (HTMLHint) flags the missing doctype declaration, which causes the page to render in quirks mode.

🐛 Proposed fix
+<!DOCTYPE html>
 <html lang="en">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Custom Side Menu (Vanilla JS)</title>
<script>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</script>
</head>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Custom Side Menu (Vanilla JS)</title>
<script>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</script>
</head>
🧰 Tools
🪛 HTMLHint (1.9.2)

[error] 1-1: Doctype must be declared before any non-comment content.

(doctype-first)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/vanilla-js/vanilla-custom-side-menu/index.html` around lines 1 - 9,
Add the HTML5 doctype declaration at the beginning of the document, before the
html element in the custom side menu page. Leave the existing head metadata and
generated-file marker unchanged.

Source: Linters/SAST tools

<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
11 changes: 11 additions & 0 deletions examples/vanilla-js/vanilla-custom-side-menu/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./src/App.jsx";

const root = createRoot(document.getElementById("root")!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
30 changes: 30 additions & 0 deletions examples/vanilla-js/vanilla-custom-side-menu/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@blocknote/example-vanilla-js-vanilla-custom-side-menu",
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
"type": "module",
"private": true,
"version": "0.12.4",
"scripts": {
"start": "vp dev",
"dev": "vp dev",
"build:prod": "tsc && vp build",
"preview": "vp preview"
},
"dependencies": {
"@blocknote/ariakit": "latest",
"@blocknote/core": "latest",
"@blocknote/mantine": "latest",
"@blocknote/react": "latest",
"@blocknote/shadcn": "latest",
"@mantine/core": "^9.0.2",
"@mantine/hooks": "^9.0.2",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"vite-plus": "catalog:"
}
}
142 changes: 142 additions & 0 deletions examples/vanilla-js/vanilla-custom-side-menu/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
BlockNoteEditor,
SideMenuExtension,
SuggestionMenu,
} from "@blocknote/core";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/core/style.css";
import { useEffect, useRef } from "react";

import "./styles.css";

// Creates a simple button element, used for the side menu below.
function createButton(text: string, onClick?: () => void) {
const element = document.createElement("a");
element.className = "side-menu-button";
element.href = "#";
element.text = text;

if (onClick) {
element.addEventListener("click", (e) => {
onClick();
e.preventDefault();
});
}

return element;
}

// Since this example doesn't use `@blocknote/react`, we can't use the built-in
// UI components. Instead, we create a plain BlockNote editor using the vanilla
// JS API and mount it manually, then wire up our own Side Menu.
function initEditor(rootElement: HTMLElement) {
const editor = BlockNoteEditor.create();

// `editor.mount` turns the element it's given into the editable area, so we
// mount into a dedicated child element. This keeps our custom UI elements
// (like the Side Menu below) separate from the editor's content.
const editorElement = document.createElement("div");
rootElement.appendChild(editorElement);
editor.mount(editorElement);

const sideMenu = editor.getExtension(SideMenuExtension)!;
Comment on lines +32 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cleanup doesn't remove the appended DOM nodes, leaking elements on remount/StrictMode double-invoke.

initEditor appends editorElement (line 39) and the custom side menu element (line 110) as children of rootElement, but the returned cleanup (lines 124-127) only calls unsubscribe() and editor.unmount() — it never removes these nodes from rootElement. Since main.tsx wraps <App /> in <React.StrictMode>, React will mount→cleanup→mount the effect once in development; without removing the appended nodes, a stale editorElement and (if the side menu was ever shown) a stale, detached-but-still-in-DOM side menu accumulate under the same root <div> every time the effect reruns.

🐛 Proposed fix
   return () => {
     unsubscribe();
     editor.unmount();
+    editorElement.remove();
+    element?.remove();
   };

Also applies to: 108-128, 130-139

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/vanilla-js/vanilla-custom-side-menu/src/App.tsx` around lines 32 -
42, Update initEditor’s returned cleanup to remove both dynamically appended DOM
nodes from rootElement after unsubscribing and unmounting the editor: the
editorElement created in initEditor and the side-menu element created in the
side menu setup. Guard removal as needed so cleanup remains safe when the side
menu was never shown, while preserving the existing unsubscribe and
editor.unmount behavior.


let element: HTMLElement;

// The Side Menu extension exposes a store which holds its state (visibility,
// position, and the block it's attached to). We subscribe to it to update
// our custom element whenever the state changes.
const unsubscribe = sideMenu.store.subscribe(() => {
const sideMenuState = sideMenu.store.state;

// The store's initial state is `undefined`, so we wait until the side menu
// has a block to attach to.
if (!sideMenuState) {
return;
}

// We create the element the first time the side menu is shown, and reuse it
// afterwards. Since the element is only created once, its event handlers
// read the block to act on from the store, so they always use the block the
// side menu is currently attached to.
if (!element) {
element = document.createElement("div");
element.className = "side-menu";
element.style.position = "fixed";
const addBtn = createButton("+", () => {
const block = sideMenu.store.state?.block;
if (!block) {
return;
}

const blockContent = block.content;
const isBlockEmpty =
blockContent !== undefined &&
Array.isArray(blockContent) &&
blockContent.length === 0;

if (isBlockEmpty) {
editor.setTextCursorPosition(block);
editor.getExtension(SuggestionMenu)?.openSuggestionMenu("/");
} else {
const insertedBlock = editor.insertBlocks(
[{ type: "paragraph" }],
block,
"after",
)[0];
editor.setTextCursorPosition(insertedBlock);
editor.getExtension(SuggestionMenu)?.openSuggestionMenu("/");
}
});
element.appendChild(addBtn);

const dragBtn = createButton("::", () => {
// Intentionally empty - dragging is handled by the drag events below.
});

dragBtn.addEventListener("dragstart", (evt) => {
const block = sideMenu.store.state?.block;
if (block) {
sideMenu.blockDragStart(evt, block);
}
});
dragBtn.addEventListener("dragend", () => sideMenu.blockDragEnd());
dragBtn.draggable = true;
element.style.display = "none";
element.appendChild(dragBtn);

// The Side Menu is appended to the root element rather than the editor
// element, so that the editor doesn't treat it as content.
rootElement.appendChild(element);
}

if (sideMenuState.show) {
element.style.display = "flex";

element.style.top = sideMenuState.referencePos.top + "px";
element.style.left =
sideMenuState.referencePos.x - element.offsetWidth + "px";
} else {
element.style.display = "none";
}
});

return () => {
unsubscribe();
editor.unmount();
};
}

export default function App() {
const rootRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!rootRef.current) {
return;
}

return initEditor(rootRef.current);
}, []);

return <div ref={rootRef} />;
}
Loading
Loading