Skip to content

Commit

Permalink
Merge branch 'main' into feature/add-keyboard-shortcuts
Browse files Browse the repository at this point in the history
  • Loading branch information
etrepum authored Nov 5, 2024
2 parents 30bb9a6 + 86eba22 commit 026191c
Show file tree
Hide file tree
Showing 8 changed files with 348 additions and 18 deletions.
116 changes: 113 additions & 3 deletions examples/react-rich/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<LexicalNode>,
(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,
};

Expand Down
1 change: 1 addition & 0 deletions examples/react-rich/src/ExampleTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*
*/

export default {
code: 'editor-code',
heading: {
Expand Down
25 changes: 25 additions & 0 deletions examples/react-rich/src/styleConfig.ts
Original file line number Diff line number Diff line change
@@ -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 : '';
}
157 changes: 157 additions & 0 deletions packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Line 1</span>
</p>
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">This is a test. Word</span>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Line 1</span>
</p>
<p class="PlaygroundEditorTheme__paragraph"><br /></p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Line 1</span>
</p>
<p class="PlaygroundEditorTheme__paragraph"><br /></p>
`,
);
});

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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">normal</span>
<strong
class="PlaygroundEditorTheme__textBold"
data-lexical-text="true">
boldBOLD
</strong>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">normalBOLD</span>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">normalBOLD</span>
</p>
`,
);
});
});
29 changes: 29 additions & 0 deletions packages/lexical-website/docs/concepts/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default function HomepageExamples() {
{EXAMPLES.map(({id, label}) => (
<Tabs.Trigger asChild={true} value={id} key={id}>
<li
className={`cursor-pointer list-none rounded-md px-4 py-1 font-bold transition-colors hover:bg-[#f2f2f2] ${
className={`button--text cursor-pointer list-none rounded-md px-4 py-1 font-bold transition-colors hover:bg-[#f2f2f2] ${
activeItemID === id && 'pills__item--active'
}`}
tabIndex={0}
Expand Down
4 changes: 4 additions & 0 deletions packages/lexical-website/src/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ html[data-theme='dark'] .docusaurus-highlight-code-line {
html[data-theme='dark'] .button--outline:hover {
color: white;
}

html[data-theme='dark'] .button--text:hover {
color: rgba(0, 0, 0, 0.5);
}
Loading

0 comments on commit 026191c

Please sign in to comment.