import { expect, test, type Page } from '@playwright/test' /** * 富文本表格的样式 / 交互回归。 * * 1. 抗宿主页面全局样式:文档站(VitePress 默认主题)会在 `.vp-doc` 下写全局表格样式 —— * `table { display: block }` 让表格退化为块级收缩布局、网格宽度不再是 100%; * `tr:nth-child(2n) { background-color: … }` 把斑马纹染到表体行。组件必须在自身样式里 * 显式复位这些属性(与 Drawer 的 `section { margin }` 是同一类坑)。 * 2. 空内容插入表格要与顶部留出间距(`.tiptap` 需有纵向内边距)。 * 3. 单元格单行行高不能被 `

` 的 UA 外边距撑高。 * 4. 列宽拖拽:光标为 col-resize、hover 不改变行高、表格不被拖出容器、列宽有下限。 * * 这些都是布局结果,只能放 E2E(happy-dom 没有布局引擎)。 */ /** 插入表格(cols 为列数,默认 3 行 3 列) */ async function insertTable(page: Page, cols?: number): Promise { const editor = page.getByTestId('rich-text') await editor.scrollIntoViewIfNeeded() await editor.locator('button[aria-label="插入表格"]').click() if (cols) await page.locator('.g3-rich-text__table-input').nth(1).fill(String(cols)) await page.getByRole('button', { name: '插入', exact: true }).click() await expect(editor.locator('table')).toHaveCount(1) await editor.locator('table').scrollIntoViewIfNeeded() } /** 表格几何 / 样式快照 */ function tableSnapshot(page: Page) { return page.evaluate(() => { const root = document.querySelector('[data-testid="rich-text"]')! const content = root.querySelector('.g3-rich-text__content')! const tiptap = root.querySelector('.tiptap')! const table = root.querySelector('table')! const wrapper = table.parentElement! const headerCells = Array.from(table.querySelectorAll('tr:first-child th')) const rows = Array.from(table.querySelectorAll('tr')) const handle = table.querySelector('.column-resize-handle') return { // .tiptap 左右各 12px 内边距,减掉才是表格可用的内容宽度 containerWidth: Math.round(tiptap.getBoundingClientRect().width) - 24, tableWidth: Math.round(table.getBoundingClientRect().width), contentOverflowsX: content.scrollWidth > content.clientWidth, wrapperScrollsX: wrapper.scrollWidth > wrapper.clientWidth, columnWidths: headerCells.map((cell) => Math.round(cell.getBoundingClientRect().width)), rowHeights: rows.map((row) => Math.round(row.getBoundingClientRect().height)), cursor: getComputedStyle(tiptap).cursor, handlePosition: handle ? getComputedStyle(handle).position : null, } }) } /** 第一列表头右边缘(列分隔线)的视口坐标 */ function dividerPoint(page: Page) { return page.evaluate(() => { const cell = document.querySelector('[data-testid="rich-text"] table tr:first-child th')! const box = cell.getBoundingClientRect() return { x: Math.round(box.right) - 2, y: Math.round(box.y + box.height / 2) } }) } /** 拖动第一列右侧的分隔线 dx 像素 */ async function dragDivider(page: Page, dx: number): Promise { const from = await dividerPoint(page) await page.mouse.move(from.x, from.y) await page.mouse.down() await page.mouse.move(from.x + dx, from.y, { steps: 8 }) await page.mouse.up() await page.waitForTimeout(150) } test.describe('RichText 表格', () => { test('表格保持满宽,且仅表头行有底色', async ({ page }) => { await page.goto('/') // 等价于 VitePress `.vp-doc` 的宿主全局表格样式 await page.addStyleTag({ content: [ 'table { display: block; margin: 20px 0; overflow-x: auto; }', 'tr { border-top: 1px solid #e2e2e3; background-color: #fff; }', 'tr:nth-child(2n) { background-color: #f6f6f7; }', ].join('\n'), }) await insertTable(page) const editor = page.getByTestId('rich-text') const table = editor.locator('table') // 1) 布局:表格铺满容器(display:block 时网格会收缩到内容宽) const tableBox = (await table.boundingBox())! const wrapperBox = (await table.locator('xpath=..').boundingBox())! expect(Math.round(tableBox.width)).toBeCloseTo(Math.round(wrapperBox.width), 0) const headerCells = table.locator('tr').first().locator('th') await expect(headerCells).toHaveCount(3) const cellWidths = await headerCells.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().width), ) // 三列均分:每列约为 1/3 表宽(收缩布局下会远小于此) for (const width of cellWidths) expect(width).toBeGreaterThan(tableBox.width / 3 - 4) // 2) 背景:宿主斑马纹不得染到表体行,只有表头行(th)保留组件自身的底色 const rowBackgrounds = await table .locator('tr') .evaluateAll((rows) => rows.map((row) => getComputedStyle(row).backgroundColor)) expect(rowBackgrounds.slice(1).every((bg) => bg === 'rgba(0, 0, 0, 0)')).toBe(true) const headerBg = await headerCells .first() .evaluate((el) => getComputedStyle(el).backgroundColor) expect(headerBg).not.toBe('rgba(0, 0, 0, 0)') }) 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 insertTable(page) // 表格(无 UA 外边距的块)作为首块时,必须与内容区顶部留出间距,不能贴顶 const gap = await page.evaluate(() => { const root = document.querySelector('[data-testid="rich-text"]')! const tiptap = root.querySelector('.tiptap')! const table = root.querySelector('table')! return Math.round(table.getBoundingClientRect().top - tiptap.getBoundingClientRect().top) }) expect(gap).toBeGreaterThanOrEqual(8) }) test('单元格单行行高不被段落默认外边距撑高', async ({ page }) => { await page.goto('/') await insertTable(page) // 单元格内

的 UA 上下外边距(各 1em)曾把单行行高从 ~38px 撑到 ~66px const { rowHeights } = await tableSnapshot(page) for (const height of rowHeights) { expect(height).toBeGreaterThan(30) expect(height).toBeLessThan(50) } }) test('插入表格面板:label 与输入框上下排列且输入框不溢出', async ({ page }) => { await page.goto('/') const editor = page.getByTestId('rich-text') await editor.scrollIntoViewIfNeeded() await editor.locator('button[aria-label="插入表格"]').click() const fields = page.locator('.g3-rich-text__table-field') // 行数 / 列数 / 包含表头行 三个字段 await expect(fields).toHaveCount(3) // 只对含数字输入框的两个字段做上下排列 + 不溢出校验 const geometry = await fields.evaluateAll((els) => els.flatMap((el) => { const label = el.querySelector('.g3-rich-text__table-label')!.getBoundingClientRect() const input = el.querySelector('.g3-rich-text__table-input') if (!input) return [] const rect = input.getBoundingClientRect() return [ { labelBottom: label.bottom, inputTop: rect.top, inputWidth: rect.width, fieldWidth: el.getBoundingClientRect().width, }, ] }), ) expect(geometry).toHaveLength(2) for (const item of geometry) { // 输入框在 label 下方 expect(item.inputTop).toBeGreaterThanOrEqual(item.labelBottom) // content-box + width:100% 曾让输入框比容器宽 18px(padding + border) expect(Math.round(item.inputWidth)).toBeLessThanOrEqual(Math.round(item.fieldWidth) + 1) } }) }) test.describe('RichText 表格 · 列宽拖拽', () => { // 用窄视口,更容易触及列宽的上下限 test.use({ viewport: { width: 720, height: 900 } }) test('悬停分隔线:光标为 col-resize,且行高不跳动', async ({ page }) => { await page.goto('/') await insertTable(page) const before = await tableSnapshot(page) expect(before.handlePosition).toBeNull() const point = await dividerPoint(page) await page.mouse.move(point.x, point.y) await page.waitForTimeout(200) const hover = await tableSnapshot(page) expect(hover.cursor).toBe('col-resize') // 拖拽手柄必须脱离文档流 —— 回归:它曾作为流内块把行高从 38px 撑到 45px expect(hover.handlePosition).toBe('absolute') expect(hover.rowHeights).toEqual(before.rowHeights) // 手柄只在表头行显示 —— 回归:曾在每一行都画一段,看起来是一列断开的虚线 const handles = await page.evaluate(() => { const all = Array.from( document.querySelectorAll('[data-testid="rich-text"] table .column-resize-handle'), ) return { total: all.length, visible: all.filter((el) => getComputedStyle(el).display !== 'none').length, } }) expect(handles.total).toBeGreaterThan(1) expect(handles.visible).toBe(1) }) test('拖拽列宽精确跟手,且溢出只发生在表格容器层', async ({ page }) => { await page.goto('/') await insertTable(page) const before = await tableSnapshot(page) await dragDivider(page, 150) const after = await tableSnapshot(page) // 拖宽第一列 150px:第一列精确变宽,其余列纹丝不动 expect(after.columnWidths[0]).toBeCloseTo(before.columnWidths[0] + 150, -1) expect(after.columnWidths[1]).toBeCloseTo(before.columnWidths[1], -1) expect(after.columnWidths[2]).toBeCloseTo(before.columnWidths[2], -1) // 表格总宽超出容器时,横向滚动只发生在 .tableWrapper 层,不进编辑器内容区 expect(after.contentOverflowsX).toBe(false) expect(after.wrapperScrollsX).toBe(true) }) test('列宽被夹在最小宽度内,相邻列不会被挤没', async ({ page }) => { await page.goto('/') await insertTable(page) // 向右拖到远超容器:被拖列变宽、其余列保持原宽,不被挤到看不见 await dragDivider(page, 430) const wide = await tableSnapshot(page) expect(wide.contentOverflowsX).toBe(false) expect(wide.wrapperScrollsX).toBe(true) // 其余列纹丝不动(既不被挤没,也不被拉伸) expect(wide.columnWidths.slice(1).every((width) => width >= 9)).toBe(true) // 再向左拖到极窄:应停在最小列宽,而不是细到看不见 await dragDivider(page, -400) const narrow = await tableSnapshot(page) expect(narrow.columnWidths[0]).toBeGreaterThanOrEqual(9) }) test('拖拽后的列宽不会被随后输入的内容重排', async ({ page }) => { await page.goto('/') await insertTable(page) await dragDivider(page, 150) const dragged = await tableSnapshot(page) // 在相邻单元格塞一个超长不可断词:fixed 布局下内容不会重算列宽 await page.locator('[data-testid="rich-text"] table tr:first-child th').nth(1).click() await page.keyboard.type('A'.repeat(40)) await page.waitForTimeout(200) const after = await tableSnapshot(page) expect(after.columnWidths[0]).toBeCloseTo(dragged.columnWidths[0], -1) expect(after.contentOverflowsX).toBe(false) }) test('列宽总和超过容器时,横向滚动只发生在表格容器内,不进入编辑器内容区', async ({ page }) => { await page.goto('/') // 用较窄的视口 + 拖宽第一列,制造「总列宽 > 容器」的溢出场景 await insertTable(page) // 拖宽第一列,让表格总宽超过容器 await dragDivider(page, 500) const snapshot = await tableSnapshot(page) expect(snapshot.contentOverflowsX).toBe(false) expect(snapshot.wrapperScrollsX).toBe(true) }) }) test.describe('RichText 表格 · 操作浮层', () => { test.use({ viewport: { width: 900, height: 900 } }) async function openMenuInCell(page: Page, rowIndex: number): Promise { const editor = page.getByTestId('rich-text') await editor.locator('table tr').nth(rowIndex).locator('td, th').first().click() await expect(page.locator('.g3-rich-text-table-menu')).toBeVisible() } test('光标在表格内出现浮层,离开后隐藏', async ({ page }) => { await page.goto('/') await insertTable(page) const editor = page.getByTestId('rich-text') const menu = page.locator('.g3-rich-text-table-menu') // 初始(表格外 / 文档其它位置):不显示 await editor.locator('.tiptap').click({ position: { x: 10, y: 10 } }) await expect(menu).toBeHidden() await openMenuInCell(page, 1) // 点击编辑器内容区空白处,光标离开表格 → 浮层隐藏 await editor.locator('.tiptap').click({ position: { x: 10, y: 10 } }) await expect(menu).toBeHidden() }) test('浮层操作:上方插入行 / 左侧插入列 / 删除表格', async ({ page }) => { await page.goto('/') await insertTable(page) const editor = page.getByTestId('rich-text') const menu = page.locator('.g3-rich-text-table-menu') await openMenuInCell(page, 1) await menu.getByRole('button', { name: '上方插入行' }).click() await expect(editor.locator('table tr')).toHaveCount(4) await menu.getByRole('button', { name: '左侧插入列' }).click() await expect(editor.locator('table tr:first-child th')).toHaveCount(4) await menu.getByRole('button', { name: '删除表格' }).click() await expect(editor.locator('table')).toHaveCount(0) await expect(menu).toBeHidden() }) test('合并单元格后置灰关系:合并 / 拆分各自独立', async ({ page }) => { await page.goto('/') await insertTable(page) const editor = page.getByTestId('rich-text') const menu = page.locator('.g3-rich-text-table-menu') const merge = () => menu.getByRole('button', { name: '合并单元格' }) const split = () => menu.getByRole('button', { name: '拆分单元格' }) // 单格选区:没有相邻格可合并、自身也没合并过 → 两个按钮都不可用 await openMenuInCell(page, 1) await expect(merge()).toBeDisabled() await expect(split()).toBeDisabled() // 拖过同一行相邻两格(prosemirror-tables 在 mousemove 进另一格时建立 CellSelection) const cells = editor.locator('table tr').nth(1).locator('td') const first = (await cells.nth(0).boundingBox())! const second = (await cells.nth(1).boundingBox())! await page.mouse.move(first.x + 10, first.y + first.height / 2) await page.mouse.down() await page.mouse.move(second.x + second.width / 2, second.y + second.height / 2, { steps: 6 }) await page.mouse.up() await expect(menu).toBeVisible() await expect(merge()).toBeEnabled() await expect(split()).toBeDisabled() await merge().click() await expect(editor.locator('table tr').nth(1).locator('td[colspan="2"]')).toHaveCount(1) // 合并后选区落在跨列格里:拆分为可用、合并不可用 await expect(split()).toBeEnabled() await expect(merge()).toBeDisabled() await split().click() await expect(editor.locator('table tr').nth(1).locator('td')).toHaveCount(3) }) test('删除行 / 删除列与增删行列分组同排', async ({ page }) => { await page.goto('/') await insertTable(page) const editor = page.getByTestId('rich-text') const menu = page.locator('.g3-rich-text-table-menu') await openMenuInCell(page, 1) // 操作栏里已不再出现单元格对齐按钮(对齐统一走顶部工具条) await expect(menu.getByRole('button', { name: '单元格居中' })).toHaveCount(0) await expect(menu.getByRole('button', { name: '取消单元格对齐' })).toHaveCount(0) await expect(menu.getByRole('button', { name: '切换表头行' })).toHaveCount(0) // 删除行 / 删除列的图标是自绘 SVG(不再是拆分行列的 lucide 图标) for (const name of ['删除行', '删除列']) { const icon = menu.getByRole('button', { name }) await expect(icon).toBeVisible() await expect(icon.locator('svg')).toHaveCount(1) await expect(icon.locator('svg path')).toHaveCount(3) } await menu.getByRole('button', { name: '删除行' }).click() await expect(editor.locator('table tr')).toHaveCount(2) await menu.getByRole('button', { name: '删除列' }).click() await expect(editor.locator('table tr:first-child th')).toHaveCount(2) }) test('切换表头列 / 表头单元格', async ({ page }) => { await page.goto('/') await insertTable(page) const editor = page.getByTestId('rich-text') const menu = page.locator('.g3-rich-text-table-menu') // 表头单元格:把普通单元格切换为 th await openMenuInCell(page, 1) await menu.getByRole('button', { name: '切换表头单元格' }).click() await expect(editor.locator('table tr').nth(1).locator('th')).toHaveCount(1) // 表头列:整列切换为 th(第 2、3 行的首格都变 th;首行本就是表头行不受影响) await menu.getByRole('button', { name: '切换表头列' }).click() await expect(editor.locator('table tr').nth(1).locator('th')).toHaveCount(1) await expect(editor.locator('table tr').nth(2).locator('th')).toHaveCount(1) await expect(editor.locator('table tr').nth(1).locator('td')).toHaveCount(2) }) test('单元格内文字的对齐走顶部工具条', async ({ page }) => { await page.goto('/') await insertTable(page) const editor = page.getByTestId('rich-text') const cell = editor.locator('table tr').nth(1).locator('td').first() await openMenuInCell(page, 1) await cell.click() await editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '居中' }).click() // 单元格内容是 paragraph,对齐写在这层(不再是单元格自身的 align 属性) await expect(cell.locator('p')).toHaveAttribute('style', /text-align:\s*center/) }) test('插入面板行列数超界被夹回合法范围,且可关闭表头行', async ({ page }) => { await page.goto('/') const editor = page.getByTestId('rich-text') await editor.scrollIntoViewIfNeeded() await editor.locator('button[aria-label="插入表格"]').click() // 输入非法值(0 行 / 99 列) await page.locator('.g3-rich-text__table-input').nth(0).fill('0') await page.locator('.g3-rich-text__table-input').nth(1).fill('99') await page.getByRole('button', { name: '插入', exact: true }).click() const table = editor.locator('table') await expect(table).toHaveCount(1) // 0 → 1 行,99 → 20 列 await expect(table.locator('tr')).toHaveCount(1) await expect(table.locator('tr:first-child th')).toHaveCount(20) // 关闭表头行:插入后首行是 td 而非 th await editor.locator('button[aria-label="插入表格"]').click() await page.locator('.g3-rich-text__table-checkbox').uncheck() await page.getByRole('button', { name: '插入', exact: true }).click() await expect(editor.locator('table')).toHaveCount(2) await expect(editor.locator('table').nth(1).locator('tr:first-child th')).toHaveCount(0) }) })