Merge pull request #5627 from BookStackApp/lexical_20250525

Lexical Editor: Further fixes
This commit is contained in:
Dan Brown 2025-05-28 22:53:03 +01:00 committed by GitHub
commit 68df43e5a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 294 additions and 96 deletions

View File

@ -248,7 +248,7 @@ return [
'pages_edit_switch_to_markdown_stable' => '(Stable Content)', 'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor', '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' => '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_set_changelog' => 'Set Changelog',
'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made', 'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
'pages_edit_enter_changelog' => 'Enter Changelog', 'pages_edit_enter_changelog' => 'Enter Changelog',

View File

@ -17,7 +17,7 @@ import invariant from 'lexical/shared/invariant';
import { import {
$createLineBreakNode, $createLineBreakNode,
$createParagraphNode, $createParagraphNode,
$createTextNode, $createTextNode, $getNearestNodeFromDOMNode,
$isDecoratorNode, $isDecoratorNode,
$isElementNode, $isElementNode,
$isLineBreakNode, $isLineBreakNode,
@ -63,6 +63,7 @@ import {
toggleTextFormatType, toggleTextFormatType,
} from './LexicalUtils'; } from './LexicalUtils';
import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode'; import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
import {$selectSingleNode} from "../../utils/selection";
export type TextPointType = { export type TextPointType = {
_selection: BaseSelection; _selection: BaseSelection;
@ -2568,6 +2569,17 @@ export function updateDOMSelection(
} }
if (!$isRangeSelection(nextSelection)) { 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 // We don't remove selection if the prevSelection is null because
// of editor.setRootElement(). If this occurs on init when the // of editor.setRootElement(). If this occurs on init when the
// editor is already focused, then this can cause the editor to // editor is already focused, then this can cause the editor to

View File

@ -1,5 +1,6 @@
import {sizeToPixels} from "../../../utils/dom"; import {sizeToPixels} from "../../../utils/dom";
import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
import {elem} from "../../../../services/dom";
export type CommonBlockAlignment = 'left' | 'right' | 'center' | 'justify' | ''; export type CommonBlockAlignment = 'left' | 'right' | 'center' | 'justify' | '';
const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'justify']; const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'justify'];
@ -82,6 +83,38 @@ export function commonPropertiesDifferent(nodeA: CommonBlockInterface, nodeB: Co
nodeA.__dir !== nodeB.__dir; 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 { export function updateElementWithCommonBlockProps(element: HTMLElement, node: CommonBlockInterface): void {
if (node.__id) { if (node.__id) {
element.setAttribute('id', node.__id); element.setAttribute('id', node.__id);

View File

@ -30,12 +30,13 @@ import {TableDOMCell, TableDOMTable} from './LexicalTableObserver';
import {getTable} from './LexicalTableSelectionHelpers'; import {getTable} from './LexicalTableSelectionHelpers';
import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
import { import {
applyCommonPropertyChanges,
commonPropertiesDifferent, deserializeCommonBlockNode, commonPropertiesDifferent, deserializeCommonBlockNode,
setCommonBlockPropsFromElement, setCommonBlockPropsFromElement,
updateElementWithCommonBlockProps updateElementWithCommonBlockProps
} from "lexical/nodes/common"; } from "lexical/nodes/common";
import {el, extractStyleMapFromElement, StyleMap} from "../../utils/dom"; import {el, extractStyleMapFromElement, StyleMap} from "../../utils/dom";
import {getTableColumnWidths} from "../../utils/tables"; import {buildColgroupFromTableWidths, getTableColumnWidths} from "../../utils/tables";
export type SerializedTableNode = Spread<{ export type SerializedTableNode = Spread<{
colWidths: string[]; colWidths: string[];
@ -54,7 +55,7 @@ export class TableNode extends CommonBlockNode {
static clone(node: TableNode): TableNode { static clone(node: TableNode): TableNode {
const newNode = new TableNode(node.__key); const newNode = new TableNode(node.__key);
copyCommonBlockProperties(node, newNode); copyCommonBlockProperties(node, newNode);
newNode.__colWidths = node.__colWidths; newNode.__colWidths = [...node.__colWidths];
newNode.__styles = new Map(node.__styles); newNode.__styles = new Map(node.__styles);
return newNode; return newNode;
} }
@ -98,15 +99,8 @@ export class TableNode extends CommonBlockNode {
updateElementWithCommonBlockProps(tableElement, this); updateElementWithCommonBlockProps(tableElement, this);
const colWidths = this.getColWidths(); const colWidths = this.getColWidths();
if (colWidths.length > 0) { const colgroup = buildColgroupFromTableWidths(colWidths);
const colgroup = el('colgroup'); if (colgroup) {
for (const width of colWidths) {
const col = el('col');
if (width) {
col.style.width = width;
}
colgroup.append(col);
}
tableElement.append(colgroup); tableElement.append(colgroup);
} }
@ -117,11 +111,29 @@ export class TableNode extends CommonBlockNode {
return tableElement; return tableElement;
} }
updateDOM(_prevNode: TableNode): boolean { updateDOM(_prevNode: TableNode, dom: HTMLElement): boolean {
return commonPropertiesDifferent(_prevNode, this) applyCommonPropertyChanges(_prevNode, this, dom);
|| this.__colWidths.join(':') !== _prevNode.__colWidths.join(':')
|| this.__styles.size !== _prevNode.__styles.size if (this.__colWidths.join(':') !== _prevNode.__colWidths.join(':')) {
|| (Array.from(this.__styles.values()).join(':') !== (Array.from(_prevNode.__styles.values()).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 { exportDOM(editor: LexicalEditor): DOMExportOutput {
@ -169,7 +181,7 @@ export class TableNode extends CommonBlockNode {
getColWidths(): string[] { getColWidths(): string[] {
const self = this.getLatest(); const self = this.getLatest();
return self.__colWidths; return [...self.__colWidths];
} }
getStyles(): StyleMap { getStyles(): StyleMap {

View File

@ -71,6 +71,7 @@ import {TableDOMTable, TableObserver} from './LexicalTableObserver';
import {$isTableRowNode} from './LexicalTableRowNode'; import {$isTableRowNode} from './LexicalTableRowNode';
import {$isTableSelection} from './LexicalTableSelection'; import {$isTableSelection} from './LexicalTableSelection';
import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils'; import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
import {$selectOrCreateAdjacent} from "../../utils/nodes";
const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection'; const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
@ -915,9 +916,14 @@ export function getTable(tableElement: HTMLElement): TableDOMTable {
domRows.length = 0; domRows.length = 0;
while (currentNode != null) { 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 elem = currentNode as HTMLElement;
const cell = { const cell = {
elem, elem,
@ -1108,7 +1114,7 @@ const selectTableNodeInDirection = (
false, false,
); );
} else { } else {
tableNode.selectPrevious(); $selectOrCreateAdjacent(tableNode, false);
} }
return true; return true;
@ -1120,7 +1126,7 @@ const selectTableNodeInDirection = (
true, true,
); );
} else { } else {
tableNode.selectNext(); $selectOrCreateAdjacent(tableNode, true);
} }
return true; return true;

View File

@ -35,6 +35,7 @@ import {
TableRowNode, TableRowNode,
} from './LexicalTableRowNode'; } from './LexicalTableRowNode';
import {$isTableSelection} from './LexicalTableSelection'; import {$isTableSelection} from './LexicalTableSelection';
import {$isCaptionNode} from "@lexical/table/LexicalCaptionNode";
export function $createTableNodeWithDimensions( export function $createTableNodeWithDimensions(
rowCount: number, rowCount: number,
@ -779,7 +780,7 @@ export function $computeTableMapSkipCellCheck(
return tableMap[row] === undefined || tableMap[row][column] === undefined; 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++) { for (let i = 0; i < gridChildren.length; i++) {
const row = gridChildren[i]; const row = gridChildren[i];
invariant( invariant(

View File

@ -95,6 +95,21 @@ function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolea
return handled; 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 { function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean {
const editor = context.editor; const editor = context.editor;
return (event: DragEvent): boolean => { return (event: DragEvent): boolean => {
@ -138,7 +153,10 @@ function createPasteListener(context: EditorUiContext): (event: ClipboardEvent)
return false; return false;
} }
const handled = handleMediaInsert(event.clipboardData, context); const handled =
handleImageLinkInsert(event.clipboardData, context) ||
handleMediaInsert(event.clipboardData, context);
if (handled) { if (handled) {
event.preventDefault(); event.preventDefault();
} }

View File

@ -13,15 +13,16 @@ import {
import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {getLastSelection} from "../utils/selection"; import {getLastSelection} from "../utils/selection";
import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes"; import {$getNearestNodeBlockParent, $getParentOfType, $selectOrCreateAdjacent} from "../utils/nodes";
import {$setInsetForSelection} from "../utils/lists"; import {$setInsetForSelection} from "../utils/lists";
import {$isListItemNode} from "@lexical/list"; import {$isListItemNode} from "@lexical/list";
import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {$isDiagramNode} from "../utils/diagrams";
function isSingleSelectedNode(nodes: LexicalNode[]): boolean { function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
if (nodes.length === 1) { if (nodes.length === 1) {
const node = nodes[0]; const node = nodes[0];
if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node)) { if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node)) {
return true; return true;
} }
} }
@ -46,16 +47,21 @@ function deleteSingleSelectedNode(editor: LexicalEditor) {
* Insert a new empty node before/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 insertAdjacentToSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
const selectionNodes = getLastSelection(editor)?.getNodes() || []; const selectionNodes = getLastSelection(editor)?.getNodes() || [];
if (isSingleSelectedNode(selectionNodes)) { if (isSingleSelectedNode(selectionNodes)) {
const node = selectionNodes[0]; const node = selectionNodes[0];
const nearestBlock = $getNearestNodeBlockParent(node) || node; const nearestBlock = $getNearestNodeBlockParent(node) || node;
const insertBefore = event?.shiftKey === true;
if (nearestBlock) { if (nearestBlock) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
editor.update(() => { editor.update(() => {
const newParagraph = $createParagraphNode(); const newParagraph = $createParagraphNode();
if (insertBefore) {
nearestBlock.insertBefore(newParagraph);
} else {
nearestBlock.insertAfter(newParagraph); nearestBlock.insertAfter(newParagraph);
}
newParagraph.select(); newParagraph.select();
}); });
}); });
@ -74,22 +80,9 @@ function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event:
} }
event?.preventDefault(); event?.preventDefault();
const node = selectionNodes[0]; const node = selectionNodes[0];
const nearestBlock = $getNearestNodeBlockParent(node) || node;
let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling();
editor.update(() => { editor.update(() => {
if (!target) { $selectOrCreateAdjacent(node, after);
target = $createParagraphNode();
if (after) {
nearestBlock.insertAfter(target)
} else {
nearestBlock.insertBefore(target);
}
}
target.selectStart();
}); });
return true; return true;
@ -219,7 +212,7 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
}, COMMAND_PRIORITY_LOW); }, COMMAND_PRIORITY_LOW);
const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => { const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
return insertAfterSingleSelectedNode(context.editor, event) return insertAdjacentToSingleSelectedNode(context.editor, event)
|| moveAfterDetailsOnEmptyLine(context.editor, event); || moveAfterDetailsOnEmptyLine(context.editor, event);
}, COMMAND_PRIORITY_LOW); }, COMMAND_PRIORITY_LOW);

View File

@ -1,6 +1,6 @@
import {EditorDecorator} from "../framework/decorator"; import {EditorDecorator} from "../framework/decorator";
import {EditorUiContext} from "../framework/core"; 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 {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection";
import {$openDrawingEditorForNode} from "../../utils/diagrams"; import {$openDrawingEditorForNode} from "../../utils/diagrams";
@ -12,11 +12,17 @@ export class DiagramDecorator extends EditorDecorator {
setup(context: EditorUiContext, element: HTMLElement) { setup(context: EditorUiContext, element: HTMLElement) {
const diagramNode = this.getNode(); const diagramNode = this.getNode();
element.classList.add('editor-diagram'); 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(() => { context.editor.update(() => {
$selectSingleNode(this.getNode()); $selectSingleNode(this.getNode());
})
}); });
return true;
}, COMMAND_PRIORITY_NORMAL);
element.addEventListener('dblclick', event => { element.addEventListener('dblclick', event => {
context.editor.getEditorState().read(() => { context.editor.getEditorState().read(() => {

View File

@ -1,4 +1,3 @@
import {handleDropdown} from "../helpers/dropdowns";
import {EditorContainerUiElement, EditorUiElement} from "../core"; import {EditorContainerUiElement, EditorUiElement} from "../core";
import {EditorBasicButtonDefinition, EditorButton} from "../buttons"; import {EditorBasicButtonDefinition, EditorButton} from "../buttons";
import {el} from "../../../utils/dom"; import {el} from "../../../utils/dom";
@ -8,6 +7,7 @@ export type EditorDropdownButtonOptions = {
showOnHover?: boolean; showOnHover?: boolean;
direction?: 'vertical'|'horizontal'; direction?: 'vertical'|'horizontal';
showAside?: boolean; showAside?: boolean;
hideOnAction?: boolean;
button: EditorBasicButtonDefinition|EditorButton; button: EditorBasicButtonDefinition|EditorButton;
}; };
@ -15,6 +15,7 @@ const defaultOptions: EditorDropdownButtonOptions = {
showOnHover: false, showOnHover: false,
direction: 'horizontal', direction: 'horizontal',
showAside: undefined, showAside: undefined,
hideOnAction: true,
button: {label: 'Menu'}, button: {label: 'Menu'},
} }
@ -40,7 +41,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
}, },
isActive: () => { isActive: () => {
return this.open; return this.open;
} },
}); });
} }
@ -65,7 +66,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
class: 'editor-dropdown-menu-container', class: 'editor-dropdown-menu-container',
}, [button, menu]); }, [button, menu]);
handleDropdown({toggle: button, menu : menu, this.getContext().manager.dropdowns.handle({toggle: button, menu : menu,
showOnHover: this.options.showOnHover, showOnHover: this.options.showOnHover,
showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'), showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'),
onOpen : () => { onOpen : () => {
@ -76,6 +77,12 @@ export class EditorDropdownButton extends EditorContainerUiElement {
this.getContext().manager.triggerStateUpdateForElement(this.button); this.getContext().manager.triggerStateUpdateForElement(this.button);
}}); }});
if (this.options.hideOnAction) {
this.onEvent('button-action', () => {
this.getContext().manager.dropdowns.closeAll();
}, wrapper);
}
return wrapper; return wrapper;
} }
} }

View File

@ -1,6 +1,5 @@
import {EditorUiStateUpdate, EditorContainerUiElement} from "../core"; import {EditorUiStateUpdate, EditorContainerUiElement} from "../core";
import {EditorButton} from "../buttons"; import {EditorButton} from "../buttons";
import {handleDropdown} from "../helpers/dropdowns";
import {el} from "../../../utils/dom"; import {el} from "../../../utils/dom";
export class EditorFormatMenu extends EditorContainerUiElement { export class EditorFormatMenu extends EditorContainerUiElement {
@ -20,7 +19,11 @@ export class EditorFormatMenu extends EditorContainerUiElement {
class: 'editor-format-menu editor-dropdown-menu-container', class: 'editor-format-menu editor-dropdown-menu-container',
}, [toggle, menu]); }, [toggle, menu]);
handleDropdown({toggle : toggle, menu : menu}); this.getContext().manager.dropdowns.handle({toggle : toggle, menu : menu});
this.onEvent('button-action', () => {
this.getContext().manager.dropdowns.closeAll();
}, wrapper);
return wrapper; return wrapper;
} }

View File

@ -19,6 +19,7 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
label: 'More', label: 'More',
icon: moreHorizontal, icon: moreHorizontal,
}, },
hideOnAction: false,
}, []); }, []);
this.addChildren(this.overflowButton); this.addChildren(this.overflowButton);
} }

View File

@ -10,7 +10,12 @@ export interface EditorBasicButtonDefinition {
} }
export interface EditorButtonDefinition extends EditorBasicButtonDefinition { export interface EditorButtonDefinition extends EditorBasicButtonDefinition {
action: (context: EditorUiContext, button: EditorButton) => void; /**
* The action to perform when the button is used.
* This can return false to indicate that the completion of the action should
* NOT be communicated to parent UI elements, which is what occurs by default.
*/
action: (context: EditorUiContext, button: EditorButton) => void|false|Promise<void|boolean>;
isActive: (selection: BaseSelection|null, context: EditorUiContext) => boolean; isActive: (selection: BaseSelection|null, context: EditorUiContext) => boolean;
isDisabled?: (selection: BaseSelection|null, context: EditorUiContext) => boolean; isDisabled?: (selection: BaseSelection|null, context: EditorUiContext) => boolean;
setup?: (context: EditorUiContext, button: EditorButton) => void; setup?: (context: EditorUiContext, button: EditorButton) => void;
@ -78,7 +83,16 @@ export class EditorButton extends EditorUiElement {
} }
protected onClick() { protected onClick() {
this.definition.action(this.getContext(), this); const result = this.definition.action(this.getContext(), this);
if (result instanceof Promise) {
result.then(result => {
if (result === false) {
this.emitEvent('button-action');
}
});
} else if (result !== false) {
this.emitEvent('button-action');
}
} }
protected updateActiveState(selection: BaseSelection|null) { protected updateActiveState(selection: BaseSelection|null) {

View File

@ -67,6 +67,21 @@ export abstract class EditorUiElement {
updateState(state: EditorUiStateUpdate): void { updateState(state: EditorUiStateUpdate): void {
return; return;
} }
emitEvent(name: string, data: object = {}): void {
if (this.dom) {
this.dom.dispatchEvent(new CustomEvent('editor::' + name, {detail: data, bubbles: true}));
}
}
onEvent(name: string, callback: (data: object) => any, listenTarget: HTMLElement|null = null): void {
const target = listenTarget || this.dom;
if (target) {
target.addEventListener('editor::' + name, ((event: CustomEvent) => {
callback(event.detail);
}) as EventListener);
}
}
} }
export class EditorContainerUiElement extends EditorUiElement { export class EditorContainerUiElement extends EditorUiElement {

View File

@ -34,57 +34,97 @@ function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean
} }
} }
export function handleDropdown(options: HandleDropdownParams) { export class DropDownManager {
const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options;
let clickListener: Function|null = null;
const hide = () => { protected dropdownOptions: WeakMap<HTMLElement, HandleDropdownParams> = new WeakMap();
protected openDropdowns: Set<HTMLElement> = new Set();
constructor() {
this.onMenuMouseOver = this.onMenuMouseOver.bind(this);
window.addEventListener('click', (event: MouseEvent) => {
const target = event.target as HTMLElement;
this.closeAllNotContainingElement(target);
});
}
protected closeAllNotContainingElement(element: HTMLElement): void {
for (const menu of this.openDropdowns) {
if (!menu.parentElement?.contains(element)) {
this.closeDropdown(menu);
}
}
}
protected onMenuMouseOver(event: MouseEvent): void {
const target = event.target as HTMLElement;
this.closeAllNotContainingElement(target);
}
/**
* Close all open dropdowns.
*/
public closeAll(): void {
for (const menu of this.openDropdowns) {
this.closeDropdown(menu);
}
}
protected closeDropdown(menu: HTMLElement): void {
menu.hidden = true; menu.hidden = true;
menu.style.removeProperty('position'); menu.style.removeProperty('position');
menu.style.removeProperty('left'); menu.style.removeProperty('left');
menu.style.removeProperty('top'); menu.style.removeProperty('top');
if (clickListener) {
window.removeEventListener('click', clickListener as EventListener); this.openDropdowns.delete(menu);
} menu.removeEventListener('mouseover', this.onMenuMouseOver);
const onClose = this.getOptions(menu).onClose;
if (onClose) { if (onClose) {
onClose(); onClose();
} }
}; }
const show = () => { protected openDropdown(menu: HTMLElement): void {
const {toggle, showAside, onOpen} = this.getOptions(menu);
menu.hidden = false menu.hidden = false
positionMenu(menu, toggle, Boolean(showAside)); positionMenu(menu, toggle, Boolean(showAside));
clickListener = (event: MouseEvent) => {
if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) { this.openDropdowns.add(menu);
hide(); menu.addEventListener('mouseover', this.onMenuMouseOver);
}
}
window.addEventListener('click', clickListener as EventListener);
if (onOpen) { if (onOpen) {
onOpen(); onOpen();
} }
}; }
protected getOptions(menu: HTMLElement): HandleDropdownParams {
const options = this.dropdownOptions.get(menu);
if (!options) {
throw new Error(`Can't find options for dropdown menu`);
}
return options;
}
/**
* Add handling for a new dropdown.
*/
public handle(options: HandleDropdownParams) {
const {menu, toggle, showOnHover} = options;
// Register dropdown
this.dropdownOptions.set(menu, options);
// Configure default events
const toggleShowing = (event: MouseEvent) => { const toggleShowing = (event: MouseEvent) => {
menu.hasAttribute('hidden') ? show() : hide(); menu.hasAttribute('hidden') ? this.openDropdown(menu) : this.closeDropdown(menu);
}; };
toggle.addEventListener('click', toggleShowing); toggle.addEventListener('click', toggleShowing);
if (showOnHover) { if (showOnHover) {
toggle.addEventListener('mouseenter', toggleShowing); toggle.addEventListener('mouseenter', () => {
} this.openDropdown(menu);
menu.parentElement?.addEventListener('mouseleave', (event: MouseEvent) => {
// Prevent mouseleave hiding if withing the same bounds of the toggle.
// Avoids hiding in the event the mouse is interrupted by a high z-index
// item like a browser scrollbar.
const toggleBounds = toggle.getBoundingClientRect();
const withinX = event.clientX <= toggleBounds.right && event.clientX >= toggleBounds.left;
const withinY = event.clientY <= toggleBounds.bottom && event.clientY >= toggleBounds.top;
const withinToggle = withinX && withinY;
if (!withinToggle) {
hide();
}
}); });
}
}
} }

View File

@ -56,7 +56,7 @@ class TableSelectionHandler {
tableNode, tableNode,
tableElement, tableElement,
this.editor, this.editor,
false, true,
); );
this.tableSelections.set(nodeKey, tableSelection); this.tableSelections.set(nodeKey, tableSelection);
} }

View File

@ -6,6 +6,7 @@ import {DecoratorListener} from "lexical/LexicalEditor";
import type {NodeKey} from "lexical/LexicalNode"; import type {NodeKey} from "lexical/LexicalNode";
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
import {getLastSelection, setLastSelection} from "../../utils/selection"; import {getLastSelection, setLastSelection} from "../../utils/selection";
import {DropDownManager} from "./helpers/dropdowns";
export type SelectionChangeHandler = (selection: BaseSelection|null) => void; export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
@ -21,6 +22,8 @@ export class EditorUIManager {
protected activeContextToolbars: EditorContextToolbar[] = []; protected activeContextToolbars: EditorContextToolbar[] = [];
protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set(); protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
public dropdowns: DropDownManager = new DropDownManager();
setContext(context: EditorUiContext) { setContext(context: EditorUiContext) {
this.context = context; this.context = context;
this.setupEventListeners(context); this.setupEventListeners(context);
@ -241,6 +244,7 @@ export class EditorUIManager {
if (selectionChange) { if (selectionChange) {
editor.update(() => { editor.update(() => {
const selection = $getSelection(); const selection = $getSelection();
// console.log('manager::selection', selection);
this.triggerStateUpdate({ this.triggerStateUpdate({
editor, selection, editor, selection,
}); });

View File

@ -6,7 +6,7 @@ import {
$isTextNode, $isTextNode,
ElementNode, ElementNode,
LexicalEditor, LexicalEditor,
LexicalNode LexicalNode, RangeSelection
} from "lexical"; } from "lexical";
import {LexicalNodeMatcher} from "../nodes"; import {LexicalNodeMatcher} from "../nodes";
import {$generateNodesFromDOM} from "@lexical/html"; import {$generateNodesFromDOM} from "@lexical/html";
@ -118,6 +118,22 @@ export function $sortNodes(nodes: LexicalNode[]): LexicalNode[] {
return sorted; return sorted;
} }
export function $selectOrCreateAdjacent(node: LexicalNode, after: boolean): RangeSelection {
const nearestBlock = $getNearestNodeBlockParent(node) || node;
let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling()
if (!target) {
target = $createParagraphNode();
if (after) {
nearestBlock.insertAfter(target)
} else {
nearestBlock.insertBefore(target);
}
}
return after ? target.selectStart() : target.selectEnd();
}
export function nodeHasAlignment(node: object): node is NodeHasAlignment { export function nodeHasAlignment(node: object): node is NodeHasAlignment {
return '__alignment' in node; return '__alignment' in node;
} }

View File

@ -9,7 +9,7 @@ import {
} from "@lexical/table"; } from "@lexical/table";
import {$getParentOfType} from "./nodes"; import {$getParentOfType} from "./nodes";
import {$getNodeFromSelection} from "./selection"; import {$getNodeFromSelection} from "./selection";
import {formatSizeValue} from "./dom"; import {el, formatSizeValue} from "./dom";
import {TableMap} from "./table-map"; import {TableMap} from "./table-map";
function $getTableFromCell(cell: TableCellNode): TableNode|null { function $getTableFromCell(cell: TableCellNode): TableNode|null {
@ -140,6 +140,23 @@ export function $getTableCellColumnWidth(editor: LexicalEditor, cell: TableCellN
return (widths.length > index) ? widths[index] : ''; return (widths.length > index) ? widths[index] : '';
} }
export function buildColgroupFromTableWidths(colWidths: string[]): HTMLElement|null {
if (colWidths.length === 0) {
return null
}
const colgroup = el('colgroup');
for (const width of colWidths) {
const col = el('col');
if (width) {
col.style.width = width;
}
colgroup.append(col);
}
return colgroup;
}
export function $getTableCellsFromSelection(selection: BaseSelection|null): TableCellNode[] { export function $getTableCellsFromSelection(selection: BaseSelection|null): TableCellNode[] {
if ($isTableSelection(selection)) { if ($isTableSelection(selection)) {
const nodes = selection.getNodes(); const nodes = selection.getNodes();

View File

@ -422,7 +422,7 @@ body.editor-is-fullscreen {
.editor-table-marker { .editor-table-marker {
position: fixed; position: fixed;
background-color: var(--editor-color-primary); background-color: var(--editor-color-primary);
z-index: 99; z-index: 3;
user-select: none; user-select: none;
opacity: 0; opacity: 0;
&:hover, &.active { &:hover, &.active {

View File

@ -32,7 +32,7 @@
<select name="setting-app-editor" id="setting-app-editor"> <select name="setting-app-editor" id="setting-app-editor">
<option @if(setting('app-editor') === 'wysiwyg') selected @endif value="wysiwyg">WYSIWYG</option> <option @if(setting('app-editor') === 'wysiwyg') selected @endif value="wysiwyg">WYSIWYG</option>
<option @if(setting('app-editor') === 'markdown') selected @endif value="markdown">Markdown</option> <option @if(setting('app-editor') === 'markdown') selected @endif value="markdown">Markdown</option>
<option @if(setting('app-editor') === 'wysiwyg2024') selected @endif value="wysiwyg2024">New WYSIWYG (alpha testing)</option> <option @if(setting('app-editor') === 'wysiwyg2024') selected @endif value="wysiwyg2024">New WYSIWYG (beta testing)</option>
</select> </select>
</div> </div>
</div> </div>