From 4b9f284ed96dcb15022ed6b749e04bfa70cae121 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Fri, 8 Dec 2023 12:21:10 +0500 Subject: [PATCH] editor: implement simple checklists --- packages/editor/src/extensions.ts | 2 + .../check-list-item/check-list-item.ts | 208 ++++++++++++++++++ .../src/extensions/check-list-item/index.ts | 23 ++ .../src/extensions/check-list/check-list.ts | 87 ++++++++ .../editor/src/extensions/check-list/index.ts | 23 ++ packages/editor/src/index.ts | 9 + packages/editor/src/toolbar/icons.ts | 1 + .../editor/src/toolbar/tool-definitions.ts | 6 +- packages/editor/src/toolbar/tools/index.ts | 3 +- packages/editor/src/toolbar/tools/lists.tsx | 12 + packages/editor/styles/styles.css | 35 ++- 11 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 packages/editor/src/extensions/check-list-item/check-list-item.ts create mode 100644 packages/editor/src/extensions/check-list-item/index.ts create mode 100644 packages/editor/src/extensions/check-list/check-list.ts create mode 100644 packages/editor/src/extensions/check-list/index.ts diff --git a/packages/editor/src/extensions.ts b/packages/editor/src/extensions.ts index a8f1e2363..96e7f8e20 100644 --- a/packages/editor/src/extensions.ts +++ b/packages/editor/src/extensions.ts @@ -45,3 +45,5 @@ import "./extensions/list-item"; import "./extensions/outline-list"; import "./extensions/outline-list-item"; import "./extensions/table"; +import "./extensions/check-list"; +import "./extensions/check-list-item"; diff --git a/packages/editor/src/extensions/check-list-item/check-list-item.ts b/packages/editor/src/extensions/check-list-item/check-list-item.ts new file mode 100644 index 000000000..5e78c2f76 --- /dev/null +++ b/packages/editor/src/extensions/check-list-item/check-list-item.ts @@ -0,0 +1,208 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +import { + KeyboardShortcutCommand, + mergeAttributes, + Node, + wrappingInputRule +} from "@tiptap/core"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +export interface CheckListItemOptions { + onReadOnlyChecked?: (node: ProseMirrorNode, checked: boolean) => boolean; + nested: boolean; + HTMLAttributes: Record; + checkListTypeName: string; +} + +export const inputRegex = /^\s*(\[([( |x])?\])\s$/; + +export const CheckListItem = Node.create({ + name: "checkListItem", + + addOptions() { + return { + nested: false, + HTMLAttributes: {}, + checkListTypeName: "checkList" + }; + }, + + content() { + return this.options.nested ? "paragraph block*" : "paragraph+"; + }, + + defining: true, + + addAttributes() { + return { + checked: { + default: false, + keepOnSplit: false, + parseHTML: (element) => element.getAttribute("data-checked") === "true", + renderHTML: (attributes) => ({ + "data-checked": attributes.checked + }) + } + }; + }, + + parseHTML() { + return [ + { + tag: `li[data-type="${this.name}"]`, + priority: 51 + } + ]; + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + "li", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + "data-type": this.name + }), + [ + "label", + [ + "input", + { + type: "checkbox", + checked: node.attrs.checked ? "checked" : null + } + ], + ["span"] + ], + ["div", 0] + ]; + }, + + addKeyboardShortcuts() { + const shortcuts: { + [key: string]: KeyboardShortcutCommand; + } = { + Enter: () => this.editor.commands.splitListItem(this.name), + "Shift-Tab": () => this.editor.commands.liftListItem(this.name) + }; + + if (!this.options.nested) { + return shortcuts; + } + + return { + ...shortcuts, + Tab: () => this.editor.commands.sinkListItem(this.name) + }; + }, + + addNodeView() { + return ({ node, HTMLAttributes, getPos, editor }) => { + const listItem = document.createElement("li"); + const checkboxWrapper = document.createElement("label"); + const checkboxStyler = document.createElement("span"); + const checkbox = document.createElement("input"); + const content = document.createElement("div"); + + checkboxWrapper.contentEditable = "false"; + checkbox.type = "checkbox"; + checkbox.addEventListener("change", (event) => { + // if the editor isn’t editable and we don't have a handler for + // readonly checks we have to undo the latest change + if (!editor.isEditable && !this.options.onReadOnlyChecked) { + checkbox.checked = !checkbox.checked; + + return; + } + + const { checked } = event.target as any; + + if (editor.isEditable && typeof getPos === "function") { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .command(({ tr }) => { + const position = getPos(); + const currentNode = tr.doc.nodeAt(position); + + tr.setNodeMarkup(position, undefined, { + ...currentNode?.attrs, + checked + }); + + return true; + }) + .run(); + } + if (!editor.isEditable && this.options.onReadOnlyChecked) { + // Reset state if onReadOnlyChecked returns false + if (!this.options.onReadOnlyChecked(node, checked)) { + checkbox.checked = !checkbox.checked; + } + } + }); + + Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => { + listItem.setAttribute(key, value); + }); + + listItem.dataset.checked = node.attrs.checked; + if (node.attrs.checked) { + checkbox.setAttribute("checked", "checked"); + } + + checkboxWrapper.append(checkbox, checkboxStyler); + listItem.append(checkboxWrapper, content); + + Object.entries(HTMLAttributes).forEach(([key, value]) => { + listItem.setAttribute(key, value); + }); + + return { + dom: listItem, + contentDOM: content, + update: (updatedNode) => { + if (updatedNode.type !== this.type) { + return false; + } + + listItem.dataset.checked = updatedNode.attrs.checked; + if (updatedNode.attrs.checked) { + checkbox.setAttribute("checked", "checked"); + } else { + checkbox.removeAttribute("checked"); + } + + return true; + } + }; + }; + }, + + addInputRules() { + return [ + wrappingInputRule({ + find: inputRegex, + type: this.type, + getAttributes: (match) => ({ + checked: match[match.length - 1] === "x" + }) + }) + ]; + } +}); diff --git a/packages/editor/src/extensions/check-list-item/index.ts b/packages/editor/src/extensions/check-list-item/index.ts new file mode 100644 index 000000000..ddf88e3a9 --- /dev/null +++ b/packages/editor/src/extensions/check-list-item/index.ts @@ -0,0 +1,23 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +import { CheckListItem } from "./check-list-item"; + +export * from "./check-list-item"; + +export default CheckListItem; diff --git a/packages/editor/src/extensions/check-list/check-list.ts b/packages/editor/src/extensions/check-list/check-list.ts new file mode 100644 index 000000000..7338f4439 --- /dev/null +++ b/packages/editor/src/extensions/check-list/check-list.ts @@ -0,0 +1,87 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +import { mergeAttributes, Node } from "@tiptap/core"; + +export interface CheckListOptions { + itemTypeName: string; + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + checkList: { + /** + * Toggle a check list + */ + toggleCheckList: () => ReturnType; + }; + } +} + +export const CheckList = Node.create({ + name: "checkList", + + addOptions() { + return { + itemTypeName: "checkListItem", + HTMLAttributes: {} + }; + }, + + group: "block list", + + content() { + return `${this.options.itemTypeName}+`; + }, + + parseHTML() { + return [ + { + tag: `ul[data-type="${this.name}"]`, + priority: 51 + } + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "ul", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + "data-type": this.name + }), + 0 + ]; + }, + + addCommands() { + return { + toggleCheckList: + () => + ({ commands }) => { + return commands.toggleList(this.name, this.options.itemTypeName); + } + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-Shift-9": () => this.editor.commands.toggleCheckList() + }; + } +}); diff --git a/packages/editor/src/extensions/check-list/index.ts b/packages/editor/src/extensions/check-list/index.ts new file mode 100644 index 000000000..42bab716e --- /dev/null +++ b/packages/editor/src/extensions/check-list/index.ts @@ -0,0 +1,23 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +import { CheckList } from "./check-list"; + +export * from "./check-list"; + +export default CheckList; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 3bfb5eee4..767d675a2 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -83,6 +83,8 @@ import Clipboard, { ClipboardOptions } from "./extensions/clipboard"; import Blockquote from "./extensions/blockquote"; import { Quirks } from "./extensions/quirks"; import { LIST_NODE_TYPES } from "./utils/node-types"; +import CheckList from "./extensions/check-list"; +import CheckListItem from "./extensions/check-list-item"; declare global { // eslint-disable-next-line no-var @@ -281,6 +283,8 @@ const useTiptap = ( DateTime.configure({ dateFormat, timeFormat }), KeyMap, WebClipNode, + CheckList, + CheckListItem, // Quirks handlers Quirks.configure({ @@ -296,6 +300,7 @@ const useTiptap = ( ...LIST_NODE_TYPES ] }), + ListKeymap.configure({ listTypes: [ { @@ -309,6 +314,10 @@ const useTiptap = ( { itemName: OutlineListItem.name, wrapperNames: [OutlineList.name] + }, + { + itemName: CheckListItem.name, + wrapperNames: [CheckList.name] } ] }) diff --git a/packages/editor/src/toolbar/icons.ts b/packages/editor/src/toolbar/icons.ts index 2bd7e962c..f18a6b405 100644 --- a/packages/editor/src/toolbar/icons.ts +++ b/packages/editor/src/toolbar/icons.ts @@ -249,6 +249,7 @@ export const Icons = { readonlyOff: mdiPencil, selectAllUnchecked: mdiCheckboxMultipleBlankOutline, selectAllChecked: mdiCheckboxMultipleMarked, + checkList: mdiCheckboxMarkedOutline, none: "" }; diff --git a/packages/editor/src/toolbar/tool-definitions.ts b/packages/editor/src/toolbar/tool-definitions.ts index c12ff8121..5073a36bd 100644 --- a/packages/editor/src/toolbar/tool-definitions.ts +++ b/packages/editor/src/toolbar/tool-definitions.ts @@ -104,6 +104,10 @@ const tools: Record = { icon: "numberedList", title: "Numbered list" }, + checkList: { + icon: "checkList", + title: "Numbered list" + }, fontFamily: { icon: "fontFamily", title: "Font family" @@ -408,7 +412,7 @@ const defaultPresets: Record<"default" | "minimal", ToolbarDefinition> = { ], ["fontSize"], ["headings", "fontFamily"], - ["numberedList", "bulletList"], + ["checkList", "numberedList", "bulletList"], ["addLink"], ["alignment", "textDirection"], ["clearformatting"] diff --git a/packages/editor/src/toolbar/tools/index.ts b/packages/editor/src/toolbar/tools/index.ts index a87fbf771..4edfdcdb4 100644 --- a/packages/editor/src/toolbar/tools/index.ts +++ b/packages/editor/src/toolbar/tools/index.ts @@ -35,7 +35,7 @@ import { InsertBlock } from "./block"; import { FontSize, FontFamily } from "./font"; import { Alignment } from "./alignment"; import { Headings } from "./headings"; -import { NumberedList, BulletList, Outdent, Indent } from "./lists"; +import { NumberedList, BulletList, Outdent, Indent, CheckList } from "./lists"; import { TextDirection } from "./text-direction"; import { Highlight, TextColor } from "./colors"; import { @@ -117,6 +117,7 @@ const tools = { insertBlock: InsertBlock, numberedList: NumberedList, bulletList: BulletList, + checkList: CheckList, fontSize: FontSize, fontFamily: FontFamily, headings: Headings, diff --git a/packages/editor/src/toolbar/tools/lists.tsx b/packages/editor/src/toolbar/tools/lists.tsx index c5da7a9d3..80ef91887 100644 --- a/packages/editor/src/toolbar/tools/lists.tsx +++ b/packages/editor/src/toolbar/tools/lists.tsx @@ -174,6 +174,18 @@ export function BulletList(props: ToolProps) { ); } +export function CheckList(props: ToolProps) { + const { editor, ...toolProps } = props; + + return ( + editor.current?.chain().focus().toggleCheckList().run()} + /> + ); +} + export function Indent(props: ToolProps) { const { editor, ...toolProps } = props; const isBottom = useToolbarLocation() === "bottom"; diff --git a/packages/editor/styles/styles.css b/packages/editor/styles/styles.css index 2dbff27c2..1a8ad1444 100644 --- a/packages/editor/styles/styles.css +++ b/packages/editor/styles/styles.css @@ -618,4 +618,37 @@ p > *::selection { transform: rotate(90deg); } -[dir="rtl"] .taskItemTools { right: unset; left: 0 } \ No newline at end of file +[dir="rtl"] .taskItemTools { right: unset; left: 0 } + +/* Check list */ +.ProseMirror ul[data-type="checkList"] { + list-style: none; + padding: 0; + + p { + margin: 0; + } + + li { + display: flex; + + > label { + flex: 0 0 auto; + margin-right: 0.5rem; + user-select: none; + } + + > div { + flex: 1 1 auto; + } + + ul li, + ol li { + display: list-item; + } + + ul[data-type="checkList"] > li { + display: flex; + } + } +} \ No newline at end of file