Lexical: Updated task list to use/support old format

This commit is contained in:
Dan Brown 2024-07-30 14:42:19 +01:00
parent fe05cff64f
commit 13f8f39dd5
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
6 changed files with 192 additions and 2 deletions

View File

@ -10,6 +10,7 @@ import {el} from "./helpers";
import {EditorUiContext} from "./ui/framework/core"; import {EditorUiContext} from "./ui/framework/core";
import {listen as listenToCommonEvents} from "./common-events"; import {listen as listenToCommonEvents} from "./common-events";
import {handleDropEvents} from "./drop-handling"; 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 { export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
const config: CreateEditorArgs = { const config: CreateEditorArgs = {
@ -47,6 +48,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
registerRichText(editor), registerRichText(editor),
registerHistory(editor, createEmptyHistoryState(), 300), registerHistory(editor, createEmptyHistoryState(), 300),
registerTableResizer(editor, editWrap), registerTableResizer(editor, editWrap),
registerTaskListHandler(editor, editArea),
); );
listenToCommonEvents(editor); listenToCommonEvents(editor);

View File

@ -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;
}

View File

@ -19,6 +19,7 @@ import {CodeBlockNode} from "./code-block";
import {DiagramNode} from "./diagram"; import {DiagramNode} from "./diagram";
import {EditorUiContext} from "../ui/framework/core"; import {EditorUiContext} from "../ui/framework/core";
import {MediaNode} from "./media"; import {MediaNode} from "./media";
import {CustomListItemNode} from "./custom-list-item";
/** /**
* Load the nodes for lexical. * Load the nodes for lexical.
@ -29,7 +30,7 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
HeadingNode, // Todo - Create custom HeadingNode, // Todo - Create custom
QuoteNode, // Todo - Create custom QuoteNode, // Todo - Create custom
ListNode, // Todo - Create custom ListNode, // Todo - Create custom
ListItemNode, CustomListItemNode,
CustomTableNode, CustomTableNode,
TableRowNode, TableRowNode,
TableCellNode, TableCellNode,
@ -53,6 +54,12 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
return new CustomTableNode(); return new CustomTableNode();
} }
}, },
{
replace: ListItemNode,
with: (node: ListItemNode) => {
return new CustomListItemNode(node.__value, node.__checked);
}
}
]; ];
} }

View File

@ -12,7 +12,6 @@
- Image paste upload - Image paste upload
- Keyboard shortcuts support - Keyboard shortcuts support
- Add ID support to all block types - Add ID support to all block types
- Task list render/import from existing format
- Link popup menu for cross-content reference - Link popup menu for cross-content reference
- Link heading-based ID reference menu - Link heading-based ID reference menu
- Image gallery integration for insert - Image gallery integration for insert

View File

@ -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();
};
}

View File

@ -324,6 +324,37 @@ body.editor-is-fullscreen {
outline: 2px dashed var(--editor-color-primary); 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 elements
.editor-form-field-wrapper { .editor-form-field-wrapper {
margin-bottom: .5rem; margin-bottom: .5rem;