import { Node, textblockTypeInputRule } from '@tiptap/core' import { Plugin, PluginKey, TextSelection } from 'prosemirror-state' export interface CodeBlockOptions { languageClassPrefix: string, HTMLAttributes: Record, } declare module '@tiptap/core' { interface Commands { codeBlock: { /** * Set a code block */ setCodeBlock: (attributes?: { language: string }) => ReturnType, /** * Toggle a code block */ toggleCodeBlock: (attributes?: { language: string }) => ReturnType, } } } export const backtickInputRegex = /^```(?[a-z]*)?[\s\n]$/ export const tildeInputRegex = /^~~~(?[a-z]*)?[\s\n]$/ export const CodeBlock = Node.create({ name: 'codeBlock', addOptions() { return { languageClassPrefix: 'language-', HTMLAttributes: {}, } }, content: 'text*', marks: '', group: 'block', code: true, defining: true, addAttributes() { return { language: { default: null, parseHTML: element => { const { languageClassPrefix } = this.options const classNames = [...element.firstElementChild?.classList || []] const languages = classNames .filter(className => className.startsWith(languageClassPrefix)) .map(className => className.replace(languageClassPrefix, '')) const language = languages[0] if (!language) { return null } return language }, renderHTML: attributes => { if (!attributes.language) { return null } return { class: this.options.languageClassPrefix + attributes.language, } }, }, } }, parseHTML() { return [ { tag: 'pre', preserveWhitespace: 'full', }, ] }, renderHTML({ HTMLAttributes }) { return ['pre', this.options.HTMLAttributes, ['code', HTMLAttributes, 0]] }, addCommands() { return { setCodeBlock: attributes => ({ commands }) => { return commands.setNode('codeBlock', attributes) }, toggleCodeBlock: attributes => ({ commands }) => { return commands.toggleNode('codeBlock', 'paragraph', attributes) }, } }, addKeyboardShortcuts() { return { 'Mod-Alt-c': () => this.editor.commands.toggleCodeBlock(), // remove code block when at start of document or code block is empty Backspace: () => { const { empty, $anchor } = this.editor.state.selection const isAtStart = $anchor.pos === 1 if (!empty || $anchor.parent.type.name !== this.name) { return false } if (isAtStart || !$anchor.parent.textContent.length) { return this.editor.commands.clearNodes() } return false }, } }, addInputRules() { return [ textblockTypeInputRule({ find: backtickInputRegex, type: this.type, getAttributes: ({ groups }) => groups, }), textblockTypeInputRule({ find: tildeInputRegex, type: this.type, getAttributes: ({ groups }) => groups, }), ] }, addProseMirrorPlugins() { return [ // this plugin creates a code block for pasted content from VS Code // we can also detect the copied code language new Plugin({ key: new PluginKey('codeBlockVSCodeHandler'), props: { handlePaste: (view, event) => { if (!event.clipboardData) { return false } // don’t create a new code block within code blocks if (this.editor.isActive(this.type.name)) { return false } const text = event.clipboardData.getData('text/plain') const vscode = event.clipboardData.getData('vscode-editor-data') const vscodeData = vscode ? JSON.parse(vscode) : undefined const language = vscodeData?.mode if (!text || !language) { return false } const { tr } = view.state // create an empty code block tr.replaceSelectionWith(this.type.create({ language })) // put cursor inside the newly created code block tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, tr.selection.from - 2)))) // add text to code block // strip carriage return chars from text pasted as code // see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd tr.insertText(text.replace(/\r\n?/g, '\n')) // store meta information // this is useful for other plugins that depends on the paste event // like the paste rule plugin tr.setMeta('paste', true) view.dispatch(tr) return true }, }, }), ] }, })