feat: add easy-jyy Slidev theme
This commit is contained in:
@@ -0,0 +1,699 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { sharedState, useNav } from '@slidev/client'
|
||||||
|
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||||
|
|
||||||
|
type ScalableKind = 'table' | 'code'
|
||||||
|
|
||||||
|
interface ScaleMessage {
|
||||||
|
key: string
|
||||||
|
kind: ScalableKind
|
||||||
|
scale: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableBaseStyle {
|
||||||
|
tableFontSize: number
|
||||||
|
tableInlineFontSize: string
|
||||||
|
cells: Array<{
|
||||||
|
element: HTMLElement
|
||||||
|
inlinePaddingBottom: string
|
||||||
|
inlinePaddingLeft: string
|
||||||
|
inlinePaddingRight: string
|
||||||
|
inlinePaddingTop: string
|
||||||
|
paddingBottom: number
|
||||||
|
paddingLeft: number
|
||||||
|
paddingRight: number
|
||||||
|
paddingTop: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeBaseStyle {
|
||||||
|
codeFontSize: number
|
||||||
|
createdHost: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageInteraction {
|
||||||
|
ariaLabel: string | null
|
||||||
|
role: string | null
|
||||||
|
tabIndex: string | null
|
||||||
|
click: (event: MouseEvent) => void
|
||||||
|
keydown: (event: KeyboardEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImagePreviewRequest {
|
||||||
|
alt: string
|
||||||
|
id: string
|
||||||
|
sentAt: number
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImagePreviewAcknowledgement {
|
||||||
|
requestId: string
|
||||||
|
sentAt: number
|
||||||
|
viewerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EasySlidesSharedState {
|
||||||
|
easySlidesImagePreview?: {
|
||||||
|
acknowledgement?: ImagePreviewAcknowledgement
|
||||||
|
request?: ImagePreviewRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingImagePreview {
|
||||||
|
cleanupTimer?: ReturnType<typeof setTimeout>
|
||||||
|
fallbackOpened: boolean
|
||||||
|
fallbackTimer: ReturnType<typeof setTimeout>
|
||||||
|
image: HTMLImageElement
|
||||||
|
requestId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav = useNav()
|
||||||
|
const easySharedState = sharedState as typeof sharedState & EasySlidesSharedState
|
||||||
|
const clientId = createClientId()
|
||||||
|
const mountedAt = Date.now()
|
||||||
|
const scales = new Map<string, number>()
|
||||||
|
const tableStyles = new WeakMap<HTMLTableElement, TableBaseStyle>()
|
||||||
|
const codeStyles = new WeakMap<HTMLPreElement, CodeBaseStyle>()
|
||||||
|
const imageInteractions = new Map<HTMLImageElement, ImageInteraction>()
|
||||||
|
let observer: MutationObserver | undefined
|
||||||
|
let resizeObserver: ResizeObserver | undefined
|
||||||
|
let channel: BroadcastChannel | undefined
|
||||||
|
let lightbox: HTMLElement | undefined
|
||||||
|
let lightboxImage: HTMLImageElement | undefined
|
||||||
|
let lightboxClose: HTMLButtonElement | undefined
|
||||||
|
let activeImage: HTMLImageElement | undefined
|
||||||
|
let pendingImagePreview: PendingImagePreview | undefined
|
||||||
|
let stopAcknowledgementWatch: (() => void) | undefined
|
||||||
|
let stopRequestWatch: (() => void) | undefined
|
||||||
|
let fitFrame: number | undefined
|
||||||
|
let lastHandledRequestId = easySharedState.easySlidesImagePreview?.request?.id ?? ''
|
||||||
|
let bodyOverflow = ''
|
||||||
|
|
||||||
|
function createClientId() {
|
||||||
|
if (typeof crypto.randomUUID === 'function')
|
||||||
|
return crypto.randomUUID()
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInteractiveRoute() {
|
||||||
|
if (/(?:^|\/)(?:overview|export|editor|notes|notes-edit|print)(?:\/|$)/.test(window.location.pathname))
|
||||||
|
return false
|
||||||
|
return nav.isPresenter.value || nav.hasPrimarySlide.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuditRoute() {
|
||||||
|
return new URLSearchParams(window.location.search).has('easy-slides-audit')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectionRoute() {
|
||||||
|
return !isAuditRoute() && isInteractiveRoute() && !nav.isPresenter.value && nav.hasPrimarySlide.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampScale(scale: number) {
|
||||||
|
return Math.min(1.6, Math.max(0.6, Math.round(scale * 10) / 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSlideNumber(element: Element) {
|
||||||
|
const page = element.closest<HTMLElement>('[class*="slidev-page-"]')
|
||||||
|
const pageClass = page && Array.from(page.classList).find(className => /^slidev-page-\d+$/.test(className))
|
||||||
|
return pageClass?.replace('slidev-page-', '') ?? 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentKey(element: Element, kind: ScalableKind) {
|
||||||
|
const layout = element.closest('.slidev-layout')
|
||||||
|
const selector = kind === 'table' ? 'table' : 'pre'
|
||||||
|
const elements = layout ? Array.from(layout.querySelectorAll(selector)) : [element]
|
||||||
|
const index = Math.max(0, elements.indexOf(element))
|
||||||
|
return `slide:${findSlideNumber(element)}:${kind}:${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createButton(label: string, text: string, onClick: () => void) {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.type = 'button'
|
||||||
|
button.className = 'easy-content-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 kindLabel(kind: ScalableKind) {
|
||||||
|
return kind === 'table' ? '表格' : '代码'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToolbar(key: string, kind: ScalableKind) {
|
||||||
|
const label = kindLabel(kind)
|
||||||
|
const toolbar = document.createElement('nav')
|
||||||
|
toolbar.className = 'easy-content-scale-toolbar'
|
||||||
|
toolbar.setAttribute('aria-label', `${label}缩放`)
|
||||||
|
toolbar.dataset.easyScaleKey = key
|
||||||
|
toolbar.dataset.easyScaleKind = kind
|
||||||
|
|
||||||
|
const decrease = createButton(`缩小${label}`, '−', () => updateScale(key, kind, currentScale(key) - 0.1))
|
||||||
|
const reset = createButton(`重置${label}缩放`, '100%', () => updateScale(key, kind, 1))
|
||||||
|
reset.classList.add('easy-content-scale-reset')
|
||||||
|
const increase = createButton(`放大${label}`, '+', () => updateScale(key, kind, currentScale(key) + 0.1))
|
||||||
|
toolbar.append(decrease, reset, increase)
|
||||||
|
return toolbar
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentScale(key: string) {
|
||||||
|
return scales.get(key) ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScale(key: string, kind: ScalableKind, requestedScale: number, shouldBroadcast = true) {
|
||||||
|
const scale = clampScale(requestedScale)
|
||||||
|
scales.set(key, scale)
|
||||||
|
applyScale(key, kind, scale)
|
||||||
|
if (shouldBroadcast)
|
||||||
|
channel?.postMessage({ key, kind, scale } satisfies ScaleMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateResetButton(host: HTMLElement, scale: number) {
|
||||||
|
const reset = host.querySelector<HTMLButtonElement>('.easy-content-scale-reset')
|
||||||
|
if (!reset)
|
||||||
|
return
|
||||||
|
const percentage = `${Math.round(scale * 100)}%`
|
||||||
|
reset.textContent = percentage
|
||||||
|
reset.title = `${reset.getAttribute('aria-label')}(当前 ${percentage})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScale(key: string, kind: ScalableKind, scale: number) {
|
||||||
|
const hostSelector = kind === 'table' ? '.easy-content-table-host' : '.easy-content-code-host'
|
||||||
|
document.querySelectorAll<HTMLElement>(hostSelector).forEach((host) => {
|
||||||
|
if (host.dataset.easyScaleKey !== key)
|
||||||
|
return
|
||||||
|
updateResetButton(host, scale)
|
||||||
|
|
||||||
|
if (kind === 'code') {
|
||||||
|
const code = host.querySelector<HTMLPreElement>('pre')
|
||||||
|
const base = code && codeStyles.get(code)
|
||||||
|
if (!code || !base)
|
||||||
|
return
|
||||||
|
if (scale === 1)
|
||||||
|
host.style.removeProperty('--easy-code-scaled-font-size')
|
||||||
|
else
|
||||||
|
host.style.setProperty('--easy-code-scaled-font-size', `${base.codeFontSize * scale}px`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = host.querySelector<HTMLTableElement>('table')
|
||||||
|
const base = table && tableStyles.get(table)
|
||||||
|
if (!table || !base)
|
||||||
|
return
|
||||||
|
if (scale === 1) {
|
||||||
|
table.style.fontSize = base.tableInlineFontSize
|
||||||
|
base.cells.forEach((cell) => {
|
||||||
|
cell.element.style.paddingTop = cell.inlinePaddingTop
|
||||||
|
cell.element.style.paddingRight = cell.inlinePaddingRight
|
||||||
|
cell.element.style.paddingBottom = cell.inlinePaddingBottom
|
||||||
|
cell.element.style.paddingLeft = cell.inlinePaddingLeft
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
table.style.fontSize = `${base.tableFontSize * scale}px`
|
||||||
|
base.cells.forEach((cell) => {
|
||||||
|
cell.element.style.paddingTop = `${cell.paddingTop * scale}px`
|
||||||
|
cell.element.style.paddingRight = `${cell.paddingRight * scale}px`
|
||||||
|
cell.element.style.paddingBottom = `${cell.paddingBottom * scale}px`
|
||||||
|
cell.element.style.paddingLeft = `${cell.paddingLeft * scale}px`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLightbox() {
|
||||||
|
const overlay = document.createElement('div')
|
||||||
|
overlay.className = 'easy-image-lightbox'
|
||||||
|
overlay.hidden = true
|
||||||
|
overlay.setAttribute('role', 'dialog')
|
||||||
|
overlay.setAttribute('aria-modal', 'true')
|
||||||
|
overlay.setAttribute('aria-label', '图片全屏预览')
|
||||||
|
|
||||||
|
const preview = document.createElement('img')
|
||||||
|
preview.className = 'easy-image-lightbox-preview'
|
||||||
|
preview.alt = ''
|
||||||
|
|
||||||
|
const close = document.createElement('button')
|
||||||
|
close.type = 'button'
|
||||||
|
close.className = 'easy-image-lightbox-close'
|
||||||
|
close.setAttribute('aria-label', '关闭图片全屏预览')
|
||||||
|
close.title = '关闭(Esc)'
|
||||||
|
close.textContent = '×'
|
||||||
|
close.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
closeLightbox()
|
||||||
|
})
|
||||||
|
|
||||||
|
overlay.addEventListener('pointerdown', event => event.stopPropagation())
|
||||||
|
overlay.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.target === overlay)
|
||||||
|
closeLightbox()
|
||||||
|
})
|
||||||
|
overlay.append(preview, close)
|
||||||
|
document.body.append(overlay)
|
||||||
|
lightbox = overlay
|
||||||
|
lightboxImage = preview
|
||||||
|
lightboxClose = close
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLightbox(src: string, alt: string, image?: HTMLImageElement) {
|
||||||
|
if (!lightbox || !lightboxImage || !lightboxClose)
|
||||||
|
return
|
||||||
|
const wasHidden = lightbox.hidden
|
||||||
|
activeImage = image
|
||||||
|
lightboxImage.src = src
|
||||||
|
lightboxImage.alt = alt
|
||||||
|
lightbox.hidden = false
|
||||||
|
if (wasHidden)
|
||||||
|
bodyOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
document.addEventListener('keydown', handleLightboxKeydown, true)
|
||||||
|
lightboxClose.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLightbox(image: HTMLImageElement) {
|
||||||
|
showLightbox(image.currentSrc || image.src, image.alt, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox(restoreFocus = true) {
|
||||||
|
if (!lightbox || lightbox.hidden)
|
||||||
|
return
|
||||||
|
document.removeEventListener('keydown', handleLightboxKeydown, true)
|
||||||
|
lightbox.hidden = true
|
||||||
|
if (lightboxImage)
|
||||||
|
lightboxImage.removeAttribute('src')
|
||||||
|
document.body.style.overflow = bodyOverflow
|
||||||
|
if (restoreFocus)
|
||||||
|
activeImage?.focus()
|
||||||
|
activeImage = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLightboxKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
closeLightbox()
|
||||||
|
}
|
||||||
|
else if (event.key === 'Tab') {
|
||||||
|
event.preventDefault()
|
||||||
|
lightboxClose?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImagePreviewState(patch: NonNullable<EasySlidesSharedState['easySlidesImagePreview']>) {
|
||||||
|
easySharedState.easySlidesImagePreview = {
|
||||||
|
...easySharedState.easySlidesImagePreview,
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingImagePreview() {
|
||||||
|
if (!pendingImagePreview)
|
||||||
|
return
|
||||||
|
clearTimeout(pendingImagePreview.fallbackTimer)
|
||||||
|
if (pendingImagePreview.cleanupTimer)
|
||||||
|
clearTimeout(pendingImagePreview.cleanupTimer)
|
||||||
|
pendingImagePreview = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestProjectionLightbox(image: HTMLImageElement) {
|
||||||
|
clearPendingImagePreview()
|
||||||
|
const request: ImagePreviewRequest = {
|
||||||
|
alt: image.alt,
|
||||||
|
id: createClientId(),
|
||||||
|
sentAt: Date.now(),
|
||||||
|
src: image.currentSrc || image.src,
|
||||||
|
}
|
||||||
|
const pending: PendingImagePreview = {
|
||||||
|
fallbackOpened: false,
|
||||||
|
fallbackTimer: setTimeout(() => {
|
||||||
|
if (pendingImagePreview?.requestId !== request.id)
|
||||||
|
return
|
||||||
|
pendingImagePreview.fallbackOpened = true
|
||||||
|
openLightbox(image)
|
||||||
|
pendingImagePreview.cleanupTimer = setTimeout(() => {
|
||||||
|
if (pendingImagePreview?.requestId === request.id)
|
||||||
|
pendingImagePreview = undefined
|
||||||
|
}, 10_000)
|
||||||
|
}, 500),
|
||||||
|
image,
|
||||||
|
requestId: request.id,
|
||||||
|
}
|
||||||
|
pendingImagePreview = pending
|
||||||
|
updateImagePreviewState({ request })
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateImage(image: HTMLImageElement) {
|
||||||
|
if (nav.isPresenter.value)
|
||||||
|
requestProjectionLightbox(image)
|
||||||
|
else
|
||||||
|
openLightbox(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImagePreviewAcknowledgement(acknowledgement: ImagePreviewAcknowledgement | undefined) {
|
||||||
|
if (!acknowledgement || !pendingImagePreview || acknowledgement.requestId !== pendingImagePreview.requestId)
|
||||||
|
return
|
||||||
|
const pending = pendingImagePreview
|
||||||
|
clearPendingImagePreview()
|
||||||
|
if (pending.fallbackOpened && activeImage === pending.image)
|
||||||
|
closeLightbox(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImagePreviewRequest(request: ImagePreviewRequest | undefined) {
|
||||||
|
if (!request || !isProjectionRoute() || request.id === lastHandledRequestId)
|
||||||
|
return
|
||||||
|
if (request.sentAt < mountedAt - 100 || Date.now() - request.sentAt > 10_000)
|
||||||
|
return
|
||||||
|
lastHandledRequestId = request.id
|
||||||
|
showLightbox(request.src, request.alt)
|
||||||
|
updateImagePreviewState({
|
||||||
|
acknowledgement: {
|
||||||
|
requestId: request.id,
|
||||||
|
sentAt: Date.now(),
|
||||||
|
viewerId: clientId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceImage(image: HTMLImageElement) {
|
||||||
|
if (imageInteractions.has(image) || image.closest('a, button') || image.closest('.easy-image-lightbox'))
|
||||||
|
return
|
||||||
|
|
||||||
|
const click = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
activateImage(image)
|
||||||
|
}
|
||||||
|
const keydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ')
|
||||||
|
return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
activateImage(image)
|
||||||
|
}
|
||||||
|
imageInteractions.set(image, {
|
||||||
|
ariaLabel: image.getAttribute('aria-label'),
|
||||||
|
role: image.getAttribute('role'),
|
||||||
|
tabIndex: image.getAttribute('tabindex'),
|
||||||
|
click,
|
||||||
|
keydown,
|
||||||
|
})
|
||||||
|
image.classList.add('easy-image-preview-trigger')
|
||||||
|
image.tabIndex = 0
|
||||||
|
image.setAttribute('role', 'button')
|
||||||
|
if (!image.hasAttribute('aria-label'))
|
||||||
|
image.setAttribute('aria-label', image.alt ? `全屏查看图片:${image.alt}` : '全屏查看图片')
|
||||||
|
image.addEventListener('click', click)
|
||||||
|
image.addEventListener('keydown', keydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceTable(table: HTMLTableElement) {
|
||||||
|
if (table.closest('.easy-content-table-host'))
|
||||||
|
return
|
||||||
|
|
||||||
|
const key = contentKey(table, 'table')
|
||||||
|
const cells = Array.from(table.querySelectorAll<HTMLElement>('th, td')).map((element) => {
|
||||||
|
const style = getComputedStyle(element)
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
inlinePaddingBottom: element.style.paddingBottom,
|
||||||
|
inlinePaddingLeft: element.style.paddingLeft,
|
||||||
|
inlinePaddingRight: element.style.paddingRight,
|
||||||
|
inlinePaddingTop: element.style.paddingTop,
|
||||||
|
paddingBottom: Number.parseFloat(style.paddingBottom),
|
||||||
|
paddingLeft: Number.parseFloat(style.paddingLeft),
|
||||||
|
paddingRight: Number.parseFloat(style.paddingRight),
|
||||||
|
paddingTop: Number.parseFloat(style.paddingTop),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tableStyles.set(table, {
|
||||||
|
tableFontSize: Number.parseFloat(getComputedStyle(table).fontSize),
|
||||||
|
tableInlineFontSize: table.style.fontSize,
|
||||||
|
cells,
|
||||||
|
})
|
||||||
|
|
||||||
|
const host = document.createElement('div')
|
||||||
|
host.className = 'easy-content-scale-host easy-content-table-host'
|
||||||
|
host.dataset.easyScaleKey = key
|
||||||
|
host.dataset.easyScaleKind = 'table'
|
||||||
|
table.replaceWith(host)
|
||||||
|
host.append(table, createToolbar(key, 'table'))
|
||||||
|
applyScale(key, 'table', currentScale(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceCode(code: HTMLPreElement) {
|
||||||
|
if (code.closest('.easy-content-code-host'))
|
||||||
|
return
|
||||||
|
|
||||||
|
const key = contentKey(code, 'code')
|
||||||
|
let host = code.closest<HTMLElement>('.slidev-code-wrapper')
|
||||||
|
const createdHost = !host
|
||||||
|
if (!host) {
|
||||||
|
host = document.createElement('div')
|
||||||
|
code.replaceWith(host)
|
||||||
|
host.append(code)
|
||||||
|
}
|
||||||
|
host.classList.add('easy-content-scale-host', 'easy-content-code-host')
|
||||||
|
host.dataset.easyScaleKey = key
|
||||||
|
host.dataset.easyScaleKind = 'code'
|
||||||
|
host.dataset.easyCodeHostCreated = createdHost ? 'true' : 'false'
|
||||||
|
codeStyles.set(code, {
|
||||||
|
codeFontSize: Number.parseFloat(getComputedStyle(code).fontSize),
|
||||||
|
createdHost,
|
||||||
|
})
|
||||||
|
host.append(createToolbar(key, 'code'))
|
||||||
|
applyScale(key, 'code', currentScale(key))
|
||||||
|
resizeObserver?.observe(code.closest('.slidev-layout') ?? host)
|
||||||
|
scheduleCodeHeightFit()
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceContent(root: ParentNode = document) {
|
||||||
|
const images = Array.from(root.querySelectorAll<HTMLImageElement>('.slidev-layout img'))
|
||||||
|
const tables = Array.from(root.querySelectorAll<HTMLTableElement>('.slidev-layout table'))
|
||||||
|
const codes = Array.from(root.querySelectorAll<HTMLPreElement>('.slidev-layout pre'))
|
||||||
|
if (root instanceof HTMLImageElement && root.closest('.slidev-layout'))
|
||||||
|
images.unshift(root)
|
||||||
|
if (root instanceof HTMLTableElement && root.closest('.slidev-layout'))
|
||||||
|
tables.unshift(root)
|
||||||
|
if (root instanceof HTMLPreElement && root.closest('.slidev-layout'))
|
||||||
|
codes.unshift(root)
|
||||||
|
images.forEach(enhanceImage)
|
||||||
|
tables.forEach(enhanceTable)
|
||||||
|
codes.forEach(enhanceCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeHeightRegion(code: HTMLPreElement) {
|
||||||
|
return code.closest<HTMLElement>('.easy-two-cols-grid > div')
|
||||||
|
?? code.closest<HTMLElement>('.slidev-layout')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitCodeHeight(code: HTMLPreElement) {
|
||||||
|
if (code.style.height || code.style.maxHeight)
|
||||||
|
return true
|
||||||
|
return Boolean(getComputedStyle(code).getPropertyValue('--easy-code-height').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableCodeHeight(code: HTMLPreElement, region: HTMLElement) {
|
||||||
|
const layout = code.closest<HTMLElement>('.slidev-layout')
|
||||||
|
if (!layout || layout.clientHeight <= 0)
|
||||||
|
return 0
|
||||||
|
const layoutRect = layout.getBoundingClientRect()
|
||||||
|
const regionRect = region.getBoundingClientRect()
|
||||||
|
const codeRect = code.getBoundingClientRect()
|
||||||
|
if (layoutRect.height <= 0 || regionRect.height <= 0 || codeRect.width <= 0)
|
||||||
|
return 0
|
||||||
|
const scale = layoutRect.height / layout.clientHeight
|
||||||
|
if (!Number.isFinite(scale) || scale <= 0)
|
||||||
|
return 0
|
||||||
|
const regionPaddingBottom = Number.parseFloat(getComputedStyle(region).paddingBottom) || 0
|
||||||
|
const layoutPaddingBottom = Number.parseFloat(getComputedStyle(layout).paddingBottom) || 0
|
||||||
|
const regionBottom = regionRect.bottom - regionPaddingBottom * scale
|
||||||
|
const layoutBottom = layoutRect.bottom - layoutPaddingBottom * scale
|
||||||
|
return Math.max(0, Math.floor((Math.min(regionBottom, layoutBottom) - codeRect.top) / scale))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHostLength(host: HTMLElement, property: string, value?: number) {
|
||||||
|
const next = value && value > 0 ? `${value}px` : ''
|
||||||
|
if (host.style.getPropertyValue(property) === next)
|
||||||
|
return
|
||||||
|
if (next)
|
||||||
|
host.style.setProperty(property, next)
|
||||||
|
else
|
||||||
|
host.style.removeProperty(property)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitCodeHeights() {
|
||||||
|
const groups = new Map<HTMLElement, HTMLPreElement[]>()
|
||||||
|
document.querySelectorAll<HTMLPreElement>('.easy-content-code-host pre').forEach((code) => {
|
||||||
|
const host = code.closest<HTMLElement>('.easy-content-code-host')
|
||||||
|
if (!host)
|
||||||
|
return
|
||||||
|
if (hasExplicitCodeHeight(code)) {
|
||||||
|
setHostLength(host, '--easy-code-auto-height')
|
||||||
|
setHostLength(host, '--easy-code-auto-max-height')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const region = codeHeightRegion(code)
|
||||||
|
if (!region)
|
||||||
|
return
|
||||||
|
const group = groups.get(region) ?? []
|
||||||
|
group.push(code)
|
||||||
|
groups.set(region, group)
|
||||||
|
})
|
||||||
|
|
||||||
|
groups.forEach((codes, region) => {
|
||||||
|
const single = codes.length === 1
|
||||||
|
codes.forEach((code) => {
|
||||||
|
const host = code.closest<HTMLElement>('.easy-content-code-host')
|
||||||
|
if (!host)
|
||||||
|
return
|
||||||
|
const available = availableCodeHeight(code, region)
|
||||||
|
if (!available)
|
||||||
|
return
|
||||||
|
setHostLength(host, '--easy-code-auto-height', single ? available : undefined)
|
||||||
|
setHostLength(host, '--easy-code-auto-max-height', available)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCodeHeightFit() {
|
||||||
|
if (fitFrame !== undefined)
|
||||||
|
return
|
||||||
|
fitFrame = requestAnimationFrame(() => {
|
||||||
|
fitFrame = undefined
|
||||||
|
fitCodeHeights()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupImage(image: HTMLImageElement, interaction: ImageInteraction) {
|
||||||
|
image.removeEventListener('click', interaction.click)
|
||||||
|
image.removeEventListener('keydown', interaction.keydown)
|
||||||
|
image.classList.remove('easy-image-preview-trigger')
|
||||||
|
restoreAttribute(image, 'aria-label', interaction.ariaLabel)
|
||||||
|
restoreAttribute(image, 'role', interaction.role)
|
||||||
|
restoreAttribute(image, 'tabindex', interaction.tabIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreAttribute(element: Element, name: string, value: string | null) {
|
||||||
|
if (value === null)
|
||||||
|
element.removeAttribute(name)
|
||||||
|
else
|
||||||
|
element.setAttribute(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupTableHosts() {
|
||||||
|
document.querySelectorAll<HTMLElement>('.easy-content-table-host').forEach((host) => {
|
||||||
|
const table = host.querySelector<HTMLTableElement>('table')
|
||||||
|
const base = table && tableStyles.get(table)
|
||||||
|
if (!table)
|
||||||
|
return
|
||||||
|
if (base) {
|
||||||
|
table.style.fontSize = base.tableInlineFontSize
|
||||||
|
base.cells.forEach((cell) => {
|
||||||
|
cell.element.style.paddingTop = cell.inlinePaddingTop
|
||||||
|
cell.element.style.paddingRight = cell.inlinePaddingRight
|
||||||
|
cell.element.style.paddingBottom = cell.inlinePaddingBottom
|
||||||
|
cell.element.style.paddingLeft = cell.inlinePaddingLeft
|
||||||
|
})
|
||||||
|
}
|
||||||
|
host.replaceWith(table)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupCodeHosts() {
|
||||||
|
document.querySelectorAll<HTMLElement>('.easy-content-code-host').forEach((host) => {
|
||||||
|
const code = host.querySelector<HTMLPreElement>('pre')
|
||||||
|
const base = code && codeStyles.get(code)
|
||||||
|
host.querySelector(':scope > .easy-content-scale-toolbar')?.remove()
|
||||||
|
host.style.removeProperty('--easy-code-scaled-font-size')
|
||||||
|
host.style.removeProperty('--easy-code-auto-height')
|
||||||
|
host.style.removeProperty('--easy-code-auto-max-height')
|
||||||
|
host.classList.remove('easy-content-scale-host', 'easy-content-code-host')
|
||||||
|
delete host.dataset.easyScaleKey
|
||||||
|
delete host.dataset.easyScaleKind
|
||||||
|
delete host.dataset.easyCodeHostCreated
|
||||||
|
if (code && base?.createdHost)
|
||||||
|
host.replaceWith(code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupContent() {
|
||||||
|
clearPendingImagePreview()
|
||||||
|
closeLightbox()
|
||||||
|
imageInteractions.forEach((interaction, image) => cleanupImage(image, interaction))
|
||||||
|
imageInteractions.clear()
|
||||||
|
lightbox?.remove()
|
||||||
|
lightbox = undefined
|
||||||
|
lightboxImage = undefined
|
||||||
|
lightboxClose = undefined
|
||||||
|
cleanupTableHosts()
|
||||||
|
cleanupCodeHosts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScaleMessage(value: unknown): value is ScaleMessage {
|
||||||
|
if (!value || typeof value !== 'object')
|
||||||
|
return false
|
||||||
|
const message = value as Partial<ScaleMessage>
|
||||||
|
return typeof message.key === 'string'
|
||||||
|
&& (message.kind === 'table' || message.kind === 'code')
|
||||||
|
&& typeof message.scale === 'number'
|
||||||
|
&& Number.isFinite(message.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!isInteractiveRoute())
|
||||||
|
return
|
||||||
|
createLightbox()
|
||||||
|
if ('BroadcastChannel' in window) {
|
||||||
|
channel = new BroadcastChannel(`easy-slides:content-scale:${import.meta.env.BASE_URL}`)
|
||||||
|
channel.addEventListener('message', (event) => {
|
||||||
|
if (isScaleMessage(event.data))
|
||||||
|
updateScale(event.data.key, event.data.kind, event.data.scale, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stopAcknowledgementWatch = watch(
|
||||||
|
() => easySharedState.easySlidesImagePreview?.acknowledgement,
|
||||||
|
handleImagePreviewAcknowledgement,
|
||||||
|
)
|
||||||
|
stopRequestWatch = watch(
|
||||||
|
() => easySharedState.easySlidesImagePreview?.request,
|
||||||
|
handleImagePreviewRequest,
|
||||||
|
)
|
||||||
|
resizeObserver = new ResizeObserver(scheduleCodeHeightFit)
|
||||||
|
enhanceContent()
|
||||||
|
document.querySelectorAll<HTMLElement>('.slidev-layout').forEach(layout => resizeObserver?.observe(layout))
|
||||||
|
observer = new MutationObserver((records) => {
|
||||||
|
records.forEach(record => enhanceContent(record.target as ParentNode))
|
||||||
|
scheduleCodeHeightFit()
|
||||||
|
})
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true })
|
||||||
|
window.addEventListener('resize', scheduleCodeHeightFit)
|
||||||
|
void document.fonts?.ready.then(scheduleCodeHeightFit)
|
||||||
|
scheduleCodeHeightFit()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
channel?.close()
|
||||||
|
stopAcknowledgementWatch?.()
|
||||||
|
stopRequestWatch?.()
|
||||||
|
window.removeEventListener('resize', scheduleCodeHeightFit)
|
||||||
|
if (fitFrame !== undefined)
|
||||||
|
cancelAnimationFrame(fitFrame)
|
||||||
|
cleanupContent()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span hidden aria-hidden="true" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const configuredHash = import.meta.env.VITE_EASY_SLIDES_PRESENTER_HASH || ''
|
||||||
|
const isPresenter = computed(() => /\/presenter(?:\/\d+)?\/?$/.test(window.location.pathname))
|
||||||
|
const storageKey = `easy-slides:presenter:${import.meta.env.BASE_URL}`
|
||||||
|
const unlocked = ref(false)
|
||||||
|
const token = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const noteSize = ref(20)
|
||||||
|
const contrast = ref(false)
|
||||||
|
const gate = ref<HTMLDialogElement>()
|
||||||
|
const tokenInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
unlocked.value = sessionStorage.getItem(storageKey) === configuredHash
|
||||||
|
|| (!configuredHash && import.meta.env.DEV)
|
||||||
|
syncBodyState()
|
||||||
|
await syncPresenterGate()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (gate.value?.open)
|
||||||
|
gate.value.close()
|
||||||
|
document.body.classList.remove('easy-presenter-active', 'easy-presenter-locked', 'easy-notes-contrast')
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(unlocked, () => void syncPresenterGate())
|
||||||
|
|
||||||
|
async function unlock() {
|
||||||
|
error.value = ''
|
||||||
|
const candidate = await sha256(token.value)
|
||||||
|
if (candidate !== configuredHash) {
|
||||||
|
error.value = 'Token 不正确'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionStorage.setItem(storageKey, configuredHash)
|
||||||
|
unlocked.value = true
|
||||||
|
token.value = ''
|
||||||
|
await nextTick()
|
||||||
|
syncBodyState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function lock() {
|
||||||
|
sessionStorage.removeItem(storageKey)
|
||||||
|
unlocked.value = false
|
||||||
|
syncBodyState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPresenter() {
|
||||||
|
window.location.href = `${import.meta.env.BASE_URL}presenter/`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeNotes(delta: number) {
|
||||||
|
noteSize.value = Math.min(38, Math.max(14, noteSize.value + delta))
|
||||||
|
document.documentElement.style.setProperty('--easy-presenter-note-size', `${noteSize.value}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleContrast() {
|
||||||
|
contrast.value = !contrast.value
|
||||||
|
syncBodyState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBodyState() {
|
||||||
|
document.body.classList.toggle('easy-presenter-active', isPresenter.value && unlocked.value)
|
||||||
|
document.body.classList.toggle('easy-notes-contrast', isPresenter.value && unlocked.value && contrast.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPresenterGate() {
|
||||||
|
await nextTick()
|
||||||
|
const shouldOpen = isPresenter.value && !unlocked.value
|
||||||
|
document.body.classList.toggle('easy-presenter-locked', shouldOpen)
|
||||||
|
if (!shouldOpen) {
|
||||||
|
if (gate.value?.open)
|
||||||
|
gate.value.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (gate.value && !gate.value.open)
|
||||||
|
gate.value.showModal()
|
||||||
|
tokenInput.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepGateOpen(event: Event) {
|
||||||
|
event.preventDefault()
|
||||||
|
tokenInput.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(value: string) {
|
||||||
|
const bytes = new TextEncoder().encode(value.trim())
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||||
|
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="!isPresenter && configuredHash"
|
||||||
|
class="easy-presenter-launcher"
|
||||||
|
type="button"
|
||||||
|
title="打开演示者模式"
|
||||||
|
@click="openPresenter"
|
||||||
|
>
|
||||||
|
演示者
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<dialog
|
||||||
|
v-if="isPresenter && !unlocked"
|
||||||
|
ref="gate"
|
||||||
|
class="easy-presenter-gate"
|
||||||
|
aria-labelledby="easy-presenter-title"
|
||||||
|
@cancel="keepGateOpen"
|
||||||
|
@click.stop
|
||||||
|
@keydown.stop
|
||||||
|
@keyup.stop
|
||||||
|
@pointerdown.stop
|
||||||
|
>
|
||||||
|
<section class="easy-presenter-dialog">
|
||||||
|
<h1 id="easy-presenter-title">演示者模式</h1>
|
||||||
|
<p v-if="configuredHash">输入 token 后查看下一页、演讲备注和计时器。</p>
|
||||||
|
<p v-else>该站点没有配置演示者 token。</p>
|
||||||
|
<form v-if="configuredHash" @submit.prevent="unlock">
|
||||||
|
<input ref="tokenInput" v-model="token" type="password" autocomplete="current-password" autofocus aria-label="演示者 token">
|
||||||
|
<button type="submit">解锁</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="error" class="easy-presenter-error" role="alert">{{ error }}</p>
|
||||||
|
</section>
|
||||||
|
</dialog>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<nav v-if="isPresenter && unlocked" class="easy-presenter-tools" aria-label="提词器设置">
|
||||||
|
<button type="button" title="减小讲稿字号" @click="resizeNotes(-2)">A−</button>
|
||||||
|
<button type="button" title="增大讲稿字号" @click="resizeNotes(2)">A+</button>
|
||||||
|
<button type="button" title="切换高对比讲稿" @click="toggleContrast">◐</button>
|
||||||
|
<button type="button" title="锁定演示者模式" @click="lock">锁定</button>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useNav } from '@slidev/client'
|
||||||
|
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||||
|
|
||||||
|
const nav = useNav()
|
||||||
|
|
||||||
|
function isInteractiveRoute() {
|
||||||
|
if (/(?:^|\/)(?:overview|export|editor|notes|notes-edit|print)(?:\/|$)/.test(window.location.pathname))
|
||||||
|
return false
|
||||||
|
return nav.isPresenter.value || nav.hasPrimarySlide.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncScrollFallback() {
|
||||||
|
document.body.classList.toggle('easy-slide-scroll-fallback', isInteractiveRoute())
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(syncScrollFallback)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[nav.isPresenter, nav.hasPrimarySlide],
|
||||||
|
syncScrollFallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.body.classList.remove('easy-slide-scroll-fallback')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span hidden aria-hidden="true" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
|
|
||||||
|
let observer: MutationObserver | undefined
|
||||||
|
|
||||||
|
function enhanceImages(root: ParentNode = document) {
|
||||||
|
root.querySelectorAll<HTMLImageElement>('.slidev-layout img').forEach((image) => {
|
||||||
|
image.classList.add('easy-smart-image')
|
||||||
|
const fit = image.getAttribute('fit')
|
||||||
|
const position = image.getAttribute('position')
|
||||||
|
const maxHeight = image.getAttribute('max-height')
|
||||||
|
if (fit)
|
||||||
|
image.style.objectFit = fit
|
||||||
|
if (position)
|
||||||
|
image.style.objectPosition = position
|
||||||
|
if (maxHeight)
|
||||||
|
image.style.maxHeight = maxHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
enhanceImages()
|
||||||
|
observer = new MutationObserver(records => records.forEach(record => enhanceImages(record.target as ParentNode)))
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => observer?.disconnect())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span hidden aria-hidden="true" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ContentScaleControls from './components/ContentScaleControls.vue'
|
||||||
|
import PresenterAccess from './components/PresenterAccess.vue'
|
||||||
|
import SlideScrollFallback from './components/SlideScrollFallback.vue'
|
||||||
|
import SmartImages from './components/SmartImages.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SmartImages />
|
||||||
|
<ContentScaleControls />
|
||||||
|
<SlideScrollFallback />
|
||||||
|
<PresenterAccess />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="slidev-layout easy-layout-center">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="slidev-layout easy-layout-cover">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="slidev-layout easy-layout-default">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { resolveImageSource } from '../utils/resolve-image'
|
||||||
|
|
||||||
|
type ImagePlacement = 'auto' | 'left' | 'right' | 'top' | 'bottom'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
image?: string
|
||||||
|
imageFit?: string
|
||||||
|
imagePosition?: string
|
||||||
|
imagePlacement?: ImagePlacement
|
||||||
|
}>(), {
|
||||||
|
imageFit: 'contain',
|
||||||
|
imagePosition: 'center',
|
||||||
|
imagePlacement: 'auto',
|
||||||
|
})
|
||||||
|
|
||||||
|
const imagePane = ref<HTMLElement>()
|
||||||
|
const detectedPlacement = ref<Exclude<ImagePlacement, 'auto'>>('right')
|
||||||
|
let observedImage: HTMLImageElement | undefined
|
||||||
|
let observer: MutationObserver | undefined
|
||||||
|
|
||||||
|
const placement = computed(() => props.imagePlacement === 'auto'
|
||||||
|
? detectedPlacement.value
|
||||||
|
: props.imagePlacement)
|
||||||
|
|
||||||
|
function detectPlacement() {
|
||||||
|
const image = imagePane.value?.querySelector<HTMLImageElement>('img')
|
||||||
|
if (!image)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (observedImage !== image) {
|
||||||
|
observedImage?.removeEventListener('load', detectPlacement)
|
||||||
|
observedImage = image
|
||||||
|
observedImage.addEventListener('load', detectPlacement)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.naturalWidth > 0 && image.naturalHeight > 0)
|
||||||
|
detectedPlacement.value = image.naturalWidth / image.naturalHeight >= 1.6 ? 'bottom' : 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
detectPlacement()
|
||||||
|
if (imagePane.value) {
|
||||||
|
observer = new MutationObserver(detectPlacement)
|
||||||
|
observer.observe(imagePane.value, { childList: true, subtree: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observedImage?.removeEventListener('load', detectPlacement)
|
||||||
|
observer?.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="slidev-layout easy-layout-image-split easy-layout-image-auto"
|
||||||
|
:class="`easy-image-${placement}`"
|
||||||
|
>
|
||||||
|
<div class="easy-image-content"><slot /></div>
|
||||||
|
<div ref="imagePane" class="easy-image-pane">
|
||||||
|
<slot name="image">
|
||||||
|
<img
|
||||||
|
v-if="image"
|
||||||
|
:src="resolveImageSource(image)"
|
||||||
|
alt=""
|
||||||
|
:style="{ objectFit: imageFit, objectPosition: imagePosition }"
|
||||||
|
>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { resolveImageSource } from '../utils/resolve-image'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
image?: string
|
||||||
|
imageFit?: string
|
||||||
|
imagePosition?: string
|
||||||
|
}>(), {
|
||||||
|
imageFit: 'cover',
|
||||||
|
imagePosition: 'center',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="slidev-layout easy-layout-image-full">
|
||||||
|
<slot name="image">
|
||||||
|
<img v-if="image" :src="resolveImageSource(image)" alt="" :style="{ objectFit: imageFit, objectPosition: imagePosition }">
|
||||||
|
</slot>
|
||||||
|
<div class="easy-image-overlay"><slot /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { resolveImageSource } from '../utils/resolve-image'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
image?: string
|
||||||
|
imageFit?: string
|
||||||
|
imagePosition?: string
|
||||||
|
}>(), {
|
||||||
|
imageFit: 'contain',
|
||||||
|
imagePosition: 'center',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="slidev-layout easy-layout-image-split easy-image-left">
|
||||||
|
<div class="easy-image-content"><slot /></div>
|
||||||
|
<div class="easy-image-pane">
|
||||||
|
<slot name="image">
|
||||||
|
<img v-if="image" :src="resolveImageSource(image)" alt="" :style="{ objectFit: imageFit, objectPosition: imagePosition }">
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { resolveImageSource } from '../utils/resolve-image'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
image?: string
|
||||||
|
imageFit?: string
|
||||||
|
imagePosition?: string
|
||||||
|
}>(), {
|
||||||
|
imageFit: 'contain',
|
||||||
|
imagePosition: 'center',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="slidev-layout easy-layout-image-split easy-image-right">
|
||||||
|
<div class="easy-image-content"><slot /></div>
|
||||||
|
<div class="easy-image-pane">
|
||||||
|
<slot name="image">
|
||||||
|
<img v-if="image" :src="resolveImageSource(image)" alt="" :style="{ objectFit: imageFit, objectPosition: imagePosition }">
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="slidev-layout easy-layout-quote">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="slidev-layout easy-layout-section">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div class="slidev-layout easy-layout-two-cols">
|
||||||
|
<slot />
|
||||||
|
<div class="easy-two-cols-grid">
|
||||||
|
<div><slot name="left" /></div>
|
||||||
|
<div><slot name="right" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "slidev-theme-easy-jyy",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "JYY-inspired presentation theme for easy-slides and Slidev",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": false,
|
||||||
|
"type": "module",
|
||||||
|
"keywords": ["slidev-theme", "slidev"],
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.12.0",
|
||||||
|
"slidev": ">=52.0.0"
|
||||||
|
},
|
||||||
|
"slidev": {
|
||||||
|
"defaults": {
|
||||||
|
"aspectRatio": "4/3",
|
||||||
|
"canvasWidth": 1024,
|
||||||
|
"colorSchema": "light",
|
||||||
|
"fonts": {
|
||||||
|
"sans": "Lato,Noto Sans SC,PingFang SC,Microsoft YaHei,sans-serif",
|
||||||
|
"mono": "Inconsolata,JetBrains Mono,Fira Code,monospace",
|
||||||
|
"serif": "Noto Serif SC,STKaiti,KaiTi,serif"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@slidev/client": "52.19.1",
|
||||||
|
"@slidev/types": "52.19.1",
|
||||||
|
"markdown-it-attrs": "4.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineMarkdownSetup } from '@slidev/types'
|
||||||
|
import markdownItAttrs from 'markdown-it-attrs'
|
||||||
|
|
||||||
|
export default defineMarkdownSetup(() => ({
|
||||||
|
markdownItSetup(md) {
|
||||||
|
md.use(markdownItAttrs)
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
:root {
|
||||||
|
--easy-accent: #1d4ed8;
|
||||||
|
--easy-accent-dark: #1e40af;
|
||||||
|
--easy-accent-soft: #eff6ff;
|
||||||
|
--easy-ink: #222;
|
||||||
|
--easy-muted: #666;
|
||||||
|
--easy-border: #ddd;
|
||||||
|
--easy-code-bg: #eee;
|
||||||
|
--easy-code-font-size: 28px;
|
||||||
|
--easy-table-font-size: 30px;
|
||||||
|
--easy-table-padding-x: 14px;
|
||||||
|
--easy-table-padding-y: 10px;
|
||||||
|
--easy-slide-padding-x: 52px;
|
||||||
|
--easy-slide-padding-y: 42px;
|
||||||
|
--easy-presenter-note-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-table-sm {
|
||||||
|
--easy-table-font-size: 24px;
|
||||||
|
--easy-table-padding-x: 10px;
|
||||||
|
--easy-table-padding-y: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-table-md {
|
||||||
|
--easy-table-font-size: 30px;
|
||||||
|
--easy-table-padding-x: 14px;
|
||||||
|
--easy-table-padding-y: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-table-lg {
|
||||||
|
--easy-table-font-size: 36px;
|
||||||
|
--easy-table-padding-x: 18px;
|
||||||
|
--easy-table-padding-y: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-code-sm { --easy-code-font-size: 22px; }
|
||||||
|
.easy-code-md { --easy-code-font-size: 28px; }
|
||||||
|
.easy-code-lg { --easy-code-font-size: 34px; }
|
||||||
|
|
||||||
|
.easy-code-height-sm { --easy-code-height: 180px; }
|
||||||
|
.easy-code-height-md { --easy-code-height: 300px; }
|
||||||
|
.easy-code-height-lg { --easy-code-height: 420px; }
|
||||||
|
|
||||||
|
.slidev-layout {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--easy-slide-padding-y) var(--easy-slide-padding-x);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1.5px solid var(--easy-border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--easy-ink);
|
||||||
|
text-align: left;
|
||||||
|
font-family: Lato, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout h1,
|
||||||
|
.slidev-layout h2,
|
||||||
|
.slidev-layout h3,
|
||||||
|
.slidev-layout h4 {
|
||||||
|
color: var(--easy-ink);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout h1 {
|
||||||
|
margin: 0 0 26px;
|
||||||
|
font-size: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout h2 {
|
||||||
|
margin: 0 0 28px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 3px solid var(--easy-accent);
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout h3 {
|
||||||
|
margin: 20px 0 14px;
|
||||||
|
font-size: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout p,
|
||||||
|
.slidev-layout ul,
|
||||||
|
.slidev-layout ol {
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout ul,
|
||||||
|
.slidev-layout ol {
|
||||||
|
display: block;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout li + li {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout a {
|
||||||
|
color: var(--easy-accent);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: .13em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout blockquote {
|
||||||
|
margin: 24px 0;
|
||||||
|
padding: 4px 0 4px 24px;
|
||||||
|
border-left: 5px solid var(--easy-accent);
|
||||||
|
color: #1e3a5f;
|
||||||
|
font-family: "Noto Serif SC", STKaiti, KaiTi, serif;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout code {
|
||||||
|
font-family: Inconsolata, "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout :not(pre) > code {
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--easy-accent-soft);
|
||||||
|
color: var(--easy-accent-dark);
|
||||||
|
padding: .08em .28em;
|
||||||
|
font-size: .86em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout pre {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: var(--easy-code-height, var(--easy-code-auto-height, auto));
|
||||||
|
max-height: var(--easy-code-height, var(--easy-code-auto-max-height, var(--easy-code-auto-height, none)));
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--easy-code-bg);
|
||||||
|
padding: 18px 20px;
|
||||||
|
font-size: var(--easy-code-scaled-font-size, var(--easy-code-font-size)) !important;
|
||||||
|
line-height: 1.38 !important;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout pre code,
|
||||||
|
.slidev-layout pre code .line {
|
||||||
|
min-width: 0 !important;
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--easy-table-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout th,
|
||||||
|
.slidev-layout td {
|
||||||
|
padding: var(--easy-table-padding-y) var(--easy-table-padding-x);
|
||||||
|
border-bottom: 1px solid #d5d5d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout th {
|
||||||
|
background: #eee;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout tr:nth-child(even) {
|
||||||
|
background: #f7faff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
margin: 16px auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout p:has(> img:only-child) {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 46vh;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout p:has(> img:only-child) > img {
|
||||||
|
width: 100%;
|
||||||
|
height: 46vh;
|
||||||
|
max-height: 60vh;
|
||||||
|
margin: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout img[fit="cover"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout img[fit="fill"] { object-fit: fill; }
|
||||||
|
.slidev-layout img[fit="scale-down"] { object-fit: scale-down; }
|
||||||
|
.slidev-layout img[fit="none"] { object-fit: none; }
|
||||||
|
|
||||||
|
.slidev-layout .katex-display,
|
||||||
|
.slidev-layout .mermaid {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-page-number {
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(0, 0, 0, .26);
|
||||||
|
padding: 3px 9px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
:root {
|
||||||
|
--easy-slide-padding-x: 34px;
|
||||||
|
--easy-slide-padding-y: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* Shared content utilities for course and technical decks. */
|
||||||
|
.slidev-layout .lead {
|
||||||
|
color: var(--easy-accent);
|
||||||
|
font-size: 1.16em;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout .muted {
|
||||||
|
color: var(--easy-muted);
|
||||||
|
font-size: .72em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout .compact,
|
||||||
|
.slidev-layout.compact {
|
||||||
|
font-size: 27px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout .compact li + li,
|
||||||
|
.slidev-layout.compact li + li {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout .outline {
|
||||||
|
list-style: none;
|
||||||
|
margin: 22px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout .outline > li {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding-left: 22px;
|
||||||
|
border-left: 5px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout .outline .current {
|
||||||
|
border-left-color: var(--easy-accent);
|
||||||
|
color: var(--easy-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout .source-figure {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 56vh;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout .course-table {
|
||||||
|
font-size: 21px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout .course-table th,
|
||||||
|
.slidev-layout .course-table td {
|
||||||
|
padding: 6px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slidev-layout .v-word {
|
||||||
|
color: var(--easy-accent);
|
||||||
|
font-size: 1.22em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-cover .cover-identity {
|
||||||
|
width: 68%;
|
||||||
|
height: 20vh;
|
||||||
|
margin: 22px 0 0;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: left center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import './base.css'
|
||||||
|
import './content.css'
|
||||||
|
import './layouts.css'
|
||||||
|
import './presenter.css'
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
.easy-layout-center,
|
||||||
|
.easy-layout-section,
|
||||||
|
.easy-layout-cover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-center {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-center h1,
|
||||||
|
.easy-layout-center h2 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-cover h1 {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
font-size: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-cover blockquote {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--easy-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-section h1,
|
||||||
|
.easy-layout-section h2 {
|
||||||
|
max-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-quote {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-quote blockquote {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--easy-accent);
|
||||||
|
font-size: 46px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-two-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-two-cols .easy-two-cols-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 46px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-image-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-image-split.easy-image-left .easy-image-pane { order: -1; }
|
||||||
|
|
||||||
|
.easy-layout-image-auto.easy-image-top,
|
||||||
|
.easy-layout-image-auto.easy-image-bottom {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-image-auto.easy-image-top .easy-image-pane { order: -1; }
|
||||||
|
|
||||||
|
.easy-image-pane {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-image-pane img {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-content-scale-host {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-content-table-host {
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-content-code-host {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-image-preview-trigger {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-image-preview-trigger:focus-visible {
|
||||||
|
outline: 3px solid var(--easy-accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-image-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
place-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: clamp(16px, 4vmin, 42px);
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
background: rgba(0, 0, 0, .92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-image-lightbox-preview {
|
||||||
|
display: block;
|
||||||
|
place-self: center;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
max-block-size: 100%;
|
||||||
|
margin: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-image-lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 22px;
|
||||||
|
display: grid;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .5);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, .55);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 0 4px;
|
||||||
|
font: 300 38px/1 Arial, sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-image-lightbox-close:hover,
|
||||||
|
.easy-image-lightbox-close:focus-visible {
|
||||||
|
border-color: #fff;
|
||||||
|
background: var(--easy-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-content-scale-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid rgba(180, 180, 180, .9);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(255, 255, 255, .94);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, .12);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-content-scale-host:hover > .easy-content-scale-toolbar,
|
||||||
|
.easy-content-scale-host:focus-within > .easy-content-scale-toolbar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-content-scale-button {
|
||||||
|
min-width: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #eee;
|
||||||
|
color: #222;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font: 600 14px/1 Lato, "Noto Sans SC", sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-content-scale-button:hover,
|
||||||
|
.easy-content-scale-button:focus-visible {
|
||||||
|
background: var(--easy-accent);
|
||||||
|
color: #fff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-content-scale-reset {
|
||||||
|
min-width: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-content-code-host > .easy-content-scale-toolbar {
|
||||||
|
right: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.easy-slide-scroll-fallback .slidev-layout {
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--easy-accent) 65%, transparent) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-image-content {
|
||||||
|
min-width: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-image-full {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-image-full > img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-image-full .easy-image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 0;
|
||||||
|
padding: 46px 54px;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, .78));
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-layout-image-full .easy-image-overlay :is(h1, h2, h3, p) {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-aspect-ratio: 4 / 3) {
|
||||||
|
.easy-layout-two-cols .easy-two-cols-grid,
|
||||||
|
.easy-layout-image-split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.easy-content-scale-toolbar { opacity: .78; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.easy-content-scale-toolbar,
|
||||||
|
.easy-image-lightbox { display: none !important; }
|
||||||
|
|
||||||
|
body.easy-slide-scroll-fallback .slidev-layout { overflow: hidden !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
.easy-presenter-gate {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483646;
|
||||||
|
display: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
place-items: center;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f6f5f2;
|
||||||
|
color: #222;
|
||||||
|
font-family: Lato, "Noto Sans SC", "PingFang SC", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-gate[open] {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-gate::backdrop {
|
||||||
|
background: #f6f5f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.easy-presenter-locked {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-dialog {
|
||||||
|
width: min(440px, calc(100vw - 40px));
|
||||||
|
border: 1px solid #d8d4d7;
|
||||||
|
border-top: 4px solid var(--easy-accent);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 34px;
|
||||||
|
box-shadow: 0 20px 70px rgba(45, 26, 42, .14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-dialog h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-dialog p {
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-dialog form {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-dialog input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-dialog button,
|
||||||
|
.easy-presenter-tools button,
|
||||||
|
.easy-presenter-launcher {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--easy-accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font: 600 14px/1.1 Lato, "Noto Sans SC", sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-error {
|
||||||
|
color: #a21d32 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-launcher {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
z-index: 60;
|
||||||
|
opacity: .22;
|
||||||
|
transition: opacity .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-launcher:hover,
|
||||||
|
.easy-presenter-launcher:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-tools {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid #d4d0d3;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, .94);
|
||||||
|
box-shadow: 0 5px 22px rgba(0, 0, 0, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-tools button {
|
||||||
|
min-width: 34px;
|
||||||
|
background: #eee9ed;
|
||||||
|
color: #351231;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easy-presenter-tools button:last-child {
|
||||||
|
background: #5d1357;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.easy-presenter-tools {
|
||||||
|
right: auto;
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.easy-presenter-active :is(.presenter-notes, [class*="presenter"] [class*="notes"], [class*="presenter"] [class*="note"] p) {
|
||||||
|
font-size: var(--easy-presenter-note-size) !important;
|
||||||
|
line-height: 1.65 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.easy-notes-contrast :is(.presenter-notes, [class*="presenter"] [class*="notes"], [class*="presenter"] [class*="note"]) {
|
||||||
|
background: #050505 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.easy-notes-contrast :is(.presenter-notes, [class*="presenter"] [class*="notes"], [class*="presenter"] [class*="note"]) * {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const deckBase = (import.meta as ImportMeta & { env: { BASE_URL: string } }).env.BASE_URL
|
||||||
|
|
||||||
|
export function resolveImageSource(source: string | undefined, base = deckBase) {
|
||||||
|
if (!source || isAbsoluteSource(source))
|
||||||
|
return source
|
||||||
|
|
||||||
|
const normalizedBase = base.endsWith('/') ? base : `${base}/`
|
||||||
|
return `${normalizedBase}${source.replace(/^\.\//, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsoluteSource(source: string) {
|
||||||
|
return source.startsWith('/')
|
||||||
|
|| source.startsWith('#')
|
||||||
|
|| /^[a-z][a-z\d+.-]*:/i.test(source)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user