ddf26a177f
Co-Authored-By: Claude Code <noreply@anthropic.com>
425 lines
16 KiB
JavaScript
425 lines
16 KiB
JavaScript
// 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 {
|
||
/* 静默:任何异常不得破坏宿主页 */
|
||
}
|
||
})();
|