This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 => ({
|
||||
|
||||
Reference in New Issue
Block a user