Files
photo-live/packages/embed-loader/src/loader-v2.js
T

442 lines
16 KiB
JavaScript
Raw Normal View History

// 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 {
/* 静默:任何异常不得破坏宿主页 */
}
})();