From 0afc3236c2d773791de05d457367c73faf113ca0 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 15 Feb 2022 08:02:07 +0100 Subject: fix: indicator of the task list. See #2018. Use tiptap TaskList and TaskItem. Markdown-it happily mixes tasks and bullet points in the same list. Tiptap lists are strictly separated. Split bullet and tasks into BulletList and TaskList in markdown-io. Just like this will turn into three different lists: * one - two + three This will now also turn into three different lists with the middle one being a task list: * first list * [ ] todo * [x] done * third list Signed-off-by: Max --- src/EditorFactory.js | 8 +- src/markdownit/index.js | 2 + src/markdownit/splitMixedLists.js | 87 +++++++++++++++ src/mixins/menubar.js | 7 +- src/nodes/BulletList.js | 16 ++- src/nodes/ListItem.js | 203 ---------------------------------- src/nodes/TaskItem.js | 134 ++++++++++++++++++++++ src/nodes/TaskList.js | 40 +++++++ src/nodes/index.js | 6 +- src/tests/extensions/Markdown.spec.js | 8 +- src/tests/markdown.spec.js | 8 +- src/tests/markdownit.spec.js | 33 ++++++ src/tests/nodes/ListItem.spec.js | 20 ---- src/tests/nodes/TaskItem.spec.js | 20 ++++ 14 files changed, 348 insertions(+), 244 deletions(-) create mode 100644 src/markdownit/splitMixedLists.js delete mode 100644 src/nodes/ListItem.js create mode 100644 src/nodes/TaskItem.js create mode 100644 src/nodes/TaskList.js create mode 100644 src/tests/markdownit.spec.js delete mode 100644 src/tests/nodes/ListItem.spec.js create mode 100644 src/tests/nodes/TaskItem.spec.js (limited to 'src') diff --git a/src/EditorFactory.js b/src/EditorFactory.js index af4ff61f1..808a679c2 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -28,6 +28,7 @@ import History from '@tiptap/extension-history' import Blockquote from '@tiptap/extension-blockquote' import Placeholder from '@tiptap/extension-placeholder' import OrderedList from '@tiptap/extension-ordered-list' +import ListItem from '@tiptap/extension-list-item' import CodeBlock from '@tiptap/extension-code-block' import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight' import HorizontalRule from '@tiptap/extension-horizontal-rule' @@ -38,10 +39,11 @@ import { Strong, Italic, Strike, Link, Underline } from './marks' import { Image, PlainTextDocument, - ListItem, - BulletList, TrailingNode, Heading, + BulletList, + TaskList, + TaskItem, } from './nodes' import { Markdown, Emoji } from './extensions' import { translate as t } from '@nextcloud/l10n' @@ -85,6 +87,8 @@ const createEditor = ({ content, onCreate, onUpdate, extensions, enableRichEditi HorizontalRule, OrderedList, ListItem, + TaskList, + TaskItem, Underline, Image.configure({ currentDirectory, inline: true }), Emoji.configure({ diff --git a/src/markdownit/index.js b/src/markdownit/index.js index b3314fabd..aaeafa4c9 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -1,10 +1,12 @@ import MarkdownIt from 'markdown-it' import taskLists from 'markdown-it-task-lists' import underline from './underline' +import splitMixedLists from './splitMixedLists' const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .enable('strikethrough') .use(taskLists, { enable: true, labelAfter: true }) + .use(splitMixedLists) .use(underline) export default markdownit diff --git a/src/markdownit/splitMixedLists.js b/src/markdownit/splitMixedLists.js new file mode 100644 index 000000000..cb709af33 --- /dev/null +++ b/src/markdownit/splitMixedLists.js @@ -0,0 +1,87 @@ +/* + * @copyright Copyright (c) 2022 Max + * + * @author Max + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +/** + * @param {object} md Markdown object + */ +export default function splitMixedLists(md) { + md.core.ruler.after('github-task-lists', 'split-mixed-task-lists', state => { + const tokens = state.tokens + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + if (token.attrGet('class') !== 'contains-task-list') { + continue + } + const firstChild = tokens[i + 1] + const startsWithTask = firstChild.attrGet('class') === 'task-list-item' + if (!startsWithTask) { + token.attrs.splice(token.attrIndex('class')) + if (token.attrs.length === 0) { + token.attrs = null + } + } + const splitBefore = findChildOf(tokens, i, child => { + return child.nesting === 1 + && child.attrGet('class') !== firstChild.attrGet('class') + }) + if (splitBefore > i) { + splitListAt(tokens, splitBefore, state.Token) + } + } + + return false + }) +} + +/** + * @param {Array} tokens - all the tokens in the doc + * @param {number} index - index into the tokens array where to split + * @param {object} TokenConstructor - constructor provided by Markdown-it + */ +function splitListAt(tokens, index, TokenConstructor) { + const closeList = new TokenConstructor('bullet_list_close', 'ul', -1) + closeList.block = true + const openList = new TokenConstructor('bullet_list_open', 'ul', 1) + openList.attrSet('class', 'contains-task-list') + openList.block = true + tokens.splice(index, 0, closeList, openList) +} + +/** + * @param {Array} tokens - all the tokens in the doc + * @param {number} parentIndex - index of the parent in the tokens array + * @param {Function} predicate - test function returned child needs to pass + */ +function findChildOf(tokens, parentIndex, predicate) { + const searchLevel = tokens[parentIndex].level + 1 + for (let i = parentIndex + 1; i < tokens.length; i++) { + const token = tokens[i] + if (token.level < searchLevel) { + return -1 + } + if ((token.level === searchLevel) && predicate(tokens[i])) { + return i + } + } + return -1 +} diff --git a/src/mixins/menubar.js b/src/mixins/menubar.js index 91a8eb6d0..e9bebe69f 100644 --- a/src/mixins/menubar.js +++ b/src/mixins/menubar.js @@ -138,7 +138,7 @@ export default [ class: 'icon-ul', isActive: 'bulletList', action: (command) => { - return command.bulletListItem() + return command.toggleBulletList() }, }, { @@ -154,9 +154,8 @@ export default [ { label: t('text', 'ToDo list'), class: 'icon-checkmark', - // Do we want to indicate that the current item is a todo item? - // isActive: ['listItem', { type: 1 }], - action: (command) => command.todo_item(), + isActive: 'taskList', + action: (command) => command.toggleTaskList(), }, { label: t('text', 'Blockquote'), diff --git a/src/nodes/BulletList.js b/src/nodes/BulletList.js index 0e22a0ec7..4d4d0f537 100644 --- a/src/nodes/BulletList.js +++ b/src/nodes/BulletList.js @@ -21,16 +21,22 @@ */ import TiptapBulletList from '@tiptap/extension-bullet-list' +import { listInputRule } from '../commands' -/* We want to allow for mixed lists with todo items and bullet points. - * Therefore the list input rules are handled in the ListItem node. - * This way we can make sure that "- [ ]" can still trigger todo list items - * even inside a list with bullet points. +/* We want to allow for `* [ ]` as an input rule for bullet lists. + * Therefore the list input rules need to check the input + * until the first char after the space. + * Only there we know the user is not trying to create a task list. */ const BulletList = TiptapBulletList.extend({ addInputRules() { - return [] + return [ + listInputRule( + /^\s*([-+*])\s([^\s[])$/, + this.type + ), + ] }, }) diff --git a/src/nodes/ListItem.js b/src/nodes/ListItem.js deleted file mode 100644 index 6594929e8..000000000 --- a/src/nodes/ListItem.js +++ /dev/null @@ -1,203 +0,0 @@ -/* - * @copyright Copyright (c) 2019 Julius Härtl - * - * @author Julius Härtl - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ - -import TipTapListItem from '@tiptap/extension-list-item' -import { wrappingInputRule, mergeAttributes } from '@tiptap/core' -import { Plugin } from 'prosemirror-state' -import { findParentNode, findParentNodeClosestToPos } from 'prosemirror-utils' -import { listInputRule } from '../commands' - -const TYPES = { - BULLET: 0, - CHECKBOX: 1, -} - -const getParentList = (schema, selection) => { - return findParentNode(function(node) { - return node.type === schema.nodes.listItem - })(selection) -} - -const ListItem = TipTapListItem.extend({ - - addOptions() { - return { - nested: true, - } - }, - - addAttributes() { - return { - done: { - default: false, - }, - type: { - default: TYPES.BULLET, - }, - } - }, - - draggable: false, - - content: 'paragraph block*', - - renderHTML({ node, HTMLAttributes }) { - if (node.attrs.type === TYPES.BULLET) { - return ['li', HTMLAttributes, 0] - } - const listAttributes = { class: 'checkbox-item' } - const checkboxAttributes = { type: 'checkbox', class: '', contenteditable: false } - if (node.attrs.done) { - checkboxAttributes.checked = true - listAttributes.class += ' checked' - } - return [ - 'li', - mergeAttributes(HTMLAttributes, listAttributes), - [ - 'input', - checkboxAttributes, - ], - [ - 'label', - 0, - ], - ] - }, - - parseHTML: [ - { - priority: 100, - tag: 'li', - getAttrs: el => { - const checkbox = el.querySelector('input[type=checkbox]') - return { done: checkbox && checkbox.checked, type: checkbox ? TYPES.CHECKBOX : TYPES.BULLET } - }, - }, - ], - - toMarkdown: (state, node) => { - if (node.attrs.type === TYPES.CHECKBOX) { - state.write(`[${node.attrs.done ? 'x' : ' '}] `) - } - state.renderContent(node) - }, - - addCommands() { - return { - bulletListItem: () => ({ commands }) => { - return commands.toggleList('bulletList', 'listItem') - }, - // TODO: understand this, maybe fix it and / or tweak it. - todo_item: () => ({ chain, commands, state }) => { - const schema = state.schema - const selection = state.selection - const $from = selection.$from - const $to = selection.$to - const range = $from.blockRange($to) - - if (!range) { - return false - } - - let parentList = getParentList(schema, selection) - - const start = (typeof parentList === 'undefined') - ? chain().bulletListItem() - : chain() - - start - .command(({ tr }) => { - if (typeof parentList === 'undefined') { - parentList = getParentList(schema, tr.selection) - } - - if (typeof parentList === 'undefined') { - return false - } - if (parentList.node.attrs.type === TYPES.CHECKBOX) { - return commands.toggleList('bulletList', 'listItem') - } - - tr.doc.nodesBetween(tr.selection.from, tr.selection.to, (node, pos) => { - if (node.type === schema.nodes.listItem) { - tr.setNodeMarkup(pos, node.type, { - type: parentList.node.attrs.type === TYPES.CHECKBOX ? TYPES.BULLET : TYPES.CHECKBOX, - }) - } - }) - tr.scrollIntoView() - }) - .run() - return true - }, - } - }, - - addInputRules() { - return [ - wrappingInputRule({ - find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/, - type: this.type, - getAttributes: match => ({ - type: TYPES.CHECKBOX, - done: 'xX'.includes(match[match.length - 1]), - }), - }), - listInputRule( - /^\s*([-+*])\s([^\s[])$/, - this.type, - _match => ({ type: TYPES.BULLET }), - ), - ] - }, - - addProseMirrorPlugins() { - return [ - new Plugin({ - props: { - handleClick: (view, pos, event) => { - const state = view.state - const schema = state.schema - - const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }) - const position = state.doc.resolve(coordinates.pos) - const parentList = findParentNodeClosestToPos(position, function(node) { - return node.type === schema.nodes.listItem - }) - const isListClicked = event.target.tagName.toLowerCase() === 'li' - if (typeof parentList === 'undefined' || parentList.node.attrs.type !== TYPES.CHECKBOX || !isListClicked) { - return - } - - const tr = state.tr - tr.setNodeMarkup(parentList.pos, schema.nodes.listItem, { done: !parentList.node.attrs.done, type: TYPES.CHECKBOX }) - view.dispatch(tr) - }, - }, - }), - ] - }, - -}) - -export default ListItem diff --git a/src/nodes/TaskItem.js b/src/nodes/TaskItem.js new file mode 100644 index 000000000..74db3e82a --- /dev/null +++ b/src/nodes/TaskItem.js @@ -0,0 +1,134 @@ +/* + * @copyright Copyright (c) 2019 Julius Härtl + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import TipTapTaskItem from '@tiptap/extension-task-item' +import { wrappingInputRule, mergeAttributes } from '@tiptap/core' +import { Plugin } from 'prosemirror-state' +import { findParentNodeClosestToPos } from 'prosemirror-utils' + +const TaskItem = TipTapTaskItem.extend({ + + addOptions() { + return { + nested: true, + HTMLAttributes: {}, + } + }, + + draggable: false, + + content: 'paragraph block*', + + addAttributes() { + const adjust = { ...this.parent() } + adjust.checked.parseHTML = el => { + return el.querySelector('input[type=checkbox]')?.checked + } + return adjust + }, + + parseHTML: [ + { + priority: 101, + tag: 'li', + getAttrs: el => { + const checkbox = el.querySelector('input[type=checkbox]') + return checkbox + }, + context: 'taskList/', + }, + ], + + renderHTML({ node, HTMLAttributes }) { + const listAttributes = { class: 'checkbox-item' } + const checkboxAttributes = { type: 'checkbox', class: '', contenteditable: false } + if (node.attrs.checked) { + checkboxAttributes.checked = true + listAttributes.class += ' checked' + } + return [ + 'li', + mergeAttributes(HTMLAttributes, listAttributes), + [ + 'input', + checkboxAttributes, + ], + [ + 'label', + 0, + ], + ] + }, + + // overwrite the parent node view so renderHTML gets used + addNodeView: false, + + toMarkdown: (state, node) => { + state.write(`[${node.attrs.checked ? 'x' : ' '}] `) + state.renderContent(node) + }, + + addInputRules() { + return [ + ...this.parent(), + wrappingInputRule({ + find: /^\s*([-+*])\s(\[(x|X| ?)\])\s$/, + type: this.type, + getAttributes: match => ({ + checked: 'xX'.includes(match[match.length - 1]), + }), + }), + ] + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + handleClick: (view, pos, event) => { + const state = view.state + const schema = state.schema + + const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }) + const position = state.doc.resolve(coordinates.pos) + const parentList = findParentNodeClosestToPos(position, function(node) { + return node.type === schema.nodes.taskItem + || node.type === schema.nodes.listItem + }) + const isListClicked = event.target.tagName.toLowerCase() === 'li' + if (!isListClicked + || !parentList + || parentList.node.type !== schema.nodes.taskItem) { + return + } + const tr = state.tr + tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, { checked: !parentList.node.attrs.checked }) + view.dispatch(tr) + }, + }, + }), + ] + }, + +}) + +export default TaskItem diff --git a/src/nodes/TaskList.js b/src/nodes/TaskList.js new file mode 100644 index 000000000..5bb4be796 --- /dev/null +++ b/src/nodes/TaskList.js @@ -0,0 +1,40 @@ +/* + * @copyright Copyright (c) 2020 Julius Härtl + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import TiptapTaskList from '@tiptap/extension-task-list' + +const TaskList = TiptapTaskList.extend({ + + parseHTML: [ + { + priority: 100, + tag: 'ul.contains-task-list', + }, + ], + + toMarkdown: (state, node) => { + state.renderList(node, ' ', () => (node.attrs.bullet || '*') + ' ') + }, + +}) + +export default TaskList diff --git a/src/nodes/index.js b/src/nodes/index.js index c0ebf5aac..4340f05c9 100644 --- a/src/nodes/index.js +++ b/src/nodes/index.js @@ -22,16 +22,18 @@ import Image from './Image' import PlainTextDocument from './PlainTextDocument' -import ListItem from './ListItem' import BulletList from './BulletList' +import TaskItem from './TaskItem' +import TaskList from './TaskList' import TrailingNode from './TrailingNode' import Heading from './Heading' export { Image, PlainTextDocument, - ListItem, BulletList, + TaskItem, + TaskList, TrailingNode, Heading, } diff --git a/src/tests/extensions/Markdown.spec.js b/src/tests/extensions/Markdown.spec.js index d4a2b80d1..14bc77fd5 100644 --- a/src/tests/extensions/Markdown.spec.js +++ b/src/tests/extensions/Markdown.spec.js @@ -1,7 +1,7 @@ import { Markdown } from './../../extensions'; import { createMarkdownSerializer } from './../../extensions/Markdown'; import Underline from './../../marks/Underline'; -import { BulletList, ListItem } from './../../nodes' +import { TaskList, TaskItem } from './../../nodes' import Image from '@tiptap/extension-image' import { getExtensionField } from '@tiptap/core' import createEditor from './../createEditor' @@ -40,11 +40,11 @@ describe('Markdown extension integrated in the editor', () => { it('serializes nodes according to their spec', () => { const editor = createEditor({ - content: '

  • Hello

', - extensions: [Markdown, BulletList, ListItem], + content: '

  • Hello

', + extensions: [Markdown, TaskList, TaskItem], }) const serializer = createMarkdownSerializer(editor.schema) - expect(serializer.serialize(editor.state.doc)).toBe('\n* Hello') + expect(serializer.serialize(editor.state.doc)).toBe('\n* [ ] Hello') }) it('serializes nodes with the default prosemirror way', () => { diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index 9cbc4f46f..1dbce6097 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -131,9 +131,9 @@ describe('Markdown serializer from html', () => { expect(markdownThroughEditorHtml('

description

')).toBe('![description](image)') }) test('checkboxes', () => { - expect(markdownThroughEditorHtml('
')).toBe('* [x] foo') - expect(markdownThroughEditorHtml('
')).toBe('* [ ] test') - expect(markdownThroughEditorHtml('
  • Test

    content

')).toBe('* [x] Test\n\n **content**') - expect(markdownThroughEditorHtml('
  • Test

    Block level headline

')).toBe('* [x] Test\n\n # Block level headline') + expect(markdownThroughEditorHtml('
')).toBe('* [x] foo') + expect(markdownThroughEditorHtml('
')).toBe('* [ ] test') + expect(markdownThroughEditorHtml('
  • Test

    content

')).toBe('* [x] Test\n\n **content**') + expect(markdownThroughEditorHtml('
  • Test

    Block level headline

')).toBe('* [x] Test\n\n # Block level headline') }) }) diff --git a/src/tests/markdownit.spec.js b/src/tests/markdownit.spec.js new file mode 100644 index 000000000..08d5cb0a9 --- /dev/null +++ b/src/tests/markdownit.spec.js @@ -0,0 +1,33 @@ +import markdownit from './../markdownit' + +describe('markdownit', () => { + + it('renders task lists', () => { + const rendered = markdownit.render('* [ ] task\n* not a task') + expect(rendered).toBe(stripIndent(` +
    +
  • task
  • +
+
    +
  • not a task
  • +
+`)) + }) + + it('renders bullet and task lists separately', () => { + const rendered = markdownit.render('* not a task\n* [ ] task') + expect(rendered).toBe(stripIndent(` +
    +
  • not a task
  • +
+
    +
  • task
  • +
+`)) + }) + +}) + +function stripIndent(content) { + return content.replace(/\t/g, '').replace('\n','') +} diff --git a/src/tests/nodes/ListItem.spec.js b/src/tests/nodes/ListItem.spec.js deleted file mode 100644 index f9ef09136..000000000 --- a/src/tests/nodes/ListItem.spec.js +++ /dev/null @@ -1,20 +0,0 @@ -import { BulletList, ListItem } from './../../nodes' -import Markdown from './../../extensions/Markdown' -import { getExtensionField } from '@tiptap/core' -import createEditor from './../createEditor' - -describe('ListItem extension', () => { - it('exposes toMarkdown function', () => { - const toMarkdown = getExtensionField(ListItem, 'toMarkdown', ListItem) - expect(typeof toMarkdown).toEqual('function') - }) - - it('exposes the toMarkdown function in the prosemirror schema', () => { - const editor = createEditor({ - extensions: [Markdown, BulletList, ListItem] - }) - const listItem = editor.schema.nodes.listItem - expect(listItem.spec.toMarkdown).toBeDefined() - }) - -}) diff --git a/src/tests/nodes/TaskItem.spec.js b/src/tests/nodes/TaskItem.spec.js new file mode 100644 index 000000000..a1137317f --- /dev/null +++ b/src/tests/nodes/TaskItem.spec.js @@ -0,0 +1,20 @@ +import { TaskList, TaskItem } from './../../nodes' +import Markdown from './../../extensions/Markdown' +import { getExtensionField } from '@tiptap/core' +import createEditor from './../createEditor' + +describe('TaskItem extension', () => { + it('exposes toMarkdown function', () => { + const toMarkdown = getExtensionField(TaskItem, 'toMarkdown', TaskItem) + expect(typeof toMarkdown).toEqual('function') + }) + + it('exposes the toMarkdown function in the prosemirror schema', () => { + const editor = createEditor({ + extensions: [Markdown, TaskList, TaskItem] + }) + const taskItem = editor.schema.nodes.taskItem + expect(taskItem.spec.toMarkdown).toBeDefined() + }) + +}) -- cgit v1.2.3