diff --git a/packages/chrome-plugin/tests/lexical_webcomponent.spec.ts b/packages/chrome-plugin/tests/lexical_webcomponent.spec.ts
new file mode 100644
index 000000000..8c5380f34
--- /dev/null
+++ b/packages/chrome-plugin/tests/lexical_webcomponent.spec.ts
@@ -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);
+ });
+});
diff --git a/packages/chrome-plugin/tests/pages/lexical_webcomponent.html b/packages/chrome-plugin/tests/pages/lexical_webcomponent.html
new file mode 100644
index 000000000..bfe8aee0c
--- /dev/null
+++ b/packages/chrome-plugin/tests/pages/lexical_webcomponent.html
@@ -0,0 +1,75 @@
+
+
+
+
+ Lexical Webcomponent Test
+
+
+
+ Lexical Editor Inside Webcomponent
+
+
+
+
+
+
diff --git a/packages/lint-framework/src/lint/computeLintBoxes.ts b/packages/lint-framework/src/lint/computeLintBoxes.ts
index d3033d023..2698f057e 100644
--- a/packages/lint-framework/src/lint/computeLintBoxes.ts
+++ b/packages/lint-framework/src/lint/computeLintBoxes.ts
@@ -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';
@@ -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 {
@@ -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 = {
@@ -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);
}