mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-07-17 00:32:36 +00:00
Merge pull request #3333 from BookStackApp/wysiwyg_tasklist
WYSIWYG tasklist support
This commit is contained in:
commit
a2231c3604
28
app/Entities/Tools/Markdown/CheckboxConverter.php
Normal file
28
app/Entities/Tools/Markdown/CheckboxConverter.php
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Entities\Tools\Markdown;
|
||||||
|
|
||||||
|
use League\HTMLToMarkdown\Converter\ConverterInterface;
|
||||||
|
use League\HTMLToMarkdown\ElementInterface;
|
||||||
|
|
||||||
|
class CheckboxConverter implements ConverterInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
public function convert(ElementInterface $element): string
|
||||||
|
{
|
||||||
|
if (strtolower($element->getAttribute('type')) === 'checkbox') {
|
||||||
|
$isChecked = $element->getAttribute('checked') === 'checked';
|
||||||
|
return $isChecked ? ' [x] ' : ' [ ] ';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $element->getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function getSupportedTags(): array
|
||||||
|
{
|
||||||
|
return ['input'];
|
||||||
|
}
|
||||||
|
}
|
|
@ -87,6 +87,7 @@ class HtmlToMarkdown
|
||||||
$environment->addConverter(new CustomParagraphConverter());
|
$environment->addConverter(new CustomParagraphConverter());
|
||||||
$environment->addConverter(new PreformattedConverter());
|
$environment->addConverter(new PreformattedConverter());
|
||||||
$environment->addConverter(new TextConverter());
|
$environment->addConverter(new TextConverter());
|
||||||
|
$environment->addConverter(new CheckboxConverter());
|
||||||
|
|
||||||
return $environment;
|
return $environment;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {getPlugin as getCustomhrPlugin} from "./plugins-customhr";
|
||||||
import {getPlugin as getImagemanagerPlugin} from "./plugins-imagemanager";
|
import {getPlugin as getImagemanagerPlugin} from "./plugins-imagemanager";
|
||||||
import {getPlugin as getAboutPlugin} from "./plugins-about";
|
import {getPlugin as getAboutPlugin} from "./plugins-about";
|
||||||
import {getPlugin as getDetailsPlugin} from "./plugins-details";
|
import {getPlugin as getDetailsPlugin} from "./plugins-details";
|
||||||
|
import {getPlugin as getTasklistPlugin} from "./plugins-tasklist";
|
||||||
|
|
||||||
const style_formats = [
|
const style_formats = [
|
||||||
{title: "Large Header", format: "h2", preview: 'color: blue;'},
|
{title: "Large Header", format: "h2", preview: 'color: blue;'},
|
||||||
|
@ -81,6 +82,7 @@ function gatherPlugins(options) {
|
||||||
"imagemanager",
|
"imagemanager",
|
||||||
"about",
|
"about",
|
||||||
"details",
|
"details",
|
||||||
|
"tasklist",
|
||||||
options.textDirection === 'rtl' ? 'directionality' : '',
|
options.textDirection === 'rtl' ? 'directionality' : '',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -89,6 +91,7 @@ function gatherPlugins(options) {
|
||||||
window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin(options));
|
window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin(options));
|
||||||
window.tinymce.PluginManager.add('about', getAboutPlugin(options));
|
window.tinymce.PluginManager.add('about', getAboutPlugin(options));
|
||||||
window.tinymce.PluginManager.add('details', getDetailsPlugin(options));
|
window.tinymce.PluginManager.add('details', getDetailsPlugin(options));
|
||||||
|
window.tinymce.PluginManager.add('tasklist', getTasklistPlugin(options));
|
||||||
|
|
||||||
if (options.drawioUrl) {
|
if (options.drawioUrl) {
|
||||||
window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options));
|
window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options));
|
||||||
|
@ -204,7 +207,7 @@ export function build(options) {
|
||||||
statusbar: false,
|
statusbar: false,
|
||||||
menubar: false,
|
menubar: false,
|
||||||
paste_data_images: 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,
|
automatic_uploads: false,
|
||||||
custom_elements: 'doc-root,code-block',
|
custom_elements: 'doc-root,code-block',
|
||||||
valid_children: [
|
valid_children: [
|
||||||
|
|
171
resources/js/wysiwyg/plugins-tasklist.js
Normal file
171
resources/js/wysiwyg/plugins-tasklist.js
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* @param {Editor} editor
|
||||||
|
* @param {String} url
|
||||||
|
*/
|
||||||
|
function register(editor, url) {
|
||||||
|
|
||||||
|
// Tasklist UI buttons
|
||||||
|
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>');
|
||||||
|
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;
|
||||||
|
}
|
|
@ -31,7 +31,7 @@ function registerPrimaryToolbarGroups(editor) {
|
||||||
editor.ui.registry.addGroupToolbarButton('listoverflow', {
|
editor.ui.registry.addGroupToolbarButton('listoverflow', {
|
||||||
icon: 'more-drawer',
|
icon: 'more-drawer',
|
||||||
tooltip: 'More',
|
tooltip: 'More',
|
||||||
items: 'outdent indent'
|
items: 'tasklist outdent indent'
|
||||||
});
|
});
|
||||||
editor.ui.registry.addGroupToolbarButton('insertoverflow', {
|
editor.ui.registry.addGroupToolbarButton('insertoverflow', {
|
||||||
icon: 'more-drawer',
|
icon: 'more-drawer',
|
||||||
|
|
|
@ -55,6 +55,7 @@ return [
|
||||||
'align_justify' => 'Justify',
|
'align_justify' => 'Justify',
|
||||||
'list_bullet' => 'Bullet list',
|
'list_bullet' => 'Bullet list',
|
||||||
'list_numbered' => 'Numbered list',
|
'list_numbered' => 'Numbered list',
|
||||||
|
'list_task' => 'Task list',
|
||||||
'indent_increase' => 'Increase indent',
|
'indent_increase' => 'Increase indent',
|
||||||
'indent_decrease' => 'Decrease indent',
|
'indent_decrease' => 'Decrease indent',
|
||||||
'table' => 'Table',
|
'table' => 'Table',
|
||||||
|
|
|
@ -164,6 +164,11 @@ body.tox-fullscreen, body.markdown-fullscreen {
|
||||||
clear: both;
|
clear: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
li > input[type="checkbox"] {
|
||||||
|
vertical-align: top;
|
||||||
|
margin-top: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
p:empty {
|
p:empty {
|
||||||
min-height: 1.6em;
|
min-height: 1.6em;
|
||||||
}
|
}
|
||||||
|
|
|
@ -310,6 +310,7 @@ li > ol, li > ul {
|
||||||
}
|
}
|
||||||
|
|
||||||
li.checkbox-item, li.task-list-item {
|
li.checkbox-item, li.task-list-item {
|
||||||
|
display: list-item;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin-left: -($-m * 1.2);
|
margin-left: -($-m * 1.2);
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
|
|
|
@ -112,4 +112,36 @@ body.page-content.mce-content-body {
|
||||||
}
|
}
|
||||||
.tox-menu .tox-collection__item-label {
|
.tox-menu .tox-collection__item-label {
|
||||||
line-height: normal !important;
|
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,<svg fill="%23FFFFFF" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m8.4856 20.274-6.736-6.736 2.9287-2.7823 3.8073 3.8073 10.836-10.836 2.9287 2.9287z" stroke-width="1.4644"/></svg>');
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-size: 100% 100%;
|
||||||
}
|
}
|
|
@ -386,6 +386,18 @@ class ExportTest extends TestCase
|
||||||
$resp->assertSee("# Dogcat\n\n```JavaScript\nvar a = 'cat';\n```\n\nAnother line", false);
|
$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' => '<ul><li><input type="checkbox" checked="checked">Item A</li><li><input type="checkbox">Item B</li></ul>',
|
||||||
|
]);
|
||||||
|
$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()
|
public function test_chapter_markdown_export()
|
||||||
{
|
{
|
||||||
$chapter = Chapter::query()->first();
|
$chapter = Chapter::query()->first();
|
||||||
|
|
Loading…
Reference in New Issue
Block a user