X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/f991948c4931ecf2d4e32dadc8b099207f76fa82..45d08604482ff811f1627f8dc489f14b455ea75c:/resources/js/wysiwyg/plugins-tasklist.js diff --git a/resources/js/wysiwyg/plugins-tasklist.js b/resources/js/wysiwyg/plugins-tasklist.js index 07f934463..4afbfa8e6 100644 --- a/resources/js/wysiwyg/plugins-tasklist.js +++ b/resources/js/wysiwyg/plugins-tasklist.js @@ -1,119 +1,167 @@ -/** - * @param {Editor} editor - */ -function defineTaskListCustomElement(editor) { - const doc = editor.getDoc(); - const win = doc.defaultView; - - class TaskListElement extends win.HTMLElement { - constructor() { - super(); - // this.attachShadow({mode: 'open'}); - // - // const input = doc.createElement('input'); - // input.setAttribute('type', 'checkbox'); - // input.setAttribute('disabled', 'disabled'); - // - // if (this.hasAttribute('selected')) { - // input.setAttribute('selected', 'selected'); - // } - // - // this.shadowRoot.append(input); - // this.shadowRoot.close(); - } - } - - win.customElements.define('task-list-item', TaskListElement); -} - /** * @param {Editor} editor * @param {String} url */ function register(editor, url) { - // editor.on('NewBlock', ({ newBlock}) => { - // ensureElementHasCheckbox(newBlock); - // }); + // Tasklist UI buttons + editor.ui.registry.addIcon('tasklist', ''); + editor.ui.registry.addToggleButton('tasklist', { + tooltip: 'Task list', + icon: 'tasklist', + active: false, + onAction(api) { + if (api.isActive()) { + editor.execCommand('RemoveList'); + } else { + editor.execCommand('InsertUnorderedList', null, { + 'list-item-attributes': { + class: 'task-list-item', + }, + 'list-style-type': 'tasklist', + }); + } + }, + onSetup(api) { + editor.on('NodeChange', event => { + const parentListEl = event.parents.find(el => el.nodeName === 'LI'); + const inList = parentListEl && parentListEl.classList.contains('task-list-item'); + api.setActive(Boolean(inList)); + }); + } + }); - editor.on('PreInit', () => { + // Tweak existing bullet list button active state to not be active + // when we're in a task list. + const existingBullListButton = editor.ui.registry.getAll().buttons.bullist; + existingBullListButton.onSetup = function(api) { + editor.on('NodeChange', event => { + const parentList = event.parents.find(el => el.nodeName === 'LI'); + const inTaskList = parentList && parentList.classList.contains('task-list-item'); + const inUlList = parentList && parentList.parentNode.nodeName === 'UL'; + api.setActive(Boolean(inUlList && !inTaskList)); + }); + }; + existingBullListButton.onAction = function() { + // Cheeky hack to prevent list toggle action treating tasklists as normal + // unordered lists which would unwrap the list on toggle from tasklist to bullet list. + // Instead we quickly jump through an ordered list first if we're within a tasklist. + if (elementWithinTaskList(editor.selection.getNode())) { + editor.execCommand('InsertOrderedList', null, { + 'list-item-attributes': {class: null} + }); + } - defineTaskListCustomElement(editor); + editor.execCommand('InsertUnorderedList', null, { + 'list-item-attributes': {class: null} + }); + }; + // Tweak existing number list to not allow classes on child items + const existingNumListButton = editor.ui.registry.getAll().buttons.numlist; + existingNumListButton.onAction = function() { + editor.execCommand('InsertOrderedList', null, { + 'list-item-attributes': {class: null} + }); + }; - editor.parser.addNodeFilter('li', function(elms) { - for (const elem of elms) { - if (elem.attributes.map.class === 'task-list-item') { - replaceTaskListNode(elem); + // Setup filters on pre-init + editor.on('PreInit', () => { + editor.parser.addNodeFilter('li', function(nodes) { + for (const node of nodes) { + if (node.attributes.map.class === 'task-list-item') { + parseTaskListNode(node); } } }); + editor.serializer.addNodeFilter('li', function(nodes) { + for (const node of nodes) { + if (node.attributes.map.class === 'task-list-item') { + serializeTaskListNode(node); + } + } + }); + }); - // editor.serializer.addNodeFilter('li', function(elms) { - // for (const elem of elms) { - // if (elem.attributes.map.class === 'task-list-item') { - // ensureNodeHasCheckbox(elem); - // } - // } - // }); - + // Handle checkbox click in editor + editor.on('click', function(event) { + const clickedEl = event.target; + if (clickedEl.nodeName === 'LI' && clickedEl.classList.contains('task-list-item')) { + handleTaskListItemClick(event, clickedEl, editor); + event.preventDefault(); + } }); +} +/** + * @param {Element} element + * @return {boolean} + */ +function elementWithinTaskList(element) { + const listEl = element.closest('li'); + return listEl && listEl.parentNode.nodeName === 'UL' && listEl.classList.contains('task-list-item'); } /** - * @param {AstNode} node + * @param {MouseEvent} event + * @param {Element} clickedEl + * @param {Editor} editor */ -function replaceTaskListNode(node) { +function handleTaskListItemClick(event, clickedEl, editor) { + const bounds = clickedEl.getBoundingClientRect(); + const withinBounds = event.clientX <= bounds.right + && event.clientX >= bounds.left + && event.clientY >= bounds.top + && event.clientY <= bounds.bottom; + + // Outside of the task list item bounds mean we're probably clicking the pseudo-element. + if (!withinBounds) { + editor.undoManager.transact(() => { + if (clickedEl.hasAttribute('checked')) { + clickedEl.removeAttribute('checked'); + } else { + clickedEl.setAttribute('checked', 'checked'); + } + }); + } +} - const taskListItem = new tinymce.html.Node.create('task-list-item', { - }); +/** + * @param {AstNode} node + */ +function parseTaskListNode(node) { + // Force task list item class + node.attr('class', 'task-list-item'); + // Copy checkbox status and remove checkbox within editor for (const child of node.children()) { - if (node.name !== 'input') { - taskListItem.append(child); + if (child.name === 'input') { + if (child.attr('checked') === 'checked') { + node.attr('checked', 'checked'); + } + child.remove(); } } - - node.replace(taskListItem); } -// /** -// * @param {Element} elem -// */ -// function ensureElementHasCheckbox(elem) { -// const hasCheckbox = elem.querySelector(':scope > input[type="checkbox"]') !== null; -// if (hasCheckbox) { -// return; -// } -// -// const input = elem.ownerDocument.createElement('input'); -// input.setAttribute('type', 'checkbox'); -// input.setAttribute('disabled', 'disabled'); -// elem.prepend(input); -// } - /** - * @param {AstNode} elem + * @param {AstNode} node */ -function ensureNodeHasCheckbox(elem) { - // Stop if there's already an input - if (elem.firstChild && elem.firstChild.name === 'input') { - return; +function serializeTaskListNode(node) { + // Get checked status and clean it from list node + const isChecked = node.attr('checked') === 'checked'; + node.attr('checked', null); + + const inputAttrs = {type: 'checkbox', disabled: 'disabled'}; + if (isChecked) { + inputAttrs.checked = 'checked'; } - const input = new tinymce.html.Node.create('input', { - type: 'checkbox', - disabled: 'disabled', - }); - - if (elem.firstChild) { - elem.insert(input, elem.firstChild, true); - } else { - elem.append(input); - } + // Create & insert checkbox input element + const checkbox = tinymce.html.Node.create('input', inputAttrs); + checkbox.shortEnded = true; + node.firstChild ? node.insert(checkbox, node.firstChild, true) : node.append(checkbox); } - /** * @param {WysiwygConfigOptions} options * @return {register}