diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 4130f41e8..1e9dd25df 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -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 = {}): 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 index 000000000..53467e10b --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-list-item.ts @@ -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 diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 669ffe6dd..f0df08fcb 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -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 | 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 | return new CustomTableNode(); } }, + { + replace: ListItemNode, + with: (node: ListItemNode) => { + return new CustomListItemNode(node.__value, node.__checked); + } + } ]; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 5e6cdd2cc..dda05f1da 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -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 index 000000000..da8c0eae3 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts @@ -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 diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 1e52ad6a9..4ffff3cc0 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -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,'); + background-position: 50% 50%; + background-size: 100% 100%; +} + // Editor form elements .editor-form-field-wrapper { margin-bottom: .5rem;