Lexical: Finished off baseline shortcut implementation

This commit is contained in:
Dan Brown 2024-08-20 14:54:53 +01:00
parent aa1fac62d5
commit dbb2fe3e59
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
7 changed files with 124 additions and 85 deletions

View File

@ -45,11 +45,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
const editor = createEditor(config); const editor = createEditor(config);
editor.setRootElement(editArea); editor.setRootElement(editArea);
const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options);
mergeRegister( mergeRegister(
registerRichText(editor), registerRichText(editor),
registerHistory(editor, createEmptyHistoryState(), 300), registerHistory(editor, createEmptyHistoryState(), 300),
registerShortcuts(editor), registerShortcuts(context),
registerTableResizer(editor, editWrap), registerTableResizer(editor, editWrap),
registerTableSelectionHandler(editor), registerTableSelectionHandler(editor),
registerTaskListHandler(editor, editArea), registerTaskListHandler(editor, editArea),
@ -89,7 +90,6 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
console.log(editor.getEditorState().toJSON()); console.log(editor.getEditorState().toJSON());
}; };
const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options);
registerCommonNodeMutationListeners(context); registerCommonNodeMutationListeners(context);
return new SimpleWysiwygEditorInterface(editor); return new SimpleWysiwygEditorInterface(editor);

View File

@ -1,12 +1,17 @@
import {COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical"; import {$getSelection, COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical";
import { import {
cycleSelectionCalloutFormats, cycleSelectionCalloutFormats,
formatCodeBlock, formatCodeBlock, insertOrUpdateLink,
toggleSelectionAsBlockquote, toggleSelectionAsBlockquote,
toggleSelectionAsHeading, toggleSelectionAsHeading, toggleSelectionAsList,
toggleSelectionAsParagraph toggleSelectionAsParagraph
} from "../utils/formats"; } from "../utils/formats";
import {HeadingTagType} from "@lexical/rich-text"; import {HeadingTagType} from "@lexical/rich-text";
import {EditorUiContext} from "../ui/framework/core";
import {$getNodeFromSelection} from "../utils/selection";
import {$isLinkNode, LinkNode} from "@lexical/link";
import {$showLinkForm} from "../ui/defaults/forms/objects";
import {showLinkSelector} from "../utils/links";
function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean { function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean {
toggleSelectionAsHeading(editor, tag); toggleSelectionAsHeading(editor, tag);
@ -25,10 +30,9 @@ function toggleInlineCode(editor: LexicalEditor): boolean {
return true; return true;
} }
type ShortcutAction = (editor: LexicalEditor) => boolean; type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boolean;
const actionsByKeys: Record<string, ShortcutAction> = { const actionsByKeys: Record<string, ShortcutAction> = {
// Save draft
'ctrl+s': () => { 'ctrl+s': () => {
window.$events.emit('editor-save-draft'); window.$events.emit('editor-save-draft');
return true; return true;
@ -51,18 +55,35 @@ const actionsByKeys: Record<string, ShortcutAction> = {
'ctrl+shift+e': toggleInlineCode, 'ctrl+shift+e': toggleInlineCode,
'ctrl+9': wrapFormatAction(cycleSelectionCalloutFormats), 'ctrl+9': wrapFormatAction(cycleSelectionCalloutFormats),
// TODO Lists 'ctrl+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')),
// TODO Links 'ctrl+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')),
// TODO Link selector 'ctrl+k': (editor, context) => {
editor.getEditorState().read(() => {
const selectedLink = $getNodeFromSelection($getSelection(), $isLinkNode) as LinkNode | null;
$showLinkForm(selectedLink, context);
});
return true;
},
'ctrl+shift+k': (editor, context) => {
showLinkSelector(entity => {
insertOrUpdateLink(editor, {
text: entity.name,
title: entity.link,
target: '',
url: entity.link,
});
});
return true;
},
}; };
function createKeyDownListener(editor: LexicalEditor): (e: KeyboardEvent) => void { function createKeyDownListener(context: EditorUiContext): (e: KeyboardEvent) => void {
return (event: KeyboardEvent) => { return (event: KeyboardEvent) => {
// TODO - Mac Cmd support // TODO - Mac Cmd support
const combo = `${event.ctrlKey ? 'ctrl+' : ''}${event.shiftKey ? 'shift+' : ''}${event.key}`.toLowerCase(); const combo = `${event.ctrlKey ? 'ctrl+' : ''}${event.shiftKey ? 'shift+' : ''}${event.key}`.toLowerCase();
console.log(`pressed: ${combo}`); // console.log(`pressed: ${combo}`);
if (actionsByKeys[combo]) { if (actionsByKeys[combo]) {
const handled = actionsByKeys[combo](editor); const handled = actionsByKeys[combo](context.editor, context);
if (handled) { if (handled) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
@ -78,11 +99,11 @@ function overrideDefaultCommands(editor: LexicalEditor) {
}, COMMAND_PRIORITY_HIGH); }, COMMAND_PRIORITY_HIGH);
} }
export function registerShortcuts(editor: LexicalEditor) { export function registerShortcuts(context: EditorUiContext) {
const listener = createKeyDownListener(editor); const listener = createKeyDownListener(context);
overrideDefaultCommands(editor); overrideDefaultCommands(context.editor);
return editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => { return context.editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => {
// add the listener to the current root element // add the listener to the current root element
rootElement?.addEventListener('keydown', listener); rootElement?.addEventListener('keydown', listener);
// remove the listener from the old root element // remove the listener from the old root element

View File

@ -2,7 +2,7 @@
## In progress ## In progress
- Keyboard shortcuts support //
## Main Todo ## Main Todo
@ -13,6 +13,7 @@
- Media resize support (like images) - Media resize support (like images)
- Table caption text support - Table caption text support
- Table Cut/Copy/Paste column - Table Cut/Copy/Paste column
- Mac: Shortcut support via command.
## Secondary Todo ## Secondary Todo

View File

@ -1,11 +1,12 @@
import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; import {$isListNode, ListNode, ListType} from "@lexical/list";
import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorButtonDefinition} from "../../framework/buttons";
import {EditorUiContext} from "../../framework/core"; import {EditorUiContext} from "../../framework/core";
import {$getSelection, BaseSelection, LexicalNode} from "lexical"; import {BaseSelection, LexicalNode} from "lexical";
import listBulletIcon from "@icons/editor/list-bullet.svg"; import listBulletIcon from "@icons/editor/list-bullet.svg";
import listNumberedIcon from "@icons/editor/list-numbered.svg"; import listNumberedIcon from "@icons/editor/list-numbered.svg";
import listCheckIcon from "@icons/editor/list-check.svg"; import listCheckIcon from "@icons/editor/list-check.svg";
import {$selectionContainsNodeType} from "../../../utils/selection"; import {$selectionContainsNodeType} from "../../../utils/selection";
import {toggleSelectionAsList} from "../../../utils/formats";
function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition {
@ -13,14 +14,7 @@ function buildListButton(label: string, type: ListType, icon: string): EditorBut
label, label,
icon, icon,
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.getEditorState().read(() => { toggleSelectionAsList(context.editor, type);
const selection = $getSelection();
if (this.isActive(selection, context)) {
removeList(context.editor);
} else {
insertList(context.editor, type);
}
});
}, },
isActive(selection: BaseSelection|null): boolean { isActive(selection: BaseSelection|null): boolean {
return $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { return $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => {

View File

@ -2,11 +2,9 @@ import {EditorButtonDefinition} from "../../framework/buttons";
import linkIcon from "@icons/editor/link.svg"; import linkIcon from "@icons/editor/link.svg";
import {EditorUiContext} from "../../framework/core"; import {EditorUiContext} from "../../framework/core";
import { import {
$createNodeSelection,
$createTextNode, $createTextNode,
$getRoot, $getRoot,
$getSelection, $insertNodes, $getSelection, $insertNodes,
$setSelection,
BaseSelection, BaseSelection,
ElementNode ElementNode
} from "lexical"; } from "lexical";
@ -17,7 +15,7 @@ import {$isImageNode, ImageNode} from "../../../nodes/image";
import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg"; import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg";
import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../../nodes/horizontal-rule"; import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../../nodes/horizontal-rule";
import codeBlockIcon from "@icons/editor/code-block.svg"; import codeBlockIcon from "@icons/editor/code-block.svg";
import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../../nodes/code-block"; import {$isCodeBlockNode} from "../../../nodes/code-block";
import editIcon from "@icons/edit.svg"; import editIcon from "@icons/edit.svg";
import diagramIcon from "@icons/editor/diagram.svg"; import diagramIcon from "@icons/editor/diagram.svg";
import {$createDiagramNode, DiagramNode} from "../../../nodes/diagram"; import {$createDiagramNode, DiagramNode} from "../../../nodes/diagram";
@ -32,35 +30,16 @@ import {
} from "../../../utils/selection"; } from "../../../utils/selection";
import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams";
import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
import {$showImageForm} from "../forms/objects"; import {$showImageForm, $showLinkForm} from "../forms/objects";
import {formatCodeBlock} from "../../../utils/formats"; import {formatCodeBlock} from "../../../utils/formats";
export const link: EditorButtonDefinition = { export const link: EditorButtonDefinition = {
label: 'Insert/edit link', label: 'Insert/edit link',
icon: linkIcon, icon: linkIcon,
action(context: EditorUiContext) { action(context: EditorUiContext) {
const linkModal = context.manager.createModal('link');
context.editor.getEditorState().read(() => { context.editor.getEditorState().read(() => {
const selection = $getSelection(); const selectedLink = $getNodeFromSelection($getSelection(), $isLinkNode) as LinkNode | null;
const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null; $showLinkForm(selectedLink, context);
let formDefaults = {};
if (selectedLink) {
formDefaults = {
url: selectedLink.getURL(),
text: selectedLink.getTextContent(),
title: selectedLink.getTitle(),
target: selectedLink.getTarget(),
}
context.editor.update(() => {
const selection = $createNodeSelection();
selection.add(selectedLink.getKey());
$setSelection(selection);
});
}
linkModal.show(formDefaults);
}); });
}, },
isActive(selection: BaseSelection | null): boolean { isActive(selection: BaseSelection | null): boolean {

View File

@ -5,9 +5,9 @@ import {
EditorSelectFormFieldDefinition EditorSelectFormFieldDefinition
} from "../../framework/forms"; } from "../../framework/forms";
import {EditorUiContext} from "../../framework/core"; import {EditorUiContext} from "../../framework/core";
import {$createTextNode, $getSelection, $insertNodes} from "lexical"; import {$createNodeSelection, $createTextNode, $getSelection, $insertNodes, $setSelection} from "lexical";
import {$isImageNode, ImageNode} from "../../../nodes/image"; import {$isImageNode, ImageNode} from "../../../nodes/image";
import {$createLinkNode, $isLinkNode} from "@lexical/link"; import {$createLinkNode, $isLinkNode, LinkNode} from "@lexical/link";
import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media";
import {$insertNodeToNearestRoot} from "@lexical/utils"; import {$insertNodeToNearestRoot} from "@lexical/utils";
import {$getNodeFromSelection, getLastSelection} from "../../../utils/selection"; import {$getNodeFromSelection, getLastSelection} from "../../../utils/selection";
@ -19,6 +19,7 @@ import searchImageIcon from "@icons/editor/image-search.svg";
import searchIcon from "@icons/search.svg"; import searchIcon from "@icons/search.svg";
import {showLinkSelector} from "../../../utils/links"; import {showLinkSelector} from "../../../utils/links";
import {LinkField} from "../../framework/blocks/link-field"; import {LinkField} from "../../framework/blocks/link-field";
import {insertOrUpdateLink} from "../../../utils/formats";
export function $showImageForm(image: ImageNode, context: EditorUiContext) { export function $showImageForm(image: ImageNode, context: EditorUiContext) {
const imageModal: EditorFormModal = context.manager.createModal('image'); const imageModal: EditorFormModal = context.manager.createModal('image');
@ -96,37 +97,36 @@ export const image: EditorFormDefinition = {
], ],
}; };
export function $showLinkForm(link: LinkNode|null, context: EditorUiContext) {
const linkModal = context.manager.createModal('link');
let formDefaults = {};
if (link) {
formDefaults = {
url: link.getURL(),
text: link.getTextContent(),
title: link.getTitle(),
target: link.getTarget(),
}
context.editor.update(() => {
const selection = $createNodeSelection();
selection.add(link.getKey());
$setSelection(selection);
});
}
linkModal.show(formDefaults);
}
export const link: EditorFormDefinition = { export const link: EditorFormDefinition = {
submitText: 'Apply', submitText: 'Apply',
async action(formData, context: EditorUiContext) { async action(formData, context: EditorUiContext) {
context.editor.update(() => { insertOrUpdateLink(context.editor, {
url: formData.get('url')?.toString() || '',
const url = formData.get('url')?.toString() || ''; title: formData.get('title')?.toString() || '',
const title = formData.get('title')?.toString() || '' target: formData.get('target')?.toString() || '',
const target = formData.get('target')?.toString() || ''; text: formData.get('text')?.toString() || '',
const text = formData.get('text')?.toString() || '';
const selection = $getSelection();
let link = $getNodeFromSelection(selection, $isLinkNode);
if ($isLinkNode(link)) {
link.setURL(url);
link.setTarget(target);
link.setTitle(title);
} else {
link = $createLinkNode(url, {
title: title,
target: target,
});
$insertNodes([link]);
}
if ($isLinkNode(link)) {
for (const child of link.getChildren()) {
child.remove(true);
}
link.append($createTextNode(text));
}
}); });
return true; return true;
}, },

View File

@ -1,9 +1,9 @@
import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text"; import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text";
import {$getSelection, LexicalEditor, LexicalNode} from "lexical"; import {$createTextNode, $getSelection, $insertNodes, LexicalEditor, LexicalNode} from "lexical";
import { import {
$getBlockElementNodesInSelection, $getBlockElementNodesInSelection,
$getNodeFromSelection, $getNodeFromSelection,
$insertNewBlockNodeAtSelection, $insertNewBlockNodeAtSelection, $selectionContainsNodeType,
$toggleSelectionBlockNodeType, $toggleSelectionBlockNodeType,
getLastSelection getLastSelection
} from "./selection"; } from "./selection";
@ -12,6 +12,9 @@ import {$createCustomParagraphNode, $isCustomParagraphNode} from "../nodes/custo
import {$createCustomQuoteNode} from "../nodes/custom-quote"; import {$createCustomQuoteNode} from "../nodes/custom-quote";
import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block";
import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout";
import {insertList, ListNode, ListType, removeList} from "@lexical/list";
import {$isCustomListNode} from "../nodes/custom-list";
import {$createLinkNode, $isLinkNode} from "@lexical/link";
const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => {
return $isCustomHeadingNode(node) && (node as HeadingNode).getTag() === tag; return $isCustomHeadingNode(node) && (node as HeadingNode).getTag() === tag;
@ -38,6 +41,21 @@ export function toggleSelectionAsBlockquote(editor: LexicalEditor) {
}); });
} }
export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) {
editor.getEditorState().read(() => {
const selection = $getSelection();
const listSelected = $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => {
return $isCustomListNode(node) && (node as ListNode).getListType() === type;
});
if (listSelected) {
removeList(editor);
} else {
insertList(editor, type);
}
});
}
export function formatCodeBlock(editor: LexicalEditor) { export function formatCodeBlock(editor: LexicalEditor) {
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
const selection = $getSelection(); const selection = $getSelection();
@ -85,4 +103,30 @@ export function cycleSelectionCalloutFormats(editor: LexicalEditor) {
} }
} }
}); });
}
export function insertOrUpdateLink(editor: LexicalEditor, linkDetails: {text: string, title: string, target: string, url: string}) {
editor.update(() => {
const selection = $getSelection();
let link = $getNodeFromSelection(selection, $isLinkNode);
if ($isLinkNode(link)) {
link.setURL(linkDetails.url);
link.setTarget(linkDetails.target);
link.setTitle(linkDetails.title);
} else {
link = $createLinkNode(linkDetails.url, {
title: linkDetails.title,
target: linkDetails.target,
});
$insertNodes([link]);
}
if ($isLinkNode(link)) {
for (const child of link.getChildren()) {
child.remove(true);
}
link.append($createTextNode(linkDetails.text));
}
});
} }