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) +