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

234 lines
9.8 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'
/**
* 富文本工具条的排版能力回归:对齐(左 / 中 / 右)与颜色(字体色 / 背景色)。
*
* 这两组能力都落在 Tiptap 扩展的 HTML 输出上(`text-align` 写块元素、
* `color` / `background-color` 写 `textStyle` 记号),happy-dom 里没有真实的
* contenteditable + 选区,只能放 E2E 验证「点按钮 → 选区真的被改了」。
*
* 取色面板与表格操作浮层一样 Teleport 到 body,所以色块要用全局定位找。
*/
const EDITOR = '[data-testid="rich-text"]'
/** 清空编辑器后输入一段文本并全选(保证选区落在同一段落内) */
async function typeAndSelectAll(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')
}
/** 光标所在段落的 style 属性(对齐写在这里) */
function paragraphStyle(page: Page): Promise<string> {
return page
.locator(`${EDITOR} .tiptap p`)
.first()
.evaluate((el) => el.getAttribute('style') ?? '')
}
/** 段落内所有 span 的实测颜色(颜色写在内联 span 上) */
function spanColors(page: Page): Promise<{ color: string; background: string }[]> {
return page.locator(`${EDITOR} .tiptap span`).evaluateAll((els) =>
els.map((el) => {
const style = getComputedStyle(el)
return { color: style.color, background: style.backgroundColor }
}),
)
}
test.describe('RichText 工具条 · 对齐', () => {
test('左 / 中 / 右写进段落,点当前档位即取消', async ({ page }) => {
await page.goto('/')
await typeAndSelectAll(page)
const toolbar = page.locator(`${EDITOR} .g3-rich-text__toolbar`)
const center = toolbar.getByRole('button', { name: '居中' })
await center.click()
await expect.poll(() => paragraphStyle(page)).toContain('text-align: center')
await expect(center).toHaveAttribute('aria-pressed', 'true')
// 换成右对齐:仍只有一个对齐属性
await toolbar.getByRole('button', { name: '右对齐' }).click()
await expect.poll(() => paragraphStyle(page)).toContain('text-align: right')
await expect.poll(() => paragraphStyle(page)).not.toContain('center')
// 再点当前档位 → 取消对齐(不需要单独的「取消对齐」按钮)
await toolbar.getByRole('button', { name: '右对齐' }).click()
await expect.poll(() => paragraphStyle(page)).not.toContain('text-align')
})
})
test.describe('RichText 工具条 · 颜色', () => {
test('字体颜色与背景颜色:色板取色写进选区,面板随即收起', async ({ page }) => {
await page.goto('/')
await typeAndSelectAll(page)
const editor = page.getByTestId('rich-text')
const toolbar = page.locator(`${EDITOR} .g3-rich-text__toolbar`)
const panel = page.locator('.g3-rich-text__color-panel')
// 取字体颜色:色块按 hex 作为 aria-label(Ant Design 的红色 6 号色)
await toolbar.getByRole('button', { name: '字体颜色' }).click()
await expect(panel).toBeVisible()
await page.locator('.g3-rich-text__color-swatch[aria-label="#f5222d"]').click()
await expect(panel).toHaveCount(0)
await expect
.poll(async () => (await spanColors(page)).map((item) => item.color))
.toContain('rgb(245, 34, 45)')
// 触发器的当前色条跟着变(未设色时是空位,不写内联 background)
await expect
.poll(() =>
page
.locator(`${EDITOR} .g3-rich-text__color-bar`)
.first()
.evaluate((el) => getComputedStyle(el).backgroundColor),
)
.toBe('rgb(245, 34, 45)')
// 取背景颜色:浅色档底色
await toolbar.getByRole('button', { name: '背景颜色' }).click()
await page.locator('.g3-rich-text__color-swatch[aria-label="#fff1f0"]').click()
await expect
.poll(async () => (await spanColors(page)).map((item) => item.background))
.toContain('rgb(255, 241, 240)')
// 两个取色面板不会同时开着
await expect(panel).toHaveCount(0)
// 清除字体颜色:记号里的 color 被移除,背景色保留
await toolbar.getByRole('button', { name: '字体颜色' }).click()
await page.getByRole('button', { name: '清除字体颜色' }).click()
await expect
.poll(async () => (await spanColors(page)).map((item) => item.color))
.not.toContain('rgb(245, 34, 45)')
await expect
.poll(async () => (await spanColors(page)).map((item) => item.background))
.toContain('rgb(255, 241, 240)')
})
})
test.describe('RichText 工具条 · 更多菜单', () => {
test('低频功能收进「更多」,打开后按组展示且可用', async ({ page }) => {
await page.goto('/')
await typeAndSelectAll(page)
const editor = page.getByTestId('rich-text')
const toolbar = editor.locator('.g3-rich-text__toolbar')
// 工具条上不再平铺这些低频按钮(平铺会把工具条顶到 928px 宽)
for (const name of ['删除线', '上标', '下标', '引用', '视频']) {
await expect(toolbar.getByRole('button', { name })).toHaveCount(0)
}
// 字体 / 字号下拉也收进菜单:工具条上只剩「段落格式」一个带当前值的触发器
await expect(toolbar.locator('.g3-rich-text__trigger')).toHaveCount(1)
await toolbar.getByRole('button', { name: '更多' }).click()
const menu = page.locator('.g3-dropdown__menu')
await expect(menu).toBeVisible()
// 菜单内容约 295px,比 Dropdown 默认的 256px 上限高 —— 面板内部不该出现滚动条
const menuBox = await menu.evaluate((el) => ({
scroll: el.scrollHeight,
client: el.clientHeight,
}))
expect(menuBox.scroll).toBeLessThanOrEqual(menuBox.client)
// 分组标题 + 快捷键提示(快捷键对着 Tiptap 的真实键位)
await expect(menu.getByRole('group')).toHaveText(['格式', '插入'])
await expect(menu.locator('.g3-dropdown__item-shortcut').first()).toHaveText('Ctrl+Shift+S')
// 点「删除线」:作用于选区,菜单随即收起
await menu.getByRole('menuitem', { name: /删除线/ }).click()
await expect(editor.locator('.tiptap s')).toHaveCount(1)
await expect(menu).toHaveCount(0)
})
test('字体 / 字号在「更多」的二级菜单里,选中态跟随光标', async ({ page }) => {
await page.goto('/')
await typeAndSelectAll(page)
const editor = page.getByTestId('rich-text')
await editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '更多' }).click()
await page.getByRole('menuitem', { name: /^字体/ }).hover()
const submenu = page.locator('.g3-dropdown__menu').last()
await expect(submenu).toBeVisible()
// 子菜单沿用根面板的 max-height:8 个字体正好压着默认上限,也不该出滚动条
const subBox = await submenu.evaluate((el) => ({ scroll: el.scrollHeight, client: el.clientHeight }))
expect(subBox.scroll).toBeLessThanOrEqual(subBox.client)
await submenu.getByRole('menuitem', { name: '宋体' }).click()
const span = editor.locator('.tiptap span[style*="font-family"]')
await expect(span).toHaveCount(1)
await expect(span).toHaveAttribute('style', /SimSun/)
// 再打开时当前字体带选中态
await editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '更多' }).click()
await page.getByRole('menuitem', { name: /^字体/ }).hover()
await expect(
page.locator('.g3-dropdown__menu').last().getByRole('menuitem', { name: '宋体' }),
).toHaveAttribute('aria-current', 'page')
})
})
/** 工具条当前占几行(同一行的元素共享垂直中心) */
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 rect = el.getBoundingClientRect()
centers.add(Math.round(rect.top + rect.height / 2))
}
return centers.size
})
}
test.describe('RichText 工具条 · 外观', () => {
test('窄到 780px 仍保持一行(低频收进「更多」的预算)', 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'
})
await expect
.poll(() => toolbarRows(page), {
message: '工具条自然宽度约 744px(32px 按钮 + 左右 10px 内边距);新增按钮前先想清楚该不该挪进「更多」',
})
.toBe(1)
})
test('工具条带下投影,且抬在正文之上(滚动时投影不被正文盖住)', async ({ page }) => {
await page.goto('/')
const editor = page.getByTestId('rich-text')
const toolbar = editor.locator('.g3-rich-text__toolbar')
await expect(toolbar).toBeVisible()
const style = await toolbar.evaluate((el) => {
const computed = getComputedStyle(el)
return {
shadow: computed.boxShadow,
position: computed.position,
zIndex: computed.zIndex,
}
})
// 双层柔和投影(观感对齐 TinyMCE):近层 2px 硬边 + 远层 8px 弥散,y 偏移为正 = 朝下
expect(style.shadow).toMatch(/0px 2px 2px -2px/)
expect(style.shadow).toMatch(/0px 8px 8px -4px/)
// 抬层:positioned + z-index > 0,否则滚动到工具条下沿的正文会盖住投影
expect(style.position).toBe('relative')
expect(Number(style.zIndex)).toBeGreaterThan(0)
})
})