Skip to content

refactor: move all blocks to use the custom blocks API #1904

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion examples/01-basic/01-minimal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@blocknote/ariakit": "latest",
"@blocknote/mantine": "latest",
"@blocknote/shadcn": "latest",
"@blocknote/code-block": "latest",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
Expand All @@ -24,4 +25,4 @@
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.3.4"
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
BlockSchema,
checkBlockIsFileBlock,
blockHasType,
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
Expand Down Expand Up @@ -41,7 +41,7 @@ export const FileReplaceButton = () => {

if (
block === undefined ||
!checkBlockIsFileBlock(block, editor) ||
!blockHasType(block, editor, { url: "string" }) ||
!editor.isEditable
) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function insertBlocks<
// re-convert them into full `Block`s.
const insertedBlocks = nodesToInsert.map((node) =>
nodeToBlock(node, pmSchema),
);
) as Block<BSchema, I, S>[];

return insertedBlocks;
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function removeAndInsertBlocks<
// Converts the nodes created from `blocksToInsert` into full `Block`s.
const insertedBlocks = nodesToInsert.map((node) =>
nodeToBlock(node, pmSchema),
);
) as Block<BSchema, I, S>[];

return { insertedBlocks, removedBlocks };
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { EditorState } from "prosemirror-state";
import { EditorState, Transaction } from "prosemirror-state";

import {
getBlockInfo,
getNearestBlockPos,
} from "../../../getBlockInfoFromPos.js";
import { getPmSchema } from "../../../pmUtil.js";

export const splitBlockCommand = (
posInBlock: number,
Expand All @@ -17,33 +18,41 @@ export const splitBlockCommand = (
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
const nearestBlockContainerPos = getNearestBlockPos(state.doc, posInBlock);

const info = getBlockInfo(nearestBlockContainerPos);

if (!info.isBlockContainer) {
throw new Error(
`BlockContainer expected when calling splitBlock, position ${posInBlock}`,
);
}

const types = [
{
type: info.bnBlock.node.type, // always keep blockcontainer type
attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {},
},
{
type: keepType
? info.blockContent.node.type
: state.schema.nodes["paragraph"],
attrs: keepProps ? { ...info.blockContent.node.attrs } : {},
},
];

if (dispatch) {
state.tr.split(posInBlock, 2, types);
return splitBlockTr(state.tr, posInBlock, keepType, keepProps);
}

return true;
};
};

export const splitBlockTr = (
tr: Transaction,
posInBlock: number,
keepType?: boolean,
keepProps?: boolean,
): boolean => {
const nearestBlockContainerPos = getNearestBlockPos(tr.doc, posInBlock);

const info = getBlockInfo(nearestBlockContainerPos);

if (!info.isBlockContainer) {
return false;
}
const schema = getPmSchema(tr);

const types = [
{
type: info.bnBlock.node.type, // always keep blockcontainer type
attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {},
},
{
type: keepType ? info.blockContent.node.type : schema.nodes["paragraph"],
attrs: keepProps ? { ...info.blockContent.node.attrs } : {},
},
];

tr.split(posInBlock, 2, types);

return true;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Block, PartialBlock } from "../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
import {
BlockSchema,
FileBlockConfig,
InlineContentSchema,
StyleSchema,
} from "../../../schema/index.js";
Expand Down Expand Up @@ -106,15 +105,11 @@ export async function handleFileInsertion<

event.preventDefault();

const fileBlockConfigs = Object.values(editor.schema.blockSchema).filter(
(blockConfig) => blockConfig.isFileBlock,
) as FileBlockConfig[];

for (let i = 0; i < items.length; i++) {
// Gets file block corresponding to MIME type.
let fileBlockType = "file";
for (const fileBlockConfig of fileBlockConfigs) {
for (const mimeType of fileBlockConfig.fileBlockAccept || []) {
for (const fileBlockConfig of Object.values(editor.schema.blockSchema)) {
for (const mimeType of fileBlockConfig.meta?.fileBlockAccept || []) {
const isFileExtension = mimeType.startsWith(".");
const file = items[i].getAsFile();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ function fragmentToExternalHTML<
);
externalHTML = externalHTMLExporter.exportInlineContent(ic, {});
} else {
const blocks = fragmentToBlocks(selectedFragment);
const blocks = fragmentToBlocks<BSchema, I, S>(selectedFragment);
externalHTML = externalHTMLExporter.exportBlocks(blocks, {});
}
return externalHTML;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,19 @@ function serializeBlock<
// we should change toExternalHTML so that this is not necessary
const attrs = Array.from(bc.dom.attributes);

const ret = editor.blockImplementations[
block.type as any
].implementation.toExternalHTML({ ...block, props } as any, editor as any);
const blockImplementation =
editor.blockImplementations[block.type as any].implementation;
const ret =
blockImplementation.toExternalHTML?.(
{ ...block, props } as any,
editor as any,
) || blockImplementation.render({ ...block, props } as any, editor as any);

const elementFragment = doc.createDocumentFragment();
if (ret.dom.classList.contains("bn-block-content")) {
if (
ret.dom instanceof HTMLElement &&
ret.dom.classList.contains("bn-block-content")
) {
const blockContentDataAttributes = [
...attrs,
...Array.from(ret.dom.attributes),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function serializeBlock<
}

const impl = editor.blockImplementations[block.type as any].implementation;
const ret = impl.toInternalHTML({ ...block, props } as any, editor as any);
const ret = impl.render({ ...block, props } as any, editor as any);

if (block.type === "numberedListItem") {
// This is a workaround to make sure there's a list index set.
Expand All @@ -83,7 +83,9 @@ function serializeBlock<
// - (a) this information is not available on the Blocks passed to the serializer. (we only have access to BlockNote Blocks)
// - (b) the NumberedListIndexingPlugin might not even have run, because we can manually call blocksToFullHTML
// with blocks that are not part of the active document
ret.dom.setAttribute("data-index", listIndex.toString());
if (ret.dom instanceof HTMLElement) {
ret.dom.setAttribute("data-index", listIndex.toString());
}
}

if (ret.contentDOM && block.content) {
Expand Down
172 changes: 172 additions & 0 deletions packages/core/src/blocks/Audio/block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockNoDefaults,
createBlockConfig,
createBlockSpec,
} from "../../schema/index.js";
import { defaultProps } from "../defaultProps.js";
import { parseFigureElement } from "../File/helpers/parse/parseFigureElement.js";
import { createFileBlockWrapper } from "../File/helpers/render/createFileBlockWrapper.js";
import { createFigureWithCaption } from "../File/helpers/toExternalHTML/createFigureWithCaption.js";
import { createLinkWithCaption } from "../File/helpers/toExternalHTML/createLinkWithCaption.js";
import { parseAudioElement } from "./parseAudioElement.js";

export const FILE_AUDIO_ICON_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 16.0001H5.88889L11.1834 20.3319C11.2727 20.405 11.3846 20.4449 11.5 20.4449C11.7761 20.4449 12 20.2211 12 19.9449V4.05519C12 3.93977 11.9601 3.8279 11.887 3.73857C11.7121 3.52485 11.3971 3.49335 11.1834 3.66821L5.88889 8.00007H2C1.44772 8.00007 1 8.44778 1 9.00007V15.0001C1 15.5524 1.44772 16.0001 2 16.0001ZM23 12C23 15.292 21.5539 18.2463 19.2622 20.2622L17.8445 18.8444C19.7758 17.1937 21 14.7398 21 12C21 9.26016 19.7758 6.80629 17.8445 5.15557L19.2622 3.73779C21.5539 5.75368 23 8.70795 23 12ZM18 12C18 10.0883 17.106 8.38548 15.7133 7.28673L14.2842 8.71584C15.3213 9.43855 16 10.64 16 12C16 13.36 15.3213 14.5614 14.2842 15.2841L15.7133 16.7132C17.106 15.6145 18 13.9116 18 12Z"></path></svg>';

export interface AudioOptions {
icon?: string;
}

export const createAudioBlockConfig = createBlockConfig(
(_ctx: AudioOptions) =>
({
type: "audio" as const,
propSchema: {
backgroundColor: defaultProps.backgroundColor,
// File name.
name: {
default: "" as const,
},
// File url.
url: {
default: "" as const,
},
// File caption.
caption: {
default: "" as const,
},

showPreview: {
default: true,
},
},
content: "none",
meta: {
fileBlockAccept: ["audio/*"],
},
}) as const,
);

export const audioParse =
(_config: AudioOptions = {}) =>
(element: HTMLElement) => {
if (element.tagName === "AUDIO") {
// Ignore if parent figure has already been parsed.
if (element.closest("figure")) {
return undefined;
}

return parseAudioElement(element as HTMLAudioElement);
}

if (element.tagName === "FIGURE") {
const parsedFigure = parseFigureElement(element, "audio");
if (!parsedFigure) {
return undefined;
}

const { targetElement, caption } = parsedFigure;

return {
...parseAudioElement(targetElement as HTMLAudioElement),
caption,
};
}

return undefined;
};

export const audioRender =
(config: AudioOptions = {}) =>
(
block: BlockNoDefaults<
Record<"audio", ReturnType<typeof createAudioBlockConfig>>,
any,
any
>,
editor: BlockNoteEditor<
Record<"audio", ReturnType<typeof createAudioBlockConfig>>,
any,
any
>,
) => {
const icon = document.createElement("div");
icon.innerHTML = config.icon ?? FILE_AUDIO_ICON_SVG;

const audio = document.createElement("audio");
audio.className = "bn-audio";
if (editor.resolveFileUrl) {
editor.resolveFileUrl(block.props.url).then((downloadUrl) => {
audio.src = downloadUrl;
});
} else {
audio.src = block.props.url;
}
audio.controls = true;
audio.contentEditable = "false";
audio.draggable = false;

return createFileBlockWrapper(
block,
editor,
{ dom: audio },
editor.dictionary.file_blocks.audio.add_button_text,
icon.firstElementChild as HTMLElement,
);
};

export const audioToExternalHTML =
(_config: AudioOptions = {}) =>
(
block: BlockNoDefaults<
Record<"audio", ReturnType<typeof createAudioBlockConfig>>,
any,
any
>,
_editor: BlockNoteEditor<
Record<"audio", ReturnType<typeof createAudioBlockConfig>>,
any,
any
>,
) => {
if (!block.props.url) {
const div = document.createElement("p");
div.textContent = "Add audio";

return {
dom: div,
};
}

let audio;
if (block.props.showPreview) {
audio = document.createElement("audio");
audio.src = block.props.url;
} else {
audio = document.createElement("a");
audio.href = block.props.url;
audio.textContent = block.props.name || block.props.url;
}

if (block.props.caption) {
if (block.props.showPreview) {
return createFigureWithCaption(audio, block.props.caption);
} else {
return createLinkWithCaption(audio, block.props.caption);
}
}

return {
dom: audio,
};
};

export const createAudioBlockSpec = createBlockSpec(
createAudioBlockConfig,
).implementation((config = {}) => ({
parse: audioParse(config),
render: audioRender(config),
toExternalHTML: audioToExternalHTML(config),
runsBefore: ["file"],
}));
Loading