234 lines
9.8 KiB
TypeScript
234 lines
9.8 KiB
TypeScript
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)
|
||
})
|
||
})
|