diff --git a/docs/testing/e2e/core.spec.ts b/docs/testing/e2e/core.spec.ts
index ffa2086..5607cd0 100644
--- a/docs/testing/e2e/core.spec.ts
+++ b/docs/testing/e2e/core.spec.ts
@@ -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 the docs and bold text');
+ await expectAtEnd('末尾是加粗');
+ // 末尾是链接
+ await setHtml('read this first, then the link');
+ await expectAtEnd('末尾是链接');
+ // 多行加行内混排
+ await setHtml('
first line
');
+ 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) => `line ${i + 1}
`).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({});
@@ -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);
// 点圆点不夺走焦点
diff --git a/docs/testing/unit/ui/input-dot.test.ts b/docs/testing/unit/ui/input-dot.test.ts
index 4c1c4df..58cec73 100644
--- a/docs/testing/unit/ui/input-dot.test.ts
+++ b/docs/testing/unit/ui/input-dot.test.ts
@@ -16,6 +16,8 @@
* (TC-E2E-134);jsdom 没有布局,测量算不出来,这里只覆盖回落分支。
* #666 的单行文本框同理(真值见 TC-E2E-138)。
* #668:字体加载完成、站点改了框的样式之后重新对齐;同样用回落分支观察。
+ * #671:contenteditable 用行盒测量贴文字末尾(真值见 TC-E2E-141),停手对齐与
+ * 回落沿用同一套,这里同样只覆盖回落分支。
* #670:连续打字期间不重算位置,停手后(与逐段按钮悬停意图同一个 140ms
* 口径)对齐一次。这里用回落分支观察:打字期间框挪了位置,圆点也不跟。
*/
@@ -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 });
@@ -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('你好');
diff --git a/package.json b/package.json
index 3c65d0a..7225d20 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "parallel-translation",
- "version": "2.1.33",
+ "version": "2.1.34",
"description": "对照式网页翻译浏览器扩展",
"license": "GPL-3.0-or-later",
"private": true,
diff --git a/src/ui/input-dot.ts b/src/ui/input-dot.ts
index 216b6e5..ce08ef5 100644
--- a/src/ui/input-dot.ts
+++ b/src/ui/input-dot.ts
@@ -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';
@@ -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;
diff --git a/src/ui/input-measure.ts b/src/ui/input-measure.ts
index ae15c03..a352aa2 100644
--- a/src/ui/input-measure.ts
+++ b/src/ui/input-measure.ts
@@ -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;
@@ -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 };
+}