1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-12-23 23:02:08 +03:00

Lexical: Improved nested details interaction

- Set to open by default on insert.
- Updated selection handling not to always fully cascade to lowest
  editable child on selection, so parents can be reliably selected.
- Updated mouse handling to treat details panes like the root element,
  inserting within-details where relevant.
This commit is contained in:
Dan Brown
2025-08-26 14:41:42 +01:00
parent ee994fa2b7
commit 849bc4d6c3
8 changed files with 96 additions and 23 deletions

View File

@@ -1,4 +1,4 @@
import {createEditor, LexicalEditor} from 'lexical';
import {createEditor} from 'lexical';
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {registerRichText} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils';
@@ -89,6 +89,9 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
window.debugEditorState = () => {
return editor.getEditorState().toJSON();
};
context.manager.onSelectionChange((selection) => {
console.log(selection, context.editor.getEditorState());
});
registerCommonNodeMutationListeners(context);

View File

@@ -383,6 +383,14 @@ export class LexicalNode {
return isSelected;
}
/**
* Indicate if this node should be selected directly instead of the default
* where the selection would descend to the nearest initial child element.
*/
shouldSelectDirectly(): boolean {
return false;
}
/**
* Returns this nodes key.
*/

View File

@@ -476,12 +476,12 @@ export class RangeSelection implements BaseSelection {
const startOffset = firstPoint.offset;
const endOffset = lastPoint.offset;
if ($isElementNode(firstNode)) {
if ($isElementNode(firstNode) && !firstNode.shouldSelectDirectly()) {
const firstNodeDescendant =
firstNode.getDescendantByIndex<ElementNode>(startOffset);
firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
}
if ($isElementNode(lastNode)) {
if ($isElementNode(lastNode) && !lastNode.shouldSelectDirectly()) {
let lastNodeDescendant =
lastNode.getDescendantByIndex<ElementNode>(endOffset);
// We don't want to over-select, as node selection infers the child before
@@ -499,7 +499,7 @@ export class RangeSelection implements BaseSelection {
let nodes: Array<LexicalNode>;
if (firstNode.is(lastNode)) {
if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) {
if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0 && !firstNode.shouldSelectDirectly()) {
nodes = [];
} else {
nodes = [firstNode];

View File

@@ -150,6 +150,20 @@ export class ElementNode extends LexicalNode {
}
return node;
}
getFirstSelectableDescendant<T extends LexicalNode>(): null | T {
if (this.shouldSelectDirectly()) {
return null;
}
let node = this.getFirstChild<T>();
while ($isElementNode(node) && !node.shouldSelectDirectly()) {
const child = node.getFirstChild<T>();
if (child === null) {
break;
}
node = child;
}
return node;
}
getLastDescendant<T extends LexicalNode>(): null | T {
let node = this.getLastChild<T>();
while ($isElementNode(node)) {
@@ -161,6 +175,20 @@ export class ElementNode extends LexicalNode {
}
return node;
}
getLastSelectableDescendant<T extends LexicalNode>(): null | T {
if (this.shouldSelectDirectly()) {
return null;
}
let node = this.getLastChild<T>();
while ($isElementNode(node) && !node.shouldSelectDirectly()) {
const child = node.getLastChild<T>();
if (child === null) {
break;
}
node = child;
}
return node;
}
getDescendantByIndex<T extends LexicalNode>(index: number): null | T {
const children = this.getChildren<T>();
const childrenLength = children.length;
@@ -319,11 +347,11 @@ export class ElementNode extends LexicalNode {
return selection;
}
selectStart(): RangeSelection {
const firstNode = this.getFirstDescendant();
const firstNode = this.getFirstSelectableDescendant();
return firstNode ? firstNode.selectStart() : this.select();
}
selectEnd(): RangeSelection {
const lastNode = this.getLastDescendant();
const lastNode = this.getLastSelectableDescendant();
return lastNode ? lastNode.selectEnd() : this.select();
}
clear(): this {

View File

@@ -75,6 +75,9 @@ export class DetailsNode extends ElementNode {
if (this.__open) {
el.setAttribute('open', 'true');
el.removeAttribute('contenteditable');
} else {
el.setAttribute('contenteditable', 'false');
}
const summary = document.createElement('summary');
@@ -84,7 +87,7 @@ export class DetailsNode extends ElementNode {
event.preventDefault();
_editor.update(() => {
this.select();
})
});
});
el.append(summary);
@@ -96,6 +99,11 @@ export class DetailsNode extends ElementNode {
if (prevNode.__open !== this.__open) {
dom.toggleAttribute('open', this.__open);
if (this.__open) {
dom.removeAttribute('contenteditable');
} else {
dom.setAttribute('contenteditable', 'false');
}
}
return prevNode.__id !== this.__id
@@ -144,6 +152,7 @@ export class DetailsNode extends ElementNode {
}
element.removeAttribute('open');
element.removeAttribute('contenteditable');
return {element};
}
@@ -165,6 +174,10 @@ export class DetailsNode extends ElementNode {
return node;
}
shouldSelectDirectly(): boolean {
return true;
}
}
export function $createDetailsNode() {

View File

@@ -1,31 +1,41 @@
import {EditorUiContext} from "../ui/framework/core";
import {
$createParagraphNode, $getRoot,
$getSelection,
$createParagraphNode, $getNearestNodeFromDOMNode, $getRoot,
$isDecoratorNode, CLICK_COMMAND,
COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
LexicalEditor,
COMMAND_PRIORITY_LOW, ElementNode,
LexicalNode
} from "lexical";
import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {getLastSelection} from "../utils/selection";
import {$getNearestNodeBlockParent, $getParentOfType, $selectOrCreateAdjacent} from "../utils/nodes";
import {$setInsetForSelection} from "../utils/lists";
import {$isListItemNode} from "@lexical/list";
import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {$isDiagramNode} from "../utils/diagrams";
import {$isTableNode} from "@lexical/table";
import {$isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
function isHardToEscapeNode(node: LexicalNode): boolean {
return $isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node) || $isTableNode(node);
return $isDecoratorNode(node)
|| $isImageNode(node)
|| $isMediaNode(node)
|| $isDiagramNode(node)
|| $isTableNode(node)
|| $isDetailsNode(node);
}
function $getContextNode(event: MouseEvent): ElementNode {
if (event.target instanceof HTMLElement) {
const nearestDetails = event.target.closest('details');
if (nearestDetails) {
const detailsNode = $getNearestNodeFromDOMNode(nearestDetails);
if ($isDetailsNode(detailsNode)) {
return detailsNode;
}
}
}
return $getRoot();
}
function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boolean {
const lastNode = $getRoot().getLastChild();
const contextNode = $getContextNode(event);
const lastNode = contextNode.getLastChild();
if (!lastNode || !isHardToEscapeNode(lastNode)) {
return false;
}
@@ -40,7 +50,7 @@ function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boole
if (isClickBelow) {
context.editor.update(() => {
const newNode = $createParagraphNode();
$getRoot().append(newNode);
contextNode.append(newNode);
newNode.select();
});
return true;
@@ -49,7 +59,6 @@ function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boole
return false;
}
export function registerMouseHandling(context: EditorUiContext): () => void {
const unregisterClick = context.editor.registerCommand(CLICK_COMMAND, (event): boolean => {
insertBelowLastNode(context, event);

View File

@@ -193,6 +193,8 @@ export const details: EditorButtonDefinition = {
.filter(n => n !== null) as ElementNode[];
const uniqueTopLevels = [...new Set(topLevels)];
detailsNode.setOpen(true);
if (uniqueTopLevels.length > 0) {
uniqueTopLevels[0].insertAfter(detailsNode);
} else {