diff --git a/docs/content/docs/getting-started/vanilla-js.mdx b/docs/content/docs/getting-started/vanilla-js.mdx index 54aff2fae5..3735e09fb7 100644 --- a/docs/content/docs/getting-started/vanilla-js.mdx +++ b/docs/content/docs/getting-started/vanilla-js.mdx @@ -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: + + diff --git a/docs/content/examples/meta.json b/docs/content/examples/meta.json index 303ef969fc..a6c66132c0 100644 --- a/docs/content/examples/meta.json +++ b/docs/content/examples/meta.json @@ -13,6 +13,7 @@ "custom-schema", "collaboration", "extensions", - "ai" + "ai", + "vanilla-js" ] } diff --git a/examples/vanilla-js/vanilla-custom-side-menu/.bnexample.json b/examples/vanilla-js/vanilla-custom-side-menu/.bnexample.json new file mode 100644 index 0000000000..836611be62 --- /dev/null +++ b/examples/vanilla-js/vanilla-custom-side-menu/.bnexample.json @@ -0,0 +1,6 @@ +{ + "playground": true, + "docs": true, + "author": "matthewlipski", + "tags": ["Advanced", "UI Components", "Block Side Menu"] +} diff --git a/examples/vanilla-js/vanilla-custom-side-menu/README.md b/examples/vanilla-js/vanilla-custom-side-menu/README.md new file mode 100644 index 0000000000..c67c08d3fc --- /dev/null +++ b/examples/vanilla-js/vanilla-custom-side-menu/README.md @@ -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) diff --git a/examples/vanilla-js/vanilla-custom-side-menu/index.html b/examples/vanilla-js/vanilla-custom-side-menu/index.html new file mode 100644 index 0000000000..4dfcf7b637 --- /dev/null +++ b/examples/vanilla-js/vanilla-custom-side-menu/index.html @@ -0,0 +1,14 @@ + + + + + Custom Side Menu (Vanilla JS) + + + +
+ + + diff --git a/examples/vanilla-js/vanilla-custom-side-menu/main.tsx b/examples/vanilla-js/vanilla-custom-side-menu/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/vanilla-js/vanilla-custom-side-menu/main.tsx @@ -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( + + + , +); diff --git a/examples/vanilla-js/vanilla-custom-side-menu/package.json b/examples/vanilla-js/vanilla-custom-side-menu/package.json new file mode 100644 index 0000000000..7c71b5a417 --- /dev/null +++ b/examples/vanilla-js/vanilla-custom-side-menu/package.json @@ -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:" + } +} diff --git a/examples/vanilla-js/vanilla-custom-side-menu/src/App.tsx b/examples/vanilla-js/vanilla-custom-side-menu/src/App.tsx new file mode 100644 index 0000000000..183cfd2511 --- /dev/null +++ b/examples/vanilla-js/vanilla-custom-side-menu/src/App.tsx @@ -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)!; + + 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(null); + + useEffect(() => { + if (!rootRef.current) { + return; + } + + return initEditor(rootRef.current); + }, []); + + return
; +} diff --git a/examples/vanilla-js/vanilla-custom-side-menu/src/styles.css b/examples/vanilla-js/vanilla-custom-side-menu/src/styles.css new file mode 100644 index 0000000000..3e75c182d7 --- /dev/null +++ b/examples/vanilla-js/vanilla-custom-side-menu/src/styles.css @@ -0,0 +1,34 @@ +.side-menu { + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: 6px; + background-color: white; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(0, 0, 0, 0.08); +} + +.side-menu-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + color: #6b7280; + font-weight: 600; + text-decoration: none; + cursor: pointer; + user-select: none; +} + +.side-menu-button:hover { + background-color: #f3f4f6; + color: #111827; +} + +.side-menu-button[draggable="true"] { + cursor: grab; +} diff --git a/examples/vanilla-js/vanilla-custom-side-menu/tsconfig.json b/examples/vanilla-js/vanilla-custom-side-menu/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/vanilla-js/vanilla-custom-side-menu/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/vanilla-js/vanilla-custom-side-menu/vite-env.d.ts b/examples/vanilla-js/vanilla-custom-side-menu/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/vanilla-js/vanilla-custom-side-menu/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts b/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/packages/dev-scripts/examples/util.ts b/packages/dev-scripts/examples/util.ts index 5e3d7095c4..3d9a13eac6 100644 --- a/packages/dev-scripts/examples/util.ts +++ b/packages/dev-scripts/examples/util.ts @@ -164,6 +164,7 @@ export function addTitleToGroups(grouped: ReturnType) { collaboration: "Collaboration", extensions: "Extensions", ai: "AI", + "vanilla-js": "Vanilla JS", }; const groupsWithTitles = Object.fromEntries( diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 54431d30e2..85037877a4 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -2116,6 +2116,24 @@ export const examples = { }, readme: "", }, + { + projectSlug: "vanilla-custom-side-menu", + fullSlug: "vanilla-js/vanilla-custom-side-menu", + pathFromRoot: "examples/vanilla-js/vanilla-custom-side-menu", + config: { + playground: true, + docs: true, + author: "matthewlipski", + tags: ["Advanced", "UI Components", "Block Side Menu"], + }, + title: "Custom Side Menu (Vanilla JS)", + group: { + pathFromRoot: "examples/vanilla-js", + slug: "vanilla-js", + }, + readme: + "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.\n\n**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!\n\n**Relevant Docs:**\n\n- [Getting Started with Vanilla JS](/docs/getting-started/vanilla-js)\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Extensions](/docs/features/extensions)", + }, ], }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 090c217f93..b7103cd48f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4863,6 +4863,49 @@ importers: specifier: 'catalog:' version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) + examples/vanilla-js/vanilla-custom-side-menu: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^9.0.2 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': + specifier: ^9.0.2 + version: 9.1.1(react@19.2.5) + react: + specifier: ^19.2.3 + version: 19.2.5 + react-dom: + specifier: ^19.2.3 + version: 19.2.5(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + vite-plus: + specifier: 'catalog:' + version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) + packages/ariakit: dependencies: '@ariakit/react': @@ -27212,7 +27255,7 @@ snapshots: picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 + tinyexec: 1.2.4 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 vite: 8.0.8(@types/node@20.19.37)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) @@ -27242,7 +27285,7 @@ snapshots: picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 + tinyexec: 1.2.4 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)