7.9 KiB
window.__TAURITAVERN__.api.layout — Layout API(Safe‑Area / Viewport / IME)
TauriTavern 为扩展作者提供的布局 API:用**稳定契约(CSS vars + attributes)**统一移动端 safe‑area、viewport 与 Android IME(键盘)语义;并提供 api.layout 作为读取/订阅入口,外加可选 layout-kit.js DX 糖衣。
核心理念:宿主负责“提供契约与最小准入(admission)”,扩展只需几行 opt‑in,就能稳定适配移动端;不引入新的运行时几何系统,不做黑名单堆积。
0. 快速上手(推荐:layout-kit.js)
layout-kit.js 是 TauriTavern 提供的“软 SDK(DX 糖衣)”:推荐使用,用于避免字符串拼写错误、减少样板代码。
它不会替代硬 ABI;底层仍然是 data-tt-mobile-surface + --tt-* contract。
0.1 安装(ESM import)
import {
waitForHostReady,
subscribeLayout,
SURFACE,
applySurface,
} from '/scripts/tauritavern/layout-kit.js';
await waitForHostReady();
0.2 全屏面板 + 遮罩(移动端 safe‑area + IME 自动正确)
applySurface(backdropEl, SURFACE.Backdrop);
applySurface(panelEl, SURFACE.FullscreenWindow);
fullscreen-window会进入宿主的 safe‑area/IME contract;backdrop保持 full‑bleed(不收 safe‑area)。
0.3 悬浮球/小窗(避免被贴边限制)
applySurface(bubbleEl, SURFACE.FreeWindow);
0.4 同源 iframe(viewport-host)
applySurface(iframeEl, SURFACE.ViewportHost);
iframeEl.src = '/scripts/extensions/third-party/my-ext/ui/index.html';
0.5 订阅布局快照(用于 clamp / 自定义动画等)
const unsubscribe = await subscribeLayout((snap) => {
// snap.safeInsets / snap.safeFrame / snap.ime.keyboardOffset
});
0.x 不使用 SDK(直接写硬 ABI)
如果你不想引入 layout-kit.js(或无法使用 ESM import),仍然可以直接使用硬 ABI:
await (window.__TAURITAVERN__?.ready ?? window.__TAURITAVERN_MAIN_READY__);
const layout = window.__TAURITAVERN__.api.layout;
panelEl.dataset.ttMobileSurface = 'fullscreen-window';
backdropEl.dataset.ttMobileSurface = 'backdrop';
1. 硬 ABI(Contract):扩展最推荐的接入方式
1.1 CSS 变量(stable)
这些变量是宿主对外承诺的布局输入(px):
--tt-inset-top/right/left/bottom:当前应避让的 safe‑area inset--tt-base-viewport-height:Android 下“无 IME 时”的基准 viewport 高度--tt-viewport-bottom-inset:底部可达性 inset(语义为max(--tt-inset-bottom, --tt-ime-bottom);IME 为 surface‑local,因此该变量在 active surface subtree 才会随键盘变化)--tt-ime-bottom:Android 下键盘 inset(注入到 active IME target surface root,不承诺在:root全局更新)
说明:iOS 主要使用
env(safe-area-inset-*),TauriTavern 在src/style.css中提供默认值;Android 以 native 注入为准。
1.2 data-tt-mobile-surface taxonomy(stable)
扩展可显式 opt‑in:
| 值 | 语义 | 适用场景 |
|---|---|---|
backdrop |
full‑bleed 遮罩,不收 safe‑area | modal overlay mask |
fullscreen-window |
全屏交互窗口,收四边 safe‑area | 全屏面板、弹窗根 |
free-window |
可拖拽浮窗,宿主不长期钳制 top/left | 悬浮球、小工具窗 |
viewport-host |
同源 iframe 宿主窗口(outer full‑bleed) | iframe 小应用(同源) |
edge-window |
严格 fallback(不推荐显式使用) | 仅宿主兜底 |
none |
显式声明不接入 | 极少数特殊场景 |
重要:宿主的 overlay classifier 保持克制(避免全局 subtree observer);框架型扩展请优先显式写
data-tt-mobile-surface,不要“赌 classifier 一定扫到结构”。
2. api.layout(读取/订阅布局快照)
2.1 layout.snapshot()
const snap = layout.snapshot();
console.log(snap.safeInsets.top, snap.viewport.height, snap.ime.keyboardOffset);
返回 LayoutSnapshot(字段均为 px number;activeSurface 为 Element 引用或 null):
timestampMsviewport:{ left, top, width, height, right, bottom }(优先visualViewport)safeInsets:{ top, right, bottom, left }(来自:root的--tt-inset-*)safeFrame:viewport 扣除 safeInsets 的 frameime:activeSurface:当前 active IME surface root(可能为null)kind:'composer' | 'fixed-shell' | 'dialog'bottom:active surface 的--tt-ime-bottomviewportBottomInset:max(safeInsets.bottom, bottom)keyboardOffset:max(viewportBottomInset - safeInsets.bottom, 0)
2.2 layout.subscribe(handler)
订阅会在以下信号变更时推送新快照:
resize/orientationchangevisualViewport resize/scroll(如果可用):root style变更(insets/base viewport)- active IME surface 变更/其 style 变更(IME bottom)
const unsubscribe = await layout.subscribe((snap) => {
// 更新气泡 clamp / panel padding / 自定义动画等
});
// later
await unsubscribe?.();
约束(稳定契约):
- handler 会立即收到一次快照(避免订阅后还要手动 snapshot)
unsubscribe幂等,可安全重复/延迟调用- 错误不静默:非法参数或 DOM 不可用会直接抛错(便于排查)
3. IME(Android 键盘)语义要点
3.1 为什么 --tt-ime-bottom 是 surface‑local
Android 下 IME 不再依赖 WebView viewport resize(避免双重解释键盘语义)。宿主把 IME inset 注入到“当前正在输入的 surface root”,并通过 data-tt-ime-* 标记 active surface。
因此:
- 不要假设
:root上会出现键盘高度 - 对全屏面板/弹窗,正确做法是:把面板 root 标记为
fullscreen-window(让宿主把它当作 IME target) - 如果你只需要“UI 不被挡住”,通常不需要自己计算键盘高度
3.2 keyboardOffset 的意义
keyboardOffset = max(imeViewportBottomInset - safeInsets.bottom, 0) 表示“键盘相对 safe‑area 底部新增的遮挡高度”,适合用于:
- 自定义 bottom padding / scroll-padding
- 自己的 IME 进入/退出动画
3.3 键盘可达性(scroll reachability slack)
一些 fixed-shell UI 是典型的 height: 100% + flex: 1 全高表单。此时仅靠“减高 + scroll-padding-bottom”未必能增加可滚动范围,用户体感会变成“键盘出来了,但页面并没有更能滚动”。
推荐做法:为 scroll container 末尾增加一个与 --tt-viewport-bottom-inset 对齐的 spacer(slack),并把 scroll-padding-bottom 设为 keyboardOffset(或按 contract 公式推导):
.my-scroll::after {
content: '';
display: block;
height: max(var(--tt-viewport-bottom-inset, var(--tt-inset-bottom)), 0px);
pointer-events: none;
}
.my-scroll {
scroll-padding-bottom: max(
calc(var(--tt-viewport-bottom-inset, var(--tt-inset-bottom)) - var(--tt-inset-bottom)),
0px
);
}
4. viewport-host(same-origin iframe)
仅承诺 same-origin iframe。
推荐模式:
iframe.dataset.ttMobileSurface = 'viewport-host';
iframe.src = '/scripts/extensions/third-party/my-ext/ui/index.html';
语义:
- outer
viewport-host永远 full‑bleed(不收 safe‑area,避免 replaced element shrink-to-fit) - safe‑area contract 由 iframe 内消费(宿主会把
--tt-inset-* / --tt-base-viewport-height桥接进 iframe 的:root)
5. layout-kit.js(可选 DX 糖衣)
推荐使用 layout-kit.js(DX 糖衣),用于减少样板代码并避免字符串 typo:
import { waitForHostReady, SURFACE, applySurface } from '/scripts/tauritavern/layout-kit.js';
await waitForHostReady();
applySurface(panelEl, SURFACE.FullscreenWindow);
说明:
layout-kit.js是软 SDK,不替代硬 ABI- 它只封装
api.layout与 surface opt‑in,不引入新的几何系统