Merge pull request #5627 from BookStackApp/lexical_20250525
Lexical Editor: Further fixes
This commit is contained in:
		
						commit
						68df43e5a8
					
				| 
						 | 
				
			
			@ -248,7 +248,7 @@ return [
 | 
			
		|||
    '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',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,7 +17,7 @@ import invariant from 'lexical/shared/invariant';
 | 
			
		|||
import {
 | 
			
		||||
  $createLineBreakNode,
 | 
			
		||||
  $createParagraphNode,
 | 
			
		||||
  $createTextNode,
 | 
			
		||||
  $createTextNode, $getNearestNodeFromDOMNode,
 | 
			
		||||
  $isDecoratorNode,
 | 
			
		||||
  $isElementNode,
 | 
			
		||||
  $isLineBreakNode,
 | 
			
		||||
| 
						 | 
				
			
			@ -63,6 +63,7 @@ import {
 | 
			
		|||
  toggleTextFormatType,
 | 
			
		||||
} from './LexicalUtils';
 | 
			
		||||
import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
 | 
			
		||||
import {$selectSingleNode} from "../../utils/selection";
 | 
			
		||||
 | 
			
		||||
export type TextPointType = {
 | 
			
		||||
  _selection: BaseSelection;
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'];
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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[];
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			@ -169,7 +181,7 @@ export class TableNode extends CommonBlockNode {
 | 
			
		|||
 | 
			
		||||
  getColWidths(): string[] {
 | 
			
		||||
    const self = this.getLatest();
 | 
			
		||||
    return self.__colWidths;
 | 
			
		||||
    return [...self.__colWidths];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStyles(): StyleMap {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			@ -1108,7 +1114,7 @@ const selectTableNodeInDirection = (
 | 
			
		|||
          false,
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        tableNode.selectPrevious();
 | 
			
		||||
        $selectOrCreateAdjacent(tableNode, false);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return true;
 | 
			
		||||
| 
						 | 
				
			
			@ -1120,7 +1126,7 @@ const selectTableNodeInDirection = (
 | 
			
		|||
          true,
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        tableNode.selectNext();
 | 
			
		||||
        $selectOrCreateAdjacent(tableNode, true);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return true;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -35,6 +35,7 @@ import {
 | 
			
		|||
  TableRowNode,
 | 
			
		||||
} from './LexicalTableRowNode';
 | 
			
		||||
import {$isTableSelection} from './LexicalTableSelection';
 | 
			
		||||
import {$isCaptionNode} from "@lexical/table/LexicalCaptionNode";
 | 
			
		||||
 | 
			
		||||
export function $createTableNodeWithDimensions(
 | 
			
		||||
  rowCount: number,
 | 
			
		||||
| 
						 | 
				
			
			@ -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(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 => {
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
| 
						 | 
				
			
			@ -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(() => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,3 @@
 | 
			
		|||
import {handleDropdown} from "../helpers/dropdowns";
 | 
			
		||||
import {EditorContainerUiElement, EditorUiElement} from "../core";
 | 
			
		||||
import {EditorBasicButtonDefinition, EditorButton} from "../buttons";
 | 
			
		||||
import {el} from "../../../utils/dom";
 | 
			
		||||
| 
						 | 
				
			
			@ -8,6 +7,7 @@ export type EditorDropdownButtonOptions = {
 | 
			
		|||
    showOnHover?: boolean;
 | 
			
		||||
    direction?: 'vertical'|'horizontal';
 | 
			
		||||
    showAside?: boolean;
 | 
			
		||||
    hideOnAction?: boolean;
 | 
			
		||||
    button: EditorBasicButtonDefinition|EditorButton;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -15,6 +15,7 @@ const defaultOptions: EditorDropdownButtonOptions = {
 | 
			
		|||
    showOnHover: false,
 | 
			
		||||
    direction: 'horizontal',
 | 
			
		||||
    showAside: undefined,
 | 
			
		||||
    hideOnAction: true,
 | 
			
		||||
    button: {label: 'Menu'},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -40,7 +41,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
 | 
			
		|||
                },
 | 
			
		||||
                isActive: () => {
 | 
			
		||||
                    return this.open;
 | 
			
		||||
                }
 | 
			
		||||
                },
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -65,7 +66,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
 | 
			
		|||
            class: 'editor-dropdown-menu-container',
 | 
			
		||||
        }, [button, menu]);
 | 
			
		||||
 | 
			
		||||
        handleDropdown({toggle: button, menu : menu,
 | 
			
		||||
        this.getContext().manager.dropdowns.handle({toggle: button, menu : menu,
 | 
			
		||||
            showOnHover: this.options.showOnHover,
 | 
			
		||||
            showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'),
 | 
			
		||||
            onOpen : () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -76,6 +77,12 @@ export class EditorDropdownButton extends EditorContainerUiElement {
 | 
			
		|||
            this.getContext().manager.triggerStateUpdateForElement(this.button);
 | 
			
		||||
        }});
 | 
			
		||||
 | 
			
		||||
        if (this.options.hideOnAction) {
 | 
			
		||||
            this.onEvent('button-action', () => {
 | 
			
		||||
                this.getContext().manager.dropdowns.closeAll();
 | 
			
		||||
            }, wrapper);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return wrapper;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,5 @@
 | 
			
		|||
import {EditorUiStateUpdate, EditorContainerUiElement} from "../core";
 | 
			
		||||
import {EditorButton} from "../buttons";
 | 
			
		||||
import {handleDropdown} from "../helpers/dropdowns";
 | 
			
		||||
import {el} from "../../../utils/dom";
 | 
			
		||||
 | 
			
		||||
export class EditorFormatMenu extends EditorContainerUiElement {
 | 
			
		||||
| 
						 | 
				
			
			@ -20,7 +19,11 @@ export class EditorFormatMenu extends EditorContainerUiElement {
 | 
			
		|||
            class: 'editor-format-menu editor-dropdown-menu-container',
 | 
			
		||||
        }, [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;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,6 +19,7 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
 | 
			
		|||
                label: 'More',
 | 
			
		||||
                icon: moreHorizontal,
 | 
			
		||||
            },
 | 
			
		||||
            hideOnAction: false,
 | 
			
		||||
        }, []);
 | 
			
		||||
        this.addChildren(this.overflowButton);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,7 +10,12 @@ export interface 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;
 | 
			
		||||
    isDisabled?: (selection: BaseSelection|null, context: EditorUiContext) => boolean;
 | 
			
		||||
    setup?: (context: EditorUiContext, button: EditorButton) => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -78,7 +83,16 @@ export class EditorButton extends EditorUiElement {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    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) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -67,6 +67,21 @@ export abstract class EditorUiElement {
 | 
			
		|||
    updateState(state: EditorUiStateUpdate): void {
 | 
			
		||||
        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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -34,57 +34,97 @@ function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean
 | 
			
		|||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function handleDropdown(options: HandleDropdownParams) {
 | 
			
		||||
    const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options;
 | 
			
		||||
    let clickListener: Function|null = null;
 | 
			
		||||
export class DropDownManager {
 | 
			
		||||
 | 
			
		||||
    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.style.removeProperty('position');
 | 
			
		||||
        menu.style.removeProperty('left');
 | 
			
		||||
        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) {
 | 
			
		||||
            onClose();
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const show = () => {
 | 
			
		||||
    protected openDropdown(menu: HTMLElement): void {
 | 
			
		||||
        const {toggle, showAside, onOpen} = this.getOptions(menu);
 | 
			
		||||
        menu.hidden = false
 | 
			
		||||
        positionMenu(menu, toggle, Boolean(showAside));
 | 
			
		||||
        clickListener = (event: MouseEvent) => {
 | 
			
		||||
            if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) {
 | 
			
		||||
                hide();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        window.addEventListener('click', clickListener as EventListener);
 | 
			
		||||
 | 
			
		||||
        this.openDropdowns.add(menu);
 | 
			
		||||
        menu.addEventListener('mouseover', this.onMenuMouseOver);
 | 
			
		||||
 | 
			
		||||
        if (onOpen) {
 | 
			
		||||
            onOpen();
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const toggleShowing = (event: MouseEvent) => {
 | 
			
		||||
        menu.hasAttribute('hidden') ? show() : hide();
 | 
			
		||||
    };
 | 
			
		||||
    toggle.addEventListener('click', toggleShowing);
 | 
			
		||||
    if (showOnHover) {
 | 
			
		||||
        toggle.addEventListener('mouseenter', toggleShowing);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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();
 | 
			
		||||
    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) => {
 | 
			
		||||
            menu.hasAttribute('hidden') ? this.openDropdown(menu) : this.closeDropdown(menu);
 | 
			
		||||
        };
 | 
			
		||||
        toggle.addEventListener('click', toggleShowing);
 | 
			
		||||
        if (showOnHover) {
 | 
			
		||||
            toggle.addEventListener('mouseenter', () => {
 | 
			
		||||
                this.openDropdown(menu);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -56,7 +56,7 @@ class TableSelectionHandler {
 | 
			
		|||
                tableNode,
 | 
			
		||||
                tableElement,
 | 
			
		||||
                this.editor,
 | 
			
		||||
                false,
 | 
			
		||||
                true,
 | 
			
		||||
            );
 | 
			
		||||
            this.tableSelections.set(nodeKey, tableSelection);
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,7 @@ import {DecoratorListener} from "lexical/LexicalEditor";
 | 
			
		|||
import type {NodeKey} from "lexical/LexicalNode";
 | 
			
		||||
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
 | 
			
		||||
import {getLastSelection, setLastSelection} from "../../utils/selection";
 | 
			
		||||
import {DropDownManager} from "./helpers/dropdowns";
 | 
			
		||||
 | 
			
		||||
export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -21,6 +22,8 @@ export class EditorUIManager {
 | 
			
		|||
    protected activeContextToolbars: EditorContextToolbar[] = [];
 | 
			
		||||
    protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
 | 
			
		||||
 | 
			
		||||
    public dropdowns: DropDownManager = new DropDownManager();
 | 
			
		||||
 | 
			
		||||
    setContext(context: EditorUiContext) {
 | 
			
		||||
        this.context = context;
 | 
			
		||||
        this.setupEventListeners(context);
 | 
			
		||||
| 
						 | 
				
			
			@ -241,6 +244,7 @@ export class EditorUIManager {
 | 
			
		|||
            if (selectionChange) {
 | 
			
		||||
                editor.update(() => {
 | 
			
		||||
                    const selection = $getSelection();
 | 
			
		||||
                    // console.log('manager::selection', selection);
 | 
			
		||||
                    this.triggerStateUpdate({
 | 
			
		||||
                        editor, selection,
 | 
			
		||||
                    });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,7 +6,7 @@ import {
 | 
			
		|||
    $isTextNode,
 | 
			
		||||
    ElementNode,
 | 
			
		||||
    LexicalEditor,
 | 
			
		||||
    LexicalNode
 | 
			
		||||
    LexicalNode, RangeSelection
 | 
			
		||||
} from "lexical";
 | 
			
		||||
import {LexicalNodeMatcher} from "../nodes";
 | 
			
		||||
import {$generateNodesFromDOM} from "@lexical/html";
 | 
			
		||||
| 
						 | 
				
			
			@ -118,6 +118,22 @@ export function $sortNodes(nodes: LexicalNode[]): LexicalNode[] {
 | 
			
		|||
    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 {
 | 
			
		||||
    return '__alignment' in node;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,7 @@ import {
 | 
			
		|||
} from "@lexical/table";
 | 
			
		||||
import {$getParentOfType} from "./nodes";
 | 
			
		||||
import {$getNodeFromSelection} from "./selection";
 | 
			
		||||
import {formatSizeValue} from "./dom";
 | 
			
		||||
import {el, formatSizeValue} from "./dom";
 | 
			
		||||
import {TableMap} from "./table-map";
 | 
			
		||||
 | 
			
		||||
function $getTableFromCell(cell: TableCellNode): TableNode|null {
 | 
			
		||||
| 
						 | 
				
			
			@ -140,6 +140,23 @@ export function $getTableCellColumnWidth(editor: LexicalEditor, cell: TableCellN
 | 
			
		|||
    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[]  {
 | 
			
		||||
    if ($isTableSelection(selection)) {
 | 
			
		||||
        const nodes = selection.getNodes();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -422,7 +422,7 @@ body.editor-is-fullscreen {
 | 
			
		|||
.editor-table-marker {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  background-color: var(--editor-color-primary);
 | 
			
		||||
  z-index: 99;
 | 
			
		||||
  z-index: 3;
 | 
			
		||||
  user-select: none;
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  &:hover, &.active {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,7 +32,7 @@
 | 
			
		|||
                    <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') === '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>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue