diff --git a/docs/testing/e2e/core.spec.ts b/docs/testing/e2e/core.spec.ts index 80af0aa0..cb5b41dd 100644 --- a/docs/testing/e2e/core.spec.ts +++ b/docs/testing/e2e/core.spec.ts @@ -2863,6 +2863,99 @@ test.describe('输入翻译:点圆点翻译', () => { expect(await queries()).toEqual(['这个问题我们明天再讨论']); }); + test('@core TC-E2E-129: contenteditable 里点圆点整段替换 —— 受控编辑器收到 insertText 输入事件,内部模型与界面一致、光标在末尾;普通 contenteditable 走原生插入(#656)', async ({ + page, mockGoogle, seedSettings, gotoFixture, + }) => { + await seedSettings({ from: 'en' }); + await mockGoogle({}); + await gotoFixture('rich-input'); + await waitForBall(page); + const dot = page.locator(DOT); + /** 受控编辑器夹具的状态:模型、收到的输入事件、绕过编辑器的 DOM 变动、界面文字。 */ + const editorState = () => + page.evaluate(() => { + const s = (window as unknown as { __editor: { + model: string; + sel: { start: number; end: number }; + inputs: Array<{ inputType: string; data: string | null }>; + foreignMutations: number; + } }).__editor; + return { + model: s.model, + sel: s.sel, + last: s.inputs.at(-1), + foreign: s.foreignMutations, + text: document.getElementById('editor')!.innerText, + }; + }); + + // 受控编辑器:自己拦下 beforeinput、改模型、重新渲染,不发 input 事件 + const editor = page.locator('#editor'); + await editor.click(); + await page.keyboard.type('你好世界'); + await expect(dot).toBeVisible(); + // 有选区也整段替换 + await page.keyboard.press('Shift+ArrowLeft'); + await dot.click(); + await expect(editor).toHaveText('【译】你好世界', { timeout: 10_000 }); + const after = await editorState(); + expect(after.model).toBe('【译】你好世界'); + expect(after.text).toBe(after.model); + expect(after.last).toEqual({ inputType: 'insertText', data: '【译】你好世界' }); + expect(after.foreign).toBe(0); + expect(after.sel).toEqual({ start: after.model.length, end: after.model.length }); + await expect(editor).toBeFocused(); + // 接着打字落在末尾,模型照常跟着走 + await page.keyboard.type('!'); + expect((await editorState()).model).toBe('【译】你好世界!'); + + // 普通 contenteditable:没人拦 beforeinput,走浏览器原生插入,页面收到真实的 input + const rich = page.locator('#rich'); + await rich.click(); + await page.keyboard.type('早上好'); + await rich.evaluate((el) => { + const log: Array<{ inputType: string; data: string | null }> = []; + el.addEventListener('input', (e) => log.push({ inputType: (e as InputEvent).inputType, data: (e as InputEvent).data })); + (window as unknown as { __richInputs: typeof log }).__richInputs = log; + }); + await dot.click(); + await expect(rich).toHaveText('【译】早上好', { timeout: 10_000 }); + expect(await page.evaluate(() => (window as unknown as { __richInputs: unknown }).__richInputs)).toEqual([ + { inputType: 'insertText', data: '【译】早上好' }, + ]); + await expect(rich).toBeFocused(); + }); + + test('@core TC-E2E-130: 受控编辑器里同样有原文快照、在飞标记与转圈 —— 进行中再点不发第二次请求,送翻后又打了字就不覆盖、译文进提示条(#656)', async ({ + page, serviceWorker, mockGoogle, seedSettings, gotoFixture, + }) => { + await seedSettings({ from: 'en' }); + await mockGoogle({ delayMs: 1_500 }); + const queries = await recordGoogleQueries(serviceWorker); + await gotoFixture('rich-input'); + await waitForBall(page); + const dot = page.locator(DOT); + const editor = page.locator('#editor'); + const model = () => page.evaluate(() => (window as unknown as { __editor: { model: string } }).__editor.model); + + await editor.click(); + await page.keyboard.type('你好世界'); + await dot.click(); + // #648:进行中变灰转圈 + await expect(dot).toHaveAttribute('data-state', 'busy'); + // #647:进行中再点不发第二次请求,编辑器里一个字不动(转圈中的元素不等它静止) + await dot.click({ force: true }); + expect(await model()).toBe('你好世界'); + + // #646:送翻后又打了字 → 不覆盖,译文出现在提示条里 + await page.keyboard.type('!'); + await expect(page.locator(TOAST)).toHaveText('【译】你好世界', { timeout: 10_000 }); + expect(await model()).toBe('你好世界!'); + await expect(editor).toHaveText('你好世界!'); + await expect(dot).not.toHaveAttribute('data-state', 'busy'); + expect(await queries()).toEqual(['你好世界']); + }); + test('@core TC-E2E-124: 翻译进行中圆点变灰转圈;完成、失败、放弃写回之后都回到常态(#648)', async ({ page, mockGoogle, seedSettings, gotoFixture, }) => { diff --git a/docs/testing/e2e/fixtures/rich-input.html b/docs/testing/e2e/fixtures/rich-input.html index 56b88e7a..7891591e 100644 --- a/docs/testing/e2e/fixtures/rich-input.html +++ b/docs/testing/e2e/fixtures/rich-input.html @@ -15,5 +15,86 @@

Write a message

Static text block that is not editable.
+ +
+ diff --git a/docs/testing/unit/ui/input-dot.test.ts b/docs/testing/unit/ui/input-dot.test.ts index cea49a27..978af0d6 100644 --- a/docs/testing/unit/ui/input-dot.test.ts +++ b/docs/testing/unit/ui/input-dot.test.ts @@ -10,6 +10,8 @@ * #648:进行中圆点挂上进行中状态(变灰转圈),结束后回到常态。 * #655:contenteditable 的编辑宿主获得焦点且有文字时同样浮出,位置用同一个 * 回落位置;非编辑态的元素与可编辑区里的 contenteditable=false 子块不出现。 + * #656:受控编辑器拦下 beforeinput 自己改 DOM,不会有 input 事件 —— 编辑宿主 + * 里的文字变化同样让圆点跟着出现与消失。 */ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { createLifecycleRegistry, type LifecycleRegistry } from '~/src/ui/lifecycle-registry'; @@ -387,6 +389,29 @@ describe('contenteditable 富文本框(#655)', () => { expect(shown()).toBe(false); }); + test('受控编辑器自己改 DOM、没有 input 事件:文字够了照样浮出,删空了消失(#656)', async () => { + registry.ensure('input-dot', true); + const host = rich(''); + host.focus(); + expect(shown()).toBe(false); + host.textContent = '你好'; + await Promise.resolve(); + expect(shown()).toBe(true); + host.textContent = ''; + await Promise.resolve(); + expect(shown()).toBe(false); + }); + + test('焦点离开后编辑宿主里的变化不再让圆点出现(#656)', async () => { + registry.ensure('input-dot', true); + const host = rich(''); + host.focus(); + host.blur(); + host.textContent = '你好'; + await Promise.resolve(); + expect(shown()).toBe(false); + }); + test('位置回落到框内侧右下角(ADR-0006)', () => { registry.ensure('input-dot', true); const host = rich('hello'); diff --git a/entrypoints/content.ts b/entrypoints/content.ts index a355c9a4..d9680829 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -725,7 +725,7 @@ export default defineContentScript({ current: inputText(el), focused: deepActiveElement() === el, }); - if (!writeBack.write || !replaceInputText(el, result.translation!)) { + if (!writeBack.write || !(await replaceInputText(el, result.translation!))) { toast(result.translation!); } } diff --git a/package.json b/package.json index 537e7efb..14c70bd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallel-translation", - "version": "2.1.23", + "version": "2.1.24", "description": "对照式网页翻译浏览器扩展", "license": "GPL-3.0-or-later", "private": true, diff --git a/src/ui/input-dot.ts b/src/ui/input-dot.ts index 27d9b90e..38580d28 100644 --- a/src/ui/input-dot.ts +++ b/src/ui/input-dot.ts @@ -18,6 +18,8 @@ // #650:能不能参与交给「这个输入框能不能参与」的判定,放行普通文本框与 // 搜索框;圆点位置仍是回落位置。 // #655:contenteditable 的编辑宿主同样浮出,位置同样是回落位置。 +// #656:受控编辑器拦下 beforeinput 自己改 DOM,没有 input 事件 —— 焦点所在的 +// 编辑宿主改由 DOM 变动驱动同步。 import { mountIsolated, unmountIsolated } from './mount'; import { tf } from '../i18n'; @@ -120,11 +122,36 @@ export function createInputDot(handlers: InputDotHandlers): () => void { placeFallback(dot, el); }; + /** + * 焦点所在的编辑宿主里的 DOM 变动(#656)。Slate、Lexical 一类受控编辑器 + * 拦下 beforeinput、改自己的模型再重新渲染,浏览器不再派发 input 事件; + * 只听 input,打字时圆点就不会跟着出现。 + */ + const observer = new MutationObserver(() => { + const el = deepActiveElement(); + if (el === observed) sync(el); + }); + let observed: Element | null = null; + const observe = (el: Element | null): void => { + observer.disconnect(); + observed = null; + const d = decideInputEligibility(el); + if (el && d.eligible && d.kind === 'contenteditable') { + observer.observe(el, { childList: true, characterData: true, subtree: true }); + observed = el; + } + }; + // focusin 的 target 在跨 shadow 边界时被重定向到宿主,取路径首项才是输入框 const onFocusIn = (e: FocusEvent): void => { - sync((e.composedPath()[0] as Element | undefined) ?? null); + const el = (e.composedPath()[0] as Element | undefined) ?? null; + observe(el); + sync(el); + }; + const onFocusOut = (): void => { + observe(null); + hide(); }; - const onFocusOut = (): void => hide(); const onInput = (e: Event): void => { const el = (e.composedPath()[0] as Element | undefined) ?? null; if (el === deepActiveElement()) sync(el); @@ -158,6 +185,7 @@ export function createInputDot(handlers: InputDotHandlers): () => void { window.addEventListener('resize', onReflow, { passive: true }); // 打开开关时焦点可能已经在框里 —— 即刻生效,不必重新聚焦 + observe(deepActiveElement()); sync(deepActiveElement()); return () => { @@ -166,6 +194,7 @@ export function createInputDot(handlers: InputDotHandlers): () => void { document.removeEventListener('input', onInput, true); window.removeEventListener('scroll', onReflow, true); window.removeEventListener('resize', onReflow); + observer.disconnect(); unmountIsolated(HOST_ID); }; } diff --git a/src/ui/input-replace.ts b/src/ui/input-replace.ts index 021b2da0..ea04a56f 100644 --- a/src/ui/input-replace.ts +++ b/src/ui/input-replace.ts @@ -9,24 +9,71 @@ // 这是扩展第一次往用户自己写的文字上动手,所以写回必须走浏览器原生的 // 文本插入路径(`execCommand('insertText')`),不直接赋值 `value`: // - 原生撤销栈保得住,Ctrl / Cmd+Z 能把原文撤回来(#645) -// - 页面收到的是一次真实的 `beforeinput` / `input`(inputType 为 -// insertText),受控组件的内部状态不与界面脱节 +// - 页面收到的是一次真实的 `input`(inputType 为 insertText),受控组件的 +// 内部状态不与界面脱节 // // `execCommand` 已被标为过时,但它是目前唯一能进撤销栈的文本插入方式, // Chrome 与 Firefox 都支持在 textarea 与单行文本框(#650)里执行。 +// +// contenteditable(#656)同样不碰 innerHTML。受控编辑器(Slate、Lexical 一类) +// 背后是自己的文档模型,它们拦下 `beforeinput` 改模型、再自己重新渲染; +// 绕过它们直接改 DOM,轻则光标乱跳,重则发出去的是模型里的旧内容或空消息。 +// 而 `execCommand` 不派发 `beforeinput`(实测 Chrome 如此),编辑器收不到。 +// 所以先全选编辑宿主的内容,派发一次可取消的 `beforeinput`(insertText, +// 目标范围就是全选范围): +// - 编辑器取消了它 —— 编辑器已经按自己的方式插入,撤销栈也由它管 +// - 没人取消 —— 普通 contenteditable,走 `execCommand`,进原生撤销栈 import type { TextInput } from './input-eligibility'; +/** 等编辑器把选区同步进模型的上限:Slate 一类在 selectionchange 时才同步。 */ +const SELECTION_SETTLE_MS = 100; + /** * 整段替换:不论有没有选区,先全选再插入。 * 返回是否写回成功;浏览器拒绝执行时返回 false,框里内容不动, * 调用方改为把译文弹在提示条里。 */ -export function replaceInputText(el: TextInput, text: string): boolean { - // contenteditable 的写回还没有(#656):返回 false,译文弹在提示条里 - if (!(el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement)) return false; +export async function replaceInputText(el: TextInput, text: string): Promise { // 插入作用在当前焦点上 —— 点圆点不夺焦点,这里只是兜底 if (document.activeElement !== el) el.focus(); - el.select(); + if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) { + el.select(); + return document.execCommand('insertText', false, text); + } + return replaceEditableText(el, text); +} + +async function replaceEditableText(el: HTMLElement, text: string): Promise { + const selection = document.getSelection(); + if (!selection) return false; + const all = document.createRange(); + all.selectNodeContents(el); + selection.removeAllRanges(); + selection.addRange(all); + await selectionSettled(); + + const before = new InputEvent('beforeinput', { + inputType: 'insertText', + data: text, + bubbles: true, + cancelable: true, + composed: true, + targetRanges: [new StaticRange(all)], + }); + if (!el.dispatchEvent(before)) return true; return document.execCommand('insertText', false, text); } + +/** 等下一次 selectionchange(编辑器的监听先于这里执行),最多等上限。 */ +function selectionSettled(): Promise { + return new Promise((resolve) => { + const done = (): void => { + document.removeEventListener('selectionchange', done); + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(done, SELECTION_SETTLE_MS); + document.addEventListener('selectionchange', done); + }); +}