diff --git a/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs b/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs
new file mode 100644
index 00000000000..989a4bbbeb8
--- /dev/null
+++ b/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs
@@ -0,0 +1,372 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {
+ applyCodeBlock,
+ applyHeading,
+ applyNormalFormat,
+ applyQuoteBlock,
+ centerAlign,
+ clearFormatting,
+ decreaseFontSize,
+ increaseFontSize,
+ indent,
+ justifyAlign,
+ leftAlign,
+ outdent,
+ rightAlign,
+ selectCharacters,
+ toggleBold,
+ toggleBulletList,
+ toggleChecklist,
+ toggleInsertCodeBlock,
+ toggleItalic,
+ toggleNumberedList,
+ toggleStrikethrough,
+ toggleSubscript,
+ toggleSuperscript,
+ toggleUnderline,
+} from '../keyboardShortcuts/index.mjs';
+import {
+ assertHTML,
+ assertSelection,
+ evaluate,
+ expect,
+ focusEditor,
+ html,
+ initialize,
+ test,
+ textContent,
+} from '../utils/index.mjs';
+
+const formatTestCases = [
+ {
+ applyShortcut: (page) => applyNormalFormat(page),
+ canToggle: false,
+ format: 'Normal',
+ },
+ {
+ applyShortcut: (page) => applyHeading(page, 1),
+ canToggle: false,
+ format: 'Heading 1',
+ },
+ {
+ applyShortcut: (page) => applyHeading(page, 2),
+ canToggle: false,
+ format: 'Heading 2',
+ },
+ {
+ applyShortcut: (page) => applyHeading(page, 3),
+ canToggle: false,
+ format: 'Heading 3',
+ },
+ {
+ applyShortcut: (page) => toggleBulletList(page),
+ canToggle: true,
+ format: 'Bulleted List',
+ },
+ {
+ applyShortcut: (page) => toggleNumberedList(page),
+ canToggle: true,
+ format: 'Numbered List',
+ },
+ {
+ applyShortcut: (page) => toggleChecklist(page),
+ canToggle: true,
+ format: 'Check List',
+ },
+ {
+ applyShortcut: (page) => applyQuoteBlock(page),
+ canToggle: false,
+ format: 'Quote',
+ },
+ {
+ applyShortcut: (page) => applyCodeBlock(page),
+ canToggle: false,
+ format: 'Code Block',
+ },
+];
+
+const alignmentTestCases = [
+ {
+ alignment: 'Left Align',
+ applyShortcut: (page) => leftAlign(page),
+ },
+ {
+ alignment: 'Center Align',
+ applyShortcut: (page) => centerAlign(page),
+ },
+ {
+ alignment: 'Right Align',
+ applyShortcut: (page) => rightAlign(page),
+ },
+ {
+ alignment: 'Justify Align',
+ applyShortcut: (page) => justifyAlign(page),
+ },
+];
+
+const additionalStylesTestCases = [
+ {
+ applyShortcut: (page) => toggleStrikethrough(page),
+ style: 'Strikethrough',
+ },
+ {
+ applyShortcut: (page) => toggleSubscript(page),
+ style: 'Subscript',
+ },
+ {
+ applyShortcut: (page) => toggleSuperscript(page),
+ style: 'Superscript',
+ },
+];
+
+const DEFAULT_FORMAT = 'Normal';
+
+const getSelectedFormat = async (page) => {
+ return await textContent(
+ page,
+ '.toolbar-item.block-controls > .text.dropdown-button-text',
+ );
+};
+
+const isDropdownItemActive = async (page, dropdownItemIndex) => {
+ return await evaluate(
+ page,
+ async (_dropdownItemIndex) => {
+ await document
+ .querySelector(
+ 'button[aria-label="Formatting options for additional text styles"]',
+ )
+ .click();
+
+ const isActive = await document
+ .querySelector('.dropdown')
+ .children[_dropdownItemIndex].classList.contains('active');
+
+ await document
+ .querySelector(
+ 'button[aria-label="Formatting options for additional text styles"]',
+ )
+ .click();
+
+ return isActive;
+ },
+ dropdownItemIndex,
+ );
+};
+
+test.describe('Keyboard shortcuts', () => {
+ test.beforeEach(({isPlainText, isCollab, page}) => {
+ test.skip(isPlainText);
+ return initialize({isCollab, page});
+ });
+
+ formatTestCases.forEach(({format, applyShortcut, canToggle}) => {
+ test(`Can use ${format} format with the shortcut`, async ({
+ page,
+ isPlainText,
+ }) => {
+ await focusEditor(page);
+
+ if (format === DEFAULT_FORMAT) {
+ // Apply a different format first
+ await applyHeading(page, 1);
+ }
+
+ await applyShortcut(page);
+
+ expect(await getSelectedFormat(page)).toBe(format);
+
+ if (canToggle) {
+ await applyShortcut(page);
+
+ // Should revert back to the default format
+ expect(await getSelectedFormat(page)).toBe(DEFAULT_FORMAT);
+ }
+ });
+ });
+
+ alignmentTestCases.forEach(({alignment, applyShortcut}, index) => {
+ test(`Can use ${alignment} with the shortcut`, async ({
+ page,
+ isPlainText,
+ }) => {
+ await focusEditor(page);
+ await applyShortcut(page);
+
+ const selectedAlignment = await textContent(
+ page,
+ '.toolbar-item.spaced.alignment > .text.dropdown-button-text',
+ );
+
+ expect(selectedAlignment).toBe(alignment);
+ });
+ });
+
+ additionalStylesTestCases.forEach(
+ ({applyShortcut, style}, dropdownItemIndex) => {
+ test(`Can use ${style} with the shortcut`, async ({
+ page,
+ isPlainText,
+ }) => {
+ await focusEditor(page);
+ await applyShortcut(page);
+
+ expect(await isDropdownItemActive(page, dropdownItemIndex)).toBe(true);
+
+ // Toggle the style off and check if it's off
+ await focusEditor(page);
+ await applyShortcut(page);
+ expect(await isDropdownItemActive(page, dropdownItemIndex)).toBe(false);
+ });
+ },
+ );
+
+ test('Can increase and decrease font size with the shortcuts', async ({
+ page,
+ isPlainText,
+ }) => {
+ await focusEditor(page);
+ await increaseFontSize(page);
+
+ const getFontSize = async () => {
+ return await evaluate(page, () => {
+ return document.querySelector('.font-size-input').value;
+ });
+ };
+
+ expect(await getFontSize()).toBe('17');
+ await decreaseFontSize(page);
+ expect(await getFontSize()).toBe('15');
+ });
+
+ test('Can clear formatting with the shortcut', async ({
+ page,
+ isPlainText,
+ }) => {
+ await focusEditor(page);
+ // Apply some formatting first
+ await page.keyboard.type('abc');
+ await selectCharacters(page, 'left', 3);
+
+ await assertSelection(page, {
+ anchorOffset: 3,
+ anchorPath: [0, 0, 0],
+ focusOffset: 0,
+ focusPath: [0, 0, 0],
+ });
+
+ await toggleBold(page);
+ await toggleItalic(page);
+ await toggleUnderline(page);
+ await toggleStrikethrough(page);
+ await toggleSubscript(page);
+
+ await assertHTML(
+ page,
+ html`
+
+
+
+ abc
+
+
+
+ `,
+ );
+
+ await clearFormatting(page);
+
+ await assertHTML(
+ page,
+ html`
+
+ abc
+
+ `,
+ );
+ });
+
+ test('Can toggle Insert Code Block with the shortcut', async ({
+ page,
+ isPlainText,
+ }) => {
+ await focusEditor(page);
+
+ const isCodeBlockActive = async () => {
+ return await evaluate(page, () => {
+ return document
+ .querySelector(`button[aria-label="Insert code block"]`)
+ .classList.contains('active');
+ });
+ };
+
+ // Toggle the code block on
+ await toggleInsertCodeBlock(page);
+ expect(await isCodeBlockActive()).toBe(true);
+
+ // Toggle the code block off
+ await toggleInsertCodeBlock(page);
+ expect(await isCodeBlockActive()).toBe(false);
+ });
+
+ test('Can indent and outdent with the shortcuts', async ({
+ page,
+ isPlainText,
+ }) => {
+ await focusEditor(page);
+ await page.keyboard.type('abc');
+ await indent(page, 3);
+
+ await assertHTML(
+ page,
+ html`
+
+ abc
+
+ `,
+ );
+
+ await outdent(page, 2);
+
+ await assertHTML(
+ page,
+ html`
+
+ abc
+
+ `,
+ );
+
+ await outdent(page, 1);
+
+ await assertHTML(
+ page,
+ html`
+
+ abc
+
+ `,
+ );
+ });
+});
diff --git a/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs b/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs
index f767c590698..41893cd7900 100644
--- a/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs
+++ b/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs
@@ -253,6 +253,22 @@ export async function toggleItalic(page) {
await keyUpCtrlOrMeta(page);
}
+export async function toggleInsertCodeBlock(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Shift');
+ await page.keyboard.press('c');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Shift');
+}
+
+export async function toggleStrikethrough(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Shift');
+ await page.keyboard.press('s');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Shift');
+}
+
export async function pressShiftEnter(page) {
await page.keyboard.down('Shift');
await page.keyboard.press('Enter');
@@ -288,3 +304,143 @@ export async function paste(page) {
await page.keyboard.press('KeyV');
await keyUpCtrlOrMeta(page);
}
+
+export async function toggleSubscript(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.press(',');
+ await keyUpCtrlOrMeta(page);
+}
+
+export async function toggleSuperscript(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.press('.');
+ await keyUpCtrlOrMeta(page);
+}
+
+export async function clearFormatting(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.press('\\');
+ await keyUpCtrlOrMeta(page);
+}
+
+export async function leftAlign(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Shift');
+ await page.keyboard.press('l');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Shift');
+}
+
+export async function centerAlign(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Shift');
+ await page.keyboard.press('e');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Shift');
+}
+
+export async function rightAlign(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Shift');
+ await page.keyboard.press('r');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Shift');
+}
+
+export async function justifyAlign(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Shift');
+ await page.keyboard.press('j');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Shift');
+}
+
+export async function outdent(page, times = 1) {
+ for (let i = 0; i < times; i++) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.press('[');
+ await keyUpCtrlOrMeta(page);
+ }
+}
+
+export async function indent(page, times = 1) {
+ for (let i = 0; i < times; i++) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.press(']');
+ await keyUpCtrlOrMeta(page);
+ }
+}
+
+export async function applyNormalFormat(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Alt');
+ await page.keyboard.press('0');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Alt');
+}
+
+export async function applyHeading(page, level) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Alt');
+ await page.keyboard.press(level.toString());
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Alt');
+}
+
+export async function toggleBulletList(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Alt');
+ await page.keyboard.press('4');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Alt');
+}
+
+export async function toggleNumberedList(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Alt');
+ await page.keyboard.press('5');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Alt');
+}
+
+export async function toggleChecklist(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Alt');
+ await page.keyboard.press('6');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Alt');
+}
+
+export async function applyQuoteBlock(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Alt');
+ await page.keyboard.press('q');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Alt');
+}
+
+export async function applyCodeBlock(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Alt');
+ await page.keyboard.press('c');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Alt');
+}
+
+export async function increaseFontSize(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Shift');
+ // shift + '.' becomes '>' on US keyboard layout. See https://keycode.info/
+ await page.keyboard.press('>');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Shift');
+}
+
+export async function decreaseFontSize(page) {
+ await keyDownCtrlOrMeta(page);
+ await page.keyboard.down('Shift');
+ // shift + ',' becomes '<' on US keyboard layout. See https://keycode.info/
+ await page.keyboard.press('<');
+ await keyUpCtrlOrMeta(page);
+ await page.keyboard.up('Shift');
+}
diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx
index 3cdc15f505f..6f20d8c3841 100644
--- a/packages/lexical-playground/src/App.tsx
+++ b/packages/lexical-playground/src/App.tsx
@@ -24,6 +24,7 @@ import {FlashMessageContext} from './context/FlashMessageContext';
import {SettingsContext, useSettings} from './context/SettingsContext';
import {SharedAutocompleteContext} from './context/SharedAutocompleteContext';
import {SharedHistoryContext} from './context/SharedHistoryContext';
+import {ToolbarContext} from './context/ToolbarContext';
import Editor from './Editor';
import logo from './images/logo.svg';
import PlaygroundNodes from './nodes/PlaygroundNodes';
@@ -211,20 +212,22 @@ function App(): JSX.Element {
-
-
-
-
-
- {isDevPlayground ? : null}
- {isDevPlayground ? : null}
- {isDevPlayground ? : null}
+
+
+
+
+
+
+ {isDevPlayground ? : null}
+ {isDevPlayground ? : null}
+ {isDevPlayground ? : null}
- {measureTypingPerf ? : null}
+ {measureTypingPerf ? : null}
+
diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx
index 31d8a38d433..2c4f0419575 100644
--- a/packages/lexical-playground/src/Editor.tsx
+++ b/packages/lexical-playground/src/Editor.tsx
@@ -12,6 +12,7 @@ import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin';
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
import {ClickableLinkPlugin} from '@lexical/react/LexicalClickableLinkPlugin';
import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
@@ -59,6 +60,7 @@ import {MaxLengthPlugin} from './plugins/MaxLengthPlugin';
import MentionsPlugin from './plugins/MentionsPlugin';
import PageBreakPlugin from './plugins/PageBreakPlugin';
import PollPlugin from './plugins/PollPlugin';
+import ShortcutsPlugin from './plugins/ShortcutsPlugin';
import SpeechToTextPlugin from './plugins/SpeechToTextPlugin';
import TabFocusPlugin from './plugins/TabFocusPlugin';
import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin';
@@ -104,6 +106,8 @@ export default function Editor(): JSX.Element {
useState(null);
const [isSmallWidthViewport, setIsSmallWidthViewport] =
useState(false);
+ const [editor] = useLexicalComposerContext();
+ const [activeEditor, setActiveEditor] = useState(editor);
const [isLinkEditMode, setIsLinkEditMode] = useState(false);
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
@@ -131,7 +135,20 @@ export default function Editor(): JSX.Element {
return (
<>
- {isRichText && }
+ {isRichText && (
+
+ )}
+ {isRichText && (
+
+ )}
= ToolbarState[Key];
+
+type ContextShape = {
+ toolbarState: ToolbarState;
+ updateToolbarState
(
+ key: Key,
+ value: ToolbarStateValue,
+ ): void;
+};
+
+const Context = createContext(undefined);
+
+export const ToolbarContext = ({
+ children,
+}: {
+ children: ReactNode;
+}): JSX.Element => {
+ const [toolbarState, setToolbarState] = useState(INITIAL_TOOLBAR_STATE);
+ const selectionFontSize = toolbarState.fontSize;
+
+ const updateToolbarState = useCallback(
+ (key: Key, value: ToolbarStateValue) => {
+ setToolbarState((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ },
+ [],
+ );
+
+ useEffect(() => {
+ updateToolbarState('fontSizeInputValue', selectionFontSize.slice(0, -2));
+ }, [selectionFontSize, updateToolbarState]);
+
+ const contextValue = useMemo(() => {
+ return {
+ toolbarState,
+ updateToolbarState,
+ };
+ }, [toolbarState, updateToolbarState]);
+
+ return {children};
+};
+
+export const useToolbarState = () => {
+ const context = useContext(Context);
+
+ if (context === undefined) {
+ throw new Error('useToolbarState must be used within a ToolbarProvider');
+ }
+
+ return context;
+};
diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css
index 40b443d0976..e5362290c69 100644
--- a/packages/lexical-playground/src/index.css
+++ b/packages/lexical-playground/src/index.css
@@ -751,9 +751,22 @@ i.page-break,
min-width: 100px;
}
-.dropdown .item.fontsize-item,
-.dropdown .item.fontsize-item .text {
- min-width: unset;
+.dropdown .item.wide {
+ align-items: center;
+ width: 248px;
+}
+
+.dropdown .item.wide .icon-text-container {
+ display: flex;
+
+ .text {
+ min-width: 120px;
+ }
+}
+
+.dropdown .item .shortcut {
+ color: #939393;
+ self-align: flex-end;
}
.dropdown .item .active {
diff --git a/packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx b/packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx
new file mode 100644
index 00000000000..4549d8a10e8
--- /dev/null
+++ b/packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx
@@ -0,0 +1,168 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {TOGGLE_LINK_COMMAND} from '@lexical/link';
+import {HeadingTagType} from '@lexical/rich-text';
+import {
+ COMMAND_PRIORITY_NORMAL,
+ FORMAT_ELEMENT_COMMAND,
+ FORMAT_TEXT_COMMAND,
+ INDENT_CONTENT_COMMAND,
+ KEY_MODIFIER_COMMAND,
+ LexicalEditor,
+ OUTDENT_CONTENT_COMMAND,
+} from 'lexical';
+import {Dispatch, useEffect} from 'react';
+
+import {useToolbarState} from '../../context/ToolbarContext';
+import {sanitizeUrl} from '../../utils/url';
+import {
+ clearFormatting,
+ formatBulletList,
+ formatCheckList,
+ formatCode,
+ formatHeading,
+ formatNumberedList,
+ formatParagraph,
+ formatQuote,
+ updateFontSize,
+ UpdateFontSizeType,
+} from '../ToolbarPlugin/utils';
+import {
+ isCenterAlign,
+ isClearFormatting,
+ isDecreaseFontSize,
+ isFormatBulletList,
+ isFormatCheckList,
+ isFormatCode,
+ isFormatHeading,
+ isFormatNumberedList,
+ isFormatParagraph,
+ isFormatQuote,
+ isIncreaseFontSize,
+ isIndent,
+ isInsertCodeBlock,
+ isInsertLink,
+ isJustifyAlign,
+ isLeftAlign,
+ isOutdent,
+ isRightAlign,
+ isStrikeThrough,
+ isSubscript,
+ isSuperscript,
+} from './shortcuts';
+
+export default function ShortcutsPlugin({
+ editor,
+ setIsLinkEditMode,
+}: {
+ editor: LexicalEditor;
+ setIsLinkEditMode: Dispatch;
+}): null {
+ const {toolbarState} = useToolbarState();
+
+ useEffect(() => {
+ const keyboardShortcutsHandler = (payload: KeyboardEvent) => {
+ const event: KeyboardEvent = payload;
+
+ if (isFormatParagraph(event)) {
+ event.preventDefault();
+ formatParagraph(editor);
+ } else if (isFormatHeading(event)) {
+ event.preventDefault();
+ const {code} = event;
+ const headingSize = `h${code[code.length - 1]}` as HeadingTagType;
+ formatHeading(editor, toolbarState.blockType, headingSize);
+ } else if (isFormatBulletList(event)) {
+ event.preventDefault();
+ formatBulletList(editor, toolbarState.blockType);
+ } else if (isFormatNumberedList(event)) {
+ event.preventDefault();
+ formatNumberedList(editor, toolbarState.blockType);
+ } else if (isFormatCheckList(event)) {
+ event.preventDefault();
+ formatCheckList(editor, toolbarState.blockType);
+ } else if (isFormatCode(event)) {
+ event.preventDefault();
+ formatCode(editor, toolbarState.blockType);
+ } else if (isFormatQuote(event)) {
+ event.preventDefault();
+ formatQuote(editor, toolbarState.blockType);
+ } else if (isStrikeThrough(event)) {
+ event.preventDefault();
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
+ } else if (isIndent(event)) {
+ event.preventDefault();
+ editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
+ } else if (isOutdent(event)) {
+ event.preventDefault();
+ editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
+ } else if (isCenterAlign(event)) {
+ event.preventDefault();
+ editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
+ } else if (isLeftAlign(event)) {
+ event.preventDefault();
+ editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
+ } else if (isRightAlign(event)) {
+ event.preventDefault();
+ editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right');
+ } else if (isJustifyAlign(event)) {
+ event.preventDefault();
+ editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify');
+ } else if (isSubscript(event)) {
+ event.preventDefault();
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript');
+ } else if (isSuperscript(event)) {
+ event.preventDefault();
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript');
+ } else if (isInsertCodeBlock(event)) {
+ event.preventDefault();
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
+ } else if (isIncreaseFontSize(event)) {
+ event.preventDefault();
+ updateFontSize(
+ editor,
+ UpdateFontSizeType.increment,
+ toolbarState.fontSizeInputValue,
+ );
+ } else if (isDecreaseFontSize(event)) {
+ event.preventDefault();
+ updateFontSize(
+ editor,
+ UpdateFontSizeType.decrement,
+ toolbarState.fontSizeInputValue,
+ );
+ } else if (isClearFormatting(event)) {
+ event.preventDefault();
+ clearFormatting(editor);
+ } else if (isInsertLink(event)) {
+ event.preventDefault();
+ const url = toolbarState.isLink ? null : sanitizeUrl('https://');
+ setIsLinkEditMode(!toolbarState.isLink);
+
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
+ }
+
+ return false;
+ };
+
+ return editor.registerCommand(
+ KEY_MODIFIER_COMMAND,
+ keyboardShortcutsHandler,
+ COMMAND_PRIORITY_NORMAL,
+ );
+ }, [
+ editor,
+ toolbarState.isLink,
+ toolbarState.blockType,
+ toolbarState.fontSizeInputValue,
+ setIsLinkEditMode,
+ ]);
+
+ return null;
+}
diff --git a/packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts b/packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts
new file mode 100644
index 00000000000..4a959f9dcac
--- /dev/null
+++ b/packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts
@@ -0,0 +1,225 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {IS_APPLE} from 'shared/environment';
+
+//disable eslint sorting rule for quick reference to shortcuts
+/* eslint-disable sort-keys-fix/sort-keys-fix */
+export const SHORTCUTS = Object.freeze({
+ // (Ctrl|⌘) + (Alt|Option) + shortcuts
+ NORMAL: IS_APPLE ? '⌘+Opt+0' : 'Ctrl+Alt+0',
+ HEADING1: IS_APPLE ? '⌘+Opt+1' : 'Ctrl+Alt+1',
+ HEADING2: IS_APPLE ? '⌘+Opt+2' : 'Ctrl+Alt+2',
+ HEADING3: IS_APPLE ? '⌘+Opt+3' : 'Ctrl+Alt+3',
+ BULLET_LIST: IS_APPLE ? '⌘+Opt+4' : 'Ctrl+Alt+4',
+ NUMBERED_LIST: IS_APPLE ? '⌘+Opt+5' : 'Ctrl+Alt+5',
+ CHECK_LIST: IS_APPLE ? '⌘+Opt+6' : 'Ctrl+Alt+6',
+ CODE_BLOCK: IS_APPLE ? '⌘+Opt+C' : 'Ctrl+Alt+C',
+ QUOTE: IS_APPLE ? '⌘+Opt+Q' : 'Ctrl+Alt+Q',
+
+ // (Ctrl|⌘) + Shift + shortcuts
+ INCREASE_FONT_SIZE: IS_APPLE ? '⌘+Shift+.' : 'Ctrl+Shift+.',
+ DECREASE_FONT_SIZE: IS_APPLE ? '⌘+Shift+,' : 'Ctrl+Shift+,',
+ INSERT_CODE_BLOCK: IS_APPLE ? '⌘+Shift+C' : 'Ctrl+Shift+C',
+ STRIKETHROUGH: IS_APPLE ? '⌘+Shift+S' : 'Ctrl+Shift+S',
+ CENTER_ALIGN: IS_APPLE ? '⌘+Shift+E' : 'Ctrl+Shift+E',
+ JUSTIFY_ALIGN: IS_APPLE ? '⌘+Shift+J' : 'Ctrl+Shift+J',
+ LEFT_ALIGN: IS_APPLE ? '⌘+Shift+L' : 'Ctrl+Shift+L',
+ RIGHT_ALIGN: IS_APPLE ? '⌘+Shift+R' : 'Ctrl+Shift+R',
+
+ // (Ctrl|⌘) + shortcuts
+ SUBSCRIPT: IS_APPLE ? '⌘+,' : 'Ctrl+,',
+ SUPERSCRIPT: IS_APPLE ? '⌘+.' : 'Ctrl+.',
+ INDENT: IS_APPLE ? '⌘+]' : 'Ctrl+]',
+ OUTDENT: IS_APPLE ? '⌘+[' : 'Ctrl+[',
+ CLEAR_FORMATTING: IS_APPLE ? '⌘+\\' : 'Ctrl+\\',
+ REDO: IS_APPLE ? '⌘+Shift+Z' : 'Ctrl+Y',
+ UNDO: IS_APPLE ? '⌘+Z' : 'Ctrl+Z',
+ BOLD: IS_APPLE ? '⌘+B' : 'Ctrl+B',
+ ITALIC: IS_APPLE ? '⌘+I' : 'Ctrl+I',
+ UNDERLINE: IS_APPLE ? '⌘+U' : 'Ctrl+U',
+ INSERT_LINK: IS_APPLE ? '⌘+K' : 'Ctrl+K',
+});
+
+export function controlOrMeta(metaKey: boolean, ctrlKey: boolean): boolean {
+ return IS_APPLE ? metaKey : ctrlKey;
+}
+
+export function isFormatParagraph(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+
+ return (
+ (code === 'Numpad0' || code === 'Digit0') &&
+ !shiftKey &&
+ altKey &&
+ controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isFormatHeading(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ const keyNumber = code[code.length - 1];
+
+ return (
+ ['1', '2', '3'].includes(keyNumber) &&
+ !shiftKey &&
+ altKey &&
+ controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isFormatBulletList(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ (code === 'Numpad4' || code === 'Digit4') &&
+ !shiftKey &&
+ altKey &&
+ controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isFormatNumberedList(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ (code === 'Numpad5' || code === 'Digit5') &&
+ !shiftKey &&
+ altKey &&
+ controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isFormatCheckList(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ (code === 'Numpad6' || code === 'Digit6') &&
+ !shiftKey &&
+ altKey &&
+ controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isFormatCode(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'KeyC' && !shiftKey && altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isFormatQuote(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'KeyQ' && !shiftKey && altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isStrikeThrough(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'KeyS' && shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isIndent(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'BracketRight' &&
+ !shiftKey &&
+ !altKey &&
+ controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isOutdent(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'BracketLeft' &&
+ !shiftKey &&
+ !altKey &&
+ controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isCenterAlign(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'KeyE' && shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isLeftAlign(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'KeyL' && shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isRightAlign(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'KeyR' && shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isJustifyAlign(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'KeyJ' && shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isSubscript(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'Comma' && !shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isSuperscript(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'Period' && !shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isInsertCodeBlock(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'KeyC' && shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isIncreaseFontSize(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'Period' && shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isDecreaseFontSize(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'Comma' && shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isClearFormatting(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'Backslash' &&
+ !shiftKey &&
+ !altKey &&
+ controlOrMeta(metaKey, ctrlKey)
+ );
+}
+
+export function isInsertLink(event: KeyboardEvent): boolean {
+ const {code, shiftKey, altKey, metaKey, ctrlKey} = event;
+ return (
+ code === 'KeyK' && !shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey)
+ );
+}
diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.tsx
index 1e1f5406820..ceb0dd408bd 100644
--- a/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.tsx
+++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.tsx
@@ -8,13 +8,19 @@
import './fontSize.css';
-import {$patchStyleText} from '@lexical/selection';
-import {$getSelection, LexicalEditor} from 'lexical';
+import {LexicalEditor} from 'lexical';
import * as React from 'react';
-const MIN_ALLOWED_FONT_SIZE = 8;
-const MAX_ALLOWED_FONT_SIZE = 72;
-const DEFAULT_FONT_SIZE = 15;
+import {
+ MAX_ALLOWED_FONT_SIZE,
+ MIN_ALLOWED_FONT_SIZE,
+} from '../../context/ToolbarContext';
+import {SHORTCUTS} from '../ShortcutsPlugin/shortcuts';
+import {
+ updateFontSize,
+ updateFontSizeInSelection,
+ UpdateFontSizeType,
+} from './utils';
export function parseAllowedFontSize(input: string): string {
const match = input.match(/^(\d+(?:\.\d+)?)px$/);
@@ -27,12 +33,6 @@ export function parseAllowedFontSize(input: string): string {
return '';
}
-// eslint-disable-next-line no-shadow
-enum updateFontSizeType {
- increment = 1,
- decrement,
-}
-
export default function FontSize({
selectionFontSize,
disabled,
@@ -45,105 +45,6 @@ export default function FontSize({
const [inputValue, setInputValue] = React.useState(selectionFontSize);
const [inputChangeFlag, setInputChangeFlag] = React.useState(false);
- /**
- * Calculates the new font size based on the update type.
- * @param currentFontSize - The current font size
- * @param updateType - The type of change, either increment or decrement
- * @returns the next font size
- */
- const calculateNextFontSize = (
- currentFontSize: number,
- updateType: updateFontSizeType | null,
- ) => {
- if (!updateType) {
- return currentFontSize;
- }
-
- let updatedFontSize: number = currentFontSize;
- switch (updateType) {
- case updateFontSizeType.decrement:
- switch (true) {
- case currentFontSize > MAX_ALLOWED_FONT_SIZE:
- updatedFontSize = MAX_ALLOWED_FONT_SIZE;
- break;
- case currentFontSize >= 48:
- updatedFontSize -= 12;
- break;
- case currentFontSize >= 24:
- updatedFontSize -= 4;
- break;
- case currentFontSize >= 14:
- updatedFontSize -= 2;
- break;
- case currentFontSize >= 9:
- updatedFontSize -= 1;
- break;
- default:
- updatedFontSize = MIN_ALLOWED_FONT_SIZE;
- break;
- }
- break;
-
- case updateFontSizeType.increment:
- switch (true) {
- case currentFontSize < MIN_ALLOWED_FONT_SIZE:
- updatedFontSize = MIN_ALLOWED_FONT_SIZE;
- break;
- case currentFontSize < 12:
- updatedFontSize += 1;
- break;
- case currentFontSize < 20:
- updatedFontSize += 2;
- break;
- case currentFontSize < 36:
- updatedFontSize += 4;
- break;
- case currentFontSize <= 60:
- updatedFontSize += 12;
- break;
- default:
- updatedFontSize = MAX_ALLOWED_FONT_SIZE;
- break;
- }
- break;
-
- default:
- break;
- }
- return updatedFontSize;
- };
- /**
- * Patches the selection with the updated font size.
- */
-
- const updateFontSizeInSelection = React.useCallback(
- (newFontSize: string | null, updateType: updateFontSizeType | null) => {
- const getNextFontSize = (prevFontSize: string | null): string => {
- if (!prevFontSize) {
- prevFontSize = `${DEFAULT_FONT_SIZE}px`;
- }
- prevFontSize = prevFontSize.slice(0, -2);
- const nextFontSize = calculateNextFontSize(
- Number(prevFontSize),
- updateType,
- );
- return `${nextFontSize}px`;
- };
-
- editor.update(() => {
- if (editor.isEditable()) {
- const selection = $getSelection();
- if (selection !== null) {
- $patchStyleText(selection, {
- 'font-size': newFontSize || getNextFontSize,
- });
- }
- }
- });
- },
- [editor],
- );
-
const handleKeyPress = (e: React.KeyboardEvent) => {
const inputValueNumber = Number(inputValue);
@@ -170,18 +71,6 @@ export default function FontSize({
}
};
- const handleButtonClick = (updateType: updateFontSizeType) => {
- if (inputValue !== '') {
- const nextFontSize = calculateNextFontSize(
- Number(inputValue),
- updateType,
- );
- updateFontSizeInSelection(String(nextFontSize) + 'px', null);
- } else {
- updateFontSizeInSelection(null, updateType);
- }
- };
-
const updateFontSizeByInputValue = (inputValueNumber: number) => {
let updatedFontSize = inputValueNumber;
if (inputValueNumber > MAX_ALLOWED_FONT_SIZE) {
@@ -191,7 +80,7 @@ export default function FontSize({
}
setInputValue(String(updatedFontSize));
- updateFontSizeInSelection(String(updatedFontSize) + 'px', null);
+ updateFontSizeInSelection(editor, String(updatedFontSize) + 'px', null);
setInputChangeFlag(false);
};
@@ -208,13 +97,18 @@ export default function FontSize({
(selectionFontSize !== '' &&
Number(inputValue) <= MIN_ALLOWED_FONT_SIZE)
}
- onClick={() => handleButtonClick(updateFontSizeType.decrement)}
- className="toolbar-item font-decrement">
+ onClick={() =>
+ updateFontSize(editor, UpdateFontSizeType.decrement, inputValue)
+ }
+ className="toolbar-item font-decrement"
+ aria-label="Decrease font size"
+ title={`Decrease font size (${SHORTCUTS.DECREASE_FONT_SIZE})`}>
= MAX_ALLOWED_FONT_SIZE)
}
- onClick={() => handleButtonClick(updateFontSizeType.increment)}
- className="toolbar-item font-increment">
+ onClick={() =>
+ updateFontSize(editor, UpdateFontSizeType.increment, inputValue)
+ }
+ className="toolbar-item font-increment"
+ aria-label="Increase font size"
+ title={`Increase font size (${SHORTCUTS.INCREASE_FONT_SIZE})`}>
>
diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx
index 7ce9abbdbcc..ed5da202bca 100644
--- a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx
+++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx
@@ -7,63 +7,42 @@
*/
import {
- $createCodeNode,
$isCodeNode,
CODE_LANGUAGE_FRIENDLY_NAME_MAP,
CODE_LANGUAGE_MAP,
getLanguageFriendlyName,
} from '@lexical/code';
import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
-import {
- $isListNode,
- INSERT_CHECK_LIST_COMMAND,
- INSERT_ORDERED_LIST_COMMAND,
- INSERT_UNORDERED_LIST_COMMAND,
- ListNode,
-} from '@lexical/list';
+import {$isListNode, ListNode} from '@lexical/list';
import {INSERT_EMBED_COMMAND} from '@lexical/react/LexicalAutoEmbedPlugin';
-import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
-import {$isDecoratorBlockNode} from '@lexical/react/LexicalDecoratorBlockNode';
import {INSERT_HORIZONTAL_RULE_COMMAND} from '@lexical/react/LexicalHorizontalRuleNode';
-import {
- $createHeadingNode,
- $createQuoteNode,
- $isHeadingNode,
- $isQuoteNode,
- HeadingTagType,
-} from '@lexical/rich-text';
+import {$isHeadingNode} from '@lexical/rich-text';
import {
$getSelectionStyleValueForProperty,
$isParentElementRTL,
$patchStyleText,
- $setBlocksType,
} from '@lexical/selection';
import {$isTableNode, $isTableSelection} from '@lexical/table';
import {
$findMatchingParent,
- $getNearestBlockElementAncestorOrThrow,
$getNearestNodeOfType,
$isEditorIsNestedEditor,
mergeRegister,
} from '@lexical/utils';
import {
- $createParagraphNode,
$getNodeByKey,
$getRoot,
$getSelection,
$isElementNode,
$isRangeSelection,
$isRootOrShadowRoot,
- $isTextNode,
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
COMMAND_PRIORITY_CRITICAL,
- COMMAND_PRIORITY_NORMAL,
ElementFormatType,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
INDENT_CONTENT_COMMAND,
- KEY_MODIFIER_COMMAND,
LexicalEditor,
NodeKey,
OUTDENT_CONTENT_COMMAND,
@@ -75,6 +54,10 @@ import {Dispatch, useCallback, useEffect, useState} from 'react';
import * as React from 'react';
import {IS_APPLE} from 'shared/environment';
+import {
+ blockTypeToBlockName,
+ useToolbarState,
+} from '../../context/ToolbarContext';
import useModal from '../../hooks/useModal';
import catTypingGif from '../../images/cat-typing.gif';
import {$createStickyNode} from '../../nodes/StickyNode';
@@ -95,23 +78,19 @@ import {InsertInlineImageDialog} from '../InlineImagePlugin';
import InsertLayoutDialog from '../LayoutPlugin/InsertLayoutDialog';
import {INSERT_PAGE_BREAK} from '../PageBreakPlugin';
import {InsertPollDialog} from '../PollPlugin';
+import {SHORTCUTS} from '../ShortcutsPlugin/shortcuts';
import {InsertTableDialog} from '../TablePlugin';
import FontSize from './fontSize';
-
-const blockTypeToBlockName = {
- bullet: 'Bulleted List',
- check: 'Check List',
- code: 'Code Block',
- h1: 'Heading 1',
- h2: 'Heading 2',
- h3: 'Heading 3',
- h4: 'Heading 4',
- h5: 'Heading 5',
- h6: 'Heading 6',
- number: 'Numbered List',
- paragraph: 'Normal',
- quote: 'Quote',
-};
+import {
+ clearFormatting,
+ formatBulletList,
+ formatCheckList,
+ formatCode,
+ formatHeading,
+ formatNumberedList,
+ formatParagraph,
+ formatQuote,
+} from './utils';
const rootTypeToRootName = {
root: 'Root',
@@ -213,79 +192,6 @@ function BlockFormatDropDown({
editor: LexicalEditor;
disabled?: boolean;
}): JSX.Element {
- const formatParagraph = () => {
- editor.update(() => {
- const selection = $getSelection();
- if ($isRangeSelection(selection)) {
- $setBlocksType(selection, () => $createParagraphNode());
- }
- });
- };
-
- const formatHeading = (headingSize: HeadingTagType) => {
- if (blockType !== headingSize) {
- editor.update(() => {
- const selection = $getSelection();
- $setBlocksType(selection, () => $createHeadingNode(headingSize));
- });
- }
- };
-
- const formatBulletList = () => {
- if (blockType !== 'bullet') {
- editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
- } else {
- formatParagraph();
- }
- };
-
- const formatCheckList = () => {
- if (blockType !== 'check') {
- editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
- } else {
- formatParagraph();
- }
- };
-
- const formatNumberedList = () => {
- if (blockType !== 'number') {
- editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
- } else {
- formatParagraph();
- }
- };
-
- const formatQuote = () => {
- if (blockType !== 'quote') {
- editor.update(() => {
- const selection = $getSelection();
- $setBlocksType(selection, () => $createQuoteNode());
- });
- }
- };
-
- const formatCode = () => {
- if (blockType !== 'code') {
- editor.update(() => {
- let selection = $getSelection();
-
- if (selection !== null) {
- if (selection.isCollapsed()) {
- $setBlocksType(selection, () => $createCodeNode());
- } else {
- const textContent = selection.getTextContent();
- const codeNode = $createCodeNode();
- selection.insertNodes([codeNode]);
- selection = $getSelection();
- if ($isRangeSelection(selection)) {
- selection.insertRawText(textContent);
- }
- }
- }
- });
- }
- };
-
return (
-
- Normal
+ className={
+ 'item wide ' + dropDownActiveClass(blockType === 'paragraph')
+ }
+ onClick={() => formatParagraph(editor)}>
+
+
+ Normal
+
+ {SHORTCUTS.NORMAL}
formatHeading('h1')}>
-
- Heading 1
+ className={'item wide ' + dropDownActiveClass(blockType === 'h1')}
+ onClick={() => formatHeading(editor, blockType, 'h1')}>
+
+
+ Heading 1
+
+ {SHORTCUTS.HEADING1}
formatHeading('h2')}>
-
- Heading 2
+ className={'item wide ' + dropDownActiveClass(blockType === 'h2')}
+ onClick={() => formatHeading(editor, blockType, 'h2')}>
+
+
+ Heading 2
+
+ {SHORTCUTS.HEADING2}
formatHeading('h3')}>
-
- Heading 3
+ className={'item wide ' + dropDownActiveClass(blockType === 'h3')}
+ onClick={() => formatHeading(editor, blockType, 'h3')}>
+
+
+ Heading 3
+
+ {SHORTCUTS.HEADING3}
-
- Bullet List
+ className={'item wide ' + dropDownActiveClass(blockType === 'bullet')}
+ onClick={() => formatBulletList(editor, blockType)}>
+
+
+ Bullet List
+
+ {SHORTCUTS.BULLET_LIST}
-
- Numbered List
+ className={'item wide ' + dropDownActiveClass(blockType === 'number')}
+ onClick={() => formatNumberedList(editor, blockType)}>
+
+
+ Numbered List
+
+ {SHORTCUTS.NUMBERED_LIST}
-
- Check List
+ className={'item wide ' + dropDownActiveClass(blockType === 'check')}
+ onClick={() => formatCheckList(editor, blockType)}>
+
+
+ Check List
+
+ {SHORTCUTS.CHECK_LIST}
-
- Quote
+ className={'item wide ' + dropDownActiveClass(blockType === 'quote')}
+ onClick={() => formatQuote(editor, blockType)}>
+
+
+ Quote
+
+ {SHORTCUTS.QUOTE}
-
- Code Block
+ className={'item wide ' + dropDownActiveClass(blockType === 'code')}
+ onClick={() => formatCode(editor, blockType)}>
+
+
+ Code Block
+
+ {SHORTCUTS.CODE_BLOCK}
);
@@ -436,39 +371,51 @@ function ElementFormatDropdown({
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
}}
- className="item">
-
- Left Align
+ className="item wide">
+
+
+ Left Align
+
+ {SHORTCUTS.LEFT_ALIGN}
{
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
}}
- className="item">
-
- Center Align
+ className="item wide">
+
+
+ Center Align
+
+ {SHORTCUTS.CENTER_ALIGN}
{
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right');
}}
- className="item">
-
- Right Align
+ className="item wide">
+
+
+ Right Align
+
+ {SHORTCUTS.RIGHT_ALIGN}
{
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify');
}}
- className="item">
-
- Justify Align
+ className="item wide">
+
+
+ Justify Align
+
+ {SHORTCUTS.JUSTIFY_ALIGN}
{
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'start');
}}
- className="item">
+ className="item wide">
{
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'end');
}}
- className="item">
+ className="item wide">
{
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
}}
- className="item">
-
- Outdent
+ className="item wide">
+
+
+ Outdent
+
+ {SHORTCUTS.OUTDENT}
{
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
}}
- className="item">
-
- Indent
+ className="item wide">
+
+
+ Indent
+
+ {SHORTCUTS.INDENT}
);
}
export default function ToolbarPlugin({
+ editor,
+ activeEditor,
+ setActiveEditor,
setIsLinkEditMode,
}: {
+ editor: LexicalEditor;
+ activeEditor: LexicalEditor;
+ setActiveEditor: Dispatch;
setIsLinkEditMode: Dispatch;
}): JSX.Element {
- const [editor] = useLexicalComposerContext();
- const [activeEditor, setActiveEditor] = useState(editor);
- const [blockType, setBlockType] =
- useState('paragraph');
- const [rootType, setRootType] =
- useState('root');
const [selectedElementKey, setSelectedElementKey] = useState(
null,
);
- const [fontSize, setFontSize] = useState('15px');
- const [fontColor, setFontColor] = useState('#000');
- const [bgColor, setBgColor] = useState('#fff');
- const [fontFamily, setFontFamily] = useState('Arial');
- const [elementFormat, setElementFormat] = useState('left');
- const [isLink, setIsLink] = useState(false);
- const [isBold, setIsBold] = useState(false);
- const [isItalic, setIsItalic] = useState(false);
- const [isUnderline, setIsUnderline] = useState(false);
- const [isStrikethrough, setIsStrikethrough] = useState(false);
- const [isSubscript, setIsSubscript] = useState(false);
- const [isSuperscript, setIsSuperscript] = useState(false);
- const [isCode, setIsCode] = useState(false);
- const [canUndo, setCanUndo] = useState(false);
- const [canRedo, setCanRedo] = useState(false);
const [modal, showModal] = useModal();
- const [isRTL, setIsRTL] = useState(false);
- const [codeLanguage, setCodeLanguage] = useState('');
const [isEditable, setIsEditable] = useState(() => editor.isEditable());
- const [isImageCaption, setIsImageCaption] = useState(false);
+ const {toolbarState, updateToolbarState} = useToolbarState();
const $updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
if (activeEditor !== editor && $isEditorIsNestedEditor(activeEditor)) {
const rootElement = activeEditor.getRootElement();
- setIsImageCaption(
+ updateToolbarState(
+ 'isImageCaption',
!!rootElement?.parentElement?.classList.contains(
'image-caption-container',
),
);
} else {
- setIsImageCaption(false);
+ updateToolbarState('isImageCaption', false);
}
const anchorNode = selection.anchor.getNode();
@@ -578,22 +515,19 @@ export default function ToolbarPlugin({
const elementKey = element.getKey();
const elementDOM = activeEditor.getElementByKey(elementKey);
- setIsRTL($isParentElementRTL(selection));
+ updateToolbarState('isRTL', $isParentElementRTL(selection));
// Update links
const node = getSelectedNode(selection);
const parent = node.getParent();
- if ($isLinkNode(parent) || $isLinkNode(node)) {
- setIsLink(true);
- } else {
- setIsLink(false);
- }
+ const isLink = $isLinkNode(parent) || $isLinkNode(node);
+ updateToolbarState('isLink', isLink);
const tableNode = $findMatchingParent(node, $isTableNode);
if ($isTableNode(tableNode)) {
- setRootType('table');
+ updateToolbarState('rootType', 'table');
} else {
- setRootType('root');
+ updateToolbarState('rootType', 'root');
}
if (elementDOM !== null) {
@@ -606,18 +540,23 @@ export default function ToolbarPlugin({
const type = parentList
? parentList.getListType()
: element.getListType();
- setBlockType(type);
+
+ updateToolbarState('blockType', type);
} else {
const type = $isHeadingNode(element)
? element.getTag()
: element.getType();
if (type in blockTypeToBlockName) {
- setBlockType(type as keyof typeof blockTypeToBlockName);
+ updateToolbarState(
+ 'blockType',
+ type as keyof typeof blockTypeToBlockName,
+ );
}
if ($isCodeNode(element)) {
const language =
element.getLanguage() as keyof typeof CODE_LANGUAGE_MAP;
- setCodeLanguage(
+ updateToolbarState(
+ 'codeLanguage',
language ? CODE_LANGUAGE_MAP[language] || language : '',
);
return;
@@ -625,17 +564,20 @@ export default function ToolbarPlugin({
}
}
// Handle buttons
- setFontColor(
+ updateToolbarState(
+ 'fontColor',
$getSelectionStyleValueForProperty(selection, 'color', '#000'),
);
- setBgColor(
+ updateToolbarState(
+ 'bgColor',
$getSelectionStyleValueForProperty(
selection,
'background-color',
'#fff',
),
);
- setFontFamily(
+ updateToolbarState(
+ 'fontFamily',
$getSelectionStyleValueForProperty(selection, 'font-family', 'Arial'),
);
let matchingParent;
@@ -648,7 +590,8 @@ export default function ToolbarPlugin({
}
// If matchingParent is a valid node, pass it's format type
- setElementFormat(
+ updateToolbarState(
+ 'elementFormat',
$isElementNode(matchingParent)
? matchingParent.getFormatType()
: $isElementNode(node)
@@ -658,19 +601,22 @@ export default function ToolbarPlugin({
}
if ($isRangeSelection(selection) || $isTableSelection(selection)) {
// Update text format
- setIsBold(selection.hasFormat('bold'));
- setIsItalic(selection.hasFormat('italic'));
- setIsUnderline(selection.hasFormat('underline'));
- setIsStrikethrough(selection.hasFormat('strikethrough'));
- setIsSubscript(selection.hasFormat('subscript'));
- setIsSuperscript(selection.hasFormat('superscript'));
- setIsCode(selection.hasFormat('code'));
-
- setFontSize(
+ updateToolbarState('isBold', selection.hasFormat('bold'));
+ updateToolbarState('isItalic', selection.hasFormat('italic'));
+ updateToolbarState('isUnderline', selection.hasFormat('underline'));
+ updateToolbarState(
+ 'isStrikethrough',
+ selection.hasFormat('strikethrough'),
+ );
+ updateToolbarState('isSubscript', selection.hasFormat('subscript'));
+ updateToolbarState('isSuperscript', selection.hasFormat('superscript'));
+ updateToolbarState('isCode', selection.hasFormat('code'));
+ updateToolbarState(
+ 'fontSize',
$getSelectionStyleValueForProperty(selection, 'font-size', '15px'),
);
}
- }, [activeEditor, editor]);
+ }, [activeEditor, editor, updateToolbarState]);
useEffect(() => {
return editor.registerCommand(
@@ -682,7 +628,7 @@ export default function ToolbarPlugin({
},
COMMAND_PRIORITY_CRITICAL,
);
- }, [editor, $updateToolbar]);
+ }, [editor, $updateToolbar, setActiveEditor]);
useEffect(() => {
activeEditor.getEditorState().read(() => {
@@ -703,7 +649,7 @@ export default function ToolbarPlugin({
activeEditor.registerCommand(
CAN_UNDO_COMMAND,
(payload) => {
- setCanUndo(payload);
+ updateToolbarState('canUndo', payload);
return false;
},
COMMAND_PRIORITY_CRITICAL,
@@ -711,38 +657,13 @@ export default function ToolbarPlugin({
activeEditor.registerCommand(
CAN_REDO_COMMAND,
(payload) => {
- setCanRedo(payload);
+ updateToolbarState('canRedo', payload);
return false;
},
COMMAND_PRIORITY_CRITICAL,
),
);
- }, [$updateToolbar, activeEditor, editor]);
-
- useEffect(() => {
- return activeEditor.registerCommand(
- KEY_MODIFIER_COMMAND,
- (payload) => {
- const event: KeyboardEvent = payload;
- const {code, ctrlKey, metaKey} = event;
-
- if (code === 'KeyK' && (ctrlKey || metaKey)) {
- event.preventDefault();
- let url: string | null;
- if (!isLink) {
- setIsLinkEditMode(true);
- url = sanitizeUrl('https://');
- } else {
- setIsLinkEditMode(false);
- url = null;
- }
- return activeEditor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
- }
- return false;
- },
- COMMAND_PRIORITY_NORMAL,
- );
- }, [activeEditor, isLink, setIsLinkEditMode]);
+ }, [$updateToolbar, activeEditor, editor, updateToolbarState]);
const applyStyleText = useCallback(
(styles: Record, skipHistoryStack?: boolean) => {
@@ -759,62 +680,6 @@ export default function ToolbarPlugin({
[activeEditor],
);
- const clearFormatting = useCallback(() => {
- activeEditor.update(() => {
- const selection = $getSelection();
- if ($isRangeSelection(selection) || $isTableSelection(selection)) {
- const anchor = selection.anchor;
- const focus = selection.focus;
- const nodes = selection.getNodes();
- const extractedNodes = selection.extract();
-
- if (anchor.key === focus.key && anchor.offset === focus.offset) {
- return;
- }
-
- nodes.forEach((node, idx) => {
- // We split the first and last node by the selection
- // So that we don't format unselected text inside those nodes
- if ($isTextNode(node)) {
- // Use a separate variable to ensure TS does not lose the refinement
- let textNode = node;
- if (idx === 0 && anchor.offset !== 0) {
- textNode = textNode.splitText(anchor.offset)[1] || textNode;
- }
- if (idx === nodes.length - 1) {
- textNode = textNode.splitText(focus.offset)[0] || textNode;
- }
- /**
- * If the selected text has one format applied
- * selecting a portion of the text, could
- * clear the format to the wrong portion of the text.
- *
- * The cleared text is based on the length of the selected text.
- */
- // We need this in case the selected text only has one format
- const extractedTextNode = extractedNodes[0];
- if (nodes.length === 1 && $isTextNode(extractedTextNode)) {
- textNode = extractedTextNode;
- }
-
- if (textNode.__style !== '') {
- textNode.setStyle('');
- }
- if (textNode.__format !== 0) {
- textNode.setFormat(0);
- $getNearestBlockElementAncestorOrThrow(textNode).setFormat('');
- }
- node = textNode;
- } else if ($isHeadingNode(node) || $isQuoteNode(node)) {
- node.replace($createParagraphNode(), true);
- } else if ($isDecoratorBlockNode(node)) {
- node.setFormat('');
- }
- });
- }
- });
- }, [activeEditor]);
-
const onFontColorSelect = useCallback(
(value: string, skipHistoryStack: boolean) => {
applyStyleText({color: value}, skipHistoryStack);
@@ -830,7 +695,7 @@ export default function ToolbarPlugin({
);
const insertLink = useCallback(() => {
- if (!isLink) {
+ if (!toolbarState.isLink) {
setIsLinkEditMode(true);
activeEditor.dispatchCommand(
TOGGLE_LINK_COMMAND,
@@ -840,7 +705,7 @@ export default function ToolbarPlugin({
setIsLinkEditMode(false);
activeEditor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
- }, [activeEditor, isLink, setIsLinkEditMode]);
+ }, [activeEditor, setIsLinkEditMode, toolbarState.isLink]);
const onCodeLanguageSelect = useCallback(
(value: string) => {
@@ -859,13 +724,13 @@ export default function ToolbarPlugin({
activeEditor.dispatchCommand(INSERT_IMAGE_COMMAND, payload);
};
- const canViewerSeeInsertDropdown = !isImageCaption;
- const canViewerSeeInsertCodeButton = !isImageCaption;
+ const canViewerSeeInsertDropdown = !toolbarState.isImageCaption;
+ const canViewerSeeInsertCodeButton = !toolbarState.isImageCaption;
return (
- {blockType in blockTypeToBlockName && activeEditor === editor && (
- <>
-
-
- >
- )}
- {blockType === 'code' ? (
+ {toolbarState.blockType in blockTypeToBlockName &&
+ activeEditor === editor && (
+ <>
+
+
+ >
+ )}
+ {toolbarState.blockType === 'code' ? (
{CODE_LANGUAGE_OPTIONS.map(([value, name]) => {
return (
onCodeLanguageSelect(value)}
key={value}>
@@ -922,12 +788,12 @@ export default function ToolbarPlugin({
@@ -937,12 +803,12 @@ export default function ToolbarPlugin({
onClick={() => {
activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
- className={'toolbar-item spaced ' + (isBold ? 'active' : '')}
- title={IS_APPLE ? 'Bold (⌘B)' : 'Bold (Ctrl+B)'}
+ className={
+ 'toolbar-item spaced ' + (toolbarState.isBold ? 'active' : '')
+ }
+ title={`Bold (${SHORTCUTS.BOLD})`}
type="button"
- aria-label={`Format text as bold. Shortcut: ${
- IS_APPLE ? '⌘B' : 'Ctrl+B'
- }`}>
+ aria-label={`Format text as bold. Shortcut: ${SHORTCUTS.BOLD}`}>
{canViewerSeeInsertCodeButton && (
@@ -977,8 +844,10 @@ export default function ToolbarPlugin({
onClick={() => {
activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
}}
- className={'toolbar-item spaced ' + (isCode ? 'active' : '')}
- title="Insert code block"
+ className={
+ 'toolbar-item spaced ' + (toolbarState.isCode ? 'active' : '')
+ }
+ title={`Insert code block (${SHORTCUTS.INSERT_CODE_BLOCK})`}
type="button"
aria-label="Insert code block">
@@ -987,9 +856,11 @@ export default function ToolbarPlugin({
@@ -998,7 +869,7 @@ export default function ToolbarPlugin({
buttonClassName="toolbar-item color-picker"
buttonAriaLabel="Formatting text color"
buttonIconClassName="icon font-color"
- color={fontColor}
+ color={toolbarState.fontColor}
onChange={onFontColorSelect}
title="text color"
/>
@@ -1007,7 +878,7 @@ export default function ToolbarPlugin({
buttonClassName="toolbar-item color-picker"
buttonAriaLabel="Formatting background color"
buttonIconClassName="icon bg-color"
- color={bgColor}
+ color={toolbarState.bgColor}
onChange={onBgColorSelect}
title="bg color"
/>
@@ -1024,21 +895,31 @@ export default function ToolbarPlugin({
'strikethrough',
);
}}
- className={'item ' + dropDownActiveClass(isStrikethrough)}
+ className={
+ 'item wide ' + dropDownActiveClass(toolbarState.isStrikethrough)
+ }
title="Strikethrough"
aria-label="Format text with a strikethrough">
-
- Strikethrough
+
+
+ Strikethrough
+
+ {SHORTCUTS.STRIKETHROUGH}
{
activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript');
}}
- className={'item ' + dropDownActiveClass(isSubscript)}
+ className={
+ 'item wide ' + dropDownActiveClass(toolbarState.isSubscript)
+ }
title="Subscript"
aria-label="Format text with a subscript">
-
- Subscript
+
+
+ Subscript
+
+ {SHORTCUTS.SUBSCRIPT}
{
@@ -1047,19 +928,27 @@ export default function ToolbarPlugin({
'superscript',
);
}}
- className={'item ' + dropDownActiveClass(isSuperscript)}
+ className={
+ 'item wide ' + dropDownActiveClass(toolbarState.isSuperscript)
+ }
title="Superscript"
aria-label="Format text with a superscript">
-
- Superscript
+
+
+ Superscript
+
+ {SHORTCUTS.SUPERSCRIPT}
clearFormatting(activeEditor)}
+ className="item wide"
title="Clear text formatting"
aria-label="Clear all text formatting">
-
- Clear Formatting
+
+
+ Clear Formatting
+
+ {SHORTCUTS.CLEAR_FORMATTING}
{canViewerSeeInsertDropdown && (
@@ -1236,9 +1125,9 @@ export default function ToolbarPlugin({
{modal}
diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts b/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts
new file mode 100644
index 00000000000..8d0ca04d5e3
--- /dev/null
+++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts
@@ -0,0 +1,292 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import {$createCodeNode} from '@lexical/code';
+import {
+ INSERT_CHECK_LIST_COMMAND,
+ INSERT_ORDERED_LIST_COMMAND,
+ INSERT_UNORDERED_LIST_COMMAND,
+} from '@lexical/list';
+import {$isDecoratorBlockNode} from '@lexical/react/LexicalDecoratorBlockNode';
+import {
+ $createHeadingNode,
+ $createQuoteNode,
+ $isHeadingNode,
+ $isQuoteNode,
+ HeadingTagType,
+} from '@lexical/rich-text';
+import {$patchStyleText, $setBlocksType} from '@lexical/selection';
+import {$isTableSelection} from '@lexical/table';
+import {$getNearestBlockElementAncestorOrThrow} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $getSelection,
+ $isRangeSelection,
+ $isTextNode,
+ LexicalEditor,
+} from 'lexical';
+
+import {
+ DEFAULT_FONT_SIZE,
+ MAX_ALLOWED_FONT_SIZE,
+ MIN_ALLOWED_FONT_SIZE,
+} from '../../context/ToolbarContext';
+
+// eslint-disable-next-line no-shadow
+export enum UpdateFontSizeType {
+ increment = 1,
+ decrement,
+}
+
+/**
+ * Calculates the new font size based on the update type.
+ * @param currentFontSize - The current font size
+ * @param updateType - The type of change, either increment or decrement
+ * @returns the next font size
+ */
+export const calculateNextFontSize = (
+ currentFontSize: number,
+ updateType: UpdateFontSizeType | null,
+) => {
+ if (!updateType) {
+ return currentFontSize;
+ }
+
+ let updatedFontSize: number = currentFontSize;
+ switch (updateType) {
+ case UpdateFontSizeType.decrement:
+ switch (true) {
+ case currentFontSize > MAX_ALLOWED_FONT_SIZE:
+ updatedFontSize = MAX_ALLOWED_FONT_SIZE;
+ break;
+ case currentFontSize >= 48:
+ updatedFontSize -= 12;
+ break;
+ case currentFontSize >= 24:
+ updatedFontSize -= 4;
+ break;
+ case currentFontSize >= 14:
+ updatedFontSize -= 2;
+ break;
+ case currentFontSize >= 9:
+ updatedFontSize -= 1;
+ break;
+ default:
+ updatedFontSize = MIN_ALLOWED_FONT_SIZE;
+ break;
+ }
+ break;
+
+ case UpdateFontSizeType.increment:
+ switch (true) {
+ case currentFontSize < MIN_ALLOWED_FONT_SIZE:
+ updatedFontSize = MIN_ALLOWED_FONT_SIZE;
+ break;
+ case currentFontSize < 12:
+ updatedFontSize += 1;
+ break;
+ case currentFontSize < 20:
+ updatedFontSize += 2;
+ break;
+ case currentFontSize < 36:
+ updatedFontSize += 4;
+ break;
+ case currentFontSize <= 60:
+ updatedFontSize += 12;
+ break;
+ default:
+ updatedFontSize = MAX_ALLOWED_FONT_SIZE;
+ break;
+ }
+ break;
+
+ default:
+ break;
+ }
+ return updatedFontSize;
+};
+
+/**
+ * Patches the selection with the updated font size.
+ */
+export const updateFontSizeInSelection = (
+ editor: LexicalEditor,
+ newFontSize: string | null,
+ updateType: UpdateFontSizeType | null,
+) => {
+ const getNextFontSize = (prevFontSize: string | null): string => {
+ if (!prevFontSize) {
+ prevFontSize = `${DEFAULT_FONT_SIZE}px`;
+ }
+ prevFontSize = prevFontSize.slice(0, -2);
+ const nextFontSize = calculateNextFontSize(
+ Number(prevFontSize),
+ updateType,
+ );
+ return `${nextFontSize}px`;
+ };
+
+ editor.update(() => {
+ if (editor.isEditable()) {
+ const selection = $getSelection();
+ if (selection !== null) {
+ $patchStyleText(selection, {
+ 'font-size': newFontSize || getNextFontSize,
+ });
+ }
+ }
+ });
+};
+
+export const updateFontSize = (
+ editor: LexicalEditor,
+ updateType: UpdateFontSizeType,
+ inputValue: string,
+) => {
+ if (inputValue !== '') {
+ const nextFontSize = calculateNextFontSize(Number(inputValue), updateType);
+ updateFontSizeInSelection(editor, String(nextFontSize) + 'px', null);
+ } else {
+ updateFontSizeInSelection(editor, null, updateType);
+ }
+};
+
+export const formatParagraph = (editor: LexicalEditor) => {
+ editor.update(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ $setBlocksType(selection, () => $createParagraphNode());
+ }
+ });
+};
+
+export const formatHeading = (
+ editor: LexicalEditor,
+ blockType: string,
+ headingSize: HeadingTagType,
+) => {
+ if (blockType !== headingSize) {
+ editor.update(() => {
+ const selection = $getSelection();
+ $setBlocksType(selection, () => $createHeadingNode(headingSize));
+ });
+ }
+};
+
+export const formatBulletList = (editor: LexicalEditor, blockType: string) => {
+ if (blockType !== 'bullet') {
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
+ } else {
+ formatParagraph(editor);
+ }
+};
+
+export const formatCheckList = (editor: LexicalEditor, blockType: string) => {
+ if (blockType !== 'check') {
+ editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
+ } else {
+ formatParagraph(editor);
+ }
+};
+
+export const formatNumberedList = (
+ editor: LexicalEditor,
+ blockType: string,
+) => {
+ if (blockType !== 'number') {
+ editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
+ } else {
+ formatParagraph(editor);
+ }
+};
+
+export const formatQuote = (editor: LexicalEditor, blockType: string) => {
+ if (blockType !== 'quote') {
+ editor.update(() => {
+ const selection = $getSelection();
+ $setBlocksType(selection, () => $createQuoteNode());
+ });
+ }
+};
+
+export const formatCode = (editor: LexicalEditor, blockType: string) => {
+ if (blockType !== 'code') {
+ editor.update(() => {
+ let selection = $getSelection();
+
+ if (selection !== null) {
+ if (selection.isCollapsed()) {
+ $setBlocksType(selection, () => $createCodeNode());
+ } else {
+ const textContent = selection.getTextContent();
+ const codeNode = $createCodeNode();
+ selection.insertNodes([codeNode]);
+ selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ selection.insertRawText(textContent);
+ }
+ }
+ }
+ });
+ }
+};
+
+export const clearFormatting = (editor: LexicalEditor) => {
+ editor.update(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection) || $isTableSelection(selection)) {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const nodes = selection.getNodes();
+ const extractedNodes = selection.extract();
+
+ if (anchor.key === focus.key && anchor.offset === focus.offset) {
+ return;
+ }
+
+ nodes.forEach((node, idx) => {
+ // We split the first and last node by the selection
+ // So that we don't format unselected text inside those nodes
+ if ($isTextNode(node)) {
+ // Use a separate variable to ensure TS does not lose the refinement
+ let textNode = node;
+ if (idx === 0 && anchor.offset !== 0) {
+ textNode = textNode.splitText(anchor.offset)[1] || textNode;
+ }
+ if (idx === nodes.length - 1) {
+ textNode = textNode.splitText(focus.offset)[0] || textNode;
+ }
+ /**
+ * If the selected text has one format applied
+ * selecting a portion of the text, could
+ * clear the format to the wrong portion of the text.
+ *
+ * The cleared text is based on the length of the selected text.
+ */
+ // We need this in case the selected text only has one format
+ const extractedTextNode = extractedNodes[0];
+ if (nodes.length === 1 && $isTextNode(extractedTextNode)) {
+ textNode = extractedTextNode;
+ }
+
+ if (textNode.__style !== '') {
+ textNode.setStyle('');
+ }
+ if (textNode.__format !== 0) {
+ textNode.setFormat(0);
+ $getNearestBlockElementAncestorOrThrow(textNode).setFormat('');
+ }
+ node = textNode;
+ } else if ($isHeadingNode(node) || $isQuoteNode(node)) {
+ node.replace($createParagraphNode(), true);
+ } else if ($isDecoratorBlockNode(node)) {
+ node.setFormat('');
+ }
+ });
+ }
+ });
+};