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>
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
// 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 {
|
||||
/* 静默:任何异常不得破坏宿主页 */
|
||||
}
|
||||
})();
|
||||
@@ -44,6 +44,20 @@ export const Z_INDEX_MAX = 2147483000;
|
||||
/** loader ↔ iframe postMessage 载荷 type 白名单(双向严格校验的取值集)。 */
|
||||
export const MESSAGE_TYPES = ['resize', 'collapse', 'expand', 'config', 'destroy'];
|
||||
|
||||
// ── UI-04 v2(vertical-stream 照片流窗)固定几何与参数域 ──
|
||||
|
||||
/** v2 固定窗尺寸(CSS px,方案 §2.1 硬性要求:PC/手机同值,含边框)。 */
|
||||
export const V2_WIDTH = 195;
|
||||
export const V2_HEIGHT = 280;
|
||||
|
||||
/** v2 自动上滚速度域(CSS px/秒)。 */
|
||||
export const SPEED_MIN = 8;
|
||||
export const SPEED_MAX = 32;
|
||||
export const SPEED_DEFAULT = 16;
|
||||
|
||||
/** v2 展示模式取值(settings.displayMode;loader 侧仅接受白名单值)。 */
|
||||
export const DISPLAY_MODES = ['carousel', 'vertical-stream'];
|
||||
|
||||
function oneOf(allowed, value, fallback) {
|
||||
return allowed.indexOf(value) >= 0 ? value : fallback;
|
||||
}
|
||||
@@ -195,3 +209,122 @@ export function clampHeight(value) {
|
||||
if (isNaN(n) || !isFinite(n)) return null;
|
||||
return Math.min(HEIGHT_MAX, Math.max(HEIGHT_MIN, Math.round(n)));
|
||||
}
|
||||
|
||||
// ── UI-04 v2 参数解析(独立函数:v1 parseEmbedConfig 行为零改动)──
|
||||
// v2 与 v1 共用 data-* 显式 > 后台设置 > 默认的优先级模型;差异:
|
||||
// · 尺寸硬性固定 195×280(size/imageFit/intervalMs 不再影响布局,仅仍透传
|
||||
// 给 iframe 侧的 v1 兼容字段——实际上 v2 iframe 忽略它们);
|
||||
// · 新参数 data-speed(8..32)与 data-reactions(on|off);
|
||||
// · 窄屏(<480px)仅默认收起,不降尺寸(§2.1:展开后两端同值)。
|
||||
|
||||
function clampSpeed(value) {
|
||||
const n = Number(value);
|
||||
if (isNaN(n) || !isFinite(n)) return SPEED_DEFAULT;
|
||||
return Math.min(SPEED_MAX, Math.max(SPEED_MIN, Math.round(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 宿主 script data-* 配置解析(dataset 形如 {slot, lang, position, speed,
|
||||
* reactions, offsetX, offsetBottom, zIndex, poll})。explicit 表语义同 v1:
|
||||
* 只记 data-* 真正提供的字段,缺省字段允许后台设置生效。
|
||||
*/
|
||||
export function parseEmbedConfigV2(dataset, opts) {
|
||||
const ds = dataset || {};
|
||||
const slot = typeof ds.slot === 'string' ? ds.slot.replace(/^\s+|\s+$/g, '') : '';
|
||||
const narrow = !!(opts && opts.narrow);
|
||||
return {
|
||||
version: 2,
|
||||
slot: slot || null,
|
||||
lang: provided(ds, 'lang') ? oneOf(LANG_VALUES, ds.lang, DEFAULTS.lang) : DEFAULTS.lang,
|
||||
position: provided(ds, 'position')
|
||||
? oneOf(POSITION_VALUES, ds.position, DEFAULTS.position)
|
||||
: DEFAULTS.position,
|
||||
speed: provided(ds, 'speed') ? clampSpeed(ds.speed) : SPEED_DEFAULT,
|
||||
reactions: provided(ds, 'reactions')
|
||||
? oneOf(['on', 'off'], ds.reactions, 'on') === 'on'
|
||||
: true,
|
||||
offsetX: provided(ds, 'offsetX') ? clampInt(ds.offsetX, OFFSET_X_MIN, OFFSET_X_MAX, DEFAULTS.offsetX) : DEFAULTS.offsetX,
|
||||
offsetBottom: provided(ds, 'offsetBottom')
|
||||
? clampInt(ds.offsetBottom, OFFSET_BOTTOM_MIN, OFFSET_BOTTOM_MAX, DEFAULTS.offsetBottom)
|
||||
: DEFAULTS.offsetBottom,
|
||||
zIndex: provided(ds, 'zIndex') ? clampInt(ds.zIndex, Z_INDEX_MIN, Z_INDEX_MAX, DEFAULTS.zIndex) : DEFAULTS.zIndex,
|
||||
pollMs: provided(ds, 'poll') ? clampInt(ds.poll, 250, 60000, null) : null,
|
||||
narrow: narrow,
|
||||
explicit: {
|
||||
lang: provided(ds, 'lang'),
|
||||
position: provided(ds, 'position'),
|
||||
speed: provided(ds, 'speed'),
|
||||
reactions: provided(ds, 'reactions'),
|
||||
offsetX: provided(ds, 'offsetX'),
|
||||
offsetBottom: provided(ds, 'offsetBottom'),
|
||||
zIndex: provided(ds, 'zIndex'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 后台设置归一(config 消息 / 公开端点 settings):在 v1 全集基础上裁决
|
||||
* v2 字段(displayMode/scrollSpeed/reactionsEnabled);displayMode 仅接受
|
||||
* vertical-stream 才对 v2 loader 有意义(carousel 值表示后台切回旧模式——
|
||||
* v2 loader 不自毁,但忽略该字段;管理端切回旧模式应同时把站点 script 换回
|
||||
* v1 入口,见 README-embed)。整体非对象 → null。
|
||||
*/
|
||||
export function normalizeBackendSettingsV2(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const base = normalizeBackendSettings(value);
|
||||
if (base === null) return null;
|
||||
return {
|
||||
...base,
|
||||
displayMode: oneOf(DISPLAY_MODES, value.displayMode, 'carousel'),
|
||||
scrollSpeed: clampSpeed(value.scrollSpeed),
|
||||
reactionsEnabled: typeof value.reactionsEnabled === 'boolean' ? value.reactionsEnabled : true,
|
||||
};
|
||||
}
|
||||
|
||||
/** v2 后台设置合并(同 mergeBackendConfig 语义,作用于 v2 字段面)。 */
|
||||
export function mergeBackendConfigV2(config, settings) {
|
||||
const s = normalizeBackendSettingsV2(settings);
|
||||
if (s === null) return config;
|
||||
const e = config.explicit;
|
||||
return {
|
||||
...config,
|
||||
lang: e.lang ? config.lang : s.lang,
|
||||
position: e.position ? config.position : s.position,
|
||||
speed: e.speed ? config.speed : s.scrollSpeed,
|
||||
reactions: e.reactions ? config.reactions : s.reactionsEnabled,
|
||||
offsetX: e.offsetX ? config.offsetX : s.offsetX,
|
||||
offsetBottom: e.offsetBottom ? config.offsetBottom : s.offsetBottom,
|
||||
zIndex: e.zIndex ? config.zIndex : s.zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/** v2 iframe config 消息裁决(同 parseConfigMessage,v2 settings 归一)。 */
|
||||
export function parseConfigMessageV2(data, currentVersion) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const version = Number(data.configVersion);
|
||||
if (isNaN(version) || !isFinite(version)) return null;
|
||||
const local = currentVersion === null || currentVersion === undefined ? 0 : Number(currentVersion);
|
||||
if (version <= local) return null;
|
||||
const settings = normalizeBackendSettingsV2(data.settings);
|
||||
if (settings === null) return null;
|
||||
return { configVersion: Math.round(version), settings };
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 iframe src:{origin}/embed/{slot}?v=2&lang=&pos=(槽 token 编码防注入)。
|
||||
* speed/reactions 仅显式提供时拼接(缺省不覆盖后台设置);poll 同 v1。
|
||||
*/
|
||||
export function buildEmbedUrlV2(origin, slot, config) {
|
||||
let url =
|
||||
origin +
|
||||
'/embed/' +
|
||||
encodeURIComponent(slot) +
|
||||
'?v=2&lang=' +
|
||||
encodeURIComponent(config.lang) +
|
||||
'&pos=' +
|
||||
encodeURIComponent(config.position);
|
||||
if (config.explicit.speed) url += '&speed=' + String(config.speed);
|
||||
if (config.explicit.reactions && !config.reactions) url += '&reactions=off';
|
||||
if (config.pollMs !== null && config.pollMs !== undefined) url += '&poll=' + String(config.pollMs);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,17 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
buildEmbedUrl,
|
||||
buildEmbedUrlV2,
|
||||
clampHeight,
|
||||
mergeBackendConfig,
|
||||
mergeBackendConfigV2,
|
||||
normalizeBackendSettings,
|
||||
normalizeBackendSettingsV2,
|
||||
normalizeInterval,
|
||||
parseConfigMessage,
|
||||
parseConfigMessageV2,
|
||||
parseEmbedConfig,
|
||||
parseEmbedConfigV2,
|
||||
SIZE_WIDTH,
|
||||
} from './params.js';
|
||||
|
||||
@@ -308,3 +313,111 @@ describe('clampHeight', () => {
|
||||
expect(clampHeight(Infinity)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── UI-04 v2(vertical-stream)参数面 ──
|
||||
|
||||
describe('parseEmbedConfigV2', () => {
|
||||
it('空 dataset 全缺省:v2 字段默认 + explicit 全 false', () => {
|
||||
const c = parseEmbedConfigV2({ slot: 'tok' }, {});
|
||||
expect(c.version).toBe(2);
|
||||
expect(c.slot).toBe('tok');
|
||||
expect(c.lang).toBe('auto');
|
||||
expect(c.position).toBe('right');
|
||||
expect(c.speed).toBe(16);
|
||||
expect(c.reactions).toBe(true);
|
||||
expect(c.offsetX).toBe(16);
|
||||
expect(c.offsetBottom).toBe(24);
|
||||
expect(c.zIndex).toBe(1000);
|
||||
expect(c.pollMs).toBeNull();
|
||||
expect(c.explicit).toEqual({
|
||||
lang: false,
|
||||
position: false,
|
||||
speed: false,
|
||||
reactions: false,
|
||||
offsetX: false,
|
||||
offsetBottom: false,
|
||||
zIndex: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('data-speed 夹取 8..32;非法回退 16', () => {
|
||||
expect(parseEmbedConfigV2({ slot: 'a', speed: '1' }, {}).speed).toBe(8);
|
||||
expect(parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}).speed).toBe(24);
|
||||
expect(parseEmbedConfigV2({ slot: 'a', speed: '99' }, {}).speed).toBe(32);
|
||||
expect(parseEmbedConfigV2({ slot: 'a', speed: 'abc' }, {}).speed).toBe(16);
|
||||
expect(parseEmbedConfigV2({ slot: 'a' }, {}).explicit.speed).toBe(false);
|
||||
expect(parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}).explicit.speed).toBe(true);
|
||||
});
|
||||
|
||||
it('data-reactions 仅 on/off;其他回退 true', () => {
|
||||
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'off' }, {}).reactions).toBe(false);
|
||||
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'on' }, {}).reactions).toBe(true);
|
||||
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'yes' }, {}).reactions).toBe(true);
|
||||
});
|
||||
|
||||
it('v1 布局字段(size/interval/imageFit)不在 v2 配置面(195×280 硬性)', () => {
|
||||
const c = parseEmbedConfigV2({ slot: 'a', size: 'md', interval: '8000', imageFit: 'cover' }, {});
|
||||
expect(c.size).toBeUndefined();
|
||||
expect(c.intervalMs).toBeUndefined();
|
||||
expect(c.imageFit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBackendSettingsV2 / mergeBackendConfigV2', () => {
|
||||
it('v2 字段归一:displayMode 白名单 / speed 夹取 / reactions 布尔回退', () => {
|
||||
const s = normalizeBackendSettingsV2({
|
||||
position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false,
|
||||
imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000,
|
||||
displayMode: 'vertical-stream', scrollSpeed: 20, reactionsEnabled: false,
|
||||
});
|
||||
expect(s.displayMode).toBe('vertical-stream');
|
||||
expect(s.scrollSpeed).toBe(20);
|
||||
expect(s.reactionsEnabled).toBe(false);
|
||||
expect(normalizeBackendSettingsV2({ displayMode: 'weird', scrollSpeed: 999, reactionsEnabled: 'x' }).displayMode).toBe('carousel');
|
||||
expect(normalizeBackendSettingsV2({ scrollSpeed: 999 }).scrollSpeed).toBe(32);
|
||||
expect(normalizeBackendSettingsV2({ reactionsEnabled: 'x' }).reactionsEnabled).toBe(true);
|
||||
expect(normalizeBackendSettingsV2(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('合并优先级:data-* 显式保持,缺省字段取后台值', () => {
|
||||
const config = parseEmbedConfigV2({ slot: 'a', speed: '24' }, {});
|
||||
const merged = mergeBackendConfigV2(config, {
|
||||
position: 'left', size: 'md', intervalMs: 5000, lang: 'en', collapsedDefault: true,
|
||||
imageFit: 'contain', offsetX: 40, offsetBottom: 80, zIndex: 2000,
|
||||
displayMode: 'vertical-stream', scrollSpeed: 12, reactionsEnabled: false,
|
||||
});
|
||||
expect(merged.speed).toBe(24); // 显式 data-speed 胜出
|
||||
expect(merged.reactions).toBe(false); // 后台(未显式)
|
||||
expect(merged.position).toBe('left');
|
||||
expect(merged.lang).toBe('en');
|
||||
expect(merged.offsetX).toBe(40);
|
||||
expect(merged.offsetBottom).toBe(80);
|
||||
expect(merged.zIndex).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseConfigMessageV2 / buildEmbedUrlV2', () => {
|
||||
it('config 消息:版本闸(低于等于忽略)+ 非法忽略', () => {
|
||||
const settings = { position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false };
|
||||
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 2, settings }, 1)).not.toBeNull();
|
||||
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 1, settings }, 1)).toBeNull();
|
||||
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 'x', settings }, 0)).toBeNull();
|
||||
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 2, settings: null }, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('URL:v=2 恒在;speed/reactions 仅显式拼接;poll 透传', () => {
|
||||
const base = parseEmbedConfigV2({ slot: 'tok' }, {});
|
||||
expect(buildEmbedUrlV2('https://e.test', 'tok', base)).toBe(
|
||||
'https://e.test/embed/tok?v=2&lang=auto&pos=right',
|
||||
);
|
||||
const explicit = parseEmbedConfigV2({ slot: 'tok', speed: '20', reactions: 'off', poll: '500' }, {});
|
||||
expect(buildEmbedUrlV2('https://e.test', 'tok', explicit)).toBe(
|
||||
'https://e.test/embed/tok?v=2&lang=auto&pos=right&speed=20&reactions=off&poll=500',
|
||||
);
|
||||
});
|
||||
|
||||
it('槽 token 编码防注入(路径段转义)', () => {
|
||||
const c = parseEmbedConfigV2({ slot: 'a/b?c' }, {});
|
||||
expect(buildEmbedUrlV2('https://e.test', 'a/b?c', c)).toContain('/embed/a%2Fb%3Fc?');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user