Files
photo-live/packages/embed-loader/src/loader-v2.js
T
浅念 5a207b395e feat(app,viewer): WeChat card share + 'Share to...' label + site og tags
- 按钮文字「分享到微信/其他应用」→「分享到...」(用户需求)
- 微信卡片分享(fluwx 6):WECHAT_APPID 经 --dart-define 注入;已配置且
  装微信 → WXWebpage 卡片(标题=相册名、描述=日期·地点、缩略图=封面压缩
  240px PNG ≤32KB、点击进观众链接);未配置/未装 → 回退系统分享面板
- viewer index.html 加站点级 og 标签(微信/QQ 抓取链接预览用;每相册动态
  og 需服务端注入,登记后续增强)
- 397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:05:53 +08:00

570 lines
22 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';
// v2 上行白名单 = v1 白名单 + open-photo(宿主内详情弹框,用户决策 2026-09-23)。
var V2_MESSAGE_TYPES = MESSAGE_TYPES.concat(['open-photo']);
(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;效果示例 .handle 精确值):100×44 白底胶囊、圆角 22、
// 1px #e3eaf2 边、蓝图标 16 + 12px 文案;全部 inline style(零宿主污染)。
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.width = '100px';
s.boxSizing = 'border-box';
s.borderRadius = '22px';
s.border = '1px solid #e3eaf2';
s.background = '#ffffff';
s.boxShadow = '0 4px 16px rgba(15,23,42,0.125)';
s.zIndex = String(inst.config.zIndex);
s.cursor = 'pointer';
s.display = 'flex';
s.alignItems = 'center';
s.justifyContent = 'center';
s.gap = '8px';
s.font = '400 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif';
s.color = '#32618e';
s.whiteSpace = 'nowrap';
h.innerHTML =
'<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false" style="color:#1a73e8">' +
'<path fill="currentColor" 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 / ≤599px 视口 12(§2.1;效果示例
* media(max-width:599px) 档)。data-* 显式或后台配置到达后
* (offsetConfigured)按配置值。 */
function effectiveOffsetX(inst) {
if (inst.config.explicit.offsetX || inst.offsetConfigured) return inst.config.offsetX;
return w.innerWidth <= 599 ? 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);
// 首次创建即定位(side/offsetX/zIndex)——漏掉会使 fixed 把手失去
// 水平定位而跑向左侧(v1 同位有此调用;2026-09-23 服务器实测发现)。
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;
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];
closePhotoModal();
if (instances.length === 0) w.removeEventListener('message', onMessage);
scheduleRelayout();
}
// ── 照片详情宿主内弹框(用户决策 2026-09-23):点击滚动照片 → 宿主页
// 覆盖层 + 居中卡片内嵌 viewer 深链 iframe(不新开标签)。安全:URL
// 必须与组件 iframe 同源(frameOrigin)才打开——iframe 被攻破也无法
// 诱导宿主内嵌任意第三方页面;Esc/遮罩/× 关闭;单实例(重复点击换 src)。
var photoModal = null;
function closePhotoModal() {
if (photoModal === null) return;
if (photoModal.parentNode) photoModal.parentNode.removeChild(photoModal);
photoModal = null;
w.removeEventListener('keydown', onModalKeydown);
}
function onModalKeydown(e) {
if (e.key === 'Escape') closePhotoModal();
}
function openPhotoModal(inst, rawUrl) {
if (typeof rawUrl !== 'string' || rawUrl === '') return;
var target;
try {
target = new URL(rawUrl);
} catch {
return;
}
if (target.origin !== inst.frameOrigin) return; // 同源校验(防任意嵌入)
if (photoModal === null) {
photoModal = document.createElement('div');
var os = photoModal.style;
os.position = 'fixed';
os.inset = '0';
os.zIndex = String(inst.config.zIndex + 1);
os.background = 'rgba(9, 14, 20, 0.6)';
os.display = 'flex';
os.alignItems = 'center';
os.justifyContent = 'center';
os.padding = '16px';
photoModal.addEventListener('pointerdown', function (e) {
if (e.target === photoModal) closePhotoModal();
});
var card = document.createElement('div');
var cs = card.style;
cs.position = 'relative';
cs.width = 'min(92vw, 420px)';
cs.height = 'min(86vh, 640px)';
cs.borderRadius = '12px';
cs.border = '1px solid rgba(255,255,255,0.80)';
cs.background = '#10161e';
cs.boxShadow = '0 12px 40px rgba(15,23,42,0.35)';
cs.overflow = 'hidden';
var detailFrame = document.createElement('iframe');
detailFrame.setAttribute(
'sandbox',
'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin'
);
detailFrame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
detailFrame.setAttribute('title', 'PhotoLive photo detail');
var dfs = detailFrame.style;
dfs.width = '100%';
dfs.height = '100%';
dfs.border = '0';
dfs.display = 'block';
card.appendChild(detailFrame);
var closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.setAttribute('aria-label', isZh(inst.config.lang) ? '关闭照片详情' : 'Close photo detail');
var bs = closeBtn.style;
bs.position = 'absolute';
bs.top = '6px';
bs.right = '6px';
bs.width = '44px';
bs.height = '44px';
bs.border = '0';
bs.background = 'transparent';
bs.color = '#647084';
bs.cursor = 'pointer';
bs.padding = '0';
var dot = document.createElement('span');
var dss = dot.style;
dss.position = 'absolute';
dss.top = '6px';
dss.right = '6px';
dss.width = '24px';
dss.height = '24px';
dss.borderRadius = '50%';
dss.background = 'rgba(255,255,255,0.82)';
dss.display = 'flex';
dss.alignItems = 'center';
dss.justifyContent = 'center';
dot.textContent = '×';
dot.style.fontSize = '19px';
dot.style.lineHeight = '1';
closeBtn.appendChild(dot);
closeBtn.addEventListener('click', closePhotoModal);
card.appendChild(closeBtn);
photoModal.appendChild(card);
document.body.appendChild(photoModal);
w.addEventListener('keydown', onModalKeydown);
photoModal.__frame = detailFrame;
// 焦点移入弹框关闭钮:键盘事件落到宿主文档(Esc 可关);否则焦点
// 残留在小组件 iframe 内,宿主 keydown 收不到。
closeBtn.focus();
}
photoModal.__frame.setAttribute('src', target.href);
}
/** 后台设置应用(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.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';
// 敌意宿主全局 CSS 反制(EMB-A12:*{box-sizing:content-box!important} 等
// 会翻转 195×280 盒模型/给 iframe 加边框)——几何关键属性以 inline
// important 压制宿主样式表 important(inline important 优先级最高)。
ws.setProperty('box-sizing', 'border-box', 'important');
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';
fs.setProperty('border', '0', 'important');
fs.setProperty('width', '100%', 'important');
fs.setProperty('height', '100%', 'important');
fs.setProperty('display', 'block', 'important');
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 (V2_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);
closePhotoModal();
} else if (type === 'open-photo') {
openPhotoModal(inst, data.url);
}
// '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 {
/* 静默:任何异常不得破坏宿主页 */
}
})();