mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-12-15 07:33:36 +08:00
c8ccb2bac7
- Prevented ui shortcuts running in editor - Added form modal closing on submit - Fixed ability to escape lists via enter on empty last item
565 lines
15 KiB
TypeScript
565 lines
15 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 type {ListNode, ListType} from './';
|
|
import type {
|
|
BaseSelection,
|
|
DOMConversionMap,
|
|
DOMConversionOutput,
|
|
DOMExportOutput,
|
|
EditorConfig,
|
|
EditorThemeClasses,
|
|
LexicalNode,
|
|
NodeKey,
|
|
ParagraphNode,
|
|
RangeSelection,
|
|
SerializedElementNode,
|
|
Spread,
|
|
} from 'lexical';
|
|
|
|
import {
|
|
addClassNamesToElement,
|
|
removeClassNamesFromElement,
|
|
} from '@lexical/utils';
|
|
import {
|
|
$applyNodeReplacement,
|
|
$createParagraphNode,
|
|
$isElementNode,
|
|
$isParagraphNode,
|
|
$isRangeSelection,
|
|
ElementNode,
|
|
LexicalEditor,
|
|
} from 'lexical';
|
|
import invariant from 'lexical/shared/invariant';
|
|
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
|
|
|
|
import {$createListNode, $isListNode} from './';
|
|
import {$handleIndent, $handleOutdent, mergeLists} from './formatList';
|
|
import {isNestedListNode} from './utils';
|
|
|
|
export type SerializedListItemNode = Spread<
|
|
{
|
|
checked: boolean | undefined;
|
|
value: number;
|
|
},
|
|
SerializedElementNode
|
|
>;
|
|
|
|
/** @noInheritDoc */
|
|
export class ListItemNode extends ElementNode {
|
|
/** @internal */
|
|
__value: number;
|
|
/** @internal */
|
|
__checked?: boolean;
|
|
|
|
static getType(): string {
|
|
return 'listitem';
|
|
}
|
|
|
|
static clone(node: ListItemNode): ListItemNode {
|
|
return new ListItemNode(node.__value, node.__checked, node.__key);
|
|
}
|
|
|
|
constructor(value?: number, checked?: boolean, key?: NodeKey) {
|
|
super(key);
|
|
this.__value = value === undefined ? 1 : value;
|
|
this.__checked = checked;
|
|
}
|
|
|
|
createDOM(config: EditorConfig): HTMLElement {
|
|
const element = document.createElement('li');
|
|
const parent = this.getParent();
|
|
if ($isListNode(parent) && parent.getListType() === 'check') {
|
|
updateListItemChecked(element, this, null, parent);
|
|
}
|
|
element.value = this.__value;
|
|
$setListItemThemeClassNames(element, config.theme, this);
|
|
return element;
|
|
}
|
|
|
|
updateDOM(
|
|
prevNode: ListItemNode,
|
|
dom: HTMLElement,
|
|
config: EditorConfig,
|
|
): boolean {
|
|
const parent = this.getParent();
|
|
if ($isListNode(parent) && parent.getListType() === 'check') {
|
|
updateListItemChecked(dom, this, prevNode, parent);
|
|
}
|
|
// @ts-expect-error - this is always HTMLListItemElement
|
|
dom.value = this.__value;
|
|
$setListItemThemeClassNames(dom, config.theme, this);
|
|
|
|
return false;
|
|
}
|
|
|
|
static transform(): (node: LexicalNode) => void {
|
|
return (node: LexicalNode) => {
|
|
invariant($isListItemNode(node), 'node is not a ListItemNode');
|
|
if (node.__checked == null) {
|
|
return;
|
|
}
|
|
const parent = node.getParent();
|
|
if ($isListNode(parent)) {
|
|
if (parent.getListType() !== 'check' && node.getChecked() != null) {
|
|
node.setChecked(undefined);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
static importDOM(): DOMConversionMap | null {
|
|
return {
|
|
li: () => ({
|
|
conversion: $convertListItemElement,
|
|
priority: 0,
|
|
}),
|
|
};
|
|
}
|
|
|
|
static importJSON(serializedNode: SerializedListItemNode): ListItemNode {
|
|
const node = $createListItemNode();
|
|
node.setChecked(serializedNode.checked);
|
|
node.setValue(serializedNode.value);
|
|
node.setFormat(serializedNode.format);
|
|
node.setDirection(serializedNode.direction);
|
|
return node;
|
|
}
|
|
|
|
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
|
const element = this.createDOM(editor._config);
|
|
element.style.textAlign = this.getFormatType();
|
|
return {
|
|
element,
|
|
};
|
|
}
|
|
|
|
exportJSON(): SerializedListItemNode {
|
|
return {
|
|
...super.exportJSON(),
|
|
checked: this.getChecked(),
|
|
type: 'listitem',
|
|
value: this.getValue(),
|
|
version: 1,
|
|
};
|
|
}
|
|
|
|
append(...nodes: LexicalNode[]): this {
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const node = nodes[i];
|
|
|
|
if ($isElementNode(node) && this.canMergeWith(node)) {
|
|
const children = node.getChildren();
|
|
this.append(...children);
|
|
node.remove();
|
|
} else {
|
|
super.append(node);
|
|
}
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
replace<N extends LexicalNode>(
|
|
replaceWithNode: N,
|
|
includeChildren?: boolean,
|
|
): N {
|
|
if ($isListItemNode(replaceWithNode)) {
|
|
return super.replace(replaceWithNode);
|
|
}
|
|
this.setIndent(0);
|
|
const list = this.getParentOrThrow();
|
|
if (!$isListNode(list)) {
|
|
return replaceWithNode;
|
|
}
|
|
if (list.__first === this.getKey()) {
|
|
list.insertBefore(replaceWithNode);
|
|
} else if (list.__last === this.getKey()) {
|
|
list.insertAfter(replaceWithNode);
|
|
} else {
|
|
// Split the list
|
|
const newList = $createListNode(list.getListType());
|
|
let nextSibling = this.getNextSibling();
|
|
while (nextSibling) {
|
|
const nodeToAppend = nextSibling;
|
|
nextSibling = nextSibling.getNextSibling();
|
|
newList.append(nodeToAppend);
|
|
}
|
|
list.insertAfter(replaceWithNode);
|
|
replaceWithNode.insertAfter(newList);
|
|
}
|
|
if (includeChildren) {
|
|
invariant(
|
|
$isElementNode(replaceWithNode),
|
|
'includeChildren should only be true for ElementNodes',
|
|
);
|
|
this.getChildren().forEach((child: LexicalNode) => {
|
|
replaceWithNode.append(child);
|
|
});
|
|
}
|
|
this.remove();
|
|
if (list.getChildrenSize() === 0) {
|
|
list.remove();
|
|
}
|
|
return replaceWithNode;
|
|
}
|
|
|
|
insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode {
|
|
const listNode = this.getParentOrThrow();
|
|
|
|
if (!$isListNode(listNode)) {
|
|
invariant(
|
|
false,
|
|
'insertAfter: list node is not parent of list item node',
|
|
);
|
|
}
|
|
|
|
if ($isListItemNode(node)) {
|
|
return super.insertAfter(node, restoreSelection);
|
|
}
|
|
|
|
const siblings = this.getNextSiblings();
|
|
|
|
// Split the lists and insert the node in between them
|
|
listNode.insertAfter(node, restoreSelection);
|
|
|
|
if (siblings.length !== 0) {
|
|
const newListNode = $createListNode(listNode.getListType());
|
|
|
|
siblings.forEach((sibling) => newListNode.append(sibling));
|
|
|
|
node.insertAfter(newListNode, restoreSelection);
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
remove(preserveEmptyParent?: boolean): void {
|
|
const prevSibling = this.getPreviousSibling();
|
|
const nextSibling = this.getNextSibling();
|
|
super.remove(preserveEmptyParent);
|
|
|
|
if (
|
|
prevSibling &&
|
|
nextSibling &&
|
|
isNestedListNode(prevSibling) &&
|
|
isNestedListNode(nextSibling)
|
|
) {
|
|
mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
|
|
nextSibling.remove();
|
|
}
|
|
}
|
|
|
|
insertNewAfter(
|
|
_: RangeSelection,
|
|
restoreSelection = true,
|
|
): ListItemNode | ParagraphNode {
|
|
|
|
if (this.getTextContent().trim() === '' && this.isLastChild()) {
|
|
const list = this.getParentOrThrow<ListNode>();
|
|
if (!$isListItemNode(list.getParent())) {
|
|
const paragraph = $createParagraphNode();
|
|
list.insertAfter(paragraph, restoreSelection);
|
|
this.remove();
|
|
return paragraph;
|
|
}
|
|
}
|
|
|
|
const newElement = $createListItemNode(
|
|
this.__checked == null ? undefined : false,
|
|
);
|
|
|
|
this.insertAfter(newElement, restoreSelection);
|
|
|
|
return newElement;
|
|
}
|
|
|
|
collapseAtStart(selection: RangeSelection): true {
|
|
const paragraph = $createParagraphNode();
|
|
const children = this.getChildren();
|
|
children.forEach((child) => paragraph.append(child));
|
|
const listNode = this.getParentOrThrow();
|
|
const listNodeParent = listNode.getParentOrThrow();
|
|
const isIndented = $isListItemNode(listNodeParent);
|
|
|
|
if (listNode.getChildrenSize() === 1) {
|
|
if (isIndented) {
|
|
// if the list node is nested, we just want to remove it,
|
|
// effectively unindenting it.
|
|
listNode.remove();
|
|
listNodeParent.select();
|
|
} else {
|
|
listNode.insertBefore(paragraph);
|
|
listNode.remove();
|
|
// If we have selection on the list item, we'll need to move it
|
|
// to the paragraph
|
|
const anchor = selection.anchor;
|
|
const focus = selection.focus;
|
|
const key = paragraph.getKey();
|
|
|
|
if (anchor.type === 'element' && anchor.getNode().is(this)) {
|
|
anchor.set(key, anchor.offset, 'element');
|
|
}
|
|
|
|
if (focus.type === 'element' && focus.getNode().is(this)) {
|
|
focus.set(key, focus.offset, 'element');
|
|
}
|
|
}
|
|
} else {
|
|
listNode.insertBefore(paragraph);
|
|
this.remove();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
getValue(): number {
|
|
const self = this.getLatest();
|
|
|
|
return self.__value;
|
|
}
|
|
|
|
setValue(value: number): void {
|
|
const self = this.getWritable();
|
|
self.__value = value;
|
|
}
|
|
|
|
getChecked(): boolean | undefined {
|
|
const self = this.getLatest();
|
|
|
|
let listType: ListType | undefined;
|
|
|
|
const parent = this.getParent();
|
|
if ($isListNode(parent)) {
|
|
listType = parent.getListType();
|
|
}
|
|
|
|
return listType === 'check' ? Boolean(self.__checked) : undefined;
|
|
}
|
|
|
|
setChecked(checked?: boolean): void {
|
|
const self = this.getWritable();
|
|
self.__checked = checked;
|
|
}
|
|
|
|
toggleChecked(): void {
|
|
this.setChecked(!this.__checked);
|
|
}
|
|
|
|
getIndent(): number {
|
|
// If we don't have a parent, we are likely serializing
|
|
const parent = this.getParent();
|
|
if (parent === null) {
|
|
return this.getLatest().__indent;
|
|
}
|
|
// ListItemNode should always have a ListNode for a parent.
|
|
let listNodeParent = parent.getParentOrThrow();
|
|
let indentLevel = 0;
|
|
while ($isListItemNode(listNodeParent)) {
|
|
listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow();
|
|
indentLevel++;
|
|
}
|
|
|
|
return indentLevel;
|
|
}
|
|
|
|
setIndent(indent: number): this {
|
|
invariant(typeof indent === 'number', 'Invalid indent value.');
|
|
indent = Math.floor(indent);
|
|
invariant(indent >= 0, 'Indent value must be non-negative.');
|
|
let currentIndent = this.getIndent();
|
|
while (currentIndent !== indent) {
|
|
if (currentIndent < indent) {
|
|
$handleIndent(this);
|
|
currentIndent++;
|
|
} else {
|
|
$handleOutdent(this);
|
|
currentIndent--;
|
|
}
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
/** @deprecated @internal */
|
|
canInsertAfter(node: LexicalNode): boolean {
|
|
return $isListItemNode(node);
|
|
}
|
|
|
|
/** @deprecated @internal */
|
|
canReplaceWith(replacement: LexicalNode): boolean {
|
|
return $isListItemNode(replacement);
|
|
}
|
|
|
|
canMergeWith(node: LexicalNode): boolean {
|
|
return $isParagraphNode(node) || $isListItemNode(node);
|
|
}
|
|
|
|
extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
|
|
if (!$isRangeSelection(selection)) {
|
|
return false;
|
|
}
|
|
|
|
const anchorNode = selection.anchor.getNode();
|
|
const focusNode = selection.focus.getNode();
|
|
|
|
return (
|
|
this.isParentOf(anchorNode) &&
|
|
this.isParentOf(focusNode) &&
|
|
this.getTextContent().length === selection.getTextContent().length
|
|
);
|
|
}
|
|
|
|
isParentRequired(): true {
|
|
return true;
|
|
}
|
|
|
|
createParentElementNode(): ElementNode {
|
|
return $createListNode('bullet');
|
|
}
|
|
|
|
canMergeWhenEmpty(): true {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function $setListItemThemeClassNames(
|
|
dom: HTMLElement,
|
|
editorThemeClasses: EditorThemeClasses,
|
|
node: ListItemNode,
|
|
): void {
|
|
const classesToAdd = [];
|
|
const classesToRemove = [];
|
|
const listTheme = editorThemeClasses.list;
|
|
const listItemClassName = listTheme ? listTheme.listitem : undefined;
|
|
let nestedListItemClassName;
|
|
|
|
if (listTheme && listTheme.nested) {
|
|
nestedListItemClassName = listTheme.nested.listitem;
|
|
}
|
|
|
|
if (listItemClassName !== undefined) {
|
|
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) {
|
|
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(
|
|
dom: HTMLElement,
|
|
listItemNode: ListItemNode,
|
|
prevListItemNode: ListItemNode | null,
|
|
listNode: ListNode,
|
|
): void {
|
|
// Only add attributes for leaf list items
|
|
if ($isListNode(listItemNode.getFirstChild())) {
|
|
dom.removeAttribute('role');
|
|
dom.removeAttribute('tabIndex');
|
|
dom.removeAttribute('aria-checked');
|
|
} else {
|
|
dom.setAttribute('role', 'checkbox');
|
|
dom.setAttribute('tabIndex', '-1');
|
|
|
|
if (
|
|
!prevListItemNode ||
|
|
listItemNode.__checked !== prevListItemNode.__checked
|
|
) {
|
|
dom.setAttribute(
|
|
'aria-checked',
|
|
listItemNode.getChecked() ? 'true' : 'false',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
|
|
const isGitHubCheckList = domNode.classList.contains('task-list-item');
|
|
if (isGitHubCheckList) {
|
|
for (const child of domNode.children) {
|
|
if (child.tagName === 'INPUT') {
|
|
return $convertCheckboxInput(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
const ariaCheckedAttr = domNode.getAttribute('aria-checked');
|
|
const checked =
|
|
ariaCheckedAttr === 'true'
|
|
? true
|
|
: ariaCheckedAttr === 'false'
|
|
? false
|
|
: undefined;
|
|
return {node: $createListItemNode(checked)};
|
|
}
|
|
|
|
function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
|
|
const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
|
|
if (!isCheckboxInput) {
|
|
return {node: null};
|
|
}
|
|
const checked = domNode.hasAttribute('checked');
|
|
return {node: $createListItemNode(checked)};
|
|
}
|
|
|
|
/**
|
|
* Creates a new List Item node, passing true/false will convert it to a checkbox input.
|
|
* @param checked - Is the List Item a checkbox and, if so, is it checked? undefined/null: not a checkbox, true/false is a checkbox and checked/unchecked, respectively.
|
|
* @returns The new List Item.
|
|
*/
|
|
export function $createListItemNode(checked?: boolean): ListItemNode {
|
|
return $applyNodeReplacement(new ListItemNode(undefined, checked));
|
|
}
|
|
|
|
/**
|
|
* Checks to see if the node is a ListItemNode.
|
|
* @param node - The node to be checked.
|
|
* @returns true if the node is a ListItemNode, false otherwise.
|
|
*/
|
|
export function $isListItemNode(
|
|
node: LexicalNode | null | undefined,
|
|
): node is ListItemNode {
|
|
return node instanceof ListItemNode;
|
|
}
|