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