Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit c6e35c2

Browse files
authored
Merge pull request #5775 from BookStackApp/lexical_aug25
Lexical: August 2025 fixes
2 parents 0436ccf + f5da310 commit c6e35c2

File tree

12 files changed

+214
-54
lines changed

12 files changed

+214
-54
lines changed

resources/js/wysiwyg/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {createEditor, LexicalEditor} from 'lexical';
1+
import {createEditor} from 'lexical';
22
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
33
import {registerRichText} from '@lexical/rich-text';
44
import {mergeRegister} from '@lexical/utils';
@@ -20,6 +20,7 @@ import {modals} from "./ui/defaults/modals";
2020
import {CodeBlockDecorator} from "./ui/decorators/code-block";
2121
import {DiagramDecorator} from "./ui/decorators/diagram";
2222
import {registerMouseHandling} from "./services/mouse-handling";
23+
import {registerSelectionHandling} from "./services/selection-handling";
2324

2425
const theme = {
2526
text: {
@@ -53,6 +54,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
5354
registerShortcuts(context),
5455
registerKeyboardHandling(context),
5556
registerMouseHandling(context),
57+
registerSelectionHandling(context),
5658
registerTableResizer(editor, context.scrollDOM),
5759
registerTableSelectionHandler(editor),
5860
registerTaskListHandler(editor, context.editorDOM),

resources/js/wysiwyg/lexical/core/LexicalNode.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,14 @@ export class LexicalNode {
383383
return isSelected;
384384
}
385385

386+
/**
387+
* Indicate if this node should be selected directly instead of the default
388+
* where the selection would descend to the nearest initial child element.
389+
*/
390+
shouldSelectDirectly(): boolean {
391+
return false;
392+
}
393+
386394
/**
387395
* Returns this nodes key.
388396
*/

resources/js/wysiwyg/lexical/core/LexicalSelection.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -476,12 +476,12 @@ export class RangeSelection implements BaseSelection {
476476
const startOffset = firstPoint.offset;
477477
const endOffset = lastPoint.offset;
478478

479-
if ($isElementNode(firstNode)) {
479+
if ($isElementNode(firstNode) && !firstNode.shouldSelectDirectly()) {
480480
const firstNodeDescendant =
481481
firstNode.getDescendantByIndex<ElementNode>(startOffset);
482482
firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
483483
}
484-
if ($isElementNode(lastNode)) {
484+
if ($isElementNode(lastNode) && !lastNode.shouldSelectDirectly()) {
485485
let lastNodeDescendant =
486486
lastNode.getDescendantByIndex<ElementNode>(endOffset);
487487
// We don't want to over-select, as node selection infers the child before
@@ -499,7 +499,7 @@ export class RangeSelection implements BaseSelection {
499499
let nodes: Array<LexicalNode>;
500500

501501
if (firstNode.is(lastNode)) {
502-
if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) {
502+
if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0 && !firstNode.shouldSelectDirectly()) {
503503
nodes = [];
504504
} else {
505505
nodes = [firstNode];

resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,20 @@ export class ElementNode extends LexicalNode {
150150
}
151151
return node;
152152
}
153+
getFirstSelectableDescendant<T extends LexicalNode>(): null | T {
154+
if (this.shouldSelectDirectly()) {
155+
return null;
156+
}
157+
let node = this.getFirstChild<T>();
158+
while ($isElementNode(node) && !node.shouldSelectDirectly()) {
159+
const child = node.getFirstChild<T>();
160+
if (child === null) {
161+
break;
162+
}
163+
node = child;
164+
}
165+
return node;
166+
}
153167
getLastDescendant<T extends LexicalNode>(): null | T {
154168
let node = this.getLastChild<T>();
155169
while ($isElementNode(node)) {
@@ -161,6 +175,20 @@ export class ElementNode extends LexicalNode {
161175
}
162176
return node;
163177
}
178+
getLastSelectableDescendant<T extends LexicalNode>(): null | T {
179+
if (this.shouldSelectDirectly()) {
180+
return null;
181+
}
182+
let node = this.getLastChild<T>();
183+
while ($isElementNode(node) && !node.shouldSelectDirectly()) {
184+
const child = node.getLastChild<T>();
185+
if (child === null) {
186+
break;
187+
}
188+
node = child;
189+
}
190+
return node;
191+
}
164192
getDescendantByIndex<T extends LexicalNode>(index: number): null | T {
165193
const children = this.getChildren<T>();
166194
const childrenLength = children.length;
@@ -279,7 +307,7 @@ export class ElementNode extends LexicalNode {
279307
let anchorOffset = _anchorOffset;
280308
let focusOffset = _focusOffset;
281309
const childrenCount = this.getChildrenSize();
282-
if (!this.canBeEmpty()) {
310+
if (!this.canBeEmpty() && !this.shouldSelectDirectly()) {
283311
if (_anchorOffset === 0 && _focusOffset === 0) {
284312
const firstChild = this.getFirstChild();
285313
if ($isTextNode(firstChild) || $isElementNode(firstChild)) {
@@ -319,11 +347,11 @@ export class ElementNode extends LexicalNode {
319347
return selection;
320348
}
321349
selectStart(): RangeSelection {
322-
const firstNode = this.getFirstDescendant();
350+
const firstNode = this.getFirstSelectableDescendant();
323351
return firstNode ? firstNode.selectStart() : this.select();
324352
}
325353
selectEnd(): RangeSelection {
326-
const lastNode = this.getLastDescendant();
354+
const lastNode = this.getLastSelectableDescendant();
327355
return lastNode ? lastNode.selectEnd() : this.select();
328356
}
329357
clear(): this {

resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ export class DetailsNode extends ElementNode {
7575

7676
if (this.__open) {
7777
el.setAttribute('open', 'true');
78+
el.removeAttribute('contenteditable');
79+
} else {
80+
el.setAttribute('contenteditable', 'false');
7881
}
7982

8083
const summary = document.createElement('summary');
@@ -84,7 +87,7 @@ export class DetailsNode extends ElementNode {
8487
event.preventDefault();
8588
_editor.update(() => {
8689
this.select();
87-
})
90+
});
8891
});
8992

9093
el.append(summary);
@@ -96,6 +99,11 @@ export class DetailsNode extends ElementNode {
9699

97100
if (prevNode.__open !== this.__open) {
98101
dom.toggleAttribute('open', this.__open);
102+
if (this.__open) {
103+
dom.removeAttribute('contenteditable');
104+
} else {
105+
dom.setAttribute('contenteditable', 'false');
106+
}
99107
}
100108

101109
return prevNode.__id !== this.__id
@@ -144,6 +152,7 @@ export class DetailsNode extends ElementNode {
144152
}
145153

146154
element.removeAttribute('open');
155+
element.removeAttribute('contenteditable');
147156

148157
return {element};
149158
}
@@ -165,6 +174,14 @@ export class DetailsNode extends ElementNode {
165174
return node;
166175
}
167176

177+
shouldSelectDirectly(): boolean {
178+
return true;
179+
}
180+
181+
canBeEmpty(): boolean {
182+
return false;
183+
}
184+
168185
}
169186

170187
export function $createDetailsNode() {
Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils";
2-
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
3-
import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical";
1+
import {createTestContext} from "lexical/__tests__/utils";
2+
import {$createDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
43

54
const editorConfig = Object.freeze({
65
namespace: '',
@@ -9,32 +8,28 @@ const editorConfig = Object.freeze({
98
});
109

1110
describe('LexicalDetailsNode tests', () => {
12-
initializeUnitTest((testEnv) => {
11+
test('createDOM()', () => {
12+
const {editor} = createTestContext();
13+
let html!: string;
1314

14-
test('createDOM()', () => {
15-
const {editor} = testEnv;
16-
let html!: string;
17-
18-
editor.updateAndCommit(() => {
19-
const details = $createDetailsNode();
20-
html = details.createDOM(editorConfig, editor).outerHTML;
21-
});
22-
23-
expect(html).toBe(`<details><summary contenteditable="false"></summary></details>`);
15+
editor.updateAndCommit(() => {
16+
const details = $createDetailsNode();
17+
html = details.createDOM(editorConfig, editor).outerHTML;
2418
});
2519

26-
test('exportDOM()', () => {
27-
const {editor} = testEnv;
28-
let html!: string;
20+
expect(html).toBe(`<details contenteditable="false"><summary contenteditable="false"></summary></details>`);
21+
});
2922

30-
editor.updateAndCommit(() => {
31-
const details = $createDetailsNode();
32-
html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
33-
});
23+
test('exportDOM()', () => {
24+
const {editor} = createTestContext();
25+
let html!: string;
3426

35-
expect(html).toBe(`<details><summary></summary></details>`);
27+
editor.updateAndCommit(() => {
28+
const details = $createDetailsNode();
29+
details.setSummary('Hello there<>!')
30+
html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
3631
});
3732

38-
33+
expect(html).toBe(`<details><summary>Hello there&lt;&gt;!</summary></details>`);
3934
});
4035
})

resources/js/wysiwyg/services/keyboard-handling.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {$setInsetForSelection} from "../utils/lists";
1818
import {$isListItemNode} from "@lexical/list";
1919
import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
2020
import {$isDiagramNode} from "../utils/diagrams";
21+
import {$unwrapDetailsNode} from "../utils/details";
2122

2223
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
2324
if (nodes.length === 1) {
@@ -172,6 +173,35 @@ function getDetailsScenario(editor: LexicalEditor): {
172173
}
173174
}
174175

176+
function unwrapDetailsNode(context: EditorUiContext, event: KeyboardEvent): boolean {
177+
const selection = $getSelection();
178+
const nodes = selection?.getNodes() || [];
179+
180+
if (nodes.length !== 1) {
181+
return false;
182+
}
183+
184+
const selectedNearestBlock = $getNearestNodeBlockParent(nodes[0]);
185+
if (!selectedNearestBlock) {
186+
return false;
187+
}
188+
189+
const selectedParentBlock = selectedNearestBlock.getParent();
190+
const selectRange = selection?.getStartEndPoints();
191+
192+
if (selectRange && $isDetailsNode(selectedParentBlock) && selectRange[0].offset === 0 && selectedNearestBlock.getIndexWithinParent() === 0) {
193+
event.preventDefault();
194+
context.editor.update(() => {
195+
$unwrapDetailsNode(selectedParentBlock);
196+
selectedNearestBlock.selectStart();
197+
context.manager.triggerLayoutUpdate();
198+
});
199+
return true;
200+
}
201+
202+
return false;
203+
}
204+
175205
function $isSingleListItem(nodes: LexicalNode[]): boolean {
176206
if (nodes.length !== 1) {
177207
return false;
@@ -201,9 +231,9 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo
201231
}
202232

203233
export function registerKeyboardHandling(context: EditorUiContext): () => void {
204-
const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
234+
const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event): boolean => {
205235
deleteSingleSelectedNode(context.editor);
206-
return false;
236+
return unwrapDetailsNode(context, event);
207237
}, COMMAND_PRIORITY_LOW);
208238

209239
const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => {

resources/js/wysiwyg/services/mouse-handling.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,41 @@
11
import {EditorUiContext} from "../ui/framework/core";
22
import {
3-
$createParagraphNode, $getRoot,
4-
$getSelection,
3+
$createParagraphNode, $getNearestNodeFromDOMNode, $getRoot,
54
$isDecoratorNode, CLICK_COMMAND,
6-
COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND,
7-
KEY_BACKSPACE_COMMAND,
8-
KEY_DELETE_COMMAND,
9-
KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
10-
LexicalEditor,
5+
COMMAND_PRIORITY_LOW, ElementNode,
116
LexicalNode
127
} from "lexical";
138
import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
149
import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
15-
import {getLastSelection} from "../utils/selection";
16-
import {$getNearestNodeBlockParent, $getParentOfType, $selectOrCreateAdjacent} from "../utils/nodes";
17-
import {$setInsetForSelection} from "../utils/lists";
18-
import {$isListItemNode} from "@lexical/list";
19-
import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
2010
import {$isDiagramNode} from "../utils/diagrams";
2111
import {$isTableNode} from "@lexical/table";
12+
import {$isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
2213

2314
function isHardToEscapeNode(node: LexicalNode): boolean {
24-
return $isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node) || $isTableNode(node);
15+
return $isDecoratorNode(node)
16+
|| $isImageNode(node)
17+
|| $isMediaNode(node)
18+
|| $isDiagramNode(node)
19+
|| $isTableNode(node)
20+
|| $isDetailsNode(node);
21+
}
22+
23+
function $getContextNode(event: MouseEvent): ElementNode {
24+
if (event.target instanceof HTMLElement) {
25+
const nearestDetails = event.target.closest('details');
26+
if (nearestDetails) {
27+
const detailsNode = $getNearestNodeFromDOMNode(nearestDetails);
28+
if ($isDetailsNode(detailsNode)) {
29+
return detailsNode;
30+
}
31+
}
32+
}
33+
return $getRoot();
2534
}
2635

2736
function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boolean {
28-
const lastNode = $getRoot().getLastChild();
37+
const contextNode = $getContextNode(event);
38+
const lastNode = contextNode.getLastChild();
2939
if (!lastNode || !isHardToEscapeNode(lastNode)) {
3040
return false;
3141
}
@@ -40,7 +50,7 @@ function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boole
4050
if (isClickBelow) {
4151
context.editor.update(() => {
4252
const newNode = $createParagraphNode();
43-
$getRoot().append(newNode);
53+
contextNode.append(newNode);
4454
newNode.select();
4555
});
4656
return true;
@@ -49,7 +59,6 @@ function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boole
4959
return false;
5060
}
5161

52-
5362
export function registerMouseHandling(context: EditorUiContext): () => void {
5463
const unregisterClick = context.editor.registerCommand(CLICK_COMMAND, (event): boolean => {
5564
insertBelowLastNode(context, event);

0 commit comments

Comments
 (0)