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">`。不添加固定高度类时,单个代码框会占满标题或前置正文之后的剩余区域;同一区域有多个代码框时保持内容高度并限制在内容区内。 同样的包裹方式也适用于表格,例如 `<div class="easy-table-sm">`。不添加固定高度类时,单个代码框会占满标题或前置正文之后的剩余区域;同一区域有多个代码框时保持内容高度并限制在内容区内。
公开放映页和演示者预览中的图片可以点击进入全屏预览,也可以聚焦图片后按 `Enter` 或空格打开;通过关闭按钮、点击背景或按 `Esc` 返回幻灯片。演示者点击图片时会优先在已连接的放映页打开;没有放映页确认时,图片在约半秒后回退到演示者页打开。`present` 模式支持局域网跨设备联动,静态站点支持同一浏览器标签页联动。该交互不改变图片原始布局,也不会进入导出文件 公开放映页和演示者预览中的图片可以点击进入全屏预览,也可以聚焦图片后按 `Enter` 或空格打开;通过关闭按钮、点击背景或按 `Esc` 返回幻灯片。预览底部提供 ` / 百分比 / +` 工具栏,100% 对应图片原始像素尺寸;还可以使用鼠标滚轮、触控板或触屏双指缩放,并在放大后拖动查看细节,缩放范围为 10%–400%
演示者点击图片时会优先在已连接的放映页打开;没有放映页确认时,图片在约半秒后回退到演示者页打开。演示者发起的同一轮预览会在所有放映页之间同步缩放、拖动和关闭;放映页自行点击打开的图片仍只影响本机。`present` 模式支持局域网跨设备联动,静态站点支持同一浏览器标签页联动。该交互不改变图片原始布局,也不会进入导出文件。
表格和代码块会在悬停或键盘聚焦时显示 ` / 百分比 / +` 缩放工具栏,可在 `60%160%` 之间按 `10%` 调整;同一课件的放映页与演示者标签页会实时同步。代码缩放只改变字号,不改变高度或滚动位置。缩放只保存在当前页面内存中,刷新即恢复默认,也不会进入导出文件。 表格和代码块会在悬停或键盘聚焦时显示 ` / 百分比 / +` 缩放工具栏,可在 `60%160%` 之间按 `10%` 调整;同一课件的放映页与演示者标签页会实时同步。代码缩放只改变字号,不改变高度或滚动位置。缩放只保存在当前页面内存中,刷新即恢复默认,也不会进入导出文件。
@@ -198,7 +200,7 @@ Token 在内容仓库或部署平台中配置:
PRESENTER_TOKEN=a-long-non-obvious-token pnpm build 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 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 { interface EasySlidesSharedState {
easySlidesImagePreview?: { easySlidesImagePreview?: {
acknowledgement?: ImagePreviewAcknowledgement acknowledgement?: ImagePreviewAcknowledgement
command?: ImagePreviewCommand
request?: ImagePreviewRequest request?: ImagePreviewRequest
} }
} }
@@ -67,6 +92,15 @@ interface PendingImagePreview {
requestId: string 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 nav = useNav()
const easySharedState = sharedState as typeof sharedState & EasySlidesSharedState const easySharedState = sharedState as typeof sharedState & EasySlidesSharedState
const clientId = createClientId() const clientId = createClientId()
@@ -78,16 +112,35 @@ const imageInteractions = new Map<HTMLImageElement, ImageInteraction>()
let observer: MutationObserver | undefined let observer: MutationObserver | undefined
let resizeObserver: ResizeObserver | undefined let resizeObserver: ResizeObserver | undefined
let channel: BroadcastChannel | undefined let channel: BroadcastChannel | undefined
let imageChannel: BroadcastChannel | undefined
let lightbox: HTMLElement | undefined let lightbox: HTMLElement | undefined
let lightboxViewport: HTMLElement | undefined
let lightboxImage: HTMLImageElement | undefined let lightboxImage: HTMLImageElement | undefined
let lightboxClose: HTMLButtonElement | undefined let lightboxClose: HTMLButtonElement | undefined
let lightboxDecrease: HTMLButtonElement | undefined
let lightboxIncrease: HTMLButtonElement | undefined
let lightboxScaleOutput: HTMLOutputElement | undefined
let activeImage: HTMLImageElement | undefined let activeImage: HTMLImageElement | undefined
let activePreviewRequestId: string | undefined
let pendingImagePreview: PendingImagePreview | undefined let pendingImagePreview: PendingImagePreview | undefined
let stopAcknowledgementWatch: (() => void) | undefined let stopAcknowledgementWatch: (() => void) | undefined
let stopCommandWatch: (() => void) | undefined
let stopRequestWatch: (() => void) | undefined let stopRequestWatch: (() => void) | undefined
let fitFrame: number | undefined let fitFrame: number | undefined
let imageViewFrame: number | undefined
let pendingSharedView: ImagePreviewView | undefined
let pendingRemoteView: ImagePreviewView | undefined
let lastHandledRequestId = easySharedState.easySlidesImagePreview?.request?.id ?? '' let lastHandledRequestId = easySharedState.easySlidesImagePreview?.request?.id ?? ''
let lastHandledCommandId = easySharedState.easySlidesImagePreview?.command?.id ?? ''
let bodyOverflow = '' 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() { function createClientId() {
if (typeof crypto.randomUUID === 'function') 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() { function createLightbox() {
const overlay = document.createElement('div') const overlay = document.createElement('div')
overlay.className = 'easy-image-lightbox' overlay.className = 'easy-image-lightbox'
@@ -235,9 +542,20 @@ function createLightbox() {
overlay.setAttribute('aria-modal', 'true') overlay.setAttribute('aria-modal', 'true')
overlay.setAttribute('aria-label', '图片全屏预览') overlay.setAttribute('aria-label', '图片全屏预览')
const viewport = document.createElement('div')
viewport.className = 'easy-image-lightbox-viewport'
const preview = document.createElement('img') const preview = document.createElement('img')
preview.className = 'easy-image-lightbox-preview' preview.className = 'easy-image-lightbox-preview'
preview.alt = '' 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') const close = document.createElement('button')
close.type = 'button' close.type = 'button'
@@ -245,38 +563,66 @@ function createLightbox() {
close.setAttribute('aria-label', '关闭图片全屏预览') close.setAttribute('aria-label', '关闭图片全屏预览')
close.title = '关闭(Esc' close.title = '关闭(Esc'
close.textContent = '×' close.textContent = '×'
close.addEventListener('pointerdown', event => event.stopPropagation())
close.addEventListener('click', (event) => { close.addEventListener('click', (event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
closeLightbox() 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('pointerdown', event => event.stopPropagation())
overlay.addEventListener('click', (event) => { overlay.addEventListener('click', (event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (event.target === overlay) const clickedBackdrop = event.target === overlay || event.target === viewport
if (clickedBackdrop && !lightboxPointerMoved)
closeLightbox() closeLightbox()
lightboxPointerMoved = false
}) })
overlay.append(preview, close) overlay.append(viewport, close, toolbar)
document.body.append(overlay) document.body.append(overlay)
lightbox = overlay lightbox = overlay
lightboxViewport = viewport
lightboxImage = preview lightboxImage = preview
lightboxClose = close 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) if (!lightbox || !lightboxImage || !lightboxClose)
return return
const wasHidden = lightbox.hidden const wasHidden = lightbox.hidden
activeImage = image activeImage = image
lightboxImage.src = src activePreviewRequestId = requestId
pendingRemoteView = undefined
imageViewTouched = false
imageScale = 1
imageCenterX = 0.5
imageCenterY = 0.5
lightboxImage.alt = alt lightboxImage.alt = alt
lightbox.hidden = false lightbox.hidden = false
lightboxImage.src = src
if (wasHidden) if (wasHidden)
bodyOverflow = document.body.style.overflow bodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleLightboxKeydown, true) document.addEventListener('keydown', handleLightboxKeydown, true)
if (lightboxImage.complete && lightboxImage.naturalWidth)
requestAnimationFrame(handleLightboxImageLoad)
lightboxClose.focus() lightboxClose.focus()
} }
@@ -284,17 +630,54 @@ function openLightbox(image: HTMLImageElement) {
showLightbox(image.currentSrc || image.src, image.alt, image) 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) if (!lightbox || lightbox.hidden)
return return
if (shouldBroadcast)
broadcastImageClose()
document.removeEventListener('keydown', handleLightboxKeydown, true) document.removeEventListener('keydown', handleLightboxKeydown, true)
lightbox.hidden = 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.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 document.body.style.overflow = bodyOverflow
if (restoreFocus) if (restoreFocus)
activeImage?.focus() activeImage?.focus()
activeImage = undefined activeImage = undefined
activePreviewRequestId = undefined
imageScale = 1
imageCenterX = 0.5
imageCenterY = 0.5
imageViewTouched = false
} }
function handleLightboxKeydown(event: KeyboardEvent) { function handleLightboxKeydown(event: KeyboardEvent) {
@@ -304,8 +687,18 @@ function handleLightboxKeydown(event: KeyboardEvent) {
closeLightbox() closeLightbox()
} }
else if (event.key === 'Tab') { 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() 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) 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) { function handleImagePreviewRequest(request: ImagePreviewRequest | undefined) {
if (!request || !isProjectionRoute() || request.id === lastHandledRequestId) if (!request || !isProjectionRoute() || request.id === lastHandledRequestId)
return return
if (request.sentAt < mountedAt - 100 || Date.now() - request.sentAt > 10_000) if (request.sentAt < mountedAt - 100 || Date.now() - request.sentAt > 10_000)
return return
lastHandledRequestId = request.id lastHandledRequestId = request.id
showLightbox(request.src, request.alt) showLightbox(request.src, request.alt, undefined, request.id)
updateImagePreviewState({ updateImagePreviewState({
acknowledgement: { acknowledgement: {
requestId: request.id, 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) { function cleanupImage(image: HTMLImageElement, interaction: ImageInteraction) {
image.removeEventListener('click', interaction.click) image.removeEventListener('click', interaction.click)
image.removeEventListener('keydown', interaction.keydown) image.removeEventListener('keydown', interaction.keydown)
@@ -628,13 +1061,17 @@ function cleanupCodeHosts() {
function cleanupContent() { function cleanupContent() {
clearPendingImagePreview() clearPendingImagePreview()
closeLightbox() closeLightbox(false, false)
imageInteractions.forEach((interaction, image) => cleanupImage(image, interaction)) imageInteractions.forEach((interaction, image) => cleanupImage(image, interaction))
imageInteractions.clear() imageInteractions.clear()
lightbox?.remove() lightbox?.remove()
lightbox = undefined lightbox = undefined
lightboxViewport = undefined
lightboxImage = undefined lightboxImage = undefined
lightboxClose = undefined lightboxClose = undefined
lightboxDecrease = undefined
lightboxIncrease = undefined
lightboxScaleOutput = undefined
cleanupTableHosts() cleanupTableHosts()
cleanupCodeHosts() cleanupCodeHosts()
} }
@@ -659,11 +1096,17 @@ onMounted(() => {
if (isScaleMessage(event.data)) if (isScaleMessage(event.data))
updateScale(event.data.key, event.data.kind, event.data.scale, false) 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( stopAcknowledgementWatch = watch(
() => easySharedState.easySlidesImagePreview?.acknowledgement, () => easySharedState.easySlidesImagePreview?.acknowledgement,
handleImagePreviewAcknowledgement, handleImagePreviewAcknowledgement,
) )
stopCommandWatch = watch(
() => easySharedState.easySlidesImagePreview?.command,
handleImagePreviewCommand,
)
stopRequestWatch = watch( stopRequestWatch = watch(
() => easySharedState.easySlidesImagePreview?.request, () => easySharedState.easySlidesImagePreview?.request,
handleImagePreviewRequest, handleImagePreviewRequest,
@@ -676,7 +1119,7 @@ onMounted(() => {
scheduleCodeHeightFit() scheduleCodeHeightFit()
}) })
observer.observe(document.body, { childList: true, subtree: true }) observer.observe(document.body, { childList: true, subtree: true })
window.addEventListener('resize', scheduleCodeHeightFit) window.addEventListener('resize', handleViewportResize)
void document.fonts?.ready.then(scheduleCodeHeightFit) void document.fonts?.ready.then(scheduleCodeHeightFit)
scheduleCodeHeightFit() scheduleCodeHeightFit()
}) })
@@ -685,9 +1128,11 @@ onBeforeUnmount(() => {
observer?.disconnect() observer?.disconnect()
resizeObserver?.disconnect() resizeObserver?.disconnect()
channel?.close() channel?.close()
imageChannel?.close()
stopAcknowledgementWatch?.() stopAcknowledgementWatch?.()
stopCommandWatch?.()
stopRequestWatch?.() stopRequestWatch?.()
window.removeEventListener('resize', scheduleCodeHeightFit) window.removeEventListener('resize', handleViewportResize)
if (fitFrame !== undefined) if (fitFrame !== undefined)
cancelAnimationFrame(fitFrame) cancelAnimationFrame(fitFrame)
cleanupContent() cleanupContent()
@@ -47,10 +47,6 @@ function lock() {
syncBodyState() syncBodyState()
} }
function openPresenter() {
window.location.href = `${import.meta.env.BASE_URL}presenter/`
}
function resizeNotes(delta: number) { function resizeNotes(delta: number) {
noteSize.value = Math.min(38, Math.max(14, noteSize.value + delta)) noteSize.value = Math.min(38, Math.max(14, noteSize.value + delta))
document.documentElement.style.setProperty('--easy-presenter-note-size', `${noteSize.value}px`) document.documentElement.style.setProperty('--easy-presenter-note-size', `${noteSize.value}px`)
@@ -93,16 +89,6 @@ async function sha256(value: string) {
</script> </script>
<template> <template>
<button
v-if="!isPresenter && configuredHash"
class="easy-presenter-launcher"
type="button"
title="打开演示者模式"
@click="openPresenter"
>
演示者
</button>
<Teleport to="body"> <Teleport to="body">
<dialog <dialog
v-if="isPresenter && !unlocked" v-if="isPresenter && !unlocked"
+114 -14
View File
@@ -126,37 +126,55 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 2147483647; z-index: 2147483647;
display: grid; display: block;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
place-items: center;
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
padding: clamp(16px, 4vmin, 42px); padding: clamp(16px, 4vmin, 42px) clamp(16px, 4vmin, 42px) clamp(86px, 12vmin, 112px);
overflow: hidden; overflow: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
background: rgba(0, 0, 0, .92); background: rgba(0, 0, 0, .92);
} }
.easy-image-lightbox-preview { .easy-image-lightbox-viewport {
display: block; position: relative;
place-self: center; width: 100%;
height: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
width: auto; overflow: hidden;
height: auto; touch-action: none;
max-width: 100%; user-select: none;
max-height: 100%; }
max-inline-size: 100%;
max-block-size: 100%; .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; margin: 0;
transform-origin: 0 0;
object-fit: contain; object-fit: contain;
object-position: center; object-position: center;
user-select: none;
-webkit-user-drag: none;
} }
.easy-image-lightbox-close { .easy-image-lightbox-close {
position: absolute; position: absolute;
z-index: 2;
top: 18px; top: 18px;
right: 22px; right: 22px;
display: grid; display: grid;
@@ -179,6 +197,59 @@
outline: none; 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 { .easy-content-scale-toolbar {
position: absolute; position: absolute;
top: 8px; top: 8px;
@@ -275,6 +346,35 @@ body.easy-slide-scroll-fallback .slidev-layout {
.easy-content-scale-toolbar { opacity: .78; } .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 { @media print {
.easy-content-scale-toolbar, .easy-content-scale-toolbar,
.easy-image-lightbox { display: none !important; } .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 { .easy-presenter-gate {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -65,8 +74,7 @@ body.easy-presenter-locked {
} }
.easy-presenter-dialog button, .easy-presenter-dialog button,
.easy-presenter-tools button, .easy-presenter-tools button {
.easy-presenter-launcher {
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
background: var(--easy-accent); background: var(--easy-accent);
@@ -81,20 +89,6 @@ body.easy-presenter-locked {
font-weight: 600; 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 { .easy-presenter-tools {
position: fixed; position: fixed;
top: 12px; top: 12px;
@@ -52,9 +52,34 @@ interface ImagePreviewAcknowledgement {
viewerId: string 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 { interface EasySlidesSharedState {
easySlidesImagePreview?: { easySlidesImagePreview?: {
acknowledgement?: ImagePreviewAcknowledgement acknowledgement?: ImagePreviewAcknowledgement
command?: ImagePreviewCommand
request?: ImagePreviewRequest request?: ImagePreviewRequest
} }
} }
@@ -67,6 +92,15 @@ interface PendingImagePreview {
requestId: string 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 nav = useNav()
const easySharedState = sharedState as typeof sharedState & EasySlidesSharedState const easySharedState = sharedState as typeof sharedState & EasySlidesSharedState
const clientId = createClientId() const clientId = createClientId()
@@ -78,16 +112,35 @@ const imageInteractions = new Map<HTMLImageElement, ImageInteraction>()
let observer: MutationObserver | undefined let observer: MutationObserver | undefined
let resizeObserver: ResizeObserver | undefined let resizeObserver: ResizeObserver | undefined
let channel: BroadcastChannel | undefined let channel: BroadcastChannel | undefined
let imageChannel: BroadcastChannel | undefined
let lightbox: HTMLElement | undefined let lightbox: HTMLElement | undefined
let lightboxViewport: HTMLElement | undefined
let lightboxImage: HTMLImageElement | undefined let lightboxImage: HTMLImageElement | undefined
let lightboxClose: HTMLButtonElement | undefined let lightboxClose: HTMLButtonElement | undefined
let lightboxDecrease: HTMLButtonElement | undefined
let lightboxIncrease: HTMLButtonElement | undefined
let lightboxScaleOutput: HTMLOutputElement | undefined
let activeImage: HTMLImageElement | undefined let activeImage: HTMLImageElement | undefined
let activePreviewRequestId: string | undefined
let pendingImagePreview: PendingImagePreview | undefined let pendingImagePreview: PendingImagePreview | undefined
let stopAcknowledgementWatch: (() => void) | undefined let stopAcknowledgementWatch: (() => void) | undefined
let stopCommandWatch: (() => void) | undefined
let stopRequestWatch: (() => void) | undefined let stopRequestWatch: (() => void) | undefined
let fitFrame: number | undefined let fitFrame: number | undefined
let imageViewFrame: number | undefined
let pendingSharedView: ImagePreviewView | undefined
let pendingRemoteView: ImagePreviewView | undefined
let lastHandledRequestId = easySharedState.easySlidesImagePreview?.request?.id ?? '' let lastHandledRequestId = easySharedState.easySlidesImagePreview?.request?.id ?? ''
let lastHandledCommandId = easySharedState.easySlidesImagePreview?.command?.id ?? ''
let bodyOverflow = '' 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() { function createClientId() {
if (typeof crypto.randomUUID === 'function') 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() { function createLightbox() {
const overlay = document.createElement('div') const overlay = document.createElement('div')
overlay.className = 'easy-image-lightbox' overlay.className = 'easy-image-lightbox'
@@ -235,9 +542,20 @@ function createLightbox() {
overlay.setAttribute('aria-modal', 'true') overlay.setAttribute('aria-modal', 'true')
overlay.setAttribute('aria-label', '图片全屏预览') overlay.setAttribute('aria-label', '图片全屏预览')
const viewport = document.createElement('div')
viewport.className = 'easy-image-lightbox-viewport'
const preview = document.createElement('img') const preview = document.createElement('img')
preview.className = 'easy-image-lightbox-preview' preview.className = 'easy-image-lightbox-preview'
preview.alt = '' 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') const close = document.createElement('button')
close.type = 'button' close.type = 'button'
@@ -245,38 +563,66 @@ function createLightbox() {
close.setAttribute('aria-label', '关闭图片全屏预览') close.setAttribute('aria-label', '关闭图片全屏预览')
close.title = '关闭(Esc' close.title = '关闭(Esc'
close.textContent = '×' close.textContent = '×'
close.addEventListener('pointerdown', event => event.stopPropagation())
close.addEventListener('click', (event) => { close.addEventListener('click', (event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
closeLightbox() 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('pointerdown', event => event.stopPropagation())
overlay.addEventListener('click', (event) => { overlay.addEventListener('click', (event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (event.target === overlay) const clickedBackdrop = event.target === overlay || event.target === viewport
if (clickedBackdrop && !lightboxPointerMoved)
closeLightbox() closeLightbox()
lightboxPointerMoved = false
}) })
overlay.append(preview, close) overlay.append(viewport, close, toolbar)
document.body.append(overlay) document.body.append(overlay)
lightbox = overlay lightbox = overlay
lightboxViewport = viewport
lightboxImage = preview lightboxImage = preview
lightboxClose = close 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) if (!lightbox || !lightboxImage || !lightboxClose)
return return
const wasHidden = lightbox.hidden const wasHidden = lightbox.hidden
activeImage = image activeImage = image
lightboxImage.src = src activePreviewRequestId = requestId
pendingRemoteView = undefined
imageViewTouched = false
imageScale = 1
imageCenterX = 0.5
imageCenterY = 0.5
lightboxImage.alt = alt lightboxImage.alt = alt
lightbox.hidden = false lightbox.hidden = false
lightboxImage.src = src
if (wasHidden) if (wasHidden)
bodyOverflow = document.body.style.overflow bodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleLightboxKeydown, true) document.addEventListener('keydown', handleLightboxKeydown, true)
if (lightboxImage.complete && lightboxImage.naturalWidth)
requestAnimationFrame(handleLightboxImageLoad)
lightboxClose.focus() lightboxClose.focus()
} }
@@ -284,17 +630,54 @@ function openLightbox(image: HTMLImageElement) {
showLightbox(image.currentSrc || image.src, image.alt, image) 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) if (!lightbox || lightbox.hidden)
return return
if (shouldBroadcast)
broadcastImageClose()
document.removeEventListener('keydown', handleLightboxKeydown, true) document.removeEventListener('keydown', handleLightboxKeydown, true)
lightbox.hidden = 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.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 document.body.style.overflow = bodyOverflow
if (restoreFocus) if (restoreFocus)
activeImage?.focus() activeImage?.focus()
activeImage = undefined activeImage = undefined
activePreviewRequestId = undefined
imageScale = 1
imageCenterX = 0.5
imageCenterY = 0.5
imageViewTouched = false
} }
function handleLightboxKeydown(event: KeyboardEvent) { function handleLightboxKeydown(event: KeyboardEvent) {
@@ -304,8 +687,18 @@ function handleLightboxKeydown(event: KeyboardEvent) {
closeLightbox() closeLightbox()
} }
else if (event.key === 'Tab') { 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() 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) 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) { function handleImagePreviewRequest(request: ImagePreviewRequest | undefined) {
if (!request || !isProjectionRoute() || request.id === lastHandledRequestId) if (!request || !isProjectionRoute() || request.id === lastHandledRequestId)
return return
if (request.sentAt < mountedAt - 100 || Date.now() - request.sentAt > 10_000) if (request.sentAt < mountedAt - 100 || Date.now() - request.sentAt > 10_000)
return return
lastHandledRequestId = request.id lastHandledRequestId = request.id
showLightbox(request.src, request.alt) showLightbox(request.src, request.alt, undefined, request.id)
updateImagePreviewState({ updateImagePreviewState({
acknowledgement: { acknowledgement: {
requestId: request.id, 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) { function cleanupImage(image: HTMLImageElement, interaction: ImageInteraction) {
image.removeEventListener('click', interaction.click) image.removeEventListener('click', interaction.click)
image.removeEventListener('keydown', interaction.keydown) image.removeEventListener('keydown', interaction.keydown)
@@ -628,13 +1061,17 @@ function cleanupCodeHosts() {
function cleanupContent() { function cleanupContent() {
clearPendingImagePreview() clearPendingImagePreview()
closeLightbox() closeLightbox(false, false)
imageInteractions.forEach((interaction, image) => cleanupImage(image, interaction)) imageInteractions.forEach((interaction, image) => cleanupImage(image, interaction))
imageInteractions.clear() imageInteractions.clear()
lightbox?.remove() lightbox?.remove()
lightbox = undefined lightbox = undefined
lightboxViewport = undefined
lightboxImage = undefined lightboxImage = undefined
lightboxClose = undefined lightboxClose = undefined
lightboxDecrease = undefined
lightboxIncrease = undefined
lightboxScaleOutput = undefined
cleanupTableHosts() cleanupTableHosts()
cleanupCodeHosts() cleanupCodeHosts()
} }
@@ -659,11 +1096,17 @@ onMounted(() => {
if (isScaleMessage(event.data)) if (isScaleMessage(event.data))
updateScale(event.data.key, event.data.kind, event.data.scale, false) 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( stopAcknowledgementWatch = watch(
() => easySharedState.easySlidesImagePreview?.acknowledgement, () => easySharedState.easySlidesImagePreview?.acknowledgement,
handleImagePreviewAcknowledgement, handleImagePreviewAcknowledgement,
) )
stopCommandWatch = watch(
() => easySharedState.easySlidesImagePreview?.command,
handleImagePreviewCommand,
)
stopRequestWatch = watch( stopRequestWatch = watch(
() => easySharedState.easySlidesImagePreview?.request, () => easySharedState.easySlidesImagePreview?.request,
handleImagePreviewRequest, handleImagePreviewRequest,
@@ -676,7 +1119,7 @@ onMounted(() => {
scheduleCodeHeightFit() scheduleCodeHeightFit()
}) })
observer.observe(document.body, { childList: true, subtree: true }) observer.observe(document.body, { childList: true, subtree: true })
window.addEventListener('resize', scheduleCodeHeightFit) window.addEventListener('resize', handleViewportResize)
void document.fonts?.ready.then(scheduleCodeHeightFit) void document.fonts?.ready.then(scheduleCodeHeightFit)
scheduleCodeHeightFit() scheduleCodeHeightFit()
}) })
@@ -685,9 +1128,11 @@ onBeforeUnmount(() => {
observer?.disconnect() observer?.disconnect()
resizeObserver?.disconnect() resizeObserver?.disconnect()
channel?.close() channel?.close()
imageChannel?.close()
stopAcknowledgementWatch?.() stopAcknowledgementWatch?.()
stopCommandWatch?.()
stopRequestWatch?.() stopRequestWatch?.()
window.removeEventListener('resize', scheduleCodeHeightFit) window.removeEventListener('resize', handleViewportResize)
if (fitFrame !== undefined) if (fitFrame !== undefined)
cancelAnimationFrame(fitFrame) cancelAnimationFrame(fitFrame)
cleanupContent() cleanupContent()
@@ -47,10 +47,6 @@ function lock() {
syncBodyState() syncBodyState()
} }
function openPresenter() {
window.location.href = `${import.meta.env.BASE_URL}presenter/`
}
function resizeNotes(delta: number) { function resizeNotes(delta: number) {
noteSize.value = Math.min(38, Math.max(14, noteSize.value + delta)) noteSize.value = Math.min(38, Math.max(14, noteSize.value + delta))
document.documentElement.style.setProperty('--easy-presenter-note-size', `${noteSize.value}px`) document.documentElement.style.setProperty('--easy-presenter-note-size', `${noteSize.value}px`)
@@ -93,16 +89,6 @@ async function sha256(value: string) {
</script> </script>
<template> <template>
<button
v-if="!isPresenter && configuredHash"
class="easy-presenter-launcher"
type="button"
title="打开演示者模式"
@click="openPresenter"
>
演示者
</button>
<Teleport to="body"> <Teleport to="body">
<dialog <dialog
v-if="isPresenter && !unlocked" v-if="isPresenter && !unlocked"
+114 -14
View File
@@ -126,37 +126,55 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 2147483647; z-index: 2147483647;
display: grid; display: block;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
place-items: center;
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
padding: clamp(16px, 4vmin, 42px); padding: clamp(16px, 4vmin, 42px) clamp(16px, 4vmin, 42px) clamp(86px, 12vmin, 112px);
overflow: hidden; overflow: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
background: rgba(0, 0, 0, .92); background: rgba(0, 0, 0, .92);
} }
.easy-image-lightbox-preview { .easy-image-lightbox-viewport {
display: block; position: relative;
place-self: center; width: 100%;
height: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
width: auto; overflow: hidden;
height: auto; touch-action: none;
max-width: 100%; user-select: none;
max-height: 100%; }
max-inline-size: 100%;
max-block-size: 100%; .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; margin: 0;
transform-origin: 0 0;
object-fit: contain; object-fit: contain;
object-position: center; object-position: center;
user-select: none;
-webkit-user-drag: none;
} }
.easy-image-lightbox-close { .easy-image-lightbox-close {
position: absolute; position: absolute;
z-index: 2;
top: 18px; top: 18px;
right: 22px; right: 22px;
display: grid; display: grid;
@@ -179,6 +197,59 @@
outline: none; 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 { .easy-content-scale-toolbar {
position: absolute; position: absolute;
top: 8px; top: 8px;
@@ -275,6 +346,35 @@ body.easy-slide-scroll-fallback .slidev-layout {
.easy-content-scale-toolbar { opacity: .78; } .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 { @media print {
.easy-content-scale-toolbar, .easy-content-scale-toolbar,
.easy-image-lightbox { display: none !important; } .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 { .easy-presenter-gate {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -65,8 +74,7 @@ body.easy-presenter-locked {
} }
.easy-presenter-dialog button, .easy-presenter-dialog button,
.easy-presenter-tools button, .easy-presenter-tools button {
.easy-presenter-launcher {
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
background: var(--easy-accent); background: var(--easy-accent);
@@ -81,20 +89,6 @@ body.easy-presenter-locked {
font-weight: 600; 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 { .easy-presenter-tools {
position: fixed; position: fixed;
top: 12px; top: 12px;
+6
View File
@@ -85,6 +85,12 @@ describe('theme defaults', () => {
expect(controlsSource).toContain("overlay.setAttribute('aria-label', '图片全屏预览')") expect(controlsSource).toContain("overlay.setAttribute('aria-label', '图片全屏预览')")
expect(controlsSource).toContain("type ScalableKind = 'table' | 'code'") expect(controlsSource).toContain("type ScalableKind = 'table' | 'code'")
expect(controlsSource).toContain('easySlidesImagePreview') 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('sharedState')
expect(controlsSource).toContain('}, 500)') expect(controlsSource).toContain('}, 500)')
expect(scrollFallbackSource).toContain('easy-slide-scroll-fallback') 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 expect(page.getByRole('heading', { name: 'Engineering Slides', exact: true })).toBeVisible()
await page.getByRole('link', { name: 'easy-slides 快速开始' }).click() await page.getByRole('link', { name: 'easy-slides 快速开始' }).click()
await expect(page.getByRole('heading', { name: 'easy-slides', exact: true })).toBeVisible() 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 }) => { 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(secondAudienceDialog).toBeVisible()
await expect(presenterDialog).toBeHidden() await expect(presenterDialog).toBeHidden()
await expect(audienceDialog.locator('img')).toHaveAttribute('src', /cover\.svg/) 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 page.getByRole('button', { name: '关闭图片全屏预览' }).click()
await secondAudience.getByRole('button', { name: '关闭图片全屏预览' }).click() await expect(audienceDialog).toBeHidden()
await expect(secondAudienceDialog).toBeHidden()
await nextImage.press('Enter') await nextImage.press('Enter')
await expect(audienceDialog).toBeVisible() await expect(audienceDialog).toBeVisible()
await expect(secondAudienceDialog).toBeVisible() await expect(secondAudienceDialog).toBeVisible()
await expect(presenterDialog).toBeHidden() await expect(presenterDialog).toBeHidden()
await page.keyboard.press('Escape') 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 }) => { 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() 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 audienceContext = await browser.newContext()
const secondAudienceContext = await browser.newContext()
const presenterContext = await browser.newContext() const presenterContext = await browser.newContext()
const audience = await audienceContext.newPage() const audience = await audienceContext.newPage()
const secondAudience = await secondAudienceContext.newPage()
const presenter = await presenterContext.newPage() const presenter = await presenterContext.newPage()
try { try {
await audience.goto('http://127.0.0.1:4174/4') 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.goto('http://127.0.0.1:4174/presenter/?password=easy-slides-local')
await presenter.getByLabel('演示者 token').fill('test-token') await presenter.getByLabel('演示者 token').fill('test-token')
await presenter.getByRole('button', { name: '解锁' }).click() 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 expect(audience.getByRole('heading', { name: '智能图片' })).toBeVisible()
await current.locator('img.easy-image-preview-trigger').first().click() 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 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 { finally {
await audienceContext.close() await audienceContext.close()
await secondAudienceContext.close()
await presenterContext.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).not.toBeNull()
expect(overlay!.width).toBe(viewport!.width) expect(overlay!.width).toBe(viewport!.width)
expect(overlay!.height).toBe(viewport!.height) 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 page.keyboard.press('Escape')
await expect(dialog).toBeHidden() await expect(dialog).toBeHidden()
const after = await image.evaluate(element => ({ const after = await image.evaluate(element => ({