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 { 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 { 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 { return page.evaluate(() => { const toolbar = document.querySelector('.g3-rich-text__toolbar') as HTMLElement const centers = new Set() 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) }) })