'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',
import {
$createLineBreakNode,
$createParagraphNode,
- $createTextNode,
+ $createTextNode, $getNearestNodeFromDOMNode,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
toggleTextFormatType,
} from './LexicalUtils';
import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
+import {$selectSingleNode} from "../../utils/selection";
export type TextPointType = {
_selection: BaseSelection;
}
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
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'];
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);
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[];
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;
}
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);
}
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 {
getColWidths(): string[] {
const self = this.getLatest();
- return self.__colWidths;
+ return [...self.__colWidths];
}
getStyles(): StyleMap {
import {$isTableRowNode} from './LexicalTableRowNode';
import {$isTableSelection} from './LexicalTableSelection';
import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
+import {$selectOrCreateAdjacent} from "../../utils/nodes";
const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
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,
false,
);
} else {
- tableNode.selectPrevious();
+ $selectOrCreateAdjacent(tableNode, false);
}
return true;
true,
);
} else {
- tableNode.selectNext();
+ $selectOrCreateAdjacent(tableNode, true);
}
return true;
TableRowNode,
} from './LexicalTableRowNode';
import {$isTableSelection} from './LexicalTableSelection';
+import {$isCaptionNode} from "@lexical/table/LexicalCaptionNode";
export function $createTableNodeWithDimensions(
rowCount: number,
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(
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 => {
return false;
}
- const handled = handleMediaInsert(event.clipboardData, context);
+ const handled =
+ handleImageLinkInsert(event.clipboardData, context) ||
+ handleMediaInsert(event.clipboardData, context);
+
if (handled) {
event.preventDefault();
}
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;
}
}
* 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();
});
});
}
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;
}, 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);
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";
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(() => {
-import {handleDropdown} from "../helpers/dropdowns";
import {EditorContainerUiElement, EditorUiElement} from "../core";
import {EditorBasicButtonDefinition, EditorButton} from "../buttons";
import {el} from "../../../utils/dom";
showOnHover?: boolean;
direction?: 'vertical'|'horizontal';
showAside?: boolean;
+ hideOnAction?: boolean;
button: EditorBasicButtonDefinition|EditorButton;
};
showOnHover: false,
direction: 'horizontal',
showAside: undefined,
+ hideOnAction: true,
button: {label: 'Menu'},
}
},
isActive: () => {
return this.open;
- }
+ },
});
}
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 : () => {
this.getContext().manager.triggerStateUpdateForElement(this.button);
}});
+ if (this.options.hideOnAction) {
+ this.onEvent('button-action', () => {
+ this.getContext().manager.dropdowns.closeAll();
+ }, wrapper);
+ }
+
return wrapper;
}
}
\ No newline at end of file
import {EditorUiStateUpdate, EditorContainerUiElement} from "../core";
import {EditorButton} from "../buttons";
-import {handleDropdown} from "../helpers/dropdowns";
import {el} from "../../../utils/dom";
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;
}
label: 'More',
icon: moreHorizontal,
},
+ hideOnAction: false,
}, []);
this.addChildren(this.overflowButton);
}
}
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;
}
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) {
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 {
}
}
-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) => {
+ 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;
- // 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;
+ // Register dropdown
+ this.dropdownOptions.set(menu, options);
- if (!withinToggle) {
- hide();
+ // 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);
+ });
}
- });
+ }
}
\ No newline at end of file
tableNode,
tableElement,
this.editor,
- false,
+ true,
);
this.tableSelections.set(nodeKey, tableSelection);
}
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;
protected activeContextToolbars: EditorContextToolbar[] = [];
protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
+ public dropdowns: DropDownManager = new DropDownManager();
+
setContext(context: EditorUiContext) {
this.context = context;
this.setupEventListeners(context);
if (selectionChange) {
editor.update(() => {
const selection = $getSelection();
+ // console.log('manager::selection', selection);
this.triggerStateUpdate({
editor, selection,
});
$isTextNode,
ElementNode,
LexicalEditor,
- LexicalNode
+ LexicalNode, RangeSelection
} from "lexical";
import {LexicalNodeMatcher} from "../nodes";
import {$generateNodesFromDOM} from "@lexical/html";
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;
}
} 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 {
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();
.editor-table-marker {
position: fixed;
background-color: var(--editor-color-primary);
- z-index: 99;
+ z-index: 3;
user-select: none;
opacity: 0;
&:hover, &.active {
<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>