import { onUnmounted, ref, type Ref } from 'vue' /** * 大屏「是否全屏」的合成判断:两套互不相干的语义取或。 * * - 窗口级:Console 里的 F11 走主进程 `win.setFullScreen()`,渲染进程的 * `document.fullscreenElement` 始终是 null,只有 preload 转达的这条能看到; * - 文档级:浏览器里直接开 `/display` 时的 HTML 全屏,`document.fullscreenElement` 才是判据。 * * 任一路判定全屏,页面就按全屏排布:窗口化标题栏让位,舞台按全屏高度重新缩放。 */ /** Console 的 display-preload.js 注入的能力;浏览器直接访问 `/display` 时不存在。 */ export interface DisplayShellApi { /** 当前窗口是否处于窗口级全屏。 */ isFullscreen: () => Promise /** 订阅窗口级全屏变化,返回取消订阅函数。 */ onFullscreenChange: (callback: (fullscreen: boolean) => void) => () => void } declare global { interface Window { displayShell?: DisplayShellApi } } /** 只用到全屏相关的两个成员,抽成窄接口便于测试注入替身。 */ export interface FullscreenDocument { fullscreenElement: Element | null addEventListener(type: 'fullscreenchange', listener: () => void): void removeEventListener(type: 'fullscreenchange', listener: () => void): void } export interface FullscreenDeps { /** 窗口级来源,默认取全局 `window.displayShell`。 */ shell?: DisplayShellApi | null /** 文档级来源,默认取全局 `document`。 */ doc?: FullscreenDocument | null } export interface FullscreenTracker { get: () => boolean /** 订阅变化(与上次同值不重复通知),返回取消订阅函数。 */ subscribe: (listener: (fullscreen: boolean) => void) => () => void /** 摘掉所有监听。 */ dispose: () => void } function resolveShell(): DisplayShellApi | null { if (typeof window === 'undefined') return null return window.displayShell ?? null } function resolveDocument(): FullscreenDocument | null { if (typeof document === 'undefined') return null return document } export function createFullscreenTracker(deps: FullscreenDeps = {}): FullscreenTracker { const shell = deps.shell === undefined ? resolveShell() : deps.shell const doc = deps.doc === undefined ? resolveDocument() : deps.doc const listeners = new Set<(fullscreen: boolean) => void>() let windowFullscreen = false // 窗口事件可能先于 isFullscreen() 的应答到达,落了标记才不会让慢半拍的应答把新状态盖回去 let windowEventSeen = false let disposed = false let last = false const current = (): boolean => windowFullscreen || (doc !== null && doc.fullscreenElement !== null) const emit = (): void => { const next = current() if (next === last) return last = next for (const listener of [...listeners]) listener(next) } const onDocumentChange = (): void => emit() if (doc) doc.addEventListener('fullscreenchange', onDocumentChange) let unsubscribeShell: (() => void) | null = null if (shell) { // 先订阅再查询:两步之间发生的切换不会被漏掉 unsubscribeShell = shell.onFullscreenChange((value) => { windowEventSeen = true windowFullscreen = value === true emit() }) // 页面加载时窗口可能已经是全屏(重载、渲染进程崩溃后重建),主动问一次 void Promise.resolve(shell.isFullscreen()).then( (value) => { if (disposed || windowEventSeen) return windowFullscreen = value === true emit() }, // 主进程没应答时保持文档级判断,不影响标题栏 () => {} ) } last = current() return { get: () => current(), subscribe: (listener) => { listeners.add(listener) return () => { listeners.delete(listener) } }, dispose: () => { disposed = true listeners.clear() if (doc) doc.removeEventListener('fullscreenchange', onDocumentChange) if (unsubscribeShell) unsubscribeShell() unsubscribeShell = null }, } } /** 组件用的响应式版本:状态变化刷新 Ref,卸载时摘掉监听。 */ export function useShellFullscreen(): Ref { const tracker = createFullscreenTracker() const fullscreen = ref(tracker.get()) const unsubscribe = tracker.subscribe((value) => { fullscreen.value = value }) onUnmounted(() => { unsubscribe() tracker.dispose() }) return fullscreen }