From fc1bea0d0d1c0feaf1b9640ff93f603ff8929376 Mon Sep 17 00:00:00 2001 From: Fadekemi Adebayo <82163647+Shopiley@users.noreply.github.com> Date: Tue, 5 Nov 2024 20:29:35 +0100 Subject: [PATCH 1/3] [Lexical-website] BugFix: Change button text colour to improve visibility (#6796) --- .../lexical-website/src/components/HomepageExamples/index.js | 2 +- packages/lexical-website/src/css/custom.css | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/lexical-website/src/components/HomepageExamples/index.js b/packages/lexical-website/src/components/HomepageExamples/index.js index b60e64392c4..5189561dc03 100644 --- a/packages/lexical-website/src/components/HomepageExamples/index.js +++ b/packages/lexical-website/src/components/HomepageExamples/index.js @@ -71,7 +71,7 @@ export default function HomepageExamples() { {EXAMPLES.map(({id, label}) => (
  • Date: Wed, 6 Nov 2024 07:45:12 +1000 Subject: [PATCH 2/3] [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 3/3] [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) +