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
+4 -2
View File
@@ -182,7 +182,9 @@ const slides = await discover('slides')
同样的包裹方式也适用于表格,例如 `<div class="easy-table-sm">`。不添加固定高度类时,单个代码框会占满标题或前置正文之后的剩余区域;同一区域有多个代码框时保持内容高度并限制在内容区内。
公开放映页和演示者预览中的图片可以点击进入全屏预览,也可以聚焦图片后按 `Enter` 或空格打开;通过关闭按钮、点击背景或按 `Esc` 返回幻灯片。演示者点击图片时会优先在已连接的放映页打开;没有放映页确认时,图片在约半秒后回退到演示者页打开。`present` 模式支持局域网跨设备联动,静态站点支持同一浏览器标签页联动。该交互不改变图片原始布局,也不会进入导出文件
公开放映页和演示者预览中的图片可以点击进入全屏预览,也可以聚焦图片后按 `Enter` 或空格打开;通过关闭按钮、点击背景或按 `Esc` 返回幻灯片。预览底部提供 ` / 百分比 / +` 工具栏,100% 对应图片原始像素尺寸;还可以使用鼠标滚轮、触控板或触屏双指缩放,并在放大后拖动查看细节,缩放范围为 10%–400%
演示者点击图片时会优先在已连接的放映页打开;没有放映页确认时,图片在约半秒后回退到演示者页打开。演示者发起的同一轮预览会在所有放映页之间同步缩放、拖动和关闭;放映页自行点击打开的图片仍只影响本机。`present` 模式支持局域网跨设备联动,静态站点支持同一浏览器标签页联动。该交互不改变图片原始布局,也不会进入导出文件。
表格和代码块会在悬停或键盘聚焦时显示 ` / 百分比 / +` 缩放工具栏,可在 `60%160%` 之间按 `10%` 调整;同一课件的放映页与演示者标签页会实时同步。代码缩放只改变字号,不改变高度或滚动位置。缩放只保存在当前页面内存中,刷新即恢复默认,也不会进入导出文件。
@@ -198,7 +200,7 @@ Token 在内容仓库或部署平台中配置:
PRESENTER_TOKEN=a-long-non-obvious-token pnpm build
```
构建只写入 SHA-256 哈希。演示者页面未解锁时使用全局模态锁屏,背景控件、翻页快捷键和焦点均不可操作;正确 token 解锁后,状态保存在当前标签页的 `sessionStorage`,刷新保持,锁定或关闭标签页后失效。未设置 token 时,公开页面隐藏演示者入口,`check` 会提示警告。
构建只写入 SHA-256 哈希。普通放映页不显示演示者入口;演示者通过 `/presenter/` 或 `easy-slides present` 输出的专用地址进入。演示者页面未解锁时使用全局模态锁屏,背景控件、翻页快捷键和焦点均不可操作;正确 token 解锁后,状态保存在当前标签页的 `sessionStorage`,刷新保持,锁定或关闭标签页后失效。未设置 token 时,`check` 会提示警告。
这只是静态站点的便利门槛,不是服务端安全边界。备注和演示者代码仍可被技术用户分析。
@@ -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;
@@ -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; }
@@ -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;
+6
View File
@@ -85,6 +85,12 @@ describe('theme defaults', () => {
expect(controlsSource).toContain("overlay.setAttribute('aria-label', '图片全屏预览')")
expect(controlsSource).toContain("type ScalableKind = 'table' | 'code'")
expect(controlsSource).toContain('easySlidesImagePreview')
expect(controlsSource).toContain('const IMAGE_MIN_SCALE = 0.1')
expect(controlsSource).toContain('const IMAGE_MAX_SCALE = 4')
expect(controlsSource).toContain("type: 'close'")
expect(controlsSource).toContain('easy-slides:image-preview:')
expect(layoutCss).toContain('.easy-image-scale-toolbar')
expect(layoutCss).toContain('.easy-image-lightbox-pannable')
expect(controlsSource).toContain('sharedState')
expect(controlsSource).toContain('}, 500)')
expect(scrollFallbackSource).toContain('easy-slide-scroll-fallback')
+126 -5
View File
@@ -5,7 +5,8 @@ test('landing page links to a rendered deck', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Engineering Slides', exact: true })).toBeVisible()
await page.getByRole('link', { name: 'easy-slides 快速开始' }).click()
await expect(page.getByRole('heading', { name: 'easy-slides', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '演示者' })).toBeVisible()
await expect(page.getByRole('button', { name: '演示者' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Presenter Mode' })).toHaveCount(0)
})
test('presenter token unlocks, survives refresh, and can be locked', async ({ page }) => {
@@ -122,15 +123,63 @@ test('presenter sends image previews to every audience window', async ({ context
await expect(secondAudienceDialog).toBeVisible()
await expect(presenterDialog).toBeHidden()
await expect(audienceDialog.locator('img')).toHaveAttribute('src', /cover\.svg/)
const audiencePreview = audienceDialog.locator('img')
const secondAudiencePreview = secondAudienceDialog.locator('img')
const sharedPreviewSource = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1200" viewBox="0 0 1600 1200"><rect width="100%" height="100%" fill="#2563eb"/></svg>')}`
await Promise.all([
audiencePreview.evaluate((element, source) => {
;(element as HTMLImageElement).src = source
}, sharedPreviewSource),
secondAudiencePreview.evaluate((element, source) => {
;(element as HTMLImageElement).src = source
}, sharedPreviewSource),
])
await expect.poll(() => audiencePreview.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1600)
await expect.poll(() => secondAudiencePreview.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1600)
const initialScale = await audiencePreview.evaluate(element => Number(element.getAttribute('data-easy-image-scale')))
expect(initialScale).toBeGreaterThanOrEqual(0.1)
expect(initialScale).toBeLessThanOrEqual(1)
await audienceDialog.getByRole('button', { name: '放大图片' }).click()
await expect.poll(() => secondAudiencePreview.evaluate(element => Number(element.getAttribute('data-easy-image-scale'))))
.toBeCloseTo(initialScale + 0.1, 3)
const viewport = audienceDialog.locator('.easy-image-lightbox-viewport')
const viewportBox = await viewport.boundingBox()
expect(viewportBox).not.toBeNull()
await page.mouse.move(viewportBox!.x + viewportBox!.width / 2, viewportBox!.y + viewportBox!.height / 2)
await page.mouse.down()
await page.mouse.move(viewportBox!.x + viewportBox!.width / 2, viewportBox!.y + viewportBox!.height / 2 - 90, { steps: 5 })
await page.mouse.up()
const draggedCenterY = await audiencePreview.evaluate(element => Number(element.getAttribute('data-easy-image-center-y')))
expect(draggedCenterY).toBeGreaterThan(0.5)
await expect.poll(() => secondAudiencePreview.evaluate(element => Number(element.getAttribute('data-easy-image-center-y'))))
.toBeCloseTo(draggedCenterY, 3)
await page.getByRole('button', { name: '关闭图片全屏预览' }).click()
await secondAudience.getByRole('button', { name: '关闭图片全屏预览' }).click()
await expect(audienceDialog).toBeHidden()
await expect(secondAudienceDialog).toBeHidden()
await nextImage.press('Enter')
await expect(audienceDialog).toBeVisible()
await expect(secondAudienceDialog).toBeVisible()
await expect(presenterDialog).toBeHidden()
await page.keyboard.press('Escape')
await secondAudience.keyboard.press('Escape')
await expect(audienceDialog).toBeHidden()
await expect(secondAudienceDialog).toBeHidden()
await currentImage.click()
await expect(audienceDialog).toBeVisible()
await expect(secondAudienceDialog).toBeVisible()
await secondAudienceDialog.locator('.easy-image-lightbox-viewport').click({ position: { x: 4, y: 4 } })
await expect(audienceDialog).toBeHidden()
await expect(secondAudienceDialog).toBeHidden()
const localImage = page.locator('.slidev-layout img.easy-image-preview-trigger').first()
await localImage.click()
await expect(audienceDialog).toBeVisible()
await expect(secondAudienceDialog).toBeHidden()
await page.keyboard.press('Escape')
})
test('presenter opens an image locally when no audience acknowledges it', async ({ context, page }) => {
@@ -155,13 +204,16 @@ test('presenter opens an image locally when no audience acknowledges it', async
await auditPage.close()
})
test('present mode sends image previews across isolated browser contexts', async ({ browser }) => {
test('present mode syncs image preview views across isolated browser contexts', async ({ browser }) => {
const audienceContext = await browser.newContext()
const secondAudienceContext = await browser.newContext()
const presenterContext = await browser.newContext()
const audience = await audienceContext.newPage()
const secondAudience = await secondAudienceContext.newPage()
const presenter = await presenterContext.newPage()
try {
await audience.goto('http://127.0.0.1:4174/4')
await secondAudience.goto('http://127.0.0.1:4174/4')
await presenter.goto('http://127.0.0.1:4174/presenter/?password=easy-slides-local')
await presenter.getByLabel('演示者 token').fill('test-token')
await presenter.getByRole('button', { name: '解锁' }).click()
@@ -173,11 +225,24 @@ test('present mode sends image previews across isolated browser contexts', async
await expect(audience.getByRole('heading', { name: '智能图片' })).toBeVisible()
await current.locator('img.easy-image-preview-trigger').first().click()
await expect(audience.getByRole('dialog', { name: '图片全屏预览' })).toBeVisible()
const audienceDialog = audience.getByRole('dialog', { name: '图片全屏预览' })
const secondAudienceDialog = secondAudience.getByRole('dialog', { name: '图片全屏预览' })
await expect(audienceDialog).toBeVisible()
await expect(secondAudienceDialog).toBeVisible()
await expect(presenter.getByRole('dialog', { name: '图片全屏预览' })).toBeHidden()
await audienceDialog.getByRole('button', { name: '放大图片' }).click()
const firstScale = await audienceDialog.locator('img').evaluate(element => element.getAttribute('data-easy-image-scale'))
await expect.poll(() => secondAudienceDialog.locator('img').evaluate(element => element.getAttribute('data-easy-image-scale')))
.toBe(firstScale)
await secondAudienceDialog.getByRole('button', { name: '关闭图片全屏预览' }).click()
await expect(audienceDialog).toBeHidden()
await expect(secondAudienceDialog).toBeHidden()
}
finally {
await audienceContext.close()
await secondAudienceContext.close()
await presenterContext.close()
}
})
@@ -330,6 +395,62 @@ test('image fullscreen preserves layout and table scaling stays out of non-inter
expect(overlay).not.toBeNull()
expect(overlay!.width).toBe(viewport!.width)
expect(overlay!.height).toBe(viewport!.height)
const previewViewport = dialog.locator('.easy-image-lightbox-viewport')
const previewImage = dialog.locator('img')
const scaleValue = dialog.locator('.easy-image-scale-value')
const interactiveSource = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1200" viewBox="0 0 1600 1200"><rect width="100%" height="100%" fill="#2563eb"/></svg>')}`
await previewImage.evaluate((element, source) => {
;(element as HTMLImageElement).src = source
}, interactiveSource)
await expect.poll(() => previewImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1600)
const initialScale = await previewImage.evaluate(element => Number(element.getAttribute('data-easy-image-scale')))
expect(initialScale).toBeGreaterThanOrEqual(0.1)
expect(initialScale).toBeLessThanOrEqual(1)
await expect(scaleValue).toHaveText(`${Math.round(initialScale * 100)}%`)
const previewViewportBox = await previewViewport.boundingBox()
expect(previewViewportBox).not.toBeNull()
await page.mouse.move(
previewViewportBox!.x + previewViewportBox!.width * 0.25,
previewViewportBox!.y + previewViewportBox!.height * 0.25,
)
await page.mouse.wheel(0, -180)
await expect.poll(() => previewImage.evaluate(element => Number(element.getAttribute('data-easy-image-scale'))))
.toBeGreaterThan(initialScale)
await expect.poll(() => previewImage.evaluate(element => Number(element.getAttribute('data-easy-image-center-y'))))
.not.toBe(0.5)
const increase = dialog.getByRole('button', { name: '放大图片' })
await increase.evaluate((element) => {
for (let index = 0; index < 50; index += 1) {
const button = element as HTMLButtonElement
button.click()
}
})
await expect(scaleValue).toHaveText('400%')
await expect(increase).toBeDisabled()
const decrease = dialog.getByRole('button', { name: '缩小图片' })
await decrease.evaluate((element) => {
for (let index = 0; index < 50; index += 1) {
const button = element as HTMLButtonElement
button.click()
}
})
await expect(scaleValue).toHaveText('10%')
await expect(decrease).toBeDisabled()
const pinchCenterX = previewViewportBox!.x + previewViewportBox!.width / 2
const pinchCenterY = previewViewportBox!.y + previewViewportBox!.height / 2
await previewViewport.dispatchEvent('pointerdown', { buttons: 1, clientX: pinchCenterX - 40, clientY: pinchCenterY, pointerId: 1, pointerType: 'touch' })
await previewViewport.dispatchEvent('pointerdown', { buttons: 1, clientX: pinchCenterX + 40, clientY: pinchCenterY, pointerId: 2, pointerType: 'touch' })
await previewViewport.dispatchEvent('pointermove', { buttons: 1, clientX: pinchCenterX + 80, clientY: pinchCenterY, pointerId: 2, pointerType: 'touch' })
await previewViewport.dispatchEvent('pointerup', { clientX: pinchCenterX - 40, clientY: pinchCenterY, pointerId: 1, pointerType: 'touch' })
await previewViewport.dispatchEvent('pointerup', { clientX: pinchCenterX + 80, clientY: pinchCenterY, pointerId: 2, pointerType: 'touch' })
await expect.poll(() => previewImage.evaluate(element => Number(element.getAttribute('data-easy-image-scale'))))
.toBeGreaterThan(0.1)
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
const after = await image.evaluate(element => ({