Skip to content
Draft
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
7 changes: 7 additions & 0 deletions examples/06-custom-schema/13-callout-block/.bnexample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"playground": true,
"docs": true,
"author": "yousefed",
"tags": ["Intermediate", "Blocks", "Custom Schemas", "Nesting"],
"dependencies": {}
}
17 changes: 17 additions & 0 deletions examples/06-custom-schema/13-callout-block/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Callout Block with a Title and a Body

A callout is one block with two editable regions: a **title**, which is the
block's own rich text, and a **body**, which is the blocks nested under it.

Both are ordinary BlockNote content, so everything already works on them:
Enter splits the title, Tab indents inside the body, blocks can be dragged in
and out, and the whole thing serializes and pastes like any other block.

What makes them look like one box is `renderFrame`: the block returns the
markup that frames it, plus the `slot` element that BlockNote renders the
title and the body into.

**Relevant Docs:**

- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
- [Editor Setup](/docs/getting-started/editor-setup)
14 changes: 14 additions & 0 deletions examples/06-custom-schema/13-callout-block/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>Callout Block with a Title and a Body</title>
<script>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
11 changes: 11 additions & 0 deletions examples/06-custom-schema/13-callout-block/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/06-custom-schema/13-callout-block/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@blocknote/example-custom-schema-callout-block",
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
"type": "module",
"private": true,
"version": "0.12.4",
"scripts": {
"start": "vite",
"dev": "vite",
"build:prod": "tsc && vite build",
"preview": "vite 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": "^8.0.0"
}
}
53 changes: 53 additions & 0 deletions examples/06-custom-schema/13-callout-block/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { BlockNoteSchema } from "@blocknote/core";
import "@blocknote/core/fonts/inter.css";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { useCreateBlockNote } from "@blocknote/react";

import { createCallout } from "./Callout";
import "./styles.css";

// Our schema with block specs, which contain the configs and implementations
// for blocks that we want our editor to use.
const schema = BlockNoteSchema.create().extend({
blockSpecs: {
// Creates an instance of the Callout block and adds it to the schema.
callout: createCallout(),
},
});

export default function App() {
// Creates a new editor instance.
const editor = useCreateBlockNote({
schema,
initialContent: [
{
type: "paragraph",
content: "A callout has a title and a body:",
},
{
type: "callout",
props: { flavor: "warning" },
content: "Careful with this one",
children: [
{
type: "paragraph",
content:
"The body is made of nested blocks, so it takes anything: lists, headings, even another callout.",
},
{
type: "bulletListItem",
content: "Press Tab and Enter in here as usual",
},
],
},
{
type: "paragraph",
content: "Click the icon to change the callout's flavor.",
},
],
});

// Renders the editor instance.
return <BlockNoteView editor={editor} />;
}
73 changes: 73 additions & 0 deletions examples/06-custom-schema/13-callout-block/src/Callout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { createReactBlockSpec } from "@blocknote/react";

const FLAVORS = {
info: { emoji: "💡", label: "Info" },
warning: { emoji: "⚠️", label: "Warning" },
success: { emoji: "✅", label: "Success" },
} as const;

type Flavor = keyof typeof FLAVORS;

export const createCallout = createReactBlockSpec(
{
type: "callout" as const,
propSchema: {
flavor: {
default: "info" as const,
values: ["info", "warning", "success"] as const,
},
},
// The callout's own content is its title: ordinary rich text.
content: "inline" as const,
// ...and its children are its body. Declaring them a compartment is what
// makes the editing gestures treat the box as a unit: Enter at the end of
// the title starts the body, Shift-Tab doesn't escape it, and a block
// moved in from below arrives whole.
children: { allow: "any" as const },
},
{
// The title. `contentRef` marks the element the rich text goes in, exactly
// as in any other custom block.
render: (props) => (
<div className={"callout-title"} ref={props.contentRef} />
),

// The frame around the whole block. BlockNote renders the title *and* the
// block's nested children into `slot`, so the box wraps both.
renderFrame: (block, editor) => {
const dom = document.createElement("div");
dom.className = "callout";

const button = document.createElement("button");
button.className = "callout-flavor";
button.type = "button";
button.contentEditable = "false";

const slot = document.createElement("div");
slot.className = "callout-body";

// The button goes after the slot: BlockNote's drag handle hovers over
// the block's left edge, so a control there would sit under it.
dom.append(slot, button);

const paint = (flavor: Flavor) => {
dom.dataset.flavor = flavor;
button.textContent = FLAVORS[flavor].emoji;
button.title = `${FLAVORS[flavor].label} — click to change`;
};
paint(block.props.flavor);

// Cycles the flavor. `updateBlock` is the normal editor API; the frame
// is told about the new props through `update` below.
button.addEventListener("click", () => {
const flavors = Object.keys(FLAVORS) as Flavor[];
const current = dom.dataset.flavor as Flavor;
const next = flavors[(flavors.indexOf(current) + 1) % flavors.length];

editor.updateBlock(block, { props: { flavor: next } });
});

return { dom, slot, update: (updated) => paint(updated.props.flavor) };
},
},
);
52 changes: 52 additions & 0 deletions examples/06-custom-schema/13-callout-block/src/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
.callout {
display: flex;
gap: 0.5rem;
align-items: flex-start;
margin: 4px 0;
padding: 0.75rem;
border-radius: 6px;
border-left: 4px solid var(--callout-accent);
background: var(--callout-bg);
}

.callout[data-flavor="info"] {
--callout-accent: #3b82f6;
--callout-bg: #eff6ff;
}
.callout[data-flavor="warning"] {
--callout-accent: #f59e0b;
--callout-bg: #fffbeb;
}
.callout[data-flavor="success"] {
--callout-accent: #10b981;
--callout-bg: #ecfdf5;
}

.callout-flavor {
flex: none;
order: 2;
border: none;
background: none;
padding: 0;
font-size: 1.1rem;
line-height: 1.6;
cursor: pointer;
user-select: none;
}

/* The title and the body both render in here. */
.callout-body {
flex: 1;
min-width: 0;
order: 1;
}

.callout-title {
font-weight: 600;
}

/* The body's blocks are nested children, so they carry BlockNote's usual
nesting indent. Inside the callout the box already sets it off. */
.callout-body .bn-block-group {
padding-left: 0;
}
32 changes: 32 additions & 0 deletions examples/06-custom-schema/13-callout-block/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"__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,
"paths": {
"@shared/*": ["../../../shared/*"]
}
},
"include": ["."],
"__ADD_FOR_LOCAL_DEV_references": [
{
"path": "../../../packages/core/"
},
{
"path": "../../../packages/react/"
}
]
}
1 change: 1 addition & 0 deletions examples/06-custom-schema/13-callout-block/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
35 changes: 35 additions & 0 deletions examples/06-custom-schema/13-callout-block/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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";
// 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"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
// so it only resolves inside the monorepo). Harmless for examples
// that don't use it.
"@shared": path.resolve(__dirname, "../../../shared/"),
// 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<typeof defineConfig>[0]);
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Node } from "prosemirror-model";
import { EditorState } from "prosemirror-state";

import {
isCompartment,
isContainerNode,
} from "../../../../schema/blocks/containers.js";
import {
BlockInfo,
getBlockInfoFromResolvedPos,
Expand Down Expand Up @@ -165,6 +169,49 @@ const mergeBlocks = (
return true;
};

/**
* The block owning the compartment that the block at `beforePos` is the first
* child of - a callout, for the first block of its body. `undefined` when the
* block isn't the first child of a compartment.
*/
export const compartmentOwnerInfo = (doc: Node, beforePos: number) => {
const $pos = doc.resolve(beforePos);
if ($pos.index() !== 0 || $pos.depth < 2) {
return undefined;
}
// The body's own node is the compartment (a column), or it is a `blockGroup`
// and the compartment is the block holding it.
const ownerDepth = isContainerNode($pos.node().type)
? $pos.depth
: $pos.depth - 1;
const owner = $pos.node(ownerDepth);
if (ownerDepth < 1 || !isCompartment(owner)) {
return undefined;
}
return getBlockInfoFromResolvedPos(doc.resolve($pos.before(ownerDepth)));
};

/**
* Merges `nextBlockInfo` into `prevBlockInfo`, when both hold inline content.
* Unlike {@link mergeBlocksCommand} the two blocks are given rather than
* derived from a position, so blocks that aren't siblings can be merged - a
* compartment's first child into the block that owns it.
*/
export const mergeBlockPairCommand =
(prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) =>
({
state,
dispatch,
}: {
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
if (!canMerge(prevBlockInfo, nextBlockInfo)) {
return false;
}
return mergeBlocks(state, dispatch, prevBlockInfo, nextBlockInfo);
};

export const mergeBlocksCommand =
(posBetweenBlocks: number) =>
({
Expand Down
Loading
Loading