This commit is contained in:
oneao committed 2026-10-11 22:14:13 +08:00
1 parent 32d423b759
commit 78eb811ff7
102 files changed
+12067 -209

No files matched your search

@@ -0,0 +1,188 @@
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)
})
})