mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Lexical: Custom list nesting support
Added list nesting support to allow li > ul style nesting which lexical didn't do by default. Adds tab handling for inset/outset controls. Will be a range of edge-case bugs to squash during testing.
This commit is contained in:
parent
5083188ed8
commit
662110c269
@ -3,6 +3,7 @@ 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,
|
||||
@ -38,6 +39,10 @@ export class CustomListItemNode extends ListItemNode {
|
||||
|
||||
element.value = this.__value;
|
||||
|
||||
if ($hasNestedListWithoutLabel(this)) {
|
||||
element.style.listStyle = 'none';
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
@ -86,8 +91,28 @@ export class CustomListItemNode extends ListItemNode {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
@ -5,7 +5,8 @@ import {
|
||||
Spread
|
||||
} from "lexical";
|
||||
import {EditorConfig} from "lexical/LexicalEditor";
|
||||
import {ListNode, ListType, SerializedListNode} from "@lexical/list";
|
||||
import {$isListItemNode, ListItemNode, ListNode, ListType, SerializedListNode} from "@lexical/list";
|
||||
import {$createCustomListItemNode} from "./custom-list-item";
|
||||
|
||||
|
||||
export type SerializedCustomListNode = Spread<{
|
||||
@ -30,7 +31,7 @@ export class CustomListNode extends ListNode {
|
||||
}
|
||||
|
||||
static clone(node: CustomListNode) {
|
||||
const newNode = new CustomListNode(node.__listType, 0, node.__key);
|
||||
const newNode = new CustomListNode(node.__listType, node.__start, node.__key);
|
||||
newNode.__id = node.__id;
|
||||
return newNode;
|
||||
}
|
||||
@ -67,6 +68,11 @@ export class CustomListNode extends ListNode {
|
||||
if (element.id && baseResult?.node) {
|
||||
(baseResult.node as CustomListNode).setId(element.id);
|
||||
}
|
||||
|
||||
if (baseResult) {
|
||||
baseResult.after = $normalizeChildren;
|
||||
}
|
||||
|
||||
return baseResult;
|
||||
};
|
||||
|
||||
@ -83,8 +89,34 @@ export class CustomListNode extends ListNode {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 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, 0);
|
||||
return new CustomListNode(type, 1);
|
||||
}
|
||||
|
||||
export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode {
|
||||
|
@ -1,10 +1,11 @@
|
||||
import {EditorUiContext} from "../ui/framework/core";
|
||||
import {
|
||||
$getSelection,
|
||||
$isDecoratorNode,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
KEY_DELETE_COMMAND,
|
||||
KEY_ENTER_COMMAND,
|
||||
KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
|
||||
LexicalEditor,
|
||||
LexicalNode
|
||||
} from "lexical";
|
||||
@ -13,6 +14,8 @@ import {$isMediaNode} from "../nodes/media";
|
||||
import {getLastSelection} from "../utils/selection";
|
||||
import {$getNearestNodeBlockParent} from "../utils/nodes";
|
||||
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
|
||||
import {$isCustomListItemNode} from "../nodes/custom-list-item";
|
||||
import {$setInsetForSelection} from "../utils/lists";
|
||||
|
||||
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
|
||||
if (nodes.length === 1) {
|
||||
@ -55,6 +58,17 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null) {
|
||||
const change = event?.shiftKey ? -40 : 40;
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
const nodes = selection?.getNodes() || [];
|
||||
if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) {
|
||||
$setInsetForSelection(editor, change);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerKeyboardHandling(context: EditorUiContext): () => void {
|
||||
const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
|
||||
deleteSingleSelectedNode(context.editor);
|
||||
@ -70,9 +84,14 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
|
||||
return insertAfterSingleSelectedNode(context.editor, event);
|
||||
}, COMMAND_PRIORITY_LOW);
|
||||
|
||||
const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
|
||||
return handleInsetOnTab(context.editor, event);
|
||||
}, COMMAND_PRIORITY_LOW);
|
||||
|
||||
return () => {
|
||||
unregisterBackspace();
|
||||
unregisterDelete();
|
||||
unregisterEnter();
|
||||
unregisterTab();
|
||||
};
|
||||
}
|
@ -6,8 +6,8 @@
|
||||
|
||||
## Main Todo
|
||||
|
||||
- Align list nesting with old editor
|
||||
- Mac: Shortcut support via command.
|
||||
- RTL/LTR support
|
||||
|
||||
## Secondary Todo
|
||||
|
||||
@ -18,4 +18,5 @@
|
||||
|
||||
## Bugs
|
||||
|
||||
//
|
||||
- Focus/click area reduced to content area, single line on initial access
|
||||
- List selection can get lost on nesting/unnesting
|
@ -13,12 +13,14 @@ import indentIncreaseIcon from "@icons/editor/indent-increase.svg";
|
||||
import indentDecreaseIcon from "@icons/editor/indent-decrease.svg";
|
||||
import {
|
||||
$getBlockElementNodesInSelection,
|
||||
$selectionContainsNodeType,
|
||||
$selectionContainsNodeType, $selectNodes, $selectSingleNode,
|
||||
$toggleSelection,
|
||||
getLastSelection
|
||||
} from "../../../utils/selection";
|
||||
import {toggleSelectionAsList} from "../../../utils/formats";
|
||||
import {nodeHasInset} from "../../../utils/nodes";
|
||||
import {$isCustomListItemNode, CustomListItemNode} from "../../../nodes/custom-list-item";
|
||||
import {$nestListItem, $setInsetForSelection, $unnestListItem} from "../../../utils/lists";
|
||||
|
||||
|
||||
function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition {
|
||||
@ -40,28 +42,12 @@ export const bulletList: EditorButtonDefinition = buildListButton('Bullet list',
|
||||
export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon);
|
||||
export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon);
|
||||
|
||||
|
||||
function setInsetForSelection(editor: LexicalEditor, change: number): void {
|
||||
const selection = getLastSelection(editor);
|
||||
|
||||
const elements = $getBlockElementNodesInSelection(selection);
|
||||
for (const node of elements) {
|
||||
if (nodeHasInset(node)) {
|
||||
const currentInset = node.getInset();
|
||||
const newInset = Math.min(Math.max(currentInset + change, 0), 500);
|
||||
node.setInset(newInset)
|
||||
}
|
||||
}
|
||||
|
||||
$toggleSelection(editor);
|
||||
}
|
||||
|
||||
export const indentIncrease: EditorButtonDefinition = {
|
||||
label: 'Increase indent',
|
||||
icon: indentIncreaseIcon,
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.update(() => {
|
||||
setInsetForSelection(context.editor, 40);
|
||||
$setInsetForSelection(context.editor, 40);
|
||||
});
|
||||
},
|
||||
isActive() {
|
||||
@ -74,7 +60,7 @@ export const indentDecrease: EditorButtonDefinition = {
|
||||
icon: indentDecreaseIcon,
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.update(() => {
|
||||
setInsetForSelection(context.editor, -40);
|
||||
$setInsetForSelection(context.editor, -40);
|
||||
});
|
||||
},
|
||||
isActive() {
|
||||
|
123
resources/js/wysiwyg/utils/lists.ts
Normal file
123
resources/js/wysiwyg/utils/lists.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import {$createCustomListItemNode, $isCustomListItemNode, CustomListItemNode} from "../nodes/custom-list-item";
|
||||
import {$createCustomListNode, $isCustomListNode} from "../nodes/custom-list";
|
||||
import {BaseSelection, LexicalEditor} from "lexical";
|
||||
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection, getLastSelection} from "./selection";
|
||||
import {nodeHasInset} from "./nodes";
|
||||
|
||||
|
||||
export function $nestListItem(node: CustomListItemNode) {
|
||||
const list = node.getParent();
|
||||
if (!$isCustomListNode(list)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listItems = list.getChildren() as CustomListItemNode[];
|
||||
const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
|
||||
const isFirst = nodeIndex === 0;
|
||||
|
||||
const newListItem = $createCustomListItemNode();
|
||||
const newList = $createCustomListNode(list.getListType());
|
||||
newList.append(newListItem);
|
||||
newListItem.append(...node.getChildren());
|
||||
|
||||
if (isFirst) {
|
||||
node.append(newList);
|
||||
} else {
|
||||
const prevListItem = listItems[nodeIndex - 1];
|
||||
prevListItem.append(newList);
|
||||
node.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function $unnestListItem(node: CustomListItemNode) {
|
||||
const list = node.getParent();
|
||||
const parentListItem = list?.getParent();
|
||||
const outerList = parentListItem?.getParent();
|
||||
if (!$isCustomListNode(list) || !$isCustomListNode(outerList) || !$isCustomListItemNode(parentListItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
parentListItem.insertAfter(node);
|
||||
if (list.getChildren().length === 0) {
|
||||
list.remove();
|
||||
}
|
||||
|
||||
if (parentListItem.getChildren().length === 0) {
|
||||
parentListItem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function getListItemsForSelection(selection: BaseSelection|null): (CustomListItemNode|null)[] {
|
||||
const nodes = selection?.getNodes() || [];
|
||||
const listItemNodes = [];
|
||||
|
||||
outer: for (const node of nodes) {
|
||||
if ($isCustomListItemNode(node)) {
|
||||
listItemNodes.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parents = node.getParents();
|
||||
for (const parent of parents) {
|
||||
if ($isCustomListItemNode(parent)) {
|
||||
listItemNodes.push(parent);
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
|
||||
listItemNodes.push(null);
|
||||
}
|
||||
|
||||
return listItemNodes;
|
||||
}
|
||||
|
||||
function $reduceDedupeListItems(listItems: (CustomListItemNode|null)[]): CustomListItemNode[] {
|
||||
const listItemMap: Record<string, CustomListItemNode> = {};
|
||||
|
||||
for (const item of listItems) {
|
||||
if (item === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = item.getKey();
|
||||
if (typeof listItemMap[key] === 'undefined') {
|
||||
listItemMap[key] = item;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(listItemMap);
|
||||
}
|
||||
|
||||
export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
|
||||
const selection = getLastSelection(editor);
|
||||
|
||||
const listItemsInSelection = getListItemsForSelection(selection);
|
||||
const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
|
||||
|
||||
if (isListSelection) {
|
||||
const listItems = $reduceDedupeListItems(listItemsInSelection);
|
||||
if (change > 0) {
|
||||
for (const listItem of listItems) {
|
||||
$nestListItem(listItem);
|
||||
}
|
||||
} else if (change < 0) {
|
||||
for (const listItem of [...listItems].reverse()) {
|
||||
$unnestListItem(listItem);
|
||||
}
|
||||
}
|
||||
|
||||
$selectNodes(listItems);
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = $getBlockElementNodesInSelection(selection);
|
||||
for (const node of elements) {
|
||||
if (nodeHasInset(node)) {
|
||||
const currentInset = node.getInset();
|
||||
const newInset = Math.min(Math.max(currentInset + change, 0), 500);
|
||||
node.setInset(newInset)
|
||||
}
|
||||
}
|
||||
|
||||
$toggleSelection(editor);
|
||||
}
|
@ -10,7 +10,7 @@ import {
|
||||
ElementFormatType,
|
||||
ElementNode, LexicalEditor,
|
||||
LexicalNode,
|
||||
TextFormatType
|
||||
TextFormatType, TextNode
|
||||
} from "lexical";
|
||||
import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
|
||||
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes";
|
||||
@ -106,6 +106,57 @@ export function $selectSingleNode(node: LexicalNode) {
|
||||
$setSelection(nodeSelection);
|
||||
}
|
||||
|
||||
function getFirstTextNodeInNodes(nodes: LexicalNode[]): TextNode|null {
|
||||
for (const node of nodes) {
|
||||
if ($isTextNode(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if ($isElementNode(node)) {
|
||||
const children = node.getChildren();
|
||||
const textNode = getFirstTextNodeInNodes(children);
|
||||
if (textNode !== null) {
|
||||
return textNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLastTextNodeInNodes(nodes: LexicalNode[]): TextNode|null {
|
||||
const revNodes = [...nodes].reverse();
|
||||
for (const node of revNodes) {
|
||||
if ($isTextNode(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if ($isElementNode(node)) {
|
||||
const children = [...node.getChildren()].reverse();
|
||||
const textNode = getLastTextNodeInNodes(children);
|
||||
if (textNode !== null) {
|
||||
return textNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function $selectNodes(nodes: LexicalNode[]) {
|
||||
if (nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = $createRangeSelection();
|
||||
const firstText = getFirstTextNodeInNodes(nodes);
|
||||
const lastText = getLastTextNodeInNodes(nodes);
|
||||
if (firstText && lastText) {
|
||||
selection.setTextNodeRange(firstText, 0, lastText, lastText.getTextContentSize() || 0)
|
||||
$setSelection(selection);
|
||||
}
|
||||
}
|
||||
|
||||
export function $toggleSelection(editor: LexicalEditor) {
|
||||
const lastSelection = getLastSelection(editor);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user