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); }