This commit is contained in:
oneao committed 2026-10-12 17:32:50 +08:00
1 parent 78eb811ff7
commit 97fe2ff306
83 files changed
+10083 -3662

No files matched your search

@@ -177,13 +177,21 @@ test.describe('RichText 工具条 · 更多菜单', () => {
})
})
/** 工具条当前占几行(同一行的元素共享垂直中心) */
/**
* 工具条当前占几行(同一行的元素共享垂直中心)。
*
* ⚠️ 不能遍历 `toolbar.children` —— 分组容器用了 `display: contents`,
* 它们的盒子不参与布局、`getBoundingClientRect()` 恒为 0,按 children 数会永远得到 1 行
* (哪怕实际折成了三行)。必须量**真正参与布局的按钮**。
*/
function toolbarRows(page: Page): Promise<number> {
return page.evaluate(() => {
const toolbar = document.querySelector('.g3-rich-text__toolbar') as HTMLElement
const centers = new Set<number>()
for (const el of Array.from(toolbar.children) as HTMLElement[]) {
const buttons = toolbar.querySelectorAll('button')
for (const el of Array.from(buttons) as HTMLElement[]) {
const rect = el.getBoundingClientRect()
if (rect.height === 0) continue
centers.add(Math.round(rect.top + rect.height / 2))
}
return centers.size
@@ -191,23 +199,64 @@ function toolbarRows(page: Page): Promise<number> {
}
test.describe('RichText 工具条 · 外观', () => {
test('窄到 780px 仍保持一行(低频收进「更多」的预算)', async ({ page }) => {
test('宽容器下保持一行(低频收进「更多」的预算)', async ({ page }) => {
await page.goto('/')
const editor = page.getByTestId('rich-text')
await editor.scrollIntoViewIfNeeded()
await editor.locator('.tiptap').click()
await page.evaluate(() => {
;(document.querySelector('[data-testid="rich-text"]') as HTMLElement).style.width = '780px'
;(document.querySelector('[data-testid="rich-text"]') as HTMLElement).style.width = '1000px'
})
await expect
.poll(() => toolbarRows(page), {
message: '工具条自然宽度约 744px(32px 按钮 + 左右 10px 内边距);新增按钮前先想清楚该不该挪进「更多」',
message:
'工具条在 1000px 宽下应当是一行;窄了就折行(折行以按钮为单位,见 .g3-rich-text__group 的 display: contents)',
})
.toBe(1)
})
test('窄容器下折行时按按钮换行,不整组跳行', async ({ page }) => {
await page.goto('/')
const editor = page.getByTestId('rich-text')
await editor.scrollIntoViewIfNeeded()
await editor.locator('.tiptap').click()
// 620px:一行放不下所有按钮,必然折行
await page.evaluate(() => {
;(document.querySelector('[data-testid="rich-text"]') as HTMLElement).style.width = '620px'
})
const info = await page.evaluate(() => {
const toolbar = document.querySelector('.g3-rich-text__toolbar') as HTMLElement
const barRect = toolbar.getBoundingClientRect()
const rows = new Map<number, { right: number; count: number }>()
for (const el of Array.from(toolbar.querySelectorAll('button')) as HTMLElement[]) {
const r = el.getBoundingClientRect()
if (r.height === 0) continue
const top = Math.round(r.top)
const entry = rows.get(top) ?? { right: 0, count: 0 }
entry.right = Math.max(entry.right, Math.round(r.right))
entry.count += 1
rows.set(top, entry)
}
const sorted = [...rows.entries()].sort((a, b) => a[0] - b[0])
return {
rowCount: sorted.length,
// 每行最右侧按钮距容器内容区右边缘还剩多少(越小说明填得越满)
slack: sorted.map(([, v]) => Math.round(barRect.right - 10 - v.right)),
firstRowCount: sorted[0]?.[1].count ?? 0,
}
})
// 折行了,但不是「整组跳行」导致第一行只放几个
expect(info.rowCount).toBeGreaterThan(1)
expect(info.firstRowCount).toBeGreaterThan(8)
// 第一行应当填得比较满:剩余空隙小于一个按钮宽(不出现「右边空一大片」)
expect(info.slack[0]).toBeLessThan(36)
})
test('工具条带下投影,且抬在正文之上(滚动时投影不被正文盖住)', async ({ page }) => {
await page.goto('/')
const editor = page.getByTestId('rich-text')
@@ -231,3 +280,428 @@ test.describe('RichText 工具条 · 外观', () => {
expect(Number(style.zIndex)).toBeGreaterThan(0)
})
})
test.describe('RichText 工具条 · 键盘操作', () => {
/** 当前获得焦点的元素(用 aria-label 标识,便于断言「焦点在第几个按钮上」) */
function focusedLabel(page: Page): Promise<string | null> {
return page.evaluate(() => {
const el = document.activeElement as HTMLElement | null
if (!el || el.tagName !== 'BUTTON') return null
return el.getAttribute('aria-label')
})
}
/** 工具条里 tabindex=0 的按钮 aria-label 列表(roving tabindex 应当只有一个) */
function tabbableInToolbar(page: Page): Promise<(string | null)[]> {
return page.evaluate(() =>
Array.from(
document.querySelectorAll('.g3-rich-text__toolbar button:not(:disabled)'),
)
.filter((el) => (el as HTMLElement).tabIndex === 0)
.map((el) => el.getAttribute('aria-label')),
)
}
/**
* 工具条里**可聚焦**按钮的 aria-label,按 DOM 顺序。
*
* 必须排除 disabled:初始状态下「撤销」「重做」是禁用的(历史为空),
* 它们不可聚焦、也不该出现在方向键序列里。断言用真实 DOM 顺序而不是写死
* 按钮名,避免以后调整工具条顺序时测试变成假失败。
*/
function focusableLabels(page: Page): Promise<(string | null)[]> {
return page.evaluate(() =>
Array.from(
document.querySelectorAll('.g3-rich-text__toolbar button:not(:disabled)'),
).map((el) => el.getAttribute('aria-label')),
)
}
/**
* 段落格式触发器(「正文 / 标题 N」)。
*
* 用 class 定位而不是 role+name:它的可访问名是**当前块格式**、随光标变化
* (playground 初始内容含 `<h2>`,名字就是「标题 2」),拿它当固定名字必然不稳。
*/
function headingTrigger(page: Page) {
return page.locator(`${EDITOR} .g3-rich-text__toolbar .g3-rich-text__trigger`)
}
test('工具条是 role=toolbar 的单个 Tab 停靠点', async ({ page }) => {
await page.goto('/')
const toolbar = page.locator(`${EDITOR} .g3-rich-text__toolbar`)
await expect(toolbar).toHaveAttribute('role', 'toolbar')
await expect(toolbar).toHaveAttribute('aria-orientation', 'horizontal')
// 无论有多少按钮,tabindex=0 的只有一个 —— 否则每个按钮都占一个 Tab 位
const tabbable = await tabbableInToolbar(page)
expect(tabbable).toHaveLength(1)
const buttonCount = await page
.locator(`${EDITOR} .g3-rich-text__toolbar button:not(:disabled)`)
.count()
// 14 个图标按钮 + 1 个段落格式触发器(撤销 / 重做初始禁用,不计入)
expect(buttonCount).toBeGreaterThanOrEqual(15)
})
test('方向键在按钮间漫游,Home / End 跳首尾', async ({ page }) => {
await page.goto('/')
const labels = await focusableLabels(page)
// 初始态「撤销 / 重做」禁用,序列从段落格式触发器开始,其后是「加粗」
expect(labels[0]).toBe('段落格式')
expect(labels[1]).toBe('加粗')
await headingTrigger(page).focus()
await expect.poll(() => focusedLabel(page)).toBe('段落格式')
// 右键 → 下一个
await page.keyboard.press('ArrowRight')
await expect.poll(() => focusedLabel(page)).toBe('加粗')
// 左键回退
await page.keyboard.press('ArrowLeft')
await expect.poll(() => focusedLabel(page)).toBe('段落格式')
// 首个再往左 → 两端循环到末位(「更多」)
await page.keyboard.press('ArrowLeft')
await expect.poll(() => focusedLabel(page)).toBe(labels[labels.length - 1])
// End 跳末位 / Home 跳首位
await page.keyboard.press('End')
await expect.poll(() => focusedLabel(page)).toBe(labels[labels.length - 1])
await page.keyboard.press('Home')
await expect.poll(() => focusedLabel(page)).toBe(labels[0])
})
test('禁用按钮(撤销 / 重做)不会被方向键走到', async ({ page }) => {
await page.goto('/')
// 初始历史为空 → 撤销 / 重做禁用
const undo = page.locator(`${EDITOR} .g3-rich-text__toolbar button[aria-label="撤销"]`)
await expect(undo).toBeDisabled()
const labels = await focusableLabels(page)
expect(labels).not.toContain('撤销')
expect(labels).not.toContain('重做')
})
test('方向键漫游时不会把光标带进正文(preventDefault 生效)', async ({ page }) => {
await page.goto('/')
// 先制造可撤销的历史,让「撤销」进入可聚焦序列(更接近真实使用)
const editor = page.getByTestId('rich-text')
await editor.locator('.tiptap').click()
await page.keyboard.type('键盘测试')
const undo = editor.locator('.g3-rich-text__toolbar button[aria-label="撤销"]')
await expect(undo).toBeEnabled()
await undo.focus()
await page.keyboard.press('End')
await page.keyboard.press('Home')
// Home / End 若没被 preventDefault,浏览器会把焦点 / 光标带出工具条
await expect.poll(() => focusedLabel(page)).toBe('撤销')
await expect(editor.locator('.tiptap')).not.toHaveClass(/ProseMirror-focused/)
})
test('焦点移动后 tabindex 跟着走(roving 归一化)', async ({ page }) => {
await page.goto('/')
await headingTrigger(page).focus()
await page.keyboard.press('ArrowRight')
expect(await focusedLabel(page)).toBe('加粗')
// 任意时刻恰有一个 tabindex=0,且它就是当前焦点所在按钮
expect(await tabbableInToolbar(page)).toEqual(['加粗'])
})
})
test.describe('RichText 工具条 · 链接', () => {
/** 地址输入框(面板里两个 input,靠 aria-label 区分) */
function urlInput(page: Page) {
return page.getByLabel('链接地址')
}
/** 显示文字输入框 */
function textInput(page: Page) {
return page.getByLabel('显示文字')
}
/** 在正文里输入文本并全选,然后打开链接面板 */
async function openLinkPanel(page: Page, text = '点我'): 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)
await page.keyboard.press('Control+A')
await editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '链接' }).click()
await expect(page.locator('.g3-rich-text__link-form')).toBeVisible()
}
test('输入地址后回车写入选区,且不弹原生 dialog', async ({ page }) => {
// 回归:旧实现用 window.prompt,这里断言「没有任何原生对话框被触发」
let dialogSeen = false
page.on('dialog', async (dialog) => {
dialogSeen = true
await dialog.dismiss()
})
await page.goto('/')
await openLinkPanel(page)
await urlInput(page).fill('https://example.com/a')
await page.keyboard.press('Enter')
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveCount(1)
await expect(link).toHaveAttribute('href', 'https://example.com/a')
expect(dialogSeen, '不应再触发 window.prompt 原生弹窗').toBe(false)
// 应用后面板收起
await expect(page.locator('.g3-rich-text__link-form')).toHaveCount(0)
})
test('面板有「链接地址」与「显示文字」两个字段,且无 placeholder', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page)
await expect(page.locator('.g3-rich-text__link-form')).toBeVisible()
await expect(urlInput(page)).toBeVisible()
await expect(textInput(page)).toBeVisible()
// 刻意不留 placeholder:字段名已说明填什么,示例文案属于噪音
await expect(urlInput(page)).not.toHaveAttribute('placeholder', /.*/)
await expect(textInput(page)).not.toHaveAttribute('placeholder', /.*/)
// 有选区时「显示文字」预填选中的文字
await expect(textInput(page)).toHaveValue('点我')
})
test('显示文字:替换选中文字', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '点我')
await urlInput(page).fill('https://example.com/a')
await textInput(page).fill('查看详情')
await page.keyboard.press('Enter')
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveCount(1)
await expect(link).toHaveText('查看详情')
await expect(link).toHaveAttribute('href', 'https://example.com/a')
})
test('「取消」关闭面板且不写入文档', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '点我')
await urlInput(page).fill('https://example.com/never')
await page.locator('.g3-rich-text__link-form').getByRole('button', { name: '取消' }).click()
// 面板收起、正文没有产生链接
await expect(page.locator('.g3-rich-text__link-form')).toHaveCount(0)
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveCount(0)
await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('点我')
})
test('「取消 / 确定」靠右成组,主操作位置稳定', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '点我')
const form = page.locator('.g3-rich-text__link-form')
const cancel = form.getByRole('button', { name: '取消' })
const confirm = form.getByRole('button', { name: '确定' })
const cancelBox = (await cancel.boundingBox())!
const confirmBox = (await confirm.boundingBox())!
const formBox = (await form.boundingBox())!
// 确定在取消右侧
expect(confirmBox.x).toBeGreaterThan(cancelBox.x)
// 两者底边对齐(同一行)
expect(Math.abs(cancelBox.y - confirmBox.y)).toBeLessThan(2)
// 确定右边缘贴近面板右侧内边距(靠右而不是居中/通栏)
const formRight = formBox.x + formBox.width
expect(formRight - (confirmBox.x + confirmBox.width)).toBeLessThan(20)
})
test('操作按钮用默认尺寸而非 small(高度对齐 G3Input)', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '点我')
const form = page.locator('.g3-rich-text__link-form')
const confirm = (await form.getByRole('button', { name: '确定' }).boundingBox())!
// 量 G3Input 的**根包裹层**(32px 的那个盒子),不是它内部的裸 <input>(21px 行盒)——
// getByLabel 解析到的是内部 input,直接量它会得出「按钮比输入框高」的错误结论
const inputBox = (await form.locator('.g3-input').first().boundingBox())!
// 默认尺寸 = --g3-control-height = 32px;small 是 24px
expect(confirm.height).toBeGreaterThanOrEqual(30)
// 与输入框等高,同一行里视觉齐平
expect(Math.abs(confirm.height - inputBox.height)).toBeLessThan(3)
})
test('编辑态:移除链接在左,取消 / 确定在右', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '点我')
await urlInput(page).fill('https://example.com/x')
await page.keyboard.press('Enter')
await page.locator(`${EDITOR} .tiptap a`).click()
await page
.locator(`${EDITOR} .g3-rich-text__toolbar`)
.getByRole('button', { name: '链接' })
.click()
const form = page.locator('.g3-rich-text__link-form')
const remove = (await form.getByRole('button', { name: '移除链接' }).boundingBox())!
const cancel = (await form.getByRole('button', { name: '取消' }).boundingBox())!
// 移除链接在最左,取消在它右侧(危险操作与主操作分开)
expect(remove.x).toBeLessThan(cancel.x)
})
test('显示文字:空选区时插入为链接文字', async ({ page }) => {
await page.goto('/')
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 editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '链接' }).click()
await expect(page.locator('.g3-rich-text__link-form')).toBeVisible()
await urlInput(page).fill('https://example.com/b')
// 「显示文字」不是必填(只填地址即可提交);这里我们主动填上文字
await textInput(page).fill('官网')
await page.keyboard.press('Enter')
// 落下的文字本身带链接,href 正确、文字正确
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveCount(1)
await expect(link).toHaveText('官网')
await expect(link).toHaveAttribute('href', 'https://example.com/b')
})
test('显示文字留空时沿用原选区文字', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '保留我')
await urlInput(page).fill('https://example.com/c')
await textInput(page).fill('')
// 有原选区文字 → 仍可提交
await page.keyboard.press('Enter')
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveText('保留我')
await expect(link).toHaveAttribute('href', 'https://example.com/c')
})
test('不带协议时自动补 https://', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page)
await urlInput(page).fill('example.com/docs')
await page.locator('.g3-rich-text__link-form').getByRole('button', { name: '确定' }).click()
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveAttribute(
'href',
'https://example.com/docs',
)
})
test('空选区且未填显示文字:确定可用,并把地址本身作为链接文字', async ({ page }) => {
await page.goto('/')
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 editor
.locator('.g3-rich-text__toolbar')
.getByRole('button', { name: '链接' })
.click()
// 「显示文字」不是必填(产品要求)—— 填了地址就应当能提交
await urlInput(page).fill('https://example.com')
await expect(
page.locator('.g3-rich-text__link-form').getByRole('button', { name: '确定' }),
).toBeEnabled()
// 兜底:没有任何文字可挂时,用地址本身当链接文字
await page.keyboard.press('Enter')
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveCount(1)
await expect(link).toHaveText('https://example.com')
})
test('只填地址也能提交(显示文字留空)', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page, '原文')
// 主动清掉预填的显示文字
await textInput(page).fill('')
await urlInput(page).fill('https://example.com/only-url')
await page.keyboard.press('Enter')
const link = page.locator(`${EDITOR} .tiptap a`)
await expect(link).toHaveAttribute('href', 'https://example.com/only-url')
// 有选中文字、显示文字留空 → 沿用原文字
await expect(link).toHaveText('原文')
})
test('光标在已有链接上时,面板可编辑并可移除链接', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page)
await urlInput(page).fill('https://example.com/first')
await page.keyboard.press('Enter')
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveCount(1)
// 把光标放回链接文字里,再打开面板 → 地址被预填、可编辑
await page.locator(`${EDITOR} .tiptap a`).click()
await page
.locator(`${EDITOR} .g3-rich-text__toolbar`)
.getByRole('button', { name: '链接' })
.click()
await expect(urlInput(page)).toHaveValue('https://example.com/first')
// 编辑态能看到「移除链接」
await expect(
page.locator('.g3-rich-text__link-form').getByRole('button', { name: '移除链接' }),
).toBeVisible()
// 改地址
await urlInput(page).fill('https://example.com/second')
await page.keyboard.press('Enter')
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveAttribute(
'href',
'https://example.com/second',
)
// 再打开 → 移除链接
await page.locator(`${EDITOR} .tiptap a`).click()
await page
.locator(`${EDITOR} .g3-rich-text__toolbar`)
.getByRole('button', { name: '链接' })
.click()
await page
.locator('.g3-rich-text__link-form')
.getByRole('button', { name: '移除链接' })
.click()
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveCount(0)
})
test('javascript: 之类的可执行协议不会被写入 href', async ({ page }) => {
await page.goto('/')
await openLinkPanel(page)
await urlInput(page).fill('javascript:alert(1)')
await page.keyboard.press('Enter')
// normalizeUrl 只放行 http/https/mailto/tel/#// 开头的地址,其余原样保留 → 不写链接
const href = await page
.locator(`${EDITOR} .tiptap a`)
.evaluateAll((els) => els.map((el) => el.getAttribute('href')))
expect(href.every((value) => !value?.toLowerCase().startsWith('javascript:'))).toBe(true)
})
})