import { expect, test, type Page } from '@playwright/test' /** * 富文本内容区的几何与聚焦回归。 * * 背景:`minHeight` / `maxHeight` 作用在内容区(`.g3-rich-text__content`)上,而 * EditorContent 会再套一层包裹 DOM,`.tiptap` 原本写的 `min-height: inherit` 继承的是 * 那一层的 computed 值(恒为 0),于是可编辑区只跟内容一样高 —— 空文档时 200px 的内容区 * 里只有约 45px 能点,下方全是「点上去既不聚焦也不落光标」的死区。 * * 这三个用例分别锁:内容区短时「可编辑区撑满 + 点空白聚焦到末尾」、宿主样式破坏撑高时的 * 兜底聚焦、内容长时「可编辑区按内容撑开 + 由内容区滚动(不裁切)」。 * * 聚焦、caret 落位与滚动都必须真浏览器:happy-dom 没有真实聚焦、选区与布局。 */ const EDITOR = '[data-testid="rich-text"]' /** 清空正文并输入一段文本(光标停在输入末尾) */ async function resetContent(page: Page, text: string): Promise { const editor = page.getByTestId('rich-text') await editor.scrollIntoViewIfNeeded() await editor.locator('.tiptap').click() await page.keyboard.press('Control+A') await page.keyboard.press('Delete') await page.keyboard.type(text) } /** 内容区 / 可编辑区的实测高度 */ function heights(page: Page) { return page.evaluate(() => { const content = document.querySelector('.g3-rich-text__content') as HTMLElement const tiptap = content.querySelector('.tiptap') as HTMLElement return { content: Math.round(content.getBoundingClientRect().height), tiptap: Math.round(tiptap.getBoundingClientRect().height), focused: tiptap.classList.contains('ProseMirror-focused'), } }) } test.describe('RichText 聚焦', () => { test('可编辑区撑满内容区,点内容区空白即聚焦、光标落在正文末尾', async ({ page }) => { await page.goto('/') await resetContent(page, '第一行') const editor = page.getByTestId('rich-text') const content = editor.locator('.g3-rich-text__content') const tiptap = editor.locator('.tiptap') // 布局:空文档时内容区还是最小高度(200px),可编辑区必须一样高 const before = await heights(page) expect(before.content).toBeGreaterThanOrEqual(200) expect(before.tiptap).toBe(before.content) // 点内容区底部(正文下方那片空白) const box = (await content.boundingBox())! await page.mouse.click(box.x + box.width / 2, box.y + box.height - 10) await expect(tiptap).toHaveClass(/ProseMirror-focused/) await expect .poll(() => page.evaluate(() => document.activeElement?.classList.contains('ProseMirror') ?? false), ) .toBe(true) // 光标在末尾:继续输入会追加在正文后面 await page.keyboard.type('!') await expect(editor.locator('.tiptap')).toContainText('第一行!') }) test('宿主样式破坏撑高时,点编辑器 DOM 之外的空白仍能聚焦到末尾', async ({ page }) => { await page.goto('/') await resetContent(page, '第二行') // 模拟宿主样式把「可编辑区撑满内容区」改掉:包裹层收回到内容高度 await page.addStyleTag({ content: '.g3-rich-text__content > div { flex: 0 0 auto !important; }' }) const editor = page.getByTestId('rich-text') const content = editor.locator('.g3-rich-text__content') const gap = await heights(page) expect(gap.tiptap).toBeLessThan(gap.content) const box = (await content.boundingBox())! await page.mouse.click(box.x + box.width / 2, box.y + box.height - 10) await expect(editor.locator('.tiptap')).toHaveClass(/ProseMirror-focused/) await page.keyboard.type('!!') await expect(editor.locator('.tiptap')).toContainText('第二行!!') }) test('编辑器内部的点击仍按坐标落光标(不被空白聚焦抢走)', async ({ page }) => { await page.goto('/') await resetContent(page, '第一行') await page.keyboard.press('Enter') await page.keyboard.type('第二行') const editor = page.getByTestId('rich-text') const second = editor.locator('.tiptap p').nth(1) const box = (await second.boundingBox())! // 点第二行最左侧 —— 光标应落在该行句首,而不是被挪到全文末尾 await page.mouse.click(box.x + 8, box.y + box.height / 2) const caret = await page.evaluate(() => { const selection = window.getSelection() const node = selection?.anchorNode const element = node?.nodeType === 3 ? node.parentElement : (node as HTMLElement | null) return { text: element?.closest('p')?.textContent ?? '', offset: selection?.anchorOffset ?? -1 } }) expect(caret.text).toBe('第二行') expect(caret.offset).toBeLessThan(2) }) test('聚焦时正文区四周亮起 2px 主色描边(工具条不参与)', async ({ page }) => { await page.goto('/') const editor = page.getByTestId('rich-text') const content = editor.locator('.g3-rich-text__content') const ring = () => content.evaluate((el) => getComputedStyle(el).boxShadow) // 未聚焦:正文区没有描边 await expect.poll(ring).toBe('none') // 点正文 → 正文区四周亮起 await editor.locator('.tiptap').click() await expect.poll(ring).toContain('inset') await expect.poll(ring).toContain('rgb(24, 24, 27)') // 描边必须挂在滚动容器自己身上(背景/阴影不随内容滚动);挂在伪元素覆盖层上会跟着正文滚走 const owners = await page.evaluate(() => { const el = document.querySelector('.g3-rich-text__content') as HTMLElement return { own: getComputedStyle(el).boxShadow, pseudo: getComputedStyle(el, '::after').boxShadow } }) expect(owners.own).toContain('inset') expect(owners.pseudo).toBe('none') // 点工具条按钮:焦点临时落在按钮上,编辑器整体仍是聚焦态 → 描边不熄 await editor.locator('button[aria-label="加粗"]').click() await expect.poll(ring).toContain('inset') // 点到编辑器之外 → 熄灭(用 > 限定,编辑器正文里也有 h2) await page.locator('#s-richtext > h2').click() await expect.poll(ring).toBe('none') }) test('正文超出内容区时按内容撑开、由内容区滚动(正文不被裁切)', async ({ page }) => { await page.goto('/') // 宿主给内容区一个 max-height(等价于 maxHeight prop) await page.addStyleTag({ content: '.g3-rich-text__content { max-height: 220px !important; }' }) await resetContent(page, '第 1 行') for (let i = 1; i < 20; i++) { await page.keyboard.press('Enter') await page.keyboard.type(`第 ${i + 1} 行`) } const measure = () => page.evaluate(() => { const content = document.querySelector('.g3-rich-text__content') as HTMLElement const tiptap = content.querySelector('.tiptap') as HTMLElement const last = tiptap.lastElementChild as HTMLElement const viewportBottom = content.getBoundingClientRect().bottom return { client: content.clientHeight, scroll: content.scrollHeight, tiptap: Math.round(tiptap.getBoundingClientRect().height), lastText: last.textContent ?? '', lastVisible: last.getBoundingClientRect().bottom <= viewportBottom + 1, } }) // 停在顶部:可编辑区必须按内容撑开(大于内容区),而不是被压到视口高度裁掉正文 await page.evaluate(() => { ;(document.querySelector('.g3-rich-text__content') as HTMLElement).scrollTop = 0 }) const top = await measure() expect(top.tiptap).toBeGreaterThan(top.client) expect(top.scroll).toBeGreaterThan(top.client) expect(top.lastText).toBe('第 20 行') expect(top.lastVisible).toBe(false) // 滚到底:最后一行完整可见 await page.evaluate(() => { const content = document.querySelector('.g3-rich-text__content') as HTMLElement content.scrollTop = content.scrollHeight }) const bottom = await measure() expect(bottom.lastVisible).toBe(true) }) })