Files
yulonger e23fa413be
Engine CI / test (push) Waiting to run
feat: sync interactive image previews
2026-08-30 22:20:30 +08:00

1145 lines
38 KiB
Vue
Raw Permalink 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 ImagePreviewView {
centerX: number
centerY: number
scale: number
}
interface ImagePreviewCommandBase {
id: string
requestId: string
sentAt: number
viewerId: string
}
interface ImagePreviewCloseCommand extends ImagePreviewCommandBase {
type: 'close'
}
interface ImagePreviewViewCommand extends ImagePreviewCommandBase {
type: 'view'
view: ImagePreviewView
}
type ImagePreviewCommand = ImagePreviewCloseCommand | ImagePreviewViewCommand
interface EasySlidesSharedState {
easySlidesImagePreview?: {
acknowledgement?: ImagePreviewAcknowledgement
command?: ImagePreviewCommand
request?: ImagePreviewRequest
}
}
interface PendingImagePreview {
cleanupTimer?: ReturnType<typeof setTimeout>
fallbackOpened: boolean
fallbackTimer: ReturnType<typeof setTimeout>
image: HTMLImageElement
requestId: string
}
interface ImagePointer {
x: number
y: number
}
const IMAGE_MIN_SCALE = 0.1
const IMAGE_MAX_SCALE = 4
const IMAGE_SCALE_STEP = 0.1
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 imageChannel: BroadcastChannel | undefined
let lightbox: HTMLElement | undefined
let lightboxViewport: HTMLElement | undefined
let lightboxImage: HTMLImageElement | undefined
let lightboxClose: HTMLButtonElement | undefined
let lightboxDecrease: HTMLButtonElement | undefined
let lightboxIncrease: HTMLButtonElement | undefined
let lightboxScaleOutput: HTMLOutputElement | undefined
let activeImage: HTMLImageElement | undefined
let activePreviewRequestId: string | undefined
let pendingImagePreview: PendingImagePreview | undefined
let stopAcknowledgementWatch: (() => void) | undefined
let stopCommandWatch: (() => void) | undefined
let stopRequestWatch: (() => void) | undefined
let fitFrame: number | undefined
let imageViewFrame: number | undefined
let pendingSharedView: ImagePreviewView | undefined
let pendingRemoteView: ImagePreviewView | undefined
let lastHandledRequestId = easySharedState.easySlidesImagePreview?.request?.id ?? ''
let lastHandledCommandId = easySharedState.easySlidesImagePreview?.command?.id ?? ''
let bodyOverflow = ''
let imageScale = 1
let imageCenterX = 0.5
let imageCenterY = 0.5
let imageViewTouched = false
let lightboxPointerMoved = false
let pinchDistance = 0
let pinchMidpoint: ImagePointer | undefined
const imagePointers = new Map<number, ImagePointer>()
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 clampImageScale(scale: number) {
return Math.min(IMAGE_MAX_SCALE, Math.max(IMAGE_MIN_SCALE, Math.round(scale * 10_000) / 10_000))
}
function imageGeometry(scale = imageScale) {
if (!lightboxViewport || !lightboxImage || !lightboxImage.naturalWidth || !lightboxImage.naturalHeight)
return undefined
return {
imageHeight: lightboxImage.naturalHeight * scale,
imageWidth: lightboxImage.naturalWidth * scale,
viewportHeight: lightboxViewport.clientHeight,
viewportWidth: lightboxViewport.clientWidth,
}
}
function clampImageCenter(center: number, imageSize: number, viewportSize: number) {
if (imageSize <= viewportSize || imageSize <= 0)
return 0.5
const edge = viewportSize / (2 * imageSize)
return Math.min(1 - edge, Math.max(edge, center))
}
function currentImageView(): ImagePreviewView {
return {
centerX: imageCenterX,
centerY: imageCenterY,
scale: imageScale,
}
}
function renderImageView() {
if (!lightboxImage || !lightboxViewport)
return
const geometry = imageGeometry()
if (!geometry)
return
imageCenterX = clampImageCenter(imageCenterX, geometry.imageWidth, geometry.viewportWidth)
imageCenterY = clampImageCenter(imageCenterY, geometry.imageHeight, geometry.viewportHeight)
lightboxImage.style.width = `${geometry.imageWidth}px`
lightboxImage.style.height = `${geometry.imageHeight}px`
lightboxImage.style.transform = `translate(${-imageCenterX * 100}%, ${-imageCenterY * 100}%)`
lightboxImage.dataset.easyImageScale = String(imageScale)
lightboxImage.dataset.easyImageCenterX = String(imageCenterX)
lightboxImage.dataset.easyImageCenterY = String(imageCenterY)
lightboxViewport.classList.toggle(
'easy-image-lightbox-pannable',
geometry.imageWidth > geometry.viewportWidth + 1 || geometry.imageHeight > geometry.viewportHeight + 1,
)
const percentage = `${Math.round(imageScale * 100)}%`
if (lightboxScaleOutput) {
lightboxScaleOutput.value = percentage
lightboxScaleOutput.textContent = percentage
lightboxScaleOutput.title = `当前图片缩放 ${percentage}`
}
if (lightboxDecrease)
lightboxDecrease.disabled = imageScale <= IMAGE_MIN_SCALE
if (lightboxIncrease)
lightboxIncrease.disabled = imageScale >= IMAGE_MAX_SCALE
}
function broadcastImageView() {
if (!activePreviewRequestId)
return
pendingSharedView = currentImageView()
if (imageViewFrame !== undefined)
return
imageViewFrame = requestAnimationFrame(() => {
imageViewFrame = undefined
const view = pendingSharedView
pendingSharedView = undefined
if (!activePreviewRequestId || !view)
return
const command: ImagePreviewViewCommand = {
id: createClientId(),
requestId: activePreviewRequestId,
sentAt: Date.now(),
type: 'view',
view,
viewerId: clientId,
}
lastHandledCommandId = command.id
imageChannel?.postMessage(command)
updateImagePreviewState({ command })
})
}
function applyImageView(view: ImagePreviewView, shouldBroadcast = true, touched = true) {
if (!Number.isFinite(view.scale) || !Number.isFinite(view.centerX) || !Number.isFinite(view.centerY))
return
imageScale = clampImageScale(view.scale)
imageCenterX = Math.min(1, Math.max(0, view.centerX))
imageCenterY = Math.min(1, Math.max(0, view.centerY))
imageViewTouched ||= touched
renderImageView()
if (shouldBroadcast)
broadcastImageView()
}
function resetImageView() {
const geometry = imageGeometry(1)
if (!geometry)
return
const scale = clampImageScale(Math.min(
1,
geometry.viewportWidth / geometry.imageWidth,
geometry.viewportHeight / geometry.imageHeight,
))
imageViewTouched = false
applyImageView({ centerX: 0.5, centerY: 0.5, scale }, false, false)
}
function zoomImageAt(requestedScale: number, clientX?: number, clientY?: number, shouldBroadcast = true) {
const oldGeometry = imageGeometry()
if (!oldGeometry || !lightboxViewport)
return
const scale = clampImageScale(requestedScale)
let centerX = imageCenterX
let centerY = imageCenterY
if (clientX !== undefined && clientY !== undefined && scale !== imageScale) {
const viewportRect = lightboxViewport.getBoundingClientRect()
const pointerX = clientX - viewportRect.left
const pointerY = clientY - viewportRect.top
const imageLeft = oldGeometry.viewportWidth / 2 - imageCenterX * oldGeometry.imageWidth
const imageTop = oldGeometry.viewportHeight / 2 - imageCenterY * oldGeometry.imageHeight
const anchorX = (pointerX - imageLeft) / oldGeometry.imageWidth
const anchorY = (pointerY - imageTop) / oldGeometry.imageHeight
const nextWidth = lightboxImage!.naturalWidth * scale
const nextHeight = lightboxImage!.naturalHeight * scale
centerX = (oldGeometry.viewportWidth / 2 - pointerX + anchorX * nextWidth) / nextWidth
centerY = (oldGeometry.viewportHeight / 2 - pointerY + anchorY * nextHeight) / nextHeight
}
applyImageView({ centerX, centerY, scale }, shouldBroadcast)
}
function panImageBy(deltaX: number, deltaY: number, shouldBroadcast = true) {
const geometry = imageGeometry()
if (!geometry)
return
applyImageView({
centerX: imageCenterX - deltaX / geometry.imageWidth,
centerY: imageCenterY - deltaY / geometry.imageHeight,
scale: imageScale,
}, shouldBroadcast)
}
function pinchMetrics() {
const pointers = Array.from(imagePointers.values()).slice(0, 2)
if (pointers.length < 2)
return undefined
return {
distance: Math.hypot(pointers[1].x - pointers[0].x, pointers[1].y - pointers[0].y),
midpoint: {
x: (pointers[0].x + pointers[1].x) / 2,
y: (pointers[0].y + pointers[1].y) / 2,
},
}
}
function handleImagePointerDown(event: PointerEvent) {
if (!lightboxViewport || (event.pointerType === 'mouse' && event.button !== 0))
return
event.preventDefault()
event.stopPropagation()
if (imagePointers.size === 0)
lightboxPointerMoved = false
imagePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
try {
lightboxViewport.setPointerCapture(event.pointerId)
}
catch {
// Synthetic pointer events used by tests do not participate in native pointer capture.
}
lightboxViewport.classList.add('easy-image-lightbox-dragging')
const metrics = pinchMetrics()
pinchDistance = metrics?.distance ?? 0
pinchMidpoint = metrics?.midpoint
}
function handleImagePointerMove(event: PointerEvent) {
const previous = imagePointers.get(event.pointerId)
if (!previous)
return
event.preventDefault()
event.stopPropagation()
const current = { x: event.clientX, y: event.clientY }
imagePointers.set(event.pointerId, current)
if (Math.hypot(current.x - previous.x, current.y - previous.y) > 1)
lightboxPointerMoved = true
if (imagePointers.size === 1) {
panImageBy(current.x - previous.x, current.y - previous.y)
return
}
const metrics = pinchMetrics()
if (!metrics)
return
if (pinchDistance > 0)
zoomImageAt(imageScale * metrics.distance / pinchDistance, metrics.midpoint.x, metrics.midpoint.y, false)
if (pinchMidpoint)
panImageBy(metrics.midpoint.x - pinchMidpoint.x, metrics.midpoint.y - pinchMidpoint.y)
pinchDistance = metrics.distance
pinchMidpoint = metrics.midpoint
}
function handleImagePointerEnd(event: PointerEvent) {
if (!imagePointers.has(event.pointerId))
return
event.preventDefault()
event.stopPropagation()
imagePointers.delete(event.pointerId)
if (lightboxViewport?.hasPointerCapture(event.pointerId))
lightboxViewport.releasePointerCapture(event.pointerId)
const metrics = pinchMetrics()
pinchDistance = metrics?.distance ?? 0
pinchMidpoint = metrics?.midpoint
if (imagePointers.size === 0)
lightboxViewport?.classList.remove('easy-image-lightbox-dragging')
}
function handleImageWheel(event: WheelEvent) {
event.preventDefault()
event.stopPropagation()
const sensitivity = event.deltaMode === WheelEvent.DOM_DELTA_PIXEL ? 0.002 : 0.05
zoomImageAt(imageScale * Math.exp(-event.deltaY * sensitivity), event.clientX, event.clientY)
}
function handleLightboxImageLoad() {
if (!lightbox || lightbox.hidden)
return
resetImageView()
if (pendingRemoteView) {
const view = pendingRemoteView
pendingRemoteView = undefined
applyImageView(view, false)
}
}
function createImageScaleButton(label: string, text: string, onClick: () => void) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'easy-image-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 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 viewport = document.createElement('div')
viewport.className = 'easy-image-lightbox-viewport'
const preview = document.createElement('img')
preview.className = 'easy-image-lightbox-preview'
preview.alt = ''
preview.draggable = false
preview.addEventListener('load', handleLightboxImageLoad)
viewport.addEventListener('pointerdown', handleImagePointerDown)
viewport.addEventListener('pointermove', handleImagePointerMove)
viewport.addEventListener('pointerup', handleImagePointerEnd)
viewport.addEventListener('pointercancel', handleImagePointerEnd)
viewport.addEventListener('wheel', handleImageWheel, { passive: false })
viewport.append(preview)
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('pointerdown', event => event.stopPropagation())
close.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
closeLightbox()
})
const toolbar = document.createElement('nav')
toolbar.className = 'easy-image-scale-toolbar'
toolbar.setAttribute('aria-label', '图片缩放')
toolbar.addEventListener('pointerdown', event => event.stopPropagation())
const decrease = createImageScaleButton('缩小图片', '', () => zoomImageAt(imageScale - IMAGE_SCALE_STEP))
const scaleOutput = document.createElement('output')
scaleOutput.className = 'easy-image-scale-value'
scaleOutput.setAttribute('aria-live', 'polite')
scaleOutput.value = '100%'
scaleOutput.textContent = '100%'
const increase = createImageScaleButton('放大图片', '+', () => zoomImageAt(imageScale + IMAGE_SCALE_STEP))
toolbar.append(decrease, scaleOutput, increase)
overlay.addEventListener('pointerdown', event => event.stopPropagation())
overlay.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
const clickedBackdrop = event.target === overlay || event.target === viewport
if (clickedBackdrop && !lightboxPointerMoved)
closeLightbox()
lightboxPointerMoved = false
})
overlay.append(viewport, close, toolbar)
document.body.append(overlay)
lightbox = overlay
lightboxViewport = viewport
lightboxImage = preview
lightboxClose = close
lightboxDecrease = decrease
lightboxIncrease = increase
lightboxScaleOutput = scaleOutput
}
function showLightbox(src: string, alt: string, image?: HTMLImageElement, requestId?: string) {
if (!lightbox || !lightboxImage || !lightboxClose)
return
const wasHidden = lightbox.hidden
activeImage = image
activePreviewRequestId = requestId
pendingRemoteView = undefined
imageViewTouched = false
imageScale = 1
imageCenterX = 0.5
imageCenterY = 0.5
lightboxImage.alt = alt
lightbox.hidden = false
lightboxImage.src = src
if (wasHidden)
bodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleLightboxKeydown, true)
if (lightboxImage.complete && lightboxImage.naturalWidth)
requestAnimationFrame(handleLightboxImageLoad)
lightboxClose.focus()
}
function openLightbox(image: HTMLImageElement) {
showLightbox(image.currentSrc || image.src, image.alt, image)
}
function broadcastImageClose() {
if (!activePreviewRequestId)
return
const command: ImagePreviewCloseCommand = {
id: createClientId(),
requestId: activePreviewRequestId,
sentAt: Date.now(),
type: 'close',
viewerId: clientId,
}
lastHandledCommandId = command.id
imageChannel?.postMessage(command)
updateImagePreviewState({ command })
}
function closeLightbox(restoreFocus = true, shouldBroadcast = true) {
if (!lightbox || lightbox.hidden)
return
if (shouldBroadcast)
broadcastImageClose()
document.removeEventListener('keydown', handleLightboxKeydown, true)
lightbox.hidden = true
if (imageViewFrame !== undefined) {
cancelAnimationFrame(imageViewFrame)
imageViewFrame = undefined
}
pendingSharedView = undefined
pendingRemoteView = undefined
imagePointers.clear()
lightboxViewport?.classList.remove('easy-image-lightbox-dragging', 'easy-image-lightbox-pannable')
if (lightboxImage) {
lightboxImage.removeAttribute('src')
lightboxImage.style.removeProperty('width')
lightboxImage.style.removeProperty('height')
lightboxImage.style.removeProperty('transform')
delete lightboxImage.dataset.easyImageScale
delete lightboxImage.dataset.easyImageCenterX
delete lightboxImage.dataset.easyImageCenterY
}
document.body.style.overflow = bodyOverflow
if (restoreFocus)
activeImage?.focus()
activeImage = undefined
activePreviewRequestId = undefined
imageScale = 1
imageCenterX = 0.5
imageCenterY = 0.5
imageViewTouched = false
}
function handleLightboxKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeLightbox()
}
else if (event.key === 'Tab') {
const focusable = [lightboxClose, lightboxDecrease, lightboxIncrease].filter(
(element): element is HTMLButtonElement => Boolean(element && !element.disabled),
)
if (!focusable.length)
return
event.preventDefault()
const currentIndex = focusable.indexOf(document.activeElement as HTMLButtonElement)
const direction = event.shiftKey ? -1 : 1
const nextIndex = currentIndex < 0
? 0
: (currentIndex + direction + focusable.length) % focusable.length
focusable[nextIndex].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 isImagePreviewCommand(command: ImagePreviewCommand | undefined): command is ImagePreviewCommand {
if (!command || !command.id || !command.requestId || !command.viewerId || !Number.isFinite(command.sentAt))
return false
if (command.type === 'close')
return true
return command.type === 'view'
&& Number.isFinite(command.view?.scale)
&& Number.isFinite(command.view?.centerX)
&& Number.isFinite(command.view?.centerY)
}
function handleImagePreviewCommand(command: ImagePreviewCommand | undefined) {
if (!isImagePreviewCommand(command) || !isProjectionRoute() || command.id === lastHandledCommandId)
return
if (command.requestId !== activePreviewRequestId)
return
if (command.sentAt < mountedAt - 100 || Date.now() - command.sentAt > 10_000)
return
lastHandledCommandId = command.id
if (command.type === 'close') {
closeLightbox(false, false)
return
}
if (!lightboxImage?.naturalWidth) {
pendingRemoteView = command.view
return
}
applyImageView(command.view, 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, undefined, request.id)
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 handleViewportResize() {
scheduleCodeHeightFit()
if (!lightbox || lightbox.hidden || !lightboxImage?.naturalWidth)
return
if (imageViewTouched)
renderImageView()
else
resetImageView()
}
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(false, false)
imageInteractions.forEach((interaction, image) => cleanupImage(image, interaction))
imageInteractions.clear()
lightbox?.remove()
lightbox = undefined
lightboxViewport = undefined
lightboxImage = undefined
lightboxClose = undefined
lightboxDecrease = undefined
lightboxIncrease = undefined
lightboxScaleOutput = 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)
})
imageChannel = new BroadcastChannel(`easy-slides:image-preview:${import.meta.env.BASE_URL}`)
imageChannel.addEventListener('message', event => handleImagePreviewCommand(event.data as ImagePreviewCommand))
}
stopAcknowledgementWatch = watch(
() => easySharedState.easySlidesImagePreview?.acknowledgement,
handleImagePreviewAcknowledgement,
)
stopCommandWatch = watch(
() => easySharedState.easySlidesImagePreview?.command,
handleImagePreviewCommand,
)
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', handleViewportResize)
void document.fonts?.ready.then(scheduleCodeHeightFit)
scheduleCodeHeightFit()
})
onBeforeUnmount(() => {
observer?.disconnect()
resizeObserver?.disconnect()
channel?.close()
imageChannel?.close()
stopAcknowledgementWatch?.()
stopCommandWatch?.()
stopRequestWatch?.()
window.removeEventListener('resize', handleViewportResize)
if (fitFrame !== undefined)
cancelAnimationFrame(fitFrame)
cleanupContent()
})
</script>
<template>
<span hidden aria-hidden="true" />
</template>