editor: implement simple checklists

This commit is contained in:
Ammar Ahmed
2023-12-08 12:21:10 +05:00
committed by Abdullah Atta
parent 85c812f271
commit 4b9f284ed9
11 changed files with 406 additions and 3 deletions

View File

@@ -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";

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<string, any>;
checkListTypeName: string;
}
export const inputRegex = /^\s*(\[([( |x])?\])\s$/;
export const CheckListItem = Node.create<CheckListItemOptions>({
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 isnt 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"
})
})
];
}
});

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
import { CheckListItem } from "./check-list-item";
export * from "./check-list-item";
export default CheckListItem;

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
import { mergeAttributes, Node } from "@tiptap/core";
export interface CheckListOptions {
itemTypeName: string;
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
checkList: {
/**
* Toggle a check list
*/
toggleCheckList: () => ReturnType;
};
}
}
export const CheckList = Node.create<CheckListOptions>({
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()
};
}
});

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
import { CheckList } from "./check-list";
export * from "./check-list";
export default CheckList;

View File

@@ -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]
}
]
})

View File

@@ -249,6 +249,7 @@ export const Icons = {
readonlyOff: mdiPencil,
selectAllUnchecked: mdiCheckboxMultipleBlankOutline,
selectAllChecked: mdiCheckboxMultipleMarked,
checkList: mdiCheckboxMarkedOutline,
none: ""
};

View File

@@ -104,6 +104,10 @@ const tools: Record<ToolId, ToolDefinition> = {
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"]

View File

@@ -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,

View File

@@ -174,6 +174,18 @@ export function BulletList(props: ToolProps) {
);
}
export function CheckList(props: ToolProps) {
const { editor, ...toolProps } = props;
return (
<ToolButton
{...toolProps}
toggled={false}
onClick={() => editor.current?.chain().focus().toggleCheckList().run()}
/>
);
}
export function Indent(props: ToolProps) {
const { editor, ...toolProps } = props;
const isBottom = useToolbarLocation() === "bottom";

View File

@@ -618,4 +618,37 @@ p > *::selection {
transform: rotate(90deg);
}
[dir="rtl"] .taskItemTools { right: unset; left: 0 }
[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;
}
}
}