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