feat: sync interactive image previews
Engine CI / test (push) Waiting to run

This commit is contained in:
2026-08-30 22:20:30 +08:00
parent e3b0b2d576
commit e23fa413be
11 changed files with 1296 additions and 117 deletions
@@ -52,9 +52,34 @@ interface ImagePreviewAcknowledgement {
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
}
}
@@ -67,6 +92,15 @@ interface PendingImagePreview {
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()
@@ -78,16 +112,35 @@ 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')
@@ -227,6 +280,260 @@ function applyScale(key: string, kind: ScalableKind, scale: number) {
})
}
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'
@@ -235,9 +542,20 @@ function createLightbox() {
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'
@@ -245,38 +563,66 @@ function createLightbox() {
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()
if (event.target === overlay)
const clickedBackdrop = event.target === overlay || event.target === viewport
if (clickedBackdrop && !lightboxPointerMoved)
closeLightbox()
lightboxPointerMoved = false
})
overlay.append(preview, close)
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) {
function showLightbox(src: string, alt: string, image?: HTMLImageElement, requestId?: string) {
if (!lightbox || !lightboxImage || !lightboxClose)
return
const wasHidden = lightbox.hidden
activeImage = image
lightboxImage.src = src
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()
}
@@ -284,17 +630,54 @@ function openLightbox(image: HTMLImageElement) {
showLightbox(image.currentSrc || image.src, image.alt, image)
}
function closeLightbox(restoreFocus = true) {
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 (lightboxImage)
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) {
@@ -304,8 +687,18 @@ function handleLightboxKeydown(event: KeyboardEvent) {
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()
lightboxClose?.focus()
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()
}
}
@@ -368,13 +761,43 @@ function handleImagePreviewAcknowledgement(acknowledgement: ImagePreviewAcknowle
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)
showLightbox(request.src, request.alt, undefined, request.id)
updateImagePreviewState({
acknowledgement: {
requestId: request.id,
@@ -574,6 +997,16 @@ function scheduleCodeHeightFit() {
})
}
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)
@@ -628,13 +1061,17 @@ function cleanupCodeHosts() {
function cleanupContent() {
clearPendingImagePreview()
closeLightbox()
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()
}
@@ -659,11 +1096,17 @@ onMounted(() => {
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,
@@ -676,7 +1119,7 @@ onMounted(() => {
scheduleCodeHeightFit()
})
observer.observe(document.body, { childList: true, subtree: true })
window.addEventListener('resize', scheduleCodeHeightFit)
window.addEventListener('resize', handleViewportResize)
void document.fonts?.ready.then(scheduleCodeHeightFit)
scheduleCodeHeightFit()
})
@@ -685,9 +1128,11 @@ onBeforeUnmount(() => {
observer?.disconnect()
resizeObserver?.disconnect()
channel?.close()
imageChannel?.close()
stopAcknowledgementWatch?.()
stopCommandWatch?.()
stopRequestWatch?.()
window.removeEventListener('resize', scheduleCodeHeightFit)
window.removeEventListener('resize', handleViewportResize)
if (fitFrame !== undefined)
cancelAnimationFrame(fitFrame)
cleanupContent()
@@ -47,10 +47,6 @@ function lock() {
syncBodyState()
}
function openPresenter() {
window.location.href = `${import.meta.env.BASE_URL}presenter/`
}
function resizeNotes(delta: number) {
noteSize.value = Math.min(38, Math.max(14, noteSize.value + delta))
document.documentElement.style.setProperty('--easy-presenter-note-size', `${noteSize.value}px`)
@@ -93,16 +89,6 @@ async function sha256(value: string) {
</script>
<template>
<button
v-if="!isPresenter && configuredHash"
class="easy-presenter-launcher"
type="button"
title="打开演示者模式"
@click="openPresenter"
>
演示者
</button>
<Teleport to="body">
<dialog
v-if="isPresenter && !unlocked"
+114 -14
View File
@@ -126,37 +126,55 @@
position: fixed;
inset: 0;
z-index: 2147483647;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
place-items: center;
display: block;
box-sizing: border-box;
min-width: 0;
min-height: 0;
padding: clamp(16px, 4vmin, 42px);
padding: clamp(16px, 4vmin, 42px) clamp(16px, 4vmin, 42px) clamp(86px, 12vmin, 112px);
overflow: hidden;
overscroll-behavior: contain;
background: rgba(0, 0, 0, .92);
}
.easy-image-lightbox-preview {
display: block;
place-self: center;
.easy-image-lightbox-viewport {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
max-inline-size: 100%;
max-block-size: 100%;
overflow: hidden;
touch-action: none;
user-select: none;
}
.easy-image-lightbox-viewport.easy-image-lightbox-pannable {
cursor: grab;
}
.easy-image-lightbox-viewport.easy-image-lightbox-dragging {
cursor: grabbing;
}
.easy-image-lightbox-preview {
position: absolute;
top: 50%;
left: 50%;
display: block;
max-width: none;
max-height: none;
max-inline-size: none;
max-block-size: none;
margin: 0;
transform-origin: 0 0;
object-fit: contain;
object-position: center;
user-select: none;
-webkit-user-drag: none;
}
.easy-image-lightbox-close {
position: absolute;
z-index: 2;
top: 18px;
right: 22px;
display: grid;
@@ -179,6 +197,59 @@
outline: none;
}
.easy-image-scale-toolbar {
position: absolute;
z-index: 2;
left: 50%;
bottom: max(18px, env(safe-area-inset-bottom));
display: grid;
grid-template-columns: 52px minmax(76px, auto) 52px;
align-items: center;
padding: 6px;
border: 1px solid rgba(255, 255, 255, .72);
border-radius: 999px;
background: rgba(250, 250, 250, .96);
box-shadow: 0 12px 36px rgba(0, 0, 0, .32);
color: #202124;
transform: translateX(-50%);
backdrop-filter: blur(12px);
}
.easy-image-scale-button {
display: grid;
width: 46px;
height: 46px;
place-items: center;
border: 0;
border-radius: 50%;
background: #e4e5e7;
color: #202124;
padding: 0 0 3px;
font: 300 34px/1 Arial, sans-serif;
cursor: pointer;
}
.easy-image-scale-button:hover,
.easy-image-scale-button:focus-visible {
background: var(--easy-accent);
color: #fff;
outline: none;
}
.easy-image-scale-button:disabled {
color: #9aa0a6;
cursor: not-allowed;
opacity: .55;
}
.easy-image-scale-value {
min-width: 76px;
padding: 0 10px;
text-align: center;
font: 500 20px/1 Lato, "Noto Sans SC", sans-serif;
font-variant-numeric: tabular-nums;
}
.easy-content-scale-toolbar {
position: absolute;
top: 8px;
@@ -275,6 +346,35 @@ body.easy-slide-scroll-fallback .slidev-layout {
.easy-content-scale-toolbar { opacity: .78; }
}
@media (max-width: 520px) {
.easy-image-lightbox {
padding: 14px 14px calc(84px + env(safe-area-inset-bottom));
}
.easy-image-lightbox-close {
top: 12px;
right: 12px;
width: 42px;
height: 42px;
}
.easy-image-scale-toolbar {
bottom: max(12px, env(safe-area-inset-bottom));
grid-template-columns: 48px minmax(68px, auto) 48px;
padding: 5px;
}
.easy-image-scale-button {
width: 42px;
height: 42px;
}
.easy-image-scale-value {
min-width: 68px;
font-size: 18px;
}
}
@media print {
.easy-content-scale-toolbar,
.easy-image-lightbox { display: none !important; }
+10 -16
View File
@@ -1,3 +1,12 @@
.slidev-icon-btn[title="Presenter Mode"],
.slidev-icon-btn[title="演示者模式"],
.slidev-icon-btn[aria-label="Presenter Mode"],
.slidev-icon-btn[aria-label="演示者模式"],
[class~="w-1px"]:has(+ .slidev-icon-btn[title="Presenter Mode"]),
[class~="w-1px"]:has(+ .slidev-icon-btn[title="演示者模式"]) {
display: none !important;
}
.easy-presenter-gate {
position: fixed;
inset: 0;
@@ -65,8 +74,7 @@ body.easy-presenter-locked {
}
.easy-presenter-dialog button,
.easy-presenter-tools button,
.easy-presenter-launcher {
.easy-presenter-tools button {
border: 0;
border-radius: 6px;
background: var(--easy-accent);
@@ -81,20 +89,6 @@ body.easy-presenter-locked {
font-weight: 600;
}
.easy-presenter-launcher {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 60;
opacity: .22;
transition: opacity .16s ease;
}
.easy-presenter-launcher:hover,
.easy-presenter-launcher:focus-visible {
opacity: 1;
}
.easy-presenter-tools {
position: fixed;
top: 12px;