Skip to content

Lexical Editor: Further fixes #5627

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 28, 2025
2 changes: 1 addition & 1 deletion lang/en/entities.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@
'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
'pages_edit_set_changelog' => 'Set Changelog',
'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
'pages_edit_enter_changelog' => 'Enter Changelog',
Expand Down
14 changes: 13 additions & 1 deletion resources/js/wysiwyg/lexical/core/LexicalSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import invariant from 'lexical/shared/invariant';
import {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$createTextNode, $getNearestNodeFromDOMNode,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
Expand Down Expand Up @@ -63,6 +63,7 @@ import {
toggleTextFormatType,
} from './LexicalUtils';
import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
import {$selectSingleNode} from "../../utils/selection";

export type TextPointType = {
_selection: BaseSelection;
Expand Down Expand Up @@ -2568,6 +2569,17 @@ export function updateDOMSelection(
}

if (!$isRangeSelection(nextSelection)) {

// If the DOM selection enters a decorator node update the selection to a single node selection
if (activeElement !== null && domSelection.isCollapsed && focusDOMNode instanceof Node) {
const node = $getNearestNodeFromDOMNode(focusDOMNode);
if ($isDecoratorNode(node)) {
domSelection.removeAllRanges();
$selectSingleNode(node);
return;
}
}

// We don't remove selection if the prevSelection is null because
// of editor.setRootElement(). If this occurs on init when the
// editor is already focused, then this can cause the editor to
Expand Down
33 changes: 33 additions & 0 deletions resources/js/wysiwyg/lexical/core/nodes/common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {sizeToPixels} from "../../../utils/dom";
import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
import {elem} from "../../../../services/dom";

export type CommonBlockAlignment = 'left' | 'right' | 'center' | 'justify' | '';
const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'justify'];
Expand Down Expand Up @@ -82,6 +83,38 @@ export function commonPropertiesDifferent(nodeA: CommonBlockInterface, nodeB: Co
nodeA.__dir !== nodeB.__dir;
}

export function applyCommonPropertyChanges(prevNode: CommonBlockInterface, currentNode: CommonBlockInterface, element: HTMLElement): void {
if (prevNode.__id !== currentNode.__id) {
element.setAttribute('id', currentNode.__id);
}

if (prevNode.__alignment !== currentNode.__alignment) {
for (const alignment of validAlignments) {
element.classList.remove('align-' + alignment);
}

if (currentNode.__alignment) {
element.classList.add('align-' + currentNode.__alignment);
}
}

if (prevNode.__inset !== currentNode.__inset) {
if (currentNode.__inset) {
element.style.paddingLeft = `${currentNode.__inset}px`;
} else {
element.style.removeProperty('paddingLeft');
}
}

if (prevNode.__dir !== currentNode.__dir) {
if (currentNode.__dir) {
element.dir = currentNode.__dir;
} else {
element.removeAttribute('dir');
}
}
}

export function updateElementWithCommonBlockProps(element: HTMLElement, node: CommonBlockInterface): void {
if (node.__id) {
element.setAttribute('id', node.__id);
Expand Down
46 changes: 29 additions & 17 deletions resources/js/wysiwyg/lexical/table/LexicalTableNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ import {TableDOMCell, TableDOMTable} from './LexicalTableObserver';
import {getTable} from './LexicalTableSelectionHelpers';
import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
import {
applyCommonPropertyChanges,
commonPropertiesDifferent, deserializeCommonBlockNode,
setCommonBlockPropsFromElement,
updateElementWithCommonBlockProps
} from "lexical/nodes/common";
import {el, extractStyleMapFromElement, StyleMap} from "../../utils/dom";
import {getTableColumnWidths} from "../../utils/tables";
import {buildColgroupFromTableWidths, getTableColumnWidths} from "../../utils/tables";

export type SerializedTableNode = Spread<{
colWidths: string[];
Expand All @@ -54,7 +55,7 @@ export class TableNode extends CommonBlockNode {
static clone(node: TableNode): TableNode {
const newNode = new TableNode(node.__key);
copyCommonBlockProperties(node, newNode);
newNode.__colWidths = node.__colWidths;
newNode.__colWidths = [...node.__colWidths];
newNode.__styles = new Map(node.__styles);
return newNode;
}
Expand Down Expand Up @@ -98,15 +99,8 @@ export class TableNode extends CommonBlockNode {
updateElementWithCommonBlockProps(tableElement, this);

const colWidths = this.getColWidths();
if (colWidths.length > 0) {
const colgroup = el('colgroup');
for (const width of colWidths) {
const col = el('col');
if (width) {
col.style.width = width;
}
colgroup.append(col);
}
const colgroup = buildColgroupFromTableWidths(colWidths);
if (colgroup) {
tableElement.append(colgroup);
}

Expand All @@ -117,11 +111,29 @@ export class TableNode extends CommonBlockNode {
return tableElement;
}

updateDOM(_prevNode: TableNode): boolean {
return commonPropertiesDifferent(_prevNode, this)
|| this.__colWidths.join(':') !== _prevNode.__colWidths.join(':')
|| this.__styles.size !== _prevNode.__styles.size
|| (Array.from(this.__styles.values()).join(':') !== (Array.from(_prevNode.__styles.values()).join(':')));
updateDOM(_prevNode: TableNode, dom: HTMLElement): boolean {
applyCommonPropertyChanges(_prevNode, this, dom);

if (this.__colWidths.join(':') !== _prevNode.__colWidths.join(':')) {
const existingColGroup = Array.from(dom.children).find(child => child.nodeName === 'COLGROUP');
const newColGroup = buildColgroupFromTableWidths(this.__colWidths);
if (existingColGroup) {
existingColGroup.remove();
}

if (newColGroup) {
dom.prepend(newColGroup);
}
}

if (Array.from(this.__styles.values()).join(':') !== Array.from(_prevNode.__styles.values()).join(':')) {
dom.style.cssText = '';
for (const [name, value] of this.__styles.entries()) {
dom.style.setProperty(name, value);
}
}

return false;
}

exportDOM(editor: LexicalEditor): DOMExportOutput {
Expand Down Expand Up @@ -169,7 +181,7 @@ export class TableNode extends CommonBlockNode {

getColWidths(): string[] {
const self = this.getLatest();
return self.__colWidths;
return [...self.__colWidths];
}

getStyles(): StyleMap {
Expand Down
14 changes: 10 additions & 4 deletions resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {TableDOMTable, TableObserver} from './LexicalTableObserver';
import {$isTableRowNode} from './LexicalTableRowNode';
import {$isTableSelection} from './LexicalTableSelection';
import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
import {$selectOrCreateAdjacent} from "../../utils/nodes";

const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';

Expand Down Expand Up @@ -915,9 +916,14 @@ export function getTable(tableElement: HTMLElement): TableDOMTable {
domRows.length = 0;

while (currentNode != null) {
const nodeMame = currentNode.nodeName;
const nodeName = currentNode.nodeName;

if (nodeMame === 'TD' || nodeMame === 'TH') {
if (nodeName === 'COLGROUP' || nodeName === 'CAPTION') {
currentNode = currentNode.nextSibling;
continue;
}

if (nodeName === 'TD' || nodeName === 'TH') {
const elem = currentNode as HTMLElement;
const cell = {
elem,
Expand Down Expand Up @@ -1108,7 +1114,7 @@ const selectTableNodeInDirection = (
false,
);
} else {
tableNode.selectPrevious();
$selectOrCreateAdjacent(tableNode, false);
}

return true;
Expand All @@ -1120,7 +1126,7 @@ const selectTableNodeInDirection = (
true,
);
} else {
tableNode.selectNext();
$selectOrCreateAdjacent(tableNode, true);
}

return true;
Expand Down
3 changes: 2 additions & 1 deletion resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
TableRowNode,
} from './LexicalTableRowNode';
import {$isTableSelection} from './LexicalTableSelection';
import {$isCaptionNode} from "@lexical/table/LexicalCaptionNode";

export function $createTableNodeWithDimensions(
rowCount: number,
Expand Down Expand Up @@ -779,7 +780,7 @@ export function $computeTableMapSkipCellCheck(
return tableMap[row] === undefined || tableMap[row][column] === undefined;
}

const gridChildren = grid.getChildren();
const gridChildren = grid.getChildren().filter(node => !$isCaptionNode(node));
for (let i = 0; i < gridChildren.length; i++) {
const row = gridChildren[i];
invariant(
Expand Down
20 changes: 19 additions & 1 deletion resources/js/wysiwyg/services/drop-paste-handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@ function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolea
return handled;
}

function handleImageLinkInsert(data: DataTransfer, context: EditorUiContext): boolean {
const regex = /https?:\/\/([^?#]*?)\.(png|jpeg|jpg|gif|webp|bmp|avif)/i
const text = data.getData('text/plain');
if (text && regex.test(text)) {
context.editor.update(() => {
const image = $createImageNode(text);
$insertNodes([image]);
image.select();
});
return true;
}

return false;
}

function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean {
const editor = context.editor;
return (event: DragEvent): boolean => {
Expand Down Expand Up @@ -138,7 +153,10 @@ function createPasteListener(context: EditorUiContext): (event: ClipboardEvent)
return false;
}

const handled = handleMediaInsert(event.clipboardData, context);
const handled =
handleImageLinkInsert(event.clipboardData, context) ||
handleMediaInsert(event.clipboardData, context);

if (handled) {
event.preventDefault();
}
Expand Down
31 changes: 12 additions & 19 deletions resources/js/wysiwyg/services/keyboard-handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ import {
import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {getLastSelection} from "../utils/selection";
import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes";
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";

function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
if (nodes.length === 1) {
const node = nodes[0];
if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node)) {
if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node)) {
return true;
}
}
Expand All @@ -46,16 +47,21 @@ function deleteSingleSelectedNode(editor: LexicalEditor) {
* Insert a new empty node before/after the selection if the selection contains a single
* selected node (like image, media etc...).
*/
function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
function insertAdjacentToSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
const selectionNodes = getLastSelection(editor)?.getNodes() || [];
if (isSingleSelectedNode(selectionNodes)) {
const node = selectionNodes[0];
const nearestBlock = $getNearestNodeBlockParent(node) || node;
const insertBefore = event?.shiftKey === true;
if (nearestBlock) {
requestAnimationFrame(() => {
editor.update(() => {
const newParagraph = $createParagraphNode();
nearestBlock.insertAfter(newParagraph);
if (insertBefore) {
nearestBlock.insertBefore(newParagraph);
} else {
nearestBlock.insertAfter(newParagraph);
}
newParagraph.select();
});
});
Expand All @@ -74,22 +80,9 @@ function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event:
}

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();
$selectOrCreateAdjacent(node, after);
});

return true;
Expand Down Expand Up @@ -219,7 +212,7 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
}, COMMAND_PRIORITY_LOW);

const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
return insertAfterSingleSelectedNode(context.editor, event)
return insertAdjacentToSingleSelectedNode(context.editor, event)
|| moveAfterDetailsOnEmptyLine(context.editor, event);
}, COMMAND_PRIORITY_LOW);

Expand Down
14 changes: 10 additions & 4 deletions resources/js/wysiwyg/ui/decorators/diagram.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {EditorDecorator} from "../framework/decorator";
import {EditorUiContext} from "../framework/core";
import {BaseSelection} from "lexical";
import {BaseSelection, CLICK_COMMAND, COMMAND_PRIORITY_NORMAL} from "lexical";
import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection";
import {$openDrawingEditorForNode} from "../../utils/diagrams";
Expand All @@ -12,11 +12,17 @@ export class DiagramDecorator extends EditorDecorator {
setup(context: EditorUiContext, element: HTMLElement) {
const diagramNode = this.getNode();
element.classList.add('editor-diagram');
element.addEventListener('click', event => {

context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => {
if (!element.contains(event.target as HTMLElement)) {
return false;
}

context.editor.update(() => {
$selectSingleNode(this.getNode());
})
});
});
return true;
}, COMMAND_PRIORITY_NORMAL);

element.addEventListener('dblclick', event => {
context.editor.getEditorState().read(() => {
Expand Down
Loading
Loading