feat(viewer): embed carousel iframe with visibility pause
P4b Task 4:宿主 loader 以 iframe 载入的轮播页(/embed/:slotToken,裸布局)。 - src/embed/feed.ts:纯函数集——A22 旧响应裁决(dispatch seq + configVersion 双闸:过期/回看丢弃、等值差分合并、升值全量重置)、照片队列差分(新照片 追加队尾、缺席淘汰、thumb 签名刷新)、interval/高度夹取、轮询退避; - src/embed/useEmbedFeed.ts:取数编排(If-None-Match → 304 零处理)、轮询 (查询 interval 覆盖 → EMBED_POLL_INTERVAL_MS,连续 5 失败 ×5 退避,成功 归零;404/410 静默 collapse 隐藏自身)、A23 不可见暂停(document 隐藏 + IntersectionObserver 滚出视口 + 本地收起;恢复立即校准)、轮播 intervalMs 切换;__embedPollMs e2e 注入点; - src/embed/postMessage.ts:上行 resize/collapse(referrer origin 收敛,缺失 退 '*'——载荷无敏感数据,宿主侧 loader source+origin 双校验)、下行 expand (event.source === window.parent 白名单); - EmbedPage.vue:交叉淡入淡出(仅 thumb 小图省流量)、点击 window.open 单图 深链(noopener)、计数圆点、「照片即将上线」空态卡、×收起;lang 查询 (auto→相册 defaultLocale)驱动嵌入词条子集; - api client:fetchPublicEmbedSlot(304/ETag 分支,不走 apiRequest); - e2e:宿主夹具页 public/__test__/embed-host.html(仅测试资产)+ embed.spec 7 用例(轮换/新标签深链/收起记忆/窄屏 sm/轮询增量/换绑重置/空态/幽灵槽); helpers 增展示位 admin API 种子 + flushOutbox; - vitest 18 用例(裁决矩阵/队列差分/夹取/退避/304/可见性状态机)。 配套修正:api CORS exposedHeaders ETag(跨源浏览器可读,304 语义成立); contracts 新增浏览器安全子路径 ./constants(根出口 re-export internal-sync 的 node:crypto 会打进浏览器包);eslint ignore embed-loader 产物拷贝 (Task 3 起 viewer lint 红,先修)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// apps/viewer/src/embed/feed.ts —— 嵌入轮播纯函数集(P4b Task 4)。
|
||||
// 数据流不变量(A22 旧响应丢弃 / 队列差分合并 / 轮询参数)全部收敛为纯函数,
|
||||
// vitest 直接覆盖;useEmbedFeed.ts 组合它们完成编排。
|
||||
// 浏览器安全子路径:根出口 re-export internal-sync(node:crypto),会把
|
||||
// createHmac 拖进浏览器包使 iframe 页崩溃——constants 子路径零 Node 依赖。
|
||||
import {
|
||||
EMBED_POLL_BACKOFF_MULTIPLIER,
|
||||
EMBED_POLL_INTERVAL_MS,
|
||||
EMBED_POLL_MAX_FAILURES,
|
||||
} from '@photo/contracts/constants';
|
||||
import type { PublicEmbedPhoto } from '../api/client';
|
||||
import { FALLBACK_LOCALE, SUPPORTED_LOCALES, type ViewerLocale } from '../i18n';
|
||||
|
||||
/** 轮播切换/查询 interval 覆盖的夹取界(同契约 EmbedSlotSettings.intervalMs)。 */
|
||||
export const EMBED_INTERVAL_MIN_MS = 3000;
|
||||
export const EMBED_INTERVAL_MAX_MS = 30000;
|
||||
/** 查询未提供 interval 时轮播切换缺省(同 loader DEFAULTS.intervalMs)。 */
|
||||
export const EMBED_CAROUSEL_DEFAULT_MS = 5000;
|
||||
|
||||
/** resize 上报高度夹取界(与 embed-loader HEIGHT_MIN/HEIGHT_MAX 一致,改须同步)。 */
|
||||
export const EMBED_HEIGHT_MIN = 60;
|
||||
export const EMBED_HEIGHT_MAX = 640;
|
||||
|
||||
/** interval 覆盖夹取(轮播与轮询共用同值域);null/非法 → null(表示「无覆盖」)。 */
|
||||
export function clampEmbedIntervalMs(value: number | null): number | null {
|
||||
if (value === null || !Number.isFinite(value)) return null;
|
||||
return Math.min(EMBED_INTERVAL_MAX_MS, Math.max(EMBED_INTERVAL_MIN_MS, Math.round(value)));
|
||||
}
|
||||
|
||||
/** resize 上报高度夹取:非有限数值 → null(跳过本次上报)。 */
|
||||
export function clampEmbedHeight(value: number | null): number | null {
|
||||
if (value === null || !Number.isFinite(value)) return null;
|
||||
return Math.min(EMBED_HEIGHT_MAX, Math.max(EMBED_HEIGHT_MIN, Math.round(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 嵌入页 UI 语言解析:lang=zh-CN/en → 指定值;auto/非法 → 相册 defaultLocale
|
||||
* (不支持时回退 zh-CN)。i18n 词条子集由此驱动(照片即将上线/aria 标签)。
|
||||
*/
|
||||
export function resolveEmbedLocale(langParam: string, albumDefaultLocale: string | undefined): ViewerLocale {
|
||||
if ((SUPPORTED_LOCALES as readonly string[]).includes(langParam)) {
|
||||
return langParam as ViewerLocale;
|
||||
}
|
||||
if (albumDefaultLocale !== undefined && (SUPPORTED_LOCALES as readonly string[]).includes(albumDefaultLocale)) {
|
||||
return albumDefaultLocale as ViewerLocale;
|
||||
}
|
||||
return FALLBACK_LOCALE;
|
||||
}
|
||||
|
||||
/** 响应裁决:A22 核心竞态防护(seq 与 configVersion 双闸,见 classifyEmbedResponse)。 */
|
||||
export type EmbedResponseDecision = 'discard' | 'merge' | 'reset';
|
||||
|
||||
export function classifyEmbedResponse(input: {
|
||||
/** 请求发起时取得的 seq(每次 dispatch 自增)。 */
|
||||
dispatchSeq: number;
|
||||
/** resolve 时的当前 seq(有更新请求发出即前进)。 */
|
||||
currentSeq: number;
|
||||
/** 请求发起时的本地 configVersion(首次为 null)。 */
|
||||
dispatchVersion: number | null;
|
||||
/** resolve 时的本地 configVersion。 */
|
||||
currentVersion: number | null;
|
||||
responseVersion: number;
|
||||
}): EmbedResponseDecision {
|
||||
// ① seq 过期:发起后又有新请求(轮询/可见性恢复并发)→ 本响应作废。
|
||||
if (input.dispatchSeq !== input.currentSeq) return 'discard';
|
||||
// ② 版本回看:响应版本低于本地已接受版本(更早绑定的旧数据迟到)→ 丢弃,
|
||||
// 不得串入新相册画面(A22「旧响应不串图」)。
|
||||
const local = input.currentVersion ?? 0;
|
||||
if (input.responseVersion < local) return 'discard';
|
||||
if (input.responseVersion === local) return 'merge';
|
||||
return 'reset';
|
||||
}
|
||||
|
||||
export interface MergedPhotoQueue {
|
||||
photos: PublicEmbedPhoto[];
|
||||
/** 新增(追加到队尾)的 photoId 列表(按服务端顺序)。 */
|
||||
added: string[];
|
||||
/** 被移除(服务端已不可见)的 photoId 列表。 */
|
||||
removed: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 照片队列差分合并(equal configVersion 分支):幸存项保持现有顺序、thumb URL
|
||||
* 刷新(签名短时效,200 即换新);新照片按服务端(publishedAt 倒序)顺序追加到
|
||||
* 队尾——正在展示的照片不被打断;服务端缺席项淘汰。reset 分支不走本函数。
|
||||
*/
|
||||
export function mergePhotoQueue(current: PublicEmbedPhoto[], incoming: PublicEmbedPhoto[]): MergedPhotoQueue {
|
||||
const incomingById = new Map(incoming.map((photo) => [photo.photoId, photo]));
|
||||
const survivors = current.filter((photo) => incomingById.has(photo.photoId));
|
||||
const survivorIds = new Set(survivors.map((photo) => photo.photoId));
|
||||
const added = incoming.filter((photo) => !survivorIds.has(photo.photoId));
|
||||
const photos = survivors.map((photo) => incomingById.get(photo.photoId) ?? photo).concat(added);
|
||||
const removed = current
|
||||
.filter((photo) => !incomingById.has(photo.photoId))
|
||||
.map((photo) => photo.photoId);
|
||||
return { photos, added: added.map((photo) => photo.photoId), removed };
|
||||
}
|
||||
|
||||
/** 轮询基础间隔:e2e/宿主覆盖(查询 interval,夹取)→ 契约缺省 30s。 */
|
||||
export function basePollIntervalMs(intervalParamMs: number | null): number {
|
||||
return clampEmbedIntervalMs(intervalParamMs) ?? EMBED_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询退避:连续失败达 EMBED_POLL_MAX_FAILURES(5)后间隔 ×EMBED_POLL_BACKOFF_
|
||||
* MULTIPLIER(5);成功即归零(调用方维护 failures 计数)。参数均出契约 constants。
|
||||
*/
|
||||
export function pollIntervalAfterFailures(failures: number, baseIntervalMs: number): number {
|
||||
return failures >= EMBED_POLL_MAX_FAILURES ? baseIntervalMs * EMBED_POLL_BACKOFF_MULTIPLIER : baseIntervalMs;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// apps/viewer/src/embed/postMessage.ts —— 嵌入 iframe ↔ 宿主 loader 消息通道
|
||||
// (P4b Task 4)。协议与白名单模型见 packages/embed-loader(README-embed.md):
|
||||
// 载荷恒为 {photoliveEmbed: true, type: 'resize'|'collapse'|..., ...}。
|
||||
//
|
||||
// 上行(iframe → 宿主):resize(高度)/ collapse(收起,宿主侧高度 0 + 把手)。
|
||||
// 目标 origin 尽量收敛到 document.referrer 的源(loader 的 iframe 带
|
||||
// no-referrer-when-downgrade,宿主正常配置下可得完整来源 URL);referrer 被宿主
|
||||
// 隐去时退回 '*'——载荷仅含高度/收起意图,无敏感数据,且宿主侧 loader 以
|
||||
// 「event.source === 已登记 iframe && event.origin === iframe 源」双重严格校验,
|
||||
// 通配目标不构成越权面。
|
||||
//
|
||||
// 下行(宿主 → iframe):expand(把手点击恢复)——只认 event.source ===
|
||||
// window.parent 且载荷形状匹配(source 校验即充分:宿主页内任意脚本无法伪造
|
||||
// parent 窗口引用;loader 对上行另有 origin 全等白名单)。
|
||||
export type EmbedUpMessage =
|
||||
| { photoliveEmbed: true; type: 'resize'; height: number }
|
||||
| { photoliveEmbed: true; type: 'collapse' };
|
||||
|
||||
/** 宿主源(referrer 推导);取不到(被隐去/直接打开)为 null。 */
|
||||
export function embedParentOrigin(): string | null {
|
||||
try {
|
||||
if (document.referrer === '') return null;
|
||||
return new URL(document.referrer).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 上行发送:无父窗(直接打开开发/测试)静默跳过,绝不抛错。 */
|
||||
export function postEmbedMessage(message: EmbedUpMessage): void {
|
||||
if (typeof window === 'undefined' || window.parent === window) return;
|
||||
try {
|
||||
window.parent.postMessage(message, embedParentOrigin() ?? '*');
|
||||
} catch {
|
||||
/* 静默:嵌入环境异常不得外显 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 订阅宿主 expand;返回取消函数。 */
|
||||
export function onEmbedExpand(handler: () => void): () => void {
|
||||
const listener = (event: MessageEvent): void => {
|
||||
if (window.parent === window || event.source !== window.parent) return;
|
||||
const data = event.data as { photoliveEmbed?: unknown; type?: unknown } | null;
|
||||
if (data === null || typeof data !== 'object') return;
|
||||
if (data.photoliveEmbed !== true || data.type !== 'expand') return;
|
||||
handler();
|
||||
};
|
||||
window.addEventListener('message', listener);
|
||||
return () => window.removeEventListener('message', listener);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// apps/viewer/src/embed/useEmbedFeed.ts —— 嵌入轮播数据流编排(P4b Task 4)。
|
||||
// 职责(纯函数在 feed.ts,消息通道在 postMessage.ts):
|
||||
// · 取数:GET public embed-slots(If-None-Match → 304 无变化零处理);
|
||||
// · A22 竞态防护:每次 dispatch 记 seq + 本地 configVersion,resolve 时过期
|
||||
// (新请求已发出)或版本低于本地 → 丢弃;等值 → 照片队列差分合并(新照片
|
||||
// 追加队尾、缺席淘汰、thumb 签名刷新);升值(换绑/设置变更)→ 全量重置;
|
||||
// · 轮询:基础间隔 = 查询 interval 覆盖(夹取 3000-30000)否则契约
|
||||
// EMBED_POLL_INTERVAL_MS;连续失败达 EMBED_POLL_MAX_FAILURES 后 ×退避倍数,
|
||||
// 成功归零;404/410(槽停用/解绑/不存在,防探测同码)→ 静默 gone +
|
||||
// postMessage collapse,其余错误静默退避重试(不弹错误 UI,A11);
|
||||
// · 不可见暂停(A23):document 隐藏 / IntersectionObserver 自身滚出视口 /
|
||||
// 本地收起 → 停轮询停切换;恢复 → 立即校准一次再续常态;
|
||||
// · 轮播:intervalMs 切换索引(仅 thumb 小图,UI 在 EmbedPage.vue)。
|
||||
// e2e 注入点:window.__embedPollMs(轮询间隔覆盖,宿主页同源写入;沿用
|
||||
// __viewerRealtimePollMs 惰性读取约定,运行中改动下一轮生效)。
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, type Ref } from 'vue';
|
||||
import {
|
||||
ApiError,
|
||||
fetchPublicEmbedSlot,
|
||||
type PublicEmbedAlbum,
|
||||
type PublicEmbedPhoto,
|
||||
type PublicEmbedSlot,
|
||||
} from '../api/client';
|
||||
import type { ViewerLocale } from '../i18n';
|
||||
import {
|
||||
basePollIntervalMs,
|
||||
clampEmbedHeight,
|
||||
clampEmbedIntervalMs,
|
||||
classifyEmbedResponse,
|
||||
EMBED_CAROUSEL_DEFAULT_MS,
|
||||
mergePhotoQueue,
|
||||
pollIntervalAfterFailures,
|
||||
resolveEmbedLocale,
|
||||
} from './feed';
|
||||
import { onEmbedExpand, postEmbedMessage } from './postMessage';
|
||||
|
||||
export type EmbedFeedStatus =
|
||||
| 'loading'
|
||||
/** 相册绑定且有照片:轮播展示。 */
|
||||
| 'ready'
|
||||
/** 相册无照片(或契约前瞻 album null):「照片即将上线」小卡。 */
|
||||
| 'empty'
|
||||
/** 槽 404(停用/解绑/不存在):无 UI,postMessage collapse 后隐藏自身。 */
|
||||
| 'gone';
|
||||
|
||||
export interface UseEmbedFeedOptions {
|
||||
slotToken: string;
|
||||
/** loader 传入 lang 查询(auto|zh-CN|en)。 */
|
||||
langParam: string;
|
||||
/** loader 传入 interval 查询(毫秒;null=未提供)。 */
|
||||
intervalParamMs: number | null;
|
||||
/** 页面根元素(高度上报 / 可见性观测)。 */
|
||||
rootEl: Ref<HTMLElement | null>;
|
||||
/** 测试注入;缺省真实 fetchPublicEmbedSlot。 */
|
||||
fetcher?: typeof fetchPublicEmbedSlot;
|
||||
}
|
||||
|
||||
function overrideNumber(key: string): number | null {
|
||||
const value = (globalThis as unknown as Record<string, unknown>)[key];
|
||||
return typeof value === 'number' && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function useEmbedFeed(options: UseEmbedFeedOptions) {
|
||||
const { slotToken, langParam, intervalParamMs, rootEl } = options;
|
||||
const fetcher = options.fetcher ?? fetchPublicEmbedSlot;
|
||||
|
||||
const status = ref<EmbedFeedStatus>('loading');
|
||||
const album = ref<PublicEmbedAlbum | null>(null);
|
||||
const photos = ref<PublicEmbedPhoto[]>([]);
|
||||
const index = ref(0);
|
||||
const resolvedLocale = ref<ViewerLocale | null>(null);
|
||||
|
||||
let etag: string | null = null;
|
||||
let configVersion: number | null = null;
|
||||
/** dispatch 序号:每次发起取数自增,resolve 时落后即丢弃。 */
|
||||
let seq = 0;
|
||||
let failures = 0;
|
||||
let alive = true;
|
||||
let collapsed = false;
|
||||
let pageVisible = true;
|
||||
let intersecting = true;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let carouselTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
let visibilityObserver: IntersectionObserver | null = null;
|
||||
let offExpand: (() => void) | null = null;
|
||||
let resizeFrame: number | null = null;
|
||||
|
||||
function pollBaseMs(): number {
|
||||
return overrideNumber('__embedPollMs') ?? basePollIntervalMs(intervalParamMs);
|
||||
}
|
||||
|
||||
function carouselMs(): number {
|
||||
return clampEmbedIntervalMs(intervalParamMs) ?? EMBED_CAROUSEL_DEFAULT_MS;
|
||||
}
|
||||
|
||||
/** 活动闸门:未卸载、未收起、非 gone、页面可见且自身在视口内。 */
|
||||
function running(): boolean {
|
||||
return alive && !collapsed && status.value !== 'gone' && pageVisible && intersecting;
|
||||
}
|
||||
|
||||
function stopTimers(): void {
|
||||
if (pollTimer !== null) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (carouselTimer !== null) {
|
||||
clearInterval(carouselTimer);
|
||||
carouselTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCarousel(): void {
|
||||
if (carouselTimer !== null) clearInterval(carouselTimer);
|
||||
carouselTimer = null;
|
||||
if (!running() || photos.value.length < 2) return;
|
||||
carouselTimer = setInterval(
|
||||
() => {
|
||||
index.value = (index.value + 1) % photos.value.length;
|
||||
},
|
||||
carouselMs(),
|
||||
);
|
||||
}
|
||||
|
||||
function applyResponse(dispatchSeq: number, dispatchVersion: number | null, data: PublicEmbedSlot): void {
|
||||
const decision = classifyEmbedResponse({
|
||||
dispatchSeq,
|
||||
currentSeq: seq,
|
||||
dispatchVersion,
|
||||
currentVersion: configVersion,
|
||||
responseVersion: data.configVersion,
|
||||
});
|
||||
if (decision === 'discard') return;
|
||||
if (decision === 'reset') {
|
||||
configVersion = data.configVersion;
|
||||
photos.value = data.photos;
|
||||
index.value = 0;
|
||||
} else {
|
||||
const merged = mergePhotoQueue(photos.value, data.photos);
|
||||
photos.value = merged.photos;
|
||||
if (index.value >= merged.photos.length) index.value = 0;
|
||||
}
|
||||
album.value = data.album;
|
||||
resolvedLocale.value = resolveEmbedLocale(langParam, data.album?.defaultLocale);
|
||||
const hasContent = data.album !== null && photos.value.length > 0;
|
||||
status.value = hasContent ? 'ready' : 'empty';
|
||||
ensureCarousel();
|
||||
}
|
||||
|
||||
function fatal(): void {
|
||||
status.value = 'gone';
|
||||
stopTimers();
|
||||
postEmbedMessage({ photoliveEmbed: true, type: 'collapse' });
|
||||
}
|
||||
|
||||
function scheduleNextPoll(): void {
|
||||
if (!running() || pollTimer !== null) return;
|
||||
pollTimer = setTimeout(
|
||||
() => {
|
||||
pollTimer = null;
|
||||
void pollNow();
|
||||
},
|
||||
pollIntervalAfterFailures(failures, pollBaseMs()),
|
||||
);
|
||||
}
|
||||
|
||||
async function pollNow(): Promise<void> {
|
||||
if (!running()) return;
|
||||
const dispatchSeq = ++seq;
|
||||
const dispatchVersion = configVersion;
|
||||
try {
|
||||
const result = await fetcher(slotToken, { lang: langParam, etag });
|
||||
// 竞态/暂停双闸:发起后有新请求(seq 前进)或已暂停(隐藏/收起)→ 丢弃。
|
||||
if (dispatchSeq !== seq || !running()) return;
|
||||
failures = 0;
|
||||
if (result.etag !== null) etag = result.etag;
|
||||
// 304(data null):内容未变,队列/版本零处理。
|
||||
if (result.data !== null) applyResponse(dispatchSeq, dispatchVersion, result.data);
|
||||
} catch (error) {
|
||||
if (dispatchSeq !== seq || !running()) return;
|
||||
if (error instanceof ApiError && (error.status === 404 || error.status === 410)) {
|
||||
fatal();
|
||||
return;
|
||||
}
|
||||
// 网络错/5xx/限速:静默退避(无错误 UI),成功归零。
|
||||
failures += 1;
|
||||
}
|
||||
scheduleNextPoll();
|
||||
}
|
||||
|
||||
function resumeIfActive(): void {
|
||||
if (!running()) return;
|
||||
void pollNow(); // 恢复即校准一次(pollNow 完成后续排下一轮)
|
||||
ensureCarousel();
|
||||
}
|
||||
|
||||
function requestCollapse(): void {
|
||||
collapsed = true;
|
||||
stopTimers();
|
||||
postEmbedMessage({ photoliveEmbed: true, type: 'collapse' });
|
||||
}
|
||||
|
||||
function expandFromParent(): void {
|
||||
if (!alive || status.value === 'gone') return;
|
||||
collapsed = false;
|
||||
if (!running()) return;
|
||||
void pollNow();
|
||||
ensureCarousel();
|
||||
postResize();
|
||||
}
|
||||
|
||||
function postResize(): void {
|
||||
if (!alive || status.value === 'gone') return;
|
||||
const element = rootEl.value;
|
||||
if (element === null) return;
|
||||
const height = clampEmbedHeight(element.offsetHeight);
|
||||
if (height === null) return;
|
||||
postEmbedMessage({ photoliveEmbed: true, type: 'resize', height });
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
pageVisible = !document.hidden;
|
||||
if (pageVisible) resumeIfActive();
|
||||
else stopTimers();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
pageVisible = !document.hidden;
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
offExpand = onEmbedExpand(expandFromParent);
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
observer = new ResizeObserver(() => {
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = null;
|
||||
postResize();
|
||||
});
|
||||
} else {
|
||||
postResize();
|
||||
}
|
||||
});
|
||||
if (rootEl.value !== null) observer.observe(rootEl.value);
|
||||
}
|
||||
if (typeof IntersectionObserver !== 'undefined' && rootEl.value !== null) {
|
||||
visibilityObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
intersecting = entries.some((entry) => entry.isIntersecting);
|
||||
if (intersecting) resumeIfActive();
|
||||
else stopTimers();
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
visibilityObserver.observe(rootEl.value);
|
||||
}
|
||||
void pollNow();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
alive = false;
|
||||
stopTimers();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
offExpand?.();
|
||||
observer?.disconnect();
|
||||
visibilityObserver?.disconnect();
|
||||
if (resizeFrame !== null && typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
});
|
||||
|
||||
// reactive 包装:模板/脚本访问 feed.photos 等自动解包(同 useAlbumRealtime 约定)。
|
||||
return reactive({
|
||||
status,
|
||||
album,
|
||||
photos,
|
||||
index,
|
||||
resolvedLocale,
|
||||
requestCollapse,
|
||||
/** 直接触发一次高度上报(expand 后重发 / 测试)。 */
|
||||
postResize,
|
||||
});
|
||||
}
|
||||
|
||||
export type EmbedFeed = ReturnType<typeof useEmbedFeed>;
|
||||
Reference in New Issue
Block a user