@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
text/typescript
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,
);