From dd65f5913123ffe6e1f9191f511283f9f46f5fc7 Mon Sep 17 00:00:00 2001 From: Michael Shafer Date: Wed, 6 Nov 2024 07:45:12 +1000 Subject: [PATCH 1/4] [lexical-yjs] Bug Fix: clean up dangling text after undo in collaboration (#6670) Co-authored-by: James Fitzsimmons Co-authored-by: James Fitzsimmons <119275535+james-atticus@users.noreply.github.com> Co-authored-by: Bob Ippolito --- .../__tests__/e2e/Collaboration.spec.mjs | 157 ++++++++++++++++++ packages/lexical-yjs/src/CollabElementNode.ts | 32 ++-- 2 files changed, 175 insertions(+), 14 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs index 762ee82e94e..b47b3c04f6a 100644 --- a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs @@ -230,4 +230,161 @@ test.describe('Collaboration', () => { focusPath: [1, 1, 0], }); }); + + test('Remove dangling text from YJS when there is no preceding text node', async ({ + isRichText, + page, + isCollab, + browserName, + }) => { + test.skip(!isCollab); + + // Left collaborator types two paragraphs of text + await focusEditor(page); + await page.keyboard.type('Line 1'); + await page.keyboard.press('Enter'); + await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency. + await page.keyboard.type('This is a test. '); + + // Right collaborator types at the end of paragraph 2 + await sleep(1050); + await page + .frameLocator('iframe[name="right"]') + .locator('[data-lexical-editor="true"]') + .focus(); + await page.keyboard.press('ArrowDown'); // Move caret to end of paragraph 2 + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('Word'); + + await assertHTML( + page, + html` +

+ Line 1 +

+

+ This is a test. Word +

+ `, + ); + + // Left collaborator undoes their text in the second paragraph. + await sleep(50); + await page.frameLocator('iframe[name="left"]').getByLabel('Undo').click(); + + // The undo also removed the text node from YJS. + // Check that the dangling text from right user was also removed. + await assertHTML( + page, + html` +

+ Line 1 +

+


+ `, + ); + + // Left collaborator refreshes their page + await page.evaluate(() => { + document + .querySelector('iframe[name="left"]') + .contentDocument.location.reload(); + }); + + // Page content should be the same as before the refresh + await assertHTML( + page, + html` +

+ Line 1 +

+


+ `, + ); + }); + + test('Merge dangling text into preceding text node', async ({ + isRichText, + page, + isCollab, + browserName, + }) => { + test.skip(!isCollab); + + // Left collaborator types two pieces of text in the same paragraph, but with different styling. + await focusEditor(page); + await page.keyboard.type('normal'); + await sleep(1050); + await toggleBold(page); + await page.keyboard.type('bold'); + + // Right collaborator types at the end of the paragraph. + await sleep(50); + await page + .frameLocator('iframe[name="right"]') + .locator('[data-lexical-editor="true"]') + .focus(); + await page.keyboard.press('ArrowDown'); // Move caret to end of paragraph + await page.keyboard.type('BOLD'); + + await assertHTML( + page, + html` +

+ normal + + boldBOLD + +

+ `, + ); + + // Left collaborator undoes their bold text. + await sleep(50); + await page.frameLocator('iframe[name="left"]').getByLabel('Undo').click(); + + // The undo also removed bold the text node from YJS. + // Check that the dangling text from right user was merged into the preceding text node. + await assertHTML( + page, + html` +

+ normalBOLD +

+ `, + ); + + // Left collaborator refreshes their page + await page.evaluate(() => { + document + .querySelector('iframe[name="left"]') + .contentDocument.location.reload(); + }); + + // Page content should be the same as before the refresh + await assertHTML( + page, + html` +

+ normalBOLD +

+ `, + ); + }); }); diff --git a/packages/lexical-yjs/src/CollabElementNode.ts b/packages/lexical-yjs/src/CollabElementNode.ts index f4c3f124c55..c38171af0e8 100644 --- a/packages/lexical-yjs/src/CollabElementNode.ts +++ b/packages/lexical-yjs/src/CollabElementNode.ts @@ -157,21 +157,25 @@ export class CollabElementNode { nodeIndex !== 0 ? children[nodeIndex - 1] : null; const nodeSize = node.getSize(); - if ( - offset === 0 && - delCount === 1 && - nodeIndex > 0 && - prevCollabNode instanceof CollabTextNode && - length === nodeSize && - // If the node has no keys, it's been deleted - Array.from(node._map.keys()).length === 0 - ) { - // Merge the text node with previous. - prevCollabNode._text += node._text; - children.splice(nodeIndex, 1); - } else if (offset === 0 && delCount === nodeSize) { - // The entire thing needs removing + if (offset === 0 && length === nodeSize) { + // Text node has been deleted. children.splice(nodeIndex, 1); + // If this was caused by an undo from YJS, there could be dangling text. + const danglingText = spliceString( + node._text, + offset, + delCount - 1, + '', + ); + if (danglingText.length > 0) { + if (prevCollabNode instanceof CollabTextNode) { + // Merge the text node with previous. + prevCollabNode._text += danglingText; + } else { + // No previous text node to merge into, just delete the text. + this._xmlText.delete(offset, danglingText.length); + } + } } else { node._text = spliceString(node._text, offset, delCount, ''); } From 86eba220414e77d8e770353065bf5dcc6901cfad Mon Sep 17 00:00:00 2001 From: Ajaezo Kingsley <54126417+Kingscliq@users.noreply.github.com> Date: Tue, 5 Nov 2024 22:53:25 +0100 Subject: [PATCH 2/4] [lexical-website] Documentation Update: Add Documentation for html Property in Lexical Editor Configuration (#6770) Co-authored-by: Bob Ippolito --- examples/react-rich/src/App.tsx | 116 +++++++++++++++++- examples/react-rich/src/ExampleTheme.ts | 1 + examples/react-rich/src/styleConfig.ts | 25 ++++ .../docs/concepts/serialization.md | 29 +++++ 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 examples/react-rich/src/styleConfig.ts diff --git a/examples/react-rich/src/App.tsx b/examples/react-rich/src/App.tsx index e2e7adbcf5a..206d5624c19 100644 --- a/examples/react-rich/src/App.tsx +++ b/examples/react-rich/src/App.tsx @@ -5,27 +5,137 @@ * LICENSE file in the root directory of this source tree. * */ + import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin'; import {LexicalComposer} from '@lexical/react/LexicalComposer'; import {ContentEditable} from '@lexical/react/LexicalContentEditable'; import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import { + $isTextNode, + DOMConversionMap, + DOMExportOutput, + Klass, + LexicalEditor, + LexicalNode, + ParagraphNode, + TextNode, +} from 'lexical'; import ExampleTheme from './ExampleTheme'; import ToolbarPlugin from './plugins/ToolbarPlugin'; import TreeViewPlugin from './plugins/TreeViewPlugin'; +import {parseAllowedColor, parseAllowedFontSize} from './styleConfig'; const placeholder = 'Enter some rich text...'; +const removeStylesExportDOM = ( + editor: LexicalEditor, + target: LexicalNode, +): DOMExportOutput => { + const output = target.exportDOM(editor); + if (output && output.element instanceof HTMLElement) { + // Remove all inline styles and classes if the element is an HTMLElement + // Children are checked as well since TextNode can be nested + // in i, b, and strong tags. + for (const el of [ + output.element, + ...output.element.querySelectorAll('[style],[class],[dir="ltr"]'), + ]) { + el.removeAttribute('class'); + el.removeAttribute('style'); + if (el.getAttribute('dir') === 'ltr') { + el.removeAttribute('dir'); + } + } + } + return output; +}; + +const exportMap = new Map< + Klass, + (editor: LexicalEditor, target: LexicalNode) => DOMExportOutput +>([ + [ParagraphNode, removeStylesExportDOM], + [TextNode, removeStylesExportDOM], +]); + +const getExtraStyles = (element: HTMLElement): string => { + // Parse styles from pasted input, but only if they match exactly the + // sort of styles that would be produced by exportDOM + let extraStyles = ''; + const fontSize = parseAllowedFontSize(element.style.fontSize); + const backgroundColor = parseAllowedColor(element.style.backgroundColor); + const color = parseAllowedColor(element.style.color); + if (fontSize !== '' && fontSize !== '15px') { + extraStyles += `font-size: ${fontSize};`; + } + if (backgroundColor !== '' && backgroundColor !== 'rgb(255, 255, 255)') { + extraStyles += `background-color: ${backgroundColor};`; + } + if (color !== '' && color !== 'rgb(0, 0, 0)') { + extraStyles += `color: ${color};`; + } + return extraStyles; +}; + +const constructImportMap = (): DOMConversionMap => { + const importMap: DOMConversionMap = {}; + + // Wrap all TextNode importers with a function that also imports + // the custom styles implemented by the playground + for (const [tag, fn] of Object.entries(TextNode.importDOM() || {})) { + importMap[tag] = (importNode) => { + const importer = fn(importNode); + if (!importer) { + return null; + } + return { + ...importer, + conversion: (element) => { + const output = importer.conversion(element); + if ( + output === null || + output.forChild === undefined || + output.after !== undefined || + output.node !== null + ) { + return output; + } + const extraStyles = getExtraStyles(element); + if (extraStyles) { + const {forChild} = output; + return { + ...output, + forChild: (child, parent) => { + const textNode = forChild(child, parent); + if ($isTextNode(textNode)) { + textNode.setStyle(textNode.getStyle() + extraStyles); + } + return textNode; + }, + }; + } + return output; + }, + }; + }; + } + + return importMap; +}; + const editorConfig = { + html: { + export: exportMap, + import: constructImportMap(), + }, namespace: 'React.js Demo', - nodes: [], - // Handling of errors during update + nodes: [ParagraphNode, TextNode], onError(error: Error) { throw error; }, - // The editor theme theme: ExampleTheme, }; diff --git a/examples/react-rich/src/ExampleTheme.ts b/examples/react-rich/src/ExampleTheme.ts index bbd871b653a..1cc2bc15528 100644 --- a/examples/react-rich/src/ExampleTheme.ts +++ b/examples/react-rich/src/ExampleTheme.ts @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. * */ + export default { code: 'editor-code', heading: { diff --git a/examples/react-rich/src/styleConfig.ts b/examples/react-rich/src/styleConfig.ts new file mode 100644 index 00000000000..d2d121c7980 --- /dev/null +++ b/examples/react-rich/src/styleConfig.ts @@ -0,0 +1,25 @@ +/** + * 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. + * + */ + +const MIN_ALLOWED_FONT_SIZE = 8; +const MAX_ALLOWED_FONT_SIZE = 72; + +export const parseAllowedFontSize = (input: string): string => { + const match = input.match(/^(\d+(?:\.\d+)?)px$/); + if (match) { + const n = Number(match[1]); + if (n >= MIN_ALLOWED_FONT_SIZE && n <= MAX_ALLOWED_FONT_SIZE) { + return input; + } + } + return ''; +}; + +export function parseAllowedColor(input: string) { + return /^rgb\(\d+, \d+, \d+\)$/.test(input) ? input : ''; +} diff --git a/packages/lexical-website/docs/concepts/serialization.md b/packages/lexical-website/docs/concepts/serialization.md index 03ed5f924e8..90eaf313fe3 100644 --- a/packages/lexical-website/docs/concepts/serialization.md +++ b/packages/lexical-website/docs/concepts/serialization.md @@ -437,3 +437,32 @@ function patchStyleConversion( }; } ``` + +### `html` Property for Import and Export Configuration + +The `html` property in `CreateEditorArgs` provides an alternate way to configure HTML import and export behavior in Lexical without subclassing or node replacement. It includes two properties: + +- `import` - Similar to `importDOM`, it controls how HTML elements are transformed into `LexicalNodes`. However, instead of defining conversions directly on each `LexicalNode`, `html.import` provides a configuration that can be overridden easily in the editor setup. + +- `export` - Similar to `exportDOM`, this property customizes how `LexicalNodes` are serialized into HTML. With `html.export`, users can specify transformations for various nodes collectively, offering a flexible override mechanism that can adapt without needing to extend or replace specific `LexicalNodes`. + +#### Key Differences from `importDOM` and `exportDOM` + +While `importDOM` and `exportDOM` allow for highly customized, node-specific conversions by defining them directly within the `LexicalNode` class, the `html` property enables broader, editor-wide configurations. This setup benefits situations where: + +- **Consistent Transformations**: You want uniform import/export behavior across different nodes without adjusting each node individually. +- **No Subclassing Required**: Overrides to import and export logic are applied at the editor configuration level, simplifying customization and reducing the need for extensive subclassing. + +#### Type Definitions + +```typescript +type HTMLConfig = { + export?: DOMExportOutputMap; // Optional map defining how nodes are exported to HTML. + import?: DOMConversionMap; // Optional record defining how HTML is converted into nodes. +}; +``` + +#### Example of a use case for the `html` Property for Import and Export Configuration: + +[Rich text sandbox](https://stackblitz.com/github/facebook/lexical/tree/main/examples/react-rich?embed=1&file=src%2FApp.tsx&terminalHeight=0&ctl=1&showSidebar=0&devtoolsheight=0&view=preview) + From b284bead0d6664e99e76af118c98eff0f2e33012 Mon Sep 17 00:00:00 2001 From: Bedru Umer <63902795+bedre7@users.noreply.github.com> Date: Wed, 6 Nov 2024 04:23:57 +0300 Subject: [PATCH 3/4] [lexical-playground] Feature: Add more keyboard shortcuts (#6754) Co-authored-by: Bob Ippolito --- .../__tests__/e2e/KeyboardShortcuts.spec.mjs | 372 +++++++++++ .../__tests__/keyboardShortcuts/index.mjs | 156 +++++ packages/lexical-playground/src/App.tsx | 29 +- packages/lexical-playground/src/Editor.tsx | 19 +- .../src/context/ToolbarContext.tsx | 125 ++++ packages/lexical-playground/src/index.css | 19 +- .../src/plugins/ShortcutsPlugin/index.tsx | 168 +++++ .../src/plugins/ShortcutsPlugin/shortcuts.ts | 225 +++++++ .../src/plugins/ToolbarPlugin/fontSize.tsx | 152 +---- .../src/plugins/ToolbarPlugin/index.tsx | 611 +++++++----------- .../src/plugins/ToolbarPlugin/utils.ts | 292 +++++++++ 11 files changed, 1663 insertions(+), 505 deletions(-) create mode 100644 packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs create mode 100644 packages/lexical-playground/src/context/ToolbarContext.tsx create mode 100644 packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx create mode 100644 packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts create mode 100644 packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts 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 { -
- - Lexical Logo - -
-
- -
- - {isDevPlayground ? : null} - {isDevPlayground ? : null} - {isDevPlayground ? : null} + +
+ + Lexical Logo + +
+
+ +
+ + {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(''); + } + }); + } + }); +}; From a4e70168a13271dfdaa8e2684f5fc99658f3236c Mon Sep 17 00:00:00 2001 From: Ivaylo Pavlov Date: Wed, 6 Nov 2024 02:01:45 +0000 Subject: [PATCH 4/4] Fix importDOM for Layout plugin (#6799) --- .../src/nodes/LayoutItemNode.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/lexical-playground/src/nodes/LayoutItemNode.ts b/packages/lexical-playground/src/nodes/LayoutItemNode.ts index d579b4ad6d8..3d227976d64 100644 --- a/packages/lexical-playground/src/nodes/LayoutItemNode.ts +++ b/packages/lexical-playground/src/nodes/LayoutItemNode.ts @@ -8,6 +8,7 @@ import type { DOMConversionMap, + DOMConversionOutput, EditorConfig, LexicalNode, SerializedElementNode, @@ -18,6 +19,10 @@ import {ElementNode} from 'lexical'; export type SerializedLayoutItemNode = SerializedElementNode; +function $convertLayoutItemElement(): DOMConversionOutput | null { + return {node: $createLayoutItemNode()}; +} + export class LayoutItemNode extends ElementNode { static getType(): string { return 'layout-item'; @@ -29,6 +34,7 @@ export class LayoutItemNode extends ElementNode { createDOM(config: EditorConfig): HTMLElement { const dom = document.createElement('div'); + dom.setAttribute('data-lexical-layout-item', 'true'); if (typeof config.theme.layoutItem === 'string') { addClassNamesToElement(dom, config.theme.layoutItem); } @@ -40,7 +46,17 @@ export class LayoutItemNode extends ElementNode { } static importDOM(): DOMConversionMap | null { - return {}; + return { + div: (domNode: HTMLElement) => { + if (!domNode.hasAttribute('data-lexical-layout-item')) { + return null; + } + return { + conversion: $convertLayoutItemElement, + priority: 2, + }; + }, + }; } static importJSON(): LayoutItemNode {