From 924b1a15f3c026b7c2c728b812fae86ea365d6aa Mon Sep 17 00:00:00 2001 From: yulonger's MacBook Pro 14 Date: Mon, 31 Aug 2026 20:08:32 +0800 Subject: [PATCH] fix: make code block height content-aware --- README.md | 4 +- .../theme/components/ContentScaleControls.vue | 35 ++++++++-- dist-cli/theme/styles/base.css | 4 +- .../slides/getting-started/slides.md | 19 ++++++ .../components/ContentScaleControls.vue | 35 ++++++++-- .../slidev-theme-easy-jyy/styles/base.css | 4 +- tests/core.test.ts | 4 +- tests/e2e/presenter.spec.ts | 65 +++++++++++++------ 8 files changed, 130 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 4d8977e..87fea03 100644 --- a/README.md +++ b/README.md @@ -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') ```` -同样的包裹方式也适用于表格,例如 `
`。不添加固定高度类时,单个代码框会占满标题或前置正文之后的剩余区域;同一区域有多个代码框时保持内容高度并限制在内容区内。 +同样的包裹方式也适用于表格,例如 `
`。不添加固定高度类时,代码框保持内容的自然高度;内容较长时会自动扣除前后正文占用的空间并限制在内容区内。同一区域有多个代码框时也遵循这一规则。 公开放映页和演示者预览中的图片可以点击进入全屏预览,也可以聚焦图片后按 `Enter` 或空格打开;通过关闭按钮、点击背景或按 `Esc` 返回幻灯片。预览底部提供 `− / 百分比 / +` 工具栏,100% 对应图片原始像素尺寸;还可以使用鼠标滚轮、触控板或触屏双指缩放,并在放大后拖动查看细节,缩放范围为 10%–400%。 diff --git a/dist-cli/theme/components/ContentScaleControls.vue b/dist-cli/theme/components/ContentScaleControls.vue index 6dc68d2..858f963 100644 --- a/dist-cli/theme/components/ContentScaleControls.vue +++ b/dist-cli/theme/components/ContentScaleControls.vue @@ -927,7 +927,8 @@ function hasExplicitCodeHeight(code: HTMLPreElement) { function availableCodeHeight(code: HTMLPreElement, region: HTMLElement) { const layout = code.closest('.slidev-layout') - if (!layout || layout.clientHeight <= 0) + const host = code.closest('.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('*').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('.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 diff --git a/dist-cli/theme/styles/base.css b/dist-cli/theme/styles/base.css index 6975ab3..a656099 100644 --- a/dist-cli/theme/styles/base.css +++ b/dist-cli/theme/styles/base.css @@ -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; diff --git a/examples/content-repo/slides/getting-started/slides.md b/examples/content-repo/slides/getting-started/slides.md index 83b9b3c..5e3375c 100644 --- a/examples/content-repo/slides/getting-started/slides.md +++ b/examples/content-repo/slides/getting-started/slides.md @@ -144,6 +144,25 @@ layout: default 表格和代码尺寸既可以按页设置,也可以用 div 包裹后只调整一个内容块。 --> +--- +layout: default +--- + +## 智能代码高度 + +```text +用户提问 → 模型回答 + +看起来:一次请求,一台“AI” +实际上:网关 → 调度 → 推理 → 检索 → 存储 +``` + +### 代码之后的正文仍保留在内容区内 + + + --- layout: quote --- diff --git a/packages/slidev-theme-easy-jyy/components/ContentScaleControls.vue b/packages/slidev-theme-easy-jyy/components/ContentScaleControls.vue index 6dc68d2..858f963 100644 --- a/packages/slidev-theme-easy-jyy/components/ContentScaleControls.vue +++ b/packages/slidev-theme-easy-jyy/components/ContentScaleControls.vue @@ -927,7 +927,8 @@ function hasExplicitCodeHeight(code: HTMLPreElement) { function availableCodeHeight(code: HTMLPreElement, region: HTMLElement) { const layout = code.closest('.slidev-layout') - if (!layout || layout.clientHeight <= 0) + const host = code.closest('.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('*').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('.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 diff --git a/packages/slidev-theme-easy-jyy/styles/base.css b/packages/slidev-theme-easy-jyy/styles/base.css index 6975ab3..a656099 100644 --- a/packages/slidev-theme-easy-jyy/styles/base.css +++ b/packages/slidev-theme-easy-jyy/styles/base.css @@ -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; diff --git a/tests/core.test.ts b/tests/core.test.ts index 2a047cd..3588141 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -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') diff --git a/tests/e2e/presenter.spec.ts b/tests/e2e/presenter.spec.ts index e962f65..942ecfd 100644 --- a/tests/e2e/presenter.spec.ts +++ b/tests/e2e/presenter.spec.ts @@ -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()