Files
photo-live/packages/embed-loader/src/loader.js
T
2026-09-21 18:55:15 +08:00

425 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.js
// PhotoLive 悬浮轮播组件加载器 v1 —— 对外发布物(esbuild 打包为 dist/v1/loader.js,
// 由 viewer 静态服务,宿主以 <script src="{EMBED_ORIGIN}/embed/v1/loader.js" data-slot> 引入)。
// 约束:零依赖、零全局(仅版本标记属性)、无 console、一切错误静默不抛(A11 不破坏宿主)。
// 安全模型(postMessage 双向白名单 / sandbox 取舍)见同包 README-embed.md。
// UI-01(§5):imageFit/offsetX/offsetBottom/zIndex 配置链(data-* 显式 > iframe
// config 消息 > 默认)、收起把手图标+文字、destroy 生命周期、同侧多槽纵排、
// 窗口 resize/横竖屏重排、宽度取配置宽与视口剩余宽的较小值。
import {
parseEmbedConfig,
buildEmbedUrl,
clampHeight,
mergeBackendConfig,
parseConfigMessage,
SIZE_WIDTH,
MESSAGE_TYPES,
HEIGHT_MIN,
} from './params.js';
(function () {
'use strict';
try {
var w = window;
// 已初始化去重:同一页面重复引入 loader 只跑一次(不同 slot 的多个标签在首次运行时全部扫描)。
if (w.__photoliveEmbedV1) return;
w.__photoliveEmbedV1 = '1';
var COLLAPSE_KEY = 'pl-embed-collapsed:';
var DEFAULT_HEIGHT = 96; // 首条 resize 消息前的占位高度
var STACK_GAP = 8; // 同侧多槽纵排间距(UI-01 §5.4)
var HANDLE_MIN = 44; // 收起把手有效高度/最小宽度(§5.3 ≥44×44)
var MIN_WRAPPER_WIDTH = 180; // 极窄视口防溢出下限
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 {
/* Safari 隐私模式等 storage 异常:静默放弃记忆 */
}
}
/** 会话内是否已有用户收起/展开选择(无选择时后台 collapsedDefault 才生效)。 */
function hasChoice(slot) {
try {
return w.sessionStorage.getItem(COLLAPSE_KEY + slot) !== null;
} catch {
return false;
}
}
// 自发现:currentScript(defer 下同步执行仍可用)→ 兜底最后一个匹配标签。
function selfScript() {
if (document.currentScript && document.currentScript.src) return document.currentScript;
var all = document.querySelectorAll('script[src*="/embed/v1/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 handleLabel(lang) {
var zh = lang === 'zh-CN';
if (!zh && lang === 'auto') {
try {
zh = /^zh/i.test(w.navigator.language || '');
} catch {
zh = false;
}
}
return zh ? '展开照片轮播' : 'Show photo carousel';
}
function handleText(lang) {
var zh = lang === 'zh-CN';
if (!zh && lang === 'auto') {
try {
zh = /^zh/i.test(w.navigator.language || '');
} catch {
zh = false;
}
}
return zh ? '照片' : 'Photos';
}
// 收起把手:图片图标 + Photos/照片 文案(§5.1),胶囊形,高 44px、
// 有效点击区 ≥44×44;全部 inline style(零宿主 CSS 污染)。
function createHandle(inst) {
var h = document.createElement('div');
h.setAttribute('role', 'button');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', handleLabel(inst.config.lang));
var s = h.style;
s.position = 'fixed';
s.height = HANDLE_MIN + 'px';
s.minWidth = HANDLE_MIN + 'px';
s.boxSizing = 'border-box';
s.padding = '0 14px';
s.borderRadius = HANDLE_MIN / 2 + 'px';
s.background = 'rgba(15,23,42,0.88)';
s.boxShadow = '0 2px 10px rgba(0,0,0,0.3)';
s.zIndex = String(inst.config.zIndex);
s.cursor = 'pointer';
s.display = 'flex';
s.alignItems = 'center';
s.justifyContent = 'center';
s.gap = '6px';
s.font = '500 13px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif';
s.color = '#ffffff';
s.whiteSpace = 'nowrap';
h.innerHTML =
'<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">' +
'<path fill="#ffffff" 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>' + handleText(inst.config.lang) + '</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);
// 通知 iframe 内部恢复轮播/可见行为;目标源严格限定 iframe 源(绝不 '*')。
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;
}
/** 布局占位高度:收起态计把手 44px(多槽纵排的堆叠依据)。 */
function effectiveHeight(inst) {
return inst.collapsed ? HANDLE_MIN : inst.height;
}
/** wrapper 几何:宽度取配置宽与视口剩余宽的较小值(§5.4 无溢出)。 */
function applyWrapperBox(inst) {
var side = inst.config.position === 'left' ? 'left' : 'right';
var other = side === 'left' ? 'right' : 'left';
var sizeKey = inst.narrow ? 'sm' : inst.config.size;
var width = SIZE_WIDTH[sizeKey] || SIZE_WIDTH.md;
var available = w.innerWidth - inst.config.offsetX - 8;
if (available < MIN_WRAPPER_WIDTH) available = MIN_WRAPPER_WIDTH;
if (width > available) width = available;
var ws = inst.wrapper.style;
ws.width = width + 'px';
ws.zIndex = String(inst.config.zIndex);
ws[side] = inst.config.offsetX + 'px';
ws[other] = '';
if (inst.handle) {
var hs = inst.handle.style;
hs.zIndex = String(inst.config.zIndex);
hs[side] = inst.config.offsetX + 'px';
hs[other] = '';
}
}
// 同侧多槽纵排(§5.4):按登记顺序自下而上堆叠,间距 8px;可用高度不足时
// 后续槽收起(不写用户偏好——空间约束非用户选择)。
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 + HEIGHT_MIN > 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.height = '0px';
inst.wrapper.style.boxShadow = 'none';
if (!inst.handle) {
inst.handle = createHandle(inst);
document.body.appendChild(inst.handle);
applyWrapperBox(inst);
}
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;
inst.wrapper.style.height = inst.height + 'px';
inst.wrapper.style.boxShadow = '0 4px 16px rgba(0,0,0,0.18)';
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 消息已过 parseConfigMessage 版本闸):非 data-*
* 显式字段立即生效(位置/偏移/层级/尺寸 → wrapper;collapsedDefault 在无
* 用户选择时应用)。 */
function applyConfig(inst, version, settings) {
inst.backendVersion = version;
inst.config = mergeBackendConfig(inst.config, settings);
applyWrapperBox(inst);
if (!inst.userChoice && !hasChoice(inst.config.slot)) {
if (inst.config.collapsedDefault) collapseInstance(inst, false);
else if (!inst.narrow) expandInstance(inst);
}
scheduleRelayout();
}
function createInstance(origin, config) {
var wrapper = document.createElement('div');
var ws = wrapper.style;
ws.position = 'fixed';
ws.bottom = config.offsetBottom + 'px';
ws.height = DEFAULT_HEIGHT + 'px';
ws.borderRadius = '12px';
ws.boxShadow = '0 4px 16px rgba(0,0,0,0.18)';
ws.overflow = 'hidden';
var frame = document.createElement('iframe');
// sandbox 必须含 allow-same-origin(评审修正):否则 iframe 为 opaque 源 "null",
// loader 的「消息 origin === iframe 源」白名单会拒掉自己的 resize/collapse,
// 且 iframe 内取公开 API 需 ACAO *。宿主↔iframe 天然跨源,same-origin 仅使
// iframe 保有项目域源,白名单按源+source 双重严格校验兜底。
frame.setAttribute(
'sandbox',
'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin'
);
frame.setAttribute('src', buildEmbedUrl(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,
// 白名单基准源:从 iframe src 推导(构建后即固定,等于 EMBED_ORIGIN)。
frameOrigin: originOf(frame) || origin,
height: DEFAULT_HEIGHT,
collapsed: false,
narrow: config.narrow,
handle: null,
userChoice: false,
backendVersion: 0,
destroyed: false,
};
instances.push(inst);
applyWrapperBox(inst);
// 收起记忆 / 无用户选择时的默认(窄屏强制收起 + 后台 collapsedDefault 经
// config 消息到达;会话内用户选择优先,不反复弹出)。
if (hasChoice(config.slot)) {
if (readCollapsed(config.slot)) collapseInstance(inst, false);
} else if (inst.narrow) {
collapseInstance(inst, false);
}
return inst;
}
// postMessage 白名单:仅 photoliveEmbed===true 且 type∈MESSAGE_TYPES,且
// event.source 命中已登记 iframe 且 event.origin 严格等于该 iframe 的源。
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 === 'resize') {
var h = clampHeight(data.height);
if (h !== null) {
inst.height = h;
if (!inst.collapsed) inst.wrapper.style.height = h + 'px';
scheduleRelayout();
}
} else if (type === 'collapse') {
inst.userChoice = true; // iframe × 按钮是用户选择(会话记忆 + 抵御后续默认)
collapseInstance(inst, true);
} else if (type === 'expand') {
inst.userChoice = true;
expandInstance(inst);
} else if (type === 'config') {
var parsed = parseConfigMessage(data, inst.backendVersion);
if (parsed !== null) applyConfig(inst, parsed.configVersion, parsed.settings);
} else {
destroyInstance(inst);
}
return;
}
} catch {
/* 静默 */
}
}
function isNarrow() {
try {
return !!w.matchMedia && w.matchMedia('(max-width: 479px)').matches;
} catch {
return false;
}
}
// 响应式(§5.4):窗口 resize / 横竖屏切换 → 重新评估窄屏降级与全部几何。
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();
// 扫描全部 loader 标签:单标签单槽为主路径,多标签(多槽/集中引入)一并覆盖;
// 同 data-slot 重复出现时后者静默忽略。
var tags = document.querySelectorAll('script[src*="/embed/v1/loader.js"]');
for (var i = 0; i < tags.length; i++) {
var config = parseEmbedConfig(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 {
/* 静默:任何异常不得破坏宿主页 */
}
})();