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

This commit is contained in:
lzstealth committed 2026-10-20 18:52:36 +01:00
1 parent 821593dd8f
commit 3514d514a3
36 files changed
+2329 -346

No files matched your search

+19
View File
@@ -2,6 +2,25 @@
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.16.0 - 2026-10-20
### Added
- Added a slide layout editor with drag/resize, pixel position and size, z-index, ratio locking, keyboard nudging, undo/redo, and reset-to-template controls. Its wide modal leaves the sidebar visible and matches the template editor's region outlines; both editors provide alignment guides and snapping, with the template snap toggle in the Designer header.
- Added slide-specific region geometry overrides that flow through previews, playback, and thumbnails without changing sibling slides. Users with template-edit permission can apply the layout to this slide or save it to the shared template; shared saves confirm the affected-slide count, preserve region IDs and animations, clear only the current slide's geometry overrides, and reject stale templates.
- Added a separate slide animation editor with the template's presets and advanced timing controls, plus intro/outro/attention previews on the actual slide canvas. Changes can be applied to this slide or saved to the shared template; per-region and all-region reset actions are available, and shared saves preserve slide content and sibling overrides.
- Added consistent +/- sizing controls to HTML region editors, matching WYSIWYG regions, with the sandboxed iframe note alongside the controls.
### Changed
- Updated browser error pages to use a centered AdminLTE-style layout, with blue 404/other 4xx statuses, warning 403, and red 5xx statuses.
- Slide animation overrides are now normalized as JSON objects in slide content, while legacy string values remain supported.
### Fixed
- Fixed region dragging and resizing when template animation metadata is null.
- Fixed template and slide region outlines so they follow sizes below 12px, including scaled one-pixel regions, while keeping resize handles usable.
## 2.15.7 - 2026-10-19 ## 2.15.7 - 2026-10-19
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.15.7", "version": "2.16.0",
"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.15.7", "version": "2.16.0",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+11 -1
View File
@@ -178,7 +178,17 @@ A slide is an individual piece of signage content. It is normally created from a
Slides can contain ordinary editorial content such as text, images, video, HTML, QR codes, and webpages, as well as content that changes over time through announcements or data sources. This lets a playlist remain stable while the information inside a slide stays current. Slides can contain ordinary editorial content such as text, images, video, HTML, QR codes, and webpages, as well as content that changes over time through announcements or data sources. This lets a playlist remain stable while the information inside a slide stays current.
When filling a slide, work through each region in the template rather than trying to redesign the layout in the slide editor. Enter text in text regions, upload or select media in media regions, and choose configured sources in live-data regions. The template's region names and layout remain the reference for how the slide is intended to look. When filling a slide, work through each region in the template. Enter text in text regions, upload or select media in media regions, and choose configured sources in live-data regions. The template's region names and layout provide the starting design.
Choose **Adjust layout** beside the preview to open the large layout modal and adjust this slide only. Select a region on the canvas or in the region dropdown, drag its edges, or enter exact X, Y, width, and height values in canvas pixels. Set **Z-Index** to change stacking order and **Lock ratio** to constrain resizing. Arrow keys move the selected region by one pixel; Shift plus an arrow moves it by ten.
The slide and template editors share position, size, stacking, and ratio controls, along with matching region outlines and labels. Choose **Adjust animations** for a separate animation popup with the template's presets and advanced duration, delay, and repeat settings. Use **Intro**, **Outro**, and **Attention** to preview those animations on the slide canvas. **Reset region animations to template** resets the selected region; **Reset all animations to template** clears every slide animation override. **Apply to this slide** keeps changes in the slide draft; Cancel discards them. Users with template-edit permission can choose **Save animations to shared template** from the adjacent save menu. That action confirms the number of affected slides, updates shared template animations, and clears animation overrides from the current slide so it inherits the template again. Other slides' explicit animation overrides and unsaved slide content are preserved. Existing animation settings are also preserved when adjusting placement. Region names and types are defined by the template.
Both layout editors offer **Snap to guides** for alignment with the canvas edges, centers, and other regions. Hold Alt while dragging to bypass snapping. The slide workspace also provides undo and redo. **Reset region to template** and **Reset all to template** remove local overrides so those regions follow the template again. **Apply to this slide** keeps adjustments in the slide draft; save the slide to publish them. Cancel discards changes made during that layout session.
Users with template editing permission can choose **Save layout to shared template** from the arrow beside **Apply to this slide**. Confirmation shows how many slides use the template. This saves the current position, size, stacking order, and ratio settings immediately without saving unsaved slide content or changing template animations. The current slide's geometry overrides are cleared so it inherits the saved layout, while explicit animation overrides are retained. Other slides keep their own overrides. Saving an existing slide's layout this way also requires slide editing permission.
If the shared template changed while the layout editor was open, the shared save is rejected and the draft remains available. Reload the slide after preserving any unsaved content before trying again. A failed save or cancelled confirmation leaves the current layout draft unchanged.
For media regions, check the crop and aspect ratio before saving. For text and live data, check the longest likely value, line wrapping, font size, and contrast. For webpages and streams, confirm the player network can reach the external address. For media regions, check the crop and aspect ratio before saving. For text and live data, check the longest likely value, line wrapping, font size, and contrast. For webpages and streams, confirm the player network can reach the external address.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.15.7", "version": "2.16.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.15.7", "version": "2.16.0",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.15.7", "version": "2.16.0",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
@@ -21,7 +21,7 @@
"start:player": "node -r dotenv/config src/player.js", "start:player": "node -r dotenv/config src/player.js",
"dev:web": "nodemon -r dotenv/config src/web.js", "dev:web": "nodemon -r dotenv/config src/web.js",
"dev:player": "nodemon -r dotenv/config src/player.js", "dev:player": "nodemon -r dotenv/config src/player.js",
"test": "node --test" "test": "node --test test/*.test.js"
}, },
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
+27
View File
@@ -2,6 +2,7 @@
const { fetchTemplateById } = require('#src/data/templates'); const { fetchTemplateById } = require('#src/data/templates');
const { parseJsonSafe, validateMaxLength } = require('#src/data/utils'); const { parseJsonSafe, validateMaxLength } = require('#src/data/utils');
const { normalizeLayout } = require('#src/web/public/js/shared/region-layout');
const TITLE_MAX_LENGTH = 255; const TITLE_MAX_LENGTH = 255;
const { buildQrCodeContent } = require('#src/data/qr-code'); const { buildQrCodeContent } = require('#src/data/qr-code');
@@ -622,6 +623,30 @@ async function buildSlidePayload(pool, req, existingSlide) {
if (template) { if (template) {
const content = await buildTemplateContent(pool, template, req.body, filesByField, existingContent); const content = await buildTemplateContent(pool, template, req.body, filesByField, existingContent);
const submittedLayouts = req.body.region_layouts_json;
let layouts = null;
if (submittedLayouts !== undefined) {
layouts = parseJsonSafe(submittedLayouts);
if (!layouts || typeof layouts !== 'object' || Array.isArray(layouts)) {
const error = new Error('Invalid slide layout.');
error.statusCode = 400;
throw error;
}
}
const canvas = { width: template.canvas_size_width, height: template.canvas_size_height };
for (const region of template.regions) {
const key = region.region_key;
const value = layouts ? (Object.hasOwn(layouts, key) ? layouts[key] : null)
: Number(existingSlide && existingSlide.template_id) === template.id && existingContent[key] && existingContent[key].layout;
if (!value) continue;
const layout = normalizeLayout(value, canvas);
if (!layout) {
const error = new Error('Invalid position or size for region ' + key + '.');
error.statusCode = 400;
throw error;
}
if (content[key]) content[key].layout = layout;
}
await Promise.all(Object.keys(content).map(async function (regionKey) { await Promise.all(Object.keys(content).map(async function (regionKey) {
const region = template.regions.find(function (item) { const region = template.regions.find(function (item) {
return String(item.region_key || '').trim() === regionKey; return String(item.region_key || '').trim() === regionKey;
@@ -630,7 +655,9 @@ async function buildSlidePayload(pool, req, existingSlide) {
return; return;
} }
const layout = content[regionKey].layout;
content[regionKey] = await buildQrCodeContent(content[regionKey]); content[regionKey] = await buildQrCodeContent(content[regionKey]);
if (layout) content[regionKey].layout = layout;
})); }));
return { return {
+54
View File
@@ -2,6 +2,7 @@
const { parseJsonSafe, readFormArray, validateMaxLength } = require('#src/data/utils'); const { parseJsonSafe, readFormArray, validateMaxLength } = require('#src/data/utils');
const animationPresets = require('#src/web/public/js/templates/animation-presets'); const animationPresets = require('#src/web/public/js/templates/animation-presets');
const { normalizeLayout, getTemplateLayoutSnapshot, getTemplateAnimationSnapshot } = require('#src/web/public/js/shared/region-layout');
const TEMPLATE_NAME_MAX_LENGTH = 255; const TEMPLATE_NAME_MAX_LENGTH = 255;
const REGION_NAME_MAX_LENGTH = 255; const REGION_NAME_MAX_LENGTH = 255;
@@ -309,11 +310,64 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
}; };
} }
function buildTemplateLayoutUpdate(template, body) {
function fail(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
throw error;
}
if (JSON.stringify(body.baseline) !== JSON.stringify(getTemplateLayoutSnapshot(template))) {
fail('The shared template layout has changed. Reload the slide before saving to the template.', 409);
}
const submitted = body.regions;
if (!Array.isArray(submitted) || submitted.length !== template.regions.length) {
fail('The layout must contain every existing template region.', 400);
}
const seen = new Set();
const canvas = { width: template.canvas_size_width, height: template.canvas_size_height };
return submitted.map(function (value) {
const region = value && template.regions.find(function (item) { return item.region_key === value.region_key; });
if (!region || seen.has(region.region_key)) fail('Invalid or duplicate template region.', 400);
seen.add(region.region_key);
if (typeof value.z_index !== 'number' || typeof value.lock_ratio !== 'string') fail('Invalid stacking order or ratio.', 400);
const layout = normalizeLayout({
x: value.x, y: value.y, width: value.width, height: value.height,
z_index: value.z_index, lock_ratio: value.lock_ratio
}, canvas);
if (!layout) fail('Invalid region position, size, stacking order, or ratio.', 400);
return Object.assign({}, region, layout);
}).sort(function (first, second) { return first.z_index - second.z_index || Number(first.id) - Number(second.id); });
}
function buildTemplateAnimationUpdate(template, body) {
function fail(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
throw error;
}
if (JSON.stringify(body.baseline) !== JSON.stringify(getTemplateAnimationSnapshot(template))) {
fail('The shared template animations have changed. Reload the slide before saving.', 409);
}
if (!Array.isArray(body.animations) || body.animations.length !== template.regions.length) {
fail('Animations must be provided for every existing template region.', 400);
}
const seen = new Set();
return body.animations.map(function (value) {
const region = value && template.regions.find(function (item) { return item.region_key === value.region_key; });
if (!region || seen.has(region.region_key)) fail('Invalid or duplicate template region.', 400);
seen.add(region.region_key);
return Object.assign({}, region, { animation_json: normalizeTemplateRegionAnimationConfig(value.animation_json) });
}).sort(function (first, second) { return Number(first.z_index) - Number(second.z_index) || Number(first.id) - Number(second.id); });
}
module.exports = { module.exports = {
fetchTemplateById, fetchTemplateById,
fetchTemplatesData, fetchTemplatesData,
extractTemplateRegions, extractTemplateRegions,
buildTemplatePayload, buildTemplatePayload,
normalizeBackgroundGradient, normalizeBackgroundGradient,
buildTemplateLayoutUpdate,
buildTemplateAnimationUpdate,
normalizeTemplateRegionAnimationConfig,
parseJsonSafe parseJsonSafe
}; };
+14 -10
View File
@@ -207,20 +207,23 @@ function isSlideActive(slide, now) {
return true; return true;
} }
// Build the cache key for a template layout. // Build the cache key for a template layout.
function getTemplateLayoutCacheKey(template) { function getTemplateLayoutCacheKey(template, content) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight; var viewportKey = window.innerWidth + 'x' + window.innerHeight;
return [currentPlaylistSignature || '', template && template.id ? template.id : '', viewportKey].join('|'); var layouts = (template && template.regions || []).map(function (region) {
return content && content[region.region_key] && content[region.region_key].layout || null;
});
return [currentPlaylistSignature || '', template && template.id ? template.id : '', viewportKey, JSON.stringify(layouts)].join('|');
} }
// Build or reuse layout metadata for a template. // Build or reuse layout metadata for a template.
function getTemplateLayout(template) { function getTemplateLayout(template, content) {
if (!template || !template.id) { if (!template || !template.id) {
return null; return null;
} }
syncRenderCacheViewport(); syncRenderCacheViewport();
var cacheKey = getTemplateLayoutCacheKey(template); var cacheKey = getTemplateLayoutCacheKey(template, content);
if (Object.prototype.hasOwnProperty.call(templateLayoutCache, cacheKey)) { if (Object.prototype.hasOwnProperty.call(templateLayoutCache, cacheKey)) {
return templateLayoutCache[cacheKey]; return templateLayoutCache[cacheKey];
} }
@@ -229,6 +232,7 @@ function getTemplateLayout(template) {
var canvasSize = fitCanvasSize(templateCanvas.width, templateCanvas.height, window.innerWidth, window.innerHeight); var canvasSize = fitCanvasSize(templateCanvas.width, templateCanvas.height, window.innerWidth, window.innerHeight);
var canvasScale = canvasSize.width / templateCanvas.width; var canvasScale = canvasSize.width / templateCanvas.width;
var regions = (template.regions || []).map(function (region) { var regions = (template.regions || []).map(function (region) {
region = window.pulseRegionLayout.applyLayout(region, content && content[region.region_key], templateCanvas);
var left = (Number(region.x) / templateCanvas.width) * 100; var left = (Number(region.x) / templateCanvas.width) * 100;
var top = (Number(region.y) / templateCanvas.height) * 100; var top = (Number(region.y) / templateCanvas.height) * 100;
var width = (Number(region.width) / templateCanvas.width) * 100; var width = (Number(region.width) / templateCanvas.width) * 100;
@@ -297,24 +301,24 @@ function buildBackdropStyle(backgroundColor, backgroundImagePath, backgroundGrad
} }
// Build the cache key for a template render plan. // Build the cache key for a template render plan.
function getTemplateRenderPlanCacheKey(template) { function getTemplateRenderPlanCacheKey(template, content) {
return getTemplateLayoutCacheKey(template); return getTemplateLayoutCacheKey(template, content);
} }
// Build or reuse the render plan for a template. // Build or reuse the render plan for a template.
function getTemplateRenderPlan(template) { function getTemplateRenderPlan(template, content) {
if (!template || !template.id) { if (!template || !template.id) {
return null; return null;
} }
syncRenderCacheViewport(); syncRenderCacheViewport();
var cacheKey = getTemplateRenderPlanCacheKey(template); var cacheKey = getTemplateRenderPlanCacheKey(template, content);
if (Object.prototype.hasOwnProperty.call(templateRenderPlanCache, cacheKey)) { if (Object.prototype.hasOwnProperty.call(templateRenderPlanCache, cacheKey)) {
return templateRenderPlanCache[cacheKey]; return templateRenderPlanCache[cacheKey];
} }
var layout = getTemplateLayout(template); var layout = getTemplateLayout(template, content);
function getPlayerRegionModule(regionType) { function getPlayerRegionModule(regionType) {
return window.pulsePlayerRegionTypes && typeof window.pulsePlayerRegionTypes.get === 'function' ? window.pulsePlayerRegionTypes.get(regionType) : null; return window.pulsePlayerRegionTypes && typeof window.pulsePlayerRegionTypes.get === 'function' ? window.pulsePlayerRegionTypes.get(regionType) : null;
} }
@@ -359,7 +363,7 @@ function getTemplateRenderPlan(template) {
function renderTemplateSlideMarkup(slide) { function renderTemplateSlideMarkup(slide) {
const template = slide.template; const template = slide.template;
const content = slide.content || {}; const content = slide.content || {};
const plan = getTemplateRenderPlan(template); const plan = getTemplateRenderPlan(template, content);
const layout = plan ? plan.layout : null; const layout = plan ? plan.layout : null;
const regions = layout ? layout.regions.map(function (region) { const regions = layout ? layout.regions.map(function (region) {
const regionContent = content[region.regionKey] || {}; const regionContent = content[region.regionKey] || {};
+1
View File
@@ -225,6 +225,7 @@ function getPlayerRuntimeScripts() {
['placeholder-utils', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8').trim()], ['placeholder-utils', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8').trim()],
['chart-data', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'regions', 'chart-data.js'), 'utf8').trim()], ['chart-data', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'regions', 'chart-data.js'), 'utf8').trim()],
['qr-code-svg', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js'), 'utf8').trim()], ['qr-code-svg', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js'), 'utf8').trim()],
['region-layout', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'region-layout.js'), 'utf8').trim()],
['rendering-shared', fs.readFileSync(playerRenderingSharedScriptPath, 'utf8').trim()], ['rendering-shared', fs.readFileSync(playerRenderingSharedScriptPath, 'utf8').trim()],
['client-name', getScriptBody(getPlayerClientNameScript()())], ['client-name', getScriptBody(getPlayerClientNameScript()())],
['offline', getScriptBody(getPlayerPageOfflineScript()())], ['offline', getScriptBody(getPlayerPageOfflineScript()())],
+3 -2
View File
@@ -208,7 +208,9 @@ async function start() {
const isPermissionError = statusCode === 403; const isPermissionError = statusCode === 403;
const message = isPermissionError const message = isPermissionError
? String(error && error.message ? error.message : PERMISSION_DENIED_MESSAGE) ? String(error && error.message ? error.message : PERMISSION_DENIED_MESSAGE)
: String(error && error.message ? error.message : 'An unexpected error occurred.'); : statusCode === 404 || statusCode >= 500
? ''
: String(error && error.message ? error.message : 'An unexpected error occurred.');
const title = isPermissionError const title = isPermissionError
? 'Access denied' ? 'Access denied'
: statusCode === 404 : statusCode === 404
@@ -218,7 +220,6 @@ async function start() {
return res.status(statusCode).send(pages.renderErrorPage({ return res.status(statusCode).send(pages.renderErrorPage({
statusCode: statusCode, statusCode: statusCode,
title: title, title: title,
errorTitle: title,
message: message, message: message,
detail: statusCode >= 500 ? 'The server could not complete the request.' : '', detail: statusCode >= 500 ? 'The server could not complete the request.' : '',
backUrl: req.currentUser ? '/dashboard' : '/login', backUrl: req.currentUser ? '/dashboard' : '/login',
+3
View File
@@ -178,6 +178,9 @@ function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8')); Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8')); Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8')); Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8'));
Handlebars.registerPartial('signage/templates/region-layout-fields', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'region-layout-fields.hbs'), 'utf8'));
Handlebars.registerPartial('signage/templates/region-animation-controls', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'region-animation-controls.hbs'), 'utf8'));
Handlebars.registerPartial('signage/templates/animation-advanced-fields', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-fields.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8')); Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8')); Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/weather/preview', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'weather', 'preview.hbs'), 'utf8')); Handlebars.registerPartial('data-sources/weather/preview', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'weather', 'preview.hbs'), 'utf8'));
@@ -7,6 +7,7 @@ const {
sanitizeFontSize, sanitizeFontSize,
sanitizeTextColor sanitizeTextColor
} = require('#src/player/render-helpers'); } = require('#src/player/render-helpers');
const { applyLayout } = require('#src/web/public/js/shared/region-layout');
function normalizeBaseUrl(baseUrl) { function normalizeBaseUrl(baseUrl) {
return String(baseUrl || '').trim().replace(/\/$/, ''); return String(baseUrl || '').trim().replace(/\/$/, '');
@@ -173,6 +174,7 @@ function buildThumbnailPreviewMarkup(slide, baseUrl) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl); const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
return (Array.isArray(template.regions) ? template.regions : []).map(function (region) { return (Array.isArray(template.regions) ? template.regions : []).map(function (region) {
const regionContent = getRegionContent(slide, region); const regionContent = getRegionContent(slide, region);
region = applyLayout(region, regionContent, canvasSize);
const previewRegion = Object.assign({}, region, { const previewRegion = Object.assign({}, region, {
baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height), baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height),
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)), pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
+5 -4
View File
@@ -19,7 +19,7 @@ function getErrorCopy(statusCode, message) {
return { return {
title: 'Not found', title: 'Not found',
errorTitle: 'Oops! Page not found.', errorTitle: 'Oops! Page not found.',
errorMessage: message || 'We could not find the page you were looking for. Meanwhile, you may return to the dashboard or try searching for what you need.', errorMessage: message || 'We could not find the page you were looking for. Please check the address or return to the dashboard.',
backLabel: 'Back to dashboard' backLabel: 'Back to dashboard'
}; };
} }
@@ -27,8 +27,8 @@ function getErrorCopy(statusCode, message) {
if (normalizedStatusCode >= 500) { if (normalizedStatusCode >= 500) {
return { return {
title: 'Something went wrong', title: 'Something went wrong',
errorTitle: 'Something went wrong.', errorTitle: 'Something went wrong on our end.',
errorMessage: message || 'An unexpected error occurred. Please try again in a moment.', errorMessage: message || 'The server encountered an unexpected condition. Please try again in a moment, or contact your administrator if the issue persists.',
backLabel: 'Back to dashboard' backLabel: 'Back to dashboard'
}; };
} }
@@ -49,6 +49,7 @@ module.exports = function renderErrorPage(options, currentUser) {
title: String(errorOptions.title || copy.title || 'Error').trim(), title: String(errorOptions.title || copy.title || 'Error').trim(),
active: '', active: '',
messageVariant: statusCode === 403 ? 'warning' : 'primary', messageVariant: statusCode === 403 ? 'warning' : 'primary',
statusVariant: statusCode >= 500 ? 'danger' : statusCode === 403 ? 'warning' : 'primary',
currentUser: currentUser || null, currentUser: currentUser || null,
statusCode: statusCode, statusCode: statusCode,
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(), errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
@@ -57,7 +58,7 @@ module.exports = function renderErrorPage(options, currentUser) {
backUrl: String(errorOptions.backUrl || '/dashboard').trim() || '/dashboard', backUrl: String(errorOptions.backUrl || '/dashboard').trim() || '/dashboard',
backLabel: String(errorOptions.backLabel || copy.backLabel || 'Back to dashboard').trim() || 'Back to dashboard', backLabel: String(errorOptions.backLabel || copy.backLabel || 'Back to dashboard').trim() || 'Back to dashboard',
searchUrl: String(errorOptions.searchUrl || '').trim(), searchUrl: String(errorOptions.searchUrl || '').trim(),
bodyClass: 'error-page bg-dark text-white', bodyClass: 'error-page',
errorShell: true, errorShell: true,
stylesheets: [], stylesheets: [],
scripts: [] scripts: []
+237 -2
View File
@@ -1,3 +1,8 @@
.error-page-content {
width: 100%;
max-width: 52rem;
}
.media-picker-item { .media-picker-item {
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
border-radius: .375rem; border-radius: .375rem;
@@ -2351,6 +2356,236 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
background: #111; background: #111;
} }
#slide-layout-modal,
#slide-animation-modal {
left: var(--slide-layout-left, 0px);
width: calc(100% - var(--slide-layout-left, 0px));
}
.slide-layout-dialog {
width: calc(100% - 2rem);
max-width: none;
height: calc(100dvh - 4rem);
max-height: 860px;
min-height: 0;
margin: 2rem auto;
}
.slide-layout-dialog .modal-content {
height: 100%;
}
.slide-layout-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 21rem;
gap: 1.25rem;
min-height: 0;
}
.slide-layout-workspace {
container-type: size;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
}
.slide-layout-stage {
width: min(100%, calc(100cqh * var(--slide-layout-ratio, 1.777778)));
flex-shrink: 0;
overflow: visible;
outline: 1px solid var(--bs-border-color);
isolation: isolate;
touch-action: none;
}
.slide-layout-stage .slide-preview-canvas {
overflow: hidden;
}
.slide-layout-properties {
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 4px;
}
.slide-layout-properties .region-field-grid--size,
.slide-layout-properties .region-field-grid--position {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.slide-animation-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 21rem;
gap: 1.25rem;
min-height: 0;
}
.slide-animation-controls {
min-width: 0;
overflow-y: auto;
padding: 0 4px;
}
.slide-animation-controls .region-field-grid--animation {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.slide-animation-advanced-fields {
min-width: 0;
overflow-x: clip;
}
#slide-animation-advanced {
gap: 0.5rem !important;
}
.slide-animation-advanced-fields .row {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.5rem;
margin: 0;
}
.slide-animation-advanced-fields .row > * {
width: 100%;
max-width: 100%;
min-width: 0;
padding: 0;
}
.slide-animation-advanced-fields .card,
.slide-animation-advanced-fields .card-body,
.slide-animation-advanced-fields label {
min-width: 0;
}
.slide-animation-advanced-fields input,
.slide-animation-advanced-fields select {
width: 100%;
min-width: 0;
}
#slide-animation-advanced[hidden] {
display: none !important;
}
.slide-animation-preview-panel {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
}
#slide-animation-preview {
width: 100%;
}
#slide-animation-preview .slide-preview-stage {
width: 100%;
}
.slide-animation-preview-panel [data-slide-animation-preview][aria-pressed="true"] {
color: var(--bs-btn-active-color, #fff);
background-color: var(--bs-info);
border-color: var(--bs-info);
}
@media (max-width: 767.98px) {
.slide-animation-body {
grid-template-columns: minmax(0, 1fr);
}
.slide-animation-controls {
overflow: visible;
}
}
.template-designer-main-header::after {
display: none;
}
.template-snap-control {
display: flex;
align-items: center;
gap: 0.5rem;
padding-left: 0;
}
.template-snap-control .form-check-input {
float: none;
margin: 0;
flex: 0 0 auto;
order: 2;
}
.template-snap-control .form-check-label {
order: 1;
}
.slide-layout-layer[hidden] {
display: none;
}
.slide-layout-layer {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
--designer-overlay-scale: 1;
}
.slide-layout-hit {
pointer-events: auto;
}
.slide-layout-hit.selected {
z-index: 4;
}
.slide-layout-hit:focus-visible {
outline: 3px solid #ffc107;
}
.layout-alignment-guide {
position: absolute;
pointer-events: none;
z-index: 10;
background: #ff4081;
}
.layout-alignment-guide--x {
top: 0;
bottom: 0;
width: 1px;
}
.layout-alignment-guide--y {
left: 0;
right: 0;
height: 1px;
}
@media (max-width: 767.98px) {
.slide-layout-dialog {
width: calc(100% - 1rem);
height: calc(100dvh - 2rem);
margin: 1rem auto;
}
.slide-layout-body {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(14rem, 1fr) auto;
overflow-y: auto;
}
.slide-layout-properties {
overflow: visible;
}
}
.slide-preview-canvas { .slide-preview-canvas {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -2812,8 +3047,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
background: rgba(13, 110, 253, 0.12); background: rgba(13, 110, 253, 0.12);
box-sizing: border-box; box-sizing: border-box;
border-radius: 0; border-radius: 0;
min-width: 12px; min-width: 0;
min-height: 12px; min-height: 0;
cursor: move; cursor: move;
} }
+9 -2
View File
@@ -59,9 +59,16 @@
return registry.renderEditorCardShell({ return registry.renderEditorCardShell({
region: region, region: region,
headerActions: '<span class="chip">HTML</span>', headerActions: '<span class="chip">HTML</span>',
bodyClass: 'card-body p-3 d-grid gap-2',
bodyHtml: '' + bodyHtml: '' +
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' + '<div class="d-flex align-items-center justify-content-between gap-2">' +
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>' '<div class="muted">HTML is rendered in a sandboxed iframe preview.</div>' +
'<div class="btn-group btn-group-sm template-editor-size-controls" role="group" aria-label="Editor size controls">' +
'<button type="button" class="btn btn-outline-secondary" data-editor-size-action="decrease" aria-label="Decrease editor size" title="Decrease editor size">-</button>' +
'<button type="button" class="btn btn-outline-secondary" data-editor-size-action="increase" aria-label="Increase editor size" title="Increase editor size">+</button>' +
'</div>' +
'</div>' +
'<textarea name="region_html_' + region.id + '" class="form-control html-editor-source" data-region-id="' + region.id + '" rows="10" style="resize:none;" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>'
}); });
} }
@@ -0,0 +1,83 @@
(function () {
function normalize(value) { return window.templateDesignerUtils.normalizeAnimationConfig(value); }
function normalizePreset(value) { return window.templateAnimationPresets.normalize(value); }
function read(card) {
var config = normalize(card.querySelector('[name="region_animation_json[]"]').value || '{}');
card.querySelectorAll('[data-animation-preset]').forEach(function (input) {
if (input.dataset.animationPresetCustom !== '1' && input.selectedIndex >= 0) {
config[input.dataset.animationPreset].preset = normalizePreset(input.value);
}
});
return config;
}
function write(card, value) {
var config = normalize(value);
card.querySelector('[name="region_animation_json[]"]').value = JSON.stringify(config);
card.querySelectorAll('[data-animation-preset]').forEach(function (input) {
var step = input.dataset.animationPreset;
var preset = config[step].preset;
var custom = !Array.from(input.options).some(function (option) { return option.value === preset; });
input.value = preset;
input.hidden = custom;
if (custom) input.dataset.animationPresetCustom = '1';
else delete input.dataset.animationPresetCustom;
var reset = card.querySelector('[data-animation-reset-button="' + step + '"]');
if (reset) reset.hidden = !custom;
});
return config;
}
function setPreset(card, step, preset) {
var config = read(card);
config[step] = Object.assign({}, config[step], {
preset: normalizePreset(preset), duration_ms: null, delay_ms: null,
iterations: null, easing: '', fill_mode: ''
});
return write(card, config);
}
function bind(card, options) {
card.querySelectorAll('[data-animation-preset]').forEach(function (input) {
input.addEventListener('change', function () {
setPreset(card, input.dataset.animationPreset, input.value);
options.onChange();
});
});
card.querySelectorAll('[data-animation-reset-button]').forEach(function (button) {
button.addEventListener('click', function (event) {
event.preventDefault();
setPreset(card, button.dataset.animationResetButton, 'none');
options.onChange();
});
});
var advanced = card.querySelector('[data-animation-advanced-button]');
if (advanced) advanced.addEventListener('click', options.onAdvanced);
}
function populateAdvanced(container, value) {
var config = normalize(value);
container.querySelectorAll('[data-animation-modal-field]').forEach(function (input) {
var step = config[input.dataset.animationModalStep];
var field = input.dataset.animationModalField;
var value = step[field];
if (field === 'duration_ms') value = Math.min(200, Math.max(1, Math.round(Number(value) / 10) || 100));
if (field === 'delay_ms') value = Number(value) || 0;
if (field === 'iterations') value = Math.min(999, Math.max(1, Number(value) || 1));
input.value = value;
});
}
function readAdvanced(container, value) {
var config = normalize(value);
container.querySelectorAll('[data-animation-modal-field]').forEach(function (input) {
var step = config[input.dataset.animationModalStep];
var field = input.dataset.animationModalField;
var value = input.value;
if (field === 'duration_ms') value = Math.min(200, Math.max(1, Math.round(Number(value)) || 100)) * 10;
else if (field === 'delay_ms') value = Math.max(0, Math.round(Number(value)) || 0);
else if (field === 'iterations') value = Math.min(999, Math.max(1, Math.round(Number(value)) || 1));
step[field] = value;
});
return normalize(config);
}
window.pulseRegionAnimationControls = {
read: read, write: write, setPreset: setPreset, bind: bind,
populateAdvanced: populateAdvanced, readAdvanced: readAdvanced
};
})();
+160
View File
@@ -0,0 +1,160 @@
(function (root, factory) {
var helpers = factory();
if (typeof module === 'object' && module.exports) {
module.exports = helpers;
} else {
root.pulseRegionLayout = helpers;
}
})(typeof window !== 'undefined' ? window : globalThis, function () {
function normalizeLayout(value, canvas) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
var fields = ['x', 'y', 'width', 'height'];
var hasGeometry = fields.some(function (field) { return Object.hasOwn(value, field); });
if (hasGeometry && !fields.every(function (field) {
return typeof value[field] === 'number' && Number.isFinite(value[field]);
})) return null;
if (!hasGeometry && !['z_index', 'lock_ratio', 'animation_json'].some(function (field) { return Object.hasOwn(value, field); })) return null;
var width = Math.max(1, Math.round(Number(canvas.width) || 1920));
var height = Math.max(1, Math.round(Number(canvas.height) || 1080));
var result = hasGeometry ? {
x: Math.max(0, Math.min(width - 1, Math.round(value.x))),
y: Math.max(0, Math.min(height - 1, Math.round(value.y))),
width: Math.max(1, Math.min(width, Math.round(value.width))),
height: Math.max(1, Math.min(height, Math.round(value.height)))
} : {};
if (hasGeometry) {
result.width = Math.min(result.width, width - result.x);
result.height = Math.min(result.height, height - result.y);
}
if (Object.hasOwn(value, 'z_index')) {
if (typeof value.z_index !== 'number' || !Number.isFinite(value.z_index)) return null;
result.z_index = Math.max(1, Math.round(value.z_index));
}
if (Object.hasOwn(value, 'lock_ratio')) {
var ratio = String(value.lock_ratio || '').trim().replace(/\s+/g, '');
if (ratio && (!/^\d+:\d+$/.test(ratio) || ratio.split(':').some(function (part) { return !(Number(part) > 0); }))) return null;
result.lock_ratio = ratio;
}
if (Object.hasOwn(value, 'animation_json')) {
var animation = value.animation_json;
try {
if (typeof animation === 'string') animation = JSON.parse(animation);
} catch (_error) { return null; }
if (!animation || typeof animation !== 'object' || Array.isArray(animation)) return null;
result.animation_json = animation;
}
return result;
}
function applyLayout(region, content, canvas) {
var layout = normalizeLayout(content && content.layout, canvas);
return Object.assign({}, region, layout || {});
}
function fitLayout(box, canvas, ratio, direction) {
box = { x: box.x, y: box.y, width: box.width, height: box.height };
if (!(ratio > 0)) return normalizeLayout(box, canvas);
var west = String(direction || '').includes('w');
var north = String(direction || '').includes('n');
var anchorX = west ? box.x + box.width : box.x;
var anchorY = north ? box.y + box.height : box.y;
var maxWidth = Math.max(1, west ? anchorX : canvas.width - anchorX);
var maxHeight = Math.max(1, north ? anchorY : canvas.height - anchorY);
var width = Math.max(1, Math.min(box.width, maxWidth, maxHeight * ratio));
var height = Math.max(1, width / ratio);
return normalizeLayout({
x: west ? anchorX - width : anchorX,
y: north ? anchorY - height : anchorY,
width: width, height: height
}, canvas);
}
function snapBox(box, peers, canvas, tolerance, direction, ratio) {
var result = Object.assign({}, box);
var guides = [];
['x', 'y'].forEach(function (axis) {
var size = axis === 'x' ? 'width' : 'height';
var startHandle = axis === 'x' ? 'w' : 'n';
var endHandle = axis === 'x' ? 'e' : 's';
var extent = Number(canvas[size]);
var targets = [0, extent / 2, extent];
peers.forEach(function (peer) {
targets.push(Number(peer[axis]), Number(peer[axis]) + Number(peer[size]) / 2, Number(peer[axis]) + Number(peer[size]));
});
var anchors = direction
? (direction.indexOf(startHandle) !== -1 ? [0] : direction.indexOf(endHandle) !== -1 ? [1] : [])
: [0, 0.5, 1];
var nearest = null;
anchors.forEach(function (anchor) {
targets.forEach(function (target) {
var delta = target - (box[axis] + box[size] * anchor);
if (Math.abs(delta) <= tolerance && (!nearest || Math.abs(delta) < Math.abs(nearest.delta))) {
nearest = { delta: delta, target: target, anchor: anchor };
}
});
});
if (!nearest) return;
if (!direction) {
result[axis] += nearest.delta;
} else if (nearest.anchor === 0) {
result[axis] += nearest.delta;
result[size] -= nearest.delta;
} else {
result[size] += nearest.delta;
}
guides.push({ axis: axis, position: nearest.target });
});
if (direction && ratio > 0) {
if (guides.some(function (guide) { return guide.axis === 'x'; })) {
result.height = result.width / ratio;
if (direction.includes('n')) result.y = box.y + box.height - result.height;
} else if (guides.some(function (guide) { return guide.axis === 'y'; })) {
result.width = result.height * ratio;
if (direction.includes('w')) result.x = box.x + box.width - result.width;
}
}
if (!direction) {
result.x = Math.max(0, Math.min(canvas.width - result.width, result.x));
result.y = Math.max(0, Math.min(canvas.height - result.height, result.y));
}
var normalized = fitLayout(result, canvas, direction ? ratio : 0, direction);
guides = guides.filter(function (guide) {
var size = guide.axis === 'x' ? 'width' : 'height';
return [0, 0.5, 1].some(function (anchor) {
return Math.abs(normalized[guide.axis] + normalized[size] * anchor - guide.position) <= 0.5;
});
});
return { box: normalized, guides: guides };
}
function getTemplateLayoutSnapshot(template) {
return {
width: Number(template.canvas_size_width),
height: Number(template.canvas_size_height),
regions: (template.regions || []).map(function (region) {
return {
id: Number(region.id), region_key: region.region_key,
x: Number(region.x), y: Number(region.y), width: Number(region.width), height: Number(region.height),
z_index: Math.max(1, Number(region.z_index) || 1), lock_ratio: String(region.lock_ratio || '')
};
}).sort(function (first, second) { return first.region_key < second.region_key ? -1 : first.region_key > second.region_key ? 1 : 0; })
};
}
function getTemplateAnimationSnapshot(template) {
return (template && Array.isArray(template.regions) ? template.regions : []).map(function (region) {
var animation = region.animation_json;
if (typeof animation === 'string') {
try { animation = JSON.parse(animation); } catch (_error) { animation = {}; }
}
return { id: Number(region.id), region_key: region.region_key, animation_json: animation || {} };
}).sort(function (first, second) { return first.region_key < second.region_key ? -1 : first.region_key > second.region_key ? 1 : 0; });
}
function retainLayoutAnimation(layout) {
return layout && Object.hasOwn(layout, 'animation_json')
? normalizeLayout({ animation_json: layout.animation_json }, {}) : null;
}
return { normalizeLayout: normalizeLayout, applyLayout: applyLayout, fitLayout: fitLayout, snapBox: snapBox, getTemplateLayoutSnapshot: getTemplateLayoutSnapshot, getTemplateAnimationSnapshot: getTemplateAnimationSnapshot, retainLayoutAnimation: retainLayoutAnimation };
});
@@ -0,0 +1,259 @@
export function createSlideAnimationEditor(options) {
var controls = window.pulseRegionAnimationControls;
var modal = document.getElementById('slide-animation-modal');
var openButton = document.getElementById('adjust-slide-animations');
var regionSelect = document.getElementById('slide-animation-region');
var card = document.getElementById('slide-animation-fields');
var advanced = document.getElementById('slide-animation-advanced');
var advancedFields = advanced.querySelector('[data-animation-advanced-fields]');
var stage = options.stage;
var previewHost = document.getElementById('slide-animation-preview');
var previewButtons = Array.prototype.slice.call(modal.querySelectorAll('[data-slide-animation-preview]'));
var sharedButton = document.getElementById('save-animations-to-template');
var saveStatus = document.getElementById('slide-animation-save-status');
var sharedSaving = false;
var active = false;
var draft = Object.create(null);
var baseline = '';
var sharedBaseline = null;
var previewMode = '';
var previewTimer = 0;
var previewToken = 0;
var home = null;
var nextSibling = null;
function template() { return options.getTemplate(); }
function regions() { return template() && template().regions || []; }
function selectedKey() { return regionSelect.value; }
function getConfig(regionKey) {
var region = regions().find(function (item) { return item.region_key === regionKey; });
return Object.prototype.hasOwnProperty.call(draft, regionKey)
? draft[regionKey]
: region && region.animation_json || '{}';
}
function getCurrentConfig() {
var config = controls.read(card);
return advanced.hidden ? config : controls.readAdvanced(advancedFields, config);
}
function syncDraft() {
if (!active || !selectedKey()) return;
stopPreview();
var config = getCurrentConfig();
controls.write(card, config);
draft[selectedKey()] = JSON.stringify(config);
updatePreviewButtons();
}
function renderRegion() {
var key = selectedKey();
var region = regions().find(function (item) { return item.region_key === key; });
if (!region) return;
controls.write(card, getConfig(key));
controls.populateAdvanced(advancedFields, getConfig(key));
advanced.hidden = true;
updatePreviewButtons();
document.getElementById('slide-animation-reset-region').disabled = !Object.prototype.hasOwnProperty.call(draft, key);
document.getElementById('slide-animation-reset-all').disabled = !Object.keys(draft).length;
}
function readResponse(response) {
return response.json().then(function (data) {
if (!response.ok) throw new Error(data.error || 'Unable to save shared animations.');
return data;
}, function () {
throw new Error('Unable to save shared animations. Check your connection and sign-in status.');
});
}
if (sharedButton) sharedButton.addEventListener('click', async function () {
if (sharedSaving) return;
stopPreview();
sharedSaving = true;
modal.setAttribute('aria-busy', 'true');
var controlsToRestore = Array.from(modal.querySelectorAll('button, input, select')).map(function (element) {
var state = { element: element, disabled: element.disabled };
element.disabled = true;
return state;
});
saveStatus.classList.remove('text-danger');
saveStatus.textContent = 'Checking shared animations...';
try {
var url = '/templates/' + encodeURIComponent(template().id) + '/animations';
var current = await readResponse(await fetch(url, { headers: { Accept: 'application/json' } }));
if (JSON.stringify(current.baseline) !== JSON.stringify(sharedBaseline)) {
throw new Error('The shared template animations have changed. Reload the slide before saving.');
}
var count = Number(current.slideCount) || 0;
if (!window.confirm('Save these animations to "' + current.template.name + '"? The template is used by ' + count + ' slide' + (count === 1 ? '' : 's') + '. Other slides keep their explicit animation overrides. Unsaved slide content is not saved.')) {
saveStatus.textContent = '';
return;
}
saveStatus.textContent = 'Saving shared animations...';
var animations = regions().map(function (region) {
return { region_key: region.region_key, animation_json: getConfig(region.region_key) };
});
var result = await readResponse(await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ slideId: options.slideId || null, baseline: sharedBaseline, animations: animations })
}));
options.updateTemplate(result.template);
draft = Object.create(null);
options.setAnimationOverrides(draft);
options.markEdited();
active = false;
saveStatus.textContent = '';
sharedSaving = false;
modal.removeAttribute('aria-busy');
window.pulseModal.hide(modal);
if (typeof window.showToast === 'function') window.showToast('Shared template animations saved.', 'success');
} catch (error) {
saveStatus.textContent = error.message;
saveStatus.classList.add('text-danger');
} finally {
sharedSaving = false;
modal.removeAttribute('aria-busy');
controlsToRestore.forEach(function (state) { state.element.disabled = state.disabled; });
renderRegion();
}
});
modal.addEventListener('hide.bs.modal', function (event) {
if (sharedSaving) event.preventDefault();
});
function getPreviewElements() {
return Array.prototype.slice.call(previewHost.querySelectorAll('.slide-preview-region[data-region-key]'));
}
function stopPreview() {
if (previewTimer) window.clearTimeout(previewTimer);
previewTimer = 0;
previewToken += 1;
previewMode = '';
getPreviewElements().forEach(function (element) {
element.className.split(/\s+/).forEach(function (className) {
if (className.indexOf('animate__') === 0) element.classList.remove(className);
});
['--animate-duration', '--animate-delay', '--animate-repeat'].forEach(function (property) { element.style.removeProperty(property); });
});
updatePreviewButtons();
}
function updatePreviewButtons() {
var modeKeys = { intro: 'intro', outro: 'outro', loop: 'loop' };
previewButtons.forEach(function (button) {
var mode = button.dataset.slideAnimationPreview;
var available = regions().some(function (region) {
var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(region.region_key));
return window.templateAnimationPresets.normalize(config[modeKeys[mode]].preset) !== 'none';
});
var disabled = !available || (previewMode && previewMode !== mode);
button.disabled = Boolean(disabled);
button.classList.toggle('btn-outline-secondary', Boolean(disabled));
button.classList.toggle('btn-info', !disabled);
button.setAttribute('aria-pressed', String(Boolean(previewMode === mode)));
var icon = button.querySelector('[data-animation-preview-icon]');
if (icon) icon.className = 'bi ' + (previewMode === mode ? 'bi-stop-fill' : 'bi-play-fill') + ' me-1';
});
}
function startPreview(mode) {
if (previewMode === mode) { stopPreview(); return; }
stopPreview();
previewMode = mode;
var token = previewToken;
var longest = 0;
getPreviewElements().forEach(function (element, index) {
var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(element.dataset.regionKey));
var step = config[mode] || {};
var preset = window.templateAnimationPresets.normalize(step.preset);
if (preset === 'none') return;
var delay = Math.max(0, Number(step.delay_ms) || 0) + index * 90;
var duration = Math.max(1, Number(step.duration_ms) || 1000);
var iterations = mode === 'loop' ? Math.max(1, Number(step.iterations) || 1) : 1;
element.style.setProperty('--animate-duration', duration + 'ms');
element.style.setProperty('--animate-delay', delay + 'ms');
element.style.setProperty('--animate-repeat', String(iterations));
element.classList.remove('animate__animated', 'animate__' + preset, 'animate__repeat-1');
void element.offsetWidth;
element.classList.add('animate__animated', 'animate__' + preset);
if (mode === 'loop') element.classList.add('animate__repeat-1');
longest = Math.max(longest, delay + duration * iterations);
});
updatePreviewButtons();
previewTimer = window.setTimeout(function () {
if (token === previewToken) stopPreview();
}, longest + 180);
}
previewButtons.forEach(function (button) {
button.addEventListener('click', function () { startPreview(button.dataset.slideAnimationPreview); });
});
openButton.addEventListener('click', function () {
if (!regions().length || !window.pulseModal) return;
regionSelect.replaceChildren();
regions().forEach(function (region) {
var option = document.createElement('option');
option.value = region.region_key;
option.textContent = region.label || region.region_key;
regionSelect.appendChild(option);
});
draft = options.getAnimationOverrides();
baseline = JSON.stringify(draft);
sharedBaseline = window.pulseRegionLayout.getTemplateAnimationSnapshot(template());
active = true;
var main = document.querySelector('.app-main');
var sidebarEdge = window.innerWidth >= 992 && main ? Math.max(0, main.getBoundingClientRect().left) : 0;
modal.style.setProperty('--slide-layout-left', sidebarEdge + 'px');
saveStatus.textContent = '';
saveStatus.classList.remove('text-danger');
home = stage.parentNode;
nextSibling = stage.nextSibling;
previewHost.appendChild(stage);
renderRegion();
options.requestPreviewRender();
window.pulseModal.show(modal);
});
modal.addEventListener('shown.bs.modal', function () {
options.requestPreviewRender();
regionSelect.focus();
});
modal.addEventListener('hidden.bs.modal', function () {
stopPreview();
active = false;
draft = Object.create(null);
if (home) home.insertBefore(stage, nextSibling);
options.requestPreviewRender();
openButton.focus();
});
modal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (button) {
button.removeAttribute('data-bs-dismiss');
button.addEventListener('click', function () { window.pulseModal.hide(modal); });
});
regionSelect.addEventListener('change', function () {
stopPreview();
renderRegion();
});
controls.bind(card, {
onChange: syncDraft,
onAdvanced: function () {
advanced.hidden = !advanced.hidden;
if (!advanced.hidden) controls.populateAdvanced(advancedFields, controls.read(card));
}
});
advancedFields.addEventListener('change', syncDraft);
document.getElementById('slide-animation-reset-region').addEventListener('click', function () {
stopPreview();
delete draft[selectedKey()];
renderRegion();
});
document.getElementById('slide-animation-reset-all').addEventListener('click', function () {
stopPreview();
draft = Object.create(null);
renderRegion();
});
document.getElementById('slide-animation-apply').addEventListener('click', function () {
stopPreview();
if (JSON.stringify(draft) !== baseline) {
options.setAnimationOverrides(draft);
options.markEdited();
}
window.pulseModal.hide(modal);
});
return { refresh: function () { openButton.disabled = active || !regions().length; } };
}
@@ -89,7 +89,7 @@ export function createSlideFormEditorController(options) {
return null; return null;
} }
return templateFields.querySelector('.editor-holder[data-region-id="' + regionId + '"]'); return templateFields.querySelector('.editor-holder[data-region-id="' + regionId + '"], .html-editor-source[data-region-id="' + regionId + '"]');
} }
function getEditorHeight(regionId) { function getEditorHeight(regionId) {
@@ -734,6 +734,11 @@ export function createSlideFormEditorController(options) {
bindEditorSizeControls(); bindEditorSizeControls();
watchThemeChanges(); watchThemeChanges();
Array.prototype.forEach.call(templateFields.querySelectorAll('.html-editor-source'), function (source) {
var regionId = source.getAttribute('data-region-id');
applyEditorHeight(regionId, getEditorHeight(regionId), { silent: true });
});
var holders = Array.prototype.slice.call(templateFields.querySelectorAll('.editor-holder')); var holders = Array.prototype.slice.call(templateFields.querySelectorAll('.editor-holder'));
return Promise.all(holders.map(function (holder) { return Promise.all(holders.map(function (holder) {
return createEditorForHolder(holder); return createEditorForHolder(holder);
+38 -2
View File
@@ -2,6 +2,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
import { createSlideFormEditorController } from '/assets/js/slides/slide-form-editor.js'; import { createSlideFormEditorController } from '/assets/js/slides/slide-form-editor.js';
import { createSlideFormRegionHelpers } from '/assets/js/slides/slide-form-regions.js'; import { createSlideFormRegionHelpers } from '/assets/js/slides/slide-form-regions.js';
import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-preview.js'; import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-preview.js';
import { createSlideLayoutEditor } from '/assets/js/slides/slide-layout-editor.js';
import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-editor.js';
(function () { (function () {
var DEFAULT_FONT_SIZE = 32; var DEFAULT_FONT_SIZE = 32;
@@ -58,6 +60,37 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var sidebarBaseOffset = 16; var sidebarBaseOffset = 16;
var templateSelectorLock = createTemplateSelectorLockController(templateSelect); var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
var regionTypes = window.pulseRegionTypes || {}; var regionTypes = window.pulseRegionTypes || {};
var slideLayoutEditor = createSlideLayoutEditor({
slideId: slideEditorData.slideId,
stage: slidePreviewStage,
getTemplate: getSelectedTemplate,
requestPreviewRender: requestPreviewRender,
updateTemplate: function (updatedTemplate) {
Object.assign(getSelectedTemplate(), updatedTemplate);
},
markEdited: function () {
slideForm.dataset.dirty = 'true';
templateSelectorLock.arm();
templateSelectorLock.markEdited();
}
});
var slideAnimationEditor = createSlideAnimationEditor({
stage: slidePreviewStage,
slideId: slideEditorData.slideId,
getTemplate: getSelectedTemplate,
requestPreviewRender: requestPreviewRender,
updateTemplate: function (updatedTemplate) {
Object.assign(getSelectedTemplate(), updatedTemplate);
},
getAnimation: slideLayoutEditor.getAnimation,
getAnimationOverrides: slideLayoutEditor.getAnimationOverrides,
setAnimationOverrides: slideLayoutEditor.setAnimationOverrides,
markEdited: function () {
slideForm.dataset.dirty = 'true';
templateSelectorLock.arm();
templateSelectorLock.markEdited();
}
});
function escapeHtml(value) { function escapeHtml(value) {
return String(value ?? '') return String(value ?? '')
@@ -614,6 +647,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
currentPreviewCanvasWidth = canvasWidth; currentPreviewCanvasWidth = canvasWidth;
currentPreviewCanvasHeight = canvasHeight; currentPreviewCanvasHeight = canvasHeight;
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight; slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
slidePreviewStage.style.setProperty('--slide-layout-ratio', String(canvasWidth / canvasHeight));
applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path, template.background_gradient); applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path, template.background_gradient);
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight; slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
if (!(template.regions || []).length) { if (!(template.regions || []).length) {
@@ -651,7 +685,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
slidePreviewCanvas.style.height = canvasHeight + 'px'; slidePreviewCanvas.style.height = canvasHeight + 'px';
slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')';
slidePreviewCanvas.style.transformOrigin = 'top left'; slidePreviewCanvas.style.transformOrigin = 'top left';
var regions = template.regions || []; var regions = (template.regions || []).map(slideLayoutEditor.apply);
slidePreviewOverlay.innerHTML = regions.map(function (region) { slidePreviewOverlay.innerHTML = regions.map(function (region) {
var box = getRegionBox(region, canvasWidth, canvasHeight); var box = getRegionBox(region, canvasWidth, canvasHeight);
var card = templateFields.querySelector('[data-region-id="' + region.id + '"]'); var card = templateFields.querySelector('[data-region-id="' + region.id + '"]');
@@ -680,7 +714,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
: region.region_type === 'html' : region.region_type === 'html'
? ' slide-preview-html-region' ? ' slide-preview-html-region'
: ' slide-preview-text-region'; : ' slide-preview-text-region';
return '<div class="slide-preview-region' + selected + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;">' + content + '</div>'; return '<div class="slide-preview-region' + selected + '" data-region-key="' + escapeHtml(region.region_key || '') + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;z-index:' + Math.max(1, Number(region.z_index) || 1) + ';">' + content + '</div>';
}).join(''); }).join('');
regions.forEach(function (region) { regions.forEach(function (region) {
@@ -692,6 +726,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
startPreviewVideoPlayback(slidePreviewOverlay); startPreviewVideoPlayback(slidePreviewOverlay);
slideLayoutEditor.refresh();
syncPopupPreview(); syncPopupPreview();
} }
@@ -723,6 +758,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
clearImagePreviewUrls(); clearImagePreviewUrls();
slideFormEditorController.destroyEditors(); slideFormEditorController.destroyEditors();
var template = getTemplateById(templateSelect.value); var template = getTemplateById(templateSelect.value);
slideLayoutEditor.reset(Number(templateSelect.value) === Number(existingTemplateId) ? existingContent : {});
if (!template) { if (!template) {
templateFields.innerHTML = ''; templateFields.innerHTML = '';
requestPreviewRender(); requestPreviewRender();
@@ -0,0 +1,450 @@
export function createSlideLayoutEditor(options) {
var helpers = window.pulseRegionLayout;
var stage = options.stage;
var modal = document.getElementById('slide-layout-modal');
var workspace = document.getElementById('slide-layout-workspace');
var regionSelect = document.getElementById('slide-layout-region');
var hidden = document.getElementById('slide-layouts-json');
var openButton = document.getElementById('adjust-slide-layout');
var animationButton = document.getElementById('adjust-slide-animations');
var undoButton = document.getElementById('slide-layout-undo');
var redoButton = document.getElementById('slide-layout-redo');
var resetButton = document.getElementById('slide-layout-reset');
var snapInput = document.getElementById('slide-layout-snap');
var sharedButton = document.getElementById('save-layout-to-template');
var saveStatus = document.getElementById('slide-layout-save-status');
var sharedSaving = false;
var sharedBaseline = null;
var properties = document.getElementById('slide-layout-fields');
var layer = document.createElement('div');
layer.className = 'slide-layout-layer';
layer.hidden = true;
stage.appendChild(layer);
var fields = {};
['x', 'y', 'width', 'height', 'z_index', 'lock_ratio'].forEach(function (field) {
fields[field] = properties.querySelector('[name="region_' + (field === 'z_index' ? 'z' : field) + '[]"]');
fields[field].id = 'slide-layout-' + field;
});
var layouts = Object.create(null);
var selectedKey = '';
var history = [];
var historyIndex = 0;
var baseline = '';
var home = stage.parentNode;
var nextSibling = stage.nextSibling;
var active = false;
var applied = false;
var drag = null;
var guides = [];
async function readResponse(response) {
var data;
try { data = await response.json(); } catch (_error) { throw new Error('Unable to save the shared layout. Check your connection and sign-in status.'); }
if (!response.ok) throw new Error(data.error || 'Unable to save the shared layout.');
return data;
}
if (sharedButton) sharedButton.addEventListener('click', async function () {
if (sharedSaving) return;
if (Object.keys(fields).some(function (field) { return !fields[field].reportValidity(); })) return;
sharedSaving = true;
modal.setAttribute('aria-busy', 'true');
var controls = Array.from(modal.querySelectorAll('button, input, select')).map(function (input) {
var state = { input: input, disabled: input.disabled };
input.disabled = true;
return state;
});
saveStatus.classList.remove('text-danger');
saveStatus.textContent = 'Checking shared template...';
try {
var url = '/templates/' + encodeURIComponent(template().id) + '/layout';
var current = await readResponse(await fetch(url, { headers: { Accept: 'application/json' } }));
if (JSON.stringify(helpers.getTemplateLayoutSnapshot(current.template)) !== JSON.stringify(sharedBaseline)) {
throw new Error('The shared template layout has changed. Reload the slide before saving to the template.');
}
var count = Number(current.slideCount) || 0;
if (!window.confirm('Save this layout to "' + current.template.name + '"? It is used by ' + count + ' slide' + (count === 1 ? '' : 's') + '. Other slides keep their explicit overrides. Unsaved slide content is not saved.')) {
saveStatus.textContent = '';
return;
}
saveStatus.textContent = 'Saving shared layout...';
var result = await readResponse(await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ slideId: options.slideId || null, baseline: sharedBaseline, regions: helpers.getTemplateLayoutSnapshot(Object.assign({}, template(), { regions: regions().map(effective) })).regions })
}));
options.updateTemplate(result.template);
var inheritedLayouts = Object.create(null);
Object.keys(layouts).forEach(function (key) {
var animation = helpers.retainLayoutAnimation(layouts[key]);
if (animation) inheritedLayouts[key] = animation;
});
layouts = inheritedLayouts;
baseline = snapshot();
sharedBaseline = helpers.getTemplateLayoutSnapshot(template());
history = [baseline];
historyIndex = 0;
applied = true;
options.markEdited();
hidden.value = baseline;
sharedSaving = false;
saveStatus.textContent = '';
options.requestPreviewRender();
window.pulseModal.hide(modal);
if (typeof window.showToast === 'function') window.showToast('Shared template layout saved.', 'success');
} catch (error) {
saveStatus.textContent = error.message;
saveStatus.classList.add('text-danger');
} finally {
sharedSaving = false;
modal.removeAttribute('aria-busy');
controls.forEach(function (state) { state.input.disabled = state.disabled; });
render();
}
});
modal.addEventListener('hide.bs.modal', function (event) {
if (sharedSaving) event.preventDefault();
});
function template() { return options.getTemplate(); }
function canvas() {
var current = template();
return { width: Number(current && current.canvas_size_width) || 1920, height: Number(current && current.canvas_size_height) || 1080 };
}
function regions() { return template() && template().regions || []; }
function selected() {
return regions().find(function (region) { return region.region_key === selectedKey; });
}
function effective(region) {
return helpers.applyLayout(region, { layout: layouts[region.region_key] }, canvas());
}
function snapshot() { return JSON.stringify(layouts); }
function commit() {
var value = snapshot();
if (history[historyIndex] === value) return;
history = history.slice(0, historyIndex + 1);
history.push(value);
historyIndex = history.length - 1;
}
function update() {
hidden.value = snapshot();
render();
options.requestPreviewRender();
}
function change(box) {
if (!selected()) return;
var geometry = { x: box.x, y: box.y, width: box.width, height: box.height };
layouts[selectedKey] = helpers.normalizeLayout(Object.assign({}, layouts[selectedKey], geometry), canvas());
update();
}
function changeSettings(patch) {
if (!selected()) return;
var box = effective(selected());
var next = helpers.normalizeLayout(Object.assign({ x: box.x, y: box.y, width: box.width, height: box.height }, layouts[selectedKey], patch), canvas());
if (!next) return false;
layouts[selectedKey] = next;
commit();
update();
return true;
}
function render() {
openButton.disabled = active || !regions().length;
animationButton.disabled = active || !regions().length;
if (!active || sharedSaving) return;
if (!selected()) selectedKey = regions().length ? regions()[0].region_key : '';
var region = selected();
var box = region && effective(region);
Object.keys(fields).forEach(function (field) {
fields[field].disabled = !box;
fields[field].value = box && box[field] !== undefined && box[field] !== null ? box[field] : '';
fields[field].setCustomValidity('');
if (field === 'x' || field === 'width') fields[field].max = canvas().width;
if (field === 'y' || field === 'height') fields[field].max = canvas().height;
});
regionSelect.value = selectedKey;
undoButton.disabled = historyIndex === 0;
redoButton.disabled = historyIndex >= history.length - 1;
resetButton.disabled = !Object.hasOwn(layouts, selectedKey);
document.getElementById('slide-layout-reset-all').disabled = !Object.keys(layouts).length;
layer.replaceChildren();
regions().map(effective).sort(function (first, second) {
return Number(first.z_index || 1) - Number(second.z_index || 1);
}).forEach(function (item) {
var itemBox = effective(item);
var hit = document.createElement('div');
hit.className = 'designer-rect slide-layout-hit' + (item.region_key === selectedKey ? ' selected' : '');
hit.dataset.regionKey = item.region_key;
hit.setAttribute('role', 'button');
hit.setAttribute('aria-label', item.label || item.region_key);
hit.setAttribute('aria-pressed', String(item.region_key === selectedKey));
hit.tabIndex = 0;
hit.style.left = itemBox.x / canvas().width * 100 + '%';
hit.style.top = itemBox.y / canvas().height * 100 + '%';
hit.style.width = itemBox.width / canvas().width * 100 + '%';
hit.style.height = itemBox.height / canvas().height * 100 + '%';
var label = document.createElement('span');
label.className = 'designer-rect-label';
label.textContent = item.label || item.region_key;
hit.appendChild(label);
if (item.region_key === selectedKey) {
['nw', 'ne', 'sw', 'se'].forEach(function (direction) {
var handle = document.createElement('span');
handle.className = 'resize-handle ' + direction;
handle.dataset.direction = direction;
hit.appendChild(handle);
});
}
layer.appendChild(hit);
});
guides.forEach(function (guide) {
var line = document.createElement('div');
line.className = 'layout-alignment-guide layout-alignment-guide--' + guide.axis;
line.style[guide.axis === 'x' ? 'left' : 'top'] = guide.position / canvas()[guide.axis === 'x' ? 'width' : 'height'] * 100 + '%';
layer.appendChild(line);
});
}
function open() {
if (active || !regions().length || !window.pulseModal) return;
baseline = snapshot();
sharedBaseline = helpers.getTemplateLayoutSnapshot(template());
saveStatus.textContent = '';
saveStatus.classList.remove('text-danger');
history = [baseline];
historyIndex = 0;
applied = false;
active = true;
home = stage.parentNode;
nextSibling = stage.nextSibling;
regionSelect.replaceChildren();
regions().forEach(function (region) {
var option = document.createElement('option');
option.value = region.region_key;
option.textContent = region.label || region.region_key;
regionSelect.appendChild(option);
});
workspace.appendChild(stage);
stage.classList.add('slide-layout-stage');
stage.tabIndex = 0;
layer.hidden = false;
var main = document.querySelector('.app-main');
var sidebarEdge = window.innerWidth >= 992 && main ? Math.max(0, main.getBoundingClientRect().left) : 0;
modal.style.setProperty('--slide-layout-left', sidebarEdge + 'px');
render();
window.pulseModal.show(modal);
}
modal.addEventListener('shown.bs.modal', function () {
options.requestPreviewRender();
stage.focus();
});
modal.addEventListener('hidden.bs.modal', function () {
if (!applied) layouts = JSON.parse(baseline);
active = false;
drag = null;
guides = [];
layer.hidden = true;
stage.classList.remove('slide-layout-stage');
stage.removeAttribute('tabindex');
home.insertBefore(stage, nextSibling);
update();
openButton.focus();
});
openButton.addEventListener('click', open);
modal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (button) {
button.removeAttribute('data-bs-dismiss');
button.addEventListener('click', function () { window.pulseModal.hide(modal); });
});
document.getElementById('slide-layout-apply').addEventListener('click', function () {
applied = true;
if (snapshot() !== baseline) options.markEdited();
hidden.value = snapshot();
window.pulseModal.hide(modal);
});
regionSelect.addEventListener('change', function () {
selectedKey = regionSelect.value;
guides = [];
render();
});
function restoreHistory(offset) {
var index = historyIndex + offset;
if (index < 0 || index >= history.length) return;
historyIndex = index;
layouts = JSON.parse(history[index]);
guides = [];
update();
}
undoButton.addEventListener('click', function () { restoreHistory(-1); });
redoButton.addEventListener('click', function () { restoreHistory(1); });
resetButton.addEventListener('click', function () {
delete layouts[selectedKey];
commit();
update();
});
document.getElementById('slide-layout-reset-all').addEventListener('click', function () {
layouts = Object.create(null);
commit();
update();
});
Object.keys(fields).forEach(function (field) {
fields[field].addEventListener('keydown', function (event) {
if (event.key === 'Enter') { event.preventDefault(); fields[field].blur(); }
});
fields[field].addEventListener('change', function () {
if (field === 'lock_ratio') {
var ratioValue = fields[field].value.trim().replace(/\s+/g, '');
var parts = ratioValue.split(':').map(Number);
var patch = { lock_ratio: ratioValue };
if (/^\d+:\d+$/.test(ratioValue) && parts[0] > 0 && parts[1] > 0) {
var current = effective(selected());
var fitted = helpers.fitLayout({ x: current.x, y: current.y, width: current.width, height: current.width * parts[1] / parts[0] }, canvas(), parts[0] / parts[1], 'se');
Object.assign(patch, fitted);
}
if (!changeSettings(patch)) {
fields[field].setCustomValidity('Enter a positive ratio such as 16:9, or leave blank.');
fields[field].reportValidity();
}
return;
}
if (!selected() || fields[field].value === '' || !Number.isFinite(fields[field].valueAsNumber)) {
render();
return;
}
var box = effective(selected());
box[field] = fields[field].valueAsNumber;
if (field === 'z_index') { changeSettings({ z_index: box.z_index }); return; }
var ratio = String(box.lock_ratio || '').split(':').map(Number);
if (ratio[0] > 0 && ratio[1] > 0) {
if (field === 'width') box.height = Math.round(box.width * ratio[1] / ratio[0]);
if (field === 'height') box.width = Math.round(box.height * ratio[0] / ratio[1]);
}
change(helpers.fitLayout(box, canvas(), ratio[0] / ratio[1], 'se'));
commit();
render();
});
});
stage.addEventListener('pointerdown', function (event) {
if (!active || sharedSaving || event.button !== 0) return;
var hit = event.target.closest('.slide-layout-hit');
if (!hit) return;
event.preventDefault();
selectedKey = hit.dataset.regionKey;
drag = {
pointerId: event.pointerId,
startX: event.clientX, startY: event.clientY,
box: effective(selected()),
direction: event.target.dataset.direction || '',
snapshot: snapshot()
};
stage.setPointerCapture(event.pointerId);
stage.focus({ preventScroll: true });
render();
});
stage.addEventListener('pointermove', function (event) {
if (sharedSaving || !drag || event.pointerId !== drag.pointerId) return;
var bounds = stage.getBoundingClientRect();
var dx = (event.clientX - drag.startX) * canvas().width / bounds.width;
var dy = (event.clientY - drag.startY) * canvas().height / bounds.height;
if (Math.abs(event.clientX - drag.startX) + Math.abs(event.clientY - drag.startY) < 2) return;
var box = Object.assign({}, drag.box);
var direction = drag.direction;
var aspect = 0;
if (direction) {
if (direction.includes('w')) { box.x += dx; box.width -= dx; }
if (direction.includes('e')) box.width += dx;
if (direction.includes('n')) { box.y += dy; box.height -= dy; }
if (direction.includes('s')) box.height += dy;
var ratio = String(effective(selected()).lock_ratio || '').split(':').map(Number);
if (ratio[0] > 0 && ratio[1] > 0) {
aspect = ratio[0] / ratio[1];
if (Math.abs(dy * aspect) > Math.abs(dx)) box.width = Math.max(1, box.height * aspect);
box.height = Math.max(1, box.width / aspect);
if (direction.includes('w')) box.x = drag.box.x + drag.box.width - box.width;
if (direction.includes('n')) box.y = drag.box.y + drag.box.height - box.height;
}
box = helpers.fitLayout(box, canvas(), aspect, direction);
} else {
box.x = Math.max(0, Math.min(canvas().width - box.width, box.x + dx));
box.y = Math.max(0, Math.min(canvas().height - box.height, box.y + dy));
}
var peers = regions().filter(function (region) { return region.region_key !== selectedKey; }).map(effective);
var snapped = helpers.snapBox(box, peers, canvas(), snapInput.checked && !event.altKey ? 6 * canvas().width / bounds.width : -1, direction, aspect);
guides = snapped.guides;
change(snapped.box);
});
function endDrag(event) {
if (!drag || event.pointerId !== drag.pointerId) return;
if (event.type === 'pointercancel') layouts = JSON.parse(drag.snapshot);
if (stage.hasPointerCapture(event.pointerId)) stage.releasePointerCapture(event.pointerId);
drag = null;
guides = [];
commit();
update();
}
stage.addEventListener('pointerup', endDrag);
stage.addEventListener('pointercancel', endDrag);
stage.addEventListener('keydown', function (event) {
if (!active || sharedSaving) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault();
restoreHistory(event.shiftKey ? 1 : -1);
return;
}
var hit = event.target.closest('.slide-layout-hit');
if (hit) selectedKey = hit.dataset.regionKey;
if (!selected()) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
stage.focus();
render();
return;
}
var movements = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
var movement = movements[event.key];
if (!movement) return;
event.preventDefault();
var box = effective(selected());
var step = event.shiftKey ? 10 : 1;
box.x = Math.max(0, Math.min(canvas().width - box.width, box.x + movement[0] * step));
box.y = Math.max(0, Math.min(canvas().height - box.height, box.y + movement[1] * step));
change(box);
commit();
render();
stage.focus();
});
return {
apply: effective,
refresh: render,
getAnimationOverrides: function () {
var overrides = Object.create(null);
Object.keys(layouts).forEach(function (key) {
if (layouts[key] && layouts[key].animation_json !== undefined) overrides[key] = layouts[key].animation_json;
});
return overrides;
},
getAnimation: function (regionKey) {
var region = regions().find(function (item) { return item.region_key === regionKey; });
var override = layouts[regionKey] && layouts[regionKey].animation_json;
return override !== undefined ? override : String(region && region.animation_json || '{}');
},
setAnimationOverrides: function (overrides) {
regions().forEach(function (region) {
var key = region.region_key;
var current = layouts[key] ? Object.assign({}, layouts[key]) : {};
delete current.animation_json;
if (Object.prototype.hasOwnProperty.call(overrides, key)) current.animation_json = overrides[key];
if (Object.keys(current).length) layouts[key] = helpers.normalizeLayout(current, canvas());
else delete layouts[key];
});
commit();
update();
},
reset: function (content) {
layouts = Object.create(null);
regions().forEach(function (region) {
var layout = helpers.normalizeLayout(content && content[region.region_key] && content[region.region_key].layout, canvas());
if (layout) layouts[region.region_key] = layout;
});
hidden.value = snapshot();
render();
}
};
}
+44 -139
View File
@@ -20,6 +20,8 @@
: (Array.isArray(templateData) ? templateData : []); : (Array.isArray(templateData) ? templateData : []);
var stage = document.getElementById('designer-stage'); var stage = document.getElementById('designer-stage');
var overlay = document.getElementById('designer-overlay'); var overlay = document.getElementById('designer-overlay');
var snapInput = document.getElementById('template-layout-snap');
var alignmentGuides = [];
var previewCard = document.getElementById('template-preview-card'); var previewCard = document.getElementById('template-preview-card');
var previewIntroButton = document.getElementById('template-preview-intro'); var previewIntroButton = document.getElementById('template-preview-intro');
var previewOutButton = document.getElementById('template-preview-out'); var previewOutButton = document.getElementById('template-preview-out');
@@ -557,104 +559,19 @@
} }
function getAnimationConfig(card) { function getAnimationConfig(card) {
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]'); return window.pulseRegionAnimationControls.read(card);
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
if (introPresetInput && introPresetInput.dataset.animationPresetCustom !== '1' && introPresetInput.selectedIndex >= 0) {
animationConfig.intro.preset = normalizePreviewAnimation(introPresetInput.value);
}
if (outroPresetInput && outroPresetInput.dataset.animationPresetCustom !== '1' && outroPresetInput.selectedIndex >= 0) {
animationConfig.outro.preset = normalizePreviewAnimation(outroPresetInput.value);
}
if (loopPresetInput && loopPresetInput.dataset.animationPresetCustom !== '1' && loopPresetInput.selectedIndex >= 0) {
animationConfig.loop.preset = normalizePreviewAnimation(loopPresetInput.value);
}
return animationConfig;
}
function syncAnimationPresetCustomState(selectInput, resetButton, preset) {
if (!selectInput) {
return;
}
var normalizedPreset = normalizePreviewAnimation(preset);
var hasMatchingOption = Array.prototype.some.call(selectInput.options || [], function (option) {
return option && option.value === normalizedPreset;
});
if (hasMatchingOption) {
delete selectInput.dataset.animationPresetCustom;
selectInput.hidden = false;
if (resetButton) {
resetButton.hidden = true;
}
} else {
selectInput.dataset.animationPresetCustom = '1';
selectInput.hidden = true;
if (resetButton) {
resetButton.hidden = false;
resetButton.textContent = 'Reset to basic';
}
}
} }
function writeAnimationConfig(card, animationConfig) { function writeAnimationConfig(card, animationConfig) {
var normalized = normalizeAnimationConfig(animationConfig); var normalized = window.pulseRegionAnimationControls.write(card, animationConfig);
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
var introResetButton = card.querySelector('[data-animation-reset-button="intro"]');
var outroResetButton = card.querySelector('[data-animation-reset-button="outro"]');
var loopResetButton = card.querySelector('[data-animation-reset-button="loop"]');
if (hiddenAnimationInput) {
hiddenAnimationInput.value = JSON.stringify(normalized, null, 2);
}
if (introPresetInput) {
introPresetInput.value = normalized.intro.preset;
syncAnimationPresetCustomState(introPresetInput, introResetButton, normalized.intro.preset);
}
if (outroPresetInput) {
outroPresetInput.value = normalized.outro.preset;
syncAnimationPresetCustomState(outroPresetInput, outroResetButton, normalized.outro.preset);
}
if (loopPresetInput) {
loopPresetInput.value = normalized.loop.preset;
syncAnimationPresetCustomState(loopPresetInput, loopResetButton, normalized.loop.preset);
}
updatePreviewCardVisibility(); updatePreviewCardVisibility();
return normalized; return normalized;
} }
function setAnimationPreset(card, stepName, preset) { function setAnimationPreset(card, stepName, preset) {
var config = getAnimationConfig(card); window.pulseRegionAnimationControls.setPreset(card, stepName, preset);
if (config[stepName]) { updatePreviewCardVisibility();
config[stepName].preset = normalizePreviewAnimation(preset); if (activeAnimationCard === card) populateAnimationModal(card);
config[stepName].duration_ms = null;
config[stepName].delay_ms = null;
config[stepName].iterations = null;
config[stepName].easing = '';
config[stepName].fill_mode = '';
writeAnimationConfig(card, config);
if (activeAnimationCard === card) {
populateAnimationModal(card);
}
}
}
function getAnimationModalField(stepName, fieldName) {
if (!animationAdvancedModal) {
return null;
}
return animationAdvancedModal.querySelector('[data-animation-modal-step="' + stepName + '"][data-animation-modal-field="' + fieldName + '"]');
} }
function populateAnimationModal(card) { function populateAnimationModal(card) {
@@ -670,20 +587,7 @@
title.textContent = (label ? label.textContent : 'Region') + ' animation settings'; title.textContent = (label ? label.textContent : 'Region') + ' animation settings';
} }
['intro', 'outro', 'loop'].forEach(function (stepName) { window.pulseRegionAnimationControls.populateAdvanced(animationAdvancedModal, config);
var step = config[stepName] || normalizeAnimationStep({}, 'none');
var presetInput = getAnimationModalField(stepName, 'preset');
var durationInput = getAnimationModalField(stepName, 'duration_ms');
var delayInput = getAnimationModalField(stepName, 'delay_ms');
if (presetInput) { presetInput.value = step.preset; }
if (durationInput) { durationInput.value = String(getAnimationDurationPercent(step.duration_ms)); }
if (delayInput) { delayInput.value = step.delay_ms === null || step.delay_ms === undefined ? '0' : String(step.delay_ms); }
if (stepName === 'loop') {
var iterationsInput = getAnimationModalField(stepName, 'iterations');
if (iterationsInput) { iterationsInput.value = step.iterations === null || step.iterations === undefined ? '1' : String(Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, Number(step.iterations) || 1))); }
}
});
} }
function readNumberField(value) { function readNumberField(value) {
@@ -719,45 +623,12 @@
} }
} }
function getAnimationDurationPercent(durationMs) {
var value = Number(durationMs);
if (!Number.isFinite(value) || value <= 0) {
return 100;
}
return Math.min(200, Math.max(1, Math.round(value / 10)));
}
function getAnimationDurationMs(durationPercent) {
var percent = Math.min(200, Math.max(1, readNumberField(durationPercent) || 100));
return percent * 10;
}
function readAnimationModalConfig() { function readAnimationModalConfig() {
if (!animationAdvancedModal) { if (!animationAdvancedModal) {
return null; return null;
} }
var config = getAnimationConfig(activeAnimationCard) || { intro: {}, outro: {}, loop: {} }; return window.pulseRegionAnimationControls.readAdvanced(animationAdvancedModal, getAnimationConfig(activeAnimationCard));
['intro', 'outro', 'loop'].forEach(function (stepName) {
var presetInput = getAnimationModalField(stepName, 'preset');
var durationInput = getAnimationModalField(stepName, 'duration_ms');
var delayInput = getAnimationModalField(stepName, 'delay_ms');
var nextStep = Object.assign({}, config[stepName] || {});
nextStep.preset = presetInput ? presetInput.value : nextStep.preset;
nextStep.duration_ms = getAnimationDurationMs(durationInput ? durationInput.value : '');
nextStep.delay_ms = readNumberField(delayInput ? delayInput.value : '') || 0;
if (stepName === 'loop') {
var iterationsInput = getAnimationModalField(stepName, 'iterations');
nextStep.iterations = Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, readNumberField(iterationsInput ? iterationsInput.value : '') || 1));
}
config[stepName] = normalizeAnimationStep(nextStep, 'none');
});
return config;
} }
function saveAnimationModal() { function saveAnimationModal() {
@@ -1475,6 +1346,9 @@
var selected = index === selectedNow ? ' selected' : ''; var selected = index === selectedNow ? ' selected' : '';
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="--designer-overlay-scale:1;left:' + renderedBox.left + 'px;top:' + renderedBox.top + 'px;width:' + renderedBox.width + 'px;height:' + renderedBox.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>'; return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="--designer-overlay-scale:1;left:' + renderedBox.left + 'px;top:' + renderedBox.top + 'px;width:' + renderedBox.width + 'px;height:' + renderedBox.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
}).join(''); }).join('');
alignmentGuides.forEach(function (guide) {
overlay.innerHTML += '<div class="layout-alignment-guide layout-alignment-guide--' + guide.axis + '" style="' + (guide.axis === 'x' ? 'left' : 'top') + ':' + (guide.position / canvasSize[guide.axis === 'x' ? 'width' : 'height'] * 100) + '%;"></div>';
});
if (draft) { if (draft) {
var rect = overlayRect; var rect = overlayRect;
var size = canvasSize; var size = canvasSize;
@@ -1633,6 +1507,15 @@
document.addEventListener('mouseup', upHandler); document.addEventListener('mouseup', upHandler);
} }
function snapRegion(region, index, event, direction, aspect) {
var size = getCanvasSize();
var scale = getOverlayRect().width / size.width;
var peers = getCards().filter(function (_card, peerIndex) { return peerIndex !== index; }).map(readCard);
var snapped = window.pulseRegionLayout.snapBox(region, peers, size, snapInput.checked && !event.altKey ? 6 / Math.max(0.01, scale) : -1, direction, aspect && aspect.ratio);
alignmentGuides = snapped.guides;
return snapped.box;
}
function startMove(index, event) { function startMove(index, event) {
if (previewState.active) { if (previewState.active) {
return; return;
@@ -1649,11 +1532,13 @@
var maxY = Math.max(0, size.height - startRegion.height); var maxY = Math.max(0, size.height - startRegion.height);
var nextX = clamp(startRegion.x + dx, 0, maxX); var nextX = clamp(startRegion.x + dx, 0, maxX);
var nextY = clamp(startRegion.y + dy, 0, maxY); var nextY = clamp(startRegion.y + dy, 0, maxY);
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height }); writeCard(cardAt(index), snapRegion({ x: nextX, y: nextY, width: startRegion.width, height: startRegion.height }, index, moveEvent));
markTemplateFormDirty(); markTemplateFormDirty();
requestOverlayRender(); requestOverlayRender();
} }
function upHandler() { function upHandler() {
alignmentGuides = [];
requestOverlayRender();
document.removeEventListener('mousemove', moveHandler); document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler); document.removeEventListener('mouseup', upHandler);
} }
@@ -1755,11 +1640,14 @@
if (next.width < 1) { if (dir.indexOf('w') !== -1) { next.x -= 1 - next.width; } next.width = 1; } if (next.width < 1) { if (dir.indexOf('w') !== -1) { next.x -= 1 - next.width; } next.width = 1; }
if (next.height < 1) { if (dir.indexOf('n') !== -1) { next.y -= 1 - next.height; } next.height = 1; } if (next.height < 1) { if (dir.indexOf('n') !== -1) { next.y -= 1 - next.height; } next.height = 1; }
next = fitRegionToCanvas(next, aspect); next = fitRegionToCanvas(next, aspect);
next = snapRegion(next, index, moveEvent, dir, aspect);
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height }); writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
markTemplateFormDirty(); markTemplateFormDirty();
requestOverlayRender(); requestOverlayRender();
} }
function upHandler() { function upHandler() {
alignmentGuides = [];
requestOverlayRender();
document.removeEventListener('mousemove', moveHandler); document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler); document.removeEventListener('mouseup', upHandler);
} }
@@ -1931,12 +1819,14 @@
canvasHeightInput.addEventListener('input', render); canvasHeightInput.addEventListener('input', render);
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); }); regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
overlay.addEventListener('mousedown', function (event) { overlay.addEventListener('mousedown', function (event) {
if (event.button !== 0) return;
var rect = event.target.closest('.designer-rect'); var rect = event.target.closest('.designer-rect');
if (rect) { if (rect) {
var index = Number(rect.getAttribute('data-index')); var index = Number(rect.getAttribute('data-index'));
var handle = event.target.closest('.resize-handle'); var handle = event.target.closest('.resize-handle');
event.preventDefault(); event.preventDefault();
setSelected(index); setSelected(index);
overlay.focus({ preventScroll: true });
if (handle) { if (handle) {
resizeFromHandle(index, handle.getAttribute('data-dir'), event); resizeFromHandle(index, handle.getAttribute('data-dir'), event);
} else { } else {
@@ -1950,6 +1840,21 @@
event.preventDefault(); event.preventDefault();
setSelected(-1); setSelected(-1);
}); });
overlay.addEventListener('keydown', function (event) {
if (previewState.active || selectedIndex < 0 || !cardAt(selectedIndex)) return;
var movements = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
var movement = movements[event.key];
if (!movement) return;
event.preventDefault();
var region = readCard(cardAt(selectedIndex));
var size = getCanvasSize();
var step = event.shiftKey ? 10 : 1;
region.x = clamp(region.x + movement[0] * step, 0, Math.max(0, size.width - region.width));
region.y = clamp(region.y + movement[1] * step, 0, Math.max(0, size.height - region.height));
writeCard(cardAt(selectedIndex), region);
markTemplateFormDirty();
requestOverlayRender();
});
if (templateForm) { if (templateForm) {
templateForm.addEventListener('invalid', function (event) { templateForm.addEventListener('invalid', function (event) {
var target = event.target; var target = event.target;
+175 -1
View File
@@ -8,6 +8,8 @@ const { fetchAppSettings } = require('#src/data/app-settings');
const { convertWeatherSnapshot } = require('#src/data/weather-units'); const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { buildAuditChanges } = require('#src/data/audit-log'); const { buildAuditChanges } = require('#src/data/audit-log');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
const { retainLayoutAnimation, getTemplateAnimationSnapshot } = require('#src/web/public/js/shared/region-layout');
const { fetchMediaAssets, createImageVariants, registerMediaAssets, syncMediaAssetsFromDirectory, publishMediaAssets, removePendingMediaAssets } = require('#src/web/lib/media/library'); const { fetchMediaAssets, createImageVariants, registerMediaAssets, syncMediaAssetsFromDirectory, publishMediaAssets, removePendingMediaAssets } = require('#src/web/lib/media/library');
module.exports = function registerContentRoutes(app, deps) { module.exports = function registerContentRoutes(app, deps) {
@@ -751,7 +753,8 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(404).send('Template not found'); return res.status(404).send('Template not found');
} }
template.region_usage = await fetchTemplateRegionUsage(template); template.region_usage = await fetchTemplateRegionUsage(template);
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0; template.slide_count = (await fetchSlidesByTemplateId(template.id)).length;
template.inUse = template.slide_count > 0;
const sizeData = await common.fetchCanvasSizesData(pool, template.canvas_size_id); const sizeData = await common.fetchCanvasSizesData(pool, template.canvas_size_id);
await syncMediaAssetsFromDirectory(pool, deps.uploadDir); await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = await fetchMediaAssets(pool); const mediaAssets = await fetchMediaAssets(pool);
@@ -762,6 +765,177 @@ module.exports = function registerContentRoutes(app, deps) {
} }
}); });
app.get('/templates/:id/layout', requirePermission('templates.update'), async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) return res.status(404).json({ error: 'Template not found.' });
res.json({ template: template, slideCount: await fetchTemplateSlideCount(template.id) });
} catch (error) {
next(error);
}
});
app.post('/templates/:id/layout', requirePermission('templates.update'), async function (req, res, next) {
let connection;
let transaction = false;
try {
const slideId = req.body && req.body.slideId;
if (slideId !== null && slideId !== undefined) {
if (!Number.isInteger(slideId) || slideId <= 0) return res.status(400).json({ error: 'Invalid slide.' });
if (!hasAnyPermission(req.currentUser, ['slides.update'])) return res.status(403).json({ error: PERMISSION_DENIED_MESSAGE });
}
connection = await pool.getConnection();
await connection.beginTransaction();
transaction = true;
const templateId = Number(req.params.id);
await connection.query('SELECT id FROM c_templates WHERE id = ? FOR UPDATE', [templateId]);
await connection.query('SELECT id FROM c_template_regions WHERE template_id = ? FOR UPDATE', [templateId]);
const template = await common.fetchTemplateById(connection, templateId);
if (!template) {
const error = new Error('Template not found.');
error.statusCode = 404;
throw error;
}
const regions = buildTemplateLayoutUpdate(template, req.body || {});
const actorId = getAuditUserId(req);
if (slideId) {
const [slides] = await connection.query('SELECT id, template_id, content_json FROM c_slides WHERE id = ? FOR UPDATE', [slideId]);
const slide = slides[0];
if (!slide || Number(slide.template_id) !== templateId) {
const error = new Error('The slide no longer uses this template. Reload before saving.');
error.statusCode = 409;
throw error;
}
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json) : slide.content_json || {};
for (const region of regions) {
const entry = content[region.region_key];
if (!entry || typeof entry !== 'object' || !Object.hasOwn(entry, 'layout')) continue;
const animation = retainLayoutAnimation(entry.layout);
if (animation) entry.layout = animation;
else delete entry.layout;
}
await connection.query('UPDATE c_slides SET content_json = ?, modified_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ? AND template_id = ?', [JSON.stringify(content), actorId, slideId, templateId]);
}
for (const region of regions) {
await connection.query(
'UPDATE c_template_regions SET x = ?, y = ?, width = ?, height = ?, z_index = ?, lock_ratio = ?, modified_by = ? WHERE id = ? AND template_id = ?',
[region.x, region.y, region.width, region.height, region.z_index, region.lock_ratio || null, actorId, region.id, templateId]
);
}
await connection.query('UPDATE c_templates SET modified_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ?', [actorId, templateId]);
await connection.commit();
transaction = false;
connection.release();
connection = null;
const slideCount = await fetchTemplateSlideCount(templateId);
if (slideCount > 0) {
queueTemplateSlideThumbnailRefresh(templateId).catch(function (error) {
console.warn('Unable to queue template slide thumbnail refresh:', error);
});
}
await notifyPlayerScreens(await fetchScreensByTemplateId(pool, templateId), 'refresh');
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, {
category: 'templates', eventType: 'template.updated', actorUserId: actorId,
targetType: 'template', targetId: templateId, targetLabel: template.name,
details: { changes: buildAuditChanges(
{ regions: normalizeTemplateRegionsForAudit(template.regions) },
{ regions: normalizeTemplateRegionsForAudit(regions) }
) }
});
res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount });
} catch (error) {
if (transaction) await connection.rollback();
if (error.statusCode) return res.status(error.statusCode).json({ error: error.message });
next(error);
} finally {
if (connection) connection.release();
}
});
app.get('/templates/:id/animations', requirePermission('templates.update'), async function (req, res, next) {
try {
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) return res.status(404).json({ error: 'Template not found.' });
res.json({ template: template, slideCount: await fetchTemplateSlideCount(template.id), baseline: getTemplateAnimationSnapshot(template) });
} catch (error) {
next(error);
}
});
app.post('/templates/:id/animations', requirePermission('templates.update'), async function (req, res, next) {
let connection;
let transaction = false;
try {
const slideId = req.body && req.body.slideId;
if (slideId !== null && slideId !== undefined) {
if (!Number.isInteger(slideId) || slideId <= 0) return res.status(400).json({ error: 'Invalid slide.' });
if (!hasAnyPermission(req.currentUser, ['slides.update'])) return res.status(403).json({ error: PERMISSION_DENIED_MESSAGE });
}
connection = await pool.getConnection();
await connection.beginTransaction();
transaction = true;
const templateId = Number(req.params.id);
await connection.query('SELECT id FROM c_templates WHERE id = ? FOR UPDATE', [templateId]);
await connection.query('SELECT id FROM c_template_regions WHERE template_id = ? FOR UPDATE', [templateId]);
const template = await common.fetchTemplateById(connection, templateId);
if (!template) {
const error = new Error('Template not found.');
error.statusCode = 404;
throw error;
}
const regions = buildTemplateAnimationUpdate(template, req.body || {});
const actorId = getAuditUserId(req);
if (slideId) {
const [slides] = await connection.query('SELECT id, template_id, content_json FROM c_slides WHERE id = ? FOR UPDATE', [slideId]);
const slide = slides[0];
if (!slide || Number(slide.template_id) !== templateId) {
const error = new Error('The slide no longer uses this template. Reload before saving.');
error.statusCode = 409;
throw error;
}
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json) : slide.content_json || {};
for (const region of regions) {
const entry = content[region.region_key];
if (!entry || !entry.layout || typeof entry.layout !== 'object') continue;
delete entry.layout.animation_json;
if (!Object.keys(entry.layout).length) delete entry.layout;
}
await connection.query('UPDATE c_slides SET content_json = ?, modified_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ? AND template_id = ?', [JSON.stringify(content), actorId, slideId, templateId]);
}
for (const region of regions) {
await connection.query('UPDATE c_template_regions SET animation_json = ?, modified_by = ? WHERE id = ? AND template_id = ?', [JSON.stringify(region.animation_json), actorId, region.id, templateId]);
}
await connection.query('UPDATE c_templates SET modified_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ?', [actorId, templateId]);
const changes = buildAuditChanges(
{ regions: normalizeTemplateRegionsForAudit(template.regions) },
{ regions: normalizeTemplateRegionsForAudit(regions) }
);
await connection.commit();
transaction = false;
connection.release();
connection = null;
const slideCount = await fetchTemplateSlideCount(templateId);
if (slideCount > 0) {
queueTemplateSlideThumbnailRefresh(templateId).catch(function (error) {
console.warn('Unable to queue template slide thumbnail refresh:', error);
});
}
await notifyPlayerScreens(await fetchScreensByTemplateId(pool, templateId), 'refresh');
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, {
category: 'templates', eventType: 'template.updated', actorUserId: actorId,
targetType: 'template', targetId: templateId, targetLabel: template.name,
details: { changes: changes }
});
res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount });
} catch (error) {
if (transaction) await connection.rollback();
if (error.statusCode) return res.status(error.statusCode).json({ error: error.message });
next(error);
} finally {
if (connection) connection.release();
}
});
app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) { app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
try { try {
await validateUploadedFiles(req.files || []); await validateUploadedFiles(req.files || []);
@@ -1,6 +1,7 @@
// Shared slide form view-model builder. // Shared slide form view-model builder.
const { getRegionEditorScripts } = require('#src/web/lib/region-scripts'); const { getRegionEditorScripts } = require('#src/web/lib/region-scripts');
const animationPresets = require('#src/web/public/js/templates/animation-presets');
function buildDefaultSlide() { function buildDefaultSlide() {
return { return {
@@ -44,8 +45,10 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
: 'data-async-save data-async-save-close-url="/slides" data-async-save-new-redirect="response-url" data-async-save-new-url="/slides/new"', : 'data-async-save data-async-save-close-url="/slides" data-async-save-new-redirect="response-url" data-async-save-new-url="/slides/new"',
slide: viewSlide, slide: viewSlide,
templates: templates, templates: templates,
animationPresets: animationPresets,
stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [], stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [],
slideEditorData: { slideEditorData: {
slideId: viewSlide.id || null,
templates: templates, templates: templates,
rssFeeds: rssFeeds, rssFeeds: rssFeeds,
apiSources: apiSources, apiSources: apiSources,
@@ -60,7 +63,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
mediaAssets: mediaAssets mediaAssets: mediaAssets
}, },
assetVersion: assetVersion, assetVersion: assetVersion,
slideEditorScripts: ['vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)), slideEditorScripts: getRegionEditorScripts(assetVersion),
currentUser: currentUser || null currentUser: currentUser || null
}; };
} }
@@ -58,7 +58,7 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
showSaveSecondaryActions: true, showSaveSecondaryActions: true,
deleteDisabled: !isEdit || Boolean(current.inUse), deleteDisabled: !isEdit || Boolean(current.inUse),
formAction: isEdit && current.id ? '/templates/' + current.id : '/templates', formAction: isEdit && current.id ? '/templates/' + current.id : '/templates',
formAttrs: 'data-template-has-slides="' + (current.slide_count ? 'true' : 'false') + '" data-async-save data-async-save-close-url="/templates"' + (isEdit ? '' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/templates/new"', formAttrs: 'data-template-has-slides="' + (current.slide_count ? 'true' : 'false') + '" data-template-slide-count="' + (Number(current.slide_count) || 0) + '" data-async-save data-async-save-close-url="/templates"' + (isEdit ? '' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/templates/new"',
cancelUrl: '/templates', cancelUrl: '/templates',
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?", cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '', deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
@@ -67,7 +67,7 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
mediaAssets: Array.isArray(mediaAssets) ? mediaAssets : [], mediaAssets: Array.isArray(mediaAssets) ? mediaAssets : [],
imageUploadMaxBytes: Number(imageUploadMaxBytes) || 100 * 1024 * 1024, imageUploadMaxBytes: Number(imageUploadMaxBytes) || 100 * 1024 * 1024,
animationPresets: animationPresets, animationPresets: animationPresets,
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion]) scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/shared/region-layout.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/shared/region-animation-controls.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
}; };
} }
+10 -22
View File
@@ -1,23 +1,11 @@
<div class="card border-0 shadow-sm mx-auto" style="max-width: 48rem;"> <section class="error-page-content text-center">
<div class="card-body p-4 p-md-5"> <p class="display-1 fw-bold text-{{statusVariant}} mb-3" aria-label="Error {{statusCode}}">{{statusCode}}</p>
<div class="d-flex align-items-start gap-3"> <h1 class="h2 mb-3">{{errorTitle}}</h1>
<div class="fs-1 text-danger"> <p class="text-body-secondary mb-4">{{errorMessage}}</p>
<i class="bi bi-exclamation-triangle-fill"></i> <div class="d-flex justify-content-center gap-2 flex-wrap">
</div> {{#if searchUrl}}
<div> <a class="btn btn-primary" href="{{searchUrl}}"><i class="bi bi-search me-2" aria-hidden="true"></i>Search</a>
<p class="text-uppercase text-muted fw-semibold mb-1">{{statusCode}}</p> {{/if}}
<h1 class="h3 mb-3">{{errorTitle}}</h1> <a class="btn btn-outline-secondary" href="{{backUrl}}"><i class="bi bi-arrow-left me-2" aria-hidden="true"></i>{{backLabel}}</a>
<p class="mb-4">{{errorMessage}}</p>
{{#if detail}}
<pre class="small bg-body-tertiary border rounded p-3 mb-4 text-wrap">{{detail}}</pre>
{{/if}}
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-primary" href="{{backUrl}}">{{backLabel}}</a>
{{#if searchUrl}}
<a class="btn btn-outline-secondary" href="{{searchUrl}}">Search</a>
{{/if}}
</div>
</div>
</div>
</div> </div>
</div> </section>
+87 -1
View File
@@ -8,6 +8,7 @@
<div class="slide-editor-shell"> <div class="slide-editor-shell">
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" /> <link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
<form method="post" action="{{action}}" enctype="multipart/form-data" id="slide-form" class="slide-form-stack" {{{formAttrs}}}> <form method="post" action="{{action}}" enctype="multipart/form-data" id="slide-form" class="slide-form-stack" {{{formAttrs}}}>
<input type="hidden" id="slide-layouts-json" name="region_layouts_json" value="{}" />
<div class="card card-outline card-primary admin-form-card slide-template-card"> <div class="card card-outline card-primary admin-form-card slide-template-card">
<div class="card-header bg-body-tertiary"> <div class="card-header bg-body-tertiary">
<h3 class="card-title">Slide details</h3> <h3 class="card-title">Slide details</h3>
@@ -68,12 +69,92 @@
<div class="slide-preview-overlay" id="slide-preview-overlay"></div> <div class="slide-preview-overlay" id="slide-preview-overlay"></div>
</div> </div>
</div> </div>
<button type="button" class="btn btn-outline-secondary w-100 mt-3" id="open-preview-popup">Open popup preview</button> <div class="d-flex justify-content-end gap-2 mt-3">
<button type="button" class="btn btn-outline-secondary flex-grow-1" id="open-preview-popup"><i class="bi bi-box-arrow-up-right me-1" aria-hidden="true"></i>Open popup preview</button>
<button type="button" class="btn btn-outline-primary flex-shrink-0" id="adjust-slide-layout" title="Adjust layout" aria-label="Adjust layout" disabled><i class="bi bi-bounding-box" aria-hidden="true"></i></button>
<button type="button" class="btn btn-outline-secondary flex-shrink-0" id="adjust-slide-animations" title="Adjust animations" aria-label="Adjust animations" disabled><i class="bi bi-stars" aria-hidden="true"></i></button>
</div>
</div> </div>
</aside> </aside>
</div> </div>
</div> </div>
{{#> modal-shell modalId="slide-layout-modal" modalLabelId="slide-layout-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable slide-layout-dialog"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="slide-layout-modal-label">Adjust layout <span class="text-body-secondary fs-6">This slide only</span></h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel layout changes"></button>
</div>
<div class="modal-body slide-layout-body">
<div class="slide-layout-workspace" id="slide-layout-workspace"></div>
<aside class="slide-layout-properties">
<label for="slide-layout-region" class="form-label">Region</label>
<select id="slide-layout-region" class="form-select mb-3"></select>
<div id="slide-layout-fields" class="d-grid gap-3">{{> signage/templates/region-layout-fields slideLayout=true}}</div>
<div class="form-check form-switch my-3"><input type="checkbox" id="slide-layout-snap" class="form-check-input" checked /><label for="slide-layout-snap" class="form-check-label">Snap to guides</label></div>
<div class="btn-group mb-3" role="group" aria-label="Layout history">
<button type="button" id="slide-layout-undo" class="btn btn-outline-secondary" title="Undo" aria-label="Undo"><i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i></button>
<button type="button" id="slide-layout-redo" class="btn btn-outline-secondary" title="Redo" aria-label="Redo"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i></button>
</div>
<button type="button" id="slide-layout-reset" class="btn btn-outline-secondary w-100 mb-2"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Reset region to template</button>
<button type="button" id="slide-layout-reset-all" class="btn btn-outline-secondary w-100"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Reset all to template</button>
</aside>
</div>
<div class="modal-footer">
<span id="slide-layout-save-status" class="small me-auto" role="status" aria-live="polite"></span>
<div class="btn-group dropup" role="group" aria-label="Layout save actions">
<button type="button" class="btn btn-primary" id="slide-layout-apply">Apply to this slide</button>
{{#if (hasPermission currentUser 'templates.update')}}
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false" title="Layout save options" aria-label="Layout save options"></button>
<div class="dropdown-menu dropdown-menu-end">
<button type="button" class="dropdown-item" id="save-layout-to-template">Save layout to shared template</button>
</div>
{{/if}}
</div>
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
</div>
{{/modal-shell}}
{{#> modal-shell modalId="slide-animation-modal" modalLabelId="slide-animation-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable slide-layout-dialog"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="slide-animation-modal-label">Adjust animations <span class="text-body-secondary fs-6">This slide only</span></h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel animation changes"></button>
</div>
<div class="modal-body slide-animation-body">
<section class="slide-animation-preview-panel">
<div id="slide-animation-preview"></div>
</section>
<div class="slide-animation-controls">
<div class="btn-group w-100 mb-3" role="group" aria-label="Animation preview">
<button type="button" class="btn btn-outline-secondary" data-slide-animation-preview="intro" aria-pressed="false"><i class="bi bi-play-fill me-1" aria-hidden="true" data-animation-preview-icon></i>Intro</button>
<button type="button" class="btn btn-outline-secondary" data-slide-animation-preview="outro" aria-pressed="false"><i class="bi bi-play-fill me-1" aria-hidden="true" data-animation-preview-icon></i>Outro</button>
<button type="button" class="btn btn-outline-secondary" data-slide-animation-preview="loop" aria-pressed="false"><i class="bi bi-play-fill me-1" aria-hidden="true" data-animation-preview-icon></i>Attention</button>
</div>
<label for="slide-animation-region" class="form-label">Region</label>
<select id="slide-animation-region" class="form-select mb-3"></select>
<div id="slide-animation-fields" class="d-grid gap-3">
{{> signage/templates/region-animation-controls}}
<div id="slide-animation-advanced" class="d-grid gap-3" hidden>
<h3 class="h6 mb-0">Advanced animation settings</h3>
<div class="slide-animation-advanced-fields" data-animation-advanced-fields>{{> signage/templates/animation-advanced-fields}}</div>
</div>
</div>
<button type="button" id="slide-animation-reset-region" class="btn btn-outline-secondary w-100 mt-3"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Reset region animations to template</button>
<button type="button" id="slide-animation-reset-all" class="btn btn-outline-secondary w-100 mt-2"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Reset all animations to template</button>
</div>
</div>
<div class="modal-footer">
<span id="slide-animation-save-status" class="small me-auto" role="status" aria-live="polite"></span>
<div class="btn-group dropup" role="group" aria-label="Animation save actions">
<button type="button" class="btn btn-primary" id="slide-animation-apply">Apply to this slide</button>
{{#if (hasPermission currentUser 'templates.update')}}
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false" title="Animation save options" aria-label="Animation save options"></button>
<div class="dropdown-menu dropdown-menu-end"><button type="button" class="dropdown-item" id="save-animations-to-template">Save animations to shared template</button></div>
{{/if}}
</div>
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
</div>
{{/modal-shell}}
{{#> modal-shell modalId="slide-image-cropper-modal" modalLabelId="slide-image-cropper-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}} {{#> modal-shell modalId="slide-image-cropper-modal" modalLabelId="slide-image-cropper-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header"> <div class="modal-header">
<div> <div>
@@ -125,6 +206,11 @@
{{/if}} {{/if}}
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea> <textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
<script src="/assets/js/lib/modal.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/region-layout.js?v={{appVersion}}"></script>
<script src="/assets/js/templates/animation-presets.js?v={{appVersion}}"></script>
<script src="/assets/js/templates/template-designer-utils.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/region-animation-controls.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/qr-code-svg.js?v={{appVersion}}"></script> <script src="/assets/js/shared/qr-code-svg.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/upload-utils.js?v={{appVersion}}"></script> <script src="/assets/js/shared/upload-utils.js?v={{appVersion}}"></script>
<script src="/assets/vendor/cropperjs/cropper.min.js"></script> <script src="/assets/vendor/cropperjs/cropper.min.js"></script>
@@ -0,0 +1,33 @@
<div class="row g-3">
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent"><strong>Intro</strong></div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">Preset<select class="form-select" data-animation-modal-step="intro" data-animation-modal-field="preset">{{#each animationPresets.advanced.intro}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<label class="d-grid gap-1">Duration %<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="intro" data-animation-modal-field="duration_ms" placeholder="100" /></label>
<label class="d-grid gap-1">Delay ms<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="delay_ms" placeholder="0" /></label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent"><strong>Outro</strong></div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">Preset<select class="form-select" data-animation-modal-step="outro" data-animation-modal-field="preset">{{#each animationPresets.advanced.outro}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<label class="d-grid gap-1">Duration %<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="outro" data-animation-modal-field="duration_ms" placeholder="100" /></label>
<label class="d-grid gap-1">Delay ms<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="delay_ms" placeholder="0" /></label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent"><strong>Attention seekers</strong></div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">Preset<select class="form-select" data-animation-modal-step="loop" data-animation-modal-field="preset">{{#each animationPresets.advanced.attentionSeekers}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<label class="d-grid gap-1">Duration %<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="loop" data-animation-modal-field="duration_ms" placeholder="100" /></label>
<label class="d-grid gap-1">Delay ms<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="delay_ms" placeholder="0" /></label>
<label class="d-grid gap-1">Repeat<input class="form-control" type="number" min="1" max="999" step="1" data-animation-modal-step="loop" data-animation-modal-field="iterations" placeholder="1" /></label>
</div>
</div>
</div>
</div>
@@ -6,89 +6,7 @@
</div> </div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body d-grid gap-4"> <div class="modal-body d-grid gap-4">{{> signage/templates/animation-advanced-fields}}</div>
<div class="row g-3">
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Intro</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="intro" data-animation-modal-field="preset">
{{#each animationPresets.advanced.intro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration %
<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="intro" data-animation-modal-field="duration_ms" placeholder="100" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Outro</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="outro" data-animation-modal-field="preset">
{{#each animationPresets.advanced.outro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration %
<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="outro" data-animation-modal-field="duration_ms" placeholder="100" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Attention seekers</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="loop" data-animation-modal-field="preset">
{{#each animationPresets.advanced.attentionSeekers}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration %
<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="loop" data-animation-modal-field="duration_ms" placeholder="100" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
<label class="d-grid gap-1">
Repeat
<input class="form-control" type="number" min="1" max="999" step="1" data-animation-modal-step="loop" data-animation-modal-field="iterations" placeholder="1" />
</label>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer justify-content-between"> <div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-animation-modal-reset>Reset to defaults</button> <button type="button" class="btn btn-outline-secondary" data-animation-modal-reset>Reset to defaults</button>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
+4 -57
View File
@@ -16,15 +16,16 @@
<div class="template-designer-layout"> <div class="template-designer-layout">
<div class="card card-outline card-secondary admin-form-card mb-3"> <div class="card card-outline card-secondary admin-form-card mb-3">
<div class="card-header bg-body-tertiary"> <div class="card-header bg-body-tertiary d-flex align-items-center justify-content-between template-designer-main-header">
<h3 class="card-title">Designer</h3> <h3 class="card-title">Designer</h3>
<div class="form-check form-switch template-snap-control mb-0 ms-auto"><label for="template-layout-snap" class="form-check-label">Snap to guides</label><input type="checkbox" id="template-layout-snap" class="form-check-input" checked /></div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="template-designer-main"> <div class="template-designer-main">
<div class="designer-stage" id="designer-stage" style="aspect-ratio: {{template.canvas_size_width}} / {{template.canvas_size_height}};"> <div class="designer-stage" id="designer-stage" style="aspect-ratio: {{template.canvas_size_width}} / {{template.canvas_size_height}};">
<img id="background-preview" src="{{template.background_image_path}}" alt="" style="{{#if template.background_image_path}}{{else}}display:none;{{/if}}" /> <img id="background-preview" src="{{template.background_image_path}}" alt="" style="{{#if template.background_image_path}}{{else}}display:none;{{/if}}" />
<div class="empty-bg" id="background-empty" style="opacity:0.4;{{#if template.background_image_path}}display:none;{{/if}}">No background image selected</div> <div class="empty-bg" id="background-empty" style="opacity:0.4;{{#if template.background_image_path}}display:none;{{/if}}">No background image selected</div>
<div class="designer-overlay" id="designer-overlay"></div> <div class="designer-overlay" id="designer-overlay" tabindex="0" role="group" aria-label="Template layout"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -168,61 +169,7 @@
<input type="hidden" name="region_type[]" value="text" /> <input type="hidden" name="region_type[]" value="text" />
<input type="hidden" name="region_key[]" value="" /> <input type="hidden" name="region_key[]" value="" />
<input type="hidden" name="region_label[]" value="" /> <input type="hidden" name="region_label[]" value="" />
<div class="region-field-grid region-field-grid--position"> {{> signage/templates/region-layout-fields}}
<label>X<input class="form-control" type="number" name="region_x[]" value="80" required /></label>
<label>Y<input class="form-control" type="number" name="region_y[]" value="80" required /></label>
<label>Z-Index<input class="form-control" type="number" name="region_z[]" min="1" step="1" value="1" required /></label>
</div>
<div class="region-field-grid region-field-grid--size">
<label>Width<input class="form-control" type="number" name="region_width[]" min="1" step="1" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" min="1" step="1" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" maxlength="20" data-limit-text-length placeholder="Ratio, e.g. 16:9" /></label>
<label>Remove<button type="button" class="btn btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>
</div>
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>
Intro
<select class="form-select" data-animation-preset="intro">
{{#each animationPresets.basic.intro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="intro" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1">
<label>
Outro
<select class="form-select" data-animation-preset="outro">
{{#each animationPresets.basic.outro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="outro" hidden>Reset to basic</button>
</div>
</div>
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>
Attention seekers
<select class="form-select" data-animation-preset="loop">
{{#each animationPresets.basic.attentionSeekers}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="loop" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1">
<label>
Advanced
<button type="button" class="btn btn-outline-secondary w-100" data-animation-advanced-button>Advanced</button>
</label>
</div>
</div>
<textarea class="visually-hidden" name="region_animation_json[]" data-animation-json-input rows="1"></textarea>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,18 @@
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>Intro<select class="form-select" data-animation-preset="intro">{{#each animationPresets.basic.intro}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="intro" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1">
<label>Outro<select class="form-select" data-animation-preset="outro">{{#each animationPresets.basic.outro}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="outro" hidden>Reset to basic</button>
</div>
</div>
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>Attention seekers<select class="form-select" data-animation-preset="loop">{{#each animationPresets.basic.attentionSeekers}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="loop" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1"><label>Advanced<button type="button" class="btn btn-outline-secondary w-100" data-animation-advanced-button aria-expanded="false">Advanced</button></label></div>
</div>
<textarea class="visually-hidden" name="region_animation_json[]" data-animation-json-input rows="1"></textarea>
@@ -0,0 +1,14 @@
<div class="region-field-grid region-field-grid--position">
<label>X<input class="form-control" type="number" name="region_x[]" value="80" required /></label>
<label>Y<input class="form-control" type="number" name="region_y[]" value="80" required /></label>
<label>Z-Index<input class="form-control" type="number" name="region_z[]" min="1" step="1" value="1" required /></label>
</div>
<div class="region-field-grid region-field-grid--size">
<label>Width<input class="form-control" type="number" name="region_width[]" min="1" step="1" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" min="1" step="1" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" maxlength="20" data-limit-text-length placeholder="Ratio, e.g. 16:9" /></label>
{{#unless slideLayout}}<label>Remove<button type="button" class="btn btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>{{/unless}}
</div>
{{#unless slideLayout}}
{{> signage/templates/region-animation-controls}}
{{/unless}}
+484
View File
@@ -0,0 +1,484 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { normalizeLayout, applyLayout, snapBox } = require('../src/web/public/js/shared/region-layout');
const fs = require('node:fs');
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 { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('../src/data/templates');
const { getTemplateLayoutSnapshot, getTemplateAnimationSnapshot, retainLayoutAnimation } = require('../src/web/public/js/shared/region-layout');
const canvas = { width: 1920, height: 1080 };
test('shared layout updates preserve region identity and animations and reject stale edits', function () {
const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{
id: 11, region_key: 'hero', region_type: 'text', label: 'Hero',
x: 20, y: 30, width: 400, height: 120, z_index: 2, lock_ratio: null,
animation_json: JSON.stringify({ intro: { preset: 'fadeIn' } })
}] };
const body = {
baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 200, y: 300, width: 600, height: 200, z_index: 4, lock_ratio: '3:1', animation_json: '{}' }]
};
const updated = buildTemplateLayoutUpdate(template, body);
assert.equal(updated[0].x, 200);
assert.equal(updated[0].id, 11);
assert.equal(updated[0].region_type, 'text');
assert.equal(updated[0].animation_json, template.regions[0].animation_json);
assert.equal(template.regions[0].x, 20);
assert.throws(() => buildTemplateLayoutUpdate(template, { ...body, baseline: {} }), { statusCode: 409 });
assert.throws(() => buildTemplateLayoutUpdate(template, { ...body, regions: [] }), { statusCode: 400 });
assert.throws(() => buildTemplateLayoutUpdate(template, { ...body, regions: [{ ...body.regions[0], region_key: 'unknown' }] }), { statusCode: 400 });
});
test('slide layout overrides do not mutate templates or sibling slides', function () {
const region = { region_key: 'title', x: 20, y: 30, width: 400, height: 120 };
const content = { layout: { x: 200, y: 100, width: 600, height: 150 } };
assert.deepEqual(applyLayout(region, content, canvas), { ...region, ...content.layout });
assert.deepEqual(applyLayout(region, {}, canvas), region);
assert.equal(region.x, 20);
assert.equal(region.width, 400);
});
test('layout geometry rejects invalid fields and clamps overrides to the canvas', function () {
assert.equal(normalizeLayout({ x: '10', y: 0, width: 10, height: 20 }, canvas), null);
assert.equal(normalizeLayout({ x: NaN, y: 0, width: 10, height: 20 }, canvas), null);
assert.equal(normalizeLayout({ x: 1, y: 2 }, canvas), null);
assert.deepEqual(normalizeLayout({ x: -10, y: 1000, width: 9999, height: 200 }, canvas), {
x: 0, y: 1000, width: 1920, height: 80
});
});
test('region overrides retain stacking, ratio and complete animation settings', async function () {
const animation = { intro: { preset: 'fadeIn', duration_ms: 1200, delay_ms: 250 }, outro: { preset: 'zoomOut' }, loop: { preset: 'pulse', iterations: 4 } };
const layout = { x: 200, y: 300, width: 500, height: 100, z_index: 8, lock_ratio: '5:1', animation_json: animation };
const legacyLayout = { ...layout, animation_json: JSON.stringify(animation) };
assert.deepEqual(normalizeLayout(layout, canvas), layout);
assert.deepEqual(normalizeLayout(legacyLayout, canvas), layout);
const region = { x: 0, y: 0, width: 100, height: 100, z_index: 1, lock_ratio: '', animation_json: '{}' };
const applied = applyLayout(region, { layout: legacyLayout }, canvas);
assert.equal(applied.z_index, 8);
assert.deepEqual(applied.animation_json, animation);
assert.equal(region.z_index, 1);
const payload = await buildSlidePayload(createPool(), { body: {
title: 'Full settings', template_id: '7', region_layouts_json: JSON.stringify({ hero: legacyLayout })
} }, null);
assert.deepEqual(JSON.parse(payload.contentJson).hero.layout, layout);
assert.equal(normalizeLayout({ ...layout, lock_ratio: '1:0' }, canvas), null);
assert.equal(normalizeLayout({ ...layout, animation_json: 'broken' }, canvas), null);
});
test('moving regions snaps to canvas centers and neighboring edges', function () {
const centered = snapBox({ x: 858, y: 102, width: 200, height: 80 }, [], canvas, 5);
assert.equal(centered.box.x, 860);
assert.deepEqual(centered.guides, [{ axis: 'x', position: 960 }]);
const aligned = snapBox({ x: 397, y: 301, width: 100, height: 90 }, [
{ x: 100, y: 200, width: 300, height: 100 }
], canvas, 5);
assert.equal(aligned.box.x, 400);
assert.equal(aligned.box.y, 300);
});
test('dragging template regions ignores nullable non-geometric metadata', function () {
const region = {
x: 858, y: 100, width: 200, height: 80,
z_index: 2, lock_ratio: null, animation_json: null
};
const moved = snapBox(region, [], canvas, 5);
assert.deepEqual(moved.box, { x: 860, y: 100, width: 200, height: 80 });
assert.deepEqual(moved.guides, [{ axis: 'x', position: 960 }]);
assert.equal(region.animation_json, null);
const resized = snapBox({ ...region, width: 1058 }, [], canvas, 5, 'se');
assert.equal(resized.box.width, 1062);
});
test('resizing snaps only the moving edges and keeps the opposite corner fixed', function () {
const resized = snapBox({ x: 100, y: 100, width: 1817, height: 977 }, [], canvas, 5, 'se');
assert.deepEqual(resized.box, { x: 100, y: 100, width: 1820, height: 980 });
const unchanged = snapBox({ x: 100, y: 100, width: 200, height: 200 }, [], canvas, 0, 'se');
assert.deepEqual(unchanged.box, { x: 100, y: 100, width: 200, height: 200 });
});
test('locked aspect ratios remain intact while snapping and resizing near canvas bounds', function () {
const snapped = snapBox({ x: 100, y: 100, width: 1817, height: 908.5 }, [], canvas, 5, 'se', 2);
assert.equal(snapped.box.width, 1820);
assert.equal(snapped.box.height, 910);
const constrained = snapBox({ x: 100, y: 800, width: 900, height: 900 }, [], canvas, -1, 'se', 1);
assert.deepEqual(constrained.box, { x: 100, y: 800, width: 280, height: 280 });
assert.deepEqual(constrained.guides, []);
});
function createPool(regionType = 'text') {
return {
async query(sql) {
if (sql.includes('FROM c_templates')) return [[{ id: 7, canvas_size_width: 1920, canvas_size_height: 1080 }]];
if (sql.includes('FROM c_template_regions')) return [[{
id: 11, template_id: 7, region_key: 'hero', region_type: regionType,
x: 10, y: 20, width: 300, height: 100
}]];
throw new Error('Unexpected query: ' + sql);
}
};
}
test('slide saves persist overrides, preserve omitted layout fields, and allow explicit reset', async function () {
const layout = { x: 200, y: 300, width: 500, height: 100 };
const body = { title: 'Example', template_id: '7', region_text_11: 'Hello', region_layouts_json: JSON.stringify({ hero: layout }) };
const payload = await buildSlidePayload(createPool(), { body }, null);
assert.deepEqual(JSON.parse(payload.contentJson).hero.layout, layout);
const existing = { template_id: 7, content_json: payload.contentJson };
delete body.region_layouts_json;
const preserved = await buildSlidePayload(createPool(), { body }, existing);
assert.deepEqual(JSON.parse(preserved.contentJson).hero.layout, layout);
body.region_layouts_json = '{}';
const reset = await buildSlidePayload(createPool(), { body }, existing);
assert.equal(Object.hasOwn(JSON.parse(reset.contentJson).hero, 'layout'), false);
body.region_layouts_json = JSON.stringify({ hero: { x: 'invalid' } });
await assert.rejects(buildSlidePayload(createPool(), { body }, existing), { statusCode: 400 });
body.region_layouts_json = '[]';
await assert.rejects(buildSlidePayload(createPool(), { body }, existing), { statusCode: 400 });
});
test('QR content rebuilding retains slide layout overrides', async function () {
const layout = { x: 40, y: 60, width: 200, height: 200 };
const payload = await buildSlidePayload(createPool('qr-code'), { body: {
title: 'QR example', template_id: '7', region_qr_code_11: 'https://example.com',
region_qr_preview_11: 'data:image/png;base64,example',
region_layouts_json: JSON.stringify({ hero: layout })
} }, null);
assert.deepEqual(JSON.parse(payload.contentJson).hero.layout, layout);
});
test('thumbnail markup uses the slide override without changing template geometry', function () {
const region = { region_key: 'hero', region_type: 'image', x: 0, y: 0, width: 1920, height: 1080 };
const html = buildThumbnailPreviewMarkup({
template: { regions: [region], canvas_size_width: 1920, canvas_size_height: 1080 },
content: { hero: { type: 'image', value: '/media/photo.png', layout: { x: 960, y: 540, width: 960, height: 540 } } }
}, 'http://localhost');
assert.match(html, /left:50%;top:50%;width:50%;height:50%/);
assert.equal(region.x, 0);
});
test('player caches separate slide overrides for a shared template', function () {
const context = {
window: { innerWidth: 1920, innerHeight: 1080, pulseRegionLayout: { applyLayout } },
currentPlaylistSignature: 'revision', templateLayoutCache: {}, templateRenderPlanCache: {},
syncRenderCacheViewport() {},
getTemplateCanvasSize() { return canvas; },
fitCanvasSize() { return canvas; }
};
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8'), context);
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8'), context);
const template = { id: 7, regions: [
{
region_key: 'hero', x: 0, y: 0, width: 100, height: 100, z_index: 1,
animation_json: { intro: { preset: 'zoomIn', duration_ms: 800 } }
},
{
region_key: 'footer', x: 100, y: 100, width: 200, height: 50, z_index: 2,
animation_json: { loop: { preset: 'pulse', iterations: 2 } }
}
] };
const first = context.getTemplateRenderPlan(template, { hero: { layout: {
x: 960, y: 0, width: 400, height: 100, z_index: 9,
animation_json: { intro: { preset: 'fadeIn', duration_ms: 1400 } }
} } });
const sibling = context.getTemplateRenderPlan(template, {});
assert.notEqual(first, sibling);
assert.match(first.layout.regions[0].baseStyle, /left:50%/);
assert.match(first.layout.regions[0].baseStyle, /z-index:9/);
assert.equal(first.layout.regions[0].animationJson.intro.duration_ms, 1400);
assert.equal(first.layout.regions[1].animationJson.loop.preset, 'pulse');
assert.equal(first.layout.regions[1].animationJson.loop.iterations, 2);
assert.match(sibling.layout.regions[0].baseStyle, /left:0%/);
assert.equal(sibling.layout.regions[0].animationJson.intro.preset, 'zoomIn');
assert.equal(sibling.layout.regions[0].animationJson.intro.duration_ms, 800);
assert.equal(template.regions[0].animation_json.intro.preset, 'zoomIn');
assert.equal(template.regions[0].x, 0);
});
test('shared template action requires template update permission and loads the modal helper', async function () {
const renderSlide = require('../src/web/routes/signage/slides/edit');
const slide = { id: 1, title: 'Example', template_id: 7, content: {} };
const withPermission = await renderSlide({}, slide, '', { permissionKeys: ['slides.update', 'templates.update'] });
assert.match(withPermission, /id="save-layout-to-template"/);
assert.match(withPermission, /Apply to this slide/);
assert.doesNotMatch(withPermission, /slide-shared-template|edit-shared-slide-template/);
assert.match(withPermission, /\/assets\/js\/lib\/modal\.js/);
assert.match(withPermission, /modal-xl modal-dialog-centered modal-dialog-scrollable slide-layout-dialog/);
assert.doesNotMatch(withPermission, /modal-fullscreen/);
assert.match(withPermission, /name="region_z\[\]"/);
const layoutModal = withPermission.split('id="slide-layout-modal"')[1].split('id="slide-animation-modal"')[0];
assert.doesNotMatch(layoutModal, /data-animation-preset|animation-modal-field/);
assert.match(withPermission, /id="adjust-slide-animations"/);
assert.equal((withPermission.match(/id="adjust-slide-animations"/g) || []).length, 1);
assert.match(withPermission, /id="slide-animation-modal"/);
assert.match(withPermission, /data-animation-preset="intro"/);
assert.equal((withPermission.match(/id="slide-animation-modal"/g) || []).length, 1);
assert.match(withPermission, /data-slide-animation-preview="intro"/);
assert.match(withPermission, /data-slide-animation-preview="outro"/);
assert.match(withPermission, /data-slide-animation-preview="loop"/);
assert.match(withPermission, /id="slide-animation-preview"/);
assert.match(withPermission, /shared\/region-animation-controls\.js/);
for (const script of [
'/assets/js/templates/animation-presets.js',
'/assets/js/templates/template-designer-utils.js',
'/assets/js/shared/region-animation-controls.js',
'/assets/js/shared/qr-code-svg.js',
'/assets/vendor/qr-code-styling/qr-code-styling-loader.js'
]) {
assert.equal(withPermission.split(script).length - 1, 1, script + ' should load once');
}
const animationModal = withPermission.split('id="slide-animation-modal"')[1].split('id="slide-image-cropper-modal"')[0];
assert.match(animationModal, /Advanced animation settings/);
assert.match(animationModal, /id="slide-animation-advanced"[^>]*hidden/);
assert.match(animationModal, /Reset region animations to template/);
assert.match(animationModal, /Reset all animations to template/);
assert.ok(animationModal.indexOf('data-slide-animation-preview="intro"') < animationModal.indexOf('for="slide-animation-region"'));
const resetButtons = Array.from(withPermission.matchAll(/<button[^>]*id="slide-(?:layout-reset(?:-all)?|animation-reset-(?:region|all))"[^>]*>([\s\S]*?)<\/button>/g));
assert.equal(resetButtons.length, 4);
resetButtons.forEach((match) => assert.match(match[1], /<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"><\/i>/));
assert.match(animationModal, /id="save-animations-to-template"/);
assert.match(animationModal, /Apply to this slide/);
assert.equal((withPermission.match(/id="slide-animation-apply"/g) || []).length, 1);
assert.equal((withPermission.match(/id="slide-animation-save-status"/g) || []).length, 1);
const pageHeader = withPermission.split('class="page-header"')[1].split('</div>')[0];
assert.doesNotMatch(pageHeader, /slide-animation-apply|slide-animation-save-status|Animation save actions/);
assert.match(pageHeader, /Build slide content and preview it against the selected template\./);
assert.equal((withPermission.match(/id="adjust-slide-animations"/g) || []).length, 1);
assert.doesNotMatch(animationModal, /save-layout-to-template|slide-layout-snap/);
const withoutTemplatePermission = await renderSlide({}, slide, '', { permissionKeys: ['slides.update'] });
assert.doesNotMatch(withoutTemplatePermission, /id="save-animations-to-template"/);
const animationEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-animation-editor.js'), 'utf8');
assert.match(animationEditorSource, /getAnimationOverrides:|options\.getAnimationOverrides\(\)/);
assert.match(animationEditorSource, /modal\.addEventListener\('hidden\.bs\.modal'/);
assert.match(animationEditorSource, /var disabled = !available \|\| \(previewMode && previewMode !== mode\)/);
assert.match(animationEditorSource, /classList\.toggle\('btn-info', !disabled\)/);
assert.match(withPermission, /class="btn btn-outline-primary flex-shrink-0" id="adjust-slide-layout"[^>]*title="Adjust layout"[^>]*aria-label="Adjust layout"/);
assert.match(withPermission, /id="open-preview-popup"[^>]*>[\s\S]*?Open popup preview<\/button>\s*<button[^>]*id="adjust-slide-layout"/);
assert.doesNotMatch(withPermission, /id="adjust-slide-layout"[^>]*>[\s\S]*?Adjust layout<\/button>/);
const withoutPermission = await renderSlide({}, slide, '', { permissionKeys: ['slides.update'] });
assert.doesNotMatch(withoutPermission, /id="save-layout-to-template"/);
});
test('layout and animation modals use the same preview and properties column sizing', function () {
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(css, /\.slide-layout-body\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+21rem;/);
assert.match(css, /\.slide-animation-body\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+21rem;/);
assert.match(css, /\.slide-animation-body\s*\{[^}]*gap:\s*1\.25rem;/);
});
test('advanced animation section uses compact heading and card spacing', function () {
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(css, /#slide-animation-advanced\s*\{\s*gap:\s*0\.5rem\s*!important;/);
assert.match(css, /\.slide-animation-advanced-fields \.row\s*\{[^}]*gap:\s*0\.5rem;/);
});
test('shared animation controls round-trip advanced settings with template normalization', function () {
const context = { window: {} };
for (const file of ['templates/animation-presets.js', 'templates/template-designer-utils.js', 'shared/region-animation-controls.js']) {
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/' + file), 'utf8'), context);
}
const fields = [
{ dataset: { animationModalStep: 'intro', animationModalField: 'preset' }, value: 'fadeIn' },
{ dataset: { animationModalStep: 'intro', animationModalField: 'duration_ms' }, value: '150' },
{ dataset: { animationModalStep: 'intro', animationModalField: 'delay_ms' }, value: '300' },
{ dataset: { animationModalStep: 'loop', animationModalField: 'preset' }, value: 'pulse' },
{ dataset: { animationModalStep: 'loop', animationModalField: 'iterations' }, value: '4' }
];
const container = { querySelectorAll() { return fields; } };
const controls = context.window.pulseRegionAnimationControls;
const config = controls.readAdvanced(container, { intro: { easing: 'ease-in', fill_mode: 'both' } });
assert.equal(config.intro.duration_ms, 1500);
assert.equal(config.intro.delay_ms, 300);
assert.equal(config.intro.easing, 'ease-in');
assert.equal(config.intro.fill_mode, 'both');
assert.equal(config.loop.iterations, 4);
controls.populateAdvanced(container, config);
assert.equal(Number(fields[1].value), 150);
fields[1].value = '999';
fields[2].value = '-200';
fields[4].value = '2000';
const clamped = controls.readAdvanced(container, config);
assert.equal(clamped.intro.duration_ms, 2000);
assert.equal(clamped.intro.delay_ms, 0);
assert.equal(clamped.loop.iterations, 999);
});
function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate) {
const registerContentRoutes = require('../src/web/routes/admin/content');
const handlers = {};
const registrations = [];
const calls = [];
const savedContent = { hero: {
type: 'text', value: 'Saved headline', font_size: 40,
layout: { x: 90, y: 80, width: 500, height: 200, animation_json: '{"loop":{"preset":"pulse"}}' }
}, other: { type: 'text', value: 'Keep this content' } };
const template = { id: 7, name: 'Shared layout', canvas_size_width: 1920, canvas_size_height: 1080, regions: [{
id: 11, region_key: 'hero', region_type: 'text', x: 10, y: 20, width: 300, height: 100,
z_index: 1, lock_ratio: null, animation_json: '{"intro":{"preset":"fadeIn"}}'
}] };
const connection = {
async beginTransaction() { calls.push(['begin']); },
async commit() { calls.push(['commit']); },
async rollback() { calls.push(['rollback']); },
release() { calls.push(['release']); },
async query(sql, params) {
calls.push([sql, params]);
if (sql.startsWith('SELECT id, template_id, content_json')) return [[{ id: 42, template_id: 7, content_json: JSON.stringify(savedContent) }]];
if (failUpdate && sql.startsWith('UPDATE c_template_regions')) throw new Error('Write failed');
if (sql.startsWith('UPDATE c_template_regions SET animation_json')) {
template.regions.find(region => region.id === params[2]).animation_json = params[0];
return [{ affectedRows: 1 }];
}
return [[]];
}
};
const noop = async () => {};
registerContentRoutes({
get(route, ...actions) { handlers['GET ' + route] = actions; registrations.push('GET ' + route); },
post(route, ...actions) { handlers['POST ' + route] = actions; registrations.push('POST ' + route); }
}, {
pool: { async getConnection() { return connection; }, async query() { return [[{ slide_count: 3 }]]; } },
common: { fetchTemplateById: async () => template, parseJsonSafe: JSON.parse }, pages: {},
upload: { any: () => noop, single: () => noop }, setAuthMessageCookie: noop,
fetchScreensBySlideId: async () => [], fetchScreensByTemplateId: async () => [{ slug: 'lobby' }],
collectUploadReferencesFromSlide: () => [], collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: () => [], removeUnusedUploadFiles: noop,
syncPlaylistUploadsOnChange: noop, getAuditUserId: () => 9, redirectAfterSave: noop,
notifyPlayerScreens: async (...args) => calls.push(['notify', ...args]), broadcastDashboardState: noop,
getSlideDeleteBlockMessage: noop, getTemplateDeleteBlockMessage: noop, getCanvasSizeDeleteBlockMessage: noop,
hasAnyPermission: () => Boolean(allowSlideUpdate),
recordRequestAuditEvent: async (_pool, _req, event) => calls.push(['audit', event]),
backgroundTaskQueue: { enqueueTask: async task => calls.push(['thumbnail', task]) },
requirePermission(key) { return Object.assign(function () {}, { permission: key }); }
});
return { handlers, calls, template, savedContent, registrations };
}
test('shared layout route is permission protected and updates existing rows without touching content or animation', async function () {
const { handlers, calls, template, registrations } = createSharedLayoutRouteHarness();
assert.equal(registrations.filter(route => route === 'GET /templates/:id/animations').length, 1);
assert.equal(registrations.filter(route => route === 'POST /templates/:id/animations').length, 1);
const route = handlers['POST /templates/:id/layout'];
assert.equal(route[0].permission, 'templates.update');
assert.equal(handlers['GET /templates/:id/layout'][0].permission, 'templates.update');
let result;
await route.at(-1)({ params: { id: '7' }, body: {
baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 120, y: 220, width: 400, height: 200, z_index: 5, lock_ratio: '2:1' }]
} }, { json(value) { result = value; } }, error => { throw error; });
assert.equal(result.template.regions[0].id, 11);
assert.equal(result.template.regions[0].animation_json, template.regions[0].animation_json);
assert.equal(result.slideCount, 3);
assert.ok(calls.some(call => call[0] === 'commit'));
assert.ok(calls.some(call => call[0] === 'thumbnail'));
assert.ok(calls.some(call => call[0] === 'notify'));
assert.ok(calls.some(call => call[0] === 'audit'));
const writes = calls.filter(call => /^UPDATE|DELETE|INSERT/.test(call[0]));
assert.equal(writes.length, 2);
assert.ok(writes.every(call => !/content_json|animation_json|DELETE|INSERT/.test(call[0])));
});
test('shared layout route rolls back conflicts and failed writes', async function () {
for (const failUpdate of [false, true]) {
const { handlers, calls, template } = createSharedLayoutRouteHarness(failUpdate);
let status;
let errorMessage;
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: {
baseline: failUpdate ? getTemplateLayoutSnapshot(template) : {},
regions: [{ region_key: 'hero', x: 120, y: 220, width: 400, height: 200, z_index: 5, lock_ratio: '' }]
} }, {
status(value) { status = value; return this; },
json(value) { errorMessage = value.error; }
}, error => { errorMessage = error.message; });
if (!failUpdate) assert.equal(status, 409);
assert.ok(errorMessage);
assert.ok(calls.some(call => call[0] === 'rollback'));
assert.ok(calls.some(call => call[0] === 'release'));
assert.ok(!calls.some(call => ['commit', 'notify', 'thumbnail', 'audit'].includes(call[0])));
}
});
test('promoted slide layouts inherit geometry while retaining any animation override', function () {
const original = { x: 20, y: 30, width: 400, height: 120, animation_json: '{"intro":{"preset":"fadeIn"}}' };
const inherited = retainLayoutAnimation(original);
const animation = { intro: { preset: 'fadeIn' } };
assert.deepEqual(inherited, { animation_json: animation });
const region = { x: 100, y: 200, width: 600, height: 300, animation_json: '{}' };
assert.deepEqual(applyLayout(region, { layout: inherited }, canvas), { ...region, animation_json: animation });
assert.equal(retainLayoutAnimation({ x: 20, y: 30, width: 400, height: 120 }), null);
assert.equal(normalizeLayout({}, canvas), null);
});
test('shared save clears only the current slide geometry and preserves saved content and animations', async function () {
const { handlers, calls, template, savedContent } = createSharedLayoutRouteHarness(false, true);
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: {
slideId: 42, baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 120, y: 220, width: 400, height: 200, z_index: 5, lock_ratio: '' }]
} }, { json() {} }, error => { throw error; });
const writes = calls.filter(call => call[0].startsWith('UPDATE c_slides'));
assert.equal(writes.length, 1);
const persisted = JSON.parse(writes[0][1][0]);
assert.deepEqual(persisted.hero, { ...savedContent.hero, layout: { animation_json: { loop: { preset: 'pulse' } } } });
assert.deepEqual(persisted.other, savedContent.other);
assert.deepEqual(writes[0][1].slice(2), [42, 7]);
assert.ok(calls.findIndex(call => call[0] === 'commit') > calls.findIndex(call => call === writes[0]));
});
test('clearing saved slide geometry additionally requires slide update permission', async function () {
const { handlers, calls } = createSharedLayoutRouteHarness(false, false);
let status;
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: { slideId: 42 } }, {
status(value) { status = value; return this; }, json() {}
}, error => { throw error; });
assert.equal(status, 403);
assert.equal(calls.length, 0);
});
test('shared animation updates normalize every region and reject stale or incomplete sets', function () {
const template = { regions: [
{ id: 11, region_key: 'hero', animation_json: '{"intro":{"preset":"fadeIn"}}' },
{ id: 12, region_key: 'footer', animation_json: '{}' }
] };
const body = {
baseline: getTemplateAnimationSnapshot(template),
animations: [
{ region_key: 'hero', animation_json: { intro: { preset: 'zoomIn', duration_ms: 800 } } },
{ region_key: 'footer', animation_json: { loop: { preset: 'pulse', iterations: 3 } } }
]
};
const updated = buildTemplateAnimationUpdate(template, body);
assert.deepEqual(updated.map(region => region.id), [11, 12]);
assert.equal(updated[0].animation_json.intro.preset, 'zoomIn');
assert.equal(updated[0].animation_json.intro.duration_ms, 800);
assert.equal(updated[1].animation_json.loop.iterations, 3);
assert.equal(template.regions[0].animation_json, '{"intro":{"preset":"fadeIn"}}');
assert.throws(() => buildTemplateAnimationUpdate(template, { ...body, baseline: [] }), { statusCode: 409 });
assert.throws(() => buildTemplateAnimationUpdate(template, { ...body, animations: body.animations.slice(0, 1) }), { statusCode: 400 });
});
test('shared animation route is protected and updates template animations without replacing rows or slide content', async function () {
const { handlers, calls, template, savedContent } = createSharedLayoutRouteHarness(false, true);
const route = handlers['POST /templates/:id/animations'];
assert.equal(route[0].permission, 'templates.update');
let response;
await route.at(-1)({ params: { id: '7' }, currentUser: { id: 1 }, body: {
slideId: 42,
baseline: getTemplateAnimationSnapshot(template),
animations: [{ region_key: 'hero', animation_json: { intro: { preset: 'zoomIn', duration_ms: 800 } } }]
} }, { json(value) { response = value; } }, error => { throw error; });
assert.equal(response.template.regions[0].id, 11);
assert.equal(response.template.regions[0].animation_json.intro.preset, 'zoomIn');
const contentUpdate = calls.find(call => call[0].startsWith('UPDATE c_slides'));
const content = JSON.parse(contentUpdate[1][0]);
assert.equal(content.hero.value, savedContent.hero.value);
assert.deepEqual(content.hero.layout, { x: 90, y: 80, width: 500, height: 200 });
assert.deepEqual(content.other, savedContent.other);
assert.ok(calls.some(call => call[0].startsWith('UPDATE c_template_regions SET animation_json')));
assert.ok(calls.some(call => call[0] === 'commit'));
assert.ok(calls.some(call => call[0] === 'notify'));
assert.ok(calls.some(call => call[0] === 'thumbnail'));
});
+38
View File
@@ -1,6 +1,7 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const vm = require('node:vm');
const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8'); const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8');
const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8'); const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
@@ -109,3 +110,40 @@ test('webpage preview updates only through the explicit button', () => {
assert.ok(slideFormSource.includes('input[type="url"][name^="region_webpage_"]')); assert.ok(slideFormSource.includes('input[type="url"][name^="region_webpage_"]'));
assert.ok(slideFormSource.includes('requestPreviewRender();')); assert.ok(slideFormSource.includes('requestPreviewRender();'));
}); });
test('HTML editor shares bounded size controls without changing its content', async () => {
const source = { dataset: {}, style: {}, value: '<div>Keep this HTML</div>', getAttribute: () => '708' };
let clickHandler;
const templateFields = {
addEventListener: (event, handler) => { clickHandler = handler; },
querySelector: (selector) => selector.includes('.html-editor-source') ? source : null,
querySelectorAll: (selector) => selector === '.html-editor-source' ? [source] : []
};
const sandbox = { window: {} };
vm.runInNewContext(slideFormEditorSource.replace('export function', 'function'), sandbox);
const controller = sandbox.createSlideFormEditorController({ templateFields });
await controller.renderEditors();
assert.equal(source.style.height, '360px');
function resize(action, count = 1) {
const button = {
getAttribute: () => action,
closest: () => ({ getAttribute: () => '708' })
};
for (let index = 0; index < count; index += 1) {
clickHandler({ target: { closest: () => button }, preventDefault() {} });
}
}
resize('increase');
assert.equal(source.style.height, '440px');
resize('decrease');
assert.equal(source.style.height, '360px');
resize('decrease', 20);
assert.equal(source.style.height, '240px');
resize('increase', 20);
assert.equal(source.style.height, '960px');
assert.equal(source.value, '<div>Keep this HTML</div>');
assert.match(slideHtmlRegionSource, /style="resize:none;"/);
assert.match(slideHtmlRegionSource, /sandboxed iframe preview\.<\/div>'\s*\+\s*'<div class="btn-group/);
});
+28 -10
View File
@@ -275,6 +275,14 @@ test('template preview preserves the live canvas aspect and visual settings', ()
assert.match(livePreview.html, /left:10%;top:10%;width:50%;height:50%;z-index:2;/); assert.match(livePreview.html, /left:10%;top:10%;width:50%;height:50%;z-index:2;/);
}); });
test('template and slide region outlines do not impose a twelve-pixel minimum', () => {
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
const rule = css.match(/^\.designer-rect\s*\{([^}]+)\}/m);
assert.ok(rule);
assert.match(rule[1], /min-width:\s*0\s*;/);
assert.match(rule[1], /min-height:\s*0\s*;/);
});
test('template preview and live output keep matching visual CSS contracts', () => { test('template preview and live output keep matching visual CSS contracts', () => {
const previewCss = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8'); const previewCss = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
const playerCss = fs.readFileSync(require.resolve('../src/player/public/css/player.css'), 'utf8'); const playerCss = fs.readFileSync(require.resolve('../src/player/public/css/player.css'), 'utf8');
@@ -315,20 +323,23 @@ test('template animation controls mark the form dirty', () => {
test('template animation durations use percent in the advanced modal', () => { test('template animation durations use percent in the advanced modal', () => {
const modal = fs.readFileSync(require.resolve('../src/web/views/signage/templates/animation-advanced-modal.hbs'), 'utf8'); const modal = fs.readFileSync(require.resolve('../src/web/views/signage/templates/animation-advanced-modal.hbs'), 'utf8');
const advancedFields = fs.readFileSync(require.resolve('../src/web/views/signage/templates/animation-advanced-fields.hbs'), 'utf8');
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8'); const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
const utilsScript = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer-utils.js'), 'utf8'); const utilsScript = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer-utils.js'), 'utf8');
const sharedControls = fs.readFileSync(require.resolve('../src/web/public/js/shared/region-animation-controls.js'), 'utf8');
assert.match(modal, /Duration %/); assert.match(modal, /signage\/templates\/animation-advanced-fields/);
assert.match(modal, /data-animation-modal-field="duration_ms"/); assert.match(advancedFields, /Duration %/);
assert.match(modal, /max="200"/); assert.match(advancedFields, /data-animation-modal-field="duration_ms"/);
assert.match(modal, /data-animation-modal-field="iterations"/); assert.match(advancedFields, /max="200"/);
assert.match(modal, /max="999"/); assert.match(advancedFields, /data-animation-modal-field="iterations"/);
assert.ok(script.includes('function getAnimationDurationPercent(durationMs)')); assert.match(advancedFields, /max="999"/);
assert.ok(script.includes('function getAnimationDurationMs(durationPercent)')); assert.ok(script.includes('window.pulseRegionAnimationControls.populateAdvanced(animationAdvancedModal, config)'));
assert.ok(script.includes('durationInput.value = String(getAnimationDurationPercent(step.duration_ms));')); assert.ok(script.includes('window.pulseRegionAnimationControls.readAdvanced(animationAdvancedModal, getAnimationConfig(activeAnimationCard))'));
assert.ok(script.includes("nextStep.duration_ms = getAnimationDurationMs(durationInput ? durationInput.value : '');")); assert.ok(sharedControls.includes('Math.round(Number(value) / 10)'));
assert.ok(sharedControls.includes('Math.round(Number(value)) || 100)) * 10'));
assert.ok(script.includes('MAX_ANIMATION_REPEAT_COUNT = 999')); assert.ok(script.includes('MAX_ANIMATION_REPEAT_COUNT = 999'));
assert.match(script, /Math\.min\(MAX_ANIMATION_REPEAT_COUNT, Math\.max\(1, readNumberField\(iterationsInput \? iterationsInput\.value : ''\) \|\| 1\)\)/); assert.match(sharedControls, /Math\.min\(999, Math\.max\(1, Math\.round\(Number\(value\)\) \|\| 1\)\)/);
assert.match(utilsScript, /MAX_ANIMATION_REPEAT_COUNT = 999/); assert.match(utilsScript, /MAX_ANIMATION_REPEAT_COUNT = 999/);
assert.match(utilsScript, /clamp\(Math\.round\(Number\(value\.iterations\)\), 1, MAX_ANIMATION_REPEAT_COUNT\)/); assert.match(utilsScript, /clamp\(Math\.round\(Number\(value\.iterations\)\), 1, MAX_ANIMATION_REPEAT_COUNT\)/);
}); });
@@ -489,3 +500,10 @@ test('normalizeBackgroundGradient accepts linear colors and clamps the angle', (
})); }));
assert.equal(normalizeBackgroundGradient('{}'), null); assert.equal(normalizeBackgroundGradient('{}'), null);
}); });
test('template snap toggle places its switch after the label', () => {
const template = fs.readFileSync(require.resolve('../src/web/views/signage/templates/form.hbs'), 'utf8');
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(template, /class="form-check form-switch template-snap-control[^>]*"><label for="template-layout-snap"[^>]*>Snap to guides<\/label><input type="checkbox" id="template-layout-snap"/);
assert.match(css, /\.template-snap-control \.form-check-input\s*\{[^}]*order:\s*2/);
});