feat(embed,viewer): UI-01 complete carousel, loader config and accessibility
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,18 @@
|
||||
// 由 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 () {
|
||||
@@ -19,8 +26,10 @@ import {
|
||||
w.__photoliveEmbedV1 = '1';
|
||||
|
||||
var COLLAPSE_KEY = 'pl-embed-collapsed:';
|
||||
var Z_INDEX = '2147483000';
|
||||
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 {
|
||||
@@ -37,6 +46,14 @@ import {
|
||||
/* Safari 隐私模式等 storage 异常:静默放弃记忆 */
|
||||
}
|
||||
}
|
||||
/** 会话内是否已有用户收起/展开选择(无选择时后台 collapsedDefault 才生效)。 */
|
||||
function hasChoice(slot) {
|
||||
try {
|
||||
return w.sessionStorage.getItem(COLLAPSE_KEY + slot) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 自发现:currentScript(defer 下同步执行仍可用)→ 兜底最后一个匹配标签。
|
||||
function selfScript() {
|
||||
@@ -54,42 +71,67 @@ import {
|
||||
|
||||
var instances = [];
|
||||
var seenSlots = {};
|
||||
var relayoutFrame = null;
|
||||
|
||||
function handleLabel(lang) {
|
||||
if (lang === 'zh-CN') return '展开照片轮播';
|
||||
if (lang === 'en') return 'Show photo carousel';
|
||||
return 'Show photo carousel';
|
||||
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');
|
||||
var side = inst.config.position === 'left' ? 'left' : 'right';
|
||||
h.setAttribute('role', 'button');
|
||||
h.setAttribute('tabindex', '0');
|
||||
h.setAttribute('aria-label', handleLabel(inst.config.lang));
|
||||
var s = h.style;
|
||||
s.position = 'fixed';
|
||||
s.bottom = '24px';
|
||||
s[side] = '16px';
|
||||
s.width = '44px';
|
||||
s.height = '44px';
|
||||
s.borderRadius = '50%';
|
||||
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 = Z_INDEX;
|
||||
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="22" height="22" aria-hidden="true" focusable="false">' +
|
||||
'<path fill="%23ffffff" 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>';
|
||||
'<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 {
|
||||
@@ -108,7 +150,70 @@ import {
|
||||
return h;
|
||||
}
|
||||
|
||||
function collapseInstance(inst) {
|
||||
/** 布局占位高度:收起态计把手 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';
|
||||
@@ -116,9 +221,15 @@ import {
|
||||
if (!inst.handle) {
|
||||
inst.handle = createHandle(inst);
|
||||
document.body.appendChild(inst.handle);
|
||||
applyWrapperBox(inst);
|
||||
}
|
||||
inst.handle.style.display = 'flex';
|
||||
writeCollapsed(inst.config.slot, true);
|
||||
if (persist !== false) writeCollapsed(inst.config.slot, true);
|
||||
}
|
||||
|
||||
function collapseInstance(inst, persist) {
|
||||
setCollapsed(inst, persist);
|
||||
scheduleRelayout();
|
||||
}
|
||||
|
||||
function expandInstance(inst) {
|
||||
@@ -128,18 +239,47 @@ import {
|
||||
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 side = config.position === 'left' ? 'left' : 'right';
|
||||
var ws = wrapper.style;
|
||||
ws.position = 'fixed';
|
||||
ws.bottom = '24px';
|
||||
ws[side] = '16px';
|
||||
ws.width = SIZE_WIDTH[config.size] + 'px';
|
||||
ws.bottom = config.offsetBottom + 'px';
|
||||
ws.height = DEFAULT_HEIGHT + 'px';
|
||||
ws.zIndex = Z_INDEX;
|
||||
ws.borderRadius = '12px';
|
||||
ws.boxShadow = '0 4px 16px rgba(0,0,0,0.18)';
|
||||
ws.overflow = 'hidden';
|
||||
@@ -172,21 +312,32 @@ import {
|
||||
frameOrigin: originOf(frame) || origin,
|
||||
height: DEFAULT_HEIGHT,
|
||||
collapsed: false,
|
||||
narrow: config.narrow,
|
||||
handle: null,
|
||||
userChoice: false,
|
||||
backendVersion: 0,
|
||||
destroyed: false,
|
||||
};
|
||||
instances.push(inst);
|
||||
// 收起记忆 / 窄屏默认收起:会话内不反复弹出(需求 9)。
|
||||
if (readCollapsed(config.slot) || config.collapsedDefault) collapseInstance(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∈{resize,collapse,expand},
|
||||
// 且 event.source 命中已登记 iframe 且 event.origin 严格等于该 iframe 的源。
|
||||
w.addEventListener('message', function (event) {
|
||||
// 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 (type !== 'resize' && type !== 'collapse' && type !== 'expand') return;
|
||||
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;
|
||||
@@ -196,30 +347,57 @@ import {
|
||||
if (h !== null) {
|
||||
inst.height = h;
|
||||
if (!inst.collapsed) inst.wrapper.style.height = h + 'px';
|
||||
scheduleRelayout();
|
||||
}
|
||||
} else if (type === 'collapse') {
|
||||
collapseInstance(inst);
|
||||
} else {
|
||||
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 = false;
|
||||
try {
|
||||
narrow = !!w.matchMedia && w.matchMedia('(max-width: 479px)').matches;
|
||||
} catch {
|
||||
narrow = false;
|
||||
}
|
||||
var narrow = isNarrow();
|
||||
// 扫描全部 loader 标签:单标签单槽为主路径,多标签(多槽/集中引入)一并覆盖;
|
||||
// 同 data-slot 重复出现时后者静默忽略。
|
||||
var tags = document.querySelectorAll('script[src*="/embed/v1/loader.js"]');
|
||||
@@ -230,6 +408,9 @@ import {
|
||||
seenSlots[config.slot] = true;
|
||||
createInstance(embedOrigin, config);
|
||||
}
|
||||
w.addEventListener('message', onMessage);
|
||||
w.addEventListener('resize', onViewportChange);
|
||||
w.addEventListener('orientationchange', onViewportChange);
|
||||
}
|
||||
|
||||
if (document.body) {
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
// 纯函数参数模块:loader(esbuild 打包内联)与 vitest 单测共用的唯一参数源。
|
||||
// 取值域与缺省严格对齐契约 EmbedSlotSettings(packages/contracts/openapi.yaml),
|
||||
// 非法/缺省一律静默回退——loader 对宿主零报错(A11「不破坏宿主」)。
|
||||
// UI-01:新增 imageFit/offsetX/offsetBottom/zIndex;配置优先级
|
||||
// data-* 显式 > 后台设置(iframe config 消息)> 契约默认。「data-* 缺省」不是
|
||||
// 显式覆盖——缺省字段允许后台设置生效(explicit 表只记真正提供的字段)。
|
||||
|
||||
export const POSITION_VALUES = ['left', 'right'];
|
||||
export const SIZE_VALUES = ['sm', 'md'];
|
||||
export const LANG_VALUES = ['auto', 'zh-CN', 'en'];
|
||||
export const IMAGE_FIT_VALUES = ['contain', 'cover'];
|
||||
|
||||
export const DEFAULTS = {
|
||||
position: 'right',
|
||||
@@ -13,10 +17,14 @@ export const DEFAULTS = {
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
imageFit: 'contain',
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
/** size → 宿主侧 wrapper 宽度(px)。sm 为窄屏降级档。 */
|
||||
export const SIZE_WIDTH = { sm: 280, md: 340 };
|
||||
/** size → 宿主侧 wrapper 宽度(px)。sm 为窄屏降级档;UI-01 §5.1 md 统一 320。 */
|
||||
export const SIZE_WIDTH = { sm: 280, md: 320 };
|
||||
|
||||
export const INTERVAL_MIN = 3000;
|
||||
export const INTERVAL_MAX = 30000;
|
||||
@@ -25,21 +33,44 @@ export const INTERVAL_MAX = 30000;
|
||||
export const HEIGHT_MIN = 60;
|
||||
export const HEIGHT_MAX = 640;
|
||||
|
||||
/** UI-01 新增偏移/层级取值域(对齐契约 EmbedSlotSettings)。 */
|
||||
export const OFFSET_X_MIN = 0;
|
||||
export const OFFSET_X_MAX = 120;
|
||||
export const OFFSET_BOTTOM_MIN = 0;
|
||||
export const OFFSET_BOTTOM_MAX = 240;
|
||||
export const Z_INDEX_MIN = 1;
|
||||
export const Z_INDEX_MAX = 2147483000;
|
||||
|
||||
/** loader ↔ iframe postMessage 载荷 type 白名单(双向严格校验的取值集)。 */
|
||||
export const MESSAGE_TYPES = ['resize', 'collapse', 'expand', 'config', 'destroy'];
|
||||
|
||||
function oneOf(allowed, value, fallback) {
|
||||
return allowed.indexOf(value) >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
/** 数值夹取:非有限数值/缺失 → fallback;否则取整并夹取到 [min, max]。 */
|
||||
function clampInt(value, min, max, fallback) {
|
||||
const n = Number(value);
|
||||
if (isNaN(n) || !isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(n)));
|
||||
}
|
||||
|
||||
function provided(ds, key) {
|
||||
const value = ds[key];
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
}
|
||||
|
||||
/** 轮播间隔(ms):解析失败取缺省,超界夹取到 [3000, 30000]。 */
|
||||
export function normalizeInterval(value) {
|
||||
const n = parseInt(value, 10);
|
||||
if (isNaN(n)) return DEFAULTS.intervalMs;
|
||||
return Math.min(INTERVAL_MAX, Math.max(INTERVAL_MIN, n));
|
||||
return clampInt(value, INTERVAL_MIN, INTERVAL_MAX, DEFAULTS.intervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析宿主 script 标签 data-* 配置(dataset 形如 {slot, lang, position, interval})。
|
||||
* opts.narrow=true(viewport <480px)时强制 sm 尺寸 + 默认收起(计划评审缺口:移动端可用)。
|
||||
* data-slot 缺失/空白 → slot 为 null,调用方须静默 no-op。
|
||||
* 解析宿主 script 标签 data-* 配置(dataset 形如 {slot, lang, position,
|
||||
* interval, imageFit, offsetX, offsetBottom, zIndex, poll})。
|
||||
* opts.narrow=true(viewport <480px)时强制 sm 尺寸 + 默认收起(移动端可用)。
|
||||
* 返回的 explicit 表标记哪些字段被 data-* 真正提供(缺省 ≠ 覆盖,后台设置
|
||||
* 仍可生效,方案 §5.4)。data-slot 缺失/空白 → slot 为 null,调用方静默 no-op。
|
||||
*/
|
||||
export function parseEmbedConfig(dataset, opts) {
|
||||
const ds = dataset || {};
|
||||
@@ -47,27 +78,114 @@ export function parseEmbedConfig(dataset, opts) {
|
||||
const narrow = !!(opts && opts.narrow);
|
||||
return {
|
||||
slot: slot || null,
|
||||
lang: oneOf(LANG_VALUES, ds.lang, DEFAULTS.lang),
|
||||
position: oneOf(POSITION_VALUES, ds.position, DEFAULTS.position),
|
||||
intervalMs: normalizeInterval(ds.interval),
|
||||
size: narrow ? 'sm' : oneOf(SIZE_VALUES, ds.size, DEFAULTS.size),
|
||||
collapsedDefault: narrow ? true : DEFAULTS.collapsedDefault,
|
||||
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,
|
||||
intervalMs: provided(ds, 'interval') ? normalizeInterval(ds.interval) : DEFAULTS.intervalMs,
|
||||
size: narrow ? 'sm' : provided(ds, 'size') ? oneOf(SIZE_VALUES, ds.size, DEFAULTS.size) : DEFAULTS.size,
|
||||
collapsedDefault: DEFAULTS.collapsedDefault,
|
||||
imageFit: provided(ds, 'imageFit')
|
||||
? oneOf(IMAGE_FIT_VALUES, ds.imageFit, DEFAULTS.imageFit)
|
||||
: DEFAULTS.imageFit,
|
||||
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,
|
||||
// dev/e2e 轮询加速(viewer dev 构建读取;生产构建忽略),显式才透传。
|
||||
pollMs: provided(ds, 'poll') ? clampInt(ds.poll, 250, 60000, null) : null,
|
||||
narrow: narrow,
|
||||
explicit: {
|
||||
lang: provided(ds, 'lang'),
|
||||
position: provided(ds, 'position'),
|
||||
interval: provided(ds, 'interval'),
|
||||
size: !narrow && provided(ds, 'size'),
|
||||
imageFit: provided(ds, 'imageFit'),
|
||||
offsetX: provided(ds, 'offsetX'),
|
||||
offsetBottom: provided(ds, 'offsetBottom'),
|
||||
zIndex: provided(ds, 'zIndex'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** iframe src:{origin}/embed/{slot}?lang=&pos=&interval=(槽 token 编码防注入)。 */
|
||||
/**
|
||||
* 后台展示设置(iframe config 消息 / 公开端点 settings)逐字段校验归一:
|
||||
* 枚举白名单外回退缺省、数值越界夹取、布尔/形状异常回退缺省。整体非对象 → null
|
||||
* (调用方忽略该消息)。
|
||||
*/
|
||||
export function normalizeBackendSettings(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
return {
|
||||
position: oneOf(POSITION_VALUES, value.position, DEFAULTS.position),
|
||||
size: oneOf(SIZE_VALUES, value.size, DEFAULTS.size),
|
||||
intervalMs: clampInt(value.intervalMs, INTERVAL_MIN, INTERVAL_MAX, DEFAULTS.intervalMs),
|
||||
lang: oneOf(LANG_VALUES, value.lang, DEFAULTS.lang),
|
||||
collapsedDefault: typeof value.collapsedDefault === 'boolean' ? value.collapsedDefault : DEFAULTS.collapsedDefault,
|
||||
imageFit: oneOf(IMAGE_FIT_VALUES, value.imageFit, DEFAULTS.imageFit),
|
||||
offsetX: clampInt(value.offsetX, OFFSET_X_MIN, OFFSET_X_MAX, DEFAULTS.offsetX),
|
||||
offsetBottom: clampInt(value.offsetBottom, OFFSET_BOTTOM_MIN, OFFSET_BOTTOM_MAX, DEFAULTS.offsetBottom),
|
||||
zIndex: clampInt(value.zIndex, Z_INDEX_MIN, Z_INDEX_MAX, DEFAULTS.zIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台设置合并进当前配置:data-* 显式覆盖的字段保持宿主值,其余字段采用
|
||||
* 后台值(loader 侧二次校验后的归一结果)。narrow 强制(sm/默认收起)优先于
|
||||
* 后台值。返回新配置对象(不改入参)。
|
||||
*/
|
||||
export function mergeBackendConfig(config, settings) {
|
||||
const s = normalizeBackendSettings(settings);
|
||||
if (s === null) return config;
|
||||
const e = config.explicit;
|
||||
return {
|
||||
slot: config.slot,
|
||||
lang: e.lang ? config.lang : s.lang,
|
||||
position: e.position ? config.position : s.position,
|
||||
intervalMs: e.interval ? config.intervalMs : s.intervalMs,
|
||||
size: config.narrow ? 'sm' : e.size ? config.size : s.size,
|
||||
collapsedDefault: config.narrow ? true : s.collapsedDefault,
|
||||
imageFit: e.imageFit ? config.imageFit : s.imageFit,
|
||||
offsetX: e.offsetX ? config.offsetX : s.offsetX,
|
||||
offsetBottom: e.offsetBottom ? config.offsetBottom : s.offsetBottom,
|
||||
zIndex: e.zIndex ? config.zIndex : s.zIndex,
|
||||
pollMs: config.pollMs,
|
||||
narrow: config.narrow,
|
||||
explicit: e,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* iframe config 消息裁决:configVersion 必须为有限数值且高于当前版本(旧请求
|
||||
* 不得覆盖新配置),settings 归一校验。非法/过期 → null(忽略)。
|
||||
*/
|
||||
export function parseConfigMessage(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 = normalizeBackendSettings(data.settings);
|
||||
if (settings === null) return null;
|
||||
return { configVersion: Math.round(version), settings };
|
||||
}
|
||||
|
||||
/** iframe src:{origin}/embed/{slot}?lang=&pos=&interval=(槽 token 编码防注入)。
|
||||
* lang/interval/fit/poll 仅在 data-* 显式提供时拼接——缺省不覆盖后台设置
|
||||
* (iframe 侧以响应 settings 为准,方案 §5.4)。 */
|
||||
export function buildEmbedUrl(origin, slot, config) {
|
||||
return (
|
||||
let url =
|
||||
origin +
|
||||
'/embed/' +
|
||||
encodeURIComponent(slot) +
|
||||
'?lang=' +
|
||||
encodeURIComponent(config.lang) +
|
||||
'&pos=' +
|
||||
encodeURIComponent(config.position) +
|
||||
'&interval=' +
|
||||
String(config.intervalMs)
|
||||
);
|
||||
encodeURIComponent(config.position);
|
||||
if (config.explicit.interval) url += '&interval=' + String(config.intervalMs);
|
||||
if (config.explicit.imageFit) url += '&fit=' + encodeURIComponent(config.imageFit);
|
||||
if (config.pollMs !== null && config.pollMs !== undefined) url += '&poll=' + String(config.pollMs);
|
||||
return url;
|
||||
}
|
||||
|
||||
/** resize 高度夹取:非有限数值/缺失 → null(忽略该消息)。 */
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
// packages/embed-loader/src/params.test.js
|
||||
// 纯函数单测:data-* 解析/缺省回退/夹取、窄屏强制、URL 拼接、高度夹取。
|
||||
// DOM/宿主交互行为由 Task 4 Playwright 宿主页覆盖(计划注明 node 模拟 DOM 太薄)。
|
||||
// 纯函数单测:data-* 解析/缺省回退/夹取、显式覆盖表(缺省 ≠ 覆盖)、窄屏强制、
|
||||
// URL 拼接、高度夹取、后台设置归一/合并优先级、config 消息裁决、消息类型白名单。
|
||||
// DOM/宿主交互行为由 Playwright 宿主页覆盖(计划注明 node 模拟 DOM 太薄)。
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
buildEmbedUrl,
|
||||
clampHeight,
|
||||
mergeBackendConfig,
|
||||
normalizeBackendSettings,
|
||||
normalizeInterval,
|
||||
parseConfigMessage,
|
||||
parseEmbedConfig,
|
||||
SIZE_WIDTH,
|
||||
} from './params.js';
|
||||
|
||||
describe('parseEmbedConfig', () => {
|
||||
it('空 dataset 全缺省(slot null → 调用方 no-op)', () => {
|
||||
it('空 dataset 全缺省(slot null → 调用方 no-op)+ UI-01 新字段默认', () => {
|
||||
const c = parseEmbedConfig({});
|
||||
expect(c).toEqual({
|
||||
slot: null,
|
||||
@@ -19,6 +25,22 @@ describe('parseEmbedConfig', () => {
|
||||
intervalMs: 5000,
|
||||
size: 'md',
|
||||
collapsedDefault: false,
|
||||
imageFit: 'contain',
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000,
|
||||
pollMs: null,
|
||||
narrow: false,
|
||||
explicit: {
|
||||
lang: false,
|
||||
position: false,
|
||||
interval: false,
|
||||
size: false,
|
||||
imageFit: false,
|
||||
offsetX: false,
|
||||
offsetBottom: false,
|
||||
zIndex: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,12 +50,15 @@ describe('parseEmbedConfig', () => {
|
||||
expect(parseEmbedConfig({ slot: ' ' }).slot).toBeNull();
|
||||
});
|
||||
|
||||
it('lang/position 白名单外静默回退缺省', () => {
|
||||
it('lang/position/imageFit 白名单外静默回退缺省', () => {
|
||||
expect(parseEmbedConfig({ slot: 'a', lang: 'zh-CN' }).lang).toBe('zh-CN');
|
||||
expect(parseEmbedConfig({ slot: 'a', lang: 'en' }).lang).toBe('en');
|
||||
expect(parseEmbedConfig({ slot: 'a', lang: 'fr' }).lang).toBe('auto');
|
||||
expect(parseEmbedConfig({ slot: 'a', position: 'left' }).position).toBe('left');
|
||||
expect(parseEmbedConfig({ slot: 'a', position: 'top' }).position).toBe('right');
|
||||
expect(parseEmbedConfig({ slot: 'a', imageFit: 'cover' }).imageFit).toBe('cover');
|
||||
expect(parseEmbedConfig({ slot: 'a', imageFit: 'fill' }).imageFit).toBe('contain');
|
||||
expect(parseEmbedConfig({ slot: 'a', imageFit: 'cover' }).explicit.imageFit).toBe(true);
|
||||
});
|
||||
|
||||
it('interval 解析失败取缺省、越界夹取到 [3000, 30000]', () => {
|
||||
@@ -45,35 +70,222 @@ describe('parseEmbedConfig', () => {
|
||||
expect(parseEmbedConfig({ slot: 'a', interval: '2500' }).intervalMs).toBe(3000);
|
||||
});
|
||||
|
||||
it('offsetX/offsetBottom/zIndex 数值夹取到契约范围,非法回退缺省', () => {
|
||||
expect(parseEmbedConfig({ slot: 'a', offsetX: '200' }).offsetX).toBe(120);
|
||||
expect(parseEmbedConfig({ slot: 'a', offsetX: '-5' }).offsetX).toBe(0);
|
||||
expect(parseEmbedConfig({ slot: 'a', offsetX: '64' }).offsetX).toBe(64);
|
||||
expect(parseEmbedConfig({ slot: 'a', offsetBottom: '999' }).offsetBottom).toBe(240);
|
||||
expect(parseEmbedConfig({ slot: 'a', offsetBottom: 'abc' }).offsetBottom).toBe(24);
|
||||
expect(parseEmbedConfig({ slot: 'a', zIndex: '99999999999' }).zIndex).toBe(2147483000);
|
||||
expect(parseEmbedConfig({ slot: 'a', zIndex: '0' }).zIndex).toBe(1);
|
||||
expect(parseEmbedConfig({ slot: 'a', zIndex: '3000' }).zIndex).toBe(3000);
|
||||
});
|
||||
|
||||
it('窄屏(<480px):强制 sm 尺寸 + 默认收起,覆盖 dataset 尺寸语义', () => {
|
||||
const c = parseEmbedConfig({ slot: 'a', size: 'md' }, { narrow: true });
|
||||
expect(c.size).toBe('sm');
|
||||
expect(c.narrow).toBe(true);
|
||||
expect(c.explicit.size).toBe(false);
|
||||
});
|
||||
|
||||
it('md 尺寸宽 320(UI-01 §5.1 视觉变更),sm 280', () => {
|
||||
expect(SIZE_WIDTH.md).toBe(320);
|
||||
expect(SIZE_WIDTH.sm).toBe(280);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit 覆盖表(缺省 ≠ 显式覆盖)', () => {
|
||||
it('仅真正提供的字段进 explicit(空串视为缺省)', () => {
|
||||
const c = parseEmbedConfig({ slot: 'a', offsetX: '40', lang: '', imageFit: 'cover' });
|
||||
expect(c.explicit.offsetX).toBe(true);
|
||||
expect(c.explicit.imageFit).toBe(true);
|
||||
expect(c.explicit.lang).toBe(false);
|
||||
expect(c.explicit.interval).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBackendSettings(iframe config 消息 / 公开端 settings)', () => {
|
||||
it('合法输入原样通过', () => {
|
||||
expect(
|
||||
normalizeBackendSettings({
|
||||
position: 'left',
|
||||
size: 'sm',
|
||||
intervalMs: 8000,
|
||||
lang: 'en',
|
||||
collapsedDefault: true,
|
||||
imageFit: 'cover',
|
||||
offsetX: 40,
|
||||
offsetBottom: 80,
|
||||
zIndex: 2000,
|
||||
}),
|
||||
).toEqual({
|
||||
position: 'left',
|
||||
size: 'sm',
|
||||
intervalMs: 8000,
|
||||
lang: 'en',
|
||||
collapsedDefault: true,
|
||||
imageFit: 'cover',
|
||||
offsetX: 40,
|
||||
offsetBottom: 80,
|
||||
zIndex: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('非法值逐字段回退缺省(枚举/布尔/数值夹取)', () => {
|
||||
expect(
|
||||
normalizeBackendSettings({
|
||||
position: 'top',
|
||||
size: 'xl',
|
||||
intervalMs: 'x',
|
||||
lang: 1,
|
||||
collapsedDefault: 'yes',
|
||||
imageFit: null,
|
||||
offsetX: 999,
|
||||
offsetBottom: -1,
|
||||
zIndex: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
imageFit: 'contain',
|
||||
offsetX: 120,
|
||||
offsetBottom: 0,
|
||||
zIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('非对象输入返回 null(整体忽略)', () => {
|
||||
expect(normalizeBackendSettings(null)).toBeNull();
|
||||
expect(normalizeBackendSettings('x')).toBeNull();
|
||||
expect(normalizeBackendSettings(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeBackendConfig(优先级:data-* 显式 > 后台 > 默认)', () => {
|
||||
const backend = {
|
||||
position: 'left',
|
||||
size: 'sm',
|
||||
intervalMs: 9000,
|
||||
lang: 'en',
|
||||
collapsedDefault: true,
|
||||
imageFit: 'cover',
|
||||
offsetX: 48,
|
||||
offsetBottom: 96,
|
||||
zIndex: 2500,
|
||||
};
|
||||
|
||||
it('未显式覆盖的字段采用后台值', () => {
|
||||
const c = mergeBackendConfig(parseEmbedConfig({ slot: 'a' }), backend);
|
||||
expect(c.position).toBe('left');
|
||||
expect(c.intervalMs).toBe(9000);
|
||||
expect(c.imageFit).toBe('cover');
|
||||
expect(c.offsetX).toBe(48);
|
||||
expect(c.offsetBottom).toBe(96);
|
||||
expect(c.zIndex).toBe(2500);
|
||||
expect(c.collapsedDefault).toBe(true);
|
||||
});
|
||||
|
||||
it('非窄屏时 collapsedDefault 恒 false(收起默认由 iframe 侧按服务端设置下发)', () => {
|
||||
expect(parseEmbedConfig({ slot: 'a' }, { narrow: false }).collapsedDefault).toBe(false);
|
||||
it('data-* 显式覆盖的字段不被后台刷新冲掉', () => {
|
||||
const c = mergeBackendConfig(
|
||||
parseEmbedConfig({ slot: 'a', position: 'right', offsetX: '10', imageFit: 'contain' }),
|
||||
backend,
|
||||
);
|
||||
expect(c.position).toBe('right');
|
||||
expect(c.offsetX).toBe(10);
|
||||
expect(c.imageFit).toBe('contain');
|
||||
expect(c.intervalMs).toBe(9000); // 未显式 → 后台
|
||||
});
|
||||
|
||||
it('后台缺字段/非法 → 契约默认(旧投影行兼容)', () => {
|
||||
const c = mergeBackendConfig(parseEmbedConfig({ slot: 'a' }), { position: 'left' });
|
||||
expect(c.imageFit).toBe('contain');
|
||||
expect(c.offsetX).toBe(16);
|
||||
expect(c.zIndex).toBe(1000);
|
||||
expect(c.collapsedDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('后台非法输入 → 返回原配置(忽略整条消息)', () => {
|
||||
const base = parseEmbedConfig({ slot: 'a', offsetX: '10' });
|
||||
expect(mergeBackendConfig(base, null)).toBe(base);
|
||||
});
|
||||
|
||||
it('窄屏强制优先于后台:size 恒 sm、collapsedDefault 恒 true', () => {
|
||||
const c = mergeBackendConfig(parseEmbedConfig({ slot: 'a' }, { narrow: true }), {
|
||||
...backend,
|
||||
size: 'md',
|
||||
collapsedDefault: false,
|
||||
});
|
||||
expect(c.size).toBe('sm');
|
||||
expect(c.collapsedDefault).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseConfigMessage(版本闸 + 校验)', () => {
|
||||
const settings = {
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
imageFit: 'contain',
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
it('高版本 + 合法 settings → 归一结果', () => {
|
||||
const r = parseConfigMessage({ photoliveEmbed: true, type: 'config', configVersion: 3, settings }, 1);
|
||||
expect(r).toEqual({ configVersion: 3, settings });
|
||||
});
|
||||
|
||||
it('版本不高(旧请求/等值)→ null(旧配置不得覆盖新配置)', () => {
|
||||
expect(parseConfigMessage({ configVersion: 1, settings }, 1)).toBeNull();
|
||||
expect(parseConfigMessage({ configVersion: 2, settings }, 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('首条消息(当前版本 0/null)接受', () => {
|
||||
expect(parseConfigMessage({ configVersion: 1, settings }, 0)).not.toBeNull();
|
||||
expect(parseConfigMessage({ configVersion: 1, settings }, null)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('形状非法(缺版本/缺 settings/非对象)→ null', () => {
|
||||
expect(parseConfigMessage({ settings }, 0)).toBeNull();
|
||||
expect(parseConfigMessage({ configVersion: 2 }, 0)).toBeNull();
|
||||
expect(parseConfigMessage(null, 0)).toBeNull();
|
||||
expect(parseConfigMessage('x', 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEmbedUrl', () => {
|
||||
it('拼接 lang/pos/interval 查询参数', () => {
|
||||
const url = buildEmbedUrl('https://ov.example.com', 'tok', {
|
||||
lang: 'zh-CN',
|
||||
position: 'left',
|
||||
intervalMs: 7000,
|
||||
});
|
||||
expect(url).toBe('https://ov.example.com/embed/tok?lang=zh-CN&pos=left&interval=7000');
|
||||
it('显式字段拼接 interval/fit;缺省不拼(后台设置经 iframe 响应生效)', () => {
|
||||
const explicit = parseEmbedConfig({ slot: 'a', interval: '7000', imageFit: 'cover' });
|
||||
expect(buildEmbedUrl('https://ov.example.com', 'tok', explicit)).toBe(
|
||||
'https://ov.example.com/embed/tok?lang=auto&pos=right&interval=7000&fit=cover',
|
||||
);
|
||||
const bare = parseEmbedConfig({ slot: 'a' });
|
||||
expect(buildEmbedUrl('https://ov.example.com', 'tok', bare)).toBe(
|
||||
'https://ov.example.com/embed/tok?lang=auto&pos=right',
|
||||
);
|
||||
});
|
||||
|
||||
it('poll 显式时透传(dev/e2e 加速;生产构建忽略)', () => {
|
||||
const c = parseEmbedConfig({ slot: 'a', poll: '2000' });
|
||||
expect(buildEmbedUrl('https://ov.example.com', 'tok', c)).toContain('&poll=2000');
|
||||
});
|
||||
|
||||
it('slot token 编码(防路径注入)', () => {
|
||||
const url = buildEmbedUrl('https://ov.example.com', 'a/b?c=1', {
|
||||
lang: 'auto',
|
||||
position: 'right',
|
||||
intervalMs: 5000,
|
||||
});
|
||||
const url = buildEmbedUrl('https://ov.example.com', 'a/b?c=1', parseEmbedConfig({ slot: 'a/b?c=1' }));
|
||||
expect(url.startsWith('https://ov.example.com/embed/a%2Fb%3Fc%3D1?')).toBe(true);
|
||||
expect(url).toContain('lang=auto&pos=right&interval=5000');
|
||||
expect(url).toContain('lang=auto&pos=right');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MESSAGE_TYPES(消息类型白名单)', () => {
|
||||
it('resize/collapse/expand/config/destroy 全集', () => {
|
||||
expect([...MESSAGE_TYPES].sort()).toEqual(['collapse', 'config', 'destroy', 'expand', 'resize'].sort());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user