189 lines
8.1 KiB
TypeScript
189 lines
8.1 KiB
TypeScript
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)
|
||
})
|
||
})
|