UNPKG

@blocknote/core

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

446 lines (396 loc) 12.1 kB
import type { HighlighterGeneric } from "@shikijs/types"; import { InputRule, isTextSelection } from "@tiptap/core"; import { TextSelection } from "@tiptap/pm/state"; import { Parser, createHighlightPlugin } from "prosemirror-highlight"; import { createParser } from "prosemirror-highlight/shiki"; import { BlockNoteEditor } from "../../index.js"; import { PropSchema, createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, } from "../../schema/index.js"; import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; 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>>; }; type CodeBlockConfigOptions = { editor: BlockNoteEditor<any, any, any>; }; export const shikiParserSymbol = Symbol.for("blocknote.shikiParser"); export const shikiHighlighterPromiseSymbol = Symbol.for( "blocknote.shikiHighlighterPromise", ); export const defaultCodeBlockPropSchema = { language: { default: "text", }, } satisfies PropSchema; const CodeBlockContent = createStronglyTypedTiptapNode({ name: "codeBlock", content: "inline*", group: "blockContent", marks: "insertion deletion modification", code: true, defining: true, addOptions() { return { defaultLanguage: "text", indentLineWithTab: true, supportedLanguages: {}, }; }, addAttributes() { const options = this.options as CodeBlockConfigOptions; return { language: { default: options.editor.settings.codeBlock.defaultLanguage, parseHTML: (inputElement) => { let element = inputElement as HTMLElement | null; let language: string | null = null; if ( element?.tagName === "DIV" && element?.dataset.contentType === "codeBlock" ) { element = element.children[0] as HTMLElement | null; } if (element?.tagName === "PRE") { element = element?.children[0] as HTMLElement | null; } const dataLanguage = element?.getAttribute("data-language"); if (dataLanguage) { language = dataLanguage.toLowerCase(); } else { const classNames = [...(element?.className.split(" ") || [])]; const languages = classNames .filter((className) => className.startsWith("language-")) .map((className) => className.replace("language-", "")); if (languages.length > 0) { language = languages[0].toLowerCase(); } } if (!language) { return null; } return ( getLanguageId(options.editor.settings.codeBlock, language) ?? language ); }, renderHTML: (attributes) => { return attributes.language ? { class: `language-${attributes.language}`, "data-language": attributes.language, } : {}; }, }, }; }, parseHTML() { return [ // Parse from internal HTML. { tag: "div[data-content-type=" + this.name + "]", contentElement: ".bn-inline-content", }, // Parse from external HTML. { tag: "pre", // contentElement: "code", preserveWhitespace: "full", }, ]; }, renderHTML({ HTMLAttributes }) { const pre = document.createElement("pre"); const { dom, contentDOM } = createDefaultBlockDOMOutputSpec( this.name, "code", this.options.domAttributes?.blockContent || {}, { ...(this.options.domAttributes?.inlineContent || {}), ...HTMLAttributes, }, ); dom.removeChild(contentDOM); dom.appendChild(pre); pre.appendChild(contentDOM); return { dom, contentDOM, }; }, addNodeView() { const options = this.options as CodeBlockConfigOptions; return ({ editor, node, getPos, HTMLAttributes }) => { const pre = document.createElement("pre"); const select = document.createElement("select"); const selectWrapper = document.createElement("div"); const { dom, contentDOM } = createDefaultBlockDOMOutputSpec( this.name, "code", { ...(this.options.domAttributes?.blockContent || {}), ...HTMLAttributes, }, this.options.domAttributes?.inlineContent || {}, ); const handleLanguageChange = (event: Event) => { const language = (event.target as HTMLSelectElement).value; editor.commands.command(({ tr }) => { tr.setNodeAttribute(getPos(), "language", language); return true; }); }; Object.entries( options.editor.settings.codeBlock.supportedLanguages, ).forEach(([id, { name }]) => { const option = document.createElement("option"); option.value = id; option.text = name; select.appendChild(option); }); selectWrapper.contentEditable = "false"; select.value = node.attrs.language || options.editor.settings.codeBlock.defaultLanguage; dom.removeChild(contentDOM); dom.appendChild(selectWrapper); dom.appendChild(pre); pre.appendChild(contentDOM); selectWrapper.appendChild(select); select.addEventListener("change", handleLanguageChange); return { dom, contentDOM, update: (newNode) => { if (newNode.type !== this.type) { return false; } return true; }, destroy: () => { select.removeEventListener("change", handleLanguageChange); }, }; }; }, addProseMirrorPlugins() { const options = this.options as CodeBlockConfigOptions; const globalThisForShiki = globalThis as { [shikiHighlighterPromiseSymbol]?: Promise<HighlighterGeneric<any, any>>; [shikiParserSymbol]?: Parser; }; let highlighter: HighlighterGeneric<any, any> | undefined; let parser: Parser | undefined; let hasWarned = false; const lazyParser: Parser = (parserOptions) => { if (!options.editor.settings.codeBlock.createHighlighter) { if (process.env.NODE_ENV === "development" && !hasWarned) { // eslint-disable-next-line no-console console.log( "For syntax highlighting of code blocks, you must provide a `codeBlock.createHighlighter` function", ); hasWarned = true; } return []; } if (!highlighter) { globalThisForShiki[shikiHighlighterPromiseSymbol] = globalThisForShiki[shikiHighlighterPromiseSymbol] || options.editor.settings.codeBlock.createHighlighter(); return globalThisForShiki[shikiHighlighterPromiseSymbol].then( (createdHighlighter) => { highlighter = createdHighlighter; }, ); } const language = getLanguageId( options.editor.settings.codeBlock, parserOptions.language!, ); if ( !language || language === "text" || language === "none" || language === "plaintext" || language === "txt" ) { return []; } if (!highlighter.getLoadedLanguages().includes(language)) { return highlighter.loadLanguage(language); } if (!parser) { parser = globalThisForShiki[shikiParserSymbol] || createParser(highlighter as any); globalThisForShiki[shikiParserSymbol] = parser; } return parser(parserOptions); }; const shikiLazyPlugin = createHighlightPlugin({ parser: lazyParser, languageExtractor: (node) => node.attrs.language, nodeTypes: [this.name], }); return [shikiLazyPlugin]; }, addInputRules() { const options = this.options as CodeBlockConfigOptions; return [ new InputRule({ find: /^```(.*?)\s$/, handler: ({ state, range, match }) => { const $start = state.doc.resolve(range.from); const languageName = match[1].trim(); const attributes = { language: getLanguageId(options.editor.settings.codeBlock, languageName) ?? languageName, }; if ( !$start .node(-1) .canReplaceWith( $start.index(-1), $start.indexAfter(-1), this.type, ) ) { return null; } state.tr .delete(range.from, range.to) .setBlockType(range.from, range.from, this.type, attributes) .setSelection(TextSelection.create(state.tr.doc, range.from)); return; }, }), ]; }, addKeyboardShortcuts() { return { Delete: ({ editor }) => { const { selection } = editor.state; const { $from } = selection; // When inside empty codeblock, on `DELETE` key press, delete the codeblock if ( editor.isActive(this.name) && !$from.parent.textContent && isTextSelection(selection) ) { // Get the start position of the codeblock for node selection const from = $from.pos - $from.parentOffset - 2; editor.chain().setNodeSelection(from).deleteSelection().run(); return true; } return false; }, Tab: ({ editor }) => { if (!this.options.indentLineWithTab) { return false; } if (editor.isActive(this.name)) { editor.commands.insertContent(" "); return true; } return false; }, Enter: ({ editor }) => { const { $from } = editor.state.selection; if (!editor.isActive(this.name)) { return false; } const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; const endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n"); if (!isAtEnd || !endsWithDoubleNewline) { editor.commands.insertContent("\n"); return true; } return editor .chain() .command(({ tr }) => { tr.delete($from.pos - 2, $from.pos); return true; }) .exitCode() .run(); }, "Shift-Enter": ({ editor }) => { const { $from } = editor.state.selection; if (!editor.isActive(this.name)) { return false; } editor .chain() .insertContentAt( $from.pos - $from.parentOffset + $from.parent.nodeSize, { type: "paragraph", }, ) .run(); return true; }, }; }, }); export const CodeBlock = createBlockSpecFromStronglyTypedTiptapNode( CodeBlockContent, defaultCodeBlockPropSchema, ); function getLanguageId( options: CodeBlockOptions, languageName: string, ): string | undefined { return Object.entries(options.supportedLanguages).find( ([id, { aliases }]) => { return aliases?.includes(languageName) || id === languageName; }, )?.[0]; }