Release 2.20.6: saved-image slide previews
This commit is contained in:
1 parent
205ce42337
commit
24df245ea1
20 files changed
+318
-75
No files matched your search
@@ -2,6 +2,16 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.20.6 - 2026-10-26
|
||||
|
||||
### Added
|
||||
|
||||
- Image-only slide previews from thumbnails on the Slides list and playlist add/edit items, using retained full-size captures without loading slide assets and falling back to existing small thumbnails until refreshed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Slide preview popups refresh the existing window on repeat opens; portrait previews shrink proportionally to fit and remain centered without nested scrolling or overflow.
|
||||
|
||||
## 2.20.5 - 2026-10-25
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.20.5",
|
||||
"version": "2.20.6",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.20.5",
|
||||
"version": "2.20.6",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"engines": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.20.5",
|
||||
"version": "2.20.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.20.5",
|
||||
"version": "2.20.6",
|
||||
"dependencies": {
|
||||
"@better-auth/passkey": "^1.7.13",
|
||||
"animate.css": "^4.1.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.20.5",
|
||||
"version": "2.20.6",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"engines": {
|
||||
|
||||
@@ -178,6 +178,7 @@ function registerActionHelpers(Handlebars) {
|
||||
function registerPartials(Handlebars, viewsRoot) {
|
||||
Handlebars.registerPartial('view-as-banner', fs.readFileSync(path.join(viewsRoot, 'shared', 'view-as-banner.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('slide-preview-modal', fs.readFileSync(path.join(viewsRoot, 'shared', 'slide-preview-modal.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('media-picker-modal', fs.readFileSync(path.join(viewsRoot, 'shared', 'media-picker-modal.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
|
||||
|
||||
@@ -253,7 +253,8 @@ async function countMediaAssetReferences(pool, mediaPath) {
|
||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||
[normalizedPath]
|
||||
);
|
||||
const [thumbnailRows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?', [normalizedPath]);
|
||||
const thumbnailPath = normalizedPath.startsWith('/media/thumbnails/') ? normalizedPath.replace(/\.full\.png$/i, '.png') : normalizedPath;
|
||||
const [thumbnailRows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?', [thumbnailPath]);
|
||||
return contentReferenceCount + Number(templateRows[0] && templateRows[0].ref_count || 0) + Number(thumbnailRows[0] && thumbnailRows[0].ref_count || 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -456,6 +456,7 @@ async function captureSlideThumbnail(options) {
|
||||
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
|
||||
const fullSizeTempPath = filePath.replace(/\.png$/i, '.full.tmp.png');
|
||||
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
|
||||
const thumbnailPath = thumbnailRelativePath;
|
||||
|
||||
@@ -527,7 +528,7 @@ async function captureSlideThumbnail(options) {
|
||||
throw new Error('Popup preview canvas has no screenshot bounds.');
|
||||
}
|
||||
await page.screenshot({
|
||||
path: fullSizePath,
|
||||
path: fullSizeTempPath,
|
||||
clip: {
|
||||
x: Math.max(0, canvasBounds.x),
|
||||
y: Math.max(0, canvasBounds.y),
|
||||
@@ -546,7 +547,7 @@ async function captureSlideThumbnail(options) {
|
||||
});
|
||||
}
|
||||
|
||||
await sharp(fullSizePath)
|
||||
await sharp(fullSizeTempPath)
|
||||
.resize({
|
||||
width: THUMBNAIL_MAX_SIZE.width,
|
||||
height: THUMBNAIL_MAX_SIZE.height,
|
||||
@@ -558,11 +559,7 @@ async function captureSlideThumbnail(options) {
|
||||
|
||||
await fs.promises.rm(filePath, { force: true });
|
||||
await fs.promises.rename(thumbnailTempPath, filePath);
|
||||
await fs.promises.unlink(fullSizePath).catch(function (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
await fs.promises.rename(fullSizeTempPath, fullSizePath);
|
||||
|
||||
await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
|
||||
return {
|
||||
|
||||
@@ -370,9 +370,10 @@ function createUploadSyncService(options) {
|
||||
|
||||
async function countUploadReferences(pool, uploadPath) {
|
||||
const contentReferenceCount = await countContentReferenceOwners(pool, 'media', uploadPath);
|
||||
const thumbnailPath = uploadPath.startsWith('/media/thumbnails/') ? uploadPath.replace(/\.full\.png$/i, '.png') : uploadPath;
|
||||
const [thumbnailRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
|
||||
[uploadPath]
|
||||
[thumbnailPath]
|
||||
);
|
||||
const [templateRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||
|
||||
@@ -2477,9 +2477,8 @@ td[data-label="Slides"] {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.slide-editor-shell .slide-preview-scroll {
|
||||
max-height: calc(100vh - 8rem);
|
||||
overflow: auto;
|
||||
.slide-editor-shell .slide-preview-stage {
|
||||
max-height: max(12rem, calc(100vh - 22rem));
|
||||
}
|
||||
|
||||
.slide-preview-header h3 {
|
||||
@@ -4658,10 +4657,15 @@ table.table > :not(caption) > * > * {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: var(--slide-preview-aspect-ratio, 16 / 9);
|
||||
object-fit: contain;
|
||||
border: 0;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.slide-list-preview-frame[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#slide-preview-modal .modal-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -899,7 +899,7 @@
|
||||
return '' +
|
||||
'<td data-label="Slide">' +
|
||||
'<div class="playlist-slide-cell">' +
|
||||
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
|
||||
'<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="' + escapeHtmlAttribute(String(values.thumbnail_path || '')) + '" data-slide-preview-title="' + escapeHtmlAttribute(String(values.title || 'Slide')) + '" data-canvas-width="' + escapeHtmlAttribute(String(values.canvas_width || '')) + '" data-canvas-height="' + escapeHtmlAttribute(String(values.canvas_height || '')) + '" aria-label="Preview slide: ' + escapeHtmlAttribute(String(values.title || 'Slide')) + '"' + thumbnailStyle + '>' + thumbnailMarkup + '</button>' +
|
||||
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" />' +
|
||||
@@ -3327,6 +3327,11 @@
|
||||
thumb.style.setProperty('--playlist-slide-thumb-aspect-ratio', canvasWidth + ' / ' + canvasHeight);
|
||||
}
|
||||
if (thumb) {
|
||||
thumb.setAttribute('data-slide-preview-url', thumbnailPath);
|
||||
thumb.setAttribute('data-slide-preview-title', String(values.title || 'Slide'));
|
||||
thumb.setAttribute('data-canvas-width', String(canvasWidth || ''));
|
||||
thumb.setAttribute('data-canvas-height', String(canvasHeight || ''));
|
||||
thumb.setAttribute('aria-label', 'Preview slide: ' + String(values.title || 'Slide'));
|
||||
thumb.innerHTML = thumbnailPath
|
||||
? '<img class="playlist-slide-thumb-image" src="' + escapeHtmlAttribute(thumbnailPath) + '" alt="" loading="lazy" data-playlist-slide-thumb-image />'
|
||||
: '<span class="playlist-slide-thumb-placeholder" data-playlist-slide-thumb-placeholder><i class="bi bi-image" aria-hidden="true"></i></span>';
|
||||
|
||||
@@ -706,6 +706,21 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
|
||||
if (!getSelectedTemplate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||
try {
|
||||
if (previewPopupWindow.location.pathname === '/slides/popup-preview') {
|
||||
previewPopupWindow.location.hash = buildPreviewPopupHash();
|
||||
} else {
|
||||
previewPopupWindow.location.replace(buildPreviewPopupUrl());
|
||||
}
|
||||
} catch (_error) {
|
||||
previewPopupWindow.location.href = buildPreviewPopupUrl();
|
||||
}
|
||||
previewPopupWindow.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
var popupWidth = 1280;
|
||||
var popupHeight = 900;
|
||||
var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2));
|
||||
@@ -772,6 +787,19 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
|
||||
sidebarSyncFrame = window.requestAnimationFrame(syncSlideEditorSidebar);
|
||||
}
|
||||
|
||||
function sizePreviewStage(canvasWidth, canvasHeight) {
|
||||
var stageContainer = slidePreviewStage.parentElement;
|
||||
var containerStyle = window.getComputedStyle(stageContainer);
|
||||
var availableWidth = stageContainer.clientWidth - parseFloat(containerStyle.paddingLeft) - parseFloat(containerStyle.paddingRight);
|
||||
var maxHeight = parseFloat(window.getComputedStyle(slidePreviewStage).maxHeight);
|
||||
if (!availableWidth || !Number.isFinite(maxHeight)) {
|
||||
return;
|
||||
}
|
||||
|
||||
slidePreviewStage.style.width = Math.min(availableWidth, maxHeight * canvasWidth / canvasHeight) + 'px';
|
||||
slidePreviewStage.style.marginInline = 'auto';
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
var template = getSelectedTemplate();
|
||||
if (openPreviewPopupButton) openPreviewPopupButton.disabled = !template;
|
||||
@@ -787,6 +815,7 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
|
||||
slidePreviewOverlay.style.height = '';
|
||||
slidePreviewOverlay.style.transform = '';
|
||||
slidePreviewStage.style.aspectRatio = '16 / 9';
|
||||
sizePreviewStage(16, 9);
|
||||
applyBackdropStyle(slidePreviewStage, '#111111');
|
||||
return;
|
||||
}
|
||||
@@ -798,6 +827,7 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
|
||||
currentPreviewCanvasHeight = canvasHeight;
|
||||
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
|
||||
slidePreviewStage.style.setProperty('--slide-layout-ratio', String(canvasWidth / canvasHeight));
|
||||
sizePreviewStage(canvasWidth, canvasHeight);
|
||||
applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path, template.background_gradient);
|
||||
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
|
||||
if (!(template.regions || []).length) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{{#> 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">
|
||||
<img class="slide-list-preview-frame" alt="Slide preview" data-slide-preview-image hidden />
|
||||
<p class="empty m-3" data-slide-preview-empty hidden>Preview unavailable</p>
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var modal = document.getElementById('slide-preview-modal');
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
var image = modal.querySelector('[data-slide-preview-image]');
|
||||
var empty = modal.querySelector('[data-slide-preview-empty]');
|
||||
var title = modal.querySelector('#slide-preview-modal-label');
|
||||
var currentAspectRatio = 16 / 9;
|
||||
var thumbnailUrl = '';
|
||||
|
||||
image.addEventListener('error', function () {
|
||||
if (thumbnailUrl && image.getAttribute('src') !== thumbnailUrl) {
|
||||
image.src = thumbnailUrl;
|
||||
} else {
|
||||
image.hidden = true;
|
||||
image.removeAttribute('src');
|
||||
empty.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
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 || !image) {
|
||||
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();
|
||||
thumbnailUrl = trigger.getAttribute('data-slide-preview-url') || '';
|
||||
image.hidden = !thumbnailUrl;
|
||||
empty.hidden = Boolean(thumbnailUrl);
|
||||
image.alt = trigger.getAttribute('data-slide-preview-title') || 'Slide preview';
|
||||
if (thumbnailUrl) {
|
||||
image.src = thumbnailUrl.replace(/\.png$/i, '.full.png');
|
||||
}
|
||||
if (title) {
|
||||
title.textContent = 'Slide preview: ' + (trigger.getAttribute('data-slide-preview-title') || '');
|
||||
}
|
||||
});
|
||||
modal.addEventListener('hidden.bs.modal', function () {
|
||||
thumbnailUrl = '';
|
||||
image.hidden = true;
|
||||
image.removeAttribute('src');
|
||||
empty.hidden = true;
|
||||
});
|
||||
window.addEventListener('resize', function () {
|
||||
if (modal.classList.contains('show')) {
|
||||
updateModalSize();
|
||||
}
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
@@ -89,6 +89,8 @@
|
||||
|
||||
<script type="application/json" id="playlist-add-slide-data">{{json selectableSlides}}</script>
|
||||
|
||||
{{> slide-preview-modal}}
|
||||
|
||||
{{#> modal-shell modalId="playlist-add-slide-modal" modalLabelId="playlist-add-slide-modal-label" modalDialogClass="modal-dialog-centered modal-dialog-scrollable modal-xl" modalBackdropStatic=true modalKeyboardDisabled=true}}
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
</td>
|
||||
<td data-label="Slide">
|
||||
<div class="playlist-slide-cell">
|
||||
<div class="playlist-slide-thumb" aria-hidden="true" {{#if slide.canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{slide.canvas_width}} / {{slide.canvas_height}};"{{/if}} data-playlist-slide-thumb>
|
||||
<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="{{slide.thumbnail_path}}" data-slide-preview-title="{{slide.title}}" data-canvas-width="{{slide.canvas_width}}" data-canvas-height="{{slide.canvas_height}}" aria-label="Preview slide: {{slide.title}}" {{#if slide.canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{slide.canvas_width}} / {{slide.canvas_height}};"{{/if}} data-playlist-slide-thumb>
|
||||
{{#if slide.thumbnail_path}}
|
||||
<img class="playlist-slide-thumb-image" src="{{slide.thumbnail_path}}" alt="" loading="lazy" data-playlist-slide-thumb-image />
|
||||
{{else}}
|
||||
<span class="playlist-slide-thumb-placeholder" data-playlist-slide-thumb-placeholder><i class="bi bi-image" aria-hidden="true"></i></span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
<div class="playlist-slide-cell-content">
|
||||
<span class="playlist-slide-title" data-playlist-slide-title>{{slide.title}}</span>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{{#each slides}}
|
||||
<tr data-table-search-row data-search-text="{{title}} {{template_name}}">
|
||||
<td data-label="Thumbnail">
|
||||
<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}}>
|
||||
<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="{{thumbnail_path}}" 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}}
|
||||
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
|
||||
{{else}}
|
||||
@@ -79,62 +79,9 @@
|
||||
{{> table-pagination pagination=pagination basePath="/slides" alwaysShow=true}}
|
||||
</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}}
|
||||
{{> slide-preview-modal}}
|
||||
|
||||
<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') {
|
||||
window.attachSlideThumbFallbacks(document);
|
||||
}
|
||||
|
||||
@@ -193,6 +193,19 @@ test('media library counts slide, template background, and region default refere
|
||||
assert.match(queries[2].sql, /thumbnail_path/);
|
||||
});
|
||||
|
||||
test('full-size slide previews share the small thumbnail reference', async () => {
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
if (sql.includes('thumbnail_path')) {
|
||||
assert.deepEqual(params, ['/media/thumbnails/slides/slide-7.png']);
|
||||
return [[{ ref_count: 1 }]];
|
||||
}
|
||||
return [[{ owner_count: 0, ref_count: 0 }]];
|
||||
}
|
||||
};
|
||||
assert.equal(await countMediaAssetReferences(pool, '/media/thumbnails/slides/slide-7.full.png'), 1);
|
||||
});
|
||||
|
||||
test('media library upload and delete routes audit successful asset changes', async () => {
|
||||
const originalCreateVariants = mediaLibrary.createImageVariants;
|
||||
const originalRegisterAsset = mediaLibrary.registerMediaAsset;
|
||||
|
||||
@@ -5,6 +5,42 @@ require('../src/common');
|
||||
|
||||
const { buildPlaylistFormViewModel } = require('../src/web/routes/signage/playlists/form-view-model');
|
||||
|
||||
test('playlist add and edit forms share the saved-image preview and accessible thumbnail buttons', () => {
|
||||
const { renderView } = require('../src/web/view');
|
||||
for (const isEdit of [false, true]) {
|
||||
const html = renderView('playlists/form', {
|
||||
isEdit,
|
||||
playlist: { id: 3, name: 'Preview test' },
|
||||
selectableSlides: [],
|
||||
playlistSlideTemplate: { scheduleRules: [] },
|
||||
playlistSlides: [{ slide_id: 124, title: 'Landscape', thumbnail_path: '/media/thumbnails/slides/slide-124.png', canvas_width: 1920, canvas_height: 1080, scheduleRules: [] }]
|
||||
});
|
||||
assert.equal((html.match(/id="slide-preview-modal"/g) || []).length, 1);
|
||||
assert.match(html, /<button[^>]*data-slide-preview-url="\/media\/thumbnails\/slides\/slide-124.png"[^>]*data-canvas-width="1920"[^>]*aria-label="Preview slide: Landscape"/);
|
||||
assert.match(html, /data-slide-preview-image/);
|
||||
assert.doesNotMatch(html, /data-slide-preview-frame/);
|
||||
}
|
||||
});
|
||||
|
||||
test('new playlist rows receive preview URLs, titles and canvas dimensions', () => {
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const source = fs.readFileSync(require.resolve('../src/web/public/js/playlists/playlist-schedule'), 'utf8');
|
||||
const start = source.indexOf(' function populatePlaylistSlideRow(');
|
||||
const end = source.indexOf(' function createRow(', start);
|
||||
const sandbox = { escapeHtmlAttribute: value => value };
|
||||
vm.runInNewContext(source.slice(start, end), sandbox);
|
||||
const attrs = {};
|
||||
const thumb = { setAttribute(name, value) { attrs[name] = value; }, style: { setProperty() {} } };
|
||||
const row = { setAttribute() {}, querySelector(selector) { return selector === '[data-playlist-slide-thumb]' ? thumb : null; } };
|
||||
sandbox.populatePlaylistSlideRow(row, { slide_id: 125, title: 'Portrait', thumbnail_path: '/media/thumbnails/slides/slide-125.png', canvas_width: 1080, canvas_height: 1920, summary: 'Always visible' }, 'new-1');
|
||||
assert.equal(attrs['data-slide-preview-url'], '/media/thumbnails/slides/slide-125.png');
|
||||
assert.equal(attrs['data-slide-preview-title'], 'Portrait');
|
||||
assert.equal(attrs['data-canvas-width'], '1080');
|
||||
assert.equal(attrs['data-canvas-height'], '1920');
|
||||
assert.equal(attrs['aria-label'], 'Preview slide: Portrait');
|
||||
});
|
||||
|
||||
test('playlist schedule summaries show up to two short rules on separate lines and count longer summaries', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
@@ -111,6 +111,14 @@ test('popup preview falls back to iframe sizing rules', () => {
|
||||
assert.ok(popupPreviewSource.includes('#popup-preview-canvas .slide-preview-html-region iframe'));
|
||||
});
|
||||
|
||||
test('slide preview shrinks proportionally without adding a nested scrollbar', () => {
|
||||
const stylesheet = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
|
||||
|
||||
assert.match(stylesheet, /\.slide-editor-shell \.slide-preview-stage\s*\{[^}]*max-height:\s*max\(12rem,\s*calc\(100vh\s*-\s*22rem\)\);/s);
|
||||
assert.doesNotMatch(stylesheet, /\.slide-editor-shell \.slide-preview-scroll\s*\{[^}]*overflow:\s*auto;/s);
|
||||
assert.match(slideFormSource, /function sizePreviewStage\(canvasWidth, canvasHeight\)[\s\S]*?stageContainer\.clientWidth - parseFloat\(containerStyle\.paddingLeft\) - parseFloat\(containerStyle\.paddingRight\)[\s\S]*?Math\.min\(availableWidth, maxHeight \* canvasWidth \/ canvasHeight\)[\s\S]*?marginInline = 'auto'/);
|
||||
});
|
||||
|
||||
test('browser preview helpers normalize object-shaped HTML and webpage values', () => {
|
||||
assert.ok(slideHtmlRegionSource.includes('normalizePreviewValue'));
|
||||
assert.ok(slideWebpageRegionSource.includes('normalizePreviewValue'));
|
||||
@@ -183,3 +191,11 @@ test('popup preview is disabled and guarded until a template is selected', () =>
|
||||
assert.match(slideFormSource, /if \(openPreviewPopupButton\) openPreviewPopupButton\.disabled = !template;/);
|
||||
assert.match(slideFormSource, /function openPreviewPopup\(\) \{\s*if \(!getSelectedTemplate\(\)\)\s*\{\s*return;/);
|
||||
});
|
||||
|
||||
test('reopening the slide popup updates the existing window without navigating through about:blank', () => {
|
||||
const openPreviewPopup = slideFormSource.match(/function openPreviewPopup\(\) \{([\s\S]*?)\n function syncSlideEditorSidebar\(\)/);
|
||||
|
||||
assert.ok(openPreviewPopup);
|
||||
assert.match(openPreviewPopup[1], /if \(previewPopupWindow && !previewPopupWindow\.closed\)[\s\S]*?location\.hash = buildPreviewPopupHash\(\);[\s\S]*?previewPopupWindow\.focus\(\);\s*return;/);
|
||||
assert.ok(openPreviewPopup[1].indexOf('if (previewPopupWindow && !previewPopupWindow.closed)') < openPreviewPopup[1].indexOf("window.open('about:blank'"));
|
||||
});
|
||||
@@ -35,6 +35,111 @@ function loadEditorTimeDateModule() {
|
||||
return modules.get('time-date');
|
||||
}
|
||||
|
||||
test('list preview loads a saved image with legacy fallback and clears it on close', () => {
|
||||
const source = fs.readFileSync(require.resolve('../src/web/views/signage/slides/list.hbs'), 'utf8');
|
||||
assert.doesNotMatch(source, /<iframe|\/api\/internal\/slide-thumbnails/);
|
||||
assert.match(source, /{{> slide-preview-modal}}/);
|
||||
const modalSource = fs.readFileSync(require.resolve('../src/web/views/shared/slide-preview-modal.hbs'), 'utf8');
|
||||
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
|
||||
assert.match(css, /\.slide-list-preview-frame\[hidden\]\s*\{\s*display: none;/);
|
||||
const handlers = {};
|
||||
const image = {
|
||||
addEventListener(name, handler) { handlers['image:' + name] = handler; },
|
||||
getAttribute(name) { return this[name]; },
|
||||
removeAttribute(name) { delete this[name]; }
|
||||
};
|
||||
const empty = {};
|
||||
const title = {};
|
||||
const modal = {
|
||||
querySelector(selector) {
|
||||
return selector === '[data-slide-preview-image]' ? image : selector === '[data-slide-preview-empty]' ? empty : title;
|
||||
},
|
||||
addEventListener(name, handler) { handlers[name] = handler; },
|
||||
style: { setProperty() {} }
|
||||
};
|
||||
vm.runInNewContext(modalSource.match(/<script>([\s\S]*?)<\/script>/)[1], {
|
||||
document: { getElementById() { return modal; } },
|
||||
window: { innerWidth: 1440, innerHeight: 900, addEventListener() {} }
|
||||
});
|
||||
function open(url) {
|
||||
handlers['show.bs.modal']({ relatedTarget: { getAttribute(name) {
|
||||
return { 'data-slide-preview-url': url, 'data-slide-preview-title': 'Saved slide', 'data-canvas-width': '1080', 'data-canvas-height': '1920' }[name];
|
||||
} } });
|
||||
}
|
||||
open('/media/thumbnails/slides/slide-7.png');
|
||||
assert.equal(image.src, '/media/thumbnails/slides/slide-7.full.png');
|
||||
assert.equal(image.hidden, false);
|
||||
assert.equal(title.textContent, 'Slide preview: Saved slide');
|
||||
handlers['image:error']();
|
||||
assert.equal(image.src, '/media/thumbnails/slides/slide-7.png');
|
||||
handlers['image:error']();
|
||||
assert.equal(image.hidden, true);
|
||||
assert.equal(empty.hidden, false);
|
||||
open('/media/thumbnails/slides/slide-8.png');
|
||||
handlers['hidden.bs.modal']();
|
||||
assert.equal(image.src, undefined);
|
||||
assert.equal(image.hidden, true);
|
||||
open('');
|
||||
assert.equal(image.src, undefined);
|
||||
assert.equal(empty.hidden, false);
|
||||
});
|
||||
|
||||
test('thumbnail capture retains a full-size preview alongside the small thumbnail', async () => {
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const sharp = require('sharp');
|
||||
const modulePath = require.resolve('../src/web/lib/media/slide-thumbnails');
|
||||
const localRequire = require('node:module').createRequire(modulePath);
|
||||
const mediaDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-preview-'));
|
||||
const page = {
|
||||
async setViewport() {},
|
||||
async setExtraHTTPHeaders() {},
|
||||
async goto() { return { status() { return 200; } }; },
|
||||
async waitForFunction() {},
|
||||
async evaluate() {},
|
||||
async $() { return { async boundingBox() { return { x: 0, y: 0, width: 1080, height: 1920 }; } }; },
|
||||
async screenshot(options) {
|
||||
await sharp({ create: { width: options.clip.width, height: options.clip.height, channels: 3, background: '#123456' } }).png().toFile(options.path);
|
||||
},
|
||||
async close() {}
|
||||
};
|
||||
const sandbox = {
|
||||
module: { exports: {} },
|
||||
process: { env: {} },
|
||||
setTimeout(callback) { callback(); },
|
||||
require(name) {
|
||||
if (name === 'fs') return { ...fs, existsSync(candidate) { return candidate === '/usr/bin/chromium' || fs.existsSync(candidate); } };
|
||||
if (name === 'puppeteer-core') return { async launch() { return { async newPage() { return page; }, async close() {} }; } };
|
||||
return localRequire(name);
|
||||
}
|
||||
};
|
||||
sandbox.require.resolve = localRequire.resolve;
|
||||
vm.runInNewContext(fs.readFileSync(modulePath, 'utf8'), sandbox);
|
||||
const queries = [];
|
||||
try {
|
||||
const options = {
|
||||
mediaDir,
|
||||
slideId: 7,
|
||||
baseUrl: 'http://localhost:8080',
|
||||
pool: { async query(sql, params) { queries.push({ sql, params }); } },
|
||||
common: { async fetchSlideById() { return { id: 7, template: { canvas_size_width: 1080, canvas_size_height: 1920 } }; } }
|
||||
};
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const result = await sandbox.module.exports.captureSlideThumbnail(options);
|
||||
const large = await sharp(result.fullSizePath).metadata();
|
||||
const small = await sharp(result.filePath).metadata();
|
||||
assert.equal(large.width, 1080);
|
||||
assert.equal(large.height, 1920);
|
||||
assert.ok(small.width <= 480 && small.height <= 270);
|
||||
assert.equal(result.thumbnailPath, '/media/thumbnails/slides/slide-7.png');
|
||||
assert.equal(fs.existsSync(result.fullSizePath.replace('.full.png', '.full.tmp.png')), false);
|
||||
}
|
||||
assert.equal(queries.length, 2);
|
||||
} finally {
|
||||
await fs.promises.rm(mediaDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('preview sanitizers preserve entity-quoted font families without double escaping', () => {
|
||||
const entities = { apos: "'", quot: '"', amp: '&', lt: '<', gt: '>' };
|
||||
const sandbox = {
|
||||
|
||||
Reference in new issue
Block a user