Skip to content

Commit

Permalink
Merge branch 'main' into reconciler-children
Browse files Browse the repository at this point in the history
  • Loading branch information
etrepum authored Nov 6, 2024
2 parents 38f364c + a4e7016 commit 3bf7484
Show file tree
Hide file tree
Showing 18 changed files with 2,023 additions and 523 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>
`,
);
});
});
Loading

0 comments on commit 3bf7484

Please sign in to comment.