From 7652d0d17e9a0377dd0dda788b7979e77a0106e2 Mon Sep 17 00:00:00 2001 From: Teeeeeeeerry Date: Wed, 7 Oct 2026 13:08:11 +1100 Subject: [PATCH 1/2] =?UTF-8?q?feat(input):=20contenteditable=20=E7=9A=84?= =?UTF-8?q?=E5=9C=86=E7=82=B9=E8=B4=B4=E6=96=87=E5=AD=97=E6=9C=AB=E5=B0=BE?= =?UTF-8?q?=EF=BC=8C=E7=94=A8=E4=B8=8E=E9=80=90=E6=AE=B5=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E7=9A=84=E8=A1=8C=E7=9B=92=E6=B5=8B=E9=87=8F?= =?UTF-8?q?=EF=BC=8C=E5=A4=9A=E8=A1=8C=E4=B8=8E=E8=A1=8C=E5=86=85=E6=B7=B7?= =?UTF-8?q?=E6=8E=92=E9=83=BD=E5=AF=B9=EF=BC=88#671=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contenteditable 有真实的文本节点,末尾取共用行盒测量(#634)里最后一个有 高度的行盒:行内混排时它是最后那段文字自己的行盒,不是外层元素的边框盒; LTR 取右边缘、RTL 取左边缘。坐标已是屏幕坐标,不另养镜像。与光标并排 (#667)、钳边(#669)、停手对齐(#670)、测不出回落(#668)都走文本框 那一套。 TC-E2E-128 的回落位置断言改为“整颗在框里”,位置真值交给 TC-E2E-141。 Co-Authored-By: zhexuancai-uts <261878103+zhexuancai-uts@users.noreply.github.com> --- docs/testing/e2e/core.spec.ts | 96 ++++++++++++++++++++++++-- docs/testing/unit/ui/input-dot.test.ts | 24 ++++++- src/ui/input-dot.ts | 12 ++-- src/ui/input-measure.ts | 43 +++++++++--- 4 files changed, 157 insertions(+), 18 deletions(-) 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
second bold and a link
'); + 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/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 }; +} From 1761fbc65bff39ba815c7ea1daf162af4f54330e Mon Sep 17 00:00:00 2001 From: Teeeeeeeerry Date: Wed, 7 Oct 2026 13:09:11 +1100 Subject: [PATCH 2/2] =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8F=B7=202.1.34?= =?UTF-8?q?=EF=BC=88#671=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: zhexuancai-uts <261878103+zhexuancai-uts@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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,