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 '
' + renderedBody + '
'; -} - -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 - ? '
' + escapeHtml(region.label || region.region_key || 'image') + '
' - : ''; - } - - 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 - ? '
' + escapeHtml(region.label || region.region_key || 'qr code') + '
' - : ''; - } - - if (regionType === 'html') { - const html = String(rawValue || '').trim(); - if (!html) { - return ''; - } - if (/^'; - } - return '
' + html + '
'; - } - - 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, '\\$&');