Files
workspace/code/g3soft-libs/packages/ui/e2e/rich-text-focus.spec.ts
T
2026-10-11 22:14:13 +08:00

189 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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)
})
})