138 lines
4.3 KiB
Vue
138 lines
4.3 KiB
Vue
<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>
|