删除 android/、ios/ 两个 Capacitor 原生工程与 capacitor.config.ts、 ionic.config.json,以及 scripts/android、scripts/ios.sh 构建脚本; 根与 apps/web 的 @capacitor/* 依赖和相关 npm scripts 一并移除。 Web 侧去掉原生分支:登录只走云乐坊容器授权面板或顶层重定向, 暗色模式不再改原生状态栏,移除 Android 物理返回键处理。 保留 @nuxtjs/ionic(Web UI 框架)、云乐坊 window.ylf 桥接与 PWA。 决策记录见 docs/decisions/0004-web-only.md。
143 lines
4.5 KiB
TypeScript
143 lines
4.5 KiB
TypeScript
/**
|
|
* 云乐坊 App 内嵌容器(apps.yunle.fun)注入的 `window.ylf` JSAPI。
|
|
* 普通浏览器中不存在,所有调用都要特性检测。
|
|
*
|
|
* @see https://github.com/YunLeFun/apps.yunle.fun/blob/main/docs/guide/jsapi.md
|
|
*/
|
|
|
|
export type YunleDisplayMode = 'immersive' | 'titlebar'
|
|
export type YunleHapticStyle = 'light' | 'medium' | 'heavy' | 'success'
|
|
|
|
export interface YunleInsets {
|
|
top: number
|
|
bottom: number
|
|
left: number
|
|
right: number
|
|
}
|
|
|
|
export interface YunleRect {
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
export interface YunleHostEnv {
|
|
platform: 'ios' | 'android'
|
|
appId: string
|
|
/** 旧版本 App 无此字段,按 immersive 处理 */
|
|
displayMode?: YunleDisplayMode
|
|
trusted: boolean
|
|
capsuleVisible: boolean
|
|
titleBarVisible?: boolean
|
|
safeAreaInsets: YunleInsets
|
|
capsuleRect: YunleRect
|
|
}
|
|
|
|
export interface YunleJSAPI {
|
|
version: number
|
|
inYunleApp: true
|
|
canIUse: (method: string) => Promise<boolean>
|
|
getEnv: () => Promise<YunleHostEnv>
|
|
haptic?: (style: YunleHapticStyle) => Promise<void>
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
ylf?: YunleJSAPI
|
|
}
|
|
}
|
|
|
|
type HostWindow = Pick<Window, 'ylf' | 'document' | 'addEventListener' | 'setTimeout'>
|
|
|
|
/** 胶囊左侧与页面内容之间留的间距 */
|
|
const CAPSULE_GAP = 8
|
|
/** Android 未能在文档开始时注入时,宿主会在页面加载完成后补注入 */
|
|
const LATE_INJECTION_DELAY = 300
|
|
|
|
export function getYunleHost(win: Pick<Window, 'ylf'> | undefined = typeof window === 'undefined' ? undefined : window): YunleJSAPI | undefined {
|
|
const host = win?.ylf
|
|
return host?.inYunleApp === true && typeof host.getEnv === 'function' ? host : undefined
|
|
}
|
|
|
|
/**
|
|
* 等待宿主注入:先同步检测,否则在 load 之后再检测一次
|
|
*/
|
|
export function waitForYunleHost(win: HostWindow | undefined = typeof window === 'undefined' ? undefined : window): Promise<YunleJSAPI | undefined> {
|
|
if (!win)
|
|
return Promise.resolve(undefined)
|
|
const host = getYunleHost(win)
|
|
if (host)
|
|
return Promise.resolve(host)
|
|
|
|
return new Promise((resolve) => {
|
|
const check = () => win.setTimeout(() => resolve(getYunleHost(win)), LATE_INJECTION_DELAY)
|
|
if (win.document.readyState === 'complete')
|
|
check()
|
|
else
|
|
win.addEventListener('load', check, { once: true })
|
|
})
|
|
}
|
|
|
|
export function resolveDisplayMode(env: Pick<YunleHostEnv, 'displayMode'>): YunleDisplayMode {
|
|
return env.displayMode === 'titlebar' ? 'titlebar' : 'immersive'
|
|
}
|
|
|
|
/**
|
|
* 胶囊在视口内时,页面右侧需要让出的宽度;标题栏显示时胶囊在视口上方(y 为负),无需避让
|
|
*/
|
|
export function capsuleInset(env: Pick<YunleHostEnv, 'capsuleVisible' | 'capsuleRect'>, viewportWidth: number): number {
|
|
const { x, y, width, height } = env.capsuleRect
|
|
if (!env.capsuleVisible || width <= 0 || y + height <= 0)
|
|
return 0
|
|
return Math.max(0, Math.ceil(viewportWidth - x + CAPSULE_GAP))
|
|
}
|
|
|
|
/**
|
|
* 把宿主环境转换为页面使用的 CSS 变量:安全区交给 Ionic,胶囊避让交给工具栏
|
|
*/
|
|
export function hostLayoutVars(env: YunleHostEnv, viewportWidth: number): Record<string, string> {
|
|
const px = (value: number) => `${Math.max(0, Math.round(value || 0))}px`
|
|
const { top, bottom, left, right } = env.safeAreaInsets
|
|
return {
|
|
'--ion-safe-area-top': px(top),
|
|
'--ion-safe-area-bottom': px(bottom),
|
|
'--ion-safe-area-left': px(left),
|
|
'--ion-safe-area-right': px(right),
|
|
'--cook-host-capsule-inset': px(capsuleInset(env, viewportWidth)),
|
|
}
|
|
}
|
|
|
|
export const YUNLE_HOST_CLASS = 'in-yunle-app'
|
|
|
|
/**
|
|
* 在根元素上标记宿主形态并写入布局变量
|
|
*/
|
|
export function applyYunleHostLayout(root: HTMLElement, env: YunleHostEnv, viewportWidth: number) {
|
|
const mode = resolveDisplayMode(env)
|
|
root.classList.add(YUNLE_HOST_CLASS)
|
|
root.classList.toggle('ylf-mode-immersive', mode === 'immersive')
|
|
root.classList.toggle('ylf-mode-titlebar', mode === 'titlebar')
|
|
for (const [name, value] of Object.entries(hostLayoutVars(env, viewportWidth)))
|
|
root.style.setProperty(name, value)
|
|
}
|
|
|
|
let hapticSupport: Promise<boolean> | undefined
|
|
|
|
/**
|
|
* 宿主触感反馈;未授信或旧版本 App 时静默跳过
|
|
*/
|
|
export async function yunleHaptic(style: YunleHapticStyle = 'light') {
|
|
const host = getYunleHost()
|
|
if (!host?.haptic)
|
|
return
|
|
hapticSupport ??= host.canIUse('haptic').catch(() => false)
|
|
if (await hapticSupport)
|
|
await host.haptic(style).catch(() => {})
|
|
}
|
|
|
|
/** 仅供测试重置缓存 */
|
|
export function resetYunleHostCache() {
|
|
hapticSupport = undefined
|
|
}
|