diff --git a/CHANGELOG.md b/CHANGELOG.md
index f77f7b3..3e0287d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,25 @@
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
### Fixed
diff --git a/build/package.player.json b/build/package.player.json
index fb9fffb..714c974 100644
--- a/build/package.player.json
+++ b/build/package.player.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
- "version": "2.15.7",
+ "version": "2.16.0",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
diff --git a/build/package.web.json b/build/package.web.json
index ba3c116..3e6b10b 100644
--- a/build/package.web.json
+++ b/build/package.web.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
- "version": "2.15.7",
+ "version": "2.16.0",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
diff --git a/docs/user/guide-web.md b/docs/user/guide-web.md
index 5cbe843..db88575 100644
--- a/docs/user/guide-web.md
+++ b/docs/user/guide-web.md
@@ -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.
-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.
diff --git a/package-lock.json b/package-lock.json
index 8a61216..ede05cb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
- "version": "2.15.7",
+ "version": "2.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
- "version": "2.15.7",
+ "version": "2.16.0",
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
diff --git a/package.json b/package.json
index 98f9115..6228f1f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
- "version": "2.15.7",
+ "version": "2.16.0",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
@@ -21,7 +21,7 @@
"start:player": "node -r dotenv/config src/player.js",
"dev:web": "nodemon -r dotenv/config src/web.js",
"dev:player": "nodemon -r dotenv/config src/player.js",
- "test": "node --test"
+ "test": "node --test test/*.test.js"
},
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
diff --git a/src/data/slides.js b/src/data/slides.js
index f1132ff..ff43ec8 100644
--- a/src/data/slides.js
+++ b/src/data/slides.js
@@ -2,6 +2,7 @@
const { fetchTemplateById } = require('#src/data/templates');
const { parseJsonSafe, validateMaxLength } = require('#src/data/utils');
+const { normalizeLayout } = require('#src/web/public/js/shared/region-layout');
const TITLE_MAX_LENGTH = 255;
const { buildQrCodeContent } = require('#src/data/qr-code');
@@ -622,6 +623,30 @@ async function buildSlidePayload(pool, req, existingSlide) {
if (template) {
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) {
const region = template.regions.find(function (item) {
return String(item.region_key || '').trim() === regionKey;
@@ -630,7 +655,9 @@ async function buildSlidePayload(pool, req, existingSlide) {
return;
}
+ const layout = content[regionKey].layout;
content[regionKey] = await buildQrCodeContent(content[regionKey]);
+ if (layout) content[regionKey].layout = layout;
}));
return {
diff --git a/src/data/templates.js b/src/data/templates.js
index 5a7bfd4..8ba5001 100644
--- a/src/data/templates.js
+++ b/src/data/templates.js
@@ -2,6 +2,7 @@
const { parseJsonSafe, readFormArray, validateMaxLength } = require('#src/data/utils');
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 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 = {
fetchTemplateById,
fetchTemplatesData,
extractTemplateRegions,
buildTemplatePayload,
normalizeBackgroundGradient,
+ buildTemplateLayoutUpdate,
+ buildTemplateAnimationUpdate,
+ normalizeTemplateRegionAnimationConfig,
parseJsonSafe
};
diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js
index d321c5c..1c1402d 100644
--- a/src/player/public/js/player-page-rendering.js
+++ b/src/player/public/js/player-page-rendering.js
@@ -207,20 +207,23 @@ function isSlideActive(slide, now) {
return true;
}
// Build the cache key for a template layout.
-function getTemplateLayoutCacheKey(template) {
+function getTemplateLayoutCacheKey(template, content) {
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.
-function getTemplateLayout(template) {
+function getTemplateLayout(template, content) {
if (!template || !template.id) {
return null;
}
syncRenderCacheViewport();
- var cacheKey = getTemplateLayoutCacheKey(template);
+ var cacheKey = getTemplateLayoutCacheKey(template, content);
if (Object.prototype.hasOwnProperty.call(templateLayoutCache, cacheKey)) {
return templateLayoutCache[cacheKey];
}
@@ -229,6 +232,7 @@ function getTemplateLayout(template) {
var canvasSize = fitCanvasSize(templateCanvas.width, templateCanvas.height, window.innerWidth, window.innerHeight);
var canvasScale = canvasSize.width / templateCanvas.width;
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 top = (Number(region.y) / templateCanvas.height) * 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.
-function getTemplateRenderPlanCacheKey(template) {
- return getTemplateLayoutCacheKey(template);
+function getTemplateRenderPlanCacheKey(template, content) {
+ return getTemplateLayoutCacheKey(template, content);
}
// Build or reuse the render plan for a template.
-function getTemplateRenderPlan(template) {
+function getTemplateRenderPlan(template, content) {
if (!template || !template.id) {
return null;
}
syncRenderCacheViewport();
- var cacheKey = getTemplateRenderPlanCacheKey(template);
+ var cacheKey = getTemplateRenderPlanCacheKey(template, content);
if (Object.prototype.hasOwnProperty.call(templateRenderPlanCache, cacheKey)) {
return templateRenderPlanCache[cacheKey];
}
- var layout = getTemplateLayout(template);
+ var layout = getTemplateLayout(template, content);
function getPlayerRegionModule(regionType) {
return window.pulsePlayerRegionTypes && typeof window.pulsePlayerRegionTypes.get === 'function' ? window.pulsePlayerRegionTypes.get(regionType) : null;
}
@@ -359,7 +363,7 @@ function getTemplateRenderPlan(template) {
function renderTemplateSlideMarkup(slide) {
const template = slide.template;
const content = slide.content || {};
- const plan = getTemplateRenderPlan(template);
+ const plan = getTemplateRenderPlan(template, content);
const layout = plan ? plan.layout : null;
const regions = layout ? layout.regions.map(function (region) {
const regionContent = content[region.regionKey] || {};
diff --git a/src/player/render-helpers.js b/src/player/render-helpers.js
index e8f5dee..cde8551 100644
--- a/src/player/render-helpers.js
+++ b/src/player/render-helpers.js
@@ -225,6 +225,7 @@ function getPlayerRuntimeScripts() {
['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()],
['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()],
['client-name', getScriptBody(getPlayerClientNameScript()())],
['offline', getScriptBody(getPlayerPageOfflineScript()())],
diff --git a/src/web.js b/src/web.js
index 6fda0d4..4029422 100644
--- a/src/web.js
+++ b/src/web.js
@@ -208,7 +208,9 @@ async function start() {
const isPermissionError = statusCode === 403;
const message = isPermissionError
? 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
? 'Access denied'
: statusCode === 404
@@ -218,7 +220,6 @@ async function start() {
return res.status(statusCode).send(pages.renderErrorPage({
statusCode: statusCode,
title: title,
- errorTitle: title,
message: message,
detail: statusCode >= 500 ? 'The server could not complete the request.' : '',
backUrl: req.currentUser ? '/dashboard' : '/login',
diff --git a/src/web/handlebars.js b/src/web/handlebars.js
index 867439c..430c955 100644
--- a/src/web/handlebars.js
+++ b/src/web/handlebars.js
@@ -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/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/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/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'));
diff --git a/src/web/lib/media/slide-thumbnail-preview.js b/src/web/lib/media/slide-thumbnail-preview.js
index afa6f82..87beec8 100644
--- a/src/web/lib/media/slide-thumbnail-preview.js
+++ b/src/web/lib/media/slide-thumbnail-preview.js
@@ -7,6 +7,7 @@ const {
sanitizeFontSize,
sanitizeTextColor
} = require('#src/player/render-helpers');
+const { applyLayout } = require('#src/web/public/js/shared/region-layout');
function normalizeBaseUrl(baseUrl) {
return String(baseUrl || '').trim().replace(/\/$/, '');
@@ -173,6 +174,7 @@ function buildThumbnailPreviewMarkup(slide, baseUrl) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
return (Array.isArray(template.regions) ? template.regions : []).map(function (region) {
const regionContent = getRegionContent(slide, region);
+ region = applyLayout(region, regionContent, canvasSize);
const previewRegion = Object.assign({}, region, {
baseStyle: buildThumbnailRegionStyle(region, canvasSize.width, canvasSize.height),
pixelWidth: Math.max(1, Math.round(Number(region && region.width || 0) || 1)),
diff --git a/src/web/pages/error.js b/src/web/pages/error.js
index b7f1434..f17ae26 100644
--- a/src/web/pages/error.js
+++ b/src/web/pages/error.js
@@ -19,7 +19,7 @@ function getErrorCopy(statusCode, message) {
return {
title: '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'
};
}
@@ -27,8 +27,8 @@ function getErrorCopy(statusCode, message) {
if (normalizedStatusCode >= 500) {
return {
title: 'Something went wrong',
- errorTitle: 'Something went wrong.',
- errorMessage: message || 'An unexpected error occurred. Please try again in a moment.',
+ errorTitle: 'Something went wrong on our end.',
+ 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'
};
}
@@ -49,6 +49,7 @@ module.exports = function renderErrorPage(options, currentUser) {
title: String(errorOptions.title || copy.title || 'Error').trim(),
active: '',
messageVariant: statusCode === 403 ? 'warning' : 'primary',
+ statusVariant: statusCode >= 500 ? 'danger' : statusCode === 403 ? 'warning' : 'primary',
currentUser: currentUser || null,
statusCode: statusCode,
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',
backLabel: String(errorOptions.backLabel || copy.backLabel || 'Back to dashboard').trim() || 'Back to dashboard',
searchUrl: String(errorOptions.searchUrl || '').trim(),
- bodyClass: 'error-page bg-dark text-white',
+ bodyClass: 'error-page',
errorShell: true,
stylesheets: [],
scripts: []
diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css
index 218459e..4653d81 100644
--- a/src/web/public/css/theme-custom.css
+++ b/src/web/public/css/theme-custom.css
@@ -1,3 +1,8 @@
+.error-page-content {
+ width: 100%;
+ max-width: 52rem;
+}
+
.media-picker-item {
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
@@ -2351,6 +2356,236 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
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 {
position: absolute;
top: 0;
@@ -2812,8 +3047,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
background: rgba(13, 110, 253, 0.12);
box-sizing: border-box;
border-radius: 0;
- min-width: 12px;
- min-height: 12px;
+ min-width: 0;
+ min-height: 0;
cursor: move;
}
diff --git a/src/web/public/js/regions/type/html.js b/src/web/public/js/regions/type/html.js
index 2188fdd..a1ec1f5 100644
--- a/src/web/public/js/regions/type/html.js
+++ b/src/web/public/js/regions/type/html.js
@@ -59,9 +59,16 @@
return registry.renderEditorCardShell({
region: region,
headerActions: 'HTML ',
+ bodyClass: 'card-body p-3 d-grid gap-2',
bodyHtml: '' +
- '' +
- '
HTML is rendered in a sandboxed iframe preview.
'
+ '' +
+ '
HTML is rendered in a sandboxed iframe preview.
' +
+ '
' +
+ '- ' +
+ '+ ' +
+ '
' +
+ '
' +
+ ''
});
}
diff --git a/src/web/public/js/shared/region-animation-controls.js b/src/web/public/js/shared/region-animation-controls.js
new file mode 100644
index 0000000..6fff2db
--- /dev/null
+++ b/src/web/public/js/shared/region-animation-controls.js
@@ -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
+ };
+})();
\ No newline at end of file
diff --git a/src/web/public/js/shared/region-layout.js b/src/web/public/js/shared/region-layout.js
new file mode 100644
index 0000000..4a2d9ac
--- /dev/null
+++ b/src/web/public/js/shared/region-layout.js
@@ -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 };
+});
\ No newline at end of file
diff --git a/src/web/public/js/slides/slide-animation-editor.js b/src/web/public/js/slides/slide-animation-editor.js
new file mode 100644
index 0000000..e17e916
--- /dev/null
+++ b/src/web/public/js/slides/slide-animation-editor.js
@@ -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; } };
+}
diff --git a/src/web/public/js/slides/slide-form-editor.js b/src/web/public/js/slides/slide-form-editor.js
index 7b6c0c7..d0f28b7 100644
--- a/src/web/public/js/slides/slide-form-editor.js
+++ b/src/web/public/js/slides/slide-form-editor.js
@@ -89,7 +89,7 @@ export function createSlideFormEditorController(options) {
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) {
@@ -734,6 +734,11 @@ export function createSlideFormEditorController(options) {
bindEditorSizeControls();
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'));
return Promise.all(holders.map(function (holder) {
return createEditorForHolder(holder);
diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js
index 045d379..5dc7445 100644
--- a/src/web/public/js/slides/slide-form.js
+++ b/src/web/public/js/slides/slide-form.js
@@ -2,6 +2,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
import { createSlideFormEditorController } from '/assets/js/slides/slide-form-editor.js';
import { createSlideFormRegionHelpers } from '/assets/js/slides/slide-form-regions.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 () {
var DEFAULT_FONT_SIZE = 32;
@@ -58,6 +60,37 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var sidebarBaseOffset = 16;
var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
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) {
return String(value ?? '')
@@ -614,6 +647,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
currentPreviewCanvasWidth = canvasWidth;
currentPreviewCanvasHeight = 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);
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
if (!(template.regions || []).length) {
@@ -651,7 +685,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
slidePreviewCanvas.style.height = canvasHeight + 'px';
slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')';
slidePreviewCanvas.style.transformOrigin = 'top left';
- var regions = template.regions || [];
+ var regions = (template.regions || []).map(slideLayoutEditor.apply);
slidePreviewOverlay.innerHTML = regions.map(function (region) {
var box = getRegionBox(region, canvasWidth, canvasHeight);
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'
? ' slide-preview-html-region'
: ' slide-preview-text-region';
- return '' + content + '
';
+ return '' + content + '
';
}).join('');
regions.forEach(function (region) {
@@ -692,6 +726,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
startPreviewVideoPlayback(slidePreviewOverlay);
+ slideLayoutEditor.refresh();
syncPopupPreview();
}
@@ -723,6 +758,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
clearImagePreviewUrls();
slideFormEditorController.destroyEditors();
var template = getTemplateById(templateSelect.value);
+ slideLayoutEditor.reset(Number(templateSelect.value) === Number(existingTemplateId) ? existingContent : {});
if (!template) {
templateFields.innerHTML = '';
requestPreviewRender();
diff --git a/src/web/public/js/slides/slide-layout-editor.js b/src/web/public/js/slides/slide-layout-editor.js
new file mode 100644
index 0000000..056e7c2
--- /dev/null
+++ b/src/web/public/js/slides/slide-layout-editor.js
@@ -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();
+ }
+ };
+}
\ No newline at end of file
diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js
index b3a72ff..2130f70 100644
--- a/src/web/public/js/templates/template-designer.js
+++ b/src/web/public/js/templates/template-designer.js
@@ -20,6 +20,8 @@
: (Array.isArray(templateData) ? templateData : []);
var stage = document.getElementById('designer-stage');
var overlay = document.getElementById('designer-overlay');
+ var snapInput = document.getElementById('template-layout-snap');
+ var alignmentGuides = [];
var previewCard = document.getElementById('template-preview-card');
var previewIntroButton = document.getElementById('template-preview-intro');
var previewOutButton = document.getElementById('template-preview-out');
@@ -557,104 +559,19 @@
}
function getAnimationConfig(card) {
- var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
- 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';
- }
- }
+ return window.pulseRegionAnimationControls.read(card);
}
function writeAnimationConfig(card, animationConfig) {
- var normalized = normalizeAnimationConfig(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);
- }
-
+ var normalized = window.pulseRegionAnimationControls.write(card, animationConfig);
updatePreviewCardVisibility();
-
return normalized;
}
function setAnimationPreset(card, stepName, preset) {
- var config = getAnimationConfig(card);
- if (config[stepName]) {
- config[stepName].preset = normalizePreviewAnimation(preset);
- 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 + '"]');
+ window.pulseRegionAnimationControls.setPreset(card, stepName, preset);
+ updatePreviewCardVisibility();
+ if (activeAnimationCard === card) populateAnimationModal(card);
}
function populateAnimationModal(card) {
@@ -670,20 +587,7 @@
title.textContent = (label ? label.textContent : 'Region') + ' animation settings';
}
- ['intro', 'outro', 'loop'].forEach(function (stepName) {
- 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))); }
- }
- });
+ window.pulseRegionAnimationControls.populateAdvanced(animationAdvancedModal, config);
}
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() {
if (!animationAdvancedModal) {
return null;
}
- var config = getAnimationConfig(activeAnimationCard) || { intro: {}, outro: {}, loop: {} };
-
- ['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;
+ return window.pulseRegionAnimationControls.readAdvanced(animationAdvancedModal, getAnimationConfig(activeAnimationCard));
}
function saveAnimationModal() {
@@ -1475,6 +1346,9 @@
var selected = index === selectedNow ? ' selected' : '';
return '' + escapeHtml(region.label || region.region_key || 'Region') + '
';
}).join('');
+ alignmentGuides.forEach(function (guide) {
+ overlay.innerHTML += '
';
+ });
if (draft) {
var rect = overlayRect;
var size = canvasSize;
@@ -1633,6 +1507,15 @@
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) {
if (previewState.active) {
return;
@@ -1649,11 +1532,13 @@
var maxY = Math.max(0, size.height - startRegion.height);
var nextX = clamp(startRegion.x + dx, 0, maxX);
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();
requestOverlayRender();
}
function upHandler() {
+ alignmentGuides = [];
+ requestOverlayRender();
document.removeEventListener('mousemove', moveHandler);
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.height < 1) { if (dir.indexOf('n') !== -1) { next.y -= 1 - next.height; } next.height = 1; }
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 });
markTemplateFormDirty();
requestOverlayRender();
}
function upHandler() {
+ alignmentGuides = [];
+ requestOverlayRender();
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
@@ -1931,12 +1819,14 @@
canvasHeightInput.addEventListener('input', render);
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
overlay.addEventListener('mousedown', function (event) {
+ if (event.button !== 0) return;
var rect = event.target.closest('.designer-rect');
if (rect) {
var index = Number(rect.getAttribute('data-index'));
var handle = event.target.closest('.resize-handle');
event.preventDefault();
setSelected(index);
+ overlay.focus({ preventScroll: true });
if (handle) {
resizeFromHandle(index, handle.getAttribute('data-dir'), event);
} else {
@@ -1950,6 +1840,21 @@
event.preventDefault();
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) {
templateForm.addEventListener('invalid', function (event) {
var target = event.target;
diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js
index 4a999e7..6d96f2a 100644
--- a/src/web/routes/admin/content.js
+++ b/src/web/routes/admin/content.js
@@ -8,6 +8,8 @@ const { fetchAppSettings } = require('#src/data/app-settings');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { buildAuditChanges } = require('#src/data/audit-log');
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');
module.exports = function registerContentRoutes(app, deps) {
@@ -751,7 +753,8 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(404).send('Template not found');
}
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);
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
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) {
try {
await validateUploadedFiles(req.files || []);
diff --git a/src/web/routes/signage/slides/form-view-model.js b/src/web/routes/signage/slides/form-view-model.js
index c49a160..945861d 100644
--- a/src/web/routes/signage/slides/form-view-model.js
+++ b/src/web/routes/signage/slides/form-view-model.js
@@ -1,6 +1,7 @@
// Shared slide form view-model builder.
const { getRegionEditorScripts } = require('#src/web/lib/region-scripts');
+const animationPresets = require('#src/web/public/js/templates/animation-presets');
function buildDefaultSlide() {
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"',
slide: viewSlide,
templates: templates,
+ animationPresets: animationPresets,
stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [],
slideEditorData: {
+ slideId: viewSlide.id || null,
templates: templates,
rssFeeds: rssFeeds,
apiSources: apiSources,
@@ -60,7 +63,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
mediaAssets: mediaAssets
},
assetVersion: assetVersion,
- slideEditorScripts: ['vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)),
+ slideEditorScripts: getRegionEditorScripts(assetVersion),
currentUser: currentUser || null
};
}
diff --git a/src/web/routes/signage/templates/form-view-model.js b/src/web/routes/signage/templates/form-view-model.js
index 3448854..6d219f1 100644
--- a/src/web/routes/signage/templates/form-view-model.js
+++ b/src/web/routes/signage/templates/form-view-model.js
@@ -58,7 +58,7 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
showSaveSecondaryActions: true,
deleteDisabled: !isEdit || Boolean(current.inUse),
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',
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
@@ -67,7 +67,7 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
mediaAssets: Array.isArray(mediaAssets) ? mediaAssets : [],
imageUploadMaxBytes: Number(imageUploadMaxBytes) || 100 * 1024 * 1024,
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])
};
}
diff --git a/src/web/views/error/error.hbs b/src/web/views/error/error.hbs
index 36e3ca2..c872363 100644
--- a/src/web/views/error/error.hbs
+++ b/src/web/views/error/error.hbs
@@ -1,23 +1,11 @@
-
-
-
-
-
-
-
-
{{statusCode}}
-
{{errorTitle}}
-
{{errorMessage}}
- {{#if detail}}
-
{{detail}}
- {{/if}}
-
-
-
+
+ {{statusCode}}
+ {{errorTitle}}
+ {{errorMessage}}
+
+ {{#if searchUrl}}
+
Search
+ {{/if}}
+
{{backLabel}}
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/web/views/signage/slides/form.hbs b/src/web/views/signage/slides/form.hbs
index 1aa63d4..a303df7 100644
--- a/src/web/views/signage/slides/form.hbs
+++ b/src/web/views/signage/slides/form.hbs
@@ -8,6 +8,7 @@
+{{#> modal-shell modalId="slide-layout-modal" modalLabelId="slide-layout-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable slide-layout-dialog"}}
+
+
+
+{{/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"}}
+
+
+
+
+
+ Intro
+ Outro
+ Attention
+
+
Region
+
+
+ {{> signage/templates/region-animation-controls}}
+
+
Advanced animation settings
+
{{> signage/templates/animation-advanced-fields}}
+
+
+
Reset region animations to template
+
Reset all animations to template
+
+
+
+{{/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}}
-
+ {{> signage/templates/animation-advanced-fields}}