tiptap: make taskListItem more clickable

This commit is contained in:
fantasticit 2022-07-09 10:04:41 +08:00
parent 6788efa7ab
commit e4f6c03576
2 changed files with 134 additions and 67 deletions

View File

@ -1,45 +1,135 @@
import { wrappingInputRule } from '@tiptap/core'; import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core';
import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item';
import { Plugin } from 'prosemirror-state'; export interface TaskItemOptions {
import { findParentNodeClosestToPos } from 'prosemirror-utils'; nested: boolean;
import { PARSE_HTML_PRIORITY_HIGHEST } from 'tiptap/core/constants'; HTMLAttributes: Record<string, any>;
}
export const inputRegex = /^\s*(\[([ |x])\])\s$/;
export const TaskItem = Node.create<TaskItemOptions>({
name: 'taskItem',
addOptions() {
return {
nested: false,
HTMLAttributes: {},
};
},
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,
}),
},
};
},
const CustomTaskItem = BuiltInTaskItem.extend({
parseHTML() { parseHTML() {
return [ return [
{ {
tag: 'li.task-list-item', tag: `li.task-list-item`,
priority: PARSE_HTML_PRIORITY_HIGHEST, priority: 51,
}, },
]; ];
}, },
addInputRules() { renderHTML({ node, HTMLAttributes }) {
return [ return [
...this.parent(), 'li',
wrappingInputRule({ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name }),
find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/, [
type: this.type, 'label',
getAttributes: (match) => ({ [
checked: 'xX'.includes(match[match.length - 1]), 'input',
}), {
}), type: 'checkbox',
checked: node.attrs.checked ? 'checked' : null,
},
],
['span'],
],
['div', 0],
]; ];
}, },
addKeyboardShortcuts() {
const shortcuts = {
'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() { addNodeView() {
return ({ node, HTMLAttributes, getPos, editor }) => { return ({ node, HTMLAttributes, getPos, editor }) => {
const listItem = document.createElement('li'); const listItem = document.createElement('li');
const checkboxWrapper = document.createElement('span'); const checkboxWrapper = document.createElement('label');
const checkboxStyler = document.createElement('span');
const checkbox = document.createElement('input');
const content = document.createElement('div'); const content = document.createElement('div');
checkboxWrapper.contentEditable = 'false'; checkboxWrapper.contentEditable = 'false';
checkbox.type = 'checkbox';
checkbox.addEventListener('change', (event) => {
// if the editor isnt editable
// we have to undo the latest change
// if (!editor.isEditable) {
// checkbox.checked = !checkbox.checked;
// return;
// }
const { checked } = event.target as any;
if (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();
}
});
Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => { Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
listItem.setAttribute(key, value); listItem.setAttribute(key, value);
}); });
listItem.dataset.checked = node.attrs.checked; listItem.dataset.checked = node.attrs.checked;
if (node.attrs.checked) {
checkbox.setAttribute('checked', 'checked');
}
checkboxWrapper.append(checkbox, checkboxStyler);
listItem.append(checkboxWrapper, content); listItem.append(checkboxWrapper, content);
Object.entries(HTMLAttributes).forEach(([key, value]) => { Object.entries(HTMLAttributes).forEach(([key, value]) => {
@ -55,61 +145,34 @@ const CustomTaskItem = BuiltInTaskItem.extend({
} }
listItem.dataset.checked = updatedNode.attrs.checked; listItem.dataset.checked = updatedNode.attrs.checked;
if (updatedNode.attrs.checked) {
checkbox.setAttribute('checked', 'checked');
} else {
checkbox.removeAttribute('checked');
}
return true; return true;
}, },
}; };
}; };
}, },
addProseMirrorPlugins() { addInputRules() {
const extensionThis = this;
return [ return [
new Plugin({ wrappingInputRule({
props: { find: inputRegex,
handleClick: (view, pos, event) => { type: this.type,
const state = view.state; getAttributes: (match) => ({
const schema = state.schema; checked: match[match.length - 1] === 'x',
}),
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); }),
const position = state.doc.resolve(coordinates.pos); wrappingInputRule({
const parentList = findParentNodeClosestToPos(position, function (node) { find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/,
return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem; type: this.type,
}); getAttributes: (match) => ({
if (!parentList) { checked: 'xX'.includes(match[match.length - 1]),
return false; }),
}
const element = view.nodeDOM(parentList.pos) as HTMLLIElement;
if (element.tagName.toLowerCase() !== 'li') return false;
// 编辑模式:仅当点击 SPAN 时进行状态修改
if (view.editable) {
const target = event.target as HTMLElement;
if (target.tagName.toLowerCase() !== 'span') return false;
} else {
// 非编辑模式,仅支持配置 taskItemClickable 可点击
// @ts-ignore
if (!extensionThis.editor.options.editorProps.taskItemClickable) {
return;
}
}
const parentElement = element.parentElement;
const type = parentElement && parentElement.getAttribute('data-type');
if (!type || type.toLowerCase() !== 'tasklist') return false;
const tr = state.tr;
const nextValue = !(element.getAttribute('data-checked') === 'true');
tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, {
checked: nextValue,
});
view.dispatch(tr);
return true;
},
},
}), }),
]; ];
}, },
}); });
export const TaskItem = CustomTaskItem.configure({ nested: true });

View File

@ -52,7 +52,7 @@
padding-left: 16px; padding-left: 16px;
cursor: pointer; cursor: pointer;
> span { > label {
position: absolute; position: absolute;
top: 6px; top: 6px;
left: 0; left: 0;
@ -63,6 +63,10 @@
border: 1px solid var(--semi-color-border); border: 1px solid var(--semi-color-border);
border-radius: 2px; border-radius: 2px;
> input {
display: none;
}
&::after { &::after {
position: absolute; position: absolute;
top: -0.357143px; top: -0.357143px;
@ -87,7 +91,7 @@
&[data-checked='true'] { &[data-checked='true'] {
color: var(--semi-color-text-2); color: var(--semi-color-text-2);
> span { > label {
background-color: var(--semi-color-primary); background-color: var(--semi-color-primary);
&::after { &::after {