mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-01-03 23:42:28 +03:00
Merge pull request #5775 from BookStackApp/lexical_aug25
Lexical: August 2025 fixes
This commit is contained in:
@@ -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';
|
||||
@@ -20,6 +20,7 @@ import {modals} from "./ui/defaults/modals";
|
||||
import {CodeBlockDecorator} from "./ui/decorators/code-block";
|
||||
import {DiagramDecorator} from "./ui/decorators/diagram";
|
||||
import {registerMouseHandling} from "./services/mouse-handling";
|
||||
import {registerSelectionHandling} from "./services/selection-handling";
|
||||
|
||||
const theme = {
|
||||
text: {
|
||||
@@ -53,6 +54,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
|
||||
registerShortcuts(context),
|
||||
registerKeyboardHandling(context),
|
||||
registerMouseHandling(context),
|
||||
registerSelectionHandling(context),
|
||||
registerTableResizer(editor, context.scrollDOM),
|
||||
registerTableSelectionHandler(editor),
|
||||
registerTaskListHandler(editor, context.editorDOM),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
@@ -279,7 +307,7 @@ export class ElementNode extends LexicalNode {
|
||||
let anchorOffset = _anchorOffset;
|
||||
let focusOffset = _focusOffset;
|
||||
const childrenCount = this.getChildrenSize();
|
||||
if (!this.canBeEmpty()) {
|
||||
if (!this.canBeEmpty() && !this.shouldSelectDirectly()) {
|
||||
if (_anchorOffset === 0 && _focusOffset === 0) {
|
||||
const firstChild = this.getFirstChild();
|
||||
if ($isTextNode(firstChild) || $isElementNode(firstChild)) {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,14 @@ export class DetailsNode extends ElementNode {
|
||||
return node;
|
||||
}
|
||||
|
||||
shouldSelectDirectly(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
canBeEmpty(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function $createDetailsNode() {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils";
|
||||
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
|
||||
import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical";
|
||||
import {createTestContext} from "lexical/__tests__/utils";
|
||||
import {$createDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
@@ -9,32 +8,28 @@ const editorConfig = Object.freeze({
|
||||
});
|
||||
|
||||
describe('LexicalDetailsNode tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('createDOM()', () => {
|
||||
const {editor} = createTestContext();
|
||||
let html!: string;
|
||||
|
||||
test('createDOM()', () => {
|
||||
const {editor} = testEnv;
|
||||
let html!: string;
|
||||
|
||||
editor.updateAndCommit(() => {
|
||||
const details = $createDetailsNode();
|
||||
html = details.createDOM(editorConfig, editor).outerHTML;
|
||||
});
|
||||
|
||||
expect(html).toBe(`<details><summary contenteditable="false"></summary></details>`);
|
||||
editor.updateAndCommit(() => {
|
||||
const details = $createDetailsNode();
|
||||
html = details.createDOM(editorConfig, editor).outerHTML;
|
||||
});
|
||||
|
||||
test('exportDOM()', () => {
|
||||
const {editor} = testEnv;
|
||||
let html!: string;
|
||||
expect(html).toBe(`<details contenteditable="false"><summary contenteditable="false"></summary></details>`);
|
||||
});
|
||||
|
||||
editor.updateAndCommit(() => {
|
||||
const details = $createDetailsNode();
|
||||
html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
|
||||
});
|
||||
test('exportDOM()', () => {
|
||||
const {editor} = createTestContext();
|
||||
let html!: string;
|
||||
|
||||
expect(html).toBe(`<details><summary></summary></details>`);
|
||||
editor.updateAndCommit(() => {
|
||||
const details = $createDetailsNode();
|
||||
details.setSummary('Hello there<>!')
|
||||
html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
|
||||
});
|
||||
|
||||
|
||||
expect(html).toBe(`<details><summary>Hello there<>!</summary></details>`);
|
||||
});
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import {$setInsetForSelection} from "../utils/lists";
|
||||
import {$isListItemNode} from "@lexical/list";
|
||||
import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
|
||||
import {$isDiagramNode} from "../utils/diagrams";
|
||||
import {$unwrapDetailsNode} from "../utils/details";
|
||||
|
||||
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
|
||||
if (nodes.length === 1) {
|
||||
@@ -172,6 +173,35 @@ function getDetailsScenario(editor: LexicalEditor): {
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapDetailsNode(context: EditorUiContext, event: KeyboardEvent): boolean {
|
||||
const selection = $getSelection();
|
||||
const nodes = selection?.getNodes() || [];
|
||||
|
||||
if (nodes.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedNearestBlock = $getNearestNodeBlockParent(nodes[0]);
|
||||
if (!selectedNearestBlock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedParentBlock = selectedNearestBlock.getParent();
|
||||
const selectRange = selection?.getStartEndPoints();
|
||||
|
||||
if (selectRange && $isDetailsNode(selectedParentBlock) && selectRange[0].offset === 0 && selectedNearestBlock.getIndexWithinParent() === 0) {
|
||||
event.preventDefault();
|
||||
context.editor.update(() => {
|
||||
$unwrapDetailsNode(selectedParentBlock);
|
||||
selectedNearestBlock.selectStart();
|
||||
context.manager.triggerLayoutUpdate();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function $isSingleListItem(nodes: LexicalNode[]): boolean {
|
||||
if (nodes.length !== 1) {
|
||||
return false;
|
||||
@@ -201,9 +231,9 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo
|
||||
}
|
||||
|
||||
export function registerKeyboardHandling(context: EditorUiContext): () => void {
|
||||
const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
|
||||
const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event): boolean => {
|
||||
deleteSingleSelectedNode(context.editor);
|
||||
return false;
|
||||
return unwrapDetailsNode(context, event);
|
||||
}, COMMAND_PRIORITY_LOW);
|
||||
|
||||
const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
49
resources/js/wysiwyg/services/selection-handling.ts
Normal file
49
resources/js/wysiwyg/services/selection-handling.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {EditorUiContext} from "../ui/framework/core";
|
||||
import {
|
||||
$getSelection,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
SELECTION_CHANGE_COMMAND
|
||||
} from "lexical";
|
||||
import {$isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
|
||||
|
||||
|
||||
const trackedDomNodes = new Set<HTMLElement>();
|
||||
|
||||
/**
|
||||
* Set a selection indicator on nodes which require it.
|
||||
* @param context
|
||||
*/
|
||||
function setSelectionIndicator(context: EditorUiContext): boolean {
|
||||
|
||||
for (const domNode of trackedDomNodes) {
|
||||
domNode.classList.remove('selected');
|
||||
trackedDomNodes.delete(domNode);
|
||||
}
|
||||
|
||||
const selection = $getSelection();
|
||||
const nodes = selection?.getNodes() || [];
|
||||
|
||||
if (nodes.length === 1) {
|
||||
if ($isDetailsNode(nodes[0])) {
|
||||
const domEl = context.editor.getElementByKey(nodes[0].getKey());
|
||||
if (domEl) {
|
||||
domEl.classList.add('selected');
|
||||
trackedDomNodes.add(domEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerSelectionHandling(context: EditorUiContext): () => void {
|
||||
const unregisterSelectionChange = context.editor.registerCommand(SELECTION_CHANGE_COMMAND, (): boolean => {
|
||||
setSelectionIndicator(context);
|
||||
return false;
|
||||
}, COMMAND_PRIORITY_LOW);
|
||||
|
||||
|
||||
return () => {
|
||||
unregisterSelectionChange();
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert}
|
||||
import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
|
||||
import {$showDetailsForm, $showImageForm, $showLinkForm, $showMediaForm} from "../forms/objects";
|
||||
import {formatCodeBlock} from "../../../utils/formats";
|
||||
import {$unwrapDetailsNode} from "../../../utils/details";
|
||||
|
||||
export const link: EditorButtonDefinition = {
|
||||
label: 'Insert/edit link',
|
||||
@@ -193,6 +194,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 {
|
||||
@@ -249,11 +252,7 @@ export const detailsUnwrap: EditorButtonDefinition = {
|
||||
context.editor.update(() => {
|
||||
const details = $getNodeFromSelection($getSelection(), $isDetailsNode);
|
||||
if ($isDetailsNode(details)) {
|
||||
const children = details.getChildren();
|
||||
for (const child of children) {
|
||||
details.insertBefore(child);
|
||||
}
|
||||
details.remove();
|
||||
$unwrapDetailsNode(details);
|
||||
context.manager.triggerLayoutUpdate();
|
||||
}
|
||||
})
|
||||
|
||||
9
resources/js/wysiwyg/utils/details.ts
Normal file
9
resources/js/wysiwyg/utils/details.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
|
||||
|
||||
export function $unwrapDetailsNode(node: DetailsNode) {
|
||||
const children = node.getChildren();
|
||||
for (const child of children) {
|
||||
node.insertBefore(child);
|
||||
}
|
||||
node.remove();
|
||||
}
|
||||
@@ -437,6 +437,20 @@ body.editor-is-fullscreen {
|
||||
.editor-node-resizer.active .editor-node-resizer-ghost {
|
||||
display: block;
|
||||
}
|
||||
.editor-content-area details[contenteditable="false"],
|
||||
.editor-content-area summary[contenteditable="false"] {
|
||||
user-select: none;
|
||||
}
|
||||
.editor-content-area details[contenteditable="false"] > details * {
|
||||
pointer-events: none;
|
||||
}
|
||||
.editor-content-area details summary {
|
||||
caret-color: transparent;
|
||||
}
|
||||
.editor-content-area details.selected {
|
||||
outline: 1px dashed var(--editor-color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.editor-table-marker {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user