import { findParentNode, mergeAttributes, Node, wrappingInputRule, } from "@tiptap/core"; import { TextSelection } from "@tiptap/pm/state"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { CalloutType, getValidCalloutType } from "./utils"; export interface CalloutOptions { HTMLAttributes: Record; view: any; } export interface CalloutAttributes { /** * The type of callout. */ type: CalloutType; } declare module "@tiptap/core" { interface Commands { callout: { setCallout: (attributes?: CalloutAttributes) => ReturnType; unsetCallout: () => ReturnType; toggleCallout: (attributes?: CalloutAttributes) => ReturnType; updateCalloutType: (type: CalloutType) => ReturnType; }; } } /** * Matches a callout to a `:::` as input. */ export const inputRegex = /^:::([a-z]+)?[\s\n]$/; export const Callout = Node.create({ name: "callout", addOptions() { return { HTMLAttributes: {}, view: null, }; }, content: "block+", group: "block", defining: true, addAttributes() { return { type: { default: "info", parseHTML: (element) => element.getAttribute("data-callout-type"), renderHTML: (attributes) => ({ "data-callout-type": attributes.type, }), }, }; }, parseHTML() { return [ { tag: `div[data-type="${this.name}"]`, }, ]; }, renderHTML({ HTMLAttributes }) { return [ "div", mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, HTMLAttributes, ), 0, ]; }, addCommands() { return { setCallout: (attributes) => ({ commands }) => { return commands.setNode(this.name, attributes); }, unsetCallout: () => ({ commands }) => { return commands.lift(this.name); }, toggleCallout: (attributes) => ({ commands }) => { return commands.toggleWrap(this.name, attributes); }, updateCalloutType: (type: string) => ({ commands }) => commands.updateAttributes("callout", { type: getValidCalloutType(type), }), }; }, addNodeView() { return ReactNodeViewRenderer(this.options.view); }, addKeyboardShortcuts() { return { //"Mod-Shift-c": () => this.editor.commands.toggleCallout(), /** * Handle the backspace key when deleting content. * Aims to stop merging callouts when deleting content in between. */ Backspace: ({ editor }) => { const { state, view } = editor; const { selection } = state; // If the selection is not empty, return false // and let other extension handle the deletion. if (!selection.empty) { return false; } const { $from } = selection; // If not at the start of current node, no joining will happen if ($from.parentOffset !== 0) { return false; } const previousPosition = $from.before($from.depth) - 1; // If nothing above to join with if (previousPosition < 1) { return false; } const previousPos = state.doc.resolve(previousPosition); // If resolving previous position fails, bail out if (!previousPos?.parent) { return false; } const previousNode = previousPos.parent; const parentNode = findParentNode(() => true)(selection); if (!parentNode) { return false; } const { node, pos, depth } = parentNode; // If current node is nested if (depth !== 1) { return false; } // If previous node is a callout, cut current node's content into it if (node.type !== this.type && previousNode.type === this.type) { const { content, nodeSize } = node; const { tr } = state; tr.delete(pos, pos + nodeSize); tr.setSelection( TextSelection.near(tr.doc.resolve(previousPosition - 1)), ); tr.insert(previousPosition - 1, content); view.dispatch(tr); return true; } return false; }, }; }, addInputRules() { return [ wrappingInputRule({ find: inputRegex, type: this.type, getAttributes: (match) => ({ type: getValidCalloutType(match[1]), }), }), ]; }, });