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
96 changes: 92 additions & 4 deletions docs/testing/e2e/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3054,7 +3054,95 @@ test.describe('输入翻译:圆点', () => {
await expect(dot).toBeVisible();
});

test('@core TC-E2E-128: contenteditable 富文本框获得焦点且有文字时浮出圆点,在框内右下角;非编辑态元素与可编辑区里的 contenteditable=false 子块不出现(#655)', async ({
test('@core TC-E2E-141: contenteditable 里圆点贴文字末尾 —— 多行、行内混排(链接、加粗)都对;末尾滚出可见区钳在框内、滚回来重新贴末尾(#671)', async ({
page, seedSettings, gotoFixture,
}) => {
await seedSettings({});
await gotoFixture('rich-input');
await waitForBall(page);
const box = page.locator('#rich');
const dot = page.locator(DOT);

/** 文字末尾的真值:光标放到最后一个文本节点末尾时的位置(折叠选区的矩形)。 */
const caretAtEnd = () =>
box.evaluate((el) => {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let last: Text | null = null;
while (walker.nextNode()) if ((walker.currentNode as Text).length > 0) last = walker.currentNode as Text;
const r = document.createRange();
r.setStart(last!, last!.length);
r.collapse(true);
getSelection()!.removeAllRanges();
getSelection()!.addRange(r);
const c = r.getClientRects()[0]!;
return { x: c.left, cy: c.top + c.height / 2 };
});
/** 圆点在末尾右侧 4px(#667),竖直居中于末尾那一行。 */
const expectAtEnd = async (what: string) => {
await expect(async () => {
const e = await caretAtEnd();
const d = (await dot.boundingBox())!;
expect(Math.abs(d.x - (e.x + 4)), `${what}:横向`).toBeLessThanOrEqual(2);
expect(Math.abs(d.y + d.height / 2 - e.cy), `${what}:竖向`).toBeLessThanOrEqual(2);
}).toPass({ timeout: 5_000 });
};
/** 站点(或编辑器)直接改写编辑区的内容;改完等停手对齐。 */
const setHtml = (html: string) => box.evaluate((el, h) => (el.innerHTML = h), html);

// 单行
await box.click();
await page.keyboard.type('hello');
await expectAtEnd('单行');

// 多行:换行后末尾在第二行
await page.keyboard.press('Enter');
await page.keyboard.type('world');
await expectAtEnd('多行');

// 行内混排:末尾是加粗
await setHtml('see <a href="#x">the docs</a> and <b>bold text</b>');
await expectAtEnd('末尾是加粗');
// 末尾是链接
await setHtml('read <b>this</b> first, then <a href="#y">the link</a>');
await expectAtEnd('末尾是链接');
// 多行加行内混排
await setHtml('<div>first <i>line</i></div><div>second <b>bold</b> and <a href="#z">a link</a></div>');
await expectAtEnd('多行混排');

// 钳边(#669):框限高可滚动,滚到顶时末尾在框下方,圆点钳在可见区里,不消失
await box.evaluate((el) => {
el.style.height = '60px';
el.style.minHeight = '0';
el.style.overflowY = 'auto';
});
await setHtml(Array.from({ length: 10 }, (_, i) => `<div>line ${i + 1}</div>`).join(''));
await box.evaluate((el) => (el.scrollTop = el.scrollHeight));
await expectAtEnd('滚到底');
await box.evaluate((el) => (el.scrollTop = 0));
const v = await box.evaluate((el) => {
const r = el.getBoundingClientRect();
return {
left: r.left + el.clientLeft,
top: r.top + el.clientTop,
right: r.left + el.clientLeft + el.clientWidth,
bottom: r.top + el.clientTop + el.clientHeight,
};
});
await expect(async () => {
const d = (await dot.boundingBox())!;
expect(d.x).toBeGreaterThanOrEqual(v.left - 0.5);
expect(d.x + d.width).toBeLessThanOrEqual(v.right + 0.5);
expect(d.y).toBeGreaterThanOrEqual(v.top - 0.5);
expect(d.y + d.height).toBeLessThanOrEqual(v.bottom + 0.5);
}).toPass({ timeout: 5_000 });
await expect(dot).toBeVisible();

// 滚回底:重新贴末尾
await box.evaluate((el) => (el.scrollTop = el.scrollHeight));
await expectAtEnd('滚回底');
});

test('@core TC-E2E-128: contenteditable 富文本框获得焦点且有文字时浮出圆点,在框内(#671 起贴文字末尾);非编辑态元素与可编辑区里的 contenteditable=false 子块不出现(#655)', async ({
page, seedSettings, gotoFixture,
}) => {
await seedSettings({});
Expand All @@ -3072,15 +3160,15 @@ test.describe('输入翻译:圆点', () => {
await page.keyboard.type('b');
await expect(dot).toBeVisible();

// 换行后的文字同样算数,位置在框内侧右下角(ADR-0006 回落位置)
// 换行后的文字同样算数,整颗圆点在框里(#671 起贴文字末尾,位置的真值见 TC-E2E-141)
await page.keyboard.press('Enter');
await page.keyboard.type('你好世界');
await expect(dot).toBeVisible();
const b = (await box.boundingBox())!;
const d = (await dot.boundingBox())!;
expect(d.x).toBeGreaterThan(b.x + b.width / 2);
expect(d.x).toBeGreaterThanOrEqual(b.x);
expect(d.x + d.width).toBeLessThanOrEqual(b.x + b.width);
expect(d.y).toBeGreaterThan(b.y + b.height / 2);
expect(d.y).toBeGreaterThanOrEqual(b.y);
expect(d.y + d.height).toBeLessThanOrEqual(b.y + b.height);

// 点圆点不夺走焦点
Expand Down
24 changes: 23 additions & 1 deletion docs/testing/unit/ui/input-dot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* (TC-E2E-134);jsdom 没有布局,测量算不出来,这里只覆盖回落分支。
* #666 的单行文本框同理(真值见 TC-E2E-138)。
* #668:字体加载完成、站点改了框的样式之后重新对齐;同样用回落分支观察。
* #671:contenteditable 用行盒测量贴文字末尾(真值见 TC-E2E-141),停手对齐与
* 回落沿用同一套,这里同样只覆盖回落分支。
* #670:连续打字期间不重算位置,停手后(与逐段按钮悬停意图同一个 140ms
* 口径)对齐一次。这里用回落分支观察:打字期间框挪了位置,圆点也不跟。
*/
Expand Down Expand Up @@ -483,7 +485,7 @@ describe('contenteditable 富文本框(#655)', () => {
expect(shown()).toBe(false);
});

test('位置回落到框内侧右下角(ADR-0006)', () => {
test('量不出文字末尾(jsdom 没有行盒)→ 位置回落到框内侧右下角(ADR-0006;#671 起贴末尾,真值见 TC-E2E-141)', () => {
registry.ensure('input-dot', true);
const host = rich('hello');
mockBoundingRect(host, { left: 100, top: 50, right: 400, bottom: 150, width: 300, height: 100 });
Expand Down Expand Up @@ -545,6 +547,26 @@ describe('打字期间不重算位置,停手后对齐一次(#670)', () =>
expect(top()).toBeLessThan(350);
});

test('contenteditable 同一套:编辑器改 DOM 期间不动,停手满 140ms 才对齐(#671)', async () => {
registry.ensure('input-dot', true);
const host = document.createElement('div');
host.setAttribute('contenteditable', 'true');
host.textContent = '你好';
document.body.appendChild(host);
mockBoundingRect(host, RECT_A);
host.focus();
const before = top();

// 受控编辑器自己重新渲染(没有 input 事件),框挪到了别处:圆点不跟
mockBoundingRect(host, RECT_B);
host.textContent = '你好世界';
await Promise.resolve();
vi.advanceTimersByTime(SHOW_DELAY - 1);
expect(top()).toBe(before);
vi.advanceTimersByTime(1);
expect(top()).toBeGreaterThan(250);
});

test('停手前失焦:不再对齐,也不报错', () => {
registry.ensure('input-dot', true);
const ta = textarea('你好');
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.33",
"version": "2.1.34",
"description": "对照式网页翻译浏览器扩展",
"license": "GPL-3.0-or-later",
"private": true,
Expand Down
12 changes: 7 additions & 5 deletions src/ui/input-dot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@
// #668:量不准的情形都换位置、不隐藏(ADR-0006)。祖先带 transform 缩放时
// 测量交给回落,回落与钳制用的可见区域按缩放换算;停手之后网页字体才加载
// 完、站点改了框的样式或尺寸,都重新对齐一次。
// #671:contenteditable 同样贴文字末尾,用与逐段按钮共用的行盒测量(#634),
// 不另养镜像;钳边、停手对齐、测不出回落三条规则与文本框同一套。

import { mountIsolated, unmountIsolated } from './mount';
import { measureFieldEnd, type TextEnd } from './input-measure';
import { measureEditableEnd, measureFieldEnd, type TextEnd } from './input-measure';
import { GAP, SHOW_DELAY } from './paragraph-btn';
import { tf } from '../i18n';
import { decideInputEligibility, inputText, type TextInput } from './input-eligibility';
Expand Down Expand Up @@ -139,15 +141,15 @@ function besideEnd(end: TextEnd, box: InnerBox, rtl: boolean): { left: number; t
}

/**
* 圆点位置(ADR-0006):多行(#665)与单行(#666)文本框贴文字末尾,与光标
* 并排(#667),并钳在框的可见区域里(#669);量不出来、或是 contenteditable
* 时回落框内侧右下角 —— 圆点是唯一入口,量不出来只换位置,绝不不显示。
* 圆点位置(ADR-0006):多行(#665)、单行(#666)文本框与 contenteditable
* (#671)贴文字末尾,与光标并排(#667),并钳在框的可见区域里(#669);量不
* 出来时回落框内侧右下角 —— 圆点是唯一入口,量不出来只换位置,绝不不显示。
*/
function place(dot: HTMLElement, el: HTMLElement, shadow: ShadowRoot): void {
const end =
el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement
? measureFieldEnd(el, shadow)
: null;
: measureEditableEnd(el);
if (!end) {
placeFallback(dot, el);
return;
Expand Down
43 changes: 35 additions & 8 deletions src/ui/input-measure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,25 @@
// 本文件是 Parallel-Translation 的一部分,依 GNU GPL v3 或更新版本发布,
// 不含任何担保。完整条款见仓库根目录的 LICENSE。

// 输入翻译圆点的镜像测量(#665,ADR-0006)—— 量多行文本框(#665)与单行
// 文本框(#666)里文字末尾的位置。
// 输入翻译圆点的末尾测量(ADR-0006)—— 量多行文本框(#665)、单行文本框
// (#666)与 contenteditable(#671)里文字末尾的位置。
//
// textarea 与 input 的内容是 value 而不是 DOM 文本,Range 量不到。只能另建一个隐藏的
// 镜像元素:把影响排版的样式(字体、字号、行高、内边距、边框、字间距、
// 换行规则、书写方向)复制过去,放进同样的文字,在末尾插一个标记再量它,
// 最后减去框内的滚动位置。
// contenteditable 有真实的文本节点,直接用逐段翻译按钮那份行盒测量(#634)。
// 镜像元素只是文本框的无奈之举:textarea 与 input 的内容是 value 而不是 DOM
// 文本,Range 量不到,只能另建一个隐藏的镜像元素 —— 把影响排版的样式(字体、
// 字号、行高、内边距、边框、字间距、换行规则、书写方向)复制过去,放进同样
// 的文字,在末尾插一个标记再量它,最后减去框内的滚动位置。
//
// 契约:返回文字末尾的坐标,或者返回 null 表示算不出来(没有布局、框没有
// 渲染出来、祖先带 transform 缩放而量不准,#668)。算不出来时调用方回落到框内侧右下角,绝不因此不显示圆点。
// 渲染出来、祖先带 transform 缩放而量不准,#668)。算不出来时调用方回落到
// 框内侧右下角,绝不因此不显示圆点。
//
// 镜像放在调用方给的容器里(圆点自己的 shadow root):页面样式碰不到它,
// 页面的 MutationObserver 也看不到它的增删。量完立刻移除。

/** 文字末尾:视口坐标下末尾那一行的左边缘位置与行盒的上沿、高度。 */
import { textRects } from './text-rects';

/** 文字末尾:视口坐标下末尾所在的横向位置,与末尾那一行行盒的上沿、高度。 */
export interface TextEnd {
x: number;
y: number;
Expand Down Expand Up @@ -129,3 +133,26 @@ export function measureFieldEnd(
mirror.remove();
}
}

/**
* 量 contenteditable 里文字末尾的位置(#671);算不出来时为 null。
*
* 行盒按文档顺序排列,最后一个有高度的就是末尾所在:行内混排(链接、加粗)
* 时它是最后那段文字自己的行盒,而不是外层元素的边框盒。末尾在 LTR 下是它的
* 右边缘、RTL 下是左边缘。坐标已经是屏幕坐标(含框内滚动与祖先缩放),不用
* 另行换算,也就不需要镜像。
*/
export function measureEditableEnd(el: HTMLElement): TextEnd | null {
let rects: DOMRect[];
try {
rects = textRects(el);
} catch {
// 没有布局引擎(Range 量不了行盒):算不出来,交给回落
return null;
}
const last = rects.findLast((r) => r.height > 0);
if (!last) return null;
const x = getComputedStyle(el).direction === 'rtl' ? last.left : last.right;
if (!Number.isFinite(x) || !Number.isFinite(last.top)) return null;
return { x, y: last.top, height: last.height };
}
Loading