build: add precompiled CLI distribution

This commit is contained in:
2026-08-29 19:00:14 +08:00
parent d074681928
commit 4d1dc76e31
77 changed files with 3461 additions and 0 deletions
@@ -0,0 +1,699 @@
<script setup lang="ts">
import { sharedState, useNav } from '@slidev/client'
import { onBeforeUnmount, onMounted, watch } from 'vue'
type ScalableKind = 'table' | 'code'
interface ScaleMessage {
key: string
kind: ScalableKind
scale: number
}
interface TableBaseStyle {
tableFontSize: number
tableInlineFontSize: string
cells: Array<{
element: HTMLElement
inlinePaddingBottom: string
inlinePaddingLeft: string
inlinePaddingRight: string
inlinePaddingTop: string
paddingBottom: number
paddingLeft: number
paddingRight: number
paddingTop: number
}>
}
interface CodeBaseStyle {
codeFontSize: number
createdHost: boolean
}
interface ImageInteraction {
ariaLabel: string | null
role: string | null
tabIndex: string | null
click: (event: MouseEvent) => void
keydown: (event: KeyboardEvent) => void
}
interface ImagePreviewRequest {
alt: string
id: string
sentAt: number
src: string
}
interface ImagePreviewAcknowledgement {
requestId: string
sentAt: number
viewerId: string
}
interface EasySlidesSharedState {
easySlidesImagePreview?: {
acknowledgement?: ImagePreviewAcknowledgement
request?: ImagePreviewRequest
}
}
interface PendingImagePreview {
cleanupTimer?: ReturnType<typeof setTimeout>
fallbackOpened: boolean
fallbackTimer: ReturnType<typeof setTimeout>
image: HTMLImageElement
requestId: string
}
const nav = useNav()
const easySharedState = sharedState as typeof sharedState & EasySlidesSharedState
const clientId = createClientId()
const mountedAt = Date.now()
const scales = new Map<string, number>()
const tableStyles = new WeakMap<HTMLTableElement, TableBaseStyle>()
const codeStyles = new WeakMap<HTMLPreElement, CodeBaseStyle>()
const imageInteractions = new Map<HTMLImageElement, ImageInteraction>()
let observer: MutationObserver | undefined
let resizeObserver: ResizeObserver | undefined
let channel: BroadcastChannel | undefined
let lightbox: HTMLElement | undefined
let lightboxImage: HTMLImageElement | undefined
let lightboxClose: HTMLButtonElement | undefined
let activeImage: HTMLImageElement | undefined
let pendingImagePreview: PendingImagePreview | undefined
let stopAcknowledgementWatch: (() => void) | undefined
let stopRequestWatch: (() => void) | undefined
let fitFrame: number | undefined
let lastHandledRequestId = easySharedState.easySlidesImagePreview?.request?.id ?? ''
let bodyOverflow = ''
function createClientId() {
if (typeof crypto.randomUUID === 'function')
return crypto.randomUUID()
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
function isInteractiveRoute() {
if (/(?:^|\/)(?:overview|export|editor|notes|notes-edit|print)(?:\/|$)/.test(window.location.pathname))
return false
return nav.isPresenter.value || nav.hasPrimarySlide.value
}
function isAuditRoute() {
return new URLSearchParams(window.location.search).has('easy-slides-audit')
}
function isProjectionRoute() {
return !isAuditRoute() && isInteractiveRoute() && !nav.isPresenter.value && nav.hasPrimarySlide.value
}
function clampScale(scale: number) {
return Math.min(1.6, Math.max(0.6, Math.round(scale * 10) / 10))
}
function findSlideNumber(element: Element) {
const page = element.closest<HTMLElement>('[class*="slidev-page-"]')
const pageClass = page && Array.from(page.classList).find(className => /^slidev-page-\d+$/.test(className))
return pageClass?.replace('slidev-page-', '') ?? 'unknown'
}
function contentKey(element: Element, kind: ScalableKind) {
const layout = element.closest('.slidev-layout')
const selector = kind === 'table' ? 'table' : 'pre'
const elements = layout ? Array.from(layout.querySelectorAll(selector)) : [element]
const index = Math.max(0, elements.indexOf(element))
return `slide:${findSlideNumber(element)}:${kind}:${index}`
}
function createButton(label: string, text: string, onClick: () => void) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'easy-content-scale-button'
button.setAttribute('aria-label', label)
button.title = label
button.textContent = text
button.addEventListener('pointerdown', event => event.stopPropagation())
button.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
onClick()
})
return button
}
function kindLabel(kind: ScalableKind) {
return kind === 'table' ? '表格' : '代码'
}
function createToolbar(key: string, kind: ScalableKind) {
const label = kindLabel(kind)
const toolbar = document.createElement('nav')
toolbar.className = 'easy-content-scale-toolbar'
toolbar.setAttribute('aria-label', `${label}缩放`)
toolbar.dataset.easyScaleKey = key
toolbar.dataset.easyScaleKind = kind
const decrease = createButton(`缩小${label}`, '', () => updateScale(key, kind, currentScale(key) - 0.1))
const reset = createButton(`重置${label}缩放`, '100%', () => updateScale(key, kind, 1))
reset.classList.add('easy-content-scale-reset')
const increase = createButton(`放大${label}`, '+', () => updateScale(key, kind, currentScale(key) + 0.1))
toolbar.append(decrease, reset, increase)
return toolbar
}
function currentScale(key: string) {
return scales.get(key) ?? 1
}
function updateScale(key: string, kind: ScalableKind, requestedScale: number, shouldBroadcast = true) {
const scale = clampScale(requestedScale)
scales.set(key, scale)
applyScale(key, kind, scale)
if (shouldBroadcast)
channel?.postMessage({ key, kind, scale } satisfies ScaleMessage)
}
function updateResetButton(host: HTMLElement, scale: number) {
const reset = host.querySelector<HTMLButtonElement>('.easy-content-scale-reset')
if (!reset)
return
const percentage = `${Math.round(scale * 100)}%`
reset.textContent = percentage
reset.title = `${reset.getAttribute('aria-label')}(当前 ${percentage}`
}
function applyScale(key: string, kind: ScalableKind, scale: number) {
const hostSelector = kind === 'table' ? '.easy-content-table-host' : '.easy-content-code-host'
document.querySelectorAll<HTMLElement>(hostSelector).forEach((host) => {
if (host.dataset.easyScaleKey !== key)
return
updateResetButton(host, scale)
if (kind === 'code') {
const code = host.querySelector<HTMLPreElement>('pre')
const base = code && codeStyles.get(code)
if (!code || !base)
return
if (scale === 1)
host.style.removeProperty('--easy-code-scaled-font-size')
else
host.style.setProperty('--easy-code-scaled-font-size', `${base.codeFontSize * scale}px`)
return
}
const table = host.querySelector<HTMLTableElement>('table')
const base = table && tableStyles.get(table)
if (!table || !base)
return
if (scale === 1) {
table.style.fontSize = base.tableInlineFontSize
base.cells.forEach((cell) => {
cell.element.style.paddingTop = cell.inlinePaddingTop
cell.element.style.paddingRight = cell.inlinePaddingRight
cell.element.style.paddingBottom = cell.inlinePaddingBottom
cell.element.style.paddingLeft = cell.inlinePaddingLeft
})
return
}
table.style.fontSize = `${base.tableFontSize * scale}px`
base.cells.forEach((cell) => {
cell.element.style.paddingTop = `${cell.paddingTop * scale}px`
cell.element.style.paddingRight = `${cell.paddingRight * scale}px`
cell.element.style.paddingBottom = `${cell.paddingBottom * scale}px`
cell.element.style.paddingLeft = `${cell.paddingLeft * scale}px`
})
})
}
function createLightbox() {
const overlay = document.createElement('div')
overlay.className = 'easy-image-lightbox'
overlay.hidden = true
overlay.setAttribute('role', 'dialog')
overlay.setAttribute('aria-modal', 'true')
overlay.setAttribute('aria-label', '图片全屏预览')
const preview = document.createElement('img')
preview.className = 'easy-image-lightbox-preview'
preview.alt = ''
const close = document.createElement('button')
close.type = 'button'
close.className = 'easy-image-lightbox-close'
close.setAttribute('aria-label', '关闭图片全屏预览')
close.title = '关闭(Esc'
close.textContent = '×'
close.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
closeLightbox()
})
overlay.addEventListener('pointerdown', event => event.stopPropagation())
overlay.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
if (event.target === overlay)
closeLightbox()
})
overlay.append(preview, close)
document.body.append(overlay)
lightbox = overlay
lightboxImage = preview
lightboxClose = close
}
function showLightbox(src: string, alt: string, image?: HTMLImageElement) {
if (!lightbox || !lightboxImage || !lightboxClose)
return
const wasHidden = lightbox.hidden
activeImage = image
lightboxImage.src = src
lightboxImage.alt = alt
lightbox.hidden = false
if (wasHidden)
bodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleLightboxKeydown, true)
lightboxClose.focus()
}
function openLightbox(image: HTMLImageElement) {
showLightbox(image.currentSrc || image.src, image.alt, image)
}
function closeLightbox(restoreFocus = true) {
if (!lightbox || lightbox.hidden)
return
document.removeEventListener('keydown', handleLightboxKeydown, true)
lightbox.hidden = true
if (lightboxImage)
lightboxImage.removeAttribute('src')
document.body.style.overflow = bodyOverflow
if (restoreFocus)
activeImage?.focus()
activeImage = undefined
}
function handleLightboxKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeLightbox()
}
else if (event.key === 'Tab') {
event.preventDefault()
lightboxClose?.focus()
}
}
function updateImagePreviewState(patch: NonNullable<EasySlidesSharedState['easySlidesImagePreview']>) {
easySharedState.easySlidesImagePreview = {
...easySharedState.easySlidesImagePreview,
...patch,
}
}
function clearPendingImagePreview() {
if (!pendingImagePreview)
return
clearTimeout(pendingImagePreview.fallbackTimer)
if (pendingImagePreview.cleanupTimer)
clearTimeout(pendingImagePreview.cleanupTimer)
pendingImagePreview = undefined
}
function requestProjectionLightbox(image: HTMLImageElement) {
clearPendingImagePreview()
const request: ImagePreviewRequest = {
alt: image.alt,
id: createClientId(),
sentAt: Date.now(),
src: image.currentSrc || image.src,
}
const pending: PendingImagePreview = {
fallbackOpened: false,
fallbackTimer: setTimeout(() => {
if (pendingImagePreview?.requestId !== request.id)
return
pendingImagePreview.fallbackOpened = true
openLightbox(image)
pendingImagePreview.cleanupTimer = setTimeout(() => {
if (pendingImagePreview?.requestId === request.id)
pendingImagePreview = undefined
}, 10_000)
}, 500),
image,
requestId: request.id,
}
pendingImagePreview = pending
updateImagePreviewState({ request })
}
function activateImage(image: HTMLImageElement) {
if (nav.isPresenter.value)
requestProjectionLightbox(image)
else
openLightbox(image)
}
function handleImagePreviewAcknowledgement(acknowledgement: ImagePreviewAcknowledgement | undefined) {
if (!acknowledgement || !pendingImagePreview || acknowledgement.requestId !== pendingImagePreview.requestId)
return
const pending = pendingImagePreview
clearPendingImagePreview()
if (pending.fallbackOpened && activeImage === pending.image)
closeLightbox(false)
}
function handleImagePreviewRequest(request: ImagePreviewRequest | undefined) {
if (!request || !isProjectionRoute() || request.id === lastHandledRequestId)
return
if (request.sentAt < mountedAt - 100 || Date.now() - request.sentAt > 10_000)
return
lastHandledRequestId = request.id
showLightbox(request.src, request.alt)
updateImagePreviewState({
acknowledgement: {
requestId: request.id,
sentAt: Date.now(),
viewerId: clientId,
},
})
}
function enhanceImage(image: HTMLImageElement) {
if (imageInteractions.has(image) || image.closest('a, button') || image.closest('.easy-image-lightbox'))
return
const click = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
activateImage(image)
}
const keydown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== ' ')
return
event.preventDefault()
event.stopPropagation()
activateImage(image)
}
imageInteractions.set(image, {
ariaLabel: image.getAttribute('aria-label'),
role: image.getAttribute('role'),
tabIndex: image.getAttribute('tabindex'),
click,
keydown,
})
image.classList.add('easy-image-preview-trigger')
image.tabIndex = 0
image.setAttribute('role', 'button')
if (!image.hasAttribute('aria-label'))
image.setAttribute('aria-label', image.alt ? `全屏查看图片:${image.alt}` : '全屏查看图片')
image.addEventListener('click', click)
image.addEventListener('keydown', keydown)
}
function enhanceTable(table: HTMLTableElement) {
if (table.closest('.easy-content-table-host'))
return
const key = contentKey(table, 'table')
const cells = Array.from(table.querySelectorAll<HTMLElement>('th, td')).map((element) => {
const style = getComputedStyle(element)
return {
element,
inlinePaddingBottom: element.style.paddingBottom,
inlinePaddingLeft: element.style.paddingLeft,
inlinePaddingRight: element.style.paddingRight,
inlinePaddingTop: element.style.paddingTop,
paddingBottom: Number.parseFloat(style.paddingBottom),
paddingLeft: Number.parseFloat(style.paddingLeft),
paddingRight: Number.parseFloat(style.paddingRight),
paddingTop: Number.parseFloat(style.paddingTop),
}
})
tableStyles.set(table, {
tableFontSize: Number.parseFloat(getComputedStyle(table).fontSize),
tableInlineFontSize: table.style.fontSize,
cells,
})
const host = document.createElement('div')
host.className = 'easy-content-scale-host easy-content-table-host'
host.dataset.easyScaleKey = key
host.dataset.easyScaleKind = 'table'
table.replaceWith(host)
host.append(table, createToolbar(key, 'table'))
applyScale(key, 'table', currentScale(key))
}
function enhanceCode(code: HTMLPreElement) {
if (code.closest('.easy-content-code-host'))
return
const key = contentKey(code, 'code')
let host = code.closest<HTMLElement>('.slidev-code-wrapper')
const createdHost = !host
if (!host) {
host = document.createElement('div')
code.replaceWith(host)
host.append(code)
}
host.classList.add('easy-content-scale-host', 'easy-content-code-host')
host.dataset.easyScaleKey = key
host.dataset.easyScaleKind = 'code'
host.dataset.easyCodeHostCreated = createdHost ? 'true' : 'false'
codeStyles.set(code, {
codeFontSize: Number.parseFloat(getComputedStyle(code).fontSize),
createdHost,
})
host.append(createToolbar(key, 'code'))
applyScale(key, 'code', currentScale(key))
resizeObserver?.observe(code.closest('.slidev-layout') ?? host)
scheduleCodeHeightFit()
}
function enhanceContent(root: ParentNode = document) {
const images = Array.from(root.querySelectorAll<HTMLImageElement>('.slidev-layout img'))
const tables = Array.from(root.querySelectorAll<HTMLTableElement>('.slidev-layout table'))
const codes = Array.from(root.querySelectorAll<HTMLPreElement>('.slidev-layout pre'))
if (root instanceof HTMLImageElement && root.closest('.slidev-layout'))
images.unshift(root)
if (root instanceof HTMLTableElement && root.closest('.slidev-layout'))
tables.unshift(root)
if (root instanceof HTMLPreElement && root.closest('.slidev-layout'))
codes.unshift(root)
images.forEach(enhanceImage)
tables.forEach(enhanceTable)
codes.forEach(enhanceCode)
}
function codeHeightRegion(code: HTMLPreElement) {
return code.closest<HTMLElement>('.easy-two-cols-grid > div')
?? code.closest<HTMLElement>('.slidev-layout')
}
function hasExplicitCodeHeight(code: HTMLPreElement) {
if (code.style.height || code.style.maxHeight)
return true
return Boolean(getComputedStyle(code).getPropertyValue('--easy-code-height').trim())
}
function availableCodeHeight(code: HTMLPreElement, region: HTMLElement) {
const layout = code.closest<HTMLElement>('.slidev-layout')
if (!layout || layout.clientHeight <= 0)
return 0
const layoutRect = layout.getBoundingClientRect()
const regionRect = region.getBoundingClientRect()
const codeRect = code.getBoundingClientRect()
if (layoutRect.height <= 0 || regionRect.height <= 0 || codeRect.width <= 0)
return 0
const scale = layoutRect.height / layout.clientHeight
if (!Number.isFinite(scale) || scale <= 0)
return 0
const regionPaddingBottom = Number.parseFloat(getComputedStyle(region).paddingBottom) || 0
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))
}
function setHostLength(host: HTMLElement, property: string, value?: number) {
const next = value && value > 0 ? `${value}px` : ''
if (host.style.getPropertyValue(property) === next)
return
if (next)
host.style.setProperty(property, next)
else
host.style.removeProperty(property)
}
function fitCodeHeights() {
const groups = new Map<HTMLElement, HTMLPreElement[]>()
document.querySelectorAll<HTMLPreElement>('.easy-content-code-host pre').forEach((code) => {
const host = code.closest<HTMLElement>('.easy-content-code-host')
if (!host)
return
if (hasExplicitCodeHeight(code)) {
setHostLength(host, '--easy-code-auto-height')
setHostLength(host, '--easy-code-auto-max-height')
return
}
const region = codeHeightRegion(code)
if (!region)
return
const group = groups.get(region) ?? []
group.push(code)
groups.set(region, group)
})
groups.forEach((codes, region) => {
const single = codes.length === 1
codes.forEach((code) => {
const host = code.closest<HTMLElement>('.easy-content-code-host')
if (!host)
return
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)
})
})
}
function scheduleCodeHeightFit() {
if (fitFrame !== undefined)
return
fitFrame = requestAnimationFrame(() => {
fitFrame = undefined
fitCodeHeights()
})
}
function cleanupImage(image: HTMLImageElement, interaction: ImageInteraction) {
image.removeEventListener('click', interaction.click)
image.removeEventListener('keydown', interaction.keydown)
image.classList.remove('easy-image-preview-trigger')
restoreAttribute(image, 'aria-label', interaction.ariaLabel)
restoreAttribute(image, 'role', interaction.role)
restoreAttribute(image, 'tabindex', interaction.tabIndex)
}
function restoreAttribute(element: Element, name: string, value: string | null) {
if (value === null)
element.removeAttribute(name)
else
element.setAttribute(name, value)
}
function cleanupTableHosts() {
document.querySelectorAll<HTMLElement>('.easy-content-table-host').forEach((host) => {
const table = host.querySelector<HTMLTableElement>('table')
const base = table && tableStyles.get(table)
if (!table)
return
if (base) {
table.style.fontSize = base.tableInlineFontSize
base.cells.forEach((cell) => {
cell.element.style.paddingTop = cell.inlinePaddingTop
cell.element.style.paddingRight = cell.inlinePaddingRight
cell.element.style.paddingBottom = cell.inlinePaddingBottom
cell.element.style.paddingLeft = cell.inlinePaddingLeft
})
}
host.replaceWith(table)
})
}
function cleanupCodeHosts() {
document.querySelectorAll<HTMLElement>('.easy-content-code-host').forEach((host) => {
const code = host.querySelector<HTMLPreElement>('pre')
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
delete host.dataset.easyScaleKind
delete host.dataset.easyCodeHostCreated
if (code && base?.createdHost)
host.replaceWith(code)
})
}
function cleanupContent() {
clearPendingImagePreview()
closeLightbox()
imageInteractions.forEach((interaction, image) => cleanupImage(image, interaction))
imageInteractions.clear()
lightbox?.remove()
lightbox = undefined
lightboxImage = undefined
lightboxClose = undefined
cleanupTableHosts()
cleanupCodeHosts()
}
function isScaleMessage(value: unknown): value is ScaleMessage {
if (!value || typeof value !== 'object')
return false
const message = value as Partial<ScaleMessage>
return typeof message.key === 'string'
&& (message.kind === 'table' || message.kind === 'code')
&& typeof message.scale === 'number'
&& Number.isFinite(message.scale)
}
onMounted(() => {
if (!isInteractiveRoute())
return
createLightbox()
if ('BroadcastChannel' in window) {
channel = new BroadcastChannel(`easy-slides:content-scale:${import.meta.env.BASE_URL}`)
channel.addEventListener('message', (event) => {
if (isScaleMessage(event.data))
updateScale(event.data.key, event.data.kind, event.data.scale, false)
})
}
stopAcknowledgementWatch = watch(
() => easySharedState.easySlidesImagePreview?.acknowledgement,
handleImagePreviewAcknowledgement,
)
stopRequestWatch = watch(
() => easySharedState.easySlidesImagePreview?.request,
handleImagePreviewRequest,
)
resizeObserver = new ResizeObserver(scheduleCodeHeightFit)
enhanceContent()
document.querySelectorAll<HTMLElement>('.slidev-layout').forEach(layout => resizeObserver?.observe(layout))
observer = new MutationObserver((records) => {
records.forEach(record => enhanceContent(record.target as ParentNode))
scheduleCodeHeightFit()
})
observer.observe(document.body, { childList: true, subtree: true })
window.addEventListener('resize', scheduleCodeHeightFit)
void document.fonts?.ready.then(scheduleCodeHeightFit)
scheduleCodeHeightFit()
})
onBeforeUnmount(() => {
observer?.disconnect()
resizeObserver?.disconnect()
channel?.close()
stopAcknowledgementWatch?.()
stopRequestWatch?.()
window.removeEventListener('resize', scheduleCodeHeightFit)
if (fitFrame !== undefined)
cancelAnimationFrame(fitFrame)
cleanupContent()
})
</script>
<template>
<span hidden aria-hidden="true" />
</template>