fix: make code block height content-aware
Engine CI / test (push) Waiting to run

This commit is contained in:
2026-08-31 20:08:32 +08:00
parent e23fa413be
commit 924b1a15f3
8 changed files with 130 additions and 40 deletions
+2 -2
View File
@@ -158,7 +158,7 @@ Markdown 图片默认保持比例并限制在内容区,也可以显式覆盖
![架构图](./assets/architecture.png){fit="cover" position="50% 30%" max-height="65vh"}
```
主题默认使用 `1024 × 768` 的 4:3 画布。表格默认字号为 `30px`;代码默认字号为 `28px`,未指定高度时会从当前位置自动延伸到所在内容区底部,长行会正常换行,超出后只上下滚动。需要按页调整时,在该页 frontmatter 中添加尺寸类:
主题默认使用 `1024 × 768` 的 4:3 画布。表格默认字号为 `30px`;代码默认字号为 `28px`,未指定高度时会按照内容使用自然高度,并根据前后正文自动限制在所在内容区,长行会正常换行,超出后只上下滚动。需要按页调整时,在该页 frontmatter 中添加尺寸类:
```md
---
@@ -180,7 +180,7 @@ const slides = await discover('slides')
</div>
````
同样的包裹方式也适用于表格,例如 `<div class="easy-table-sm">`。不添加固定高度类时,单个代码框会占满标题或前置正文之后的剩余区域;同一区域有多个代码框时保持内容高度并限制在内容区内
同样的包裹方式也适用于表格,例如 `<div class="easy-table-sm">`。不添加固定高度类时,代码框保持内容的自然高度;内容较长时会自动扣除前后正文占用的空间并限制在内容区内。同一区域有多个代码框时也遵循这一规则
公开放映页和演示者预览中的图片可以点击进入全屏预览,也可以聚焦图片后按 `Enter` 或空格打开;通过关闭按钮、点击背景或按 `Esc` 返回幻灯片。预览底部提供 ` / 百分比 / +` 工具栏,100% 对应图片原始像素尺寸;还可以使用鼠标滚轮、触控板或触屏双指缩放,并在放大后拖动查看细节,缩放范围为 10%–400%。
@@ -927,7 +927,8 @@ function hasExplicitCodeHeight(code: HTMLPreElement) {
function availableCodeHeight(code: HTMLPreElement, region: HTMLElement) {
const layout = code.closest<HTMLElement>('.slidev-layout')
if (!layout || layout.clientHeight <= 0)
const host = code.closest<HTMLElement>('.easy-content-code-host')
if (!layout || !host || layout.clientHeight <= 0)
return 0
const layoutRect = layout.getBoundingClientRect()
const regionRect = region.getBoundingClientRect()
@@ -941,7 +942,33 @@ function availableCodeHeight(code: HTMLPreElement, region: HTMLElement) {
const layoutPaddingBottom = Number.parseFloat(getComputedStyle(layout).paddingBottom) || 0
const regionBottom = regionRect.bottom - regionPaddingBottom * scale
const layoutBottom = layoutRect.bottom - layoutPaddingBottom * scale
return Math.max(0, Math.floor((Math.min(regionBottom, layoutBottom) - codeRect.top) / scale))
const contentBottom = Math.min(regionBottom, layoutBottom)
let trailingBottom = codeRect.bottom
region.querySelectorAll<HTMLElement>('*').forEach((element) => {
if (host.contains(element) || !(host.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING))
return
const style = getComputedStyle(element)
if (style.display === 'none' || style.position === 'absolute' || style.position === 'fixed')
return
const rect = element.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0)
return
const marginBottom = Number.parseFloat(style.marginBottom) || 0
trailingBottom = Math.max(trailingBottom, rect.bottom + marginBottom * scale)
})
const trailingHeight = Math.max(0, trailingBottom - codeRect.bottom)
const codeStyle = getComputedStyle(code)
const minimumHeight = (
(Number.parseFloat(codeStyle.lineHeight) || 0)
+ (Number.parseFloat(codeStyle.paddingTop) || 0)
+ (Number.parseFloat(codeStyle.paddingBottom) || 0)
+ (Number.parseFloat(codeStyle.borderTopWidth) || 0)
+ (Number.parseFloat(codeStyle.borderBottomWidth) || 0)
)
const available = (contentBottom - codeRect.top - trailingHeight) / scale
return Math.max(1, Math.floor(Math.max(minimumHeight, available)))
}
function setHostLength(host: HTMLElement, property: string, value?: number) {
@@ -961,7 +988,6 @@ function fitCodeHeights() {
if (!host)
return
if (hasExplicitCodeHeight(code)) {
setHostLength(host, '--easy-code-auto-height')
setHostLength(host, '--easy-code-auto-max-height')
return
}
@@ -974,7 +1000,6 @@ function fitCodeHeights() {
})
groups.forEach((codes, region) => {
const single = codes.length === 1
codes.forEach((code) => {
const host = code.closest<HTMLElement>('.easy-content-code-host')
if (!host)
@@ -982,7 +1007,6 @@ function fitCodeHeights() {
const available = availableCodeHeight(code, region)
if (!available)
return
setHostLength(host, '--easy-code-auto-height', single ? available : undefined)
setHostLength(host, '--easy-code-auto-max-height', available)
})
})
@@ -1048,7 +1072,6 @@ function cleanupCodeHosts() {
const base = code && codeStyles.get(code)
host.querySelector(':scope > .easy-content-scale-toolbar')?.remove()
host.style.removeProperty('--easy-code-scaled-font-size')
host.style.removeProperty('--easy-code-auto-height')
host.style.removeProperty('--easy-code-auto-max-height')
host.classList.remove('easy-content-scale-host', 'easy-content-code-host')
delete host.dataset.easyScaleKey
+2 -2
View File
@@ -135,8 +135,8 @@
.slidev-layout pre {
box-sizing: border-box;
height: var(--easy-code-height, var(--easy-code-auto-height, auto));
max-height: var(--easy-code-height, var(--easy-code-auto-max-height, var(--easy-code-auto-height, none)));
height: var(--easy-code-height, auto);
max-height: var(--easy-code-height, var(--easy-code-auto-max-height, none));
overflow-x: hidden;
overflow-y: auto;
border-radius: 10px;
@@ -144,6 +144,25 @@ layout: default
表格和代码尺寸既可以按页设置,也可以用 div 包裹后只调整一个内容块。
-->
---
layout: default
---
## 智能代码高度
```text
用户提问 → 模型回答
看起来:一次请求,一台“AI”
实际上:网关 → 调度 → 推理 → 检索 → 存储
```
### 代码之后的正文仍保留在内容区内
<!--
未指定高度的代码框按照内容自然伸缩,并为后续正文保留空间。
-->
---
layout: quote
---
@@ -927,7 +927,8 @@ function hasExplicitCodeHeight(code: HTMLPreElement) {
function availableCodeHeight(code: HTMLPreElement, region: HTMLElement) {
const layout = code.closest<HTMLElement>('.slidev-layout')
if (!layout || layout.clientHeight <= 0)
const host = code.closest<HTMLElement>('.easy-content-code-host')
if (!layout || !host || layout.clientHeight <= 0)
return 0
const layoutRect = layout.getBoundingClientRect()
const regionRect = region.getBoundingClientRect()
@@ -941,7 +942,33 @@ function availableCodeHeight(code: HTMLPreElement, region: HTMLElement) {
const layoutPaddingBottom = Number.parseFloat(getComputedStyle(layout).paddingBottom) || 0
const regionBottom = regionRect.bottom - regionPaddingBottom * scale
const layoutBottom = layoutRect.bottom - layoutPaddingBottom * scale
return Math.max(0, Math.floor((Math.min(regionBottom, layoutBottom) - codeRect.top) / scale))
const contentBottom = Math.min(regionBottom, layoutBottom)
let trailingBottom = codeRect.bottom
region.querySelectorAll<HTMLElement>('*').forEach((element) => {
if (host.contains(element) || !(host.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING))
return
const style = getComputedStyle(element)
if (style.display === 'none' || style.position === 'absolute' || style.position === 'fixed')
return
const rect = element.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0)
return
const marginBottom = Number.parseFloat(style.marginBottom) || 0
trailingBottom = Math.max(trailingBottom, rect.bottom + marginBottom * scale)
})
const trailingHeight = Math.max(0, trailingBottom - codeRect.bottom)
const codeStyle = getComputedStyle(code)
const minimumHeight = (
(Number.parseFloat(codeStyle.lineHeight) || 0)
+ (Number.parseFloat(codeStyle.paddingTop) || 0)
+ (Number.parseFloat(codeStyle.paddingBottom) || 0)
+ (Number.parseFloat(codeStyle.borderTopWidth) || 0)
+ (Number.parseFloat(codeStyle.borderBottomWidth) || 0)
)
const available = (contentBottom - codeRect.top - trailingHeight) / scale
return Math.max(1, Math.floor(Math.max(minimumHeight, available)))
}
function setHostLength(host: HTMLElement, property: string, value?: number) {
@@ -961,7 +988,6 @@ function fitCodeHeights() {
if (!host)
return
if (hasExplicitCodeHeight(code)) {
setHostLength(host, '--easy-code-auto-height')
setHostLength(host, '--easy-code-auto-max-height')
return
}
@@ -974,7 +1000,6 @@ function fitCodeHeights() {
})
groups.forEach((codes, region) => {
const single = codes.length === 1
codes.forEach((code) => {
const host = code.closest<HTMLElement>('.easy-content-code-host')
if (!host)
@@ -982,7 +1007,6 @@ function fitCodeHeights() {
const available = availableCodeHeight(code, region)
if (!available)
return
setHostLength(host, '--easy-code-auto-height', single ? available : undefined)
setHostLength(host, '--easy-code-auto-max-height', available)
})
})
@@ -1048,7 +1072,6 @@ function cleanupCodeHosts() {
const base = code && codeStyles.get(code)
host.querySelector(':scope > .easy-content-scale-toolbar')?.remove()
host.style.removeProperty('--easy-code-scaled-font-size')
host.style.removeProperty('--easy-code-auto-height')
host.style.removeProperty('--easy-code-auto-max-height')
host.classList.remove('easy-content-scale-host', 'easy-content-code-host')
delete host.dataset.easyScaleKey
@@ -135,8 +135,8 @@
.slidev-layout pre {
box-sizing: border-box;
height: var(--easy-code-height, var(--easy-code-auto-height, auto));
max-height: var(--easy-code-height, var(--easy-code-auto-max-height, var(--easy-code-auto-height, none)));
height: var(--easy-code-height, auto);
max-height: var(--easy-code-height, var(--easy-code-auto-max-height, none));
overflow-x: hidden;
overflow-y: auto;
border-radius: 10px;
+3 -1
View File
@@ -65,7 +65,9 @@ describe('theme defaults', () => {
expect(themePackage.dependencies['@slidev/client']).toBe('52.19.1')
expect(themeCss).toContain('--easy-accent: #1d4ed8')
expect(themeCss.slice(0, themeCss.indexOf('.easy-table-sm'))).not.toContain('--easy-code-height')
expect(themeCss).toContain('var(--easy-code-auto-height, auto)')
expect(themeCss).toContain('height: var(--easy-code-height, auto)')
expect(themeCss).toContain('var(--easy-code-auto-max-height, none)')
expect(themeCss).not.toContain('--easy-code-auto-height')
expect(themeCss).toContain('--easy-code-scaled-font-size')
expect(themeCss).toContain('overflow-x: hidden')
expect(themeCss).toContain('white-space: pre-wrap')
+44 -21
View File
@@ -261,14 +261,14 @@ test('presenter advances animations, switches notes, shows timing, and syncs the
await presenter.getByTitle('Go to next slide').click()
await expect(page.getByRole('heading', { name: '内容优先' })).toBeVisible()
await expect(presenter.getByText('依次介绍四个核心能力')).toBeVisible()
await expect(presenter.getByText('2 / 8', { exact: true })).toBeVisible()
await expect(presenter.getByText('2 / 9', { exact: true })).toBeVisible()
await presenter.getByTitle('Go to next slide').click()
await expect(presenter.getByText('1/4', { exact: true })).toBeVisible()
await expect(page).toHaveURL(/#\/2\?clicks=1$/)
})
test('table and code controls adjust font size while code fills the available height', async ({ page }) => {
test('table and code controls adjust font size while code uses smart height', async ({ page }) => {
await page.goto('/getting-started/#/7')
const table = page.locator('.slidev-layout table').first()
const tablePreset = table.locator('..').locator('..')
@@ -311,15 +311,8 @@ test('table and code controls adjust font size while code fills the available he
await expect.poll(() => code.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBe(initialCodeSize)
await expect.poll(() => code.evaluate((element) => {
const region = element.closest('.easy-two-cols-grid > div')!
const layout = element.closest('.slidev-layout') as HTMLElement
const scale = layout.getBoundingClientRect().height / layout.clientHeight
const contentBottom = Math.min(
region.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(region).paddingBottom) * scale,
layout.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(layout).paddingBottom) * scale,
)
return Math.abs(contentBottom - element.getBoundingClientRect().bottom) / scale
})).toBeLessThanOrEqual(3)
return element.scrollHeight - element.clientHeight
})).toBeGreaterThan(0)
const overflow = await code.evaluate(element => ({
horizontal: getComputedStyle(element).overflowX,
lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight),
@@ -365,16 +358,46 @@ test('table and code controls adjust font size while code fills the available he
await layout.evaluate((element) => {
element.classList.remove('easy-code-height-sm', 'easy-code-height-md', 'easy-code-height-lg')
})
await expect.poll(() => code.evaluate(element => element.scrollHeight - element.clientHeight)).toBeGreaterThan(0)
})
test('auto-height code keeps its natural height and reserves room for following content', async ({ page }) => {
await page.goto('/getting-started/#/8')
const layout = page.locator('.slidev-page-8 .slidev-layout')
const code = layout.locator('pre').first()
const following = layout.getByRole('heading', { name: '代码之后的正文仍保留在内容区内' })
await expect(following).toBeVisible()
await expect.poll(() => code.evaluate((element) => {
const region = element.closest('.easy-two-cols-grid > div')!
const layout = element.closest('.slidev-layout') as HTMLElement
const scale = layout.getBoundingClientRect().height / layout.clientHeight
const contentBottom = Math.min(
region.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(region).paddingBottom) * scale,
layout.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(layout).paddingBottom) * scale,
)
return Math.abs(contentBottom - element.getBoundingClientRect().bottom) / scale
})).toBeLessThanOrEqual(3)
const layoutElement = element.closest('.slidev-layout') as HTMLElement
const scale = layoutElement.getBoundingClientRect().height / layoutElement.clientHeight
return Math.abs(element.getBoundingClientRect().height / scale - element.scrollHeight)
})).toBeLessThanOrEqual(1)
const bounds = await layout.evaluate((element) => {
const codeElement = element.querySelector('pre')!
const followingElement = Array.from(element.querySelectorAll('h3')).find(item => item.textContent?.includes('代码之后的正文'))!
return {
codeBottom: codeElement.getBoundingClientRect().bottom,
followingBottom: followingElement.getBoundingClientRect().bottom,
followingTop: followingElement.getBoundingClientRect().top,
layoutBottom: element.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(element).paddingBottom),
}
})
expect(bounds.codeBottom).toBeLessThan(bounds.followingTop)
expect(bounds.followingBottom).toBeLessThanOrEqual(bounds.layoutBottom + 1)
await code.evaluate((element) => {
element.textContent = Array.from({ length: 40 }, (_, index) => `line ${index + 1}: a deliberately long code sample`).join('\n')
})
await expect.poll(() => code.evaluate(element => element.scrollHeight - element.clientHeight)).toBeGreaterThan(100)
const longContentBounds = await layout.evaluate((element) => {
const followingElement = Array.from(element.querySelectorAll('h3')).find(item => item.textContent?.includes('代码之后的正文'))!
return {
followingBottom: followingElement.getBoundingClientRect().bottom,
layoutBottom: element.getBoundingClientRect().bottom - Number.parseFloat(getComputedStyle(element).paddingBottom),
}
})
expect(longContentBounds.followingBottom).toBeLessThanOrEqual(longContentBounds.layoutBottom + 1)
})
test('image fullscreen preserves layout and table scaling stays out of non-interactive routes', async ({ page }) => {
@@ -601,7 +624,7 @@ test('stacked image layouts constrain wide images by available height', async ({
test('sample slides stay inside the 4:3 canvas', async ({ page }) => {
await page.goto('/getting-started/#/1')
for (let slide = 1; slide <= 8; slide += 1) {
for (let slide = 1; slide <= 9; slide += 1) {
const layout = page.locator('.slidev-layout').first()
await expect(layout).toBeVisible()
const box = await layout.boundingBox()