Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/chrome-plugin/tests/lexical_webcomponent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from './fixtures';
import {
clickHarperHighlight,
getHarperHighlights,
getLexicalEditor,
replaceEditorContent,
} from './testUtils';

const TEST_PAGE_URL = 'http://localhost:8081/lexical_webcomponent.html';

test.describe('Lexical webcomponent regression', () => {
test.skip(
({ browserName }) => browserName === 'firefox',
'Firefox extension build lacks background scripts',
);
test('Applying a suggestion does not duplicate text', async ({ page }) => {
await page.goto(TEST_PAGE_URL);

const lexical = getLexicalEditor(page);
const mirror = page.locator('#lexical-mirror');
const initialText = 'This is an test. This is an test again.';
await replaceEditorContent(lexical, initialText);

await page.waitForTimeout(6000);
await expect(mirror).toHaveText(initialText);

await clickHarperHighlight(page);
await page.getByTitle('Replace with "a"').click();

await page.waitForTimeout(3000);
const afterFirst = 'This is a test. This is an test again.';
await expect(lexical).toHaveText(afterFirst);
await expect(mirror).toHaveText(afterFirst);
await expect(getHarperHighlights(page)).toHaveCount(1);

await clickHarperHighlight(page);
await page.getByTitle('Replace with "a"').click();

await page.waitForTimeout(3000);
const finalText = 'This is a test. This is a test again.';
await expect(lexical).toHaveText(finalText);
await expect(mirror).toHaveText(finalText);
await expect(getHarperHighlights(page)).toHaveCount(0);

const lexicalText = (await lexical.textContent()) ?? '';
const mirrorText = (await mirror.textContent()) ?? '';
expect(lexicalText).toBe(mirrorText);
});
});
75 changes: 75 additions & 0 deletions packages/chrome-plugin/tests/pages/lexical_webcomponent.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Lexical Webcomponent Test</title>
<style>
body {
font-family: sans-serif;
padding: 2rem;
}
reddit-lexical-editor {
display: block;
max-width: 600px;
}
[data-lexical-editor="true"] {
border: 1px solid #ccc;
padding: 1rem;
min-height: 150px;
border-radius: 8px;
}
</style>
</head>
<body>
<h1>Lexical Editor Inside Webcomponent</h1>
<reddit-lexical-editor></reddit-lexical-editor>
<section aria-live="polite" id="lexical-mirror" style="margin-top: 1rem; font-weight: bold;"></section>

<script type="module">
class RedditLexicalEditor extends HTMLElement {
constructor() {
super();
this._init();
}

_init() {
const editor = document.createElement('div');
editor.setAttribute('contenteditable', 'true');
editor.setAttribute('data-lexical-editor', 'true');
editor.textContent = 'This is an test';

const mirror = document.getElementById('lexical-mirror');
const syncMirror = () => {
if (mirror) mirror.textContent = editor.textContent ?? '';
};

editor.addEventListener('beforeinput', (event) => {
if (
event.isTrusted === false &&
event.inputType === 'insertText' &&
typeof event.data === 'string'
) {
event.preventDefault();
// Mimic Reddit's Lexical behaviour that applies the full change
// in response to synthetic beforeinput events emitted by our
// extension when a suggestion is accepted.
setTimeout(() => {
editor.textContent = `${event.data}\n`;
syncMirror();
}, 0);
}
});

editor.addEventListener('input', syncMirror);
editor.addEventListener('keyup', syncMirror);

syncMirror();

this.append(editor);
}
}

customElements.define('reddit-lexical-editor', RedditLexicalEditor);
</script>
</body>
</html>
73 changes: 54 additions & 19 deletions packages/lint-framework/src/lint/computeLintBoxes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Span } from 'harper.js';
import { domRectToBox, type IgnorableLintBox, isBottomEdgeInBox, shrinkBoxToFit } from './Box';
import { getRangeForTextSpan } from './domUtils';
import { getLexicalEditable, getSlateRoot } from './editorUtils';
import { getLexicalRoot, getSlateRoot } from './editorUtils';
import TextFieldRange from './TextFieldRange';
import { applySuggestion, type UnpackedLint, type UnpackedSuggestion } from './unpackLint';

Expand Down Expand Up @@ -87,29 +87,34 @@ export default function computeLintBoxes(
}

function replaceValue(el: HTMLElement, value: string) {
const slateRoot = getSlateRoot(el);
const lexicalRoot = getLexicalEditable(el);

if (isFormEl(el)) {
el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value }));
(el as any).value = value;
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
} else if (slateRoot != null || lexicalRoot != null) {
replaceValueSpecial(el, value);
replaceFormElementValue(el as HTMLTextAreaElement | HTMLInputElement, value);
} else if (getLexicalRoot(el) != null) {
replaceRichTextValue(el, value, { mode: 'lexical' });
} else if (getSlateRoot(el) != null) {
replaceRichTextValue(el, value, { mode: 'slate' });
} else {
(el as any).textContent = value;

el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value }));
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
replaceGenericContentEditable(el, value);
}

el.dispatchEvent(new Event('change', { bubbles: true }));
}

/** Replace the content of a special editor node. */
function replaceValueSpecial(el: HTMLElement, value: string) {
function replaceFormElementValue(el: HTMLTextAreaElement | HTMLInputElement, value: string) {
el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value }));
el.value = value;
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
}

function replaceRichTextValue(el: HTMLElement, value: string, opts: { mode: 'lexical' | 'slate' }) {
specialSelectAllText(el);
specialInsertText(el, value);
specialInsertText(el, value, opts);
}

function replaceGenericContentEditable(el: HTMLElement, value: string) {
el.textContent = value;
el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, data: value }));
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
}

function specialSelectAllText(target: Node): Range {
Expand All @@ -127,7 +132,20 @@ function specialSelectAllText(target: Node): Range {
return range;
}

function specialInsertText(el: HTMLElement, raw: string): void {
function getEditorText(el: HTMLElement): string {
const text = el.textContent ?? '';
return normalizeEditorText(text);
}

function normalizeEditorText(text: string): string {
return text.replace(/\u200b/g, '').replace(/[\n\r]+$/g, '');
}

function specialInsertText(
el: HTMLElement,
raw: string,
opts: { mode: 'lexical' | 'slate' },
): void {
const inputType = 'insertText';

const evInit: InputEventInit = {
Expand All @@ -143,12 +161,29 @@ function specialInsertText(el: HTMLElement, raw: string): void {
}

const beforeEvt = new InputEvent('beforeinput', evInit);
const biSuccess: boolean = el.dispatchEvent(beforeEvt);
const biSuccess = el.dispatchEvent(beforeEvt);
if (getEditorText(el) === raw) {
return;
}

const textEvt = new InputEvent('textInput', evInit);
const teSuccess = el.dispatchEvent(textEvt);
if (getEditorText(el) === raw) {
return;
}

if (biSuccess && teSuccess) {
const finalize = () => {
if (getEditorText(el) !== raw) {
el.textContent = raw;
}
};

const shouldRunExecCommand = opts.mode !== 'lexical' && (!biSuccess || !teSuccess);
if (shouldRunExecCommand) {
el.ownerDocument.execCommand(inputType, false, raw);
finalize();
return;
}

setTimeout(finalize, 0);
}
Loading