mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-03-14 14:35:14 +08:00
Lexical: Adjusted handling of child/sibling list items on nesting
Sibling/child items will now remain at the same visual level during nesting/un-nested, so only the selected item level is visually altered. Also added new model-based editor content matching system for tests.
This commit is contained in:
parent
fca8f928a3
commit
f4005a139b
@ -30,18 +30,14 @@ import {
|
|||||||
TextNode,
|
TextNode,
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
|
|
||||||
import {
|
import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor';
|
||||||
CreateEditorArgs,
|
|
||||||
HTMLConfig,
|
|
||||||
LexicalNodeReplacement,
|
|
||||||
} from '../../LexicalEditor';
|
|
||||||
import {resetRandomKey} from '../../LexicalUtils';
|
import {resetRandomKey} from '../../LexicalUtils';
|
||||||
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";
|
||||||
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
|
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
|
||||||
import {EditorUiContext} from "../../../../ui/framework/core";
|
import {EditorUiContext} from "../../../../ui/framework/core";
|
||||||
import {EditorUIManager} from "../../../../ui/framework/manager";
|
import {EditorUIManager} from "../../../../ui/framework/manager";
|
||||||
import {registerRichText} from "@lexical/rich-text";
|
import {turtle} from "@codemirror/legacy-modes/mode/turtle";
|
||||||
|
|
||||||
|
|
||||||
type TestEnv = {
|
type TestEnv = {
|
||||||
@ -764,6 +760,41 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
|
|||||||
expect(formatHtml(expected)).toBe(formatHtml(actual));
|
expect(formatHtml(expected)).toBe(formatHtml(actual));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type nodeTextShape = {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type nodeShape = {
|
||||||
|
type: string;
|
||||||
|
children?: (nodeShape|nodeTextShape)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape {
|
||||||
|
// @ts-ignore
|
||||||
|
const children: SerializedLexicalNode[] = (node.children || []);
|
||||||
|
|
||||||
|
const shape: nodeShape = {
|
||||||
|
type: node.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shape.type === 'text') {
|
||||||
|
// @ts-ignore
|
||||||
|
return {text: node.text}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.length > 0) {
|
||||||
|
shape.children = children.map(c => getNodeShape(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) {
|
||||||
|
const json = editor.getEditorState().toJSON();
|
||||||
|
const shape = getNodeShape(json.root) as nodeShape;
|
||||||
|
expect(shape.children).toMatchObject(expected);
|
||||||
|
}
|
||||||
|
|
||||||
function formatHtml(s: string): string {
|
function formatHtml(s: string): string {
|
||||||
return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
|
return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
|
||||||
}
|
}
|
||||||
|
124
resources/js/wysiwyg/utils/__tests__/lists.test.ts
Normal file
124
resources/js/wysiwyg/utils/__tests__/lists.test.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
createTestContext, destroyFromContext,
|
||||||
|
dispatchKeydownEventForNode, expectNodeShapeToMatch,
|
||||||
|
} from "lexical/__tests__/utils";
|
||||||
|
import {
|
||||||
|
$createParagraphNode, $getRoot, LexicalEditor, LexicalNode,
|
||||||
|
ParagraphNode,
|
||||||
|
} from "lexical";
|
||||||
|
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
|
||||||
|
import {EditorUiContext} from "../../ui/framework/core";
|
||||||
|
import {$htmlToBlockNodes} from "../nodes";
|
||||||
|
import {ListItemNode, ListNode} from "@lexical/list";
|
||||||
|
import {$nestListItem, $unnestListItem} from "../lists";
|
||||||
|
|
||||||
|
describe('List Utils', () => {
|
||||||
|
|
||||||
|
let context!: EditorUiContext;
|
||||||
|
let editor!: LexicalEditor;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
context = createTestContext();
|
||||||
|
editor = context.editor;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
destroyFromContext(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('$nestListItem', () => {
|
||||||
|
test('nesting handles child items to leave at the same level', () => {
|
||||||
|
const input = `<ul>
|
||||||
|
<li>Inner A</li>
|
||||||
|
<li>Inner B <ul>
|
||||||
|
<li>Inner C</li>
|
||||||
|
</ul></li>
|
||||||
|
</ul>`;
|
||||||
|
let list!: ListNode;
|
||||||
|
|
||||||
|
editor.updateAndCommit(() => {
|
||||||
|
$getRoot().append(...$htmlToBlockNodes(editor, input));
|
||||||
|
list = $getRoot().getFirstChild() as ListNode;
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.updateAndCommit(() => {
|
||||||
|
$nestListItem(list.getChildren()[1] as ListItemNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
expectNodeShapeToMatch(editor, [
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'listitem',
|
||||||
|
children: [
|
||||||
|
{text: 'Inner A'},
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
children: [
|
||||||
|
{type: 'listitem', children: [{text: 'Inner B'}]},
|
||||||
|
{type: 'listitem', children: [{text: 'Inner C'}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('$unnestListItem', () => {
|
||||||
|
test('middle in nested list converts to new parent item at same place', () => {
|
||||||
|
const input = `<ul>
|
||||||
|
<li>Nested list:<ul>
|
||||||
|
<li>Inner A</li>
|
||||||
|
<li>Inner B</li>
|
||||||
|
<li>Inner C</li>
|
||||||
|
</ul></li>
|
||||||
|
</ul>`;
|
||||||
|
let innerList!: ListNode;
|
||||||
|
|
||||||
|
editor.updateAndCommit(() => {
|
||||||
|
$getRoot().append(...$htmlToBlockNodes(editor, input));
|
||||||
|
innerList = (($getRoot().getFirstChild() as ListNode).getFirstChild() as ListItemNode).getLastChild() as ListNode;
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.updateAndCommit(() => {
|
||||||
|
$unnestListItem(innerList.getChildren()[1] as ListItemNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
expectNodeShapeToMatch(editor, [
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'listitem',
|
||||||
|
children: [
|
||||||
|
{text: 'Nested list:'},
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
children: [
|
||||||
|
{type: 'listitem', children: [{text: 'Inner A'}]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'listitem',
|
||||||
|
children: [
|
||||||
|
{text: 'Inner B'},
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
children: [
|
||||||
|
{type: 'listitem', children: [{text: 'Inner C'}]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -10,6 +10,9 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null;
|
||||||
|
const nodeChildItems = nodeChildList?.getChildren() || [];
|
||||||
|
|
||||||
const listItems = list.getChildren() as ListItemNode[];
|
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;
|
||||||
@ -27,6 +30,13 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
|
|||||||
node.remove();
|
node.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nodeChildList) {
|
||||||
|
for (const child of nodeChildItems) {
|
||||||
|
newListItem.insertAfter(child);
|
||||||
|
}
|
||||||
|
nodeChildList.remove();
|
||||||
|
}
|
||||||
|
|
||||||
return newListItem;
|
return newListItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,6 +48,8 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const laterSiblings = node.getNextSiblings();
|
||||||
|
|
||||||
parentListItem.insertAfter(node);
|
parentListItem.insertAfter(node);
|
||||||
if (list.getChildren().length === 0) {
|
if (list.getChildren().length === 0) {
|
||||||
list.remove();
|
list.remove();
|
||||||
@ -47,6 +59,16 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
|
|||||||
parentListItem.remove();
|
parentListItem.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (laterSiblings.length > 0) {
|
||||||
|
const childList = $createListNode(list.getListType());
|
||||||
|
childList.append(...laterSiblings);
|
||||||
|
node.append(childList);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.getChildrenSize() === 0) {
|
||||||
|
list.remove();
|
||||||
|
}
|
||||||
|
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user