Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m18s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 33s
331 lines
12 KiB
JavaScript
331 lines
12 KiB
JavaScript
// Time/date region rendering and live updates.
|
|
|
|
var registry = window.pulsePlayerRegionTypes;
|
|
var placeholderUtils = window.placeholderUtils || {};
|
|
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
|
|
var DEFAULT_STYLE = {
|
|
font_family: 'Arial',
|
|
font_size: 32,
|
|
font_color: '#000000'
|
|
};
|
|
var timeDateFormatterCache = Object.create(null);
|
|
|
|
function sanitizeTagAttributes(tagName, attrText) {
|
|
var allowedAttributes = {
|
|
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
|
blockquote: ['class', 'style'],
|
|
div: ['class', 'style'],
|
|
figure: ['class', 'style'],
|
|
figcaption: ['class', 'style'],
|
|
h1: ['class', 'style'],
|
|
h2: ['class', 'style'],
|
|
h3: ['class', 'style'],
|
|
h4: ['class', 'style'],
|
|
h5: ['class', 'style'],
|
|
h6: ['class', 'style'],
|
|
li: ['class', 'style'],
|
|
ol: ['class', 'style', 'start'],
|
|
p: ['class', 'style'],
|
|
pre: ['class', 'style'],
|
|
span: ['class', 'style'],
|
|
table: ['class', 'style'],
|
|
td: ['class', 'style', 'colspan', 'rowspan'],
|
|
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
|
tr: ['class', 'style'],
|
|
ul: ['class', 'style']
|
|
};
|
|
var allowed = allowedAttributes[tagName] || [];
|
|
if (!allowed.length) {
|
|
return '';
|
|
}
|
|
|
|
var attrs = [];
|
|
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
|
|
var lowerKey = String(key || '').toLowerCase();
|
|
if (allowed.indexOf(lowerKey) === -1) {
|
|
return '';
|
|
}
|
|
|
|
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
|
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
|
return '';
|
|
}
|
|
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
|
return '';
|
|
}
|
|
if (lowerKey === 'target') {
|
|
var targetValue = String(value || '').trim();
|
|
if (targetValue === '_blank') {
|
|
attrs.push(' target="_blank"');
|
|
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
|
|
attrs.push(' rel="noreferrer noopener"');
|
|
}
|
|
return '';
|
|
}
|
|
}
|
|
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
|
return '';
|
|
});
|
|
|
|
return attrs.join('');
|
|
}
|
|
|
|
function sanitizePreviewHtml(html) {
|
|
var output = String(html || '');
|
|
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
return output.replace(/<[^>]+>/g, function (tag) {
|
|
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
|
if (!match) {
|
|
return '';
|
|
}
|
|
|
|
var closing = Boolean(match[1]);
|
|
var name = String(match[2] || '').toLowerCase();
|
|
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
|
if (allowed.indexOf(name) === -1) {
|
|
return '';
|
|
}
|
|
|
|
if (closing) {
|
|
return '</' + name + '>';
|
|
}
|
|
|
|
return '<' + name + sanitizeTagAttributes(name, String(match[3] || '')) + '>';
|
|
});
|
|
}
|
|
|
|
function getDefaultStyle() {
|
|
return {
|
|
font_family: DEFAULT_STYLE.font_family,
|
|
font_size: DEFAULT_STYLE.font_size,
|
|
font_color: DEFAULT_STYLE.font_color
|
|
};
|
|
}
|
|
|
|
function getDefaultTimeZone() {
|
|
try {
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
} catch (_error) {
|
|
return 'UTC';
|
|
}
|
|
}
|
|
|
|
function resolveTimeZone(value) {
|
|
var raw = String(value || '').trim();
|
|
if (!raw) {
|
|
return getDefaultTimeZone();
|
|
}
|
|
|
|
try {
|
|
new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date());
|
|
return raw;
|
|
} catch (_error) {
|
|
return getDefaultTimeZone();
|
|
}
|
|
}
|
|
|
|
function getTimeDateFormatter(key, options) {
|
|
if (!timeDateFormatterCache[key]) {
|
|
timeDateFormatterCache[key] = new Intl.DateTimeFormat('en-GB', options);
|
|
}
|
|
|
|
return timeDateFormatterCache[key];
|
|
}
|
|
|
|
function getTimeDateFormattedParts(timeZone, date) {
|
|
var targetDate = date instanceof Date ? date : new Date();
|
|
var resolvedTimeZone = resolveTimeZone(timeZone);
|
|
var numericParts = getTimeDateFormatter('numeric:' + resolvedTimeZone, {
|
|
timeZone: resolvedTimeZone,
|
|
hour12: false,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric'
|
|
}).formatToParts(targetDate);
|
|
var weekdayLong = getTimeDateFormatter('weekday-long:' + resolvedTimeZone, {
|
|
timeZone: resolvedTimeZone,
|
|
weekday: 'long'
|
|
}).formatToParts(targetDate);
|
|
var weekdayShort = getTimeDateFormatter('weekday-short:' + resolvedTimeZone, {
|
|
timeZone: resolvedTimeZone,
|
|
weekday: 'short'
|
|
}).formatToParts(targetDate);
|
|
var monthLong = getTimeDateFormatter('month-long:' + resolvedTimeZone, {
|
|
timeZone: resolvedTimeZone,
|
|
month: 'long'
|
|
}).formatToParts(targetDate);
|
|
var monthShort = getTimeDateFormatter('month-short:' + resolvedTimeZone, {
|
|
timeZone: resolvedTimeZone,
|
|
month: 'short'
|
|
}).formatToParts(targetDate);
|
|
var ampm = getTimeDateFormatter('ampm:' + resolvedTimeZone, {
|
|
timeZone: resolvedTimeZone,
|
|
hour12: true,
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}).formatToParts(targetDate);
|
|
var timezoneShort = getTimeDateFormatter('tz-short:' + resolvedTimeZone, {
|
|
timeZone: resolvedTimeZone,
|
|
timeZoneName: 'short'
|
|
}).formatToParts(targetDate);
|
|
|
|
function getPart(parts, type) {
|
|
var match = parts.find(function (part) {
|
|
return part && part.type === type;
|
|
});
|
|
return match ? String(match.value || '') : '';
|
|
}
|
|
|
|
function toTitleCase(value) {
|
|
return String(value || '').toLowerCase().replace(/\b([a-z])/g, function (match, letter) {
|
|
return String(letter || '').toUpperCase();
|
|
});
|
|
}
|
|
|
|
return {
|
|
h: String(Number(getPart(numericParts, 'hour')) || 0),
|
|
hh: getPart(numericParts, 'hour'),
|
|
m: String(Number(getPart(numericParts, 'minute')) || 0),
|
|
mm: getPart(numericParts, 'minute'),
|
|
s: String(Number(getPart(numericParts, 'second')) || 0),
|
|
ss: getPart(numericParts, 'second'),
|
|
d: String(Number(getPart(numericParts, 'day')) || 0),
|
|
dd: getPart(numericParts, 'day'),
|
|
M: String(Number(getPart(numericParts, 'month')) || 0),
|
|
MM: getPart(numericParts, 'month'),
|
|
y: String(Number(getPart(numericParts, 'year')) || 0),
|
|
yyyy: getPart(numericParts, 'year'),
|
|
yy: String(Number(String(getPart(numericParts, 'year')).slice(-2)) || 0).padStart(2, '0'),
|
|
ddd: toTitleCase(getPart(weekdayShort, 'weekday')),
|
|
dddd: toTitleCase(getPart(weekdayLong, 'weekday')),
|
|
MMM: toTitleCase(getPart(monthShort, 'month')),
|
|
MMMM: toTitleCase(getPart(monthLong, 'month')),
|
|
a: toTitleCase(getPart(ampm, 'dayPeriod')),
|
|
tz: getPart(timezoneShort, 'timeZoneName'),
|
|
tz_long: resolvedTimeZone,
|
|
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
|
|
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
|
|
};
|
|
}
|
|
|
|
function resolveTimeDateTemplatePlaceholder(values, expression) {
|
|
var currentPlaceholderUtils = window.placeholderUtils || placeholderUtils || {};
|
|
if (typeof currentPlaceholderUtils.resolvePlaceholderExpression === 'function' && typeof currentPlaceholderUtils.formatPlaceholderValue === 'function') {
|
|
return currentPlaceholderUtils.formatPlaceholderValue(currentPlaceholderUtils.resolvePlaceholderExpression(values, expression));
|
|
}
|
|
|
|
var parsed = String(expression || '').trim();
|
|
return Object.prototype.hasOwnProperty.call(values, parsed) ? values[parsed] : '';
|
|
}
|
|
|
|
function renderTimeDateTemplate(format, timeZone, date) {
|
|
var template = String(format || '').trim() || DEFAULT_FORMAT;
|
|
var values = getTimeDateFormattedParts(timeZone, date);
|
|
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
|
|
return String(resolveTimeDateTemplatePlaceholder(values, key, { timeZone: timeZone }) || '');
|
|
});
|
|
}
|
|
|
|
|
|
|
|
function getTextStyle(regionContent, region) {
|
|
var defaultStyle = getDefaultStyle();
|
|
return {
|
|
font_family: regionContent.font_family || region.fontFamily || defaultStyle.font_family,
|
|
font_size: regionContent.font_size || region.fontSize || defaultStyle.font_size,
|
|
font_color: regionContent.font_color || region.fontColor || defaultStyle.font_color
|
|
};
|
|
}
|
|
|
|
function renderTimeDateRegion(region, regionContent) {
|
|
var content = regionContent && typeof regionContent === 'object' ? regionContent : {};
|
|
var format = String(content.value !== undefined ? content.value : content.text || '').trim() || DEFAULT_FORMAT;
|
|
var timeZone = resolveTimeZone(content.timezone || content.time_zone || '');
|
|
var style = getTextStyle(content, region);
|
|
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
|
|
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
|
var fontColor = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
|
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? fontFamily : '') + fontSize + fontColor + 'white-space:pre-wrap;line-height:1.1;';
|
|
var renderedText = renderTimeDateTemplate(format, timeZone, new Date());
|
|
return '<div class="template-region time-date" data-time-date-format="' + escapeHtml(format) + '" data-time-date-timezone="' + escapeHtml(timeZone) + '" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(renderedText) + '</div></div>';
|
|
}
|
|
|
|
function updateTimeDateRegion(element) {
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
var format = String(element.dataset.timeDateFormat || '').trim() || DEFAULT_FORMAT;
|
|
var timeZone = String(element.dataset.timeDateTimezone || '').trim();
|
|
var scaleWrapper = element.querySelector('.template-region-text-scale');
|
|
if (!scaleWrapper) {
|
|
return;
|
|
}
|
|
|
|
scaleWrapper.innerHTML = renderEditorJsContent(renderTimeDateTemplate(format, timeZone, new Date()));
|
|
}
|
|
|
|
function scheduleTimeDateRegionUpdate(element) {
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
if (element.__timeDateTimer) {
|
|
window.clearTimeout(element.__timeDateTimer);
|
|
element.__timeDateTimer = null;
|
|
}
|
|
|
|
function tick() {
|
|
if (!element.isConnected) {
|
|
if (element.__timeDateTimer) {
|
|
window.clearTimeout(element.__timeDateTimer);
|
|
element.__timeDateTimer = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
updateTimeDateRegion(element);
|
|
element.__timeDateTimer = window.setTimeout(tick, Math.max(100, 1000 - (Date.now() % 1000)));
|
|
}
|
|
|
|
tick();
|
|
}
|
|
|
|
function destroyTimeDateRegions(root) {
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
Array.prototype.forEach.call(root.querySelectorAll('.template-region.time-date'), function (element) {
|
|
if (element && element.__timeDateTimer) {
|
|
window.clearTimeout(element.__timeDateTimer);
|
|
element.__timeDateTimer = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
function initializeTimeDateRegions(root) {
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
Array.prototype.forEach.call(root.querySelectorAll('.template-region.time-date'), function (element) {
|
|
scheduleTimeDateRegionUpdate(element);
|
|
});
|
|
}
|
|
|
|
registry.register('time-date', {
|
|
label: 'Time / Date',
|
|
getDefaultStyle: getDefaultStyle,
|
|
getDefaultRegionSize: function () {
|
|
return { width: 420, height: 180 };
|
|
},
|
|
renderRegion: renderTimeDateRegion,
|
|
initRegion: initializeTimeDateRegions,
|
|
destroyRegion: destroyTimeDateRegions
|
|
});
|