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

446 lines
19 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'
/**
* 富文本表格的样式 / 交互回归。
*
* 1. 抗宿主页面全局样式:文档站(VitePress 默认主题)会在 `.vp-doc` 下写全局表格样式 ——
* `table { display: block }` 让表格退化为块级收缩布局、网格宽度不再是 100%;
* `tr:nth-child(2n) { background-color: … }` 把斑马纹染到表体行。组件必须在自身样式里
* 显式复位这些属性(与 Drawer 的 `section { margin }` 是同一类坑)。
* 2. 空内容插入表格要与顶部留出间距(`.tiptap` 需有纵向内边距)。
* 3. 单元格单行行高不能被 `<p>` 的 UA 外边距撑高。
* 4. 列宽拖拽:光标为 col-resize、hover 不改变行高、表格不被拖出容器、列宽有下限。
*
* 这些都是布局结果,只能放 E2E(happy-dom 没有布局引擎)。
*/
/** 插入表格(cols 为列数,默认 3 行 3 列) */
async function insertTable(page: Page, cols?: number): Promise<void> {
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<void> {
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)
// 单元格内 <p> 的 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<void> {
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)
})
})