Release 2.19.3
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m44s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
lzstealth committed 2026-10-25 01:37:40 +00:00
1 parent 55ee5dc366
commit 2e603caeb6
19 files changed
+351 -28

No files matched your search

+16
View File
@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.19.3 - 2026-10-25
### Added
- Slide-list thumbnails open an aspect-ratio-matched modal with a full-resolution preview of the saved slide.
### Changed
- 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 ## 2.19.2 - 2026-10-24
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.19.2", "version": "2.19.3",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.19.2", "version": "2.19.3",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.19.2", "version": "2.19.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.19.2", "version": "2.19.3",
"dependencies": { "dependencies": {
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"bootstrap-icons": "1.13.1", "bootstrap-icons": "1.13.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.19.2", "version": "2.19.3",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+12
View File
@@ -15,6 +15,7 @@ const {
} = require('#src/player/render-helpers'); } = require('#src/player/render-helpers');
const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth'); const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth');
const { convertWeatherSnapshot } = require('#src/data/weather-units'); const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { applyLayout } = require('#src/web/public/js/shared/region-layout');
const placeholderUtils = require('#src/web/public/js/shared/placeholder-utils'); const placeholderUtils = require('#src/web/public/js/shared/placeholder-utils');
const weatherData = require('#src/web/public/js/shared/weather-data'); const weatherData = require('#src/web/public/js/shared/weather-data');
@@ -335,6 +336,10 @@ function buildThumbnailPreviewMarkup(slide, baseUrl, options) {
function buildThumbnailPreviewPayload(slide, options) { function buildThumbnailPreviewPayload(slide, options) {
const template = slide && slide.template ? slide.template : null; const template = slide && slide.template ? slide.template : null;
const canvasSize = getThumbnailCanvasSize(slide); const canvasSize = getThumbnailCanvasSize(slide);
const regions = (Array.isArray(template && template.regions) ? template.regions : []).map(function (region) {
const content = getRegionContent(slide, region);
return Object.assign({}, applyLayout(region, content, canvasSize), { content: content });
});
return { return {
thumbnailPreview: true, thumbnailPreview: true,
canvasWidth: canvasSize.width, canvasWidth: canvasSize.width,
@@ -342,6 +347,13 @@ function buildThumbnailPreviewPayload(slide, options) {
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111', backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '', backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '', backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
regions: regions,
existingContent: slide && slide.content && typeof slide.content === 'object' ? slide.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 : [],
timetableGroups: Array.isArray(options && options.timetableGroups) ? options.timetableGroups : [],
weatherIconSets: Array.isArray(options && options.weatherIconSets) ? options.weatherIconSets : [],
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(), fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl, options) html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl, options)
}; };
+33 -3
View File
@@ -3146,8 +3146,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: #111; background: var(--bs-body-bg);
color: #fff; color: var(--bs-body-color);
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
@@ -3156,7 +3156,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: #111; background: var(--bs-body-bg);
} }
.slide-preview-popup-canvas { .slide-preview-popup-canvas {
@@ -4577,6 +4577,36 @@ table.table > :not(caption) > * > * {
justify-content: center; justify-content: center;
} }
.slide-list-thumbnail-button {
padding: 0;
border: 0;
color: inherit;
cursor: pointer;
}
.slide-list-thumbnail-button:focus-visible {
outline: 2px solid var(--bs-primary);
outline-offset: 2px;
}
.slide-list-preview-frame {
display: block;
width: 100%;
height: auto;
aspect-ratio: var(--slide-preview-aspect-ratio, 16 / 9);
border: 0;
background: var(--bs-body-bg);
}
#slide-preview-modal .modal-content {
overflow: hidden;
}
#slide-preview-modal .modal-dialog {
width: var(--slide-preview-modal-width, min(90vw, 1140px));
max-width: 90vw;
}
.card .table td[data-label="Thumbnail"] { .card .table td[data-label="Thumbnail"] {
width: 1%; width: 1%;
padding-inline: 0.5rem; padding-inline: 0.5rem;
+1 -1
View File
@@ -106,7 +106,7 @@
var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height; var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height;
if (hasPreviewBounds || imageConfig.verticalAlign) { if (hasPreviewBounds || imageConfig.verticalAlign) {
var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;'; var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
var wrapperClass = hasPreviewBounds ? ' class="api-image-placeholder-bounds"' : ''; var wrapperClass = hasPreviewBounds ? ' class="api-image-placeholder-bounds slide-preview-image-placeholder-bounds"' : '';
if (hasPreviewBounds) { if (hasPreviewBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);'; wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);';
} }
+2 -1
View File
@@ -141,13 +141,14 @@
var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height; var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height;
if (hasPreviewBounds || imageConfig.verticalAlign) { if (hasPreviewBounds || imageConfig.verticalAlign) {
var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;'; var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
var wrapperClass = hasPreviewBounds ? ' class="slide-preview-image-placeholder-bounds"' : '';
if (hasPreviewBounds) { if (hasPreviewBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);'; wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);';
} }
if (imageConfig.verticalAlign) { if (imageConfig.verticalAlign) {
wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';'; wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';';
} }
return '<span style="' + wrapperStyle + '">' + image + '</span>'; return '<span' + wrapperClass + ' style="' + wrapperStyle + '">' + image + '</span>';
} }
return image; return image;
} }
+1 -1
View File
@@ -132,7 +132,7 @@
var style = width ? ' style="display:block;font-size:' + width + 'px;line-height:1;width:' + width + 'px;height:' + height + 'px;"' : ''; var style = width ? ' style="display:block;font-size:' + width + 'px;line-height:1;width:' + width + 'px;height:' + height + 'px;"' : '';
var icon = '<i class="bi ' + iconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>'; var icon = '<i class="bi ' + iconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>';
var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle'; var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle';
return width ? '<span style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + width + 'px;height:' + height + 'px;border:1px dashed rgba(120,120,120,.75);">' + icon + '</span>' : icon; return width ? '<span class="slide-preview-image-placeholder-bounds" style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + width + 'px;height:' + height + 'px;border:1px dashed rgba(120,120,120,.75);">' + icon + '</span>' : icon;
} }
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone }))); return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(value, normalizeWeatherExpression(expression), { timeZone: value.timezone })));
}); });
@@ -0,0 +1,58 @@
(function (root, factory) {
const renderer = factory(root);
if (typeof module === 'object' && module.exports) {
module.exports = renderer;
} else {
root.pulseSlidePreviewRenderer = renderer;
}
}(typeof window !== 'undefined' ? window : globalThis, function (root) {
function renderRegionContent(region, content, context, registry) {
const regionType = String(region.region_type || content.type || 'text').trim().toLowerCase();
const definition = registry && typeof registry.get === 'function' ? registry.get(regionType) : null;
if (!definition || typeof definition.renderPreview !== 'function') {
return '';
}
if (regionType === 'rtmp') {
return definition.renderPreview(content, content.disable_audio);
}
if (regionType === 'time-date') {
return definition.renderPreview(region, content, content, 1);
}
if (regionType === 'text') {
return definition.renderPreview(region, content, content);
}
return definition.renderPreview(region, content, context);
}
function render(payload, registry) {
const settings = payload || {};
const activeRegistry = registry || root.pulseRegionTypes;
const context = {
existingContent: settings.existingContent || {},
sources: Array.isArray(settings.apiSources) ? settings.apiSources : [],
feeds: Array.isArray(settings.rssFeeds) ? settings.rssFeeds : [],
weatherLocations: Array.isArray(settings.weatherLocations) ? settings.weatherLocations : [],
timetableGroups: Array.isArray(settings.timetableGroups) ? settings.timetableGroups : []
};
return (Array.isArray(settings.regions) ? settings.regions : []).map(function (region) {
const content = region.content && typeof region.content === 'object' ? region.content : { value: region.content || '' };
const regionType = String(region.region_type || content.type || 'text').trim().toLowerCase();
const left = Math.max(0, Math.round(Number(region.x) || 0));
const top = Math.max(0, Math.round(Number(region.y) || 0));
const width = Math.max(1, Math.round(Number(region.width) || 1));
const height = Math.max(1, Math.round(Number(region.height) || 1));
const zIndex = Math.max(1, Number(region.z_index) || 1);
const regionKey = root.pulseRegionUtils && root.pulseRegionUtils.escapeHtml
? root.pulseRegionUtils.escapeHtml(region.region_key || '')
: String(region.region_key || '').replace(/[&<>"']/g, function (character) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character];
});
const html = renderRegionContent(region, content, context, activeRegistry);
return '<div class="slide-preview-region slide-preview-' + regionType + '-region" data-region-key="' + regionKey + '" style="left:' + left + 'px;top:' + top + 'px;width:' + width + 'px;height:' + height + 'px;z-index:' + zIndex + ';">' + html + '</div>';
}).join('');
}
return { render: render };
}));
+1 -1
View File
@@ -26,7 +26,7 @@
var iconWidth = width ? width + 'px' : '1em'; var iconWidth = width ? width + 'px' : '1em';
var iconHeight = height ? height + 'px' : '1em'; var iconHeight = height ? height + 'px' : '1em';
var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle'; var verticalAlign = alignment === 'top' || alignment === 'bottom' ? alignment : 'middle';
return '<span style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + iconWidth + ';height:' + iconHeight + ';"><img src="' + source + '" alt="" style="display:block;width:100%;height:100%;object-fit:contain;" /></span>'; return '<span class="slide-preview-image-placeholder-bounds" style="display:inline-flex;vertical-align:' + verticalAlign + ';align-items:center;justify-content:center;box-sizing:border-box;line-height:0;width:' + iconWidth + ';height:' + iconHeight + ';"><img src="' + source + '" alt="" style="display:block;width:100%;height:100%;object-fit:contain;" /></span>';
} }
var api = { conditionForCode: conditionForCode, imageForCode: imageForCode, isNightForWeather: isNightForWeather, renderImage: renderImage }; var api = { conditionForCode: conditionForCode, imageForCode: imageForCode, isNightForWeather: isNightForWeather, renderImage: renderImage };
if (typeof module === 'object' && module.exports) module.exports = api; if (typeof module === 'object' && module.exports) module.exports = api;
+10 -4
View File
@@ -9,6 +9,7 @@ const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
const { convertWeatherSnapshot } = require('#src/data/weather-units'); const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnails'); const { buildThumbnailPreviewPayload } = require('#src/web/lib/media/slide-thumbnails');
const { resolveApiItems } = require('#src/web/public/js/shared/placeholder-utils'); const { resolveApiItems } = require('#src/web/public/js/shared/placeholder-utils');
const { getRegionEditorScripts } = require('#src/web/lib/region-scripts');
function safeJsonForScript(value) { function safeJsonForScript(value) {
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c'); return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
@@ -22,6 +23,7 @@ module.exports = function registerSlidesRoutes(app, deps) {
const webBaseUrl = String(deps.webBaseUrl || '').trim().replace(/\/$/, ''); const webBaseUrl = String(deps.webBaseUrl || '').trim().replace(/\/$/, '');
const { buildPagination } = require('#src/web/lib/pagination'); const { buildPagination } = require('#src/web/lib/pagination');
const requirePermission = deps.requirePermission; const requirePermission = deps.requirePermission;
const slideEditorScripts = getRegionEditorScripts();
function requireRequestAuth(req, res, next) { function requireRequestAuth(req, res, next) {
if (!verifyRequestAuth(req)) { if (!verifyRequestAuth(req)) {
@@ -62,11 +64,12 @@ module.exports = function registerSlidesRoutes(app, deps) {
title: 'Slide preview', title: 'Slide preview',
framePopupCard: true, framePopupCard: true,
hideFrameHeader: true, hideFrameHeader: true,
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body' frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body',
slideEditorScripts: slideEditorScripts
})); }));
}); });
app.get('/api/internal/slide-thumbnails/:id/popup-preview', async function (req, res, next) { app.get('/api/internal/slide-thumbnails/:id/popup-preview', requirePopupPreviewAccess, async function (req, res, next) {
try { try {
const slide = await common.fetchSlideById(pool, Number(req.params.id)); const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) { if (!slide) {
@@ -95,7 +98,7 @@ module.exports = function registerSlidesRoutes(app, deps) {
item.current = Object.assign({}, item.current || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_speed_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precipitation_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' }); item.current = Object.assign({}, item.current || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_speed_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precipitation_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' });
item.daily = Object.assign({}, item.daily || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C' }); item.daily = Object.assign({}, item.daily || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C' });
item.hourly = Object.assign({}, item.hourly || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C' }); item.hourly = Object.assign({}, item.hourly || {}, { temperature_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C' });
return { id: location.id, data: item }; return Object.assign({}, location, { id: location.id, data: item, responseJson: item });
}).filter(Boolean); }).filter(Boolean);
const getItem = function (type, regionContent) { const getItem = function (type, regionContent) {
const index = Math.max(0, Math.max(1, Number(regionContent && regionContent.item_number || 1)) - 1); const index = Math.max(0, Math.max(1, Number(regionContent && regionContent.item_number || 1)) - 1);
@@ -125,6 +128,8 @@ module.exports = function registerSlidesRoutes(app, deps) {
fontStylesheetHref: getFontStylesheetHref(mediaDir), fontStylesheetHref: getFontStylesheetHref(mediaDir),
weatherIconSets: require('#src/web/lib/media/weather-icon-library').readWeatherIconSets(mediaDir), weatherIconSets: require('#src/web/lib/media/weather-icon-library').readWeatherIconSets(mediaDir),
apiSources: apiSources, apiSources: apiSources,
rssFeeds: rssFeeds,
weatherLocations: weatherLocations,
timetableGroups: timetableData.timetableGroups, timetableGroups: timetableData.timetableGroups,
getItem: getItem, getItem: getItem,
getCachedImagePath: getCachedImagePath getCachedImagePath: getCachedImagePath
@@ -136,7 +141,8 @@ module.exports = function registerSlidesRoutes(app, deps) {
framePopupCard: true, framePopupCard: true,
hideFrameHeader: true, hideFrameHeader: true,
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body', frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body',
popupPreviewPayloadJson: safeJsonForScript(payload) popupPreviewPayloadJson: safeJsonForScript(payload),
slideEditorScripts: slideEditorScripts
})); }));
} catch (error) { } catch (error) {
next(error); next(error);
+57 -2
View File
@@ -34,13 +34,13 @@
{{#each slides}} {{#each slides}}
<tr data-table-search-row data-search-text="{{title}} {{template_name}}"> <tr data-table-search-row data-search-text="{{title}} {{template_name}}">
<td data-label="Thumbnail"> <td data-label="Thumbnail">
<div class="playlist-slide-thumb" aria-hidden="true" {{#if canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{canvas_width}} / {{canvas_height}};"{{/if}}> <button type="button" class="playlist-slide-thumb slide-list-thumbnail-button" data-bs-toggle="modal" data-bs-target="#slide-preview-modal" data-slide-preview-url="/api/internal/slide-thumbnails/{{id}}/popup-preview" data-slide-preview-title="{{title}}" data-canvas-width="{{canvas_width}}" data-canvas-height="{{canvas_height}}" aria-label="Preview slide: {{title}}" {{#if canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{canvas_width}} / {{canvas_height}};"{{/if}}>
{{#if thumbnail_path}} {{#if thumbnail_path}}
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" /> <img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
{{else}} {{else}}
<span class="playlist-slide-thumb-placeholder"><i class="bi bi-image" aria-hidden="true"></i></span> <span class="playlist-slide-thumb-placeholder"><i class="bi bi-image" aria-hidden="true"></i></span>
{{/if}} {{/if}}
</div> </button>
</td> </td>
<td data-label="Title">{{title}}</td> <td data-label="Title">{{title}}</td>
<td data-label="Template">{{template_name}}</td> <td data-label="Template">{{template_name}}</td>
@@ -79,7 +79,62 @@
{{> table-pagination pagination=pagination basePath="/slides" alwaysShow=true}} {{> table-pagination pagination=pagination basePath="/slides" alwaysShow=true}}
</div> </div>
{{#> modal-shell modalId="slide-preview-modal" modalLabelId="slide-preview-modal-label" modalDialogClass="modal-xl modal-dialog-centered"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="slide-preview-modal-label">Slide preview</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<iframe class="slide-list-preview-frame" title="Slide preview" data-slide-preview-frame src="about:blank"></iframe>
</div>
{{/modal-shell}}
<script> <script>
(function () {
var modal = document.getElementById('slide-preview-modal');
if (!modal) {
return;
}
var frame = modal.querySelector('[data-slide-preview-frame]');
var dialog = modal.querySelector('.modal-dialog');
var title = modal.querySelector('#slide-preview-modal-label');
var currentAspectRatio = 16 / 9;
function updateModalSize() {
var maxWidth = Math.min(window.innerWidth * 0.9, 1140);
var maxHeight = window.innerHeight * 0.72;
var width = Math.min(maxWidth, maxHeight * currentAspectRatio);
modal.style.setProperty('--slide-preview-aspect-ratio', String(currentAspectRatio));
modal.style.setProperty('--slide-preview-modal-width', Math.round(width) + 'px');
}
modal.addEventListener('show.bs.modal', function (event) {
var trigger = event.relatedTarget;
if (!trigger || !frame) {
return;
}
var canvasWidth = Number(trigger.getAttribute('data-canvas-width'));
var canvasHeight = Number(trigger.getAttribute('data-canvas-height'));
currentAspectRatio = canvasWidth > 0 && canvasHeight > 0 ? canvasWidth / canvasHeight : 16 / 9;
updateModalSize();
frame.src = trigger.getAttribute('data-slide-preview-url') || 'about:blank';
if (title) {
title.textContent = 'Slide preview: ' + (trigger.getAttribute('data-slide-preview-title') || '');
}
});
modal.addEventListener('hidden.bs.modal', function () {
if (frame) {
frame.src = 'about:blank';
}
});
window.addEventListener('resize', function () {
if (modal.classList.contains('show')) {
updateModalSize();
}
});
}());
if (typeof window.attachSlideThumbFallbacks === 'function') { if (typeof window.attachSlideThumbFallbacks === 'function') {
window.attachSlideThumbFallbacks(document); window.attachSlideThumbFallbacks(document);
} }
+13 -6
View File
@@ -5,8 +5,8 @@
height: 100%; height: 100%;
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
background: #111; background: var(--bs-body-bg);
color: #fff; color: var(--bs-body-color);
} }
#popup-preview-stage { #popup-preview-stage {
@@ -44,8 +44,11 @@
background: transparent; background: transparent;
} }
#popup-preview-canvas .slide-preview-image-placeholder-bounds,
#popup-preview-canvas .api-image-placeholder-bounds { #popup-preview-canvas .api-image-placeholder-bounds {
border: 0 !important; border: 0 !important;
outline: 0 !important;
box-shadow: none !important;
} }
#popup-preview-canvas .template-region.chart, #popup-preview-canvas .template-region.chart,
@@ -71,9 +74,10 @@
<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div> <div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>
</div> </div>
<script src="/assets/vendor/chart.js/chart.umd.js"></script> {{#each slideEditorScripts}}
<script src="/assets/js/shared/placeholder-utils.js"></script> <script src="/assets/{{this}}"></script>
<script src="/assets/js/regions/chart-data.js"></script> {{/each}}
<script src="/assets/js/shared/slide-preview-renderer.js"></script>
{{#if popupPreviewPayloadJson}} {{#if popupPreviewPayloadJson}}
<script> <script>
@@ -237,7 +241,10 @@
canvas.style.transformOrigin = 'center center'; canvas.style.transformOrigin = 'center center';
canvas.style.width = canvasWidth + 'px'; canvas.style.width = canvasWidth + 'px';
canvas.style.height = canvasHeight + 'px'; canvas.style.height = canvasHeight + 'px';
canvas.innerHTML = String(payload.html || ''); var html = payload.thumbnailPreview && window.pulseSlidePreviewRenderer
? window.pulseSlidePreviewRenderer.render(payload)
: payload.html;
canvas.innerHTML = String(html || '');
startPreviewVideoPlayback(canvas); startPreviewVideoPlayback(canvas);
var stageRect = stage.getBoundingClientRect(); var stageRect = stage.getBoundingClientRect();
+9 -3
View File
@@ -170,7 +170,7 @@ test('API editor preview resolves zero-based indexed placeholders from sorted re
assert.doesNotMatch(preview, /Cam/); assert.doesNotMatch(preview, /Cam/);
}); });
test('API image bounds guide is limited to the small preview and hidden in the popup', () => { test('image and icon placeholder bounds are hidden in the popup but remain in the editor', () => {
const editor = loadApiEditor(); const editor = loadApiEditor();
const preview = editor.renderPreview({ id: 23, region_key: 'apiRegion', fontFamily: 'Arial', fontSize: 20, fontColor: '#000000' }, { const preview = editor.renderPreview({ id: 23, region_key: 'apiRegion', fontFamily: 'Arial', fontSize: 20, fontColor: '#000000' }, {
source_id: 11, source_id: 11,
@@ -181,10 +181,16 @@ test('API image bounds guide is limited to the small preview and hidden in the p
existingContent: {} existingContent: {}
}); });
const popupStyles = fs.readFileSync(path.join(__dirname, '../src/web/views/signage/slides/popup-preview.hbs'), 'utf8'); const popupStyles = fs.readFileSync(path.join(__dirname, '../src/web/views/signage/slides/popup-preview.hbs'), 'utf8');
const rssSource = fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/rss.js'), 'utf8');
const weatherSource = fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/weather.js'), 'utf8');
const weatherIconsSource = fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/weather-icons.js'), 'utf8');
assert.match(preview, /class="api-image-placeholder-bounds"/); assert.match(preview, /class="api-image-placeholder-bounds slide-preview-image-placeholder-bounds"/);
assert.match(preview, /border:1px dashed/); assert.match(preview, /border:1px dashed/);
assert.match(popupStyles, /#popup-preview-canvas \.api-image-placeholder-bounds\s*\{\s*border:\s*0 !important;/); assert.match(rssSource, /class="slide-preview-image-placeholder-bounds"/);
assert.match(weatherSource, /class="slide-preview-image-placeholder-bounds"/);
assert.match(weatherIconsSource, /class="slide-preview-image-placeholder-bounds"/);
assert.match(popupStyles, /#popup-preview-canvas \.slide-preview-image-placeholder-bounds,[\s\S]*?#popup-preview-canvas \.api-image-placeholder-bounds\s*\{\s*border:\s*0 !important;[\s\S]*?outline:\s*0 !important;[\s\S]*?box-shadow:\s*none !important;/);
}); });
test('API region plain placeholders work with a single object response', () => { test('API region plain placeholders work with a single object response', () => {
+8 -1
View File
@@ -97,8 +97,15 @@ test('slide thumbnail previews treat image-only text as visible content', () =>
test('popup preview falls back to iframe sizing rules', () => { test('popup preview falls back to iframe sizing rules', () => {
const popupPreviewSource = fs.readFileSync(require.resolve('../src/web/views/signage/slides/popup-preview.hbs'), 'utf8'); const popupPreviewSource = fs.readFileSync(require.resolve('../src/web/views/signage/slides/popup-preview.hbs'), 'utf8');
const stylesheet = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.ok(popupPreviewSource.includes('/assets/js/regions/chart-data.js')); assert.ok(popupPreviewSource.includes('slideEditorScripts'));
assert.ok(popupPreviewSource.includes('/assets/js/shared/slide-preview-renderer.js'));
assert.ok(popupPreviewSource.includes('window.pulseSlidePreviewRenderer.render(payload)'));
assert.ok(popupPreviewSource.includes('background: var(--bs-body-bg);'));
assert.ok(popupPreviewSource.includes('color: var(--bs-body-color);'));
assert.match(stylesheet, /\.slide-preview-popup-body\s*\{[^}]*background:\s*var\(--bs-body-bg\);[^}]*color:\s*var\(--bs-body-color\);/s);
assert.match(stylesheet, /\.slide-preview-popup-stage\s*\{[^}]*background:\s*var\(--bs-body-bg\);/s);
assert.ok(popupPreviewSource.includes('.slide-preview-chart-region canvas')); assert.ok(popupPreviewSource.includes('.slide-preview-chart-region canvas'));
assert.ok(popupPreviewSource.includes('background: transparent !important')); assert.ok(popupPreviewSource.includes('background: transparent !important'));
assert.ok(popupPreviewSource.includes('#popup-preview-canvas iframe')); assert.ok(popupPreviewSource.includes('#popup-preview-canvas iframe'));
+105
View File
@@ -0,0 +1,105 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
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');
function loadEditorTimeDateModule() {
const source = fs.readFileSync(require.resolve('../src/web/public/js/regions/type/time-date.js'), 'utf8');
const modules = new Map();
const sandbox = {
window: {
pulseRegionTypes: {
register(type, definition) {
modules.set(type, definition);
}
},
pulseRegionUtils: {
escapeHtml(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
},
sanitizePreviewHtml(value) {
return String(value);
}
},
placeholderUtils: placeholderUtils
}
};
vm.runInNewContext(source, sandbox, { filename: 'time-date.js' });
return modules.get('time-date');
}
test('thumbnail payload passes saved region geometry and content to the popup renderer', () => {
const payload = buildThumbnailPreviewPayload({
template: {
canvas_size_width: 1280,
canvas_size_height: 720,
regions: [{ id: 14, region_key: 'clock', region_type: 'time-date', x: 20, y: 30, width: 400, height: 90, z_index: 3 }]
},
content: { clock: { value: '{{hh}}:{{mm}}', timezone: 'Europe/London' } }
});
assert.equal(payload.regions[0].x, 20);
assert.equal(payload.regions[0].width, 400);
assert.equal(payload.regions[0].content.value, '{{hh}}:{{mm}}');
assert.equal(payload.existingContent.clock.timezone, 'Europe/London');
});
test('thumbnail regions use shared region renderers and their source context', () => {
const calls = [];
const registry = {
get(type) {
return {
renderPreview(...args) {
calls.push({ type, args });
return '<span>' + type + '</span>';
}
};
}
};
const apiSources = [{ id: 8, responseJson: [{ name: 'Ada' }] }];
const payload = {
regions: [
{ id: 1, region_key: 'clock', region_type: 'time-date', x: 20, y: 12, width: 200, height: 80, z_index: 4, content: { value: '{{hh}}:{{mm}}', timezone: 'UTC' } },
{ id: 2, region_key: 'donations', region_type: 'api', x: 220, y: 12, width: 300, height: 80, z_index: 2, content: { source_id: 8, value: '{{name}}' } }
],
apiSources: apiSources,
rssFeeds: [],
weatherLocations: [],
timetableGroups: []
};
const html = render(payload, registry);
assert.match(html, /data-region-key="clock"[^>]*><span>time-date<\/span>/);
assert.match(html, /data-region-key="donations"[^>]*><span>api<\/span>/);
assert.equal(calls[0].args[3], 1);
assert.equal(calls[1].args[2].sources, apiSources);
});
test('thumbnail time/date uses the editor renderer to expand current-time tokens', () => {
const registry = {
get(type) {
return type === 'time-date' ? loadEditorTimeDateModule() : null;
}
};
const html = render({
regions: [{
id: 4,
region_key: 'clock',
region_type: 'time-date',
x: 0,
y: 0,
width: 320,
height: 80,
content: { value: '{{hh}}:{{mm}}', timezone: 'UTC' }
}]
}, registry);
assert.match(html, /<div class="template-region time-date"/);
assert.match(html, /class="template-region-text-scale"[^>]*>\d{2}:\d{2}<\/div>/);
});
+20
View File
@@ -19,6 +19,26 @@ test('slide table thumbnails size to the canvas ratio and show the whole image',
assert.match(placeholderRule, /min-height:\s*0;/); assert.match(placeholderRule, /min-height:\s*0;/);
}); });
test('slide list thumbnails open and release an authorized preview modal', () => {
const listTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'slides', 'list.hbs'), 'utf8');
const slidesRoutes = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'routes', 'signage', 'slides', 'routes.js'), 'utf8');
const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8');
assert.match(listTemplate, /class="playlist-slide-thumb slide-list-thumbnail-button"[^>]*data-bs-toggle="modal"/);
assert.match(listTemplate, /data-slide-preview-url="\/api\/internal\/slide-thumbnails\/\{\{id\}\}\/popup-preview"/);
assert.match(listTemplate, /data-canvas-width="\{\{canvas_width\}\}" data-canvas-height="\{\{canvas_height\}\}"/);
assert.match(listTemplate, /currentAspectRatio = canvasWidth > 0 && canvasHeight > 0 \? canvasWidth \/ canvasHeight : 16 \/ 9/);
assert.match(listTemplate, /aria-label="Preview slide: \{\{title\}\}"/);
assert.match(listTemplate, /data-slide-preview-frame/);
assert.match(listTemplate, /frame\.src = 'about:blank'/);
assert.match(slidesRoutes, /\/api\/internal\/slide-thumbnails\/:id\/popup-preview', requirePopupPreviewAccess, async function/);
assert.match(stylesheet, /^\.slide-list-thumbnail-button:focus-visible\s*\{/m);
const previewFrameRule = stylesheet.match(/^\.slide-list-preview-frame\s*\{([^}]+)\}/m)[1];
assert.match(previewFrameRule, /background:\s*var\(--bs-body-bg\);/);
assert.match(previewFrameRule, /aspect-ratio:\s*var\(--slide-preview-aspect-ratio, 16 \/ 9\);/);
assert.match(stylesheet, /#slide-preview-modal \.modal-content\s*\{\s*overflow:\s*hidden;/);
});
test('standalone slide thumbnail columns shrink to their content and keep thumbnails centered', () => { test('standalone slide thumbnail columns shrink to their content and keep thumbnails centered', () => {
const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8'); const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8');
const columnRule = stylesheet.match(/^td\[data-label="Thumbnail"\] > \.playlist-slide-thumb\s*\{([^}]+)\}/m)[1]; const columnRule = stylesheet.match(/^td\[data-label="Thumbnail"\] > \.playlist-slide-thumb\s*\{([^}]+)\}/m)[1];