mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Lexical: Custom list nesting support
Added list nesting support to allow li > ul style nesting which lexical didn't do by default. Adds tab handling for inset/outset controls. Will be a range of edge-case bugs to squash during testing.
This commit is contained in:
parent
5083188ed8
commit
662110c269
@ -3,6 +3,7 @@ import {EditorConfig} from "lexical/LexicalEditor";
|
|||||||
import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical";
|
import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical";
|
||||||
|
|
||||||
import {el} from "../utils/dom";
|
import {el} from "../utils/dom";
|
||||||
|
import {$isCustomListNode} from "./custom-list";
|
||||||
|
|
||||||
function updateListItemChecked(
|
function updateListItemChecked(
|
||||||
dom: HTMLElement,
|
dom: HTMLElement,
|
||||||
@ -38,6 +39,10 @@ export class CustomListItemNode extends ListItemNode {
|
|||||||
|
|
||||||
element.value = this.__value;
|
element.value = this.__value;
|
||||||
|
|
||||||
|
if ($hasNestedListWithoutLabel(this)) {
|
||||||
|
element.style.listStyle = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,8 +91,28 @@ export class CustomListItemNode extends ListItemNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function $hasNestedListWithoutLabel(node: CustomListItemNode): boolean {
|
||||||
|
const children = node.getChildren();
|
||||||
|
let hasLabel = false;
|
||||||
|
let hasNestedList = false;
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
if ($isCustomListNode(child)) {
|
||||||
|
hasNestedList = true;
|
||||||
|
} else if (child.getTextContent().trim().length > 0) {
|
||||||
|
hasLabel = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasNestedList && !hasLabel;
|
||||||
|
}
|
||||||
|
|
||||||
export function $isCustomListItemNode(
|
export function $isCustomListItemNode(
|
||||||
node: LexicalNode | null | undefined,
|
node: LexicalNode | null | undefined,
|
||||||
): node is CustomListItemNode {
|
): node is CustomListItemNode {
|
||||||
return node instanceof CustomListItemNode;
|
return node instanceof CustomListItemNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $createCustomListItemNode(): CustomListItemNode {
|
||||||
|
return new CustomListItemNode();
|
||||||
}
|
}
|
@ -5,7 +5,8 @@ import {
|
|||||||
Spread
|
Spread
|
||||||
} from "lexical";
|
} from "lexical";
|
||||||
import {EditorConfig} from "lexical/LexicalEditor";
|
import {EditorConfig} from "lexical/LexicalEditor";
|
||||||
import {ListNode, ListType, SerializedListNode} from "@lexical/list";
|
import {$isListItemNode, ListItemNode, ListNode, ListType, SerializedListNode} from "@lexical/list";
|
||||||
|
import {$createCustomListItemNode} from "./custom-list-item";
|
||||||
|
|
||||||
|
|
||||||
export type SerializedCustomListNode = Spread<{
|
export type SerializedCustomListNode = Spread<{
|
||||||
@ -30,7 +31,7 @@ export class CustomListNode extends ListNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static clone(node: CustomListNode) {
|
static clone(node: CustomListNode) {
|
||||||
const newNode = new CustomListNode(node.__listType, 0, node.__key);
|
const newNode = new CustomListNode(node.__listType, node.__start, node.__key);
|
||||||
newNode.__id = node.__id;
|
newNode.__id = node.__id;
|
||||||
return newNode;
|
return newNode;
|
||||||
}
|
}
|
||||||
@ -67,6 +68,11 @@ export class CustomListNode extends ListNode {
|
|||||||
if (element.id && baseResult?.node) {
|
if (element.id && baseResult?.node) {
|
||||||
(baseResult.node as CustomListNode).setId(element.id);
|
(baseResult.node as CustomListNode).setId(element.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (baseResult) {
|
||||||
|
baseResult.after = $normalizeChildren;
|
||||||
|
}
|
||||||
|
|
||||||
return baseResult;
|
return baseResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -83,8 +89,34 @@ export class CustomListNode extends ListNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This function is a custom normalization function to allow nested lists within list item elements.
|
||||||
|
* Original taken from https://github.com/facebook/lexical/blob/6e10210fd1e113ccfafdc999b1d896733c5c5bea/packages/lexical-list/src/LexicalListNode.ts#L284-L303
|
||||||
|
* With modifications made.
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
* MIT license
|
||||||
|
*/
|
||||||
|
function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
|
||||||
|
const normalizedListItems: Array<ListItemNode> = [];
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if ($isListItemNode(node)) {
|
||||||
|
normalizedListItems.push(node);
|
||||||
|
} else {
|
||||||
|
normalizedListItems.push($wrapInListItem(node));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedListItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function $wrapInListItem(node: LexicalNode): ListItemNode {
|
||||||
|
const listItemWrapper = $createCustomListItemNode();
|
||||||
|
return listItemWrapper.append(node);
|
||||||
|
}
|
||||||
|
|
||||||
export function $createCustomListNode(type: ListType): CustomListNode {
|
export function $createCustomListNode(type: ListType): CustomListNode {
|
||||||
return new CustomListNode(type, 0);
|
return new CustomListNode(type, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode {
|
export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode {
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import {EditorUiContext} from "../ui/framework/core";
|
import {EditorUiContext} from "../ui/framework/core";
|
||||||
import {
|
import {
|
||||||
|
$getSelection,
|
||||||
$isDecoratorNode,
|
$isDecoratorNode,
|
||||||
COMMAND_PRIORITY_LOW,
|
COMMAND_PRIORITY_LOW,
|
||||||
KEY_BACKSPACE_COMMAND,
|
KEY_BACKSPACE_COMMAND,
|
||||||
KEY_DELETE_COMMAND,
|
KEY_DELETE_COMMAND,
|
||||||
KEY_ENTER_COMMAND,
|
KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
|
||||||
LexicalEditor,
|
LexicalEditor,
|
||||||
LexicalNode
|
LexicalNode
|
||||||
} from "lexical";
|
} from "lexical";
|
||||||
@ -13,6 +14,8 @@ import {$isMediaNode} from "../nodes/media";
|
|||||||
import {getLastSelection} from "../utils/selection";
|
import {getLastSelection} from "../utils/selection";
|
||||||
import {$getNearestNodeBlockParent} from "../utils/nodes";
|
import {$getNearestNodeBlockParent} from "../utils/nodes";
|
||||||
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
|
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
|
||||||
|
import {$isCustomListItemNode} from "../nodes/custom-list-item";
|
||||||
|
import {$setInsetForSelection} from "../utils/lists";
|
||||||
|
|
||||||
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
|
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
|
||||||
if (nodes.length === 1) {
|
if (nodes.length === 1) {
|
||||||
@ -55,6 +58,17 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null) {
|
||||||
|
const change = event?.shiftKey ? -40 : 40;
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
const nodes = selection?.getNodes() || [];
|
||||||
|
if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) {
|
||||||
|
$setInsetForSelection(editor, change);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function registerKeyboardHandling(context: EditorUiContext): () => void {
|
export function registerKeyboardHandling(context: EditorUiContext): () => void {
|
||||||
const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
|
const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
|
||||||
deleteSingleSelectedNode(context.editor);
|
deleteSingleSelectedNode(context.editor);
|
||||||
@ -70,9 +84,14 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
|
|||||||
return insertAfterSingleSelectedNode(context.editor, event);
|
return insertAfterSingleSelectedNode(context.editor, event);
|
||||||
}, COMMAND_PRIORITY_LOW);
|
}, COMMAND_PRIORITY_LOW);
|
||||||
|
|
||||||
|
const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
|
||||||
|
return handleInsetOnTab(context.editor, event);
|
||||||
|
}, COMMAND_PRIORITY_LOW);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unregisterBackspace();
|
unregisterBackspace();
|
||||||
unregisterDelete();
|
unregisterDelete();
|
||||||
unregisterEnter();
|
unregisterEnter();
|
||||||
|
unregisterTab();
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
## Main Todo
|
## Main Todo
|
||||||
|
|
||||||
- Align list nesting with old editor
|
|
||||||
- Mac: Shortcut support via command.
|
- Mac: Shortcut support via command.
|
||||||
|
- RTL/LTR support
|
||||||
|
|
||||||
## Secondary Todo
|
## Secondary Todo
|
||||||
|
|
||||||
@ -18,4 +18,5 @@
|
|||||||
|
|
||||||
## Bugs
|
## Bugs
|
||||||
|
|
||||||
//
|
- Focus/click area reduced to content area, single line on initial access
|
||||||
|
- List selection can get lost on nesting/unnesting
|
@ -13,12 +13,14 @@ import indentIncreaseIcon from "@icons/editor/indent-increase.svg";
|
|||||||
import indentDecreaseIcon from "@icons/editor/indent-decrease.svg";
|
import indentDecreaseIcon from "@icons/editor/indent-decrease.svg";
|
||||||
import {
|
import {
|
||||||
$getBlockElementNodesInSelection,
|
$getBlockElementNodesInSelection,
|
||||||
$selectionContainsNodeType,
|
$selectionContainsNodeType, $selectNodes, $selectSingleNode,
|
||||||
$toggleSelection,
|
$toggleSelection,
|
||||||
getLastSelection
|
getLastSelection
|
||||||
} from "../../../utils/selection";
|
} from "../../../utils/selection";
|
||||||
import {toggleSelectionAsList} from "../../../utils/formats";
|
import {toggleSelectionAsList} from "../../../utils/formats";
|
||||||
import {nodeHasInset} from "../../../utils/nodes";
|
import {nodeHasInset} from "../../../utils/nodes";
|
||||||
|
import {$isCustomListItemNode, CustomListItemNode} from "../../../nodes/custom-list-item";
|
||||||
|
import {$nestListItem, $setInsetForSelection, $unnestListItem} from "../../../utils/lists";
|
||||||
|
|
||||||
|
|
||||||
function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition {
|
function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition {
|
||||||
@ -40,28 +42,12 @@ export const bulletList: EditorButtonDefinition = buildListButton('Bullet list',
|
|||||||
export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon);
|
export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon);
|
||||||
export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon);
|
export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon);
|
||||||
|
|
||||||
|
|
||||||
function setInsetForSelection(editor: LexicalEditor, change: number): void {
|
|
||||||
const selection = getLastSelection(editor);
|
|
||||||
|
|
||||||
const elements = $getBlockElementNodesInSelection(selection);
|
|
||||||
for (const node of elements) {
|
|
||||||
if (nodeHasInset(node)) {
|
|
||||||
const currentInset = node.getInset();
|
|
||||||
const newInset = Math.min(Math.max(currentInset + change, 0), 500);
|
|
||||||
node.setInset(newInset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$toggleSelection(editor);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const indentIncrease: EditorButtonDefinition = {
|
export const indentIncrease: EditorButtonDefinition = {
|
||||||
label: 'Increase indent',
|
label: 'Increase indent',
|
||||||
icon: indentIncreaseIcon,
|
icon: indentIncreaseIcon,
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.update(() => {
|
context.editor.update(() => {
|
||||||
setInsetForSelection(context.editor, 40);
|
$setInsetForSelection(context.editor, 40);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
isActive() {
|
isActive() {
|
||||||
@ -74,7 +60,7 @@ export const indentDecrease: EditorButtonDefinition = {
|
|||||||
icon: indentDecreaseIcon,
|
icon: indentDecreaseIcon,
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.update(() => {
|
context.editor.update(() => {
|
||||||
setInsetForSelection(context.editor, -40);
|
$setInsetForSelection(context.editor, -40);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
isActive() {
|
isActive() {
|
||||||
|
123
resources/js/wysiwyg/utils/lists.ts
Normal file
123
resources/js/wysiwyg/utils/lists.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import {$createCustomListItemNode, $isCustomListItemNode, CustomListItemNode} from "../nodes/custom-list-item";
|
||||||
|
import {$createCustomListNode, $isCustomListNode} from "../nodes/custom-list";
|
||||||
|
import {BaseSelection, LexicalEditor} from "lexical";
|
||||||
|
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection, getLastSelection} from "./selection";
|
||||||
|
import {nodeHasInset} from "./nodes";
|
||||||
|
|
||||||
|
|
||||||
|
export function $nestListItem(node: CustomListItemNode) {
|
||||||
|
const list = node.getParent();
|
||||||
|
if (!$isCustomListNode(list)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listItems = list.getChildren() as CustomListItemNode[];
|
||||||
|
const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
|
||||||
|
const isFirst = nodeIndex === 0;
|
||||||
|
|
||||||
|
const newListItem = $createCustomListItemNode();
|
||||||
|
const newList = $createCustomListNode(list.getListType());
|
||||||
|
newList.append(newListItem);
|
||||||
|
newListItem.append(...node.getChildren());
|
||||||
|
|
||||||
|
if (isFirst) {
|
||||||
|
node.append(newList);
|
||||||
|
} else {
|
||||||
|
const prevListItem = listItems[nodeIndex - 1];
|
||||||
|
prevListItem.append(newList);
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $unnestListItem(node: CustomListItemNode) {
|
||||||
|
const list = node.getParent();
|
||||||
|
const parentListItem = list?.getParent();
|
||||||
|
const outerList = parentListItem?.getParent();
|
||||||
|
if (!$isCustomListNode(list) || !$isCustomListNode(outerList) || !$isCustomListItemNode(parentListItem)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parentListItem.insertAfter(node);
|
||||||
|
if (list.getChildren().length === 0) {
|
||||||
|
list.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentListItem.getChildren().length === 0) {
|
||||||
|
parentListItem.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getListItemsForSelection(selection: BaseSelection|null): (CustomListItemNode|null)[] {
|
||||||
|
const nodes = selection?.getNodes() || [];
|
||||||
|
const listItemNodes = [];
|
||||||
|
|
||||||
|
outer: for (const node of nodes) {
|
||||||
|
if ($isCustomListItemNode(node)) {
|
||||||
|
listItemNodes.push(node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parents = node.getParents();
|
||||||
|
for (const parent of parents) {
|
||||||
|
if ($isCustomListItemNode(parent)) {
|
||||||
|
listItemNodes.push(parent);
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listItemNodes.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return listItemNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function $reduceDedupeListItems(listItems: (CustomListItemNode|null)[]): CustomListItemNode[] {
|
||||||
|
const listItemMap: Record<string, CustomListItemNode> = {};
|
||||||
|
|
||||||
|
for (const item of listItems) {
|
||||||
|
if (item === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = item.getKey();
|
||||||
|
if (typeof listItemMap[key] === 'undefined') {
|
||||||
|
listItemMap[key] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(listItemMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
|
||||||
|
const selection = getLastSelection(editor);
|
||||||
|
|
||||||
|
const listItemsInSelection = getListItemsForSelection(selection);
|
||||||
|
const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
|
||||||
|
|
||||||
|
if (isListSelection) {
|
||||||
|
const listItems = $reduceDedupeListItems(listItemsInSelection);
|
||||||
|
if (change > 0) {
|
||||||
|
for (const listItem of listItems) {
|
||||||
|
$nestListItem(listItem);
|
||||||
|
}
|
||||||
|
} else if (change < 0) {
|
||||||
|
for (const listItem of [...listItems].reverse()) {
|
||||||
|
$unnestListItem(listItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectNodes(listItems);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = $getBlockElementNodesInSelection(selection);
|
||||||
|
for (const node of elements) {
|
||||||
|
if (nodeHasInset(node)) {
|
||||||
|
const currentInset = node.getInset();
|
||||||
|
const newInset = Math.min(Math.max(currentInset + change, 0), 500);
|
||||||
|
node.setInset(newInset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$toggleSelection(editor);
|
||||||
|
}
|
@ -10,7 +10,7 @@ import {
|
|||||||
ElementFormatType,
|
ElementFormatType,
|
||||||
ElementNode, LexicalEditor,
|
ElementNode, LexicalEditor,
|
||||||
LexicalNode,
|
LexicalNode,
|
||||||
TextFormatType
|
TextFormatType, TextNode
|
||||||
} from "lexical";
|
} from "lexical";
|
||||||
import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
|
import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
|
||||||
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes";
|
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes";
|
||||||
@ -106,6 +106,57 @@ export function $selectSingleNode(node: LexicalNode) {
|
|||||||
$setSelection(nodeSelection);
|
$setSelection(nodeSelection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFirstTextNodeInNodes(nodes: LexicalNode[]): TextNode|null {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if ($isTextNode(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isElementNode(node)) {
|
||||||
|
const children = node.getChildren();
|
||||||
|
const textNode = getFirstTextNodeInNodes(children);
|
||||||
|
if (textNode !== null) {
|
||||||
|
return textNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastTextNodeInNodes(nodes: LexicalNode[]): TextNode|null {
|
||||||
|
const revNodes = [...nodes].reverse();
|
||||||
|
for (const node of revNodes) {
|
||||||
|
if ($isTextNode(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isElementNode(node)) {
|
||||||
|
const children = [...node.getChildren()].reverse();
|
||||||
|
const textNode = getLastTextNodeInNodes(children);
|
||||||
|
if (textNode !== null) {
|
||||||
|
return textNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $selectNodes(nodes: LexicalNode[]) {
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = $createRangeSelection();
|
||||||
|
const firstText = getFirstTextNodeInNodes(nodes);
|
||||||
|
const lastText = getLastTextNodeInNodes(nodes);
|
||||||
|
if (firstText && lastText) {
|
||||||
|
selection.setTextNodeRange(firstText, 0, lastText, lastText.getTextContentSize() || 0)
|
||||||
|
$setSelection(selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function $toggleSelection(editor: LexicalEditor) {
|
export function $toggleSelection(editor: LexicalEditor) {
|
||||||
const lastSelection = getLastSelection(editor);
|
const lastSelection = getLastSelection(editor);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user