u
This commit is contained in:
1 parent
32d423b759
commit
78eb811ff7
102 files changed
+12067
-209
No files matched your search
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Attachment 的几何 / 命中类断言 —— happy-dom 没有布局引擎,这些只能在真浏览器里量。
|
||||
*
|
||||
* 回归背景(都踩过):
|
||||
* 1. 工具栏勾选后浮出批量按钮,一度把整条工具栏撑高、下方内容跟着下移;
|
||||
* 2. 分隔线依赖 Splitter 的 barOnHover,默认只在 hover 时显示,
|
||||
* 表现为「分类栏右侧看不见边界」。
|
||||
*/
|
||||
test.describe('Attachment 附件管理器', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.locator('[data-testid="attachment"]').scrollIntoViewIfNeeded()
|
||||
})
|
||||
|
||||
test('工具栏高度恒定:勾选前后、宽窄面板下都是 40px', async ({ page }) => {
|
||||
// 40 = --g3-control-height-sm(24) + --g3-space-2(8) * 2
|
||||
const EXPECTED_HEIGHT = 40
|
||||
|
||||
for (const testId of ['attachment', 'attachment-narrow']) {
|
||||
const toolbar = page.locator(`[data-testid="${testId}"] .g3-attachment__toolbar`)
|
||||
const before = await toolbar.boundingBox()
|
||||
|
||||
await toolbar.locator('.g3-checkbox').first().click()
|
||||
await expect(toolbar.locator('.g3-attachment__toolbar-count')).toBeVisible()
|
||||
const after = await toolbar.boundingBox()
|
||||
|
||||
expect(before, `${testId}: 工具栏应可测量`).not.toBeNull()
|
||||
expect(after, `${testId}: 工具栏应可测量`).not.toBeNull()
|
||||
expect(before!.height, `${testId}: 未勾选时工具栏高度`).toBe(EXPECTED_HEIGHT)
|
||||
// 勾选后浮出「已选 N 项 + 下载 + 删除」,高度必须纹丝不动
|
||||
expect(after!.height, `${testId}: 勾选后工具栏高度`).toBe(EXPECTED_HEIGHT)
|
||||
|
||||
// 复位,避免影响下一个实例的断言
|
||||
await toolbar.locator('.g3-checkbox').first().click()
|
||||
await expect(toolbar.locator('.g3-attachment__toolbar-count')).toBeHidden()
|
||||
}
|
||||
})
|
||||
|
||||
test('分类栏右侧的分割线常显(不依赖 hover)', async ({ page }) => {
|
||||
const line = page
|
||||
.locator('[data-testid="attachment"] .g3-attachment__splitter .g3-splitter__bar-line')
|
||||
.first()
|
||||
|
||||
await expect(line).toBeVisible()
|
||||
|
||||
const background = await line.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
// 默认是 --g3-border-color(#d9d9d9);只要不是透明的,就说明线是画出来的
|
||||
expect(background).not.toBe('rgba(0, 0, 0, 0)')
|
||||
|
||||
const box = await line.boundingBox()
|
||||
expect(box!.width).toBeLessThanOrEqual(2)
|
||||
expect(box!.height).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 滑块验证码(L3,真浏览器)。
|
||||
*
|
||||
* 为什么必须在真实浏览器里跑:这一层的断言全是**几何**与**指针轨迹** —— 手柄最终停在哪、
|
||||
* 进度条盖住多少、拖到容器外松手会不会收尾。happy-dom 没有布局引擎,单测只能把 rect
|
||||
* 钉死后验逻辑(见 src/captcha/SliderCaptcha.test.ts)。
|
||||
*
|
||||
* 两个踩过的坑(写在这里防复发):
|
||||
* 1. playground 是一整页长文档,滑块区在首屏之下,而 `boundingBox()` 返回**视口坐标** ——
|
||||
* 不先 `scrollIntoViewIfNeeded()` 就往那儿发鼠标事件,会落到视口外,表现为「拖了没反应」;
|
||||
* 2. 拖拽终点要**越过容器边缘**几像素:行程是按 padding box 算的,贴着边缘算容易因为
|
||||
* 1px 取整「差一点不通过」,越界部分本来就会被组件夹住。
|
||||
*
|
||||
* 进 RTL 统一走 `?dir=rtl`(playground 写到 `<html dir>`),不依赖点击开关的顺序。
|
||||
*/
|
||||
|
||||
const url = (dir: 'ltr' | 'rtl') => `/?dir=${dir}`
|
||||
|
||||
async function gotoDir(page: Page, dir: 'ltr' | 'rtl') {
|
||||
await page.goto(url(dir))
|
||||
await expect(page.locator('html')).toHaveAttribute('dir', dir)
|
||||
}
|
||||
|
||||
async function isRtl(page: Page): Promise<boolean> {
|
||||
return (await page.locator('html').getAttribute('dir')) === 'rtl'
|
||||
}
|
||||
|
||||
/**
|
||||
* 用真实鼠标把滑块从起点拖向行内结束端;`releaseRatio` 传 0.5 表示拖到一半就松手。
|
||||
*/
|
||||
async function dragHandle(
|
||||
page: Page,
|
||||
root: Locator,
|
||||
options: { releaseRatio?: number } = {},
|
||||
): Promise<void> {
|
||||
await root.scrollIntoViewIfNeeded()
|
||||
const rtl = await isRtl(page)
|
||||
const handle = root.locator('.g3-slider-captcha__action')
|
||||
const handleBox = (await handle.boundingBox())!
|
||||
const rootBox = (await root.boundingBox())!
|
||||
|
||||
const y = handleBox.y + handleBox.height / 2
|
||||
const startX = handleBox.x + handleBox.width / 2
|
||||
// 越界 8px:行内结束端在 LTR 是右边缘、RTL 是左边缘
|
||||
const endX = rtl ? rootBox.x - 8 : rootBox.x + rootBox.width + 8
|
||||
const targetX = startX + (endX - startX) * (options.releaseRatio ?? 1)
|
||||
|
||||
await page.mouse.move(startX, y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(targetX, y, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
test.describe('SliderCaptcha', () => {
|
||||
test('真实拖拽到末端即通过:进度 100、文案切换、状态回传', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const root = page.locator('[data-testid="slider-captcha"]')
|
||||
const handle = root.locator('.g3-slider-captcha__action')
|
||||
|
||||
await root.scrollIntoViewIfNeeded()
|
||||
await expect(root.locator('.g3-slider-captcha__text')).toHaveText('按住滑块拖动')
|
||||
await expect(handle).toHaveAttribute('aria-valuenow', '0')
|
||||
// 未通过时才可聚焦,通过后要让出 Tab 顺序
|
||||
await expect(handle).toHaveAttribute('tabindex', '0')
|
||||
|
||||
await dragHandle(page, root)
|
||||
|
||||
await expect(root).toHaveClass(/is-passed/)
|
||||
await expect(root.locator('.g3-slider-captcha__text')).toHaveText('验证通过')
|
||||
await expect(handle).toHaveAttribute('aria-valuenow', '100')
|
||||
await expect(handle).toHaveAttribute('tabindex', '-1')
|
||||
await expect(page.locator('[data-testid="slider-captcha-state"]')).toHaveText('passed')
|
||||
|
||||
// 手柄右缘贴住轨道末端(取整误差 1px 内)
|
||||
const rootBox = (await root.boundingBox())!
|
||||
const handleBox = (await handle.boundingBox())!
|
||||
expect(handleBox.x + handleBox.width).toBeLessThanOrEqual(rootBox.x + rootBox.width + 1)
|
||||
// 进度条盖住手柄的一半
|
||||
const barBox = (await root.locator('.g3-slider-captcha__bar').boundingBox())!
|
||||
expect(barBox.width).toBeGreaterThan(rootBox.width - handleBox.width)
|
||||
})
|
||||
|
||||
test('中途松手回弹到起点,不报通过', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const root = page.locator('[data-testid="slider-captcha"]')
|
||||
const handle = root.locator('.g3-slider-captcha__action')
|
||||
|
||||
await dragHandle(page, root, { releaseRatio: 0.5 })
|
||||
|
||||
await expect(root).not.toHaveClass(/is-passed/)
|
||||
await expect(page.locator('[data-testid="slider-captcha-state"]')).toHaveText('idle')
|
||||
await expect(handle).toHaveAttribute('aria-valuenow', '0')
|
||||
// 回弹有 300ms 过渡,用 poll 等它归位到行程起点
|
||||
const rootBox = (await root.boundingBox())!
|
||||
await expect
|
||||
.poll(async () => (await handle.boundingBox())!.x, { timeout: 3000 })
|
||||
.toBeLessThanOrEqual(rootBox.x + 2)
|
||||
})
|
||||
|
||||
test('禁用态拖不动,且带 is-disabled 类', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const root = page.locator('[data-testid="slider-captcha-disabled"]')
|
||||
const handle = root.locator('.g3-slider-captcha__action')
|
||||
|
||||
await root.scrollIntoViewIfNeeded()
|
||||
await expect(root).toHaveClass(/is-disabled/)
|
||||
await expect(handle).toHaveAttribute('tabindex', '-1')
|
||||
|
||||
await dragHandle(page, root)
|
||||
await expect(root).not.toHaveClass(/is-passed/)
|
||||
})
|
||||
|
||||
test('键盘:End 直达终点并通过,Home 复位', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const root = page.locator('[data-testid="slider-captcha"]')
|
||||
const handle = root.locator('.g3-slider-captcha__action')
|
||||
|
||||
await root.scrollIntoViewIfNeeded()
|
||||
await handle.focus()
|
||||
await page.keyboard.press('End')
|
||||
await expect(root).toHaveClass(/is-passed/)
|
||||
await expect(page.locator('[data-testid="slider-captcha-state"]')).toHaveText('passed')
|
||||
})
|
||||
|
||||
test.describe('RTL', () => {
|
||||
test('手柄从右侧起步,向左拖到末端才通过', async ({ page }) => {
|
||||
await gotoDir(page, 'rtl')
|
||||
const root = page.locator('[data-testid="slider-captcha"]')
|
||||
const handle = root.locator('.g3-slider-captcha__action')
|
||||
|
||||
await root.scrollIntoViewIfNeeded()
|
||||
const rootBox = (await root.boundingBox())!
|
||||
const handleBox = (await handle.boundingBox())!
|
||||
// 起点贴行内起始边 = RTL 下的右边
|
||||
expect(handleBox.x + handleBox.width).toBeGreaterThanOrEqual(rootBox.x + rootBox.width - 2)
|
||||
|
||||
// 未通过时的方向性图标按全库规则翻面(chevrons-right 在 RTL 下镜像);
|
||||
// 必须在拖拽前断言 —— 通过后图标会换成 Check,那条镜像规则就不适用了
|
||||
const iconTransform = await handle
|
||||
.locator('svg')
|
||||
.evaluate((el) => getComputedStyle(el).transform)
|
||||
expect(iconTransform).toBe('matrix(-1, 0, 0, 1, 0, 0)')
|
||||
|
||||
await dragHandle(page, root)
|
||||
|
||||
await expect(root).toHaveClass(/is-passed/)
|
||||
// 终点贴行内结束边 = RTL 下的左边
|
||||
await expect
|
||||
.poll(async () => (await handle.boundingBox())!.x, { timeout: 3000 })
|
||||
.toBeLessThanOrEqual(rootBox.x + 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 富文本内容区的几何与聚焦回归。
|
||||
*
|
||||
* 背景:`minHeight` / `maxHeight` 作用在内容区(`.g3-rich-text__content`)上,而
|
||||
* EditorContent 会再套一层包裹 DOM,`.tiptap` 原本写的 `min-height: inherit` 继承的是
|
||||
* 那一层的 computed 值(恒为 0),于是可编辑区只跟内容一样高 —— 空文档时 200px 的内容区
|
||||
* 里只有约 45px 能点,下方全是「点上去既不聚焦也不落光标」的死区。
|
||||
*
|
||||
* 这三个用例分别锁:内容区短时「可编辑区撑满 + 点空白聚焦到末尾」、宿主样式破坏撑高时的
|
||||
* 兜底聚焦、内容长时「可编辑区按内容撑开 + 由内容区滚动(不裁切)」。
|
||||
*
|
||||
* 聚焦、caret 落位与滚动都必须真浏览器:happy-dom 没有真实聚焦、选区与布局。
|
||||
*/
|
||||
|
||||
const EDITOR = '[data-testid="rich-text"]'
|
||||
|
||||
/** 清空正文并输入一段文本(光标停在输入末尾) */
|
||||
async function resetContent(page: Page, text: string): 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)
|
||||
}
|
||||
|
||||
/** 内容区 / 可编辑区的实测高度 */
|
||||
function heights(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const content = document.querySelector('.g3-rich-text__content') as HTMLElement
|
||||
const tiptap = content.querySelector('.tiptap') as HTMLElement
|
||||
return {
|
||||
content: Math.round(content.getBoundingClientRect().height),
|
||||
tiptap: Math.round(tiptap.getBoundingClientRect().height),
|
||||
focused: tiptap.classList.contains('ProseMirror-focused'),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('RichText 聚焦', () => {
|
||||
test('可编辑区撑满内容区,点内容区空白即聚焦、光标落在正文末尾', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await resetContent(page, '第一行')
|
||||
|
||||
const editor = page.getByTestId('rich-text')
|
||||
const content = editor.locator('.g3-rich-text__content')
|
||||
const tiptap = editor.locator('.tiptap')
|
||||
|
||||
// 布局:空文档时内容区还是最小高度(200px),可编辑区必须一样高
|
||||
const before = await heights(page)
|
||||
expect(before.content).toBeGreaterThanOrEqual(200)
|
||||
expect(before.tiptap).toBe(before.content)
|
||||
|
||||
// 点内容区底部(正文下方那片空白)
|
||||
const box = (await content.boundingBox())!
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height - 10)
|
||||
|
||||
await expect(tiptap).toHaveClass(/ProseMirror-focused/)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.activeElement?.classList.contains('ProseMirror') ?? false),
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// 光标在末尾:继续输入会追加在正文后面
|
||||
await page.keyboard.type('!')
|
||||
await expect(editor.locator('.tiptap')).toContainText('第一行!')
|
||||
})
|
||||
|
||||
test('宿主样式破坏撑高时,点编辑器 DOM 之外的空白仍能聚焦到末尾', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await resetContent(page, '第二行')
|
||||
|
||||
// 模拟宿主样式把「可编辑区撑满内容区」改掉:包裹层收回到内容高度
|
||||
await page.addStyleTag({ content: '.g3-rich-text__content > div { flex: 0 0 auto !important; }' })
|
||||
|
||||
const editor = page.getByTestId('rich-text')
|
||||
const content = editor.locator('.g3-rich-text__content')
|
||||
const gap = await heights(page)
|
||||
expect(gap.tiptap).toBeLessThan(gap.content)
|
||||
|
||||
const box = (await content.boundingBox())!
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height - 10)
|
||||
|
||||
await expect(editor.locator('.tiptap')).toHaveClass(/ProseMirror-focused/)
|
||||
await page.keyboard.type('!!')
|
||||
await expect(editor.locator('.tiptap')).toContainText('第二行!!')
|
||||
})
|
||||
|
||||
test('编辑器内部的点击仍按坐标落光标(不被空白聚焦抢走)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await resetContent(page, '第一行')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.type('第二行')
|
||||
|
||||
const editor = page.getByTestId('rich-text')
|
||||
const second = editor.locator('.tiptap p').nth(1)
|
||||
const box = (await second.boundingBox())!
|
||||
// 点第二行最左侧 —— 光标应落在该行句首,而不是被挪到全文末尾
|
||||
await page.mouse.click(box.x + 8, box.y + box.height / 2)
|
||||
|
||||
const caret = await page.evaluate(() => {
|
||||
const selection = window.getSelection()
|
||||
const node = selection?.anchorNode
|
||||
const element = node?.nodeType === 3 ? node.parentElement : (node as HTMLElement | null)
|
||||
return { text: element?.closest('p')?.textContent ?? '', offset: selection?.anchorOffset ?? -1 }
|
||||
})
|
||||
expect(caret.text).toBe('第二行')
|
||||
expect(caret.offset).toBeLessThan(2)
|
||||
})
|
||||
|
||||
test('聚焦时正文区四周亮起 2px 主色描边(工具条不参与)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const editor = page.getByTestId('rich-text')
|
||||
const content = editor.locator('.g3-rich-text__content')
|
||||
const ring = () => content.evaluate((el) => getComputedStyle(el).boxShadow)
|
||||
|
||||
// 未聚焦:正文区没有描边
|
||||
await expect.poll(ring).toBe('none')
|
||||
|
||||
// 点正文 → 正文区四周亮起
|
||||
await editor.locator('.tiptap').click()
|
||||
await expect.poll(ring).toContain('inset')
|
||||
await expect.poll(ring).toContain('rgb(24, 24, 27)')
|
||||
|
||||
// 描边必须挂在滚动容器自己身上(背景/阴影不随内容滚动);挂在伪元素覆盖层上会跟着正文滚走
|
||||
const owners = await page.evaluate(() => {
|
||||
const el = document.querySelector('.g3-rich-text__content') as HTMLElement
|
||||
return { own: getComputedStyle(el).boxShadow, pseudo: getComputedStyle(el, '::after').boxShadow }
|
||||
})
|
||||
expect(owners.own).toContain('inset')
|
||||
expect(owners.pseudo).toBe('none')
|
||||
|
||||
// 点工具条按钮:焦点临时落在按钮上,编辑器整体仍是聚焦态 → 描边不熄
|
||||
await editor.locator('button[aria-label="加粗"]').click()
|
||||
await expect.poll(ring).toContain('inset')
|
||||
|
||||
// 点到编辑器之外 → 熄灭(用 > 限定,编辑器正文里也有 h2)
|
||||
await page.locator('#s-richtext > h2').click()
|
||||
await expect.poll(ring).toBe('none')
|
||||
})
|
||||
|
||||
test('正文超出内容区时按内容撑开、由内容区滚动(正文不被裁切)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
// 宿主给内容区一个 max-height(等价于 maxHeight prop)
|
||||
await page.addStyleTag({ content: '.g3-rich-text__content { max-height: 220px !important; }' })
|
||||
await resetContent(page, '第 1 行')
|
||||
for (let i = 1; i < 20; i++) {
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.type(`第 ${i + 1} 行`)
|
||||
}
|
||||
|
||||
const measure = () =>
|
||||
page.evaluate(() => {
|
||||
const content = document.querySelector('.g3-rich-text__content') as HTMLElement
|
||||
const tiptap = content.querySelector('.tiptap') as HTMLElement
|
||||
const last = tiptap.lastElementChild as HTMLElement
|
||||
const viewportBottom = content.getBoundingClientRect().bottom
|
||||
return {
|
||||
client: content.clientHeight,
|
||||
scroll: content.scrollHeight,
|
||||
tiptap: Math.round(tiptap.getBoundingClientRect().height),
|
||||
lastText: last.textContent ?? '',
|
||||
lastVisible: last.getBoundingClientRect().bottom <= viewportBottom + 1,
|
||||
}
|
||||
})
|
||||
|
||||
// 停在顶部:可编辑区必须按内容撑开(大于内容区),而不是被压到视口高度裁掉正文
|
||||
await page.evaluate(() => {
|
||||
;(document.querySelector('.g3-rich-text__content') as HTMLElement).scrollTop = 0
|
||||
})
|
||||
const top = await measure()
|
||||
expect(top.tiptap).toBeGreaterThan(top.client)
|
||||
expect(top.scroll).toBeGreaterThan(top.client)
|
||||
expect(top.lastText).toBe('第 20 行')
|
||||
expect(top.lastVisible).toBe(false)
|
||||
|
||||
// 滚到底:最后一行完整可见
|
||||
await page.evaluate(() => {
|
||||
const content = document.querySelector('.g3-rich-text__content') as HTMLElement
|
||||
content.scrollTop = content.scrollHeight
|
||||
})
|
||||
const bottom = await measure()
|
||||
expect(bottom.lastVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,445 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user