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
93 changes: 93 additions & 0 deletions docs/testing/e2e/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
81 changes: 81 additions & 0 deletions docs/testing/e2e/fixtures/rich-input.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,86 @@ <h1>Write a message</h1>
<div id="rich" class="box" contenteditable="true"></div>
<!-- 非编辑态、可聚焦的普通元素 -->
<div id="static" class="box" tabindex="0">Static text block that is not editable.</div>
<!-- 受控编辑器(#656):Slate / Lexical 一类 —— 文档模型是唯一的真相,
beforeinput 一律拦下,改模型后自己重新渲染;选区也是模型的一部分,
与 Slate 一样在 selectionchange 时才从 DOM 同步过来。只认单行文字,
换行不在夹具范围内。 -->
<div id="editor" class="box" contenteditable="true"></div>
<script>
(() => {
const editor = document.getElementById('editor');
const state = {
/** 文档模型:纯文本 */
model: '',
/** 模型里的选区(偏移) */
sel: { start: 0, end: 0 },
/** 收到的 beforeinput:inputType 与 data */
inputs: [],
/** 不是编辑器自己渲染造成的 DOM 变动次数(有人绕过编辑器直接改 DOM) */
foreignMutations: 0,
};
window.__editor = state;
let rendering = false;

/** 编辑器内 (node, offset) 对应的模型偏移。 */
function offsetOf(node, offset) {
const r = document.createRange();
r.setStart(editor, 0);
r.setEnd(node, offset);
return r.toString().length;
}

function render(caret) {
rendering = true;
state.sel = { start: caret, end: caret };
editor.textContent = state.model;
const text = editor.firstChild;
const sel = getSelection();
const r = document.createRange();
if (text) r.setStart(text, caret);
else r.setStart(editor, 0);
r.collapse(true);
sel.removeAllRanges();
sel.addRange(r);
observer.takeRecords();
rendering = false;
}

editor.addEventListener('beforeinput', (e) => {
e.preventDefault();
state.inputs.push({ inputType: e.inputType, data: e.data });
// 有目标范围就用它(真实按键),没有就用模型里的选区(脚本派发的事件)
const target = e.getTargetRanges()[0];
const { start, end } = target
? { start: offsetOf(target.startContainer, target.startOffset), end: offsetOf(target.endContainer, target.endOffset) }
: state.sel;
const m = state.model;
switch (e.inputType) {
case 'insertText':
state.model = m.slice(0, start) + e.data + m.slice(end);
render(start + e.data.length);
break;
case 'deleteContentBackward': {
const from = start === end ? Math.max(0, start - 1) : start;
state.model = m.slice(0, from) + m.slice(end);
render(from);
break;
}
}
});

document.addEventListener('selectionchange', () => {
const sel = getSelection();
if (!sel.rangeCount || !editor.contains(sel.anchorNode)) return;
const r = sel.getRangeAt(0);
state.sel = { start: offsetOf(r.startContainer, r.startOffset), end: offsetOf(r.endContainer, r.endOffset) };
});

const observer = new MutationObserver((records) => {
if (!rendering) state.foreignMutations += records.length;
});
observer.observe(editor, { childList: true, characterData: true, subtree: true });
})();
</script>
</body>
</html>
25 changes: 25 additions & 0 deletions docs/testing/unit/ui/input-dot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion entrypoints/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!);
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "parallel-translation",
"version": "2.1.23",
"version": "2.1.24",
"description": "对照式网页翻译浏览器扩展",
"license": "GPL-3.0-or-later",
"private": true,
Expand Down
33 changes: 31 additions & 2 deletions src/ui/input-dot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
// #650:能不能参与交给「这个输入框能不能参与」的判定,放行普通文本框与
// 搜索框;圆点位置仍是回落位置。
// #655:contenteditable 的编辑宿主同样浮出,位置同样是回落位置。
// #656:受控编辑器拦下 beforeinput 自己改 DOM,没有 input 事件 —— 焦点所在的
// 编辑宿主改由 DOM 变动驱动同步。

import { mountIsolated, unmountIsolated } from './mount';
import { tf } from '../i18n';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -158,6 +185,7 @@ export function createInputDot(handlers: InputDotHandlers): () => void {
window.addEventListener('resize', onReflow, { passive: true });

// 打开开关时焦点可能已经在框里 —— 即刻生效,不必重新聚焦
observe(deepActiveElement());
sync(deepActiveElement());

return () => {
Expand All @@ -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);
};
}
59 changes: 53 additions & 6 deletions src/ui/input-replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
// 插入作用在当前焦点上 —— 点圆点不夺焦点,这里只是兜底
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<boolean> {
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<void> {
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);
});
}
Loading