Files
photo-live/packages/embed-loader/src/loader-v2.js
T
浅念 66d1eb3e9f feat(embed,ui04): E01 v2 contract skeleton — displayMode/scrollSpeed/reactionsEnabled, v2 loader entry and fixed 195x280 shell
- contracts: EmbedSlotSettings optional v2 fields (carousel default) + SlotSettingsPayload; gen + redocly green
- api: DTO validation + normalization full-set (cn manage + ov public projection share normalizeStoredSettings)
- embed-loader: params v2 parse/merge/url family, loader-v2.js fixed shell (border/radius/#10161E/safe-area, resize ignored, stack gap 12), dual-artifact build+sync; v1 source frozen
- viewer: EmbedPage v=2 branch, EmbedStream (viewport/track/real anchors/collapse/empty), stream.ts pure fns, i18n zh/en
- admin: types + utils (create default vertical-stream, clampScrollSpeed, v1/v2 snippet selection)
- tests: loader 36 (+9 v2), viewer 222, admin 82, api unit 51, v1 embed e2e 20, v2 E01 gate 2 (195x280 desktop+mobile cross-origin, no footer)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 12:50:28 +08:00

442 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// packages/embed-loader/src/loader-v2.js
// PhotoLive 嵌入组件加载器 v2(UI-04 vertical-stream 照片流窗)——对外发布物
// (esbuild 打包为 dist/v2/loader.js,宿主以 <script src="{EMBED_ORIGIN}/
// embed/v2/loader.js" data-slot> 引入)。与 v1(src/loader.js)同约束:零依赖、
// 零全局(仅版本标记属性)、无 console、一切错误静默不抛(A11 不破坏宿主)。
//
// v2 与 v1 的宿主侧差异(方案 §2/§7/§8):
// · 窗口硬性固定 195×280 CSS px(border-box,含 1px 边框);PC/手机同值;
// 仅在视口不足时按 min(195,可用宽)/min(280,可用高) 收缩,绝不 transform
// 缩放;resize 消息不改高度(iframe 内容自适应窗口,窗口不迁就内容);
// · 外壳:圆角 12、边框 1px rgba(255,255,255,0.80)(单圈)、阴影
// 0 4px 16px rgba(15,23,42,0.16)、底色 #10161E(加载/缺口不露宿主内容);
// · 默认停靠:桌面 right 16 / 手机 right 12,bottom 24 + safe-area-inset;
// offsetX 仅在 data-* 与后台都未配置时按视口取默认;
// · 收起把手:白底胶囊 88–104×44、圆角 22、蓝图标 + 12px「照片/Photos」;
// · 多槽同侧纵排间距 12;可用高度不足时后续槽收起(空间约束≠用户选择);
// · 窄屏(<480px)默认收起沿用 v1 策略;展开后两端尺寸一致(195×280)。
// 收起按钮本体在 iframe 内(viewer EmbedStream 渲染,§2.4),经 collapse 消息
// 请求宿主收起——宿主与 iframe 各自清理,语义同 v1。
import {
parseEmbedConfigV2,
buildEmbedUrlV2,
mergeBackendConfigV2,
parseConfigMessageV2,
MESSAGE_TYPES,
V2_WIDTH,
V2_HEIGHT,
} from './params.js';
(function () {
'use strict';
try {
var w = window;
// v2 独立标记:同页引入 v1+v2(迁移期)互不干扰。
if (w.__photoliveEmbedV2) return;
w.__photoliveEmbedV2 = '1';
var COLLAPSE_KEY = 'pl-embed-collapsed:'; // 与 v1 共用(同槽同会话记忆一致)
var STACK_GAP = 12; // §8.1 多槽同侧纵排间距
var HANDLE_MIN = 44; // 收起把手高/最小宽(§2.4)
// 收缩下限:低于此尺寸无法保证照片窗可用 → 改收起入口(§2.1「小到无法
// 操作时改成收起入口,而不是缩成不可点」)。
var MIN_OPERABLE_WIDTH = 150;
var MIN_OPERABLE_HEIGHT = 200;
function readCollapsed(slot) {
try {
return w.sessionStorage.getItem(COLLAPSE_KEY + slot) === '1';
} catch {
return false;
}
}
function writeCollapsed(slot, collapsed) {
try {
if (collapsed) w.sessionStorage.setItem(COLLAPSE_KEY + slot, '1');
else w.sessionStorage.removeItem(COLLAPSE_KEY + slot);
} catch {
/* storage 异常:静默放弃记忆 */
}
}
function hasChoice(slot) {
try {
return w.sessionStorage.getItem(COLLAPSE_KEY + slot) !== null;
} catch {
return false;
}
}
function selfScript() {
if (document.currentScript && document.currentScript.src) return document.currentScript;
var all = document.querySelectorAll('script[src*="/embed/v2/loader.js"]');
return all.length ? all[all.length - 1] : null;
}
function originOf(el) {
try {
return new URL(el.src, w.location.href).origin;
} catch {
return null;
}
}
var instances = [];
var seenSlots = {};
var relayoutFrame = null;
function isZh(lang) {
if (lang === 'zh-CN') return true;
if (lang === 'auto') {
try {
return /^zh/i.test(w.navigator.language || '');
} catch {
return false;
}
}
return false;
}
// 收起把手(§2.4):白底胶囊、蓝色相机图标 + 照片/Photos 文案;全部
// inline style(零宿主 CSS 污染);有效点击区 ≥44×44。
function createHandle(inst) {
var h = document.createElement('div');
h.setAttribute('role', 'button');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', isZh(inst.config.lang) ? '展开照片窗' : 'Show photo window');
var s = h.style;
s.position = 'fixed';
s.height = HANDLE_MIN + 'px';
s.minWidth = '88px';
s.boxSizing = 'border-box';
s.padding = '0 16px';
s.borderRadius = '22px';
s.background = '#ffffff';
s.boxShadow = '0 2px 10px rgba(15,23,42,0.18)';
s.zIndex = String(inst.config.zIndex);
s.cursor = 'pointer';
s.display = 'flex';
s.alignItems = 'center';
s.justifyContent = 'center';
s.gap = '6px';
s.font = '500 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif';
s.color = '#1A73E8';
s.whiteSpace = 'nowrap';
h.innerHTML =
'<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">' +
'<path fill="#1A73E8" d="M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/></svg>' +
'<span>' + (isZh(inst.config.lang) ? '照片' : 'Photos') + '</span>';
function activate(ev) {
if (ev.type === 'keydown') {
var k = ev.key;
if (k !== 'Enter' && k !== ' ' && k !== 'Spacebar') return;
ev.preventDefault();
}
inst.userChoice = true;
expandInstance(inst);
try {
if (inst.frame.contentWindow) {
inst.frame.contentWindow.postMessage(
{ photoliveEmbed: true, type: 'expand' },
inst.frameOrigin
);
}
} catch {
/* 静默 */
}
}
h.addEventListener('click', activate);
h.addEventListener('keydown', activate);
return h;
}
/** 展开态盒高(收缩后可能 < 280);收起态计把手 44(堆叠依据)。 */
function effectiveHeight(inst) {
return inst.collapsed ? HANDLE_MIN : inst.height;
}
/** 默认水平偏移:未配置时桌面 16 / 窄屏 12(§2.1)。data-* 显式或后台
* 配置到达后(offsetConfigured)按配置值。 */
function effectiveOffsetX(inst) {
if (inst.config.explicit.offsetX || inst.offsetConfigured) return inst.config.offsetX;
return inst.narrow ? 12 : 16;
}
/** wrapper 几何:W=min(195,可用宽)、H=min(280,可用高);不足操作下限时
* 由调用方收起(空间约束)。 */
function measureBox(inst) {
var offsetX = effectiveOffsetX(inst);
var availW = w.innerWidth - offsetX - 8;
var availH = w.innerHeight - inst.config.offsetBottom - 8;
return {
offsetX: offsetX,
width: Math.max(120, Math.min(V2_WIDTH, availW)),
height: Math.max(160, Math.min(V2_HEIGHT, availH)),
operable: availW >= MIN_OPERABLE_WIDTH && availH >= MIN_OPERABLE_HEIGHT,
};
}
function applyWrapperBox(inst) {
var box = measureBox(inst);
inst.height = box.height;
var side = inst.config.position === 'left' ? 'left' : 'right';
var other = side === 'left' ? 'right' : 'left';
var ws = inst.wrapper.style;
if (!inst.collapsed) {
ws.display = 'block';
ws.width = box.width + 'px';
ws.height = box.height + 'px';
}
ws.zIndex = String(inst.config.zIndex);
ws[side] = box.offsetX + 'px';
ws[other] = '';
// 底部偏移 + 安全区(§2.1):不支持 env() 的旧浏览器整条声明失效回退
// 上一行设置的裸像素值(先写裸值再写 calc,双保险)。
ws.bottom = inst.config.offsetBottom + 'px';
ws.bottom =
'calc(' + inst.config.offsetBottom + 'px + env(safe-area-inset-bottom, 0px))';
if (inst.handle) {
var hs = inst.handle.style;
hs.zIndex = String(inst.config.zIndex);
hs[side] = box.offsetX + 'px';
hs[other] = '';
}
return box;
}
// 同侧多槽纵排(§8.1):登记顺序自下而上,间距 12;可用高度不足时后续
// 槽收起(不写用户偏好)。
function relayout() {
var sides = { left: [], right: [] };
for (var i = 0; i < instances.length; i++) {
var inst = instances[i];
sides[inst.config.position === 'left' ? 'left' : 'right'].push(inst);
}
var keys = ['left', 'right'];
for (var s = 0; s < keys.length; s++) {
var list = sides[keys[s]];
var cursorBottom = 0;
for (var j = 0; j < list.length; j++) {
var it = list[j];
var bottom = j === 0 ? it.config.offsetBottom : cursorBottom + effectiveHeight(list[j - 1]) + STACK_GAP;
if (j > 0 && bottom + V2_HEIGHT > w.innerHeight) setCollapsed(it, false);
it.wrapper.style.bottom = bottom + 'px';
if (it.handle) it.handle.style.bottom = bottom + 'px';
cursorBottom = bottom;
}
}
}
function scheduleRelayout() {
if (relayoutFrame !== null || typeof w.requestAnimationFrame !== 'function') {
relayout();
return;
}
relayoutFrame = w.requestAnimationFrame(function () {
relayoutFrame = null;
relayout();
});
}
/** 收起核心(不触发重排调度——relayout 空间不足收起时避免再排)。 */
function setCollapsed(inst, persist) {
if (inst.collapsed) return;
inst.collapsed = true;
inst.wrapper.style.display = 'none';
if (!inst.handle) {
inst.handle = createHandle(inst);
document.body.appendChild(inst.handle);
}
inst.handle.style.display = 'flex';
if (persist !== false) writeCollapsed(inst.config.slot, true);
}
function collapseInstance(inst, persist) {
setCollapsed(inst, persist);
scheduleRelayout();
}
function expandInstance(inst) {
if (!inst.collapsed) return;
inst.collapsed = false;
applyWrapperBox(inst);
if (inst.handle) inst.handle.style.display = 'none';
writeCollapsed(inst.config.slot, false);
scheduleRelayout();
}
/** 服务端终止展示(停用/解绑/404/410):移除照片窗与把手,不留空壳。 */
function destroyInstance(inst) {
if (inst.destroyed) return;
inst.destroyed = true;
if (inst.handle && inst.handle.parentNode) inst.handle.parentNode.removeChild(inst.handle);
if (inst.wrapper.parentNode) inst.wrapper.parentNode.removeChild(inst.wrapper);
try {
w.sessionStorage.removeItem(COLLAPSE_KEY + inst.config.slot);
} catch {
/* 静默 */
}
var i = instances.indexOf(inst);
if (i >= 0) instances.splice(i, 1);
delete seenSlots[inst.config.slot];
if (instances.length === 0) w.removeEventListener('message', onMessage);
scheduleRelayout();
}
/** 后台设置应用(config 消息已过 parseConfigMessageV2 版本闸):非 data-*
* 显式字段立即生效;collapsedDefault 仅在无用户选择时应用。 */
function applyConfig(inst, version, settings) {
inst.backendVersion = version;
inst.offsetConfigured = true; // 后台 settings 恒为全集 → offsetX 已「配置」
inst.config = mergeBackendConfigV2(inst.config, settings);
applyWrapperBox(inst);
if (!inst.userChoice && !hasChoice(inst.config.slot)) {
if (settings.collapsedDefault) collapseInstance(inst, false);
else if (!spaceConstrained(inst)) expandInstance(inst);
}
scheduleRelayout();
}
/** 当前视口是否空间不足(不足时默认收起而非塞下不可用的窗口)。 */
function spaceConstrained(inst) {
return !measureBox(inst).operable;
}
function createInstance(origin, config) {
var wrapper = document.createElement('div');
var ws = wrapper.style;
ws.position = 'fixed';
ws.width = V2_WIDTH + 'px';
ws.height = V2_HEIGHT + 'px';
ws.boxSizing = 'border-box';
ws.borderRadius = '12px';
ws.border = '1px solid rgba(255,255,255,0.80)';
ws.boxShadow = '0 4px 16px rgba(15,23,42,0.16)';
ws.background = '#10161E';
ws.overflow = 'hidden';
var frame = document.createElement('iframe');
// sandbox 同 v1(含 allow-same-origin:iframe 保有项目域源,白名单按
// 源+source 双重严格校验;跨域隔离与链接新页安全不变)。
frame.setAttribute(
'sandbox',
'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin'
);
frame.setAttribute('src', buildEmbedUrlV2(origin, config.slot, config));
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
frame.setAttribute('title', 'PhotoLive photos');
var fs = frame.style;
fs.width = '100%';
fs.height = '100%';
fs.border = '0';
fs.display = 'block';
wrapper.appendChild(frame);
document.body.appendChild(wrapper);
var inst = {
config: config,
wrapper: wrapper,
frame: frame,
frameOrigin: originOf(frame) || origin,
height: V2_HEIGHT,
collapsed: false,
narrow: config.narrow,
handle: null,
userChoice: false,
backendVersion: 0,
offsetConfigured: config.explicit.offsetX,
destroyed: false,
};
instances.push(inst);
var box = applyWrapperBox(inst);
// 收起记忆 / 默认(窄屏或空间不足默认收起;用户选择优先,不反复弹出)。
if (hasChoice(config.slot)) {
if (readCollapsed(config.slot)) collapseInstance(inst, false);
} else if (inst.narrow || !box.operable) {
collapseInstance(inst, false);
}
return inst;
}
// postMessage 白名单:同 v1 双重严格校验(event.source 命中已登记 iframe
// 且 event.origin 严格等于该 iframe 源)。v2 忽略 resize(窗口固定)。
function onMessage(event) {
try {
var data = event.data;
if (!data || data.photoliveEmbed !== true) return;
var type = data.type;
if (MESSAGE_TYPES.indexOf(type) < 0) return;
for (var i = 0; i < instances.length; i++) {
var inst = instances[i];
if (!inst.frame.contentWindow || event.source !== inst.frame.contentWindow) continue;
if (event.origin !== inst.frameOrigin) return;
if (type === 'collapse') {
inst.userChoice = true;
collapseInstance(inst, true);
} else if (type === 'expand') {
inst.userChoice = true;
expandInstance(inst);
} else if (type === 'config') {
var parsed = parseConfigMessageV2(data, inst.backendVersion);
if (parsed !== null) applyConfig(inst, parsed.configVersion, parsed.settings);
} else if (type === 'destroy') {
destroyInstance(inst);
}
// 'resize':v2 窗口固定,显式忽略(§7.3)。
return;
}
} catch {
/* 静默 */
}
}
function isNarrow() {
try {
return !!w.matchMedia && w.matchMedia('(max-width: 479px)').matches;
} catch {
return false;
}
}
function onViewportChange() {
var narrow = isNarrow();
for (var i = 0; i < instances.length; i++) {
var inst = instances[i];
if (inst.narrow !== narrow) {
inst.narrow = narrow;
if (narrow && !inst.userChoice && !hasChoice(inst.config.slot)) {
collapseInstance(inst, false);
}
}
applyWrapperBox(inst);
}
scheduleRelayout();
}
function init() {
var self = selfScript();
if (!self) return;
var embedOrigin = originOf(self);
if (!embedOrigin) return;
var narrow = isNarrow();
var tags = document.querySelectorAll('script[src*="/embed/v2/loader.js"]');
for (var i = 0; i < tags.length; i++) {
var config = parseEmbedConfigV2(tags[i].dataset, { narrow: narrow });
if (!config.slot) continue;
if (seenSlots[config.slot]) continue;
seenSlots[config.slot] = true;
createInstance(embedOrigin, config);
}
w.addEventListener('message', onMessage);
w.addEventListener('resize', onViewportChange);
w.addEventListener('orientationchange', onViewportChange);
}
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
} catch {
/* 静默:任何异常不得破坏宿主页 */
}
})();