UNPKG

@blocknote/core

Version:

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

300 lines (265 loc) 9.19 kB
import { InputRule } from "@tiptap/core"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromSelection, getNearestBlockPos, } from "../../../api/getBlockInfoFromPos.js"; import { PropSchema, createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, propsToAttributes, } from "../../../schema/index.js"; import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js"; import { defaultProps } from "../../defaultProps.js"; import { getListItemContent } from "../getListItemContent.js"; import { handleEnter } from "../ListItemKeyboardShortcuts.js"; export const checkListItemPropSchema = { ...defaultProps, checked: { default: false, }, } satisfies PropSchema; const checkListItemBlockContent = createStronglyTypedTiptapNode({ name: "checkListItem", content: "inline*", group: "blockContent", addAttributes() { return propsToAttributes(checkListItemPropSchema); }, addInputRules() { return [ // Creates a checklist when starting with "[]" or "[X]". new InputRule({ find: new RegExp(`\\[\\s*\\]\\s$`), handler: ({ state, chain, range }) => { const blockInfo = getBlockInfoFromSelection(state); if ( !blockInfo.isBlockContainer || blockInfo.blockContent.node.type.spec.content !== "inline*" ) { return; } chain() .command( updateBlockCommand(blockInfo.bnBlock.beforePos, { type: "checkListItem", props: { checked: false as any, }, }), ) // Removes the characters used to set the list. .deleteRange({ from: range.from, to: range.to }); }, }), new InputRule({ find: new RegExp(`\\[[Xx]\\]\\s$`), handler: ({ state, chain, range }) => { const blockInfo = getBlockInfoFromSelection(state); if ( !blockInfo.isBlockContainer || blockInfo.blockContent.node.type.spec.content !== "inline*" ) { return; } chain() .command( updateBlockCommand(blockInfo.bnBlock.beforePos, { type: "checkListItem", props: { checked: true as any, }, }), ) // Removes the characters used to set the list. .deleteRange({ from: range.from, to: range.to }); }, }), ]; }, addKeyboardShortcuts() { return { Enter: () => handleEnter(this.options.editor), "Mod-Shift-9": () => { const blockInfo = getBlockInfoFromSelection(this.editor.state); if ( !blockInfo.isBlockContainer || blockInfo.blockContent.node.type.spec.content !== "inline*" ) { return true; } return this.editor.commands.command( updateBlockCommand(blockInfo.bnBlock.beforePos, { type: "checkListItem", props: {}, }), ); }, }; }, parseHTML() { return [ // Parse from internal HTML. { tag: "div[data-content-type=" + this.name + "]", contentElement: ".bn-inline-content", }, // Parse from external HTML. { tag: "input", getAttrs: (element) => { if (typeof element === "string") { return false; } // Ignore if we already parsed an ancestor list item to avoid double-parsing. if (element.closest("[data-content-type]") || element.closest("li")) { return false; } if ((element as HTMLInputElement).type === "checkbox") { return { checked: (element as HTMLInputElement).checked }; } return false; }, node: "checkListItem", }, { tag: "li", getAttrs: (element) => { if (typeof element === "string") { return false; } const parent = element.parentElement; if (parent === null) { return false; } if ( parent.tagName === "UL" || (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL") ) { const checkbox = (element.querySelector( "input[type=checkbox]", ) as HTMLInputElement) || null; if (checkbox === null) { return false; } return { checked: checkbox.checked }; } return false; }, // As `li` elements can contain multiple paragraphs, we need to merge their contents // into a single one so that ProseMirror can parse everything correctly. getContent: (node, schema) => getListItemContent(node, schema, this.name), node: "checkListItem", }, ]; }, // Since there is no HTML checklist element, there isn't really any // standardization for what checklists should look like in the DOM. GDocs' // and Notion's aren't cross compatible, for example. This implementation // has a semantically correct DOM structure (though missing a label for the // checkbox) which is also converted correctly to Markdown by remark. renderHTML({ node, HTMLAttributes }) { const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = node.attrs.checked; if (node.attrs.checked) { checkbox.setAttribute("checked", ""); } const { dom, contentDOM } = createDefaultBlockDOMOutputSpec( this.name, "p", { ...(this.options.domAttributes?.blockContent || {}), ...HTMLAttributes, }, this.options.domAttributes?.inlineContent || {}, ); dom.insertBefore(checkbox, contentDOM); return { dom, contentDOM }; }, // Need to render node view since the checkbox needs to be able to update the // node. This is only possible with a node view as it exposes `getPos`. addNodeView() { return ({ node, getPos, editor, HTMLAttributes }) => { // Need to wrap certain elements in a div or keyboard navigation gets // confused. const wrapper = document.createElement("div"); const checkboxWrapper = document.createElement("div"); checkboxWrapper.contentEditable = "false"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = node.attrs.checked; if (node.attrs.checked) { checkbox.setAttribute("checked", ""); } const changeHandler = () => { if (!editor.isEditable) { // This seems like the most effective way of blocking the checkbox // from being toggled, as event.preventDefault() does not stop it for // "click" or "change" events. checkbox.checked = !checkbox.checked; return; } // TODO: test if (typeof getPos !== "boolean") { const beforeBlockContainerPos = getNearestBlockPos( editor.state.doc, getPos(), ); if (beforeBlockContainerPos.node.type.name !== "blockContainer") { throw new Error( `Expected blockContainer node, got ${beforeBlockContainerPos.node.type.name}`, ); } this.editor.commands.command( updateBlockCommand(beforeBlockContainerPos.posBeforeNode, { type: "checkListItem", props: { checked: checkbox.checked as any, }, }), ); } }; checkbox.addEventListener("change", changeHandler); const { dom, contentDOM } = createDefaultBlockDOMOutputSpec( this.name, "p", { ...(this.options.domAttributes?.blockContent || {}), ...HTMLAttributes, }, this.options.domAttributes?.inlineContent || {}, ); if (typeof getPos !== "boolean") { // Since `node` is a blockContent node, we have to get the block ID from // the parent blockContainer node. This means we can't add the label in // `renderHTML` as we can't use `getPos` and therefore can't get the // parent blockContainer node. const blockID = this.editor.state.doc.resolve(getPos()).node().attrs.id; const label = "label-" + blockID; checkbox.setAttribute("aria-labelledby", label); contentDOM.id = label; } dom.removeChild(contentDOM); dom.appendChild(wrapper); wrapper.appendChild(checkboxWrapper); wrapper.appendChild(contentDOM); checkboxWrapper.appendChild(checkbox); return { dom, contentDOM, destroy: () => { checkbox.removeEventListener("change", changeHandler); }, }; }; }, }); export const CheckListItem = createBlockSpecFromStronglyTypedTiptapNode( checkListItemBlockContent, checkListItemPropSchema, );