1168 lines
39 KiB
Vue
1168 lines
39 KiB
Vue
<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')
|
||
const host = code.closest<HTMLElement>('.easy-content-code-host')
|
||
if (!layout || !host || 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
|
||
const contentBottom = Math.min(regionBottom, layoutBottom)
|
||
let trailingBottom = codeRect.bottom
|
||
|
||
region.querySelectorAll<HTMLElement>('*').forEach((element) => {
|
||
if (host.contains(element) || !(host.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING))
|
||
return
|
||
const style = getComputedStyle(element)
|
||
if (style.display === 'none' || style.position === 'absolute' || style.position === 'fixed')
|
||
return
|
||
const rect = element.getBoundingClientRect()
|
||
if (rect.width <= 0 || rect.height <= 0)
|
||
return
|
||
const marginBottom = Number.parseFloat(style.marginBottom) || 0
|
||
trailingBottom = Math.max(trailingBottom, rect.bottom + marginBottom * scale)
|
||
})
|
||
|
||
const trailingHeight = Math.max(0, trailingBottom - codeRect.bottom)
|
||
const codeStyle = getComputedStyle(code)
|
||
const minimumHeight = (
|
||
(Number.parseFloat(codeStyle.lineHeight) || 0)
|
||
+ (Number.parseFloat(codeStyle.paddingTop) || 0)
|
||
+ (Number.parseFloat(codeStyle.paddingBottom) || 0)
|
||
+ (Number.parseFloat(codeStyle.borderTopWidth) || 0)
|
||
+ (Number.parseFloat(codeStyle.borderBottomWidth) || 0)
|
||
)
|
||
const available = (contentBottom - codeRect.top - trailingHeight) / scale
|
||
return Math.max(1, Math.floor(Math.max(minimumHeight, available)))
|
||
}
|
||
|
||
function setHostLength(host: HTMLElement, property: string, value?: number) {
|
||
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-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) => {
|
||
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-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-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>
|