diff --git a/app/Entities/Tools/Markdown/CheckboxConverter.php b/app/Entities/Tools/Markdown/CheckboxConverter.php new file mode 100644 index 000000000..e4666d666 --- /dev/null +++ b/app/Entities/Tools/Markdown/CheckboxConverter.php @@ -0,0 +1,28 @@ +getAttribute('type')) === 'checkbox') { + $isChecked = $element->getAttribute('checked') === 'checked'; + return $isChecked ? ' [x] ' : ' [ ] '; + } + + return $element->getValue(); + } + + /** + * @return string[] + */ + public function getSupportedTags(): array + { + return ['input']; + } +} \ No newline at end of file diff --git a/app/Entities/Tools/Markdown/HtmlToMarkdown.php b/app/Entities/Tools/Markdown/HtmlToMarkdown.php index e8804690c..51366705c 100644 --- a/app/Entities/Tools/Markdown/HtmlToMarkdown.php +++ b/app/Entities/Tools/Markdown/HtmlToMarkdown.php @@ -87,6 +87,7 @@ class HtmlToMarkdown $environment->addConverter(new CustomParagraphConverter()); $environment->addConverter(new PreformattedConverter()); $environment->addConverter(new TextConverter()); + $environment->addConverter(new CheckboxConverter()); return $environment; } diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index 965b14d08..e75e4f712 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -10,6 +10,7 @@ import {getPlugin as getCustomhrPlugin} from "./plugins-customhr"; import {getPlugin as getImagemanagerPlugin} from "./plugins-imagemanager"; import {getPlugin as getAboutPlugin} from "./plugins-about"; import {getPlugin as getDetailsPlugin} from "./plugins-details"; +import {getPlugin as getTasklistPlugin} from "./plugins-tasklist"; const style_formats = [ {title: "Large Header", format: "h2", preview: 'color: blue;'}, @@ -81,6 +82,7 @@ function gatherPlugins(options) { "imagemanager", "about", "details", + "tasklist", options.textDirection === 'rtl' ? 'directionality' : '', ]; @@ -89,6 +91,7 @@ function gatherPlugins(options) { window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin(options)); window.tinymce.PluginManager.add('about', getAboutPlugin(options)); window.tinymce.PluginManager.add('details', getDetailsPlugin(options)); + window.tinymce.PluginManager.add('tasklist', getTasklistPlugin(options)); if (options.drawioUrl) { window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options)); @@ -204,7 +207,7 @@ export function build(options) { statusbar: false, menubar: false, paste_data_images: false, - extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],div[*]', + extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],div[*],li[class|checked]', automatic_uploads: false, custom_elements: 'doc-root,code-block', valid_children: [ diff --git a/resources/js/wysiwyg/plugins-tasklist.js b/resources/js/wysiwyg/plugins-tasklist.js new file mode 100644 index 000000000..5b0e1c1f0 --- /dev/null +++ b/resources/js/wysiwyg/plugins-tasklist.js @@ -0,0 +1,171 @@ +/** + * @param {Editor} editor + * @param {String} url + */ +function register(editor, url) { + + // 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(inList); + }); + } + }); + + // 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(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} + }); + } + + 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} + }); + }; + + // 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); + } + } + }); + }); + + // 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 {MouseEvent} event + * @param {Element} clickedEl + * @param {Editor} editor + */ +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'); + } + }); + } +} + +/** + * @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 (child.name === 'input') { + if (child.attr('checked') === 'checked') { + node.attr('checked', 'checked'); + } + child.remove(); + } + } +} + +/** + * @param {AstNode} node + */ +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'; + } + + // Create & insert checkbox input element + const checkbox = new 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} + */ +export function getPlugin(options) { + return register; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/toolbars.js b/resources/js/wysiwyg/toolbars.js index 40cf09dc3..740220d84 100644 --- a/resources/js/wysiwyg/toolbars.js +++ b/resources/js/wysiwyg/toolbars.js @@ -31,7 +31,7 @@ function registerPrimaryToolbarGroups(editor) { editor.ui.registry.addGroupToolbarButton('listoverflow', { icon: 'more-drawer', tooltip: 'More', - items: 'outdent indent' + items: 'tasklist outdent indent' }); editor.ui.registry.addGroupToolbarButton('insertoverflow', { icon: 'more-drawer', diff --git a/resources/lang/en/editor.php b/resources/lang/en/editor.php index a70a403f6..7160af22d 100644 --- a/resources/lang/en/editor.php +++ b/resources/lang/en/editor.php @@ -55,6 +55,7 @@ return [ 'align_justify' => 'Justify', 'list_bullet' => 'Bullet list', 'list_numbered' => 'Numbered list', + 'list_task' => 'Task list', 'indent_increase' => 'Increase indent', 'indent_decrease' => 'Decrease indent', 'table' => 'Table', diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 8103ca20d..73819975f 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -164,6 +164,11 @@ body.tox-fullscreen, body.markdown-fullscreen { clear: both; } + li > input[type="checkbox"] { + vertical-align: top; + margin-top: 0.3em; + } + p:empty { min-height: 1.6em; } diff --git a/resources/sass/_text.scss b/resources/sass/_text.scss index cbe3cd4be..884808bb4 100644 --- a/resources/sass/_text.scss +++ b/resources/sass/_text.scss @@ -310,6 +310,7 @@ li > ol, li > ul { } li.checkbox-item, li.task-list-item { + display: list-item; list-style: none; margin-left: -($-m * 1.2); input[type="checkbox"] { diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index 6add27f45..0ee3fa40b 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -112,4 +112,36 @@ body.page-content.mce-content-body { } .tox-menu .tox-collection__item-label { line-height: normal !important; +} + +/** + * Fake task list checkboxes + */ +.page-content.mce-content-body .task-list-item { + margin-left: 0; + position: relative; +} +.page-content.mce-content-body .task-list-item > input[type="checkbox"] { + display: none; +} +.page-content.mce-content-body .task-list-item:before { + content: ''; + display: inline-block; + border: 2px solid #CCC; + width: 12px; + height: 12px; + border-radius: 2px; + margin-right: 8px; + vertical-align: text-top; + cursor: pointer; + position: absolute; + left: -24px; + top: 4px; +} + +.page-content.mce-content-body .task-list-item[checked]:before { + background-color: #CCC; + background-image: url('data:image/svg+xml;utf8,'); + background-position: 50% 50%; + background-size: 100% 100%; } \ No newline at end of file diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index fc15bb8f3..2841175ad 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -386,6 +386,18 @@ class ExportTest extends TestCase $resp->assertSee("# Dogcat\n\n```JavaScript\nvar a = 'cat';\n```\n\nAnother line", false); } + public function test_page_markdown_export_handles_tasklist_checkboxes() + { + $page = Page::query()->first()->forceFill([ + 'markdown' => '', + 'html' => '', + ]); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); + $resp->assertSee("- [x] Item A\n- [ ] Item B", false); + } + public function test_chapter_markdown_export() { $chapter = Chapter::query()->first();