mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-05-04 08:10:26 +00:00
Merge pull request #5558 from BookStackApp/lexical_round3
Lexical Fixes: Round 3
This commit is contained in:
commit
78a0a2f519
11 changed files with 350 additions and 201 deletions
resources
js
components
wysiwyg
lexical
core/__tests__/utils
list
rich-text
services
utils
sass
|
@ -112,7 +112,7 @@ export class PageEditor extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
savePage() {
|
savePage() {
|
||||||
this.container.closest('form').submit();
|
this.container.closest('form').requestSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveDraft() {
|
async saveDraft() {
|
||||||
|
|
|
@ -25,6 +25,7 @@ export class WysiwygEditor extends Component {
|
||||||
textDirection: this.$opts.textDirection,
|
textDirection: this.$opts.textDirection,
|
||||||
translations,
|
translations,
|
||||||
});
|
});
|
||||||
|
window.wysiwyg = this.editor;
|
||||||
});
|
});
|
||||||
|
|
||||||
let handlingFormSubmit = false;
|
let handlingFormSubmit = false;
|
||||||
|
@ -38,7 +39,9 @@ export class WysiwygEditor extends Component {
|
||||||
handlingFormSubmit = true;
|
handlingFormSubmit = true;
|
||||||
this.editor.getContentAsHtml().then(html => {
|
this.editor.getContentAsHtml().then(html => {
|
||||||
this.input.value = html;
|
this.input.value = html;
|
||||||
this.input.form.submit();
|
setTimeout(() => {
|
||||||
|
this.input.form.requestSubmit();
|
||||||
|
}, 5);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
handlingFormSubmit = false;
|
handlingFormSubmit = false;
|
||||||
|
|
|
@ -37,6 +37,7 @@ 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 {ImageNode} from "@lexical/rich-text/LexicalImageNode";
|
||||||
|
|
||||||
type TestEnv = {
|
type TestEnv = {
|
||||||
readonly container: HTMLDivElement;
|
readonly container: HTMLDivElement;
|
||||||
|
@ -484,6 +485,9 @@ export function createTestContext(): EditorUiContext {
|
||||||
const editor = createTestEditor({
|
const editor = createTestEditor({
|
||||||
namespace: 'testing',
|
namespace: 'testing',
|
||||||
theme: {},
|
theme: {},
|
||||||
|
nodes: [
|
||||||
|
ImageNode,
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.setRootElement(editorDOM);
|
editor.setRootElement(editorDOM);
|
||||||
|
|
|
@ -332,7 +332,19 @@ function isDomChecklist(domNode: HTMLElement) {
|
||||||
}
|
}
|
||||||
// if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting.
|
// if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting.
|
||||||
for (const child of domNode.childNodes) {
|
for (const child of domNode.childNodes) {
|
||||||
if (isHTMLElement(child) && child.hasAttribute('aria-checked')) {
|
if (!isHTMLElement(child)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.hasAttribute('aria-checked')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.classList.contains('task-list-item')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.firstElementChild && child.firstElementChild.matches('input[type="checkbox"]')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
import {ParagraphNode, TextNode} from 'lexical';
|
import {ParagraphNode, TextNode} from 'lexical';
|
||||||
import {initializeUnitTest} from 'lexical/__tests__/utils';
|
import {createTestContext} from 'lexical/__tests__/utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
$createListItemNode,
|
$createListItemNode,
|
||||||
|
@ -16,6 +16,7 @@ import {
|
||||||
ListItemNode,
|
ListItemNode,
|
||||||
ListNode,
|
ListNode,
|
||||||
} from '../..';
|
} from '../..';
|
||||||
|
import {$htmlToBlockNodes} from "../../../../utils/nodes";
|
||||||
|
|
||||||
const editorConfig = Object.freeze({
|
const editorConfig = Object.freeze({
|
||||||
namespace: '',
|
namespace: '',
|
||||||
|
@ -46,123 +47,122 @@ const editorConfig = Object.freeze({
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('LexicalListNode tests', () => {
|
describe('LexicalListNode tests', () => {
|
||||||
initializeUnitTest((testEnv) => {
|
test('ListNode.constructor', async () => {
|
||||||
test('ListNode.constructor', async () => {
|
const {editor} = createTestContext();
|
||||||
const {editor} = testEnv;
|
|
||||||
|
|
||||||
await editor.update(() => {
|
await editor.update(() => {
|
||||||
const listNode = $createListNode('bullet', 1);
|
const listNode = $createListNode('bullet', 1);
|
||||||
expect(listNode.getType()).toBe('list');
|
expect(listNode.getType()).toBe('list');
|
||||||
expect(listNode.getTag()).toBe('ul');
|
expect(listNode.getTag()).toBe('ul');
|
||||||
expect(listNode.getTextContent()).toBe('');
|
expect(listNode.getTextContent()).toBe('');
|
||||||
});
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
expect(() => $createListNode()).toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ListNode.getTag()', async () => {
|
// @ts-expect-error
|
||||||
const {editor} = testEnv;
|
expect(() => $createListNode()).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
await editor.update(() => {
|
test('ListNode.getTag()', async () => {
|
||||||
const ulListNode = $createListNode('bullet', 1);
|
const {editor} = createTestContext();
|
||||||
expect(ulListNode.getTag()).toBe('ul');
|
|
||||||
const olListNode = $createListNode('number', 1);
|
await editor.update(() => {
|
||||||
expect(olListNode.getTag()).toBe('ol');
|
const ulListNode = $createListNode('bullet', 1);
|
||||||
const checkListNode = $createListNode('check', 1);
|
expect(ulListNode.getTag()).toBe('ul');
|
||||||
expect(checkListNode.getTag()).toBe('ul');
|
const olListNode = $createListNode('number', 1);
|
||||||
});
|
expect(olListNode.getTag()).toBe('ol');
|
||||||
|
const checkListNode = $createListNode('check', 1);
|
||||||
|
expect(checkListNode.getTag()).toBe('ul');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('ListNode.createDOM()', async () => {
|
test('ListNode.createDOM()', async () => {
|
||||||
const {editor} = testEnv;
|
const {editor} = createTestContext();
|
||||||
|
|
||||||
await editor.update(() => {
|
await editor.update(() => {
|
||||||
const listNode = $createListNode('bullet', 1);
|
const listNode = $createListNode('bullet', 1);
|
||||||
expect(listNode.createDOM(editorConfig).outerHTML).toBe(
|
expect(listNode.createDOM(editorConfig).outerHTML).toBe(
|
||||||
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
listNode.createDOM({
|
listNode.createDOM({
|
||||||
namespace: '',
|
namespace: '',
|
||||||
theme: {
|
theme: {
|
||||||
list: {},
|
list: {},
|
||||||
},
|
},
|
||||||
}).outerHTML,
|
}).outerHTML,
|
||||||
).toBe('<ul></ul>');
|
).toBe('<ul></ul>');
|
||||||
expect(
|
expect(
|
||||||
listNode.createDOM({
|
listNode.createDOM({
|
||||||
namespace: '',
|
namespace: '',
|
||||||
theme: {},
|
theme: {},
|
||||||
}).outerHTML,
|
}).outerHTML,
|
||||||
).toBe('<ul></ul>');
|
).toBe('<ul></ul>');
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => {
|
test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => {
|
||||||
const {editor} = testEnv;
|
const {editor} = createTestContext();
|
||||||
|
|
||||||
await editor.update(() => {
|
await editor.update(() => {
|
||||||
const listNode1 = $createListNode('bullet');
|
const listNode1 = $createListNode('bullet');
|
||||||
const listNode2 = $createListNode('bullet');
|
const listNode2 = $createListNode('bullet');
|
||||||
const listNode3 = $createListNode('bullet');
|
const listNode3 = $createListNode('bullet');
|
||||||
const listNode4 = $createListNode('bullet');
|
const listNode4 = $createListNode('bullet');
|
||||||
const listNode5 = $createListNode('bullet');
|
const listNode5 = $createListNode('bullet');
|
||||||
const listNode6 = $createListNode('bullet');
|
const listNode6 = $createListNode('bullet');
|
||||||
const listNode7 = $createListNode('bullet');
|
const listNode7 = $createListNode('bullet');
|
||||||
|
|
||||||
const listItem1 = $createListItemNode();
|
const listItem1 = $createListItemNode();
|
||||||
const listItem2 = $createListItemNode();
|
const listItem2 = $createListItemNode();
|
||||||
const listItem3 = $createListItemNode();
|
const listItem3 = $createListItemNode();
|
||||||
const listItem4 = $createListItemNode();
|
const listItem4 = $createListItemNode();
|
||||||
|
|
||||||
listNode1.append(listItem1);
|
listNode1.append(listItem1);
|
||||||
listItem1.append(listNode2);
|
listItem1.append(listNode2);
|
||||||
listNode2.append(listItem2);
|
listNode2.append(listItem2);
|
||||||
listItem2.append(listNode3);
|
listItem2.append(listNode3);
|
||||||
listNode3.append(listItem3);
|
listNode3.append(listItem3);
|
||||||
listItem3.append(listNode4);
|
listItem3.append(listNode4);
|
||||||
listNode4.append(listItem4);
|
listNode4.append(listItem4);
|
||||||
listNode4.append(listNode5);
|
listNode4.append(listNode5);
|
||||||
listNode5.append(listNode6);
|
listNode5.append(listNode6);
|
||||||
listNode6.append(listNode7);
|
listNode6.append(listNode7);
|
||||||
|
|
||||||
expect(listNode1.createDOM(editorConfig).outerHTML).toBe(
|
expect(listNode1.createDOM(editorConfig).outerHTML).toBe(
|
||||||
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
listNode1.createDOM({
|
listNode1.createDOM({
|
||||||
namespace: '',
|
namespace: '',
|
||||||
theme: {
|
theme: {
|
||||||
list: {},
|
list: {},
|
||||||
},
|
},
|
||||||
}).outerHTML,
|
}).outerHTML,
|
||||||
).toBe('<ul></ul>');
|
).toBe('<ul></ul>');
|
||||||
expect(
|
expect(
|
||||||
listNode1.createDOM({
|
listNode1.createDOM({
|
||||||
namespace: '',
|
namespace: '',
|
||||||
theme: {},
|
theme: {},
|
||||||
}).outerHTML,
|
}).outerHTML,
|
||||||
).toBe('<ul></ul>');
|
).toBe('<ul></ul>');
|
||||||
expect(listNode2.createDOM(editorConfig).outerHTML).toBe(
|
expect(listNode2.createDOM(editorConfig).outerHTML).toBe(
|
||||||
'<ul class="my-ul-list-class my-ul-list-class-2"></ul>',
|
'<ul class="my-ul-list-class my-ul-list-class-2"></ul>',
|
||||||
);
|
);
|
||||||
expect(listNode3.createDOM(editorConfig).outerHTML).toBe(
|
expect(listNode3.createDOM(editorConfig).outerHTML).toBe(
|
||||||
'<ul class="my-ul-list-class my-ul-list-class-3"></ul>',
|
'<ul class="my-ul-list-class my-ul-list-class-3"></ul>',
|
||||||
);
|
);
|
||||||
expect(listNode4.createDOM(editorConfig).outerHTML).toBe(
|
expect(listNode4.createDOM(editorConfig).outerHTML).toBe(
|
||||||
'<ul class="my-ul-list-class my-ul-list-class-4"></ul>',
|
'<ul class="my-ul-list-class my-ul-list-class-4"></ul>',
|
||||||
);
|
);
|
||||||
expect(listNode5.createDOM(editorConfig).outerHTML).toBe(
|
expect(listNode5.createDOM(editorConfig).outerHTML).toBe(
|
||||||
'<ul class="my-ul-list-class my-ul-list-class-5"></ul>',
|
'<ul class="my-ul-list-class my-ul-list-class-5"></ul>',
|
||||||
);
|
);
|
||||||
expect(listNode6.createDOM(editorConfig).outerHTML).toBe(
|
expect(listNode6.createDOM(editorConfig).outerHTML).toBe(
|
||||||
'<ul class="my-ul-list-class my-ul-list-class-6"></ul>',
|
'<ul class="my-ul-list-class my-ul-list-class-6"></ul>',
|
||||||
);
|
);
|
||||||
expect(listNode7.createDOM(editorConfig).outerHTML).toBe(
|
expect(listNode7.createDOM(editorConfig).outerHTML).toBe(
|
||||||
'<ul class="my-ul-list-class my-ul-list-class-7"></ul>',
|
'<ul class="my-ul-list-class my-ul-list-class-7"></ul>',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
listNode5.createDOM({
|
listNode5.createDOM({
|
||||||
namespace: '',
|
namespace: '',
|
||||||
theme: {
|
theme: {
|
||||||
|
@ -176,123 +176,135 @@ describe('LexicalListNode tests', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}).outerHTML,
|
}).outerHTML,
|
||||||
).toBe('<ul class="my-ul-list-class my-ul-list-class-2"></ul>');
|
).toBe('<ul class="my-ul-list-class my-ul-list-class-2"></ul>');
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('ListNode.updateDOM()', async () => {
|
test('ListNode.updateDOM()', async () => {
|
||||||
const {editor} = testEnv;
|
const {editor} = createTestContext();
|
||||||
|
|
||||||
await editor.update(() => {
|
await editor.update(() => {
|
||||||
const listNode = $createListNode('bullet', 1);
|
const listNode = $createListNode('bullet', 1);
|
||||||
const domElement = listNode.createDOM(editorConfig);
|
const domElement = listNode.createDOM(editorConfig);
|
||||||
|
|
||||||
expect(domElement.outerHTML).toBe(
|
expect(domElement.outerHTML).toBe(
|
||||||
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
||||||
);
|
);
|
||||||
|
|
||||||
const newListNode = $createListNode('number', 1);
|
const newListNode = $createListNode('number', 1);
|
||||||
const result = newListNode.updateDOM(
|
const result = newListNode.updateDOM(
|
||||||
listNode,
|
listNode,
|
||||||
domElement,
|
domElement,
|
||||||
editorConfig,
|
editorConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(domElement.outerHTML).toBe(
|
expect(domElement.outerHTML).toBe(
|
||||||
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
'<ul class="my-ul-list-class my-ul-list-class-1"></ul>',
|
||||||
);
|
);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ListNode.append() should properly transform a ListItemNode', async () => {
|
|
||||||
const {editor} = testEnv;
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
const listNode = new ListNode('bullet', 1);
|
|
||||||
const listItemNode = new ListItemNode();
|
|
||||||
const textNode = new TextNode('Hello');
|
|
||||||
|
|
||||||
listItemNode.append(textNode);
|
|
||||||
const nodesToAppend = [listItemNode];
|
|
||||||
|
|
||||||
expect(listNode.append(...nodesToAppend)).toBe(listNode);
|
|
||||||
expect(listNode.getFirstChild()).toBe(listItemNode);
|
|
||||||
expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ListNode.append() should properly transform a ListNode', async () => {
|
|
||||||
const {editor} = testEnv;
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
const listNode = new ListNode('bullet', 1);
|
|
||||||
const nestedListNode = new ListNode('bullet', 1);
|
|
||||||
const listItemNode = new ListItemNode();
|
|
||||||
const textNode = new TextNode('Hello');
|
|
||||||
|
|
||||||
listItemNode.append(textNode);
|
|
||||||
nestedListNode.append(listItemNode);
|
|
||||||
|
|
||||||
const nodesToAppend = [nestedListNode];
|
|
||||||
|
|
||||||
expect(listNode.append(...nodesToAppend)).toBe(listNode);
|
|
||||||
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
|
|
||||||
expect(listNode.getFirstChild<ListItemNode>()!.getFirstChild()).toBe(
|
|
||||||
nestedListNode,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ListNode.append() should properly transform a ParagraphNode', async () => {
|
|
||||||
const {editor} = testEnv;
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
const listNode = new ListNode('bullet', 1);
|
|
||||||
const paragraph = new ParagraphNode();
|
|
||||||
const textNode = new TextNode('Hello');
|
|
||||||
paragraph.append(textNode);
|
|
||||||
const nodesToAppend = [paragraph];
|
|
||||||
|
|
||||||
expect(listNode.append(...nodesToAppend)).toBe(listNode);
|
|
||||||
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
|
|
||||||
expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('$createListNode()', async () => {
|
|
||||||
const {editor} = testEnv;
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
const listNode = $createListNode('bullet', 1);
|
|
||||||
const createdListNode = $createListNode('bullet');
|
|
||||||
|
|
||||||
expect(listNode.__type).toEqual(createdListNode.__type);
|
|
||||||
expect(listNode.__parent).toEqual(createdListNode.__parent);
|
|
||||||
expect(listNode.__tag).toEqual(createdListNode.__tag);
|
|
||||||
expect(listNode.__key).not.toEqual(createdListNode.__key);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('$isListNode()', async () => {
|
|
||||||
const {editor} = testEnv;
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
const listNode = $createListNode('bullet', 1);
|
|
||||||
|
|
||||||
expect($isListNode(listNode)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('$createListNode() with tag name (backward compatibility)', async () => {
|
|
||||||
const {editor} = testEnv;
|
|
||||||
|
|
||||||
await editor.update(() => {
|
|
||||||
const numberList = $createListNode('number', 1);
|
|
||||||
const bulletList = $createListNode('bullet', 1);
|
|
||||||
expect(numberList.__listType).toBe('number');
|
|
||||||
expect(bulletList.__listType).toBe('bullet');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ListNode.append() should properly transform a ListItemNode', async () => {
|
||||||
|
const {editor} = createTestContext();
|
||||||
|
|
||||||
|
await editor.update(() => {
|
||||||
|
const listNode = new ListNode('bullet', 1);
|
||||||
|
const listItemNode = new ListItemNode();
|
||||||
|
const textNode = new TextNode('Hello');
|
||||||
|
|
||||||
|
listItemNode.append(textNode);
|
||||||
|
const nodesToAppend = [listItemNode];
|
||||||
|
|
||||||
|
expect(listNode.append(...nodesToAppend)).toBe(listNode);
|
||||||
|
expect(listNode.getFirstChild()).toBe(listItemNode);
|
||||||
|
expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ListNode.append() should properly transform a ListNode', async () => {
|
||||||
|
const {editor} = createTestContext();
|
||||||
|
|
||||||
|
await editor.update(() => {
|
||||||
|
const listNode = new ListNode('bullet', 1);
|
||||||
|
const nestedListNode = new ListNode('bullet', 1);
|
||||||
|
const listItemNode = new ListItemNode();
|
||||||
|
const textNode = new TextNode('Hello');
|
||||||
|
|
||||||
|
listItemNode.append(textNode);
|
||||||
|
nestedListNode.append(listItemNode);
|
||||||
|
|
||||||
|
const nodesToAppend = [nestedListNode];
|
||||||
|
|
||||||
|
expect(listNode.append(...nodesToAppend)).toBe(listNode);
|
||||||
|
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
|
||||||
|
expect(listNode.getFirstChild<ListItemNode>()!.getFirstChild()).toBe(
|
||||||
|
nestedListNode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ListNode.append() should properly transform a ParagraphNode', async () => {
|
||||||
|
const {editor} = createTestContext();
|
||||||
|
|
||||||
|
await editor.update(() => {
|
||||||
|
const listNode = new ListNode('bullet', 1);
|
||||||
|
const paragraph = new ParagraphNode();
|
||||||
|
const textNode = new TextNode('Hello');
|
||||||
|
paragraph.append(textNode);
|
||||||
|
const nodesToAppend = [paragraph];
|
||||||
|
|
||||||
|
expect(listNode.append(...nodesToAppend)).toBe(listNode);
|
||||||
|
expect($isListItemNode(listNode.getFirstChild())).toBe(true);
|
||||||
|
expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('$createListNode()', async () => {
|
||||||
|
const {editor} = createTestContext();
|
||||||
|
|
||||||
|
await editor.update(() => {
|
||||||
|
const listNode = $createListNode('bullet', 1);
|
||||||
|
const createdListNode = $createListNode('bullet');
|
||||||
|
|
||||||
|
expect(listNode.__type).toEqual(createdListNode.__type);
|
||||||
|
expect(listNode.__parent).toEqual(createdListNode.__parent);
|
||||||
|
expect(listNode.__tag).toEqual(createdListNode.__tag);
|
||||||
|
expect(listNode.__key).not.toEqual(createdListNode.__key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('$isListNode()', async () => {
|
||||||
|
const {editor} = createTestContext();
|
||||||
|
|
||||||
|
await editor.update(() => {
|
||||||
|
const listNode = $createListNode('bullet', 1);
|
||||||
|
|
||||||
|
expect($isListNode(listNode)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('$createListNode() with tag name (backward compatibility)', async () => {
|
||||||
|
const {editor} = createTestContext();
|
||||||
|
|
||||||
|
await editor.update(() => {
|
||||||
|
const numberList = $createListNode('number', 1);
|
||||||
|
const bulletList = $createListNode('bullet', 1);
|
||||||
|
expect(numberList.__listType).toBe('number');
|
||||||
|
expect(bulletList.__listType).toBe('bullet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importDOM handles old editor expected task list format', async () => {
|
||||||
|
const {editor} = createTestContext();
|
||||||
|
|
||||||
|
let list!: ListNode;
|
||||||
|
editor.update(() => {
|
||||||
|
const nodes = $htmlToBlockNodes(editor, `<ul><li class="task-list-item"><input checked="" disabled="" type="checkbox"> A</li></ul>`);
|
||||||
|
list = nodes[0] as ListNode;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(list).toBeInstanceOf(ListNode);
|
||||||
|
expect(list.getListType()).toBe('check');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -133,7 +133,7 @@ export class ImageNode extends ElementNode {
|
||||||
|
|
||||||
element.addEventListener('click', e => {
|
element.addEventListener('click', e => {
|
||||||
_editor.update(() => {
|
_editor.update(() => {
|
||||||
$selectSingleNode(this);
|
this.select();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {
|
import {
|
||||||
createTestContext, destroyFromContext,
|
createTestContext, destroyFromContext,
|
||||||
dispatchKeydownEventForNode,
|
dispatchKeydownEventForNode,
|
||||||
dispatchKeydownEventForSelectedNode,
|
dispatchKeydownEventForSelectedNode, expectNodeShapeToMatch,
|
||||||
} from "lexical/__tests__/utils";
|
} from "lexical/__tests__/utils";
|
||||||
import {
|
import {
|
||||||
$createParagraphNode, $createTextNode,
|
$createParagraphNode, $createTextNode,
|
||||||
|
@ -13,6 +13,7 @@ import {registerKeyboardHandling} from "../keyboard-handling";
|
||||||
import {registerRichText} from "@lexical/rich-text";
|
import {registerRichText} from "@lexical/rich-text";
|
||||||
import {EditorUiContext} from "../../ui/framework/core";
|
import {EditorUiContext} from "../../ui/framework/core";
|
||||||
import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list";
|
import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list";
|
||||||
|
import {$createImageNode, ImageNode} from "@lexical/rich-text/LexicalImageNode";
|
||||||
|
|
||||||
describe('Keyboard-handling service tests', () => {
|
describe('Keyboard-handling service tests', () => {
|
||||||
|
|
||||||
|
@ -127,4 +128,34 @@ describe('Keyboard-handling service tests', () => {
|
||||||
expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey());
|
expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Images: up on selected image creates new paragraph if none above', () => {
|
||||||
|
let image!: ImageNode;
|
||||||
|
editor.updateAndCommit(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
const imageWrap = $createParagraphNode();
|
||||||
|
image = $createImageNode('https://example.com/cat.png');
|
||||||
|
imageWrap.append(image);
|
||||||
|
root.append(imageWrap);
|
||||||
|
image.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
expectNodeShapeToMatch(editor, [{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [
|
||||||
|
{type: 'image'}
|
||||||
|
],
|
||||||
|
}]);
|
||||||
|
|
||||||
|
dispatchKeydownEventForNode(image, editor, 'ArrowUp');
|
||||||
|
|
||||||
|
expectNodeShapeToMatch(editor, [{
|
||||||
|
type: 'paragraph',
|
||||||
|
}, {
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [
|
||||||
|
{type: 'image'}
|
||||||
|
],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
});
|
});
|
|
@ -3,7 +3,7 @@ import {
|
||||||
$createParagraphNode,
|
$createParagraphNode,
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$isDecoratorNode,
|
$isDecoratorNode,
|
||||||
COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND,
|
COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND,
|
||||||
KEY_BACKSPACE_COMMAND,
|
KEY_BACKSPACE_COMMAND,
|
||||||
KEY_DELETE_COMMAND,
|
KEY_DELETE_COMMAND,
|
||||||
KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
|
KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
|
||||||
|
@ -43,7 +43,7 @@ function deleteSingleSelectedNode(editor: LexicalEditor) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a new empty node after the selection if the selection contains a single
|
* Insert a new empty node before/after the selection if the selection contains a single
|
||||||
* selected node (like image, media etc...).
|
* selected node (like image, media etc...).
|
||||||
*/
|
*/
|
||||||
function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
|
function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
|
||||||
|
@ -67,6 +67,34 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event: KeyboardEvent|null, after: boolean = true): boolean {
|
||||||
|
const selectionNodes = getLastSelection(editor)?.getNodes() || [];
|
||||||
|
if (!isSingleSelectedNode(selectionNodes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
|
const node = selectionNodes[0];
|
||||||
|
const nearestBlock = $getNearestNodeBlockParent(node) || node;
|
||||||
|
let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling();
|
||||||
|
|
||||||
|
editor.update(() => {
|
||||||
|
if (!target) {
|
||||||
|
target = $createParagraphNode();
|
||||||
|
if (after) {
|
||||||
|
nearestBlock.insertAfter(target)
|
||||||
|
} else {
|
||||||
|
nearestBlock.insertBefore(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.selectStart();
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a new node after a details node, if inside a details node that's
|
* Insert a new node after a details node, if inside a details node that's
|
||||||
* the last element, and if the cursor is at the last block within the details node.
|
* the last element, and if the cursor is at the last block within the details node.
|
||||||
|
@ -199,8 +227,13 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
|
||||||
return handleInsetOnTab(context.editor, event);
|
return handleInsetOnTab(context.editor, event);
|
||||||
}, COMMAND_PRIORITY_LOW);
|
}, COMMAND_PRIORITY_LOW);
|
||||||
|
|
||||||
|
const unregisterUp = context.editor.registerCommand(KEY_ARROW_UP_COMMAND, (event): boolean => {
|
||||||
|
return focusAdjacentOrInsertForSingleSelectNode(context.editor, event, false);
|
||||||
|
}, COMMAND_PRIORITY_LOW);
|
||||||
|
|
||||||
const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => {
|
const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => {
|
||||||
return insertAfterDetails(context.editor, event);
|
return insertAfterDetails(context.editor, event)
|
||||||
|
|| focusAdjacentOrInsertForSingleSelectNode(context.editor, event, true)
|
||||||
}, COMMAND_PRIORITY_LOW);
|
}, COMMAND_PRIORITY_LOW);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -208,6 +241,7 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
|
||||||
unregisterDelete();
|
unregisterDelete();
|
||||||
unregisterEnter();
|
unregisterEnter();
|
||||||
unregisterTab();
|
unregisterTab();
|
||||||
|
unregisterUp();
|
||||||
unregisterDown();
|
unregisterDown();
|
||||||
};
|
};
|
||||||
}
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical";
|
import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical";
|
||||||
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
|
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
|
||||||
import {nodeHasInset} from "./nodes";
|
import {$sortNodes, nodeHasInset} from "./nodes";
|
||||||
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
|
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
|
||||||
|
|
||||||
|
|
||||||
|
@ -49,16 +49,11 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
const laterSiblings = node.getNextSiblings();
|
const laterSiblings = node.getNextSiblings();
|
||||||
|
|
||||||
parentListItem.insertAfter(node);
|
parentListItem.insertAfter(node);
|
||||||
if (list.getChildren().length === 0) {
|
if (list.getChildren().length === 0) {
|
||||||
list.remove();
|
list.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentListItem.getChildren().length === 0) {
|
|
||||||
parentListItem.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (laterSiblings.length > 0) {
|
if (laterSiblings.length > 0) {
|
||||||
const childList = $createListNode(list.getListType());
|
const childList = $createListNode(list.getListType());
|
||||||
childList.append(...laterSiblings);
|
childList.append(...laterSiblings);
|
||||||
|
@ -69,23 +64,54 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
|
||||||
list.remove();
|
list.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parentListItem.getChildren().length === 0) {
|
||||||
|
parentListItem.remove();
|
||||||
|
}
|
||||||
|
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getListItemsForSelection(selection: BaseSelection|null): (ListItemNode|null)[] {
|
function getListItemsForSelection(selection: BaseSelection|null): (ListItemNode|null)[] {
|
||||||
const nodes = selection?.getNodes() || [];
|
const nodes = selection?.getNodes() || [];
|
||||||
const listItemNodes = [];
|
let [start, end] = selection?.getStartEndPoints() || [null, null];
|
||||||
|
|
||||||
|
// Ensure we ignore parent list items of the top-most list item since,
|
||||||
|
// although technically part of the selection, from a user point of
|
||||||
|
// view the selection does not spread to encompass this outer element.
|
||||||
|
const itemsToIgnore: Set<string> = new Set();
|
||||||
|
if (selection && start) {
|
||||||
|
if (selection.isBackward() && end) {
|
||||||
|
[end, start] = [start, end];
|
||||||
|
}
|
||||||
|
|
||||||
|
const startParents = start.getNode().getParents();
|
||||||
|
let foundList = false;
|
||||||
|
for (const parent of startParents) {
|
||||||
|
if ($isListItemNode(parent)) {
|
||||||
|
if (foundList) {
|
||||||
|
itemsToIgnore.add(parent.getKey());
|
||||||
|
} else {
|
||||||
|
foundList = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listItemNodes = [];
|
||||||
outer: for (const node of nodes) {
|
outer: for (const node of nodes) {
|
||||||
if ($isListItemNode(node)) {
|
if ($isListItemNode(node)) {
|
||||||
listItemNodes.push(node);
|
if (!itemsToIgnore.has(node.getKey())) {
|
||||||
|
listItemNodes.push(node);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parents = node.getParents();
|
const parents = node.getParents();
|
||||||
for (const parent of parents) {
|
for (const parent of parents) {
|
||||||
if ($isListItemNode(parent)) {
|
if ($isListItemNode(parent)) {
|
||||||
listItemNodes.push(parent);
|
if (!itemsToIgnore.has(parent.getKey())) {
|
||||||
|
listItemNodes.push(parent);
|
||||||
|
}
|
||||||
continue outer;
|
continue outer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,7 +136,8 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.values(listItemMap);
|
const items = Object.values(listItemMap);
|
||||||
|
return $sortNodes(items) as ListItemNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
|
export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
|
||||||
|
|
|
@ -94,6 +94,30 @@ export function $getNearestNodeBlockParent(node: LexicalNode): LexicalNode|null
|
||||||
return $findMatchingParent(node, isBlockNode);
|
return $findMatchingParent(node, isBlockNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function $sortNodes(nodes: LexicalNode[]): LexicalNode[] {
|
||||||
|
const idChain: string[] = [];
|
||||||
|
const addIds = (n: ElementNode) => {
|
||||||
|
for (const child of n.getChildren()) {
|
||||||
|
idChain.push(child.getKey())
|
||||||
|
if ($isElementNode(child)) {
|
||||||
|
addIds(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = $getRoot();
|
||||||
|
addIds(root);
|
||||||
|
|
||||||
|
const sorted = Array.from(nodes);
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const aIndex = idChain.indexOf(a.getKey());
|
||||||
|
const bIndex = idChain.indexOf(b.getKey());
|
||||||
|
return aIndex - bIndex;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
export function nodeHasAlignment(node: object): node is NodeHasAlignment {
|
export function nodeHasAlignment(node: object): node is NodeHasAlignment {
|
||||||
return '__alignment' in node;
|
return '__alignment' in node;
|
||||||
}
|
}
|
||||||
|
|
|
@ -370,8 +370,10 @@ body.editor-is-fullscreen {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
outline: 2px dashed var(--editor-color-primary);
|
outline: 2px dashed var(--editor-color-primary);
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.editor-node-resizer-handle {
|
.editor-node-resizer-handle {
|
||||||
|
pointer-events: auto;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: block;
|
display: block;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue