Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### 修复

- 修复单行表格中的行内代码导致文字越过列边界的问题,同时保留连续空格;需要更宽空间的表格通过容器横向滚动显示(#280)。

### 兼容性

- 将 DSH 当前宿主与下一测试版的接口、打包安装和发布验证目标更新为 `0.2.0-rc.2` 与 `0.2.1-alpha.1`。
Expand Down
10 changes: 5 additions & 5 deletions scripts/check-text-newlines-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
*/
import { execFile } from 'node:child_process'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
Expand All @@ -15,10 +14,11 @@ import { createServer } from 'vite'
const exec = promisify(execFile)
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const out = resolve(root, '.e2e-artifacts/text-newlines')
const profile = await mkdtemp(join(tmpdir(), 'genui-newlines-'))
let profile
let server
try {
await mkdir(out, { recursive: true })
profile = await mkdtemp(join(out, 'chrome-profile-'))
const candidates = process.env.BROWSER_BIN ? [process.env.BROWSER_BIN] : ['google-chrome', 'chromium', 'chromium-browser']
let browser
for (const candidate of candidates) {
Expand Down Expand Up @@ -52,12 +52,12 @@ try {
if (raw === undefined || raw === 'pending') throw new Error('Browser fixture did not finish; inspect page.html and browser.log')
const results = JSON.parse(raw.replaceAll('&quot;', '"').replaceAll('&#39;', "'").replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&'))
await writeFile(join(out, 'results.json'), JSON.stringify(results, null, 2) + '\n')
if (results.length !== 108) throw new Error(`Expected 108 initial/repeated-control newline cases, got ${results.length}`)
if (results.length !== 118) throw new Error(`Expected 118 initial/repeated-control layout checks, got ${results.length}`)
const failed = results.filter(result => !result.pass)
if (failed.length > 0) throw new Error(`Browser newline regression failed:\n${JSON.stringify(failed, null, 2)}`)
await readFile(join(out, 'layout.png'))
console.log(`Chromium newline layout/selection check passed: ${results.length} cases`)
console.log(`Chromium newline layout/selection check passed: ${results.length} checks`)
} finally {
await server?.close()
await rm(profile, { recursive: true, force: true })
if (profile !== undefined) await rm(profile, { recursive: true, force: true })
}
4 changes: 4 additions & 0 deletions src/client/GenuiBlock.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,10 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
/* An inline code span keeps its own spacing too (a pasted snippet often lives
inside backticks), matching the block-level code surface. */
.inlineCode { white-space: pre-wrap; }
.table > thead > tr > th:not(.tdCode):not(.tdMultiline) .inlineCode,
.table > tbody > tr:not(.detailRow) > td:not(.tdCode):not(.tdMultiline) .inlineCode {
white-space: pre;
}
/* Rich-text owners must paint the real newlines kept by renderInline. Scope
this to prose/labels: table data keeps nowrap and code keeps pre-wrap. For
flex controls, target the label span so emphasis and plain text share one
Expand Down
2 changes: 1 addition & 1 deletion tests/browser/text-newlines.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<!doctype html><html><head><meta charset="UTF-8"><title>Issue 249 newline regression</title><style>body{margin:24px;font-family:Arial,sans-serif}#fixtures{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.case{border:1px solid #bbb;padding:12px;min-width:0}.case>header{font-size:11px;color:#555;margin-bottom:6px}#results{display:none}</style></head><body><h1>Issue 249: actual LF/CRLF layout and selection</h1><div id="root"></div><pre id="results">pending</pre><script type="module" src="/tests/browser/text-newlines.tsx"></script></body></html>
<!doctype html><html><head><meta charset="UTF-8"><title>Issues 249 and 280 browser regressions</title><style>body{margin:24px;font-family:Arial,sans-serif}#fixtures{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.case{border:1px solid #bbb;padding:12px;min-width:0}.case.table-geometry{grid-column:1/-1;width:540px}.case>header{font-size:11px;color:#555;margin-bottom:6px}#results{display:none}</style></head><body><h1>Browser layout regressions</h1><div id="root"></div><pre id="results">pending</pre><script type="module" src="/tests/browser/text-newlines.tsx"></script></body></html>
121 changes: 104 additions & 17 deletions tests/browser/text-newlines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface Case {
selector: string
expected: string
whiteSpace: string
tableGeometry?: boolean
detailWhitespace?: boolean
}

const cases: Case[] = []
Expand Down Expand Up @@ -59,11 +61,35 @@ cases.push({
name: 'inline code spacing', node: { type: 'text', content: '`a b`' },
selector: 'code', expected: 'a b', whiteSpace: 'pre-wrap',
})
const mixedLengthRows = [
['Run `const result = executeLongOperation(argumentOne, argumentTwo, argumentThree)` now', 'The operation completed after reconnecting to the service and checking each resource', 'Ready'],
['ok', 'Done', 'Yes'],
['Repeat `const result = executeLongOperation(argumentOne, argumentTwo, argumentThree)` later', 'Pending', 'Waiting'],
['x', 'Queued', 'No'],
['Complete', 'Done', 'Yes'],
]
cases.push({
name: 'single-line table inline code stays inside its column',
node: {
type: 'table', columns: ['Command', 'Result', 'Status'], rows: mixedLengthRows,
},
selector: 'td', expected: '', whiteSpace: '', tableGeometry: true,
})
cases.push({
name: 'expanded table details preserve nested inline code whitespace',
node: {
type: 'table', columns: ['Item'], rows: [['Expandable']], details: [[
{ type: 'text', content: 'Detail `outerInlineCodeShouldWrap`' },
{ type: 'table', columns: ['Snippet'], rows: [['before `nestedInlineCodeShouldWrap`\n after']] },
]],
},
selector: '', expected: '', whiteSpace: '', detailWhitespace: true,
})

createRoot(document.getElementById('root')!).render(
<div id="fixtures">
{cases.map((item, index) => (
<section className="case" key={index} data-case={index}>
<section className={`case${item.tableGeometry ? ' table-geometry' : ''}`} key={index} data-case={index}>
<header>{item.name}</header>
<GenuiBlock spec={{ items: [item.node] }} />
</section>
Expand All @@ -73,7 +99,61 @@ createRoot(document.getElementById('root')!).render(

/** Character ranges measure actual painted line boxes, including rich text. */
function measure(phase: string) {
return cases.map((item, index) => {
return cases.flatMap((item, index) => {
if (item.detailWhitespace) {
const fixture = document.querySelector<HTMLElement>(`[data-case="${index}"]`)
const detailRow = fixture?.querySelector<HTMLElement>('[class*="detailRow"]')
const textCode = detailRow?.querySelector<HTMLElement>('[class*="text"] code')
const nestedCode = detailRow?.querySelector<HTMLElement>('table td[class*="tdCode"] code')
const textWhiteSpace = textCode == null ? null : getComputedStyle(textCode).whiteSpace
const nestedWhiteSpace = nestedCode == null ? null : getComputedStyle(nestedCode).whiteSpace
return [
{ name: 'expanded detail text inline code keeps pre-wrap', phase, whiteSpace: textWhiteSpace, pass: textWhiteSpace === 'pre-wrap' },
{ name: 'nested detail table code keeps pre-wrap', phase, whiteSpace: nestedWhiteSpace, pass: nestedWhiteSpace === 'pre-wrap' },
]
}
if (item.tableGeometry) {
const fixture = document.querySelector<HTMLElement>(`[data-case="${index}"]`)
const wrapper = fixture?.querySelector<HTMLElement>('[class*="tableWrap"]')
const rows = wrapper?.querySelectorAll<HTMLTableRowElement>('tbody tr')
if (fixture === null || wrapper == null || rows === undefined) {
return { name: item.name, phase, pass: false, error: 'Missing table geometry fixture' }
}
const inlineCodes = [...wrapper.querySelectorAll<HTMLElement>('tbody td code')]
const expectedWhiteSpace = 'pre'
const spacingPreserved = inlineCodes.length === 2 && inlineCodes.every(code =>
code.textContent?.includes(' ') === true && getComputedStyle(code).whiteSpace === expectedWhiteSpace,
)
const overlaps: Array<{ row: number; maxTextRight: number; nextCellLeft: number }> = []
rows.forEach((row, rowIndex) => {
const firstCell = row.cells[0]
const nextCell = row.cells[1]
if (firstCell === undefined || nextCell === undefined) return
let maxTextRight = Number.NEGATIVE_INFINITY
const walker = document.createTreeWalker(firstCell, NodeFilter.SHOW_TEXT)
for (let text = walker.nextNode(); text !== null; text = walker.nextNode()) {
const content = text.textContent ?? ''
for (let position = 0; position < content.length; position++) {
if (/\s/.test(content[position]!)) continue
const range = document.createRange()
range.setStart(text, position)
range.setEnd(text, position + 1)
for (const rect of range.getClientRects()) maxTextRight = Math.max(maxTextRight, rect.right)
}
}
const nextCellLeft = nextCell.getBoundingClientRect().left
if (maxTextRight > nextCellLeft + 1) overlaps.push({ row: rowIndex, maxTextRight, nextCellLeft })
})
return [
{ name: `${item.name} adjacent-column overlap`, phase, overlaps, pass: overlaps.length === 0 },
{
name: `${item.name} horizontal scrolling`, phase,
scrollWidth: wrapper.scrollWidth, clientWidth: wrapper.clientWidth,
pass: wrapper.scrollWidth > wrapper.clientWidth,
},
{ name: `${item.name} inline code whitespace`, phase, spacingPreserved, expectedWhiteSpace, pass: spacingPreserved },
]
}
const owner = document.querySelector(`[data-case="${index}"] ${item.selector}`)
if (owner === null) return { name: item.name, phase, pass: false, error: 'Missing label owner' }
const lineTops: number[] = []
Expand Down Expand Up @@ -112,22 +192,29 @@ function measure(phase: string) {
}

setTimeout(() => {
const results = measure('initial')
// Repeated local controls/rerenders must leave label formatting intact.
for (const checkbox of document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')) {
checkbox.click()
checkbox.click()
}
for (const radio of document.querySelectorAll<HTMLInputElement>('input[type="radio"]')) radio.click()
for (const tabs of document.querySelectorAll('[role="tablist"]')) {
const buttons = tabs.querySelectorAll<HTMLButtonElement>('[role="tab"]')
buttons[1]!.click()
buttons[0]!.click()
for (const [index, item] of cases.entries()) {
if (item.detailWhitespace) {
document.querySelector<HTMLElement>(`[data-case="${index}"] [class*="detailToggle"]`)?.click()
}
}
setTimeout(() => {
results.push(...measure('after-controls'))
window.getSelection()!.removeAllRanges()
document.getElementById('results')!.textContent = JSON.stringify(results)
document.body.dataset.qa = results.every(result => result.pass) ? 'PASS' : 'FAIL'
const results = measure('initial')
// Repeated local controls/rerenders must leave label formatting intact.
for (const checkbox of document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')) {
checkbox.click()
checkbox.click()
}
for (const radio of document.querySelectorAll<HTMLInputElement>('input[type="radio"]')) radio.click()
for (const tabs of document.querySelectorAll('[role="tablist"]')) {
const buttons = tabs.querySelectorAll<HTMLButtonElement>('[role="tab"]')
buttons[1]!.click()
buttons[0]!.click()
}
setTimeout(() => {
results.push(...measure('after-controls'))
window.getSelection()!.removeAllRanges()
document.getElementById('results')!.textContent = JSON.stringify(results)
document.body.dataset.qa = results.every(result => result.pass) ? 'PASS' : 'FAIL'
}, 100)
}, 100)
}, 600)
14 changes: 14 additions & 0 deletions tests/genui-table-multiline-cell.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ describe('multi-line table cells keep their line structure and indentation', ()
expect(td.className).not.toContain('tdMultiline')
})

it('preserves repeated spaces in inline code inside a single-line table cell', () => {
const td = renderCell('`hello world`').querySelector('td:nth-child(2)')!
expect(td.querySelector('code')?.textContent).toBe('hello world')
expect(td.className).not.toContain('tdCode')
expect(td.className).not.toContain('tdMultiline')
})

it('keeps inline code in multi-line cells under the existing cell whitespace rule', () => {
const td = renderCell('before `hello world`\n after').querySelector('td:nth-child(2)')!
expect(td.className).toContain('tdCode')
expect(td.querySelector('code')?.textContent).toBe('hello world')
})

it('marks a multi-line header too', () => {
const { container } = render(<GenuiBlock spec={{
items: [{ type: 'table', columns: ['A\n B', 'C'], rows: [['1', '2']] }],
Expand All @@ -98,6 +111,7 @@ describe('multi-line table cells keep their line structure and indentation', ()
expect(css).toMatch(/\.table th\.tdCode,\s*\.table td\.tdCode\s*\{[^}]*white-space:\s*pre-wrap/)
expect(css).toMatch(/\.table th\.tdMultiline,\s*\.table td\.tdMultiline\s*\{[^}]*white-space:\s*pre-line/)
expect(css).toMatch(/\.table td\s*\{[^}]*white-space:\s*nowrap/)
expect(css).toMatch(/\.table > thead > tr > th:not\(\.tdCode\):not\(\.tdMultiline\) \.inlineCode,[\s\S]*?\.table > tbody > tr:not\(\.detailRow\) > td:not\(\.tdCode\):not\(\.tdMultiline\) \.inlineCode\s*\{[^}]*white-space:\s*pre/)
// Prose containers that may now carry a real newline must paint it.
expect(css).toMatch(/\.calloutBody,[\s\S]*?\.kvValue[\s\S]*?\{[^}]*white-space:\s*pre-line/)
})
Expand Down
Loading