]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Updated task list to use/support old format
authorDan Brown <redacted>
Tue, 30 Jul 2024 13:42:19 +0000 (14:42 +0100)
committerDan Brown <redacted>
Tue, 30 Jul 2024 13:42:19 +0000 (14:42 +0100)
resources/js/wysiwyg/index.ts
resources/js/wysiwyg/nodes/custom-list-item.ts [new file with mode: 0644]
resources/js/wysiwyg/nodes/index.ts
resources/js/wysiwyg/todo.md
resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts [new file with mode: 0644]
resources/sass/_editor.scss

index 4130f41e8a3459d2c56ba373306967f75d46abf9..1e9dd25df7c879e1ed08c88bc7d037093652f052 100644 (file)
@@ -10,6 +10,7 @@ import {el} from "./helpers";
 import {EditorUiContext} from "./ui/framework/core";
 import {listen as listenToCommonEvents} from "./common-events";
 import {handleDropEvents} from "./drop-handling";
+import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler";
 
 export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
     const config: CreateEditorArgs = {
@@ -47,6 +48,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
         registerRichText(editor),
         registerHistory(editor, createEmptyHistoryState(), 300),
         registerTableResizer(editor, editWrap),
+        registerTaskListHandler(editor, editArea),
     );
 
     listenToCommonEvents(editor);
diff --git a/resources/js/wysiwyg/nodes/custom-list-item.ts b/resources/js/wysiwyg/nodes/custom-list-item.ts
new file mode 100644 (file)
index 0000000..53467e1
--- /dev/null
@@ -0,0 +1,92 @@
+import {$isListNode, ListItemNode, ListNode, SerializedListItemNode} from "@lexical/list";
+import {EditorConfig} from "lexical/LexicalEditor";
+import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical";
+import {el} from "../helpers";
+
+function updateListItemChecked(
+    dom: HTMLElement,
+    listItemNode: ListItemNode,
+): void {
+    // Only set task list attrs for leaf list items
+    const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild());
+    dom.classList.toggle('task-list-item', shouldBeTaskItem);
+    if (listItemNode.__checked) {
+        dom.setAttribute('checked', 'checked');
+    } else {
+        dom.removeAttribute('checked');
+    }
+}
+
+
+export class CustomListItemNode extends ListItemNode {
+    static getType(): string {
+        return 'custom-list-item';
+    }
+
+    static clone(node: CustomListItemNode): CustomListItemNode {
+        return new CustomListItemNode(node.__value, node.__checked, node.__key);
+    }
+
+    createDOM(config: EditorConfig): HTMLElement {
+        const element = document.createElement('li');
+        const parent = this.getParent();
+
+        if ($isListNode(parent) && parent.getListType() === 'check') {
+            updateListItemChecked(element, this);
+        }
+
+        element.value = this.__value;
+
+        return element;
+    }
+
+    updateDOM(
+        prevNode: ListItemNode,
+        dom: HTMLElement,
+        config: EditorConfig,
+    ): boolean {
+        const parent = this.getParent();
+        if ($isListNode(parent) && parent.getListType() === 'check') {
+            updateListItemChecked(dom, this);
+        }
+        // @ts-expect-error - this is always HTMLListItemElement
+        dom.value = this.__value;
+
+        return false;
+    }
+
+    exportDOM(editor: LexicalEditor): DOMExportOutput {
+        const element = this.createDOM(editor._config);
+        element.style.textAlign = this.getFormatType();
+
+        if (element.classList.contains('task-list-item')) {
+            const input = el('input', {
+                type: 'checkbox',
+                disabled: 'disabled',
+            });
+            if (element.hasAttribute('checked')) {
+                input.setAttribute('checked', 'checked');
+                element.removeAttribute('checked');
+            }
+
+            element.prepend(input);
+        }
+
+        return {
+            element,
+        };
+    }
+
+    exportJSON(): SerializedListItemNode {
+        return {
+            ...super.exportJSON(),
+            type: 'custom-list-item',
+        };
+    }
+}
+
+export function $isCustomListItemNode(
+    node: LexicalNode | null | undefined,
+): node is CustomListItemNode {
+    return node instanceof CustomListItemNode;
+}
\ No newline at end of file
index 669ffe6ddc9eb29863f4358733007dea76999c5b..f0df08fcbb4f18be70baa1f3a72e96b7d8db3f39 100644 (file)
@@ -19,6 +19,7 @@ import {CodeBlockNode} from "./code-block";
 import {DiagramNode} from "./diagram";
 import {EditorUiContext} from "../ui/framework/core";
 import {MediaNode} from "./media";
+import {CustomListItemNode} from "./custom-list-item";
 
 /**
  * Load the nodes for lexical.
@@ -29,7 +30,7 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
         HeadingNode, // Todo - Create custom
         QuoteNode, // Todo - Create custom
         ListNode, // Todo - Create custom
-        ListItemNode,
+        CustomListItemNode,
         CustomTableNode,
         TableRowNode,
         TableCellNode,
@@ -53,6 +54,12 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
                 return new CustomTableNode();
             }
         },
+        {
+            replace: ListItemNode,
+            with: (node: ListItemNode) => {
+                return new CustomListItemNode(node.__value, node.__checked);
+            }
+        }
     ];
 }
 
index 5e6cdd2cc885434c4d3f91d4400a553c0d98d977..dda05f1da8069524d53428974d65dd96f6cb5ba1 100644 (file)
@@ -12,7 +12,6 @@
 - Image paste upload
 - Keyboard shortcuts support
 - Add ID support to all block types
-- Task list render/import from existing format
 - Link popup menu for cross-content reference
 - Link heading-based ID reference menu
 - Image gallery integration for insert
diff --git a/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts
new file mode 100644 (file)
index 0000000..da8c0ea
--- /dev/null
@@ -0,0 +1,59 @@
+import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical";
+import {$isCustomListItemNode} from "../../../nodes/custom-list-item";
+
+class TaskListHandler {
+    protected editorContainer: HTMLElement;
+    protected editor: LexicalEditor;
+
+    constructor(editor: LexicalEditor, editorContainer: HTMLElement) {
+        this.editor = editor;
+        this.editorContainer = editorContainer;
+        this.setupListeners();
+    }
+
+    protected setupListeners() {
+        this.handleClick = this.handleClick.bind(this);
+        this.editorContainer.addEventListener('click', this.handleClick);
+    }
+
+    handleClick(event: MouseEvent) {
+        const target = event.target;
+        if (target instanceof HTMLElement && target.nodeName === 'LI' && target.classList.contains('task-list-item')) {
+            this.handleTaskListItemClick(target, event);
+            event.preventDefault();
+        }
+    }
+
+    handleTaskListItemClick(listItem: HTMLElement, event: MouseEvent) {
+        const bounds = listItem.getBoundingClientRect();
+        const withinBounds = event.clientX <= bounds.right
+            && event.clientX >= bounds.left
+            && event.clientY >= bounds.top
+            && event.clientY <= bounds.bottom;
+
+        // Outside task list item bounds means we're probably clicking the pseudo-element
+        if (withinBounds) {
+            return;
+        }
+
+        this.editor.update(() => {
+            const node = $getNearestNodeFromDOMNode(listItem);
+            if ($isCustomListItemNode(node)) {
+                node.setChecked(!node.getChecked());
+            }
+        });
+    }
+
+    teardown() {
+        this.editorContainer.removeEventListener('click', this.handleClick);
+    }
+}
+
+
+export function registerTaskListHandler(editor: LexicalEditor, editorContainer: HTMLElement): (() => void) {
+    const handler = new TaskListHandler(editor, editorContainer);
+
+    return () => {
+        handler.teardown();
+    };
+}
\ No newline at end of file
index 1e52ad6a9b29703c0ccd965d118d1ff2acce8db6..4ffff3cc0c79f6576a2e3ddbe43ae3d4f9e3c6a8 100644 (file)
@@ -324,6 +324,37 @@ body.editor-is-fullscreen {
   outline: 2px dashed var(--editor-color-primary);
 }
 
+/**
+ * Fake task list checkboxes
+ */
+.editor-content-area .task-list-item {
+  margin-left: 0;
+  position: relative;
+}
+.editor-content-area .task-list-item > input[type="checkbox"] {
+  display: none;
+}
+.editor-content-area .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;
+}
+.editor-content-area .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%;
+}
+
 // Editor form elements
 .editor-form-field-wrapper {
   margin-bottom: .5rem;
Morty Proxy This is a proxified and sanitized view of the page, visit original site.