diff --git a/CHANGELOG.md b/CHANGELOG.md
index 08f9b05..02a50da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/web/lib/media/slide-thumbnail-preview.js b/src/web/lib/media/slide-thumbnail-preview.js
deleted file mode 100644
index 1554915..0000000
--- a/src/web/lib/media/slide-thumbnail-preview.js
+++ /dev/null
@@ -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 (/
]+>/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 '
';
-}
-
-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
- ? ''
- : '';
- }
-
- if (regionType === 'video') {
- const src = resolveAssetUrl(baseUrl, rawValue);
- return src
- ? ''
- : '';
- }
-
- if (regionType === 'webpage') {
- const src = resolveAssetUrl(baseUrl, rawValue);
- return src
- ? ''
- : '';
- }
-
- 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
- ? ''
- : '';
- }
-
- if (regionType === 'html') {
- const html = String(rawValue || '').trim();
- if (!html) {
- return '';
- }
- if (/^';
- }
- return '';
- }
-
- if (regionType === 'rtmp') {
- const label = String(rawValue || '').trim() || 'RTMP source';
- return '' + escapeHtml(label) + '
';
- }
-
- if (regionType === 'chart') {
- return '';
- }
-
- 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
-};
\ No newline at end of file
diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js
index c6e0291..c96c7ec 100644
--- a/src/web/lib/media/slide-thumbnails.js
+++ b/src/web/lib/media/slide-thumbnails.js
@@ -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 : [],
diff --git a/src/web/lib/region-scripts.js b/src/web/lib/region-scripts.js
index 9ab46d8..dc7cc7b 100644
--- a/src/web/lib/region-scripts.js
+++ b/src/web/lib/region-scripts.js
@@ -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 = {
diff --git a/src/web/public/js/shared/region-content.js b/src/web/public/js/shared/region-content.js
new file mode 100644
index 0000000..e4a4033
--- /dev/null
+++ b/src/web/public/js/shared/region-content.js
@@ -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 };
+});
\ No newline at end of file
diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js
index 82f18bd..66df49f 100644
--- a/src/web/public/js/slides/slide-form.js
+++ b/src/web/public/js/slides/slide-form.js
@@ -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);
});
}
diff --git a/test/player-render-helpers.test.js b/test/player-render-helpers.test.js
index ac1d867..a4fc035 100644
--- a/test/player-render-helpers.test.js
+++ b/test/player-render-helpers.test.js
@@ -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;"'));
});
diff --git a/test/region-layout.test.js b/test/region-layout.test.js
index d839b46..3eabf90 100644
--- a/test/region-layout.test.js
+++ b/test/region-layout.test.js
@@ -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');
diff --git a/test/slide-form-editor.test.js b/test/slide-form-editor.test.js
index 99557a0..0067b9b 100644
--- a/test/slide-form-editor.test.js
+++ b/test/slide-form-editor.test.js
@@ -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('/
{
+ 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 = {
diff --git a/test/templates.test.js b/test/templates.test.js
index 92ede7f..5be0380 100644
--- a/test/templates.test.js
+++ b/test/templates.test.js
@@ -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, '\\$&');