mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-12-13 14:33:37 +08:00
22d078b47f
Imported at 0.17.1, Modified to work in-app. Added & configured test dependancies. Tests need to be altered to avoid using non-included deps including react dependancies.
368 lines
9.3 KiB
TypeScript
368 lines
9.3 KiB
TypeScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
*/
|
|
|
|
import {
|
|
addClassNamesToElement,
|
|
isHTMLElement,
|
|
removeClassNamesFromElement,
|
|
} from '@lexical/utils';
|
|
import {
|
|
$applyNodeReplacement,
|
|
$createTextNode,
|
|
$isElementNode,
|
|
DOMConversionMap,
|
|
DOMConversionOutput,
|
|
DOMExportOutput,
|
|
EditorConfig,
|
|
EditorThemeClasses,
|
|
ElementNode,
|
|
LexicalEditor,
|
|
LexicalNode,
|
|
NodeKey,
|
|
SerializedElementNode,
|
|
Spread,
|
|
} from 'lexical';
|
|
import invariant from 'lexical/shared/invariant';
|
|
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
|
|
|
|
import {$createListItemNode, $isListItemNode, ListItemNode} from '.';
|
|
import {
|
|
mergeNextSiblingListIfSameType,
|
|
updateChildrenListItemValue,
|
|
} from './formatList';
|
|
import {$getListDepth, $wrapInListItem} from './utils';
|
|
|
|
export type SerializedListNode = Spread<
|
|
{
|
|
listType: ListType;
|
|
start: number;
|
|
tag: ListNodeTagType;
|
|
},
|
|
SerializedElementNode
|
|
>;
|
|
|
|
export type ListType = 'number' | 'bullet' | 'check';
|
|
|
|
export type ListNodeTagType = 'ul' | 'ol';
|
|
|
|
/** @noInheritDoc */
|
|
export class ListNode extends ElementNode {
|
|
/** @internal */
|
|
__tag: ListNodeTagType;
|
|
/** @internal */
|
|
__start: number;
|
|
/** @internal */
|
|
__listType: ListType;
|
|
|
|
static getType(): string {
|
|
return 'list';
|
|
}
|
|
|
|
static clone(node: ListNode): ListNode {
|
|
const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag];
|
|
|
|
return new ListNode(listType, node.__start, node.__key);
|
|
}
|
|
|
|
constructor(listType: ListType, start: number, key?: NodeKey) {
|
|
super(key);
|
|
const _listType = TAG_TO_LIST_TYPE[listType] || listType;
|
|
this.__listType = _listType;
|
|
this.__tag = _listType === 'number' ? 'ol' : 'ul';
|
|
this.__start = start;
|
|
}
|
|
|
|
getTag(): ListNodeTagType {
|
|
return this.__tag;
|
|
}
|
|
|
|
setListType(type: ListType): void {
|
|
const writable = this.getWritable();
|
|
writable.__listType = type;
|
|
writable.__tag = type === 'number' ? 'ol' : 'ul';
|
|
}
|
|
|
|
getListType(): ListType {
|
|
return this.__listType;
|
|
}
|
|
|
|
getStart(): number {
|
|
return this.__start;
|
|
}
|
|
|
|
// View
|
|
|
|
createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
|
|
const tag = this.__tag;
|
|
const dom = document.createElement(tag);
|
|
|
|
if (this.__start !== 1) {
|
|
dom.setAttribute('start', String(this.__start));
|
|
}
|
|
// @ts-expect-error Internal field.
|
|
dom.__lexicalListType = this.__listType;
|
|
$setListThemeClassNames(dom, config.theme, this);
|
|
|
|
return dom;
|
|
}
|
|
|
|
updateDOM(
|
|
prevNode: ListNode,
|
|
dom: HTMLElement,
|
|
config: EditorConfig,
|
|
): boolean {
|
|
if (prevNode.__tag !== this.__tag) {
|
|
return true;
|
|
}
|
|
|
|
$setListThemeClassNames(dom, config.theme, this);
|
|
|
|
return false;
|
|
}
|
|
|
|
static transform(): (node: LexicalNode) => void {
|
|
return (node: LexicalNode) => {
|
|
invariant($isListNode(node), 'node is not a ListNode');
|
|
mergeNextSiblingListIfSameType(node);
|
|
updateChildrenListItemValue(node);
|
|
};
|
|
}
|
|
|
|
static importDOM(): DOMConversionMap | null {
|
|
return {
|
|
ol: () => ({
|
|
conversion: $convertListNode,
|
|
priority: 0,
|
|
}),
|
|
ul: () => ({
|
|
conversion: $convertListNode,
|
|
priority: 0,
|
|
}),
|
|
};
|
|
}
|
|
|
|
static importJSON(serializedNode: SerializedListNode): ListNode {
|
|
const node = $createListNode(serializedNode.listType, serializedNode.start);
|
|
node.setFormat(serializedNode.format);
|
|
node.setIndent(serializedNode.indent);
|
|
node.setDirection(serializedNode.direction);
|
|
return node;
|
|
}
|
|
|
|
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
|
const {element} = super.exportDOM(editor);
|
|
if (element && isHTMLElement(element)) {
|
|
if (this.__start !== 1) {
|
|
element.setAttribute('start', String(this.__start));
|
|
}
|
|
if (this.__listType === 'check') {
|
|
element.setAttribute('__lexicalListType', 'check');
|
|
}
|
|
}
|
|
return {
|
|
element,
|
|
};
|
|
}
|
|
|
|
exportJSON(): SerializedListNode {
|
|
return {
|
|
...super.exportJSON(),
|
|
listType: this.getListType(),
|
|
start: this.getStart(),
|
|
tag: this.getTag(),
|
|
type: 'list',
|
|
version: 1,
|
|
};
|
|
}
|
|
|
|
canBeEmpty(): false {
|
|
return false;
|
|
}
|
|
|
|
canIndent(): false {
|
|
return false;
|
|
}
|
|
|
|
append(...nodesToAppend: LexicalNode[]): this {
|
|
for (let i = 0; i < nodesToAppend.length; i++) {
|
|
const currentNode = nodesToAppend[i];
|
|
|
|
if ($isListItemNode(currentNode)) {
|
|
super.append(currentNode);
|
|
} else {
|
|
const listItemNode = $createListItemNode();
|
|
|
|
if ($isListNode(currentNode)) {
|
|
listItemNode.append(currentNode);
|
|
} else if ($isElementNode(currentNode)) {
|
|
const textNode = $createTextNode(currentNode.getTextContent());
|
|
listItemNode.append(textNode);
|
|
} else {
|
|
listItemNode.append(currentNode);
|
|
}
|
|
super.append(listItemNode);
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
|
|
extractWithChild(child: LexicalNode): boolean {
|
|
return $isListItemNode(child);
|
|
}
|
|
}
|
|
|
|
function $setListThemeClassNames(
|
|
dom: HTMLElement,
|
|
editorThemeClasses: EditorThemeClasses,
|
|
node: ListNode,
|
|
): void {
|
|
const classesToAdd = [];
|
|
const classesToRemove = [];
|
|
const listTheme = editorThemeClasses.list;
|
|
|
|
if (listTheme !== undefined) {
|
|
const listLevelsClassNames = listTheme[`${node.__tag}Depth`] || [];
|
|
const listDepth = $getListDepth(node) - 1;
|
|
const normalizedListDepth = listDepth % listLevelsClassNames.length;
|
|
const listLevelClassName = listLevelsClassNames[normalizedListDepth];
|
|
const listClassName = listTheme[node.__tag];
|
|
let nestedListClassName;
|
|
const nestedListTheme = listTheme.nested;
|
|
const checklistClassName = listTheme.checklist;
|
|
|
|
if (nestedListTheme !== undefined && nestedListTheme.list) {
|
|
nestedListClassName = nestedListTheme.list;
|
|
}
|
|
|
|
if (listClassName !== undefined) {
|
|
classesToAdd.push(listClassName);
|
|
}
|
|
|
|
if (checklistClassName !== undefined && node.__listType === 'check') {
|
|
classesToAdd.push(checklistClassName);
|
|
}
|
|
|
|
if (listLevelClassName !== undefined) {
|
|
classesToAdd.push(...normalizeClassNames(listLevelClassName));
|
|
for (let i = 0; i < listLevelsClassNames.length; i++) {
|
|
if (i !== normalizedListDepth) {
|
|
classesToRemove.push(node.__tag + i);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (nestedListClassName !== undefined) {
|
|
const nestedListItemClasses = normalizeClassNames(nestedListClassName);
|
|
|
|
if (listDepth > 1) {
|
|
classesToAdd.push(...nestedListItemClasses);
|
|
} else {
|
|
classesToRemove.push(...nestedListItemClasses);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (classesToRemove.length > 0) {
|
|
removeClassNamesFromElement(dom, ...classesToRemove);
|
|
}
|
|
|
|
if (classesToAdd.length > 0) {
|
|
addClassNamesToElement(dom, ...classesToAdd);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* This function normalizes the children of a ListNode after the conversion from HTML,
|
|
* ensuring that they are all ListItemNodes and contain either a single nested ListNode
|
|
* or some other inline content.
|
|
*/
|
|
function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
|
|
const normalizedListItems: Array<ListItemNode> = [];
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const node = nodes[i];
|
|
if ($isListItemNode(node)) {
|
|
normalizedListItems.push(node);
|
|
const children = node.getChildren();
|
|
if (children.length > 1) {
|
|
children.forEach((child) => {
|
|
if ($isListNode(child)) {
|
|
normalizedListItems.push($wrapInListItem(child));
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
normalizedListItems.push($wrapInListItem(node));
|
|
}
|
|
}
|
|
return normalizedListItems;
|
|
}
|
|
|
|
function isDomChecklist(domNode: HTMLElement) {
|
|
if (
|
|
domNode.getAttribute('__lexicallisttype') === 'check' ||
|
|
// is github checklist
|
|
domNode.classList.contains('contains-task-list')
|
|
) {
|
|
return true;
|
|
}
|
|
// if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting.
|
|
for (const child of domNode.childNodes) {
|
|
if (isHTMLElement(child) && child.hasAttribute('aria-checked')) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function $convertListNode(domNode: HTMLElement): DOMConversionOutput {
|
|
const nodeName = domNode.nodeName.toLowerCase();
|
|
let node = null;
|
|
if (nodeName === 'ol') {
|
|
// @ts-ignore
|
|
const start = domNode.start;
|
|
node = $createListNode('number', start);
|
|
} else if (nodeName === 'ul') {
|
|
if (isDomChecklist(domNode)) {
|
|
node = $createListNode('check');
|
|
} else {
|
|
node = $createListNode('bullet');
|
|
}
|
|
}
|
|
|
|
return {
|
|
after: $normalizeChildren,
|
|
node,
|
|
};
|
|
}
|
|
|
|
const TAG_TO_LIST_TYPE: Record<string, ListType> = {
|
|
ol: 'number',
|
|
ul: 'bullet',
|
|
};
|
|
|
|
/**
|
|
* Creates a ListNode of listType.
|
|
* @param listType - The type of list to be created. Can be 'number', 'bullet', or 'check'.
|
|
* @param start - Where an ordered list starts its count, start = 1 if left undefined.
|
|
* @returns The new ListNode
|
|
*/
|
|
export function $createListNode(listType: ListType, start = 1): ListNode {
|
|
return $applyNodeReplacement(new ListNode(listType, start));
|
|
}
|
|
|
|
/**
|
|
* Checks to see if the node is a ListNode.
|
|
* @param node - The node to be checked.
|
|
* @returns true if the node is a ListNode, false otherwise.
|
|
*/
|
|
export function $isListNode(
|
|
node: LexicalNode | null | undefined,
|
|
): node is ListNode {
|
|
return node instanceof ListNode;
|
|
}
|