@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
305 lines (271 loc) • 8.7 kB
text/typescript
import type { HighlighterGeneric } from "@shikijs/types";
import { createExtension } from "../../editor/BlockNoteExtension.js";
import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
import { lazyShikiPlugin } from "./shiki.js";
import { DOMParser } from "@tiptap/pm/model";
export type CodeBlockOptions = {
/**
* Whether to indent lines with a tab when the user presses `Tab` in a code block.
*
* @default true
*/
indentLineWithTab?: boolean;
/**
* The default language to use for code blocks.
*
* @default "text"
*/
defaultLanguage?: string;
/**
* The languages that are supported in the editor.
*
* @example
* {
* javascript: {
* name: "JavaScript",
* aliases: ["js"],
* },
* typescript: {
* name: "TypeScript",
* aliases: ["ts"],
* },
* }
*/
supportedLanguages?: Record<
string,
{
/**
* The display name of the language.
*/
name: string;
/**
* Aliases for this language.
*/
aliases?: string[];
}
>;
/**
* The highlighter to use for code blocks.
*/
createHighlighter?: () => Promise<HighlighterGeneric<any, any>>;
};
export type CodeBlockConfig = ReturnType<typeof createCodeBlockConfig>;
export const createCodeBlockConfig = createBlockConfig(
({ defaultLanguage = "text" }: CodeBlockOptions) =>
({
type: "codeBlock" as const,
propSchema: {
language: {
default: defaultLanguage,
},
},
content: "inline",
}) as const,
);
export const createCodeBlockSpec = createBlockSpec(
createCodeBlockConfig,
(options) => ({
meta: {
code: true,
defining: true,
isolating: false,
},
parse: (e) => {
if (e.tagName !== "PRE") {
return undefined;
}
if (
e.childElementCount !== 1 ||
e.firstElementChild?.tagName !== "CODE"
) {
return undefined;
}
const code = e.firstElementChild!;
const language =
code.getAttribute("data-language") ||
code.className
.split(" ")
.find((name) => name.includes("language-"))
?.replace("language-", "");
return { language };
},
parseContent: ({ el, schema }) => {
const parser = DOMParser.fromSchema(schema);
const code = el.firstElementChild!;
return parser.parse(code, {
preserveWhitespace: "full",
topNode: schema.nodes["codeBlock"].create(),
}).content;
},
render(block, editor) {
const wrapper = document.createDocumentFragment();
const pre = document.createElement("pre");
const code = document.createElement("code");
pre.appendChild(code);
let removeSelectChangeListener = undefined;
if (options.supportedLanguages) {
const select = document.createElement("select");
Object.entries(options.supportedLanguages ?? {}).forEach(
([id, { name }]) => {
const option = document.createElement("option");
option.value = id;
option.text = name;
select.appendChild(option);
},
);
select.value =
block.props.language || options.defaultLanguage || "text";
const handleLanguageChange = (event: Event) => {
const language = (event.target as HTMLSelectElement).value;
editor.updateBlock(block.id, { props: { language } });
};
select.addEventListener("change", handleLanguageChange);
removeSelectChangeListener = () =>
select.removeEventListener("change", handleLanguageChange);
const selectWrapper = document.createElement("div");
selectWrapper.contentEditable = "false";
selectWrapper.appendChild(select);
wrapper.appendChild(selectWrapper);
}
wrapper.appendChild(pre);
return {
dom: wrapper,
contentDOM: code,
destroy: () => {
removeSelectChangeListener?.();
},
};
},
toExternalHTML(block) {
const pre = document.createElement("pre");
const code = document.createElement("code");
code.className = `language-${block.props.language}`;
code.dataset.language = block.props.language;
pre.appendChild(code);
return {
dom: pre,
contentDOM: code,
};
},
}),
(options) => {
return [
createExtension({
key: "code-block-highlighter",
prosemirrorPlugins: [lazyShikiPlugin(options)],
}),
createExtension({
key: "code-block-keyboard-shortcuts",
keyboardShortcuts: {
Delete: ({ editor }) => {
return editor.transact((tr) => {
const { block } = editor.getTextCursorPosition();
if (block.type !== "codeBlock") {
return false;
}
const { $from } = tr.selection;
// When inside empty codeblock, on `DELETE` key press, delete the codeblock
if (!$from.parent.textContent) {
editor.removeBlocks([block]);
return true;
}
return false;
});
},
Tab: ({ editor }) => {
if (options.indentLineWithTab === false) {
return false;
}
return editor.transact((tr) => {
const { block } = editor.getTextCursorPosition();
if (block.type === "codeBlock") {
// TODO should probably only tab when at a line start or already tabbed in
tr.insertText(" ");
return true;
}
return false;
});
},
Enter: ({ editor }) => {
return editor.transact((tr) => {
const { block, nextBlock } = editor.getTextCursorPosition();
if (block.type !== "codeBlock") {
return false;
}
const { $from } = tr.selection;
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
const endsWithDoubleNewline =
$from.parent.textContent.endsWith("\n\n");
// The user is trying to exit the code block by pressing enter at the end of the code block
if (isAtEnd && endsWithDoubleNewline) {
// Remove the double newline
tr.delete($from.pos - 2, $from.pos);
// If there is a next block, move the cursor to it
if (nextBlock) {
editor.setTextCursorPosition(nextBlock, "start");
return true;
}
// If there is no next block, insert a new paragraph
const [newBlock] = editor.insertBlocks(
[{ type: "paragraph" }],
block,
"after",
);
// Move the cursor to the new block
editor.setTextCursorPosition(newBlock, "start");
return true;
}
tr.insertText("\n");
return true;
});
},
"Shift-Enter": ({ editor }) => {
return editor.transact(() => {
const { block } = editor.getTextCursorPosition();
if (block.type !== "codeBlock") {
return false;
}
const [newBlock] = editor.insertBlocks(
// insert a new paragraph
[{ type: "paragraph" }],
block,
"after",
);
// move the cursor to the new block
editor.setTextCursorPosition(newBlock, "start");
return true;
});
},
},
inputRules: [
{
find: /^```(.*?)\s$/,
replace: ({ match }) => {
const languageName = match[1].trim();
const attributes = {
language: getLanguageId(options, languageName) ?? languageName,
};
return {
type: "codeBlock",
props: {
language: attributes.language,
},
content: [],
};
},
},
],
}),
];
},
);
export function getLanguageId(
options: CodeBlockOptions,
languageName: string,
): string | undefined {
return Object.entries(options.supportedLanguages ?? {}).find(
([id, { aliases }]) => {
return aliases?.includes(languageName) || id === languageName;
},
)?.[0];
}