Consolidate slide preview content resolution
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 30s

This commit is contained in:
lzstealth committed 2026-10-25 02:15:27 +00:00
1 parent 726a7fedc7
commit e9a53f577c
11 files changed
+77 -240

No files matched your search

+1 -8
View File
@@ -6,18 +6,11 @@ All notable changes to this project will be documented in this file.
### Added
- Slide-list thumbnails open an aspect-ratio-matched modal with a full-resolution preview of the saved slide.
- Slide-list thumbnails open an authorized, aspect-ratio-matched modal with a full-resolution preview of the saved slide. Previews share the editor's region renderers and content resolution, including template defaults, template-only regions, slide overrides, and time/date and data placeholders. Framing follows the application theme, preserves rounded corners, and hides editor-only image and weather-icon bounds.
### Changed
- The shared connection indicator names the connected path (Bridge or Player), shows the connected player count, uses blue when the service is online with no players, and uses one topology-neutral message when no services are connected. Status dots stay static and the last status is preserved across page navigation.
- Thumbnail previews use the shared region renderers so time/date and data placeholders match the editor preview.
- Preview framing follows the application theme and preserves the modal's rounded corners.
### Fixed
- Removed editor-only image and weather-icon placeholder bounds from popup previews while keeping them visible in the editor.
- Protected saved-slide popup previews with the existing slide-read authorization check.
## 2.19.2 - 2026-10-24
@@ -1,213 +0,0 @@
// Pure thumbnail preview payload helpers shared by routes and screenshot capture.
const {
escapeHtml,
renderEditorJsContent,
sanitizeFontFamily,
sanitizeFontSize,
sanitizeTextColor
} = require('#src/player/render-helpers');
const { applyLayout } = require('#src/web/public/js/shared/region-layout');
function normalizeBaseUrl(baseUrl) {
return String(baseUrl || '').trim().replace(/\/$/, '');
}
function resolveAssetUrl(baseUrl, value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
return raw;
}
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
return raw;
}
if (raw.startsWith('/')) {
return normalizedBaseUrl + raw;
}
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return normalizeRenderableValue(value.value);
}
if (value.text !== undefined) {
return normalizeRenderableValue(value.text);
}
if (value.html !== undefined) {
return normalizeRenderableValue(value.html);
}
if (value.url !== undefined) {
return normalizeRenderableValue(value.url);
}
if (value.href !== undefined) {
return normalizeRenderableValue(value.href);
}
if (value.src !== undefined) {
return normalizeRenderableValue(value.src);
}
if (value.content !== undefined) {
return normalizeRenderableValue(value.content);
}
return '';
}
return String(value === undefined || value === null ? '' : value);
}
function getThumbnailCanvasSize(slide) {
const template = slide && slide.template ? slide.template : null;
return {
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
};
}
function getRegionContent(slide, region) {
const saved = slide && slide.content && slide.content[region.region_key];
let selected = region.read_only === true || Number(region.read_only) === 1
? region.default_content
: (saved === undefined ? region.default_content : saved);
if (typeof selected === 'string') {
try { selected = JSON.parse(selected); } catch (_error) { selected = { value: selected }; }
}
const content = selected && typeof selected === 'object' ? selected : { value: selected };
return Object.assign({ type: region.region_type }, content || {});
}
function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
const left = Number.isFinite(Number(region && region.x)) && canvasWidth > 0 ? (Number(region.x) / canvasWidth) * 100 : 0;
const top = Number.isFinite(Number(region && region.y)) && canvasHeight > 0 ? (Number(region.y) / canvasHeight) * 100 : 0;
const width = Number.isFinite(Number(region && region.width)) && canvasWidth > 0 ? (Number(region.width) / canvasWidth) * 100 : 0;
const height = Number.isFinite(Number(region && region.height)) && canvasHeight > 0 ? (Number(region.height) / canvasHeight) * 100 : 0;
return 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region && region.z_index || 0) + ';';
}
function hasVisibleContent(html) {
var raw = String(html || '').trim();
if (!raw) {
return false;
}
if (/<img\b/i.test(raw)) {
return true;
}
return Boolean(raw.replace(/<[^>]+>/g, '').trim());
}
function buildTextRegionMarkup(region, regionContent) {
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
const renderedBody = renderEditorJsContent(regionContent.value || '');
if (!hasVisibleContent(renderedBody)) {
return '';
}
return '<div class="slide-preview-region slide-preview-text-region" style="' + region.baseStyle + '"><div class="slide-preview-text-content" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;overflow:hidden;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
}
function buildRegionInnerHtml(region, regionContent, baseUrl) {
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
if (regionType === 'image') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<div class="slide-preview-region slide-preview-image-region" style="' + region.baseStyle + '"><img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" /></div>'
: '';
}
if (regionType === 'video') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<div class="slide-preview-region slide-preview-video-region" style="' + region.baseStyle + '"><video class="slide-preview-video" src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video></div>'
: '';
}
if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<div class="slide-preview-region slide-preview-webpage-region" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe></div>'
: '';
}
if (regionType === 'qr-code') {
const src = String(regionContent.qr_preview || '').trim();
const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0)));
const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : '';
return src
? '<div class="slide-preview-region slide-preview-qr-code-region" style="' + region.baseStyle + radiusStyle + '"><img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '" /></div>'
: '';
}
if (regionType === 'html') {
const html = String(rawValue || '').trim();
if (!html) {
return '';
}
if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><iframe sandbox="" allowtransparency="true" srcdoc="' + escapeHtml(html) + '" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe></div>';
}
return '<div class="slide-preview-region slide-preview-html-region" style="' + region.baseStyle + '"><div class="slide-preview-html-content" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div></div>';
}
if (regionType === 'rtmp') {
const label = String(rawValue || '').trim() || 'RTMP source';
return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
}
if (regionType === 'chart') {
return '<div class="slide-preview-region slide-preview-chart-region" style="' + region.baseStyle + 'position:relative;background:transparent;"><canvas data-chart-json="' + escapeHtml(JSON.stringify(regionContent || {})) + '" aria-label="' + escapeHtml(region.label || region.region_key || 'chart') + '"></canvas></div>';
}
return buildTextRegionMarkup(region, regionContent);
}
function buildThumbnailPreviewMarkup(slide, baseUrl) {
const template = slide && slide.template ? slide.template : null;
if (!template) {
return '';
}
const canvasSize = getThumbnailCanvasSize(slide);
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
return (Array.isArray(template.regions) ? template.regions : []).map(function (region) {
const regionContent = getRegionContent(slide, region);
region = applyLayout(region, regionContent, canvasSize);
const previewRegion = Object.assign({}, region, {
baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height),
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
pixelHeight: Math.max(1, Math.round(Number(region && region.height || 0) || 1))
});
return buildRegionInnerHtml(previewRegion, regionContent, normalizedBaseUrl);
}).join('');
}
function buildThumbnailPreviewPayload(slide, options) {
const template = slide && slide.template ? slide.template : null;
const canvasSize = getThumbnailCanvasSize(slide);
return {
thumbnailPreview: true,
canvasWidth: canvasSize.width,
canvasHeight: canvasSize.height,
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
apiSources: Array.isArray(options && options.apiSources) ? options.apiSources : [],
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl)
};
}
module.exports = {
buildThumbnailPreviewPayload: buildThumbnailPreviewPayload,
buildThumbnailPreviewMarkup: buildThumbnailPreviewMarkup
};
+4 -3
View File
@@ -16,6 +16,7 @@ const {
const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { applyLayout } = require('#src/web/public/js/shared/region-layout');
const { resolveRegionContent } = require('#src/web/public/js/shared/region-content');
const placeholderUtils = require('#src/web/public/js/shared/placeholder-utils');
const weatherData = require('#src/web/public/js/shared/weather-data');
@@ -119,8 +120,7 @@ function getCanvasSize(slide) {
}
function getRegionContent(slide, region) {
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
return content && typeof content === 'object' ? content : { value: content };
return resolveRegionContent(region, slide && slide.content);
}
function getThumbnailCanvasSize(slide) {
@@ -324,6 +324,7 @@ function buildThumbnailPreviewMarkup(slide, baseUrl, options) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
return (Array.isArray(template.regions) ? template.regions : []).map(function (region) {
const regionContent = getRegionContent(slide, region);
region = applyLayout(region, regionContent, canvasSize);
const previewRegion = Object.assign({}, region, {
baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height),
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
@@ -348,7 +349,7 @@ function buildThumbnailPreviewPayload(slide, options) {
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
regions: regions,
existingContent: slide && slide.content && typeof slide.content === 'object' ? slide.content : {},
existingContent: Object.fromEntries(regions.map(function (region) { return [region.region_key, region.content]; })),
apiSources: Array.isArray(options && options.apiSources) ? options.apiSources : [],
rssFeeds: Array.isArray(options && options.rssFeeds) ? options.rssFeeds : [],
weatherLocations: Array.isArray(options && options.weatherLocations) ? options.weatherLocations : [],
+1 -1
View File
@@ -28,7 +28,7 @@ function getRegionModuleScripts(assetVersion) {
}
function getRegionEditorScripts(assetVersion) {
return ['js/shared/weather-icons.js'].concat(REGION_CORE_SCRIPTS).map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion));
return ['js/shared/region-content.js', 'js/shared/weather-icons.js'].concat(REGION_CORE_SCRIPTS).map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion));
}
module.exports = {
@@ -0,0 +1,22 @@
(function (root, factory) {
var helpers = factory();
if (typeof module === 'object' && module.exports) {
module.exports = helpers;
} else {
root.pulseRegionContent = helpers;
}
})(typeof window !== 'undefined' ? window : globalThis, function () {
function resolveRegionContent(region, existingContent) {
var saved = existingContent && existingContent[region.region_key];
var selected = region.read_only === true || Number(region.read_only) === 1 || saved === undefined
? region.default_content
: saved;
if (typeof selected === 'string') {
try { selected = JSON.parse(selected); } catch (_error) { selected = { value: selected }; }
}
var content = selected && typeof selected === 'object' && !Array.isArray(selected) ? selected : { value: selected };
return Object.assign({ type: region.region_type }, content);
}
return { resolveRegionContent: resolveRegionContent };
});
+1 -8
View File
@@ -394,14 +394,7 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
Object.assign(existingContent, savedExistingContent);
}
(template && Array.isArray(template.regions) ? template.regions : []).forEach(function (region) {
var content = region.default_content;
if (typeof content === 'string') {
try { content = JSON.parse(content); } catch (_error) { content = {}; }
}
if (!content || typeof content !== 'object' || Array.isArray(content)) content = {};
if (region.read_only === true || Number(region.read_only) === 1 || !Object.prototype.hasOwnProperty.call(existingContent, region.region_key)) {
existingContent[region.region_key] = content;
}
existingContent[region.region_key] = window.pulseRegionContent.resolveRegionContent(region, existingContent);
});
}
+1 -3
View File
@@ -395,14 +395,12 @@ test('shared iframe renderers size preview content explicitly', () => {
const playerHtmlRegionSource = fs.readFileSync(require.resolve('../src/player/regions/html.js'), 'utf8');
const playerWebpageRegionSource = fs.readFileSync(require.resolve('../src/player/regions/webpage.js'), 'utf8');
const playerRenderHelpersSource = fs.readFileSync(require.resolve('../src/player/render-helpers.js'), 'utf8');
const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8');
const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8');
assert.ok(playerHtmlRegionSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
assert.ok(playerWebpageRegionSource.includes('style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"'));
assert.ok(playerRenderHelpersSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
assert.ok(slideThumbnailPreviewSource.includes('slide-preview-webpage-region'));
assert.ok(slideThumbnailPreviewSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
assert.ok(slideThumbnailsSource.includes('slide-preview-webpage-region'));
assert.ok(slideThumbnailsSource.includes('slide-preview-webpage-placeholder'));
assert.ok(slideThumbnailsSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"'));
});
+1 -1
View File
@@ -6,7 +6,7 @@ const vm = require('node:vm');
require('../src/common');
const { buildSlidePayload } = require('../src/data/slides');
const { buildThumbnailPreviewMarkup } = require('../src/web/lib/media/slide-thumbnail-preview');
const { buildThumbnailPreviewMarkup } = require('../src/web/lib/media/slide-thumbnails');
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('../src/data/templates');
const { getTemplateLayoutSnapshot, getTemplateAnimationSnapshot, retainLayoutAnimation } = require('../src/web/public/js/shared/region-layout');
-2
View File
@@ -7,7 +7,6 @@ const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public
const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
const slideFormTemplateSource = fs.readFileSync(require.resolve('../src/web/views/signage/slides/form.hbs'), 'utf8');
const mediaPickerSource = fs.readFileSync(require.resolve('../src/web/public/js/shared/ui/media-picker.js'), 'utf8');
const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8');
const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8');
const slideHtmlRegionSource = fs.readFileSync(require.resolve('../src/web/public/js/regions/type/html.js'), 'utf8');
const slideWebpageRegionSource = fs.readFileSync(require.resolve('../src/web/public/js/regions/type/webpage.js'), 'utf8');
@@ -89,7 +88,6 @@ test('media library selections keep image and video remove controls', () => {
});
test('slide thumbnail previews treat image-only text as visible content', () => {
assert.ok(slideThumbnailPreviewSource.includes('/<img\\b/i.test(raw)'));
assert.ok(slideThumbnailsSource.includes('/<img\\b/i.test(raw)'));
assert.ok(slideThumbnailsSource.includes('getCachedImagePath'));
assert.ok(slideThumbnailsSource.includes('getThumbnailItem'));
+45
View File
@@ -6,6 +6,8 @@ const vm = require('node:vm');
const { render } = require('../src/web/public/js/shared/slide-preview-renderer');
const { buildThumbnailPreviewPayload } = require('../src/web/lib/media/slide-thumbnails');
const placeholderUtils = require('../src/web/public/js/shared/placeholder-utils');
const { resolveRegionContent } = require('../src/web/public/js/shared/region-content');
const { getRegionEditorScripts } = require('../src/web/lib/region-scripts');
function loadEditorTimeDateModule() {
const source = fs.readFileSync(require.resolve('../src/web/public/js/regions/type/time-date.js'), 'utf8');
@@ -49,6 +51,49 @@ test('thumbnail payload passes saved region geometry and content to the popup re
assert.equal(payload.existingContent.clock.timezone, 'Europe/London');
});
test('thumbnail popup respects template defaults, read-only regions and explicit slide content', () => {
const payload = buildThumbnailPreviewPayload({
template: {
canvas_size_width: 1280,
canvas_size_height: 720,
regions: [
{ region_key: 'logo', region_type: 'image', read_only: 1, default_content: { value: '/media/uploads/template-logo.png' }, x: 0, y: 0, width: 200, height: 100 },
{ region_key: 'heading', region_type: 'text', default_content: JSON.stringify({ value: 'Template heading' }), x: 0, y: 100, width: 400, height: 100 },
{ region_key: 'custom', region_type: 'text', default_content: { value: 'Default custom' }, x: 0, y: 200, width: 400, height: 100 },
{ region_key: 'blank', region_type: 'text', default_content: { value: 'Do not restore' }, x: 0, y: 300, width: 400, height: 100 }
]
},
content: {
logo: { value: '/media/uploads/stale-logo.png' },
custom: { value: 'Slide heading' },
blank: { value: '' }
}
});
assert.equal(payload.regions[0].content.value, '/media/uploads/template-logo.png');
assert.equal(payload.regions[0].content.type, 'image');
assert.equal(payload.regions[1].content.value, 'Template heading');
assert.equal(payload.regions[2].content.value, 'Slide heading');
assert.equal(payload.regions[3].content.value, '');
assert.equal(payload.existingContent.logo.value, '/media/uploads/template-logo.png');
assert.equal(payload.existingContent.heading.value, 'Template heading');
assert.match(payload.html, /template-logo\.png/);
assert.match(payload.html, /Template heading/);
assert.match(payload.html, /Slide heading/);
assert.doesNotMatch(payload.html, /stale-logo|Default custom|Do not restore/);
});
test('editor and popup load the same browser-compatible content resolver', () => {
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/region-content'), 'utf8'), sandbox);
const region = { region_key: 'brand', region_type: 'text', read_only: true, default_content: { value: 'Shared brand' } };
const saved = { brand: { value: 'Stale brand' } };
assert.equal(JSON.stringify(sandbox.window.pulseRegionContent.resolveRegionContent(region, saved)), JSON.stringify(resolveRegionContent(region, saved)));
assert.ok(getRegionEditorScripts().includes('js/shared/region-content.js'));
const editorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
assert.match(editorSource, /window\.pulseRegionContent\.resolveRegionContent\(region, existingContent\)/);
});
test('thumbnail regions use shared region renderers and their source context', () => {
const calls = [];
const registry = {
+1 -1
View File
@@ -13,7 +13,7 @@ const {
const renderTemplateAddPage = require('../src/web/routes/signage/templates/add');
const renderTemplateEditPage = require('../src/web/routes/signage/templates/edit');
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../src/web/routes/signage/templates/duplicate');
const { buildThumbnailPreviewPayload } = require('../src/web/lib/media/slide-thumbnail-preview');
const { buildThumbnailPreviewPayload } = require('../src/web/lib/media/slide-thumbnails');
function readCssDeclarations(css, selector) {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');