mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-03-13 13:55:26 +08:00
Lexical: Merged list nodes
This commit is contained in:
parent
36a4d79120
commit
ebd4604f21
@ -13,7 +13,6 @@ import type {
|
|||||||
DOMConversionOutput,
|
DOMConversionOutput,
|
||||||
DOMExportOutput,
|
DOMExportOutput,
|
||||||
EditorConfig,
|
EditorConfig,
|
||||||
EditorThemeClasses,
|
|
||||||
LexicalNode,
|
LexicalNode,
|
||||||
NodeKey,
|
NodeKey,
|
||||||
ParagraphNode,
|
ParagraphNode,
|
||||||
@ -22,10 +21,6 @@ import type {
|
|||||||
Spread,
|
Spread,
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
|
|
||||||
import {
|
|
||||||
addClassNamesToElement,
|
|
||||||
removeClassNamesFromElement,
|
|
||||||
} from '@lexical/utils';
|
|
||||||
import {
|
import {
|
||||||
$applyNodeReplacement,
|
$applyNodeReplacement,
|
||||||
$createParagraphNode,
|
$createParagraphNode,
|
||||||
@ -36,11 +31,11 @@ import {
|
|||||||
LexicalEditor,
|
LexicalEditor,
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
import invariant from 'lexical/shared/invariant';
|
import invariant from 'lexical/shared/invariant';
|
||||||
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
|
|
||||||
|
|
||||||
import {$createListNode, $isListNode} from './';
|
import {$createListNode, $isListNode} from './';
|
||||||
import {$handleIndent, $handleOutdent, mergeLists} from './formatList';
|
import {mergeLists} from './formatList';
|
||||||
import {isNestedListNode} from './utils';
|
import {isNestedListNode} from './utils';
|
||||||
|
import {el} from "../../utils/dom";
|
||||||
|
|
||||||
export type SerializedListItemNode = Spread<
|
export type SerializedListItemNode = Spread<
|
||||||
{
|
{
|
||||||
@ -74,11 +69,17 @@ export class ListItemNode extends ElementNode {
|
|||||||
createDOM(config: EditorConfig): HTMLElement {
|
createDOM(config: EditorConfig): HTMLElement {
|
||||||
const element = document.createElement('li');
|
const element = document.createElement('li');
|
||||||
const parent = this.getParent();
|
const parent = this.getParent();
|
||||||
|
|
||||||
if ($isListNode(parent) && parent.getListType() === 'check') {
|
if ($isListNode(parent) && parent.getListType() === 'check') {
|
||||||
updateListItemChecked(element, this, null, parent);
|
updateListItemChecked(element, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
element.value = this.__value;
|
element.value = this.__value;
|
||||||
$setListItemThemeClassNames(element, config.theme, this);
|
|
||||||
|
if ($hasNestedListWithoutLabel(this)) {
|
||||||
|
element.style.listStyle = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,11 +90,12 @@ export class ListItemNode extends ElementNode {
|
|||||||
): boolean {
|
): boolean {
|
||||||
const parent = this.getParent();
|
const parent = this.getParent();
|
||||||
if ($isListNode(parent) && parent.getListType() === 'check') {
|
if ($isListNode(parent) && parent.getListType() === 'check') {
|
||||||
updateListItemChecked(dom, this, prevNode, parent);
|
updateListItemChecked(dom, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dom.style.listStyle = $hasNestedListWithoutLabel(this) ? 'none' : '';
|
||||||
// @ts-expect-error - this is always HTMLListItemElement
|
// @ts-expect-error - this is always HTMLListItemElement
|
||||||
dom.value = this.__value;
|
dom.value = this.__value;
|
||||||
$setListItemThemeClassNames(dom, config.theme, this);
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -132,6 +134,20 @@ export class ListItemNode extends ElementNode {
|
|||||||
|
|
||||||
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
||||||
const element = this.createDOM(editor._config);
|
const element = this.createDOM(editor._config);
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
element,
|
element,
|
||||||
};
|
};
|
||||||
@ -390,89 +406,33 @@ export class ListItemNode extends ElementNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function $setListItemThemeClassNames(
|
function $hasNestedListWithoutLabel(node: ListItemNode): boolean {
|
||||||
dom: HTMLElement,
|
const children = node.getChildren();
|
||||||
editorThemeClasses: EditorThemeClasses,
|
let hasLabel = false;
|
||||||
node: ListItemNode,
|
let hasNestedList = false;
|
||||||
): void {
|
|
||||||
const classesToAdd = [];
|
|
||||||
const classesToRemove = [];
|
|
||||||
const listTheme = editorThemeClasses.list;
|
|
||||||
const listItemClassName = listTheme ? listTheme.listitem : undefined;
|
|
||||||
let nestedListItemClassName;
|
|
||||||
|
|
||||||
if (listTheme && listTheme.nested) {
|
for (const child of children) {
|
||||||
nestedListItemClassName = listTheme.nested.listitem;
|
if ($isListNode(child)) {
|
||||||
}
|
hasNestedList = true;
|
||||||
|
} else if (child.getTextContent().trim().length > 0) {
|
||||||
if (listItemClassName !== undefined) {
|
hasLabel = true;
|
||||||
classesToAdd.push(...normalizeClassNames(listItemClassName));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listTheme) {
|
|
||||||
const parentNode = node.getParent();
|
|
||||||
const isCheckList =
|
|
||||||
$isListNode(parentNode) && parentNode.getListType() === 'check';
|
|
||||||
const checked = node.getChecked();
|
|
||||||
|
|
||||||
if (!isCheckList || checked) {
|
|
||||||
classesToRemove.push(listTheme.listitemUnchecked);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isCheckList || !checked) {
|
|
||||||
classesToRemove.push(listTheme.listitemChecked);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCheckList) {
|
|
||||||
classesToAdd.push(
|
|
||||||
checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nestedListItemClassName !== undefined) {
|
return hasNestedList && !hasLabel;
|
||||||
const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
|
|
||||||
|
|
||||||
if (node.getChildren().some((child) => $isListNode(child))) {
|
|
||||||
classesToAdd.push(...nestedListItemClasses);
|
|
||||||
} else {
|
|
||||||
classesToRemove.push(...nestedListItemClasses);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (classesToRemove.length > 0) {
|
|
||||||
removeClassNamesFromElement(dom, ...classesToRemove);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (classesToAdd.length > 0) {
|
|
||||||
addClassNamesToElement(dom, ...classesToAdd);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateListItemChecked(
|
function updateListItemChecked(
|
||||||
dom: HTMLElement,
|
dom: HTMLElement,
|
||||||
listItemNode: ListItemNode,
|
listItemNode: ListItemNode,
|
||||||
prevListItemNode: ListItemNode | null,
|
|
||||||
listNode: ListNode,
|
|
||||||
): void {
|
): void {
|
||||||
// Only add attributes for leaf list items
|
// Only set task list attrs for leaf list items
|
||||||
if ($isListNode(listItemNode.getFirstChild())) {
|
const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild());
|
||||||
dom.removeAttribute('role');
|
dom.classList.toggle('task-list-item', shouldBeTaskItem);
|
||||||
dom.removeAttribute('tabIndex');
|
if (listItemNode.__checked) {
|
||||||
dom.removeAttribute('aria-checked');
|
dom.setAttribute('checked', 'checked');
|
||||||
} else {
|
} else {
|
||||||
dom.setAttribute('role', 'checkbox');
|
dom.removeAttribute('checked');
|
||||||
dom.setAttribute('tabIndex', '-1');
|
|
||||||
|
|
||||||
if (
|
|
||||||
!prevListItemNode ||
|
|
||||||
listItemNode.__checked !== prevListItemNode.__checked
|
|
||||||
) {
|
|
||||||
dom.setAttribute(
|
|
||||||
'aria-checked',
|
|
||||||
listItemNode.getChecked() ? 'true' : 'false',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,9 +36,11 @@ import {
|
|||||||
updateChildrenListItemValue,
|
updateChildrenListItemValue,
|
||||||
} from './formatList';
|
} from './formatList';
|
||||||
import {$getListDepth, $wrapInListItem} from './utils';
|
import {$getListDepth, $wrapInListItem} from './utils';
|
||||||
|
import {extractDirectionFromElement} from "../../nodes/_common";
|
||||||
|
|
||||||
export type SerializedListNode = Spread<
|
export type SerializedListNode = Spread<
|
||||||
{
|
{
|
||||||
|
id: string;
|
||||||
listType: ListType;
|
listType: ListType;
|
||||||
start: number;
|
start: number;
|
||||||
tag: ListNodeTagType;
|
tag: ListNodeTagType;
|
||||||
@ -58,15 +60,18 @@ export class ListNode extends ElementNode {
|
|||||||
__start: number;
|
__start: number;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
__listType: ListType;
|
__listType: ListType;
|
||||||
|
/** @internal */
|
||||||
|
__id: string = '';
|
||||||
|
|
||||||
static getType(): string {
|
static getType(): string {
|
||||||
return 'list';
|
return 'list';
|
||||||
}
|
}
|
||||||
|
|
||||||
static clone(node: ListNode): ListNode {
|
static clone(node: ListNode): ListNode {
|
||||||
const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag];
|
const newNode = new ListNode(node.__listType, node.__start, node.__key);
|
||||||
|
newNode.__id = node.__id;
|
||||||
return new ListNode(listType, node.__start, node.__key);
|
newNode.__dir = node.__dir;
|
||||||
|
return newNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(listType: ListType, start: number, key?: NodeKey) {
|
constructor(listType: ListType, start: number, key?: NodeKey) {
|
||||||
@ -81,6 +86,16 @@ export class ListNode extends ElementNode {
|
|||||||
return this.__tag;
|
return this.__tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setId(id: string) {
|
||||||
|
const self = this.getWritable();
|
||||||
|
self.__id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): string {
|
||||||
|
const self = this.getLatest();
|
||||||
|
return self.__id;
|
||||||
|
}
|
||||||
|
|
||||||
setListType(type: ListType): void {
|
setListType(type: ListType): void {
|
||||||
const writable = this.getWritable();
|
const writable = this.getWritable();
|
||||||
writable.__listType = type;
|
writable.__listType = type;
|
||||||
@ -108,6 +123,14 @@ export class ListNode extends ElementNode {
|
|||||||
dom.__lexicalListType = this.__listType;
|
dom.__lexicalListType = this.__listType;
|
||||||
$setListThemeClassNames(dom, config.theme, this);
|
$setListThemeClassNames(dom, config.theme, this);
|
||||||
|
|
||||||
|
if (this.__id) {
|
||||||
|
dom.setAttribute('id', this.__id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.__dir) {
|
||||||
|
dom.setAttribute('dir', this.__dir);
|
||||||
|
}
|
||||||
|
|
||||||
return dom;
|
return dom;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,7 +139,11 @@ export class ListNode extends ElementNode {
|
|||||||
dom: HTMLElement,
|
dom: HTMLElement,
|
||||||
config: EditorConfig,
|
config: EditorConfig,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (prevNode.__tag !== this.__tag) {
|
if (
|
||||||
|
prevNode.__tag !== this.__tag
|
||||||
|
|| prevNode.__dir !== this.__dir
|
||||||
|
|| prevNode.__id !== this.__id
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,8 +175,7 @@ export class ListNode extends ElementNode {
|
|||||||
|
|
||||||
static importJSON(serializedNode: SerializedListNode): ListNode {
|
static importJSON(serializedNode: SerializedListNode): ListNode {
|
||||||
const node = $createListNode(serializedNode.listType, serializedNode.start);
|
const node = $createListNode(serializedNode.listType, serializedNode.start);
|
||||||
node.setFormat(serializedNode.format);
|
node.setId(serializedNode.id);
|
||||||
node.setIndent(serializedNode.indent);
|
|
||||||
node.setDirection(serializedNode.direction);
|
node.setDirection(serializedNode.direction);
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
@ -177,6 +203,7 @@ export class ListNode extends ElementNode {
|
|||||||
tag: this.getTag(),
|
tag: this.getTag(),
|
||||||
type: 'list',
|
type: 'list',
|
||||||
version: 1,
|
version: 1,
|
||||||
|
id: this.__id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,28 +304,21 @@ function $setListThemeClassNames(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This function normalizes the children of a ListNode after the conversion from HTML,
|
* This function is a custom normalization function to allow nested lists within list item elements.
|
||||||
* ensuring that they are all ListItemNodes and contain either a single nested ListNode
|
* Original taken from https://github.com/facebook/lexical/blob/6e10210fd1e113ccfafdc999b1d896733c5c5bea/packages/lexical-list/src/LexicalListNode.ts#L284-L303
|
||||||
* or some other inline content.
|
* With modifications made.
|
||||||
*/
|
*/
|
||||||
function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
|
function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
|
||||||
const normalizedListItems: Array<ListItemNode> = [];
|
const normalizedListItems: Array<ListItemNode> = [];
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
|
||||||
const node = nodes[i];
|
for (const node of nodes) {
|
||||||
if ($isListItemNode(node)) {
|
if ($isListItemNode(node)) {
|
||||||
normalizedListItems.push(node);
|
normalizedListItems.push(node);
|
||||||
const children = node.getChildren();
|
|
||||||
if (children.length > 1) {
|
|
||||||
children.forEach((child) => {
|
|
||||||
if ($isListNode(child)) {
|
|
||||||
normalizedListItems.push($wrapInListItem(child));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
normalizedListItems.push($wrapInListItem(node));
|
normalizedListItems.push($wrapInListItem(node));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizedListItems;
|
return normalizedListItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,6 +354,14 @@ function $convertListNode(domNode: HTMLElement): DOMConversionOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (domNode.id && node) {
|
||||||
|
node.setId(domNode.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domNode.dir && node) {
|
||||||
|
node.setDirection(extractDirectionFromElement(domNode));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
after: $normalizeChildren,
|
after: $normalizeChildren,
|
||||||
node,
|
node,
|
||||||
|
@ -1265,99 +1265,5 @@ describe('LexicalListItemNode tests', () => {
|
|||||||
expect($isListItemNode(listItemNode)).toBe(true);
|
expect($isListItemNode(listItemNode)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ListItemNode.setIndent()', () => {
|
|
||||||
let listNode: ListNode;
|
|
||||||
let listItemNode1: ListItemNode;
|
|
||||||
let listItemNode2: ListItemNode;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const {editor} = testEnv;
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
const root = $getRoot();
|
|
||||||
listNode = new ListNode('bullet', 1);
|
|
||||||
listItemNode1 = new ListItemNode();
|
|
||||||
|
|
||||||
listItemNode2 = new ListItemNode();
|
|
||||||
|
|
||||||
root.append(listNode);
|
|
||||||
listNode.append(listItemNode1, listItemNode2);
|
|
||||||
listItemNode1.append(new TextNode('one'));
|
|
||||||
listItemNode2.append(new TextNode('two'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('indents and outdents list item', async () => {
|
|
||||||
const {editor} = testEnv;
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
listItemNode1.setIndent(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
expect(listItemNode1.getIndent()).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
expectHtmlToBeEqual(
|
|
||||||
editor.getRootElement()!.innerHTML,
|
|
||||||
html`
|
|
||||||
<ul>
|
|
||||||
<li value="1">
|
|
||||||
<ul>
|
|
||||||
<li value="1">
|
|
||||||
<ul>
|
|
||||||
<li value="1">
|
|
||||||
<ul>
|
|
||||||
<li value="1">
|
|
||||||
<span data-lexical-text="true">one</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li value="1">
|
|
||||||
<span data-lexical-text="true">two</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
listItemNode1.setIndent(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
expect(listItemNode1.getIndent()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
expectHtmlToBeEqual(
|
|
||||||
editor.getRootElement()!.innerHTML,
|
|
||||||
html`
|
|
||||||
<ul>
|
|
||||||
<li value="1">
|
|
||||||
<span data-lexical-text="true">one</span>
|
|
||||||
</li>
|
|
||||||
<li value="2">
|
|
||||||
<span data-lexical-text="true">two</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles fractional indent values', async () => {
|
|
||||||
const {editor} = testEnv;
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
listItemNode1.setIndent(0.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
expect(listItemNode1.getIndent()).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,120 +0,0 @@
|
|||||||
import {$isListNode, ListItemNode, SerializedListItemNode} from "@lexical/list";
|
|
||||||
import {EditorConfig} from "lexical/LexicalEditor";
|
|
||||||
import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical";
|
|
||||||
|
|
||||||
import {el} from "../utils/dom";
|
|
||||||
import {$isCustomListNode} from "./custom-list";
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if ($hasNestedListWithoutLabel(this)) {
|
|
||||||
element.style.listStyle = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDOM(
|
|
||||||
prevNode: ListItemNode,
|
|
||||||
dom: HTMLElement,
|
|
||||||
config: EditorConfig,
|
|
||||||
): boolean {
|
|
||||||
const parent = this.getParent();
|
|
||||||
if ($isListNode(parent) && parent.getListType() === 'check') {
|
|
||||||
updateListItemChecked(dom, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
dom.style.listStyle = $hasNestedListWithoutLabel(this) ? 'none' : '';
|
|
||||||
// @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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function $hasNestedListWithoutLabel(node: CustomListItemNode): boolean {
|
|
||||||
const children = node.getChildren();
|
|
||||||
let hasLabel = false;
|
|
||||||
let hasNestedList = false;
|
|
||||||
|
|
||||||
for (const child of children) {
|
|
||||||
if ($isCustomListNode(child)) {
|
|
||||||
hasNestedList = true;
|
|
||||||
} else if (child.getTextContent().trim().length > 0) {
|
|
||||||
hasLabel = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasNestedList && !hasLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function $isCustomListItemNode(
|
|
||||||
node: LexicalNode | null | undefined,
|
|
||||||
): node is CustomListItemNode {
|
|
||||||
return node instanceof CustomListItemNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function $createCustomListItemNode(): CustomListItemNode {
|
|
||||||
return new CustomListItemNode();
|
|
||||||
}
|
|
@ -1,139 +0,0 @@
|
|||||||
import {
|
|
||||||
DOMConversionFn,
|
|
||||||
DOMConversionMap, EditorConfig,
|
|
||||||
LexicalNode,
|
|
||||||
Spread
|
|
||||||
} from "lexical";
|
|
||||||
import {$isListItemNode, ListItemNode, ListNode, ListType, SerializedListNode} from "@lexical/list";
|
|
||||||
import {$createCustomListItemNode} from "./custom-list-item";
|
|
||||||
import {extractDirectionFromElement} from "./_common";
|
|
||||||
|
|
||||||
|
|
||||||
export type SerializedCustomListNode = Spread<{
|
|
||||||
id: string;
|
|
||||||
}, SerializedListNode>
|
|
||||||
|
|
||||||
export class CustomListNode extends ListNode {
|
|
||||||
__id: string = '';
|
|
||||||
|
|
||||||
static getType() {
|
|
||||||
return 'custom-list';
|
|
||||||
}
|
|
||||||
|
|
||||||
setId(id: string) {
|
|
||||||
const self = this.getWritable();
|
|
||||||
self.__id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
getId(): string {
|
|
||||||
const self = this.getLatest();
|
|
||||||
return self.__id;
|
|
||||||
}
|
|
||||||
|
|
||||||
static clone(node: CustomListNode) {
|
|
||||||
const newNode = new CustomListNode(node.__listType, node.__start, node.__key);
|
|
||||||
newNode.__id = node.__id;
|
|
||||||
newNode.__dir = node.__dir;
|
|
||||||
return newNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
createDOM(config: EditorConfig): HTMLElement {
|
|
||||||
const dom = super.createDOM(config);
|
|
||||||
if (this.__id) {
|
|
||||||
dom.setAttribute('id', this.__id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.__dir) {
|
|
||||||
dom.setAttribute('dir', this.__dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
return dom;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDOM(prevNode: ListNode, dom: HTMLElement, config: EditorConfig): boolean {
|
|
||||||
return super.updateDOM(prevNode, dom, config) ||
|
|
||||||
prevNode.__dir !== this.__dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
exportJSON(): SerializedCustomListNode {
|
|
||||||
return {
|
|
||||||
...super.exportJSON(),
|
|
||||||
type: 'custom-list',
|
|
||||||
version: 1,
|
|
||||||
id: this.__id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static importJSON(serializedNode: SerializedCustomListNode): CustomListNode {
|
|
||||||
const node = $createCustomListNode(serializedNode.listType);
|
|
||||||
node.setId(serializedNode.id);
|
|
||||||
node.setDirection(serializedNode.direction);
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
static importDOM(): DOMConversionMap | null {
|
|
||||||
// @ts-ignore
|
|
||||||
const converter = super.importDOM().ol().conversion as DOMConversionFn<HTMLElement>;
|
|
||||||
const customConvertFunction = (element: HTMLElement) => {
|
|
||||||
const baseResult = converter(element);
|
|
||||||
if (element.id && baseResult?.node) {
|
|
||||||
(baseResult.node as CustomListNode).setId(element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.dir && baseResult?.node) {
|
|
||||||
(baseResult.node as CustomListNode).setDirection(extractDirectionFromElement(element));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseResult) {
|
|
||||||
baseResult.after = $normalizeChildren;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseResult;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
ol: () => ({
|
|
||||||
conversion: customConvertFunction,
|
|
||||||
priority: 0,
|
|
||||||
}),
|
|
||||||
ul: () => ({
|
|
||||||
conversion: customConvertFunction,
|
|
||||||
priority: 0,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This function is a custom normalization function to allow nested lists within list item elements.
|
|
||||||
* Original taken from https://github.com/facebook/lexical/blob/6e10210fd1e113ccfafdc999b1d896733c5c5bea/packages/lexical-list/src/LexicalListNode.ts#L284-L303
|
|
||||||
* With modifications made.
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
* MIT license
|
|
||||||
*/
|
|
||||||
function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
|
|
||||||
const normalizedListItems: Array<ListItemNode> = [];
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
if ($isListItemNode(node)) {
|
|
||||||
normalizedListItems.push(node);
|
|
||||||
} else {
|
|
||||||
normalizedListItems.push($wrapInListItem(node));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedListItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
function $wrapInListItem(node: LexicalNode): ListItemNode {
|
|
||||||
const listItemWrapper = $createCustomListItemNode();
|
|
||||||
return listItemWrapper.append(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function $createCustomListNode(type: ListType): CustomListNode {
|
|
||||||
return new CustomListNode(type, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode {
|
|
||||||
return node instanceof CustomListNode;
|
|
||||||
}
|
|
@ -17,10 +17,8 @@ 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";
|
|
||||||
import {CustomTableCellNode} from "./custom-table-cell";
|
import {CustomTableCellNode} from "./custom-table-cell";
|
||||||
import {CustomTableRowNode} from "./custom-table-row";
|
import {CustomTableRowNode} from "./custom-table-row";
|
||||||
import {CustomListNode} from "./custom-list";
|
|
||||||
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
|
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
|
||||||
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
|
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
|
||||||
|
|
||||||
@ -32,8 +30,8 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
|
|||||||
CalloutNode,
|
CalloutNode,
|
||||||
HeadingNode,
|
HeadingNode,
|
||||||
QuoteNode,
|
QuoteNode,
|
||||||
CustomListNode,
|
ListNode,
|
||||||
CustomListItemNode, // TODO - Alignment?
|
ListItemNode,
|
||||||
CustomTableNode,
|
CustomTableNode,
|
||||||
CustomTableRowNode,
|
CustomTableRowNode,
|
||||||
CustomTableCellNode,
|
CustomTableCellNode,
|
||||||
@ -45,18 +43,6 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
|
|||||||
MediaNode, // TODO - Alignment
|
MediaNode, // TODO - Alignment
|
||||||
ParagraphNode,
|
ParagraphNode,
|
||||||
LinkNode,
|
LinkNode,
|
||||||
{
|
|
||||||
replace: ListNode,
|
|
||||||
with: (node: ListNode) => {
|
|
||||||
return new CustomListNode(node.getListType(), node.getStart());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
replace: ListItemNode,
|
|
||||||
with: (node: ListItemNode) => {
|
|
||||||
return new CustomListItemNode(node.__value, node.__checked);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
replace: TableNode,
|
replace: TableNode,
|
||||||
with(node: TableNode) {
|
with(node: TableNode) {
|
||||||
|
@ -14,8 +14,8 @@ import {$isImageNode} from "../nodes/image";
|
|||||||
import {$isMediaNode} from "../nodes/media";
|
import {$isMediaNode} from "../nodes/media";
|
||||||
import {getLastSelection} from "../utils/selection";
|
import {getLastSelection} from "../utils/selection";
|
||||||
import {$getNearestNodeBlockParent} from "../utils/nodes";
|
import {$getNearestNodeBlockParent} from "../utils/nodes";
|
||||||
import {$isCustomListItemNode} from "../nodes/custom-list-item";
|
|
||||||
import {$setInsetForSelection} from "../utils/lists";
|
import {$setInsetForSelection} from "../utils/lists";
|
||||||
|
import {$isListItemNode} from "@lexical/list";
|
||||||
|
|
||||||
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
|
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
|
||||||
if (nodes.length === 1) {
|
if (nodes.length === 1) {
|
||||||
@ -62,7 +62,7 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo
|
|||||||
const change = event?.shiftKey ? -40 : 40;
|
const change = event?.shiftKey ? -40 : 40;
|
||||||
const selection = $getSelection();
|
const selection = $getSelection();
|
||||||
const nodes = selection?.getNodes() || [];
|
const nodes = selection?.getNodes() || [];
|
||||||
if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) {
|
if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) {
|
||||||
editor.update(() => {
|
editor.update(() => {
|
||||||
$setInsetForSelection(editor, change);
|
$setInsetForSelection(editor, change);
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical";
|
import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical";
|
||||||
import {$isCustomListItemNode} from "../../../nodes/custom-list-item";
|
import {$isListItemNode} from "@lexical/list";
|
||||||
|
|
||||||
class TaskListHandler {
|
class TaskListHandler {
|
||||||
protected editorContainer: HTMLElement;
|
protected editorContainer: HTMLElement;
|
||||||
@ -38,7 +38,7 @@ class TaskListHandler {
|
|||||||
|
|
||||||
this.editor.update(() => {
|
this.editor.update(() => {
|
||||||
const node = $getNearestNodeFromDOMNode(listItem);
|
const node = $getNearestNodeFromDOMNode(listItem);
|
||||||
if ($isCustomListItemNode(node)) {
|
if ($isListItemNode(node)) {
|
||||||
node.setChecked(!node.getChecked());
|
node.setChecked(!node.getChecked());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -16,8 +16,7 @@ import {
|
|||||||
} from "./selection";
|
} from "./selection";
|
||||||
import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block";
|
import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block";
|
||||||
import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout";
|
import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout";
|
||||||
import {insertList, ListNode, ListType, removeList} from "@lexical/list";
|
import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list";
|
||||||
import {$isCustomListNode} from "../nodes/custom-list";
|
|
||||||
import {$createLinkNode, $isLinkNode} from "@lexical/link";
|
import {$createLinkNode, $isLinkNode} from "@lexical/link";
|
||||||
import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode";
|
import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode";
|
||||||
import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
|
import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
|
||||||
@ -51,7 +50,7 @@ export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) {
|
|||||||
editor.getEditorState().read(() => {
|
editor.getEditorState().read(() => {
|
||||||
const selection = $getSelection();
|
const selection = $getSelection();
|
||||||
const listSelected = $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => {
|
const listSelected = $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => {
|
||||||
return $isCustomListNode(node) && (node as ListNode).getListType() === type;
|
return $isListNode(node) && (node as ListNode).getListType() === type;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (listSelected) {
|
if (listSelected) {
|
||||||
|
@ -1,22 +1,21 @@
|
|||||||
import {$createCustomListItemNode, $isCustomListItemNode, CustomListItemNode} from "../nodes/custom-list-item";
|
|
||||||
import {$createCustomListNode, $isCustomListNode} from "../nodes/custom-list";
|
|
||||||
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
|
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
|
||||||
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
|
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
|
||||||
import {nodeHasInset} from "./nodes";
|
import {nodeHasInset} from "./nodes";
|
||||||
|
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
|
||||||
|
|
||||||
|
|
||||||
export function $nestListItem(node: CustomListItemNode): CustomListItemNode {
|
export function $nestListItem(node: ListItemNode): ListItemNode {
|
||||||
const list = node.getParent();
|
const list = node.getParent();
|
||||||
if (!$isCustomListNode(list)) {
|
if (!$isListNode(list)) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
const listItems = list.getChildren() as CustomListItemNode[];
|
const listItems = list.getChildren() as ListItemNode[];
|
||||||
const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
|
const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
|
||||||
const isFirst = nodeIndex === 0;
|
const isFirst = nodeIndex === 0;
|
||||||
|
|
||||||
const newListItem = $createCustomListItemNode();
|
const newListItem = $createListItemNode();
|
||||||
const newList = $createCustomListNode(list.getListType());
|
const newList = $createListNode(list.getListType());
|
||||||
newList.append(newListItem);
|
newList.append(newListItem);
|
||||||
newListItem.append(...node.getChildren());
|
newListItem.append(...node.getChildren());
|
||||||
|
|
||||||
@ -31,11 +30,11 @@ export function $nestListItem(node: CustomListItemNode): CustomListItemNode {
|
|||||||
return newListItem;
|
return newListItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function $unnestListItem(node: CustomListItemNode): CustomListItemNode {
|
export function $unnestListItem(node: ListItemNode): ListItemNode {
|
||||||
const list = node.getParent();
|
const list = node.getParent();
|
||||||
const parentListItem = list?.getParent();
|
const parentListItem = list?.getParent();
|
||||||
const outerList = parentListItem?.getParent();
|
const outerList = parentListItem?.getParent();
|
||||||
if (!$isCustomListNode(list) || !$isCustomListNode(outerList) || !$isCustomListItemNode(parentListItem)) {
|
if (!$isListNode(list) || !$isListNode(outerList) || !$isListItemNode(parentListItem)) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,19 +50,19 @@ export function $unnestListItem(node: CustomListItemNode): CustomListItemNode {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getListItemsForSelection(selection: BaseSelection|null): (CustomListItemNode|null)[] {
|
function getListItemsForSelection(selection: BaseSelection|null): (ListItemNode|null)[] {
|
||||||
const nodes = selection?.getNodes() || [];
|
const nodes = selection?.getNodes() || [];
|
||||||
const listItemNodes = [];
|
const listItemNodes = [];
|
||||||
|
|
||||||
outer: for (const node of nodes) {
|
outer: for (const node of nodes) {
|
||||||
if ($isCustomListItemNode(node)) {
|
if ($isListItemNode(node)) {
|
||||||
listItemNodes.push(node);
|
listItemNodes.push(node);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parents = node.getParents();
|
const parents = node.getParents();
|
||||||
for (const parent of parents) {
|
for (const parent of parents) {
|
||||||
if ($isCustomListItemNode(parent)) {
|
if ($isListItemNode(parent)) {
|
||||||
listItemNodes.push(parent);
|
listItemNodes.push(parent);
|
||||||
continue outer;
|
continue outer;
|
||||||
}
|
}
|
||||||
@ -75,8 +74,8 @@ function getListItemsForSelection(selection: BaseSelection|null): (CustomListIte
|
|||||||
return listItemNodes;
|
return listItemNodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function $reduceDedupeListItems(listItems: (CustomListItemNode|null)[]): CustomListItemNode[] {
|
function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[] {
|
||||||
const listItemMap: Record<string, CustomListItemNode> = {};
|
const listItemMap: Record<string, ListItemNode> = {};
|
||||||
|
|
||||||
for (const item of listItems) {
|
for (const item of listItems) {
|
||||||
if (item === null) {
|
if (item === null) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user