/**
* Emit a custom event for any handlers to pick-up.
*/
- emit(eventName: string, eventData: {}): void {
+ emit(eventName: string, eventData: {} = {}): void {
this.stack.push({name: eventName, data: eventData});
const listenersToRun = this.listeners[eventName] || [];
import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler";
import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler";
import {el} from "./utils/dom";
+import {registerShortcuts} from "./services/shortcuts";
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
const config: CreateEditorArgs = {
mergeRegister(
registerRichText(editor),
registerHistory(editor, createEmptyHistoryState(), 300),
+ registerShortcuts(editor),
registerTableResizer(editor, editWrap),
registerTableSelectionHandler(editor),
registerTaskListHandler(editor, editArea),
--- /dev/null
+import {COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical";
+import {
+ cycleSelectionCalloutFormats,
+ formatCodeBlock,
+ toggleSelectionAsBlockquote,
+ toggleSelectionAsHeading,
+ toggleSelectionAsParagraph
+} from "../utils/formats";
+import {HeadingTagType} from "@lexical/rich-text";
+
+function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean {
+ toggleSelectionAsHeading(editor, tag);
+ return true;
+}
+
+function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction {
+ return (editor: LexicalEditor) => {
+ formatAction(editor);
+ return true;
+ };
+}
+
+function toggleInlineCode(editor: LexicalEditor): boolean {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
+ return true;
+}
+
+type ShortcutAction = (editor: LexicalEditor) => boolean;
+
+const actionsByKeys: Record<string, ShortcutAction> = {
+ // Save draft
+ 'ctrl+s': () => {
+ window.$events.emit('editor-save-draft');
+ return true;
+ },
+ 'ctrl+enter': () => {
+ window.$events.emit('editor-save-page');
+ return true;
+ },
+ 'ctrl+1': (editor) => headerHandler(editor, 'h1'),
+ 'ctrl+2': (editor) => headerHandler(editor, 'h2'),
+ 'ctrl+3': (editor) => headerHandler(editor, 'h3'),
+ 'ctrl+4': (editor) => headerHandler(editor, 'h4'),
+ 'ctrl+5': wrapFormatAction(toggleSelectionAsParagraph),
+ 'ctrl+d': wrapFormatAction(toggleSelectionAsParagraph),
+ 'ctrl+6': wrapFormatAction(toggleSelectionAsBlockquote),
+ 'ctrl+q': wrapFormatAction(toggleSelectionAsBlockquote),
+ 'ctrl+7': wrapFormatAction(formatCodeBlock),
+ 'ctrl+e': wrapFormatAction(formatCodeBlock),
+ 'ctrl+8': toggleInlineCode,
+ 'ctrl+shift+e': toggleInlineCode,
+ 'ctrl+9': wrapFormatAction(cycleSelectionCalloutFormats),
+
+ // TODO Lists
+ // TODO Links
+ // TODO Link selector
+};
+
+function createKeyDownListener(editor: LexicalEditor): (e: KeyboardEvent) => void {
+ return (event: KeyboardEvent) => {
+ // TODO - Mac Cmd support
+ const combo = `${event.ctrlKey ? 'ctrl+' : ''}${event.shiftKey ? 'shift+' : ''}${event.key}`.toLowerCase();
+ console.log(`pressed: ${combo}`);
+ if (actionsByKeys[combo]) {
+ const handled = actionsByKeys[combo](editor);
+ if (handled) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ }
+ };
+}
+
+function overrideDefaultCommands(editor: LexicalEditor) {
+ // Prevent default ctrl+enter command
+ editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
+ return event?.ctrlKey ? true : false
+ }, COMMAND_PRIORITY_HIGH);
+}
+
+export function registerShortcuts(editor: LexicalEditor) {
+ const listener = createKeyDownListener(editor);
+ overrideDefaultCommands(editor);
+
+ return editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => {
+ // add the listener to the current root element
+ rootElement?.addEventListener('keydown', listener);
+ // remove the listener from the old root element
+ prevRootElement?.removeEventListener('keydown', listener);
+ });
+}
\ No newline at end of file
## In progress
-//
+- Keyboard shortcuts support
## Main Todo
- Alignments: Handle inline block content (image, video)
- Image paste upload
-- Keyboard shortcuts support
- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
- Media resize support (like images)
- Table caption text support
import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../../nodes/callout";
import {EditorButtonDefinition} from "../../framework/buttons";
import {EditorUiContext} from "../../framework/core";
-import {$createParagraphNode, $isParagraphNode, BaseSelection, LexicalNode} from "lexical";
+import {$isParagraphNode, BaseSelection, LexicalNode} from "lexical";
import {
- $createHeadingNode,
- $createQuoteNode,
$isHeadingNode,
$isQuoteNode,
HeadingNode,
HeadingTagType
} from "@lexical/rich-text";
import {$selectionContainsNodeType, $toggleSelectionBlockNodeType} from "../../../utils/selection";
+import {
+ toggleSelectionAsBlockquote,
+ toggleSelectionAsHeading,
+ toggleSelectionAsParagraph
+} from "../../../utils/formats";
function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition {
return {
return {
label: name,
action(context: EditorUiContext) {
- context.editor.update(() => {
- $toggleSelectionBlockNodeType(
- (node) => isHeaderNodeOfTag(node, tag),
- () => $createHeadingNode(tag),
- )
- });
+ toggleSelectionAsHeading(context.editor, tag);
},
isActive(selection: BaseSelection|null): boolean {
return $selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag));
export const blockquote: EditorButtonDefinition = {
label: 'Blockquote',
action(context: EditorUiContext) {
- context.editor.update(() => {
- $toggleSelectionBlockNodeType($isQuoteNode, $createQuoteNode);
- });
+ toggleSelectionAsBlockquote(context.editor);
},
isActive(selection: BaseSelection|null): boolean {
return $selectionContainsNodeType(selection, $isQuoteNode);
export const paragraph: EditorButtonDefinition = {
label: 'Paragraph',
action(context: EditorUiContext) {
- context.editor.update(() => {
- $toggleSelectionBlockNodeType($isParagraphNode, $createParagraphNode);
- });
+ toggleSelectionAsParagraph(context.editor);
},
isActive(selection: BaseSelection|null): boolean {
return $selectionContainsNodeType(selection, $isParagraphNode);
import {
$getNodeFromSelection,
$insertNewBlockNodeAtSelection,
- $selectionContainsNodeType
+ $selectionContainsNodeType, getLastSelection
} from "../../../utils/selection";
import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams";
import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
import {$showImageForm} from "../forms/objects";
+import {formatCodeBlock} from "../../../utils/formats";
export const link: EditorButtonDefinition = {
label: 'Insert/edit link',
icon: unlinkIcon,
action(context: EditorUiContext) {
context.editor.update(() => {
- const selection = context.lastSelection;
+ const selection = getLastSelection(context.editor);
const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null;
const selectionPoints = selection?.getStartEndPoints();
icon: imageIcon,
action(context: EditorUiContext) {
context.editor.getEditorState().read(() => {
- const selectedImage = $getNodeFromSelection(context.lastSelection, $isImageNode) as ImageNode | null;
+ const selection = getLastSelection(context.editor);
+ const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode | null;
if (selectedImage) {
$showImageForm(selectedImage, context);
return;
label: 'Insert code block',
icon: codeBlockIcon,
action(context: EditorUiContext) {
- context.editor.getEditorState().read(() => {
- const selection = $getSelection();
- const codeBlock = $getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode | null);
- if (codeBlock === null) {
- context.editor.update(() => {
- const codeBlock = $createCodeBlockNode();
- codeBlock.setCode(selection?.getTextContent() || '');
- $insertNewBlockNodeAtSelection(codeBlock, true);
- $openCodeEditorForNode(context.editor, codeBlock);
- codeBlock.selectStart();
- });
- } else {
- $openCodeEditorForNode(context.editor, codeBlock);
- }
- });
+ formatCodeBlock(context.editor);
},
isActive(selection: BaseSelection | null): boolean {
return $selectionContainsNodeType(selection, $isCodeBlockNode);
icon: diagramIcon,
action(context: EditorUiContext) {
context.editor.getEditorState().read(() => {
- const selection = $getSelection();
- const diagramNode = $getNodeFromSelection(context.lastSelection, $isDiagramNode) as (DiagramNode | null);
+ const selection = getLastSelection(context.editor);
+ const diagramNode = $getNodeFromSelection(selection, $isDiagramNode) as (DiagramNode | null);
if (diagramNode === null) {
context.editor.update(() => {
const diagram = $createDiagramNode();
import {$createLinkNode, $isLinkNode} from "@lexical/link";
import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media";
import {$insertNodeToNearestRoot} from "@lexical/utils";
-import {$getNodeFromSelection} from "../../../utils/selection";
+import {$getNodeFromSelection, getLastSelection} from "../../../utils/selection";
import {EditorFormModal} from "../../framework/modals";
import {EditorActionField} from "../../framework/blocks/action-field";
import {EditorButton} from "../../framework/buttons";
submitText: 'Apply',
async action(formData, context: EditorUiContext) {
context.editor.update(() => {
- const selectedImage = $getNodeFromSelection(context.lastSelection, $isImageNode);
+ const selection = getLastSelection(context.editor);
+ const selectedImage = $getNodeFromSelection(selection, $isImageNode);
if ($isImageNode(selectedImage)) {
selectedImage.setSrc(formData.get('src')?.toString() || '');
selectedImage.setAltText(formData.get('alt')?.toString() || '');
scrollDOM: HTMLElement; // DOM element which is the main content scroll container
translate: (text: string) => string; // Translate function
manager: EditorUIManager; // UI Manager instance for this editor
- lastSelection: BaseSelection|null; // The last tracked selection made by the user
options: Record<string, any>; // General user options which may be used by sub elements
};
import {DecoratorListener} from "lexical/LexicalEditor";
import type {NodeKey} from "lexical/LexicalNode";
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
+import {getLastSelection, setLastSelection} from "../../utils/selection";
export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
}
protected triggerStateUpdate(update: EditorUiStateUpdate): void {
- const context = this.getContext();
- context.lastSelection = update.selection;
+ setLastSelection(update.editor, update.selection);
this.toolbar?.updateState(update);
this.updateContextToolbars(update);
for (const toolbar of this.activeContextToolbars) {
}
triggerStateRefresh(): void {
+ const editor = this.getContext().editor;
this.triggerStateUpdate({
- editor: this.getContext().editor,
- selection: this.getContext().lastSelection,
+ editor,
+ selection: getLastSelection(editor),
});
}
scrollDOM: scrollContainer,
manager,
translate: (text: string): string => text,
- lastSelection: null,
options,
};
manager.setContext(context);
import {$createDiagramNode, DiagramNode} from "../nodes/diagram";
import {ImageManager} from "../../components";
import {EditorImageData} from "./images";
-import {$getNodeFromSelection} from "./selection";
+import {$getNodeFromSelection, getLastSelection} from "./selection";
export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode {
return node instanceof DiagramNode;
}
export function showDiagramManagerForInsert(context: EditorUiContext) {
- const selection = context.lastSelection;
+ const selection = getLastSelection(context.editor);
showDiagramManager((image: EditorImageData) => {
context.editor.update(() => {
const diagramNode = $createDiagramNode(image.id, image.url);
--- /dev/null
+import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text";
+import {$getSelection, LexicalEditor, LexicalNode} from "lexical";
+import {
+ $getBlockElementNodesInSelection,
+ $getNodeFromSelection,
+ $insertNewBlockNodeAtSelection,
+ $toggleSelectionBlockNodeType,
+ getLastSelection
+} from "./selection";
+import {$createCustomHeadingNode, $isCustomHeadingNode} from "../nodes/custom-heading";
+import {$createCustomParagraphNode, $isCustomParagraphNode} from "../nodes/custom-paragraph";
+import {$createCustomQuoteNode} from "../nodes/custom-quote";
+import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block";
+import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout";
+
+const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => {
+ return $isCustomHeadingNode(node) && (node as HeadingNode).getTag() === tag;
+};
+
+export function toggleSelectionAsHeading(editor: LexicalEditor, tag: HeadingTagType) {
+ editor.update(() => {
+ $toggleSelectionBlockNodeType(
+ (node) => $isHeaderNodeOfTag(node, tag),
+ () => $createCustomHeadingNode(tag),
+ )
+ });
+}
+
+export function toggleSelectionAsParagraph(editor: LexicalEditor) {
+ editor.update(() => {
+ $toggleSelectionBlockNodeType($isCustomParagraphNode, $createCustomParagraphNode);
+ });
+}
+
+export function toggleSelectionAsBlockquote(editor: LexicalEditor) {
+ editor.update(() => {
+ $toggleSelectionBlockNodeType($isQuoteNode, $createCustomQuoteNode);
+ });
+}
+
+export function formatCodeBlock(editor: LexicalEditor) {
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+ const lastSelection = getLastSelection(editor);
+ const codeBlock = $getNodeFromSelection(lastSelection, $isCodeBlockNode) as (CodeBlockNode | null);
+ if (codeBlock === null) {
+ editor.update(() => {
+ const codeBlock = $createCodeBlockNode();
+ codeBlock.setCode(selection?.getTextContent() || '');
+ $insertNewBlockNodeAtSelection(codeBlock, true);
+ $openCodeEditorForNode(editor, codeBlock);
+ codeBlock.selectStart();
+ });
+ } else {
+ $openCodeEditorForNode(editor, codeBlock);
+ }
+ });
+}
+
+export function cycleSelectionCalloutFormats(editor: LexicalEditor) {
+ editor.update(() => {
+ const selection = $getSelection();
+ const blocks = $getBlockElementNodesInSelection(selection);
+
+ let created = false;
+ for (const block of blocks) {
+ if (!$isCalloutNode(block)) {
+ block.replace($createCalloutNode('info'), true);
+ created = true;
+ }
+ }
+
+ if (created) {
+ return;
+ }
+
+ const types: CalloutCategory[] = ['info', 'warning', 'danger', 'success'];
+ for (const block of blocks) {
+ if ($isCalloutNode(block)) {
+ const type = block.getCategory();
+ const typeIndex = types.indexOf(type);
+ const newIndex = (typeIndex + 1) % types.length;
+ const newType = types[newIndex];
+ block.setCategory(newType);
+ }
+ }
+ });
+}
\ No newline at end of file
$setSelection,
BaseSelection,
ElementFormatType,
- ElementNode,
+ ElementNode, LexicalEditor,
LexicalNode,
TextFormatType
} from "lexical";
import {$setBlocksType} from "@lexical/selection";
import {$getParentOfType} from "./nodes";
+import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
+
+const lastSelectionByEditor = new WeakMap<LexicalEditor, BaseSelection|null>;
+
+export function getLastSelection(editor: LexicalEditor): BaseSelection|null {
+ return lastSelectionByEditor.get(editor) || null;
+}
+
+export function setLastSelection(editor: LexicalEditor, selection: BaseSelection|null): void {
+ lastSelectionByEditor.set(editor, selection);
+}
export function $selectionContainsNodeType(selection: BaseSelection | null, matcher: LexicalNodeMatcher): boolean {
return $getNodeFromSelection(selection, matcher) !== null;
const selection = $getSelection();
const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null;
if (selection && matcher(blockElement)) {
- $setBlocksType(selection, $createParagraphNode);
+ $setBlocksType(selection, $createCustomParagraphNode);
} else {
$setBlocksType(selection, creator);
}