]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/plugins-tasklist.js
191f8364927817c5129858719608c7b31669cc33
[bookstack] / resources / js / wysiwyg / plugins-tasklist.js
1 /**
2  * @param {Element} element
3  * @return {boolean}
4  */
5 function elementWithinTaskList(element) {
6     const listEl = element.closest('li');
7     return listEl && listEl.parentNode.nodeName === 'UL' && listEl.classList.contains('task-list-item');
8 }
9
10 /**
11  * @param {MouseEvent} event
12  * @param {Element} clickedEl
13  * @param {Editor} editor
14  */
15 function handleTaskListItemClick(event, clickedEl, editor) {
16     const bounds = clickedEl.getBoundingClientRect();
17     const withinBounds = event.clientX <= bounds.right
18         && event.clientX >= bounds.left
19         && event.clientY >= bounds.top
20         && event.clientY <= bounds.bottom;
21
22     // Outside of the task list item bounds mean we're probably clicking the pseudo-element.
23     if (!withinBounds) {
24         editor.undoManager.transact(() => {
25             if (clickedEl.hasAttribute('checked')) {
26                 clickedEl.removeAttribute('checked');
27             } else {
28                 clickedEl.setAttribute('checked', 'checked');
29             }
30         });
31     }
32 }
33
34 /**
35  * @param {AstNode} node
36  */
37 function parseTaskListNode(node) {
38     // Force task list item class
39     node.attr('class', 'task-list-item');
40
41     // Copy checkbox status and remove checkbox within editor
42     for (const child of node.children()) {
43         if (child.name === 'input') {
44             if (child.attr('checked') === 'checked') {
45                 node.attr('checked', 'checked');
46             }
47             child.remove();
48         }
49     }
50 }
51
52 /**
53  * @param {AstNode} node
54  */
55 function serializeTaskListNode(node) {
56     // Get checked status and clean it from list node
57     const isChecked = node.attr('checked') === 'checked';
58     node.attr('checked', null);
59
60     const inputAttrs = {type: 'checkbox', disabled: 'disabled'};
61     if (isChecked) {
62         inputAttrs.checked = 'checked';
63     }
64
65     // Create & insert checkbox input element
66     const checkbox = window.tinymce.html.Node.create('input', inputAttrs);
67     checkbox.shortEnded = true;
68
69     if (node.firstChild) {
70         node.insert(checkbox, node.firstChild, true);
71     } else {
72         node.append(checkbox);
73     }
74 }
75
76 /**
77  * @param {Editor} editor
78  */
79 function register(editor) {
80     // Tasklist UI buttons
81     editor.ui.registry.addIcon('tasklist', '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M22,8c0-0.55-0.45-1-1-1h-7c-0.55,0-1,0.45-1,1s0.45,1,1,1h7C21.55,9,22,8.55,22,8z M13,16c0,0.55,0.45,1,1,1h7 c0.55,0,1-0.45,1-1c0-0.55-0.45-1-1-1h-7C13.45,15,13,15.45,13,16z M10.47,4.63c0.39,0.39,0.39,1.02,0,1.41l-4.23,4.25 c-0.39,0.39-1.02,0.39-1.42,0L2.7,8.16c-0.39-0.39-0.39-1.02,0-1.41c0.39-0.39,1.02-0.39,1.41,0l1.42,1.42l3.54-3.54 C9.45,4.25,10.09,4.25,10.47,4.63z M10.48,12.64c0.39,0.39,0.39,1.02,0,1.41l-4.23,4.25c-0.39,0.39-1.02,0.39-1.42,0L2.7,16.16 c-0.39-0.39-0.39-1.02,0-1.41s1.02-0.39,1.41,0l1.42,1.42l3.54-3.54C9.45,12.25,10.09,12.25,10.48,12.64L10.48,12.64z"/></svg>');
82     editor.ui.registry.addToggleButton('tasklist', {
83         tooltip: 'Task list',
84         icon: 'tasklist',
85         active: false,
86         onAction(api) {
87             if (api.isActive()) {
88                 editor.execCommand('RemoveList');
89             } else {
90                 editor.execCommand('InsertUnorderedList', null, {
91                     'list-item-attributes': {
92                         class: 'task-list-item',
93                     },
94                     'list-style-type': 'tasklist',
95                 });
96             }
97         },
98         onSetup(api) {
99             editor.on('NodeChange', event => {
100                 const parentListEl = event.parents.find(el => el.nodeName === 'LI');
101                 const inList = parentListEl && parentListEl.classList.contains('task-list-item');
102                 api.setActive(Boolean(inList));
103             });
104         },
105     });
106
107     // Tweak existing bullet list button active state to not be active
108     // when we're in a task list.
109     const existingBullListButton = editor.ui.registry.getAll().buttons.bullist;
110     existingBullListButton.onSetup = function customBullListOnSetup(api) {
111         editor.on('NodeChange', event => {
112             const parentList = event.parents.find(el => el.nodeName === 'LI');
113             const inTaskList = parentList && parentList.classList.contains('task-list-item');
114             const inUlList = parentList && parentList.parentNode.nodeName === 'UL';
115             api.setActive(Boolean(inUlList && !inTaskList));
116         });
117     };
118     existingBullListButton.onAction = function customBullListOnAction() {
119         // Cheeky hack to prevent list toggle action treating tasklists as normal
120         // unordered lists which would unwrap the list on toggle from tasklist to bullet list.
121         // Instead we quickly jump through an ordered list first if we're within a tasklist.
122         if (elementWithinTaskList(editor.selection.getNode())) {
123             editor.execCommand('InsertOrderedList', null, {
124                 'list-item-attributes': {class: null},
125             });
126         }
127
128         editor.execCommand('InsertUnorderedList', null, {
129             'list-item-attributes': {class: null},
130         });
131     };
132     // Tweak existing number list to not allow classes on child items
133     const existingNumListButton = editor.ui.registry.getAll().buttons.numlist;
134     existingNumListButton.onAction = function customNumListButtonOnAction() {
135         editor.execCommand('InsertOrderedList', null, {
136             'list-item-attributes': {class: null},
137         });
138     };
139
140     // Setup filters on pre-init
141     editor.on('PreInit', () => {
142         editor.parser.addNodeFilter('li', nodes => {
143             for (const node of nodes) {
144                 if (node.attributes.map.class === 'task-list-item') {
145                     parseTaskListNode(node);
146                 }
147             }
148         });
149         editor.serializer.addNodeFilter('li', nodes => {
150             for (const node of nodes) {
151                 if (node.attributes.map.class === 'task-list-item') {
152                     serializeTaskListNode(node);
153                 }
154             }
155         });
156     });
157
158     // Handle checkbox click in editor
159     editor.on('click', event => {
160         const clickedEl = event.target;
161         if (clickedEl.nodeName === 'LI' && clickedEl.classList.contains('task-list-item')) {
162             handleTaskListItemClick(event, clickedEl, editor);
163             event.preventDefault();
164         }
165     });
166 }
167
168 /**
169  * @return {register}
170  */
171 export function getPlugin() {
172     return register;
173 }