Files
test-repo/docs/.vitepress/theme/components/PageSlidesButton.vue
T
2026-05-06 23:39:35 +08:00

1379 lines
33 KiB
Vue

<script setup>
import 'reveal.js/reveal.css'
import { Close, Present } from '@element-plus/icons-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useData, useRoute } from 'vitepress'
const { frontmatter } = useData()
const route = useRoute()
const overlayRef = ref(null)
const revealRef = ref(null)
const slidesRef = ref(null)
const hasDocContent = ref(false)
const isOpen = ref(false)
const isPreparingSlides = ref(false)
const slideCount = ref(0)
// 这个组件只负责把当前 VitePress 文档页临时渲染为浏览器幻灯片。
// 它不修改 Markdown 源文件、路由、部署配置,也不改变页面原有阅读体验。
let deck = null
let previousBodyOverflow = ''
let openedFullscreen = false
let slideRun = 0
let openRequest = 0
const FIT_TOLERANCE = 2
const CONTENT_IMAGE_WAIT_TIMEOUT = 2500
const MIN_RENDER_FIT_ZOOM = 0.62
const MIN_OVERSIZED_BLOCK_SCALE = 0.35
const isDocPage = computed(() => {
const layout = frontmatter.value.layout
return layout !== 'home' && route.path !== '/welcome/' && !route.path.endsWith('/welcome/')
})
const getDocContent = () => {
const doc = document.querySelector('.VPDoc .vp-doc')
if (!doc) return null
const onlyChild = doc.children.length === 1 ? doc.firstElementChild : null
if (onlyChild?.querySelector?.('h1, h2, h3')) return onlyChild
return doc
}
const refreshAvailability = async () => {
await nextTick()
hasDocContent.value = Boolean(isDocPage.value && getDocContent()?.querySelector('h1, h2, h3'))
}
const waitForContentImages = async (source) => {
const pendingImages = Array.from(source.querySelectorAll('img')).filter((image) => !image.complete)
if (!pendingImages.length) return
const imageSettled = Promise.all(
pendingImages.map((image) => {
if (image.complete) return Promise.resolve()
return new Promise((resolve) => {
image.addEventListener('load', resolve, { once: true })
image.addEventListener('error', resolve, { once: true })
})
})
)
const timeout = new Promise((resolve) =>
window.setTimeout(resolve, CONTENT_IMAGE_WAIT_TIMEOUT)
)
await Promise.race([imageSettled, timeout])
}
const isHeading = (node, level) => node.tagName?.toLowerCase() === `h${level}`
const isPrimarySlideHeading = (node) => ['h1', 'h2', 'h3'].includes(node.tagName?.toLowerCase())
// 复制正文节点时保留表单、details、canvas 等运行时状态,避免幻灯片内容退回初始态。
const removeIds = (root) => {
if (root.id) root.removeAttribute('id')
root.querySelectorAll?.('[id]').forEach((node) => node.removeAttribute('id'))
}
const removeHeaderAnchors = (root) => {
root
.querySelectorAll?.('a.header-anchor, a[aria-label^="Permalink"]')
.forEach((node) => node.remove())
}
const createContinuationHeading = (heading) => {
if (!heading) return null
const clone = heading.cloneNode(true)
removeIds(clone)
removeHeaderAnchors(clone)
clone.classList.add('ev-slide-continuation-heading')
const label = document.createElement('span')
label.className = 'ev-slide-continuation-label'
label.textContent = '续页'
clone.appendChild(label)
return clone
}
const syncClonedState = (source, clone) => {
const sourceNodes = [source, ...(source.querySelectorAll?.('*') ?? [])]
const clonedNodes = [clone, ...(clone.querySelectorAll?.('*') ?? [])]
sourceNodes.forEach((sourceNode, index) => {
const clonedNode = clonedNodes[index]
if (!clonedNode) return
const tagName = sourceNode.tagName?.toLowerCase()
if (tagName === 'canvas') {
clonedNode.width = sourceNode.width
clonedNode.height = sourceNode.height
try {
clonedNode.getContext?.('2d')?.drawImage(sourceNode, 0, 0)
} catch {
// Some canvases are tainted or WebGL-backed; keep the structural clone in those cases.
}
return
}
if (tagName === 'input') {
if (sourceNode.type === 'checkbox' || sourceNode.type === 'radio') {
clonedNode.checked = sourceNode.checked
} else {
clonedNode.value = sourceNode.value
}
return
}
if (tagName === 'textarea' || tagName === 'select') {
clonedNode.value = sourceNode.value
return
}
if (tagName === 'details') {
clonedNode.open = sourceNode.open
}
})
}
const cloneContentNode = (node, options = {}) => {
const clone = node.cloneNode(true)
syncClonedState(node, clone)
if (options.stripIds) removeIds(clone)
return clone
}
const createMeasuredClone = (node) => cloneContentNode(node, { stripIds: true })
const createMeasureContext = (size) => {
const container = document.createElement('div')
container.className = 'ev-page-slides ev-slide-measure'
container.style.width = `${size.width}px`
container.style.height = `${size.height}px`
const page = document.createElement('article')
page.className = 'ev-slide-page vp-doc'
container.appendChild(page)
document.body.appendChild(container)
return {
container,
page,
destroy: () => container.remove()
}
}
const hasGeneratedVisual = (nodes) =>
nodes.some((node) => node.classList?.contains('ev-slide-visual-card'))
const hasVisualMedia = (nodes) =>
nodes.some((node) => {
const tagName = node.tagName?.toLowerCase()
if (['img', 'video', 'canvas', 'svg', 'table', 'pre'].includes(tagName)) return true
return Boolean(node.querySelector?.('img, video, canvas, svg, table, pre'))
})
const getSlideTextLength = (nodes) =>
nodes.reduce((total, node) => total + (node.textContent || '').trim().length, 0)
const renderMeasurePage = (measureContext, nodes) => {
measureContext.page.classList.toggle('ev-slide-page--with-visual', hasGeneratedVisual(nodes))
measureContext.page.replaceChildren(...nodes.map(createMeasuredClone))
}
const doesPageFit = (measureContext, nodes) => {
renderMeasurePage(measureContext, nodes)
return (
measureContext.page.scrollHeight <= measureContext.page.clientHeight + FIT_TOLERANCE &&
measureContext.page.scrollWidth <= measureContext.page.clientWidth + FIT_TOLERANCE
)
}
const splitListNode = (node) => {
const children = Array.from(node.children)
if (children.length <= 1) return [node]
return children.map((child) => {
const list = node.cloneNode(false)
list.appendChild(cloneContentNode(child))
return list
})
}
const splitTableNode = (node) => {
const tagName = node.tagName?.toLowerCase()
const table = tagName === 'table' ? node : node.querySelector?.(':scope table')
if (!table) return [node]
const hasOnlyTableContent =
table === node ||
Array.from(node.children).every((child) => child === table || child.contains(table))
if (!hasOnlyTableContent) return [node]
const rows = Array.from(table.querySelectorAll(':scope > tbody > tr'))
const fallbackRows = rows.length ? rows : Array.from(table.querySelectorAll(':scope > tr'))
if (fallbackRows.length <= 1) return [node]
const caption = table.querySelector(':scope > caption')
const colGroups = Array.from(table.querySelectorAll(':scope > colgroup'))
const thead = table.querySelector(':scope > thead')
const tablePieces = fallbackRows.map((row) => {
const tablePiece = table.cloneNode(false)
if (caption) tablePiece.appendChild(cloneContentNode(caption))
colGroups.forEach((colGroup) => tablePiece.appendChild(cloneContentNode(colGroup)))
if (thead) tablePiece.appendChild(cloneContentNode(thead))
const tbody = document.createElement('tbody')
tbody.appendChild(cloneContentNode(row))
tablePiece.appendChild(tbody)
return tablePiece
})
if (table === node) return tablePieces
return tablePieces.map((tablePiece) => {
const wrapper = node.cloneNode(false)
wrapper.appendChild(tablePiece)
return wrapper
})
}
const splitCodeLikeNode = (node) => {
const pre = node.tagName?.toLowerCase() === 'pre' ? node : node.querySelector?.('pre')
const code = pre?.querySelector('code')
const text = code?.innerText || pre?.innerText || ''
const lines = text.split('\n')
if (!pre || lines.length <= 14) return [node]
const chunks = []
for (let index = 0; index < lines.length; index += 14) {
const wrapper = node.tagName?.toLowerCase() === 'pre' ? null : node.cloneNode(false)
const preClone = pre.cloneNode(false)
const codeClone = code ? code.cloneNode(false) : document.createElement('code')
codeClone.textContent = lines.slice(index, index + 14).join('\n')
preClone.appendChild(codeClone)
if (wrapper) {
wrapper.appendChild(preClone)
chunks.push(wrapper)
} else {
chunks.push(preClone)
}
}
return chunks
}
const getSplitPieces = (node) => {
const tagName = node.tagName?.toLowerCase()
if (tagName === 'ul' || tagName === 'ol') return splitListNode(node)
if (tagName === 'table') return splitTableNode(node)
const codePieces = splitCodeLikeNode(node)
if (codePieces.length > 1) return codePieces
return [node]
}
const createScaledBlock = (node, measureContext, prefixNodes) => {
renderMeasurePage(measureContext, [...prefixNodes, node])
const measuredNode = measureContext.page.lastElementChild
if (!measuredNode) return node
const nodeRect = measuredNode.getBoundingClientRect()
const pageRect = measureContext.page.getBoundingClientRect()
const availableHeight = Math.max(120, pageRect.bottom - nodeRect.top)
const availableWidth = Math.max(120, pageRect.width)
const scale = Math.max(
MIN_OVERSIZED_BLOCK_SCALE,
Math.min(1, (availableHeight - FIT_TOLERANCE) / nodeRect.height, availableWidth / nodeRect.width)
)
if (!Number.isFinite(scale) || scale >= 1) return node
const wrapper = document.createElement('div')
wrapper.className = 'ev-slide-scaled-block'
wrapper.style.height = `${Math.ceil(nodeRect.height * scale)}px`
const inner = document.createElement('div')
inner.className = 'ev-slide-scaled-block-inner'
inner.style.transform = `scale(${scale})`
inner.style.width = `${Math.ceil(100 / scale)}%`
inner.appendChild(cloneContentNode(node))
wrapper.appendChild(inner)
return wrapper
}
const shouldAddVisualCard = (nodes, role) => {
if (!nodes.length || hasVisualMedia(nodes)) return false
if (role === 'cover') return getSlideTextLength(nodes) <= 520
if (role === 'intro') return getSlideTextLength(nodes) <= 260
return false
}
const createSlideVisualCard = (role) => {
const card = document.createElement('figure')
card.className = `ev-slide-visual-card ev-slide-visual-card--${role}`
card.setAttribute('aria-label', 'AI 编程教学场景插图')
const panel = document.createElement('div')
panel.className = 'ev-slide-visual-panel'
const header = document.createElement('div')
header.className = 'ev-slide-visual-header'
const badge = document.createElement('span')
badge.className = 'ev-slide-visual-badge'
badge.textContent = role === 'cover' ? 'AI Coding' : 'Mini Lesson'
const title = document.createElement('strong')
title.textContent = role === 'cover' ? '从想法到原型' : '一节课一个主题'
header.append(badge, title)
const stage = document.createElement('div')
stage.className = 'ev-slide-visual-stage'
const promptCard = document.createElement('div')
promptCard.className = 'ev-slide-visual-note ev-slide-visual-note--prompt'
promptCard.innerHTML = '<span>说清目标</span><b>Prompt</b>'
const aiCard = document.createElement('div')
aiCard.className = 'ev-slide-visual-note ev-slide-visual-note--ai'
aiCard.innerHTML = '<span>AI 协作</span><b>Generate</b>'
const resultCard = document.createElement('div')
resultCard.className = 'ev-slide-visual-note ev-slide-visual-note--result'
resultCard.innerHTML = '<span>运行验证</span><b>Demo</b>'
const connector = document.createElement('div')
connector.className = 'ev-slide-visual-connector'
const screen = document.createElement('div')
screen.className = 'ev-slide-visual-screen'
screen.innerHTML = `
<div class="ev-slide-visual-window">
<span></span><span></span><span></span>
</div>
<div class="ev-slide-visual-code-row ev-slide-visual-code-row--wide"></div>
<div class="ev-slide-visual-code-row"></div>
<div class="ev-slide-visual-code-row ev-slide-visual-code-row--short"></div>
<div class="ev-slide-visual-preview">
<span></span><span></span><span></span><span></span>
</div>
`
stage.append(promptCard, connector, aiCard, resultCard, screen)
const footer = document.createElement('figcaption')
footer.className = 'ev-slide-visual-caption'
footer.textContent = '把自然语言转成可演示的网页、小游戏和应用原型'
panel.append(header, stage, footer)
card.appendChild(panel)
return card
}
const decorateSparseSlide = (nodes, role) => {
if (!shouldAddVisualCard(nodes, role)) return nodes
return [...nodes, createSlideVisualCard(role)]
}
const paginateLogicalSlide = (nodes, measureContext) => {
if (!nodes.length) return []
// 先按 h2/h3 切出逻辑页,再用隐藏测量容器分页;超长表格、列表和代码块会继续拆分或缩放。
const primaryHeading = nodes.find(isPrimarySlideHeading)
const pages = []
let currentNodes = []
let pageNumber = 1
const currentBaseNodeCount = () => {
if (pageNumber > 1 && primaryHeading) return 1
if (pageNumber === 1 && currentNodes[0] === primaryHeading) return 1
return 0
}
const currentHasBodyContent = () => currentNodes.length > currentBaseNodeCount()
const startContinuationPage = () => {
pageNumber += 1
const heading = createContinuationHeading(primaryHeading)
currentNodes = heading ? [heading] : []
}
const commitCurrentPage = () => {
if (currentNodes.length) pages.push(currentNodes)
startContinuationPage()
}
const addNode = (node) => {
const candidateNodes = [...currentNodes, node]
if (doesPageFit(measureContext, candidateNodes)) {
currentNodes.push(node)
return
}
if (currentHasBodyContent()) {
commitCurrentPage()
addNode(node)
return
}
const pieces = getSplitPieces(node)
if (pieces.length > 1) {
pieces.forEach(addNode)
return
}
const scaledBlock = createScaledBlock(node, measureContext, currentNodes)
currentNodes.push(scaledBlock)
}
nodes.forEach(addNode)
if (currentNodes.length) pages.push(currentNodes)
return pages
}
const createSlideSection = (nodes, idMap, slideIndex) => {
const section = document.createElement('section')
section.className = 'ev-slide-section'
const page = document.createElement('article')
page.className = 'ev-slide-page vp-doc'
page.classList.toggle('ev-slide-page--with-visual', hasGeneratedVisual(nodes))
page.dataset.slideIndex = String(slideIndex)
const prefix = `ev-slide-${slideRun}-${slideIndex}-`
nodes.forEach((node) => {
const clone = cloneContentNode(node)
rewriteIds(clone, prefix, idMap)
page.appendChild(clone)
})
section.appendChild(page)
return section
}
const rewriteIds = (root, prefix, idMap) => {
const nodes = root.id ? [root, ...root.querySelectorAll('[id]')] : [...root.querySelectorAll('[id]')]
nodes.forEach((node) => {
const oldId = node.id
const newId = `${prefix}${oldId}`
idMap.set(oldId, newId)
node.id = newId
})
}
const rewriteInternalLinks = (root, idMap) => {
root.querySelectorAll('a[href^="#"]').forEach((link) => {
const oldTarget = link.getAttribute('href')?.slice(1)
if (!oldTarget || !idMap.has(oldTarget)) return
link.setAttribute('href', `#${idMap.get(oldTarget)}`)
})
}
const groupPageNodes = (source) => {
const cover = []
const sections = []
let currentH2 = null
let currentH3 = null
Array.from(source.children).forEach((node) => {
if (isHeading(node, 2)) {
currentH2 = {
intro: [node],
children: []
}
currentH3 = null
sections.push(currentH2)
return
}
if (isHeading(node, 3) && currentH2) {
currentH3 = {
nodes: [node]
}
currentH2.children.push(currentH3)
return
}
if (!currentH2) {
cover.push(node)
return
}
if (currentH3) {
currentH3.nodes.push(node)
return
}
currentH2.intro.push(node)
})
return { cover, sections }
}
const buildSlides = (source, slidesRoot, size) => {
const idMap = new Map()
const { cover, sections } = groupPageNodes(source)
const measureContext = createMeasureContext(size)
let slideIndex = 0
slidesRoot.innerHTML = ''
slideRun += 1
const appendPages = (container, pages) => {
pages.forEach((pageNodes) => {
slideIndex += 1
container.appendChild(createSlideSection(pageNodes, idMap, slideIndex))
})
}
const appendLogicalPages = (container, pages) => {
if (pages.length <= 1) {
appendPages(container, pages)
return
}
const stack = document.createElement('section')
stack.className = 'ev-slide-stack'
appendPages(stack, pages)
container.appendChild(stack)
}
try {
if (cover.length) {
appendLogicalPages(slidesRoot, paginateLogicalSlide(decorateSparseSlide(cover, 'cover'), measureContext))
}
if (!sections.length && !cover.length) {
appendLogicalPages(slidesRoot, paginateLogicalSlide(Array.from(source.children), measureContext))
}
sections.forEach((section) => {
const introPages = paginateLogicalSlide(decorateSparseSlide(section.intro, 'intro'), measureContext)
if (!section.children.length) {
appendLogicalPages(slidesRoot, introPages)
return
}
const horizontal = document.createElement('section')
horizontal.className = 'ev-slide-stack'
appendPages(horizontal, introPages)
section.children.forEach((child) => appendPages(horizontal, paginateLogicalSlide(child.nodes, measureContext)))
slidesRoot.appendChild(horizontal)
})
} finally {
measureContext.destroy()
}
rewriteInternalLinks(slidesRoot, idMap)
return slideIndex
}
const fitRenderedSlides = () => {
if (!revealRef.value) return
const pages = Array.from(revealRef.value.querySelectorAll('.ev-slide-page'))
pages.forEach((page) => {
page.style.removeProperty('zoom')
page.classList.remove('ev-slide-render-fitted')
})
pages.forEach((page) => {
if (
page.scrollHeight <= page.clientHeight + FIT_TOLERANCE &&
page.scrollWidth <= page.clientWidth + FIT_TOLERANCE
) {
return
}
const heightScale = page.clientHeight / page.scrollHeight
const widthScale = page.clientWidth / page.scrollWidth
const scale = Math.max(MIN_RENDER_FIT_ZOOM, Math.min(0.98, heightScale, widthScale) - 0.01)
page.style.zoom = String(scale)
page.classList.add('ev-slide-render-fitted')
})
}
const requestOverlayFullscreen = async () => {
const overlay = overlayRef.value
if (!overlay?.requestFullscreen || document.fullscreenElement) return
try {
await overlay.requestFullscreen()
openedFullscreen = true
} catch {
openedFullscreen = false
}
}
const getRevealSize = () => {
if (window.innerWidth <= 768) {
return {
width: window.innerWidth,
height: window.innerHeight,
margin: 0.02
}
}
return {
width: 1280,
height: 720,
margin: 0.04
}
}
const openSlides = async () => {
const source = getDocContent()
if (!source || isOpen.value) return
const size = getRevealSize()
const requestId = openRequest + 1
openRequest = requestId
isOpen.value = true
isPreparingSlides.value = true
previousBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
await nextTick()
if (!isOpen.value || requestId !== openRequest) return
await waitForContentImages(source)
await nextTick()
if (!isOpen.value || requestId !== openRequest) return
if (!slidesRef.value || !revealRef.value) {
await closeSlides()
return
}
slideCount.value = buildSlides(source, slidesRef.value, size)
const { default: Reveal } = await import('reveal.js')
deck = new Reveal(revealRef.value, {
controls: true,
progress: true,
hash: false,
history: false,
center: false,
width: size.width,
height: size.height,
margin: size.margin,
minScale: 0.2,
maxScale: 2,
transition: 'slide'
})
await deck.initialize()
fitRenderedSlides()
isPreparingSlides.value = false
void requestOverlayFullscreen().then(() => {
deck?.layout?.()
fitRenderedSlides()
})
overlayRef.value?.focus()
}
const closeSlides = async () => {
if (!isOpen.value) return
openRequest += 1
isPreparingSlides.value = false
if (deck) {
deck.destroy()
deck = null
}
if (openedFullscreen && document.fullscreenElement) {
try {
await document.exitFullscreen()
} catch {
// Keep closing the overlay even if the browser rejects fullscreen exit.
}
}
openedFullscreen = false
isOpen.value = false
isPreparingSlides.value = false
slideCount.value = 0
document.body.style.overflow = previousBodyOverflow
await nextTick()
if (slidesRef.value) slidesRef.value.innerHTML = ''
}
watch(
() => route.path,
async () => {
await closeSlides()
await refreshAvailability()
}
)
watch(isDocPage, refreshAvailability)
onMounted(refreshAvailability)
onBeforeUnmount(() => {
if (deck) deck.destroy()
document.body.style.overflow = previousBodyOverflow
})
</script>
<template>
<button
v-if="hasDocContent"
class="ev-slides-button"
type="button"
aria-label="打开幻灯片"
title="幻灯片"
@click="openSlides"
>
<el-icon :size="16">
<Present />
</el-icon>
<span class="ev-slides-button-label">幻灯片</span>
</button>
<Teleport to="body">
<div
v-if="isOpen"
ref="overlayRef"
class="ev-slides-overlay"
tabindex="-1"
role="dialog"
aria-modal="true"
aria-label="页面幻灯片播放"
@keydown.esc.stop.prevent="closeSlides"
>
<button
class="ev-slides-close"
type="button"
aria-label="关闭幻灯片"
title="关闭幻灯片"
@click="closeSlides"
>
<el-icon :size="18">
<Close />
</el-icon>
</button>
<div class="ev-slides-shell">
<div
v-if="isPreparingSlides"
class="ev-slides-loading"
role="status"
aria-live="polite"
>
<span class="ev-slides-loading-dot" aria-hidden="true" />
<span>正在生成幻灯片...</span>
</div>
<div
ref="revealRef"
class="reveal ev-page-slides"
:data-slide-count="slideCount"
>
<div ref="slidesRef" class="slides" />
</div>
</div>
</div>
</Teleport>
</template>
<style>
.ev-slides-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 32px;
min-width: 32px;
margin-left: 12px;
padding: 0 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 999px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
}
.ev-slides-button:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.ev-slides-overlay {
position: fixed;
inset: 0;
z-index: 9999;
color: #1f2937;
background: #f8fafc;
outline: none;
}
.ev-slides-shell {
position: relative;
width: 100%;
height: 100%;
}
.ev-slides-loading {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
background: #f8fafc;
color: #334155;
font-size: 18px;
font-weight: 700;
}
.ev-slides-loading-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: #2563eb;
animation: ev-slides-pulse 1s ease-in-out infinite;
}
@keyframes ev-slides-pulse {
0%,
100% {
opacity: 0.35;
transform: scale(0.86);
}
50% {
opacity: 1;
transform: scale(1);
}
}
.ev-slides-close {
position: fixed;
top: 18px;
right: 18px;
z-index: 10010;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid rgba(148, 163, 184, 0.42);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #334155;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14);
cursor: pointer;
}
.ev-slides-close:hover {
border-color: #2563eb;
color: #2563eb;
}
.ev-page-slides {
width: 100%;
height: 100%;
background: #f8fafc;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.ev-page-slides .slides {
text-align: left;
}
.ev-page-slides section {
height: 100%;
}
.ev-page-slides .ev-slide-section {
padding: 0;
background: transparent;
}
.ev-page-slides .ev-slide-page {
box-sizing: border-box;
width: 100%;
height: 100%;
max-height: 100%;
padding: 48px 58px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 18px;
background: #ffffff;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.14);
color: var(--vp-c-text-1);
font-size: 22px;
line-height: 1.62;
}
.ev-page-slides .ev-slide-page--with-visual {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
gap: 28px 38px;
align-content: start;
}
.ev-page-slides .ev-slide-page--with-visual > :not(.ev-slide-visual-card) {
grid-column: 1;
}
.ev-page-slides .ev-slide-page--with-visual > .ev-slide-visual-card {
grid-column: 2;
grid-row: 1 / span 8;
}
.ev-page-slides .ev-slide-page > :first-child {
margin-top: 0;
}
.ev-page-slides .ev-slide-page > :last-child {
margin-bottom: 0;
}
.ev-page-slides .ev-slide-page h1 {
margin-bottom: 24px;
font-size: 48px;
line-height: 1.16;
}
.ev-page-slides .ev-slide-page h2 {
margin-bottom: 22px;
font-size: 38px;
line-height: 1.2;
}
.ev-page-slides .ev-slide-page h3 {
margin-bottom: 20px;
font-size: 32px;
line-height: 1.25;
}
.ev-page-slides .ev-slide-page h4 {
font-size: 26px;
}
.ev-page-slides .ev-slide-continuation-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
color: #475569;
}
.ev-page-slides .ev-slide-continuation-label {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 9px;
border: 1px solid rgba(37, 99, 235, 0.24);
border-radius: 999px;
background: rgba(37, 99, 235, 0.08);
color: #2563eb;
font-size: 0.42em;
font-weight: 700;
line-height: 1.25;
}
.ev-page-slides .ev-slide-page p,
.ev-page-slides .ev-slide-page li,
.ev-page-slides .ev-slide-page blockquote,
.ev-page-slides .ev-slide-page td,
.ev-page-slides .ev-slide-page th {
font-size: inherit;
}
.ev-page-slides .ev-slide-page img,
.ev-page-slides .ev-slide-page video,
.ev-page-slides .ev-slide-page canvas,
.ev-page-slides .ev-slide-page svg {
max-width: 100%;
height: auto;
}
.ev-page-slides .ev-slide-page table {
display: table;
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 0.8em;
line-height: 1.42;
}
.ev-page-slides .ev-slide-page th,
.ev-page-slides .ev-slide-page td {
padding: 8px 10px;
word-break: break-word;
}
.ev-page-slides .ev-slide-page :where(.vp-adaptive-theme, [class*="language-"]) {
max-width: 100%;
overflow: hidden;
}
.ev-page-slides .ev-slide-page pre {
max-width: 100%;
max-height: 100%;
overflow: hidden;
font-size: 0.72em;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.ev-page-slides .ev-slide-page code {
font-size: 0.86em;
}
.ev-page-slides .ev-slide-page .custom-block {
margin: 16px 0;
padding: 14px 18px;
font-size: 0.92em;
line-height: 1.5;
}
.ev-page-slides .ev-slide-visual-card {
align-self: start;
box-sizing: border-box;
width: 100%;
min-height: 472px;
margin: 0;
padding: 0;
border: 1px solid rgba(30, 64, 175, 0.14);
border-radius: 20px;
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.14), rgba(20, 184, 166, 0.1)),
#ffffff;
box-shadow: 0 20px 55px rgba(15, 23, 42, 0.16);
color: #1e293b;
}
.ev-page-slides .ev-slide-visual-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: inherit;
padding: 22px;
}
.ev-page-slides .ev-slide-visual-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 24px;
}
.ev-page-slides .ev-slide-visual-header strong {
color: #0f172a;
font-size: 18px;
line-height: 1.25;
}
.ev-page-slides .ev-slide-visual-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid rgba(37, 99, 235, 0.24);
border-radius: 999px;
background: rgba(255, 255, 255, 0.76);
color: #2563eb;
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.ev-page-slides .ev-slide-visual-stage {
position: relative;
flex: 1;
min-height: 340px;
}
.ev-page-slides .ev-slide-visual-note {
position: absolute;
z-index: 2;
display: flex;
flex-direction: column;
gap: 4px;
width: 132px;
padding: 13px 14px;
border: 1px solid rgba(148, 163, 184, 0.32);
border-radius: 16px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.12);
}
.ev-page-slides .ev-slide-visual-note span {
color: #64748b;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.ev-page-slides .ev-slide-visual-note b {
color: #0f172a;
font-size: 19px;
line-height: 1.15;
}
.ev-page-slides .ev-slide-visual-note--prompt {
top: 4px;
left: 0;
}
.ev-page-slides .ev-slide-visual-note--ai {
top: 74px;
right: 4px;
border-color: rgba(20, 184, 166, 0.28);
}
.ev-page-slides .ev-slide-visual-note--result {
right: 52px;
bottom: 12px;
border-color: rgba(245, 158, 11, 0.34);
}
.ev-page-slides .ev-slide-visual-connector {
position: absolute;
top: 58px;
left: 92px;
width: 190px;
height: 118px;
border-top: 3px solid rgba(37, 99, 235, 0.28);
border-right: 3px solid rgba(20, 184, 166, 0.26);
border-radius: 0 26px 0 0;
}
.ev-page-slides .ev-slide-visual-screen {
position: absolute;
left: 8px;
right: 18px;
bottom: 48px;
z-index: 1;
min-height: 170px;
padding: 18px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 18px;
background: #0f172a;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.ev-page-slides .ev-slide-visual-window {
display: flex;
gap: 6px;
margin-bottom: 18px;
}
.ev-page-slides .ev-slide-visual-window span {
width: 9px;
height: 9px;
border-radius: 999px;
background: #38bdf8;
}
.ev-page-slides .ev-slide-visual-window span:nth-child(2) {
background: #22c55e;
}
.ev-page-slides .ev-slide-visual-window span:nth-child(3) {
background: #f59e0b;
}
.ev-page-slides .ev-slide-visual-code-row {
width: 58%;
height: 10px;
margin-bottom: 10px;
border-radius: 999px;
background: rgba(226, 232, 240, 0.7);
}
.ev-page-slides .ev-slide-visual-code-row--wide {
width: 78%;
}
.ev-page-slides .ev-slide-visual-code-row--short {
width: 42%;
}
.ev-page-slides .ev-slide-visual-preview {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 20px;
}
.ev-page-slides .ev-slide-visual-preview span {
height: 46px;
border-radius: 12px;
background: #dbeafe;
}
.ev-page-slides .ev-slide-visual-preview span:nth-child(2) {
background: #ccfbf1;
}
.ev-page-slides .ev-slide-visual-preview span:nth-child(3) {
background: #fef3c7;
}
.ev-page-slides .ev-slide-visual-preview span:nth-child(4) {
background: #e0e7ff;
}
.ev-page-slides .ev-slide-visual-caption {
margin-top: 20px;
color: #475569;
font-size: 14px;
font-weight: 700;
line-height: 1.45;
}
.ev-page-slides .controls {
color: #2563eb;
}
.ev-page-slides .progress {
color: #2563eb;
}
.ev-slide-scaled-block {
width: 100%;
overflow: hidden;
}
.ev-slide-scaled-block-inner {
transform-origin: top left;
}
.ev-slide-measure {
position: fixed;
top: 0;
left: -100000px;
z-index: -1;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.ev-slide-measure .ev-slide-page {
box-shadow: none;
}
@media (max-width: 768px) {
.ev-slides-button {
width: 32px;
margin-left: 8px;
padding: 0;
}
.ev-slides-button-label {
display: none;
}
.ev-page-slides .ev-slide-page {
padding: 34px 28px;
border-radius: 14px;
font-size: 18px;
}
.ev-page-slides .ev-slide-page--with-visual {
display: block;
}
.ev-page-slides .ev-slide-visual-card {
min-height: 260px;
margin-top: 22px;
}
.ev-page-slides .ev-slide-visual-panel {
padding: 16px;
}
.ev-page-slides .ev-slide-visual-header {
margin-bottom: 14px;
}
.ev-page-slides .ev-slide-visual-header strong,
.ev-page-slides .ev-slide-visual-caption {
display: none;
}
.ev-page-slides .ev-slide-visual-stage {
min-height: 214px;
}
.ev-page-slides .ev-slide-visual-note {
width: 112px;
padding: 10px;
}
.ev-page-slides .ev-slide-visual-note b {
font-size: 15px;
}
.ev-page-slides .ev-slide-visual-screen {
right: 4px;
bottom: 4px;
min-height: 108px;
padding: 12px;
}
.ev-page-slides .ev-slide-visual-preview span {
height: 28px;
}
.ev-page-slides .ev-slide-page h1 {
font-size: 34px;
}
.ev-page-slides .ev-slide-page h2 {
font-size: 28px;
}
.ev-page-slides .ev-slide-page h3 {
font-size: 24px;
}
}
</style>