Files
easy-slides/packages/slidev-theme-easy-jyy/components/ContentScaleControls.vue
T

700 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>