u
This commit is contained in:
1 parent
78eb811ff7
commit
97fe2ff306
83 files changed
+10083
-3662
No files matched your search
@@ -1,56 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -14,13 +14,20 @@ import { expect, test, type Page } from '@playwright/test'
|
||||
* 这些都是布局结果,只能放 E2E(happy-dom 没有布局引擎)。
|
||||
*/
|
||||
|
||||
/** 插入表格(cols 为列数,默认 3 行 3 列) */
|
||||
async function insertTable(page: Page, cols?: number): Promise<void> {
|
||||
/**
|
||||
* 插入表格。
|
||||
*
|
||||
* 面板已从「行数 / 列数」两个数字输入框换成**网格选择器**(对齐 TinyMCE):
|
||||
* 移入 (rows, cols) 那个格子即高亮对应矩形,点击插入。所以这里改成点格子,
|
||||
* 不再有「插入」按钮需要按。
|
||||
*/
|
||||
async function insertTable(page: Page, cols = 3, rows = 3): 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()
|
||||
const cell = page.locator(`.g3-rich-text__table-grid-cell[data-row="${rows}"][data-col="${cols}"]`)
|
||||
await cell.hover()
|
||||
await cell.click()
|
||||
await expect(editor.locator('table')).toHaveCount(1)
|
||||
await editor.locator('table').scrollIntoViewIfNeeded()
|
||||
}
|
||||
@@ -145,41 +152,64 @@ test.describe('RichText 表格', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('插入表格面板:label 与输入框上下排列且输入框不溢出', async ({ page }) => {
|
||||
test('插入表格面板:网格选择器高亮矩形并实时显示尺寸', 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)
|
||||
// 8 行 × 10 列的网格
|
||||
const cells = page.locator('.g3-rich-text__table-grid-cell')
|
||||
await expect(cells).toHaveCount(80)
|
||||
|
||||
// 只对含数字输入框的两个字段做上下排列 + 不溢出校验
|
||||
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,
|
||||
},
|
||||
]
|
||||
}),
|
||||
// 尺寸提示:未悬停时显示默认 3 × 3
|
||||
const label = page.locator('.g3-rich-text__table-grid-label')
|
||||
await expect(label).toHaveText('3 × 3')
|
||||
|
||||
// 悬停 4 行 6 列 → 高亮 24 格,提示同步
|
||||
await page.locator('.g3-rich-text__table-grid-cell[data-row="4"][data-col="6"]').hover()
|
||||
await expect(label).toHaveText('4 × 6')
|
||||
await expect(page.locator('.g3-rich-text__table-grid-cell.is-table-grid-cell-active')).toHaveCount(
|
||||
24,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
// 悬停更小的区域 → 高亮随之收缩(不是只增不减)
|
||||
await page.locator('.g3-rich-text__table-grid-cell[data-row="2"][data-col="2"]').hover()
|
||||
await expect(label).toHaveText('2 × 2')
|
||||
await expect(page.locator('.g3-rich-text__table-grid-cell.is-table-grid-cell-active')).toHaveCount(
|
||||
4,
|
||||
)
|
||||
})
|
||||
|
||||
test('插入表格面板:不溢出容器,含表头行勾选可用', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const editor = page.getByTestId('rich-text')
|
||||
await editor.scrollIntoViewIfNeeded()
|
||||
await editor.locator('button[aria-label="插入表格"]').click()
|
||||
|
||||
const form = page.locator('.g3-rich-text__table-form')
|
||||
const grid = page.locator('.g3-rich-text__table-grid')
|
||||
const formBox = (await form.boundingBox())!
|
||||
const gridBox = (await grid.boundingBox())!
|
||||
|
||||
// 网格必须落在面板内(早期手写样式曾让内容比容器宽 18px)
|
||||
expect(gridBox.width).toBeLessThanOrEqual(formBox.width + 1)
|
||||
expect(gridBox.x).toBeGreaterThanOrEqual(formBox.x - 1)
|
||||
|
||||
// 「包含表头行」默认选中;取消后插入的表首行应为 td 而非 th。
|
||||
// 注意:点击**包裹用的 label**,不能对内部 input 调 uncheck() ——
|
||||
// G3Checkbox 的原生 input 是 1px + opacity:0 + pointer-events:none(视觉由 span 承担),
|
||||
// Playwright 认为它不可见 / 不可交互,uncheck() 会直接超时。
|
||||
const toggleInput = page.locator('.g3-rich-text__table-checkbox input')
|
||||
const toggleLabel = page.locator('.g3-rich-text__table-header-toggle')
|
||||
await expect(toggleInput).toBeChecked()
|
||||
await toggleLabel.click()
|
||||
await expect(toggleInput).not.toBeChecked()
|
||||
|
||||
await page.locator('.g3-rich-text__table-grid-cell[data-row="2"][data-col="2"]').click()
|
||||
await expect(editor.locator('table tr')).toHaveCount(2)
|
||||
await expect(editor.locator('table')).toHaveCount(1)
|
||||
await expect(editor.locator('table tr:first-child th')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -371,7 +401,10 @@ test.describe('RichText 表格 · 操作浮层', () => {
|
||||
// 操作栏里已不再出现单元格对齐按钮(对齐统一走顶部工具条)
|
||||
await expect(menu.getByRole('button', { name: '单元格居中' })).toHaveCount(0)
|
||||
await expect(menu.getByRole('button', { name: '取消单元格对齐' })).toHaveCount(0)
|
||||
// 「表头」整组已移除:切换表头列 / 切换表头单元格都不再提供
|
||||
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 ['删除行', '删除列']) {
|
||||
@@ -387,23 +420,6 @@ test.describe('RichText 表格 · 操作浮层', () => {
|
||||
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)
|
||||
@@ -418,28 +434,29 @@ test.describe('RichText 表格 · 操作浮层', () => {
|
||||
await expect(cell.locator('p')).toHaveAttribute('style', /text-align:\s*center/)
|
||||
})
|
||||
|
||||
test('插入面板行列数超界被夹回合法范围,且可关闭表头行', async ({ page }) => {
|
||||
/**
|
||||
* 「超界被夹回」这条用例已随数字输入框一起移除:网格选择器的可选范围由网格本身
|
||||
* 硬性限定(8 行 × 10 列),用户在结构上就填不出 0 行或 99 列 —— 也就没有「夹回」可言。
|
||||
* 这里改为断言这个上限本身,防止有人无意中把网格尺寸改大或改小。
|
||||
*/
|
||||
test('网格选择器的上限为 8 行 × 10 列,且只能选到边界内的格子', 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 cells = page.locator('.g3-rich-text__table-grid-cell')
|
||||
await expect(cells).toHaveCount(80)
|
||||
// 边界格子存在,越界的不存在
|
||||
await expect(page.locator('.g3-rich-text__table-grid-cell[data-row="8"][data-col="10"]')).toHaveCount(1)
|
||||
await expect(page.locator('.g3-rich-text__table-grid-cell[data-row="9"]')).toHaveCount(0)
|
||||
await expect(page.locator('.g3-rich-text__table-grid-cell[data-col="11"]')).toHaveCount(0)
|
||||
|
||||
// 选边界值:8 行 10 列能正常插入
|
||||
await page.locator('.g3-rich-text__table-grid-cell[data-row="8"][data-col="10"]').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)
|
||||
await expect(table.locator('tr')).toHaveCount(8)
|
||||
await expect(table.locator('tr:first-child th')).toHaveCount(10)
|
||||
})
|
||||
})
|
||||
@@ -177,13 +177,21 @@ test.describe('RichText 工具条 · 更多菜单', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** 工具条当前占几行(同一行的元素共享垂直中心) */
|
||||
/**
|
||||
* 工具条当前占几行(同一行的元素共享垂直中心)。
|
||||
*
|
||||
* ⚠️ 不能遍历 `toolbar.children` —— 分组容器用了 `display: contents`,
|
||||
* 它们的盒子不参与布局、`getBoundingClientRect()` 恒为 0,按 children 数会永远得到 1 行
|
||||
* (哪怕实际折成了三行)。必须量**真正参与布局的按钮**。
|
||||
*/
|
||||
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 buttons = toolbar.querySelectorAll('button')
|
||||
for (const el of Array.from(buttons) as HTMLElement[]) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height === 0) continue
|
||||
centers.add(Math.round(rect.top + rect.height / 2))
|
||||
}
|
||||
return centers.size
|
||||
@@ -191,23 +199,64 @@ function toolbarRows(page: Page): Promise<number> {
|
||||
}
|
||||
|
||||
test.describe('RichText 工具条 · 外观', () => {
|
||||
test('窄到 780px 仍保持一行(低频收进「更多」的预算)', async ({ page }) => {
|
||||
test('宽容器下保持一行(低频收进「更多」的预算)', 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'
|
||||
;(document.querySelector('[data-testid="rich-text"]') as HTMLElement).style.width = '1000px'
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => toolbarRows(page), {
|
||||
message: '工具条自然宽度约 744px(32px 按钮 + 左右 10px 内边距);新增按钮前先想清楚该不该挪进「更多」',
|
||||
message:
|
||||
'工具条在 1000px 宽下应当是一行;窄了就折行(折行以按钮为单位,见 .g3-rich-text__group 的 display: contents)',
|
||||
})
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
test('窄容器下折行时按按钮换行,不整组跳行', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const editor = page.getByTestId('rich-text')
|
||||
await editor.scrollIntoViewIfNeeded()
|
||||
await editor.locator('.tiptap').click()
|
||||
|
||||
// 620px:一行放不下所有按钮,必然折行
|
||||
await page.evaluate(() => {
|
||||
;(document.querySelector('[data-testid="rich-text"]') as HTMLElement).style.width = '620px'
|
||||
})
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const toolbar = document.querySelector('.g3-rich-text__toolbar') as HTMLElement
|
||||
const barRect = toolbar.getBoundingClientRect()
|
||||
const rows = new Map<number, { right: number; count: number }>()
|
||||
for (const el of Array.from(toolbar.querySelectorAll('button')) as HTMLElement[]) {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.height === 0) continue
|
||||
const top = Math.round(r.top)
|
||||
const entry = rows.get(top) ?? { right: 0, count: 0 }
|
||||
entry.right = Math.max(entry.right, Math.round(r.right))
|
||||
entry.count += 1
|
||||
rows.set(top, entry)
|
||||
}
|
||||
const sorted = [...rows.entries()].sort((a, b) => a[0] - b[0])
|
||||
return {
|
||||
rowCount: sorted.length,
|
||||
// 每行最右侧按钮距容器内容区右边缘还剩多少(越小说明填得越满)
|
||||
slack: sorted.map(([, v]) => Math.round(barRect.right - 10 - v.right)),
|
||||
firstRowCount: sorted[0]?.[1].count ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
// 折行了,但不是「整组跳行」导致第一行只放几个
|
||||
expect(info.rowCount).toBeGreaterThan(1)
|
||||
expect(info.firstRowCount).toBeGreaterThan(8)
|
||||
// 第一行应当填得比较满:剩余空隙小于一个按钮宽(不出现「右边空一大片」)
|
||||
expect(info.slack[0]).toBeLessThan(36)
|
||||
})
|
||||
|
||||
test('工具条带下投影,且抬在正文之上(滚动时投影不被正文盖住)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const editor = page.getByTestId('rich-text')
|
||||
@@ -231,3 +280,428 @@ test.describe('RichText 工具条 · 外观', () => {
|
||||
expect(Number(style.zIndex)).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('RichText 工具条 · 键盘操作', () => {
|
||||
/** 当前获得焦点的元素(用 aria-label 标识,便于断言「焦点在第几个按钮上」) */
|
||||
function focusedLabel(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const el = document.activeElement as HTMLElement | null
|
||||
if (!el || el.tagName !== 'BUTTON') return null
|
||||
return el.getAttribute('aria-label')
|
||||
})
|
||||
}
|
||||
|
||||
/** 工具条里 tabindex=0 的按钮 aria-label 列表(roving tabindex 应当只有一个) */
|
||||
function tabbableInToolbar(page: Page): Promise<(string | null)[]> {
|
||||
return page.evaluate(() =>
|
||||
Array.from(
|
||||
document.querySelectorAll('.g3-rich-text__toolbar button:not(:disabled)'),
|
||||
)
|
||||
.filter((el) => (el as HTMLElement).tabIndex === 0)
|
||||
.map((el) => el.getAttribute('aria-label')),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具条里**可聚焦**按钮的 aria-label,按 DOM 顺序。
|
||||
*
|
||||
* 必须排除 disabled:初始状态下「撤销」「重做」是禁用的(历史为空),
|
||||
* 它们不可聚焦、也不该出现在方向键序列里。断言用真实 DOM 顺序而不是写死
|
||||
* 按钮名,避免以后调整工具条顺序时测试变成假失败。
|
||||
*/
|
||||
function focusableLabels(page: Page): Promise<(string | null)[]> {
|
||||
return page.evaluate(() =>
|
||||
Array.from(
|
||||
document.querySelectorAll('.g3-rich-text__toolbar button:not(:disabled)'),
|
||||
).map((el) => el.getAttribute('aria-label')),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 段落格式触发器(「正文 / 标题 N」)。
|
||||
*
|
||||
* 用 class 定位而不是 role+name:它的可访问名是**当前块格式**、随光标变化
|
||||
* (playground 初始内容含 `<h2>`,名字就是「标题 2」),拿它当固定名字必然不稳。
|
||||
*/
|
||||
function headingTrigger(page: Page) {
|
||||
return page.locator(`${EDITOR} .g3-rich-text__toolbar .g3-rich-text__trigger`)
|
||||
}
|
||||
|
||||
test('工具条是 role=toolbar 的单个 Tab 停靠点', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const toolbar = page.locator(`${EDITOR} .g3-rich-text__toolbar`)
|
||||
await expect(toolbar).toHaveAttribute('role', 'toolbar')
|
||||
await expect(toolbar).toHaveAttribute('aria-orientation', 'horizontal')
|
||||
|
||||
// 无论有多少按钮,tabindex=0 的只有一个 —— 否则每个按钮都占一个 Tab 位
|
||||
const tabbable = await tabbableInToolbar(page)
|
||||
expect(tabbable).toHaveLength(1)
|
||||
const buttonCount = await page
|
||||
.locator(`${EDITOR} .g3-rich-text__toolbar button:not(:disabled)`)
|
||||
.count()
|
||||
// 14 个图标按钮 + 1 个段落格式触发器(撤销 / 重做初始禁用,不计入)
|
||||
expect(buttonCount).toBeGreaterThanOrEqual(15)
|
||||
})
|
||||
|
||||
test('方向键在按钮间漫游,Home / End 跳首尾', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const labels = await focusableLabels(page)
|
||||
// 初始态「撤销 / 重做」禁用,序列从段落格式触发器开始,其后是「加粗」
|
||||
expect(labels[0]).toBe('段落格式')
|
||||
expect(labels[1]).toBe('加粗')
|
||||
|
||||
await headingTrigger(page).focus()
|
||||
await expect.poll(() => focusedLabel(page)).toBe('段落格式')
|
||||
|
||||
// 右键 → 下一个
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await expect.poll(() => focusedLabel(page)).toBe('加粗')
|
||||
|
||||
// 左键回退
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await expect.poll(() => focusedLabel(page)).toBe('段落格式')
|
||||
|
||||
// 首个再往左 → 两端循环到末位(「更多」)
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await expect.poll(() => focusedLabel(page)).toBe(labels[labels.length - 1])
|
||||
|
||||
// End 跳末位 / Home 跳首位
|
||||
await page.keyboard.press('End')
|
||||
await expect.poll(() => focusedLabel(page)).toBe(labels[labels.length - 1])
|
||||
await page.keyboard.press('Home')
|
||||
await expect.poll(() => focusedLabel(page)).toBe(labels[0])
|
||||
})
|
||||
|
||||
test('禁用按钮(撤销 / 重做)不会被方向键走到', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
// 初始历史为空 → 撤销 / 重做禁用
|
||||
const undo = page.locator(`${EDITOR} .g3-rich-text__toolbar button[aria-label="撤销"]`)
|
||||
await expect(undo).toBeDisabled()
|
||||
|
||||
const labels = await focusableLabels(page)
|
||||
expect(labels).not.toContain('撤销')
|
||||
expect(labels).not.toContain('重做')
|
||||
})
|
||||
|
||||
test('方向键漫游时不会把光标带进正文(preventDefault 生效)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// 先制造可撤销的历史,让「撤销」进入可聚焦序列(更接近真实使用)
|
||||
const editor = page.getByTestId('rich-text')
|
||||
await editor.locator('.tiptap').click()
|
||||
await page.keyboard.type('键盘测试')
|
||||
const undo = editor.locator('.g3-rich-text__toolbar button[aria-label="撤销"]')
|
||||
await expect(undo).toBeEnabled()
|
||||
|
||||
await undo.focus()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.press('Home')
|
||||
// Home / End 若没被 preventDefault,浏览器会把焦点 / 光标带出工具条
|
||||
await expect.poll(() => focusedLabel(page)).toBe('撤销')
|
||||
await expect(editor.locator('.tiptap')).not.toHaveClass(/ProseMirror-focused/)
|
||||
})
|
||||
|
||||
test('焦点移动后 tabindex 跟着走(roving 归一化)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await headingTrigger(page).focus()
|
||||
await page.keyboard.press('ArrowRight')
|
||||
|
||||
expect(await focusedLabel(page)).toBe('加粗')
|
||||
// 任意时刻恰有一个 tabindex=0,且它就是当前焦点所在按钮
|
||||
expect(await tabbableInToolbar(page)).toEqual(['加粗'])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('RichText 工具条 · 链接', () => {
|
||||
/** 地址输入框(面板里两个 input,靠 aria-label 区分) */
|
||||
function urlInput(page: Page) {
|
||||
return page.getByLabel('链接地址')
|
||||
}
|
||||
|
||||
/** 显示文字输入框 */
|
||||
function textInput(page: Page) {
|
||||
return page.getByLabel('显示文字')
|
||||
}
|
||||
|
||||
/** 在正文里输入文本并全选,然后打开链接面板 */
|
||||
async function openLinkPanel(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')
|
||||
await editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '链接' }).click()
|
||||
await expect(page.locator('.g3-rich-text__link-form')).toBeVisible()
|
||||
}
|
||||
|
||||
test('输入地址后回车写入选区,且不弹原生 dialog', async ({ page }) => {
|
||||
// 回归:旧实现用 window.prompt,这里断言「没有任何原生对话框被触发」
|
||||
let dialogSeen = false
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogSeen = true
|
||||
await dialog.dismiss()
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page)
|
||||
|
||||
await urlInput(page).fill('https://example.com/a')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const link = page.locator(`${EDITOR} .tiptap a`)
|
||||
await expect(link).toHaveCount(1)
|
||||
await expect(link).toHaveAttribute('href', 'https://example.com/a')
|
||||
expect(dialogSeen, '不应再触发 window.prompt 原生弹窗').toBe(false)
|
||||
// 应用后面板收起
|
||||
await expect(page.locator('.g3-rich-text__link-form')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('面板有「链接地址」与「显示文字」两个字段,且无 placeholder', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page)
|
||||
|
||||
await expect(page.locator('.g3-rich-text__link-form')).toBeVisible()
|
||||
await expect(urlInput(page)).toBeVisible()
|
||||
await expect(textInput(page)).toBeVisible()
|
||||
|
||||
// 刻意不留 placeholder:字段名已说明填什么,示例文案属于噪音
|
||||
await expect(urlInput(page)).not.toHaveAttribute('placeholder', /.*/)
|
||||
await expect(textInput(page)).not.toHaveAttribute('placeholder', /.*/)
|
||||
|
||||
// 有选区时「显示文字」预填选中的文字
|
||||
await expect(textInput(page)).toHaveValue('点我')
|
||||
})
|
||||
|
||||
test('显示文字:替换选中文字', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page, '点我')
|
||||
|
||||
await urlInput(page).fill('https://example.com/a')
|
||||
await textInput(page).fill('查看详情')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const link = page.locator(`${EDITOR} .tiptap a`)
|
||||
await expect(link).toHaveCount(1)
|
||||
await expect(link).toHaveText('查看详情')
|
||||
await expect(link).toHaveAttribute('href', 'https://example.com/a')
|
||||
})
|
||||
|
||||
test('「取消」关闭面板且不写入文档', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page, '点我')
|
||||
|
||||
await urlInput(page).fill('https://example.com/never')
|
||||
await page.locator('.g3-rich-text__link-form').getByRole('button', { name: '取消' }).click()
|
||||
|
||||
// 面板收起、正文没有产生链接
|
||||
await expect(page.locator('.g3-rich-text__link-form')).toHaveCount(0)
|
||||
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveCount(0)
|
||||
await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('点我')
|
||||
})
|
||||
|
||||
test('「取消 / 确定」靠右成组,主操作位置稳定', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page, '点我')
|
||||
|
||||
const form = page.locator('.g3-rich-text__link-form')
|
||||
const cancel = form.getByRole('button', { name: '取消' })
|
||||
const confirm = form.getByRole('button', { name: '确定' })
|
||||
|
||||
const cancelBox = (await cancel.boundingBox())!
|
||||
const confirmBox = (await confirm.boundingBox())!
|
||||
const formBox = (await form.boundingBox())!
|
||||
|
||||
// 确定在取消右侧
|
||||
expect(confirmBox.x).toBeGreaterThan(cancelBox.x)
|
||||
// 两者底边对齐(同一行)
|
||||
expect(Math.abs(cancelBox.y - confirmBox.y)).toBeLessThan(2)
|
||||
// 确定右边缘贴近面板右侧内边距(靠右而不是居中/通栏)
|
||||
const formRight = formBox.x + formBox.width
|
||||
expect(formRight - (confirmBox.x + confirmBox.width)).toBeLessThan(20)
|
||||
})
|
||||
|
||||
test('操作按钮用默认尺寸而非 small(高度对齐 G3Input)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page, '点我')
|
||||
|
||||
const form = page.locator('.g3-rich-text__link-form')
|
||||
const confirm = (await form.getByRole('button', { name: '确定' }).boundingBox())!
|
||||
// 量 G3Input 的**根包裹层**(32px 的那个盒子),不是它内部的裸 <input>(21px 行盒)——
|
||||
// getByLabel 解析到的是内部 input,直接量它会得出「按钮比输入框高」的错误结论
|
||||
const inputBox = (await form.locator('.g3-input').first().boundingBox())!
|
||||
|
||||
// 默认尺寸 = --g3-control-height = 32px;small 是 24px
|
||||
expect(confirm.height).toBeGreaterThanOrEqual(30)
|
||||
// 与输入框等高,同一行里视觉齐平
|
||||
expect(Math.abs(confirm.height - inputBox.height)).toBeLessThan(3)
|
||||
})
|
||||
|
||||
test('编辑态:移除链接在左,取消 / 确定在右', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page, '点我')
|
||||
await urlInput(page).fill('https://example.com/x')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await page.locator(`${EDITOR} .tiptap a`).click()
|
||||
await page
|
||||
.locator(`${EDITOR} .g3-rich-text__toolbar`)
|
||||
.getByRole('button', { name: '链接' })
|
||||
.click()
|
||||
|
||||
const form = page.locator('.g3-rich-text__link-form')
|
||||
const remove = (await form.getByRole('button', { name: '移除链接' }).boundingBox())!
|
||||
const cancel = (await form.getByRole('button', { name: '取消' }).boundingBox())!
|
||||
|
||||
// 移除链接在最左,取消在它右侧(危险操作与主操作分开)
|
||||
expect(remove.x).toBeLessThan(cancel.x)
|
||||
})
|
||||
|
||||
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 editor.locator('.g3-rich-text__toolbar').getByRole('button', { name: '链接' }).click()
|
||||
await expect(page.locator('.g3-rich-text__link-form')).toBeVisible()
|
||||
|
||||
await urlInput(page).fill('https://example.com/b')
|
||||
// 「显示文字」不是必填(只填地址即可提交);这里我们主动填上文字
|
||||
await textInput(page).fill('官网')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// 落下的文字本身带链接,href 正确、文字正确
|
||||
const link = page.locator(`${EDITOR} .tiptap a`)
|
||||
await expect(link).toHaveCount(1)
|
||||
await expect(link).toHaveText('官网')
|
||||
await expect(link).toHaveAttribute('href', 'https://example.com/b')
|
||||
})
|
||||
|
||||
test('显示文字留空时沿用原选区文字', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page, '保留我')
|
||||
|
||||
await urlInput(page).fill('https://example.com/c')
|
||||
await textInput(page).fill('')
|
||||
// 有原选区文字 → 仍可提交
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const link = page.locator(`${EDITOR} .tiptap a`)
|
||||
await expect(link).toHaveText('保留我')
|
||||
await expect(link).toHaveAttribute('href', 'https://example.com/c')
|
||||
})
|
||||
|
||||
test('不带协议时自动补 https://', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page)
|
||||
|
||||
await urlInput(page).fill('example.com/docs')
|
||||
await page.locator('.g3-rich-text__link-form').getByRole('button', { name: '确定' }).click()
|
||||
|
||||
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com/docs',
|
||||
)
|
||||
})
|
||||
|
||||
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 editor
|
||||
.locator('.g3-rich-text__toolbar')
|
||||
.getByRole('button', { name: '链接' })
|
||||
.click()
|
||||
|
||||
// 「显示文字」不是必填(产品要求)—— 填了地址就应当能提交
|
||||
await urlInput(page).fill('https://example.com')
|
||||
await expect(
|
||||
page.locator('.g3-rich-text__link-form').getByRole('button', { name: '确定' }),
|
||||
).toBeEnabled()
|
||||
|
||||
// 兜底:没有任何文字可挂时,用地址本身当链接文字
|
||||
await page.keyboard.press('Enter')
|
||||
const link = page.locator(`${EDITOR} .tiptap a`)
|
||||
await expect(link).toHaveCount(1)
|
||||
await expect(link).toHaveText('https://example.com')
|
||||
})
|
||||
|
||||
test('只填地址也能提交(显示文字留空)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page, '原文')
|
||||
// 主动清掉预填的显示文字
|
||||
await textInput(page).fill('')
|
||||
await urlInput(page).fill('https://example.com/only-url')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const link = page.locator(`${EDITOR} .tiptap a`)
|
||||
await expect(link).toHaveAttribute('href', 'https://example.com/only-url')
|
||||
// 有选中文字、显示文字留空 → 沿用原文字
|
||||
await expect(link).toHaveText('原文')
|
||||
})
|
||||
|
||||
test('光标在已有链接上时,面板可编辑并可移除链接', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page)
|
||||
await urlInput(page).fill('https://example.com/first')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveCount(1)
|
||||
|
||||
// 把光标放回链接文字里,再打开面板 → 地址被预填、可编辑
|
||||
await page.locator(`${EDITOR} .tiptap a`).click()
|
||||
await page
|
||||
.locator(`${EDITOR} .g3-rich-text__toolbar`)
|
||||
.getByRole('button', { name: '链接' })
|
||||
.click()
|
||||
await expect(urlInput(page)).toHaveValue('https://example.com/first')
|
||||
// 编辑态能看到「移除链接」
|
||||
await expect(
|
||||
page.locator('.g3-rich-text__link-form').getByRole('button', { name: '移除链接' }),
|
||||
).toBeVisible()
|
||||
|
||||
// 改地址
|
||||
await urlInput(page).fill('https://example.com/second')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com/second',
|
||||
)
|
||||
|
||||
// 再打开 → 移除链接
|
||||
await page.locator(`${EDITOR} .tiptap a`).click()
|
||||
await page
|
||||
.locator(`${EDITOR} .g3-rich-text__toolbar`)
|
||||
.getByRole('button', { name: '链接' })
|
||||
.click()
|
||||
await page
|
||||
.locator('.g3-rich-text__link-form')
|
||||
.getByRole('button', { name: '移除链接' })
|
||||
.click()
|
||||
await expect(page.locator(`${EDITOR} .tiptap a`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('javascript: 之类的可执行协议不会被写入 href', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await openLinkPanel(page)
|
||||
|
||||
await urlInput(page).fill('javascript:alert(1)')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// normalizeUrl 只放行 http/https/mailto/tel/#// 开头的地址,其余原样保留 → 不写链接
|
||||
const href = await page
|
||||
.locator(`${EDITOR} .tiptap a`)
|
||||
.evaluateAll((els) => els.map((el) => el.getAttribute('href')))
|
||||
expect(href.every((value) => !value?.toLowerCase().startsWith('javascript:'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 富文本新增能力的回归:
|
||||
* 1. 列表样式(无序 / 有序的多档位);
|
||||
* 2. 「整理格式」清理空段落 / 空白文本 / 空 mark;
|
||||
* 3. 预览(模态框只读渲染);
|
||||
* 4. 打印(隐藏 iframe,只打正文);
|
||||
* 5. 源代码(模态框编辑 HTML + 保存写回)。
|
||||
*
|
||||
* 这些都依赖真实的 contenteditable + 选区 / 真实 DOM(打印要 iframe、模态框要 Teleport),
|
||||
* happy-dom 里跑不了,所以放在 E2E。
|
||||
*/
|
||||
|
||||
const EDITOR = '[data-testid="rich-text"]'
|
||||
|
||||
/** 清空编辑器后输入一段文本(不选中,光标落在段落末尾) */
|
||||
async function typeText(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)
|
||||
}
|
||||
|
||||
/** 打开「更多」菜单 */
|
||||
async function openMore(page: Page): Promise<void> {
|
||||
await page
|
||||
.locator(`${EDITOR} .g3-rich-text__toolbar`)
|
||||
.getByRole('button', { name: '更多' })
|
||||
.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 点开某个列表按钮的样式面板(面板 Teleport 到 body)。
|
||||
*
|
||||
* 分裂按钮(G3ButtonGroup 组合):点**图标**一键建/撤列表,点**右侧箭头**才打开样式面板。
|
||||
* 箭头是 ButtonGroup 里的第二个按钮,aria-label 为「无序列表样式 / 有序列表样式」。
|
||||
*/
|
||||
async function openListPanel(page: Page, name: '无序列表' | '有序列表') {
|
||||
await page
|
||||
.locator(`${EDITOR} .g3-rich-text__toolbar`)
|
||||
.getByRole('button', { name: `${name}样式` })
|
||||
.click()
|
||||
const panel = page.locator('.g3-rich-text__list-panel').last()
|
||||
await expect(panel).toBeVisible()
|
||||
return panel
|
||||
}
|
||||
|
||||
/** 点列表按钮的**图标**(一键建/撤列表,不打开面板) */
|
||||
async function clickListIcon(page: Page, name: '无序列表' | '有序列表') {
|
||||
await page
|
||||
.locator(`${EDITOR} .g3-rich-text__toolbar`)
|
||||
.getByRole('button', { name })
|
||||
.click()
|
||||
}
|
||||
|
||||
test.describe('RichText · 列表样式', () => {
|
||||
test('无序列表:实心方块写进 ul 的 type 属性', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '第一项')
|
||||
|
||||
// 点开无序列表按钮的样式面板,选「实心方块」—— 一步完成建列表 + 设样式
|
||||
const panel = await openListPanel(page, '无序列表')
|
||||
await panel.getByRole('menuitemradio', { name: '实心方块' }).click()
|
||||
|
||||
// 样式落在 type 属性上(不是 class)—— 属性自描述,导出即正确
|
||||
await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveCount(1)
|
||||
await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveAttribute('type', 'square')
|
||||
})
|
||||
|
||||
test('有序列表:大写罗马数字写进 ol 的 type 属性', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '第一步')
|
||||
|
||||
const panel = await openListPanel(page, '有序列表')
|
||||
await panel.getByRole('menuitemradio', { name: '大写罗马数字' }).click()
|
||||
|
||||
await expect(page.locator(`${EDITOR} .tiptap ol`)).toHaveAttribute('type', 'I')
|
||||
})
|
||||
|
||||
test('有序列表:小写希腊字母走 style(type="lower-greek" 是非法 HTML 值)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '第一步')
|
||||
|
||||
const panel = await openListPanel(page, '有序列表')
|
||||
await panel.getByRole('menuitemradio', { name: '小写希腊字母' }).click()
|
||||
|
||||
const ol = page.locator(`${EDITOR} .tiptap ol`)
|
||||
// 必须走 style —— 写 type="lower-greek" 会被浏览器当作未知值忽略、退回数字编号
|
||||
await expect(ol).toHaveAttribute('style', /list-style-type:\s*lower-greek/)
|
||||
await expect(ol).not.toHaveAttribute('type', 'lower-greek')
|
||||
})
|
||||
|
||||
test('点图标一键建列表,不打开样式面板', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '还没有列表')
|
||||
|
||||
await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveCount(0)
|
||||
|
||||
await clickListIcon(page, '无序列表')
|
||||
|
||||
// 只建列表、不弹面板(样式仍是默认档)
|
||||
await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveCount(1)
|
||||
await expect(page.locator('.g3-rich-text__list-panel')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('点图标再次点击退列表(toggle 语义)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '第一项')
|
||||
|
||||
await clickListIcon(page, '无序列表')
|
||||
await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveCount(1)
|
||||
|
||||
await clickListIcon(page, '无序列表')
|
||||
await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('点右侧箭头打开样式面板,点图标不打开', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '第一项')
|
||||
|
||||
// 点箭头:面板打开
|
||||
await (await openListPanel(page, '无序列表')).getByRole('menuitemradio', { name: '默认' }).click()
|
||||
|
||||
// 点图标:面板不开(只 toggle 列表)
|
||||
await clickListIcon(page, '无序列表')
|
||||
await expect(page.locator('.g3-rich-text__list-panel')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('当前档位带选中态(aria-checked),档位名是中文而非英文枚举', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '第一项')
|
||||
|
||||
// 先建列表 + 选实心方块
|
||||
const panel = await openListPanel(page, '无序列表')
|
||||
await panel.getByRole('menuitemradio', { name: '实心方块' }).click()
|
||||
await expect(page.locator(`${EDITOR} .tiptap ul`)).toHaveAttribute('type', 'square')
|
||||
|
||||
// 再打开面板:实心方块是当前档位 → 选中态;「默认」未被选中
|
||||
const reopened = await openListPanel(page, '无序列表')
|
||||
await expect(reopened.getByRole('menuitemradio', { name: '实心方块' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
)
|
||||
await expect(reopened.getByRole('menuitemradio', { name: '默认' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false',
|
||||
)
|
||||
// 档位名是中文,不暴露 disc / circle / square 这些英文枚举
|
||||
await expect(reopened.getByRole('menuitemradio', { name: /disc|circle|square/i })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('RichText · 整理格式', () => {
|
||||
test('清理空段落与空白文本,并把统计报给宿主', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// 用源代码对话框塞入带空段落的正文(比敲键盘造空段落更直接)
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '源代码' }).click()
|
||||
|
||||
const dialog = page.locator('.g3-modal')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('textarea').fill('<p>第一段</p><p></p><p></p><p>第二段</p>')
|
||||
await dialog.getByRole('button', { name: '保存' }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveCount(4)
|
||||
|
||||
// 「更多 → 整理格式」
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '整理格式' }).click()
|
||||
|
||||
// 两个空段落被清掉,只剩有内容的两段
|
||||
await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveCount(2)
|
||||
await expect(page.locator(`${EDITOR} .tiptap p`).first()).toHaveText('第一段')
|
||||
})
|
||||
|
||||
test('整理格式是一次可撤销的操作(撤销栈只留一步)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '源代码' }).click()
|
||||
const dialog = page.locator('.g3-modal')
|
||||
await dialog.locator('textarea').fill('<p>甲</p><p></p><p></p><p></p><p>乙</p>')
|
||||
await dialog.getByRole('button', { name: '保存' }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveCount(5)
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '整理格式' }).click()
|
||||
await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveCount(2)
|
||||
|
||||
// 一次 Ctrl+Z 就整体退回(若实现成逐段 dispatch,这里只会退回一个空段落)
|
||||
await page.locator(`${EDITOR} .tiptap`).click()
|
||||
await page.keyboard.press('Control+z')
|
||||
await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveCount(5)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('RichText · 预览', () => {
|
||||
test('模态框只读渲染当前正文,排版规则与编辑区一致', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '预览内容')
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '预览' }).click()
|
||||
|
||||
const dialog = page.locator('.g3-modal')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog).toContainText('预览内容')
|
||||
|
||||
// 只读:预览区里没有 contenteditable
|
||||
const body = dialog.locator('.g3-rich-text__preview-body')
|
||||
await expect(body).toBeVisible()
|
||||
await expect(body.locator('[contenteditable="true"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('预览取的是打开那一刻的快照(之后改正文不影响已打开的预览)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '快照')
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '预览' }).click()
|
||||
|
||||
const dialog = page.locator('.g3-modal')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// 预览区不复用编辑器的 DOM 节点(是 v-html 渲染的独立副本)
|
||||
const inEditor = await page
|
||||
.locator(`${EDITOR} .tiptap`)
|
||||
.evaluate((editable) => editable.contains(document.querySelector('.g3-rich-text__preview-body')))
|
||||
expect(inEditor).toBe(false)
|
||||
})
|
||||
|
||||
test('预览里可以发起打印', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '打印内容')
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '预览' }).click()
|
||||
|
||||
const dialog = page.locator('.g3-modal')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
/**
|
||||
* 拦掉真实的打印调用。
|
||||
*
|
||||
* 打印走的是**隐藏 iframe 的** `contentWindow.print()`,而不是宿主的 `window.print()`。
|
||||
* 在 iframe 的文档里注入拦截:`printHtml` 用 `srcdoc` 装载文档、等 `load` 后调 print,
|
||||
* 所以这里在 iframe 挂载时立刻改写它自己窗口的 print(`about:srcdoc` 与父页面同源,
|
||||
* 可以直接访问 `contentWindow`)。
|
||||
*
|
||||
* 不能用 `HTMLIFrameElement.prototype.contentWindow` 的 getter 覆盖 —— 那会破坏
|
||||
* 组件自己读 `contentWindow` 的路径(`load` 回调里要拿它调 print),
|
||||
* 结果是「为了让测试通过而把被测代码弄坏」。
|
||||
*/
|
||||
await page.evaluate(() => {
|
||||
const win = window as unknown as { __printed?: number }
|
||||
win.__printed = 0
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
for (const node of Array.from(record.addedNodes)) {
|
||||
if (!(node instanceof HTMLIFrameElement)) continue
|
||||
// 等到 iframe 的文档就绪后再改写(srcdoc 是异步解析的)
|
||||
node.addEventListener('load', () => {
|
||||
try {
|
||||
const frameWin = node.contentWindow as unknown as { print: () => void } | null
|
||||
if (frameWin) {
|
||||
frameWin.print = () => {
|
||||
win.__printed = (win.__printed ?? 0) + 1
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 跨源 / 已销毁:忽略,测试会在下面的 poll 里失败并给出真实原因
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true })
|
||||
})
|
||||
|
||||
await dialog.getByRole('button', { name: '打印' }).click()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { __printed?: number }).__printed))
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('RichText · 打印', () => {
|
||||
test('从「更多」打印:用隐藏 iframe 承载正文,且打印后清理掉', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '正文内容')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const win = window as unknown as { __printed?: number }
|
||||
win.__printed = 0
|
||||
|
||||
/**
|
||||
* 记录 iframe 里承载的正文,并拦掉它的 print。
|
||||
* 一次 `MutationObserver` 同时做两件事:断言「打印走 iframe」与
|
||||
* 「iframe 里装的是正文」(而不是宿主的整页 HTML)。
|
||||
*/
|
||||
const state = window as unknown as { __iframeHtml?: string }
|
||||
state.__iframeHtml = ''
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
for (const node of Array.from(record.addedNodes)) {
|
||||
if (!(node instanceof HTMLIFrameElement)) continue
|
||||
state.__iframeHtml = node.getAttribute('srcdoc') ?? ''
|
||||
node.addEventListener('load', () => {
|
||||
try {
|
||||
const frameWin = node.contentWindow as unknown as { print: () => void } | null
|
||||
if (frameWin) {
|
||||
frameWin.print = () => {
|
||||
win.__printed = (win.__printed ?? 0) + 1
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略:poll 会暴露真实问题
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true })
|
||||
})
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '打印' }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { __printed?: number }).__printed))
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// iframe 里装的是正文,而不是宿主页面的导航 / 按钮
|
||||
const carried = await page.evaluate(
|
||||
() => (window as unknown as { __iframeHtml?: string }).__iframeHtml ?? '',
|
||||
)
|
||||
expect(carried).toContain('正文内容')
|
||||
expect(carried).not.toContain('g3-rich-text__toolbar')
|
||||
|
||||
// 打印后清理掉 iframe,不能在 DOM 里越积越多
|
||||
await expect(page.locator('iframe[srcdoc]')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('RichText · 源代码', () => {
|
||||
test('编辑 HTML 后点保存写回编辑器', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '原内容')
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '源代码' }).click()
|
||||
|
||||
const dialog = page.locator('.g3-modal')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// 打开时预填当前 HTML
|
||||
const textarea = dialog.locator('textarea')
|
||||
await expect(textarea).toHaveValue(/原内容/)
|
||||
|
||||
await textarea.fill('<h2>新标题</h2><p>新正文</p>')
|
||||
await dialog.getByRole('button', { name: '保存' }).click()
|
||||
|
||||
// 对话框关闭 + 正文已更新
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.locator(`${EDITOR} .tiptap h2`)).toHaveText('新标题')
|
||||
await expect(page.locator(`${EDITOR} .tiptap p`)).toHaveText('新正文')
|
||||
})
|
||||
|
||||
test('「取消」关闭对话框且不写入文档', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '保持不变')
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '源代码' }).click()
|
||||
|
||||
const dialog = page.locator('.g3-modal')
|
||||
await dialog.locator('textarea').fill('<p>不该出现</p>')
|
||||
await dialog.getByRole('button', { name: '取消' }).click()
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('保持不变')
|
||||
await expect(page.locator(`${EDITOR} .tiptap`)).not.toContainText('不该出现')
|
||||
})
|
||||
|
||||
test('「重置」把草稿恢复成打开时的内容', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '初始内容')
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '源代码' }).click()
|
||||
|
||||
const dialog = page.locator('.g3-modal')
|
||||
const textarea = dialog.locator('textarea')
|
||||
await textarea.fill('<p>乱改的</p>')
|
||||
|
||||
await dialog.getByRole('button', { name: '重置' }).click()
|
||||
await expect(textarea).toHaveValue(/初始内容/)
|
||||
})
|
||||
|
||||
test('保存后的内容可以一次撤销退回', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await typeText(page, '原始文字')
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '源代码' }).click()
|
||||
const dialog = page.locator('.g3-modal')
|
||||
await dialog.locator('textarea').fill('<p>替换后的文字</p>')
|
||||
await dialog.getByRole('button', { name: '保存' }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('替换后的文字')
|
||||
|
||||
await page.locator(`${EDITOR} .tiptap`).click()
|
||||
await page.keyboard.press('Control+z')
|
||||
await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('原始文字')
|
||||
})
|
||||
|
||||
test('不认识的标签会被 schema 过滤掉(界面如实告知,不假装能完整往返)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await openMore(page)
|
||||
await page.getByRole('menuitem', { name: '源代码' }).click()
|
||||
|
||||
const dialog = page.locator('.g3-modal')
|
||||
await dialog.locator('textarea').fill('<p>保留</p><marquee>会被丢弃</marquee>')
|
||||
await dialog.getByRole('button', { name: '保存' }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
await expect(page.locator(`${EDITOR} .tiptap`)).toContainText('保留')
|
||||
await expect(page.locator(`${EDITOR} .tiptap marquee`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* G3Table 的几何 / 命中类断言 —— happy-dom 没有布局引擎,这些只能在真浏览器里量。
|
||||
*
|
||||
* 用例只按 data-testid 找元素(不依赖文案与结构),宿主页在 playground/main.ts。
|
||||
*/
|
||||
|
||||
test.describe('G3Table 表格', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.locator('[data-testid="table"]').scrollIntoViewIfNeeded()
|
||||
})
|
||||
|
||||
test('基础渲染:表头与数据行都在,且序号列存在', async ({ page }) => {
|
||||
const table = page.locator('[data-testid="table-wrap"]')
|
||||
await expect(table.locator('.stk-table')).toBeVisible()
|
||||
// 表头至少有我们的业务列文案
|
||||
await expect(table.locator('th', { hasText: '名称' })).toBeVisible()
|
||||
await expect(table.locator('th', { hasText: '金额' })).toBeVisible()
|
||||
// 数据行渲染出来了
|
||||
const rows = table.locator('.stk-tbody-main tr')
|
||||
await expect(rows).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('小数据量(3 行):纵向虚拟滚动关闭,DOM 里是真实 3 行', async ({ page }) => {
|
||||
const table = page.locator('[data-testid="table-wrap"]')
|
||||
// 库开启虚拟滚动时会给根加 .virtual 类;门控生效时不应有
|
||||
await expect(table.locator('.stk-table')).not.toHaveClass(/virtual/)
|
||||
})
|
||||
|
||||
test('大数据量(5000 行):纵向虚拟滚动开启,DOM 行数远少于数据量', async ({ page }) => {
|
||||
const big = page.locator('[data-testid="table-big-wrap"]')
|
||||
await big.scrollIntoViewIfNeeded()
|
||||
await expect(big.locator('.stk-table')).toHaveClass(/virtual/)
|
||||
|
||||
// 虚拟滚动的意义:只渲染视口内若干行,DOM 行数应当是个位数/几十,而不是 5000
|
||||
const rendered = await big.locator('.stk-tbody-main tr').count()
|
||||
expect(rendered).toBeGreaterThan(0)
|
||||
expect(rendered).toBeLessThan(200)
|
||||
})
|
||||
|
||||
test('大数据量:滚动后仍只渲染视口内行(虚拟窗口跟随)', async ({ page }) => {
|
||||
const big = page.locator('[data-testid="table-big-wrap"]')
|
||||
await big.scrollIntoViewIfNeeded()
|
||||
|
||||
const scroller = big.locator('.stk-table-scroll-container, .stk-table-main').first()
|
||||
await scroller.evaluate((el) => {
|
||||
el.scrollTop = 3000
|
||||
})
|
||||
await page.waitForTimeout(150)
|
||||
|
||||
const rendered = await big.locator('.stk-tbody-main tr').count()
|
||||
expect(rendered).toBeGreaterThan(0)
|
||||
expect(rendered).toBeLessThan(200)
|
||||
})
|
||||
|
||||
test('高度约束:容器限高时表格不溢出', async ({ page }) => {
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
const wrapBox = await wrap.boundingBox()
|
||||
const inner = wrap.locator('.stk-table').first()
|
||||
const innerBox = await inner.boundingBox()
|
||||
expect(wrapBox).not.toBeNull()
|
||||
expect(innerBox).not.toBeNull()
|
||||
// 表格不应高于它的容器(留 2px 容差:边框/亚像素)
|
||||
expect(innerBox!.height).toBeLessThanOrEqual(wrapBox!.height + 2)
|
||||
})
|
||||
|
||||
test('外框圆角:bordered 时容器有圆角与描边', async ({ page }) => {
|
||||
// 注意:data-testid 透传给了内层 StkTable(组件 inheritAttrs: false),
|
||||
// 外框与圆角在我们自己的根节点 .g3-table 上,所以要按类名定位。
|
||||
const root = page.locator('[data-testid="table-wrap"] .g3-table').first()
|
||||
await expect(root).toHaveClass(/is-bordered/)
|
||||
const radius = await root.evaluate((el) => getComputedStyle(el).borderRadius)
|
||||
expect(radius).not.toBe('0px')
|
||||
const borderWidth = await root.evaluate((el) => getComputedStyle(el).borderTopWidth)
|
||||
expect(borderWidth).toBe('1px')
|
||||
})
|
||||
|
||||
test('表头显示序号列(seq 默认开启)', async ({ page }) => {
|
||||
const table = page.locator('[data-testid="table-wrap"]')
|
||||
await expect(table.locator('th', { hasText: '序号' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('窄容器下不横向溢出容器宽度', async ({ page }) => {
|
||||
const wrap = page.locator('[data-testid="table-narrow-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
const wrapBox = await wrap.boundingBox()
|
||||
const rootBox = await wrap.locator('[data-testid="table-narrow"]').boundingBox()
|
||||
expect(rootBox!.width).toBeLessThanOrEqual(wrapBox!.width + 2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('G3Table 选择列(真浏览器命中)', () => {
|
||||
test('点击行复选框:勾选态可见地变化', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const firstRowCheckbox = wrap.locator('.g3-table__checkbox').first()
|
||||
await firstRowCheckbox.scrollIntoViewIfNeeded()
|
||||
await expect(firstRowCheckbox).not.toHaveClass(/is-checked/)
|
||||
|
||||
await firstRowCheckbox.click()
|
||||
await expect(firstRowCheckbox).toHaveClass(/is-checked/)
|
||||
})
|
||||
|
||||
test('每行复选框数 = 选择列 + checkbox 编辑器列(演示数据定义了两列)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 演示数据的 tableColumns 里有两个复选框列:选择列(注入)+ 启用列(editor: checkbox)。
|
||||
// 所以每行 2 个是预期值——这正是为什么不能靠「数复选框个数」判断是否有重复渲染。
|
||||
const bodyRows = wrap.locator('.stk-tbody-main tr')
|
||||
await expect(bodyRows).toHaveCount(3)
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await expect(bodyRows.nth(i).locator('.g3-table__checkbox')).toHaveCount(2)
|
||||
}
|
||||
})
|
||||
|
||||
test('无重复渲染:每行 td 数等于表头列数', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 单元格数必须与表头列数一致;固定列若被双份渲染,这里会多出来。
|
||||
const headerCount = await wrap.locator('thead th').count()
|
||||
const firstRowTd = await wrap.locator('.stk-tbody-main tr').first().locator('td').count()
|
||||
expect(firstRowTd).toBe(headerCount)
|
||||
})
|
||||
|
||||
test('表头全选:点击后表头与所有数据行的选择列都被勾选', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const headerCheckbox = wrap.locator('th .g3-table__checkbox').first()
|
||||
await headerCheckbox.scrollIntoViewIfNeeded()
|
||||
await headerCheckbox.click()
|
||||
|
||||
// 表头 choice 项 + 3 行选择列 = 4 个处于勾选态的选择框
|
||||
// (「启用」列的 checkbox 是数据值,初始为 1 的那两行也会是勾选态,
|
||||
// 所以这里按选择列的固定类名限定范围)
|
||||
await expect(wrap.locator('th .g3-table__checkbox.is-checked')).toHaveCount(1)
|
||||
await expect(wrap.locator('.stk-tbody-main td:first-child .g3-table__checkbox.is-checked')).toHaveCount(3)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('G3Table RTL 镜像', () => {
|
||||
test('RTL 下固定列与表格布局不塌陷', async ({ page }) => {
|
||||
await page.goto('/?dir=rtl')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const wrapBox = await wrap.boundingBox()
|
||||
const tableBox = await wrap.locator('.stk-table').first().boundingBox()
|
||||
expect(tableBox).not.toBeNull()
|
||||
// 表格仍应占据容器的整宽(镜像不该把宽度算丢)
|
||||
expect(tableBox!.width).toBeGreaterThan(wrapBox!.width * 0.9)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user