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

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

No files matched your search

+27
View File
@@ -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 {
+54
View File
@@ -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
};
+14 -10
View File
@@ -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] || {};
+1
View File
@@ -225,6 +225,7 @@ function getPlayerRuntimeScripts() {
['placeholder-utils', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8').trim()],
['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()())],
+3 -2
View File
@@ -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',
+3
View File
@@ -178,6 +178,9 @@ function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/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'));
@@ -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)),
+5 -4
View File
@@ -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: []
+237 -2
View File
@@ -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;
}
+9 -2
View File
@@ -59,9 +59,16 @@
return registry.renderEditorCardShell({
region: region,
headerActions: '<span class="chip">HTML</span>',
bodyClass: 'card-body p-3 d-grid gap-2',
bodyHtml: '' +
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>'
'<div class="d-flex align-items-center justify-content-between gap-2">' +
'<div class="muted">HTML is rendered in a sandboxed iframe preview.</div>' +
'<div class="btn-group btn-group-sm template-editor-size-controls" role="group" aria-label="Editor size controls">' +
'<button type="button" class="btn btn-outline-secondary" data-editor-size-action="decrease" aria-label="Decrease editor size" title="Decrease editor size">-</button>' +
'<button type="button" class="btn btn-outline-secondary" data-editor-size-action="increase" aria-label="Increase editor size" title="Increase editor size">+</button>' +
'</div>' +
'</div>' +
'<textarea name="region_html_' + region.id + '" class="form-control html-editor-source" data-region-id="' + region.id + '" rows="10" style="resize:none;" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>'
});
}
@@ -0,0 +1,83 @@
(function () {
function normalize(value) { return window.templateDesignerUtils.normalizeAnimationConfig(value); }
function normalizePreset(value) { return window.templateAnimationPresets.normalize(value); }
function read(card) {
var config = normalize(card.querySelector('[name="region_animation_json[]"]').value || '{}');
card.querySelectorAll('[data-animation-preset]').forEach(function (input) {
if (input.dataset.animationPresetCustom !== '1' && input.selectedIndex >= 0) {
config[input.dataset.animationPreset].preset = normalizePreset(input.value);
}
});
return config;
}
function write(card, value) {
var config = normalize(value);
card.querySelector('[name="region_animation_json[]"]').value = JSON.stringify(config);
card.querySelectorAll('[data-animation-preset]').forEach(function (input) {
var step = input.dataset.animationPreset;
var preset = config[step].preset;
var custom = !Array.from(input.options).some(function (option) { return option.value === preset; });
input.value = preset;
input.hidden = custom;
if (custom) input.dataset.animationPresetCustom = '1';
else delete input.dataset.animationPresetCustom;
var reset = card.querySelector('[data-animation-reset-button="' + step + '"]');
if (reset) reset.hidden = !custom;
});
return config;
}
function setPreset(card, step, preset) {
var config = read(card);
config[step] = Object.assign({}, config[step], {
preset: normalizePreset(preset), duration_ms: null, delay_ms: null,
iterations: null, easing: '', fill_mode: ''
});
return write(card, config);
}
function bind(card, options) {
card.querySelectorAll('[data-animation-preset]').forEach(function (input) {
input.addEventListener('change', function () {
setPreset(card, input.dataset.animationPreset, input.value);
options.onChange();
});
});
card.querySelectorAll('[data-animation-reset-button]').forEach(function (button) {
button.addEventListener('click', function (event) {
event.preventDefault();
setPreset(card, button.dataset.animationResetButton, 'none');
options.onChange();
});
});
var advanced = card.querySelector('[data-animation-advanced-button]');
if (advanced) advanced.addEventListener('click', options.onAdvanced);
}
function populateAdvanced(container, value) {
var config = normalize(value);
container.querySelectorAll('[data-animation-modal-field]').forEach(function (input) {
var step = config[input.dataset.animationModalStep];
var field = input.dataset.animationModalField;
var value = step[field];
if (field === 'duration_ms') value = Math.min(200, Math.max(1, Math.round(Number(value) / 10) || 100));
if (field === 'delay_ms') value = Number(value) || 0;
if (field === 'iterations') value = Math.min(999, Math.max(1, Number(value) || 1));
input.value = value;
});
}
function readAdvanced(container, value) {
var config = normalize(value);
container.querySelectorAll('[data-animation-modal-field]').forEach(function (input) {
var step = config[input.dataset.animationModalStep];
var field = input.dataset.animationModalField;
var value = input.value;
if (field === 'duration_ms') value = Math.min(200, Math.max(1, Math.round(Number(value)) || 100)) * 10;
else if (field === 'delay_ms') value = Math.max(0, Math.round(Number(value)) || 0);
else if (field === 'iterations') value = Math.min(999, Math.max(1, Math.round(Number(value)) || 1));
step[field] = value;
});
return normalize(config);
}
window.pulseRegionAnimationControls = {
read: read, write: write, setPreset: setPreset, bind: bind,
populateAdvanced: populateAdvanced, readAdvanced: readAdvanced
};
})();
+160
View File
@@ -0,0 +1,160 @@
(function (root, factory) {
var helpers = factory();
if (typeof module === 'object' && module.exports) {
module.exports = helpers;
} else {
root.pulseRegionLayout = helpers;
}
})(typeof window !== 'undefined' ? window : globalThis, function () {
function normalizeLayout(value, canvas) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
var fields = ['x', 'y', 'width', 'height'];
var hasGeometry = fields.some(function (field) { return Object.hasOwn(value, field); });
if (hasGeometry && !fields.every(function (field) {
return typeof value[field] === 'number' && Number.isFinite(value[field]);
})) return null;
if (!hasGeometry && !['z_index', 'lock_ratio', 'animation_json'].some(function (field) { return Object.hasOwn(value, field); })) return null;
var width = Math.max(1, Math.round(Number(canvas.width) || 1920));
var height = Math.max(1, Math.round(Number(canvas.height) || 1080));
var result = hasGeometry ? {
x: Math.max(0, Math.min(width - 1, Math.round(value.x))),
y: Math.max(0, Math.min(height - 1, Math.round(value.y))),
width: Math.max(1, Math.min(width, Math.round(value.width))),
height: Math.max(1, Math.min(height, Math.round(value.height)))
} : {};
if (hasGeometry) {
result.width = Math.min(result.width, width - result.x);
result.height = Math.min(result.height, height - result.y);
}
if (Object.hasOwn(value, 'z_index')) {
if (typeof value.z_index !== 'number' || !Number.isFinite(value.z_index)) return null;
result.z_index = Math.max(1, Math.round(value.z_index));
}
if (Object.hasOwn(value, 'lock_ratio')) {
var ratio = String(value.lock_ratio || '').trim().replace(/\s+/g, '');
if (ratio && (!/^\d+:\d+$/.test(ratio) || ratio.split(':').some(function (part) { return !(Number(part) > 0); }))) return null;
result.lock_ratio = ratio;
}
if (Object.hasOwn(value, 'animation_json')) {
var animation = value.animation_json;
try {
if (typeof animation === 'string') animation = JSON.parse(animation);
} catch (_error) { return null; }
if (!animation || typeof animation !== 'object' || Array.isArray(animation)) return null;
result.animation_json = animation;
}
return result;
}
function applyLayout(region, content, canvas) {
var layout = normalizeLayout(content && content.layout, canvas);
return Object.assign({}, region, layout || {});
}
function fitLayout(box, canvas, ratio, direction) {
box = { x: box.x, y: box.y, width: box.width, height: box.height };
if (!(ratio > 0)) return normalizeLayout(box, canvas);
var west = String(direction || '').includes('w');
var north = String(direction || '').includes('n');
var anchorX = west ? box.x + box.width : box.x;
var anchorY = north ? box.y + box.height : box.y;
var maxWidth = Math.max(1, west ? anchorX : canvas.width - anchorX);
var maxHeight = Math.max(1, north ? anchorY : canvas.height - anchorY);
var width = Math.max(1, Math.min(box.width, maxWidth, maxHeight * ratio));
var height = Math.max(1, width / ratio);
return normalizeLayout({
x: west ? anchorX - width : anchorX,
y: north ? anchorY - height : anchorY,
width: width, height: height
}, canvas);
}
function snapBox(box, peers, canvas, tolerance, direction, ratio) {
var result = Object.assign({}, box);
var guides = [];
['x', 'y'].forEach(function (axis) {
var size = axis === 'x' ? 'width' : 'height';
var startHandle = axis === 'x' ? 'w' : 'n';
var endHandle = axis === 'x' ? 'e' : 's';
var extent = Number(canvas[size]);
var targets = [0, extent / 2, extent];
peers.forEach(function (peer) {
targets.push(Number(peer[axis]), Number(peer[axis]) + Number(peer[size]) / 2, Number(peer[axis]) + Number(peer[size]));
});
var anchors = direction
? (direction.indexOf(startHandle) !== -1 ? [0] : direction.indexOf(endHandle) !== -1 ? [1] : [])
: [0, 0.5, 1];
var nearest = null;
anchors.forEach(function (anchor) {
targets.forEach(function (target) {
var delta = target - (box[axis] + box[size] * anchor);
if (Math.abs(delta) <= tolerance && (!nearest || Math.abs(delta) < Math.abs(nearest.delta))) {
nearest = { delta: delta, target: target, anchor: anchor };
}
});
});
if (!nearest) return;
if (!direction) {
result[axis] += nearest.delta;
} else if (nearest.anchor === 0) {
result[axis] += nearest.delta;
result[size] -= nearest.delta;
} else {
result[size] += nearest.delta;
}
guides.push({ axis: axis, position: nearest.target });
});
if (direction && ratio > 0) {
if (guides.some(function (guide) { return guide.axis === 'x'; })) {
result.height = result.width / ratio;
if (direction.includes('n')) result.y = box.y + box.height - result.height;
} else if (guides.some(function (guide) { return guide.axis === 'y'; })) {
result.width = result.height * ratio;
if (direction.includes('w')) result.x = box.x + box.width - result.width;
}
}
if (!direction) {
result.x = Math.max(0, Math.min(canvas.width - result.width, result.x));
result.y = Math.max(0, Math.min(canvas.height - result.height, result.y));
}
var normalized = fitLayout(result, canvas, direction ? ratio : 0, direction);
guides = guides.filter(function (guide) {
var size = guide.axis === 'x' ? 'width' : 'height';
return [0, 0.5, 1].some(function (anchor) {
return Math.abs(normalized[guide.axis] + normalized[size] * anchor - guide.position) <= 0.5;
});
});
return { box: normalized, guides: guides };
}
function getTemplateLayoutSnapshot(template) {
return {
width: Number(template.canvas_size_width),
height: Number(template.canvas_size_height),
regions: (template.regions || []).map(function (region) {
return {
id: Number(region.id), region_key: region.region_key,
x: Number(region.x), y: Number(region.y), width: Number(region.width), height: Number(region.height),
z_index: Math.max(1, Number(region.z_index) || 1), lock_ratio: String(region.lock_ratio || '')
};
}).sort(function (first, second) { return first.region_key < second.region_key ? -1 : first.region_key > second.region_key ? 1 : 0; })
};
}
function getTemplateAnimationSnapshot(template) {
return (template && Array.isArray(template.regions) ? template.regions : []).map(function (region) {
var animation = region.animation_json;
if (typeof animation === 'string') {
try { animation = JSON.parse(animation); } catch (_error) { animation = {}; }
}
return { id: Number(region.id), region_key: region.region_key, animation_json: animation || {} };
}).sort(function (first, second) { return first.region_key < second.region_key ? -1 : first.region_key > second.region_key ? 1 : 0; });
}
function retainLayoutAnimation(layout) {
return layout && Object.hasOwn(layout, 'animation_json')
? normalizeLayout({ animation_json: layout.animation_json }, {}) : null;
}
return { normalizeLayout: normalizeLayout, applyLayout: applyLayout, fitLayout: fitLayout, snapBox: snapBox, getTemplateLayoutSnapshot: getTemplateLayoutSnapshot, getTemplateAnimationSnapshot: getTemplateAnimationSnapshot, retainLayoutAnimation: retainLayoutAnimation };
});
@@ -0,0 +1,259 @@
export function createSlideAnimationEditor(options) {
var controls = window.pulseRegionAnimationControls;
var modal = document.getElementById('slide-animation-modal');
var openButton = document.getElementById('adjust-slide-animations');
var regionSelect = document.getElementById('slide-animation-region');
var card = document.getElementById('slide-animation-fields');
var advanced = document.getElementById('slide-animation-advanced');
var advancedFields = advanced.querySelector('[data-animation-advanced-fields]');
var stage = options.stage;
var previewHost = document.getElementById('slide-animation-preview');
var previewButtons = Array.prototype.slice.call(modal.querySelectorAll('[data-slide-animation-preview]'));
var sharedButton = document.getElementById('save-animations-to-template');
var saveStatus = document.getElementById('slide-animation-save-status');
var sharedSaving = false;
var active = false;
var draft = Object.create(null);
var baseline = '';
var sharedBaseline = null;
var previewMode = '';
var previewTimer = 0;
var previewToken = 0;
var home = null;
var nextSibling = null;
function template() { return options.getTemplate(); }
function regions() { return template() && template().regions || []; }
function selectedKey() { return regionSelect.value; }
function getConfig(regionKey) {
var region = regions().find(function (item) { return item.region_key === regionKey; });
return Object.prototype.hasOwnProperty.call(draft, regionKey)
? draft[regionKey]
: region && region.animation_json || '{}';
}
function getCurrentConfig() {
var config = controls.read(card);
return advanced.hidden ? config : controls.readAdvanced(advancedFields, config);
}
function syncDraft() {
if (!active || !selectedKey()) return;
stopPreview();
var config = getCurrentConfig();
controls.write(card, config);
draft[selectedKey()] = JSON.stringify(config);
updatePreviewButtons();
}
function renderRegion() {
var key = selectedKey();
var region = regions().find(function (item) { return item.region_key === key; });
if (!region) return;
controls.write(card, getConfig(key));
controls.populateAdvanced(advancedFields, getConfig(key));
advanced.hidden = true;
updatePreviewButtons();
document.getElementById('slide-animation-reset-region').disabled = !Object.prototype.hasOwnProperty.call(draft, key);
document.getElementById('slide-animation-reset-all').disabled = !Object.keys(draft).length;
}
function readResponse(response) {
return response.json().then(function (data) {
if (!response.ok) throw new Error(data.error || 'Unable to save shared animations.');
return data;
}, function () {
throw new Error('Unable to save shared animations. Check your connection and sign-in status.');
});
}
if (sharedButton) sharedButton.addEventListener('click', async function () {
if (sharedSaving) return;
stopPreview();
sharedSaving = true;
modal.setAttribute('aria-busy', 'true');
var controlsToRestore = Array.from(modal.querySelectorAll('button, input, select')).map(function (element) {
var state = { element: element, disabled: element.disabled };
element.disabled = true;
return state;
});
saveStatus.classList.remove('text-danger');
saveStatus.textContent = 'Checking shared animations...';
try {
var url = '/templates/' + encodeURIComponent(template().id) + '/animations';
var current = await readResponse(await fetch(url, { headers: { Accept: 'application/json' } }));
if (JSON.stringify(current.baseline) !== JSON.stringify(sharedBaseline)) {
throw new Error('The shared template animations have changed. Reload the slide before saving.');
}
var count = Number(current.slideCount) || 0;
if (!window.confirm('Save these animations to "' + current.template.name + '"? The template is used by ' + count + ' slide' + (count === 1 ? '' : 's') + '. Other slides keep their explicit animation overrides. Unsaved slide content is not saved.')) {
saveStatus.textContent = '';
return;
}
saveStatus.textContent = 'Saving shared animations...';
var animations = regions().map(function (region) {
return { region_key: region.region_key, animation_json: getConfig(region.region_key) };
});
var result = await readResponse(await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ slideId: options.slideId || null, baseline: sharedBaseline, animations: animations })
}));
options.updateTemplate(result.template);
draft = Object.create(null);
options.setAnimationOverrides(draft);
options.markEdited();
active = false;
saveStatus.textContent = '';
sharedSaving = false;
modal.removeAttribute('aria-busy');
window.pulseModal.hide(modal);
if (typeof window.showToast === 'function') window.showToast('Shared template animations saved.', 'success');
} catch (error) {
saveStatus.textContent = error.message;
saveStatus.classList.add('text-danger');
} finally {
sharedSaving = false;
modal.removeAttribute('aria-busy');
controlsToRestore.forEach(function (state) { state.element.disabled = state.disabled; });
renderRegion();
}
});
modal.addEventListener('hide.bs.modal', function (event) {
if (sharedSaving) event.preventDefault();
});
function getPreviewElements() {
return Array.prototype.slice.call(previewHost.querySelectorAll('.slide-preview-region[data-region-key]'));
}
function stopPreview() {
if (previewTimer) window.clearTimeout(previewTimer);
previewTimer = 0;
previewToken += 1;
previewMode = '';
getPreviewElements().forEach(function (element) {
element.className.split(/\s+/).forEach(function (className) {
if (className.indexOf('animate__') === 0) element.classList.remove(className);
});
['--animate-duration', '--animate-delay', '--animate-repeat'].forEach(function (property) { element.style.removeProperty(property); });
});
updatePreviewButtons();
}
function updatePreviewButtons() {
var modeKeys = { intro: 'intro', outro: 'outro', loop: 'loop' };
previewButtons.forEach(function (button) {
var mode = button.dataset.slideAnimationPreview;
var available = regions().some(function (region) {
var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(region.region_key));
return window.templateAnimationPresets.normalize(config[modeKeys[mode]].preset) !== 'none';
});
var disabled = !available || (previewMode && previewMode !== mode);
button.disabled = Boolean(disabled);
button.classList.toggle('btn-outline-secondary', Boolean(disabled));
button.classList.toggle('btn-info', !disabled);
button.setAttribute('aria-pressed', String(Boolean(previewMode === mode)));
var icon = button.querySelector('[data-animation-preview-icon]');
if (icon) icon.className = 'bi ' + (previewMode === mode ? 'bi-stop-fill' : 'bi-play-fill') + ' me-1';
});
}
function startPreview(mode) {
if (previewMode === mode) { stopPreview(); return; }
stopPreview();
previewMode = mode;
var token = previewToken;
var longest = 0;
getPreviewElements().forEach(function (element, index) {
var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(element.dataset.regionKey));
var step = config[mode] || {};
var preset = window.templateAnimationPresets.normalize(step.preset);
if (preset === 'none') return;
var delay = Math.max(0, Number(step.delay_ms) || 0) + index * 90;
var duration = Math.max(1, Number(step.duration_ms) || 1000);
var iterations = mode === 'loop' ? Math.max(1, Number(step.iterations) || 1) : 1;
element.style.setProperty('--animate-duration', duration + 'ms');
element.style.setProperty('--animate-delay', delay + 'ms');
element.style.setProperty('--animate-repeat', String(iterations));
element.classList.remove('animate__animated', 'animate__' + preset, 'animate__repeat-1');
void element.offsetWidth;
element.classList.add('animate__animated', 'animate__' + preset);
if (mode === 'loop') element.classList.add('animate__repeat-1');
longest = Math.max(longest, delay + duration * iterations);
});
updatePreviewButtons();
previewTimer = window.setTimeout(function () {
if (token === previewToken) stopPreview();
}, longest + 180);
}
previewButtons.forEach(function (button) {
button.addEventListener('click', function () { startPreview(button.dataset.slideAnimationPreview); });
});
openButton.addEventListener('click', function () {
if (!regions().length || !window.pulseModal) return;
regionSelect.replaceChildren();
regions().forEach(function (region) {
var option = document.createElement('option');
option.value = region.region_key;
option.textContent = region.label || region.region_key;
regionSelect.appendChild(option);
});
draft = options.getAnimationOverrides();
baseline = JSON.stringify(draft);
sharedBaseline = window.pulseRegionLayout.getTemplateAnimationSnapshot(template());
active = true;
var main = document.querySelector('.app-main');
var sidebarEdge = window.innerWidth >= 992 && main ? Math.max(0, main.getBoundingClientRect().left) : 0;
modal.style.setProperty('--slide-layout-left', sidebarEdge + 'px');
saveStatus.textContent = '';
saveStatus.classList.remove('text-danger');
home = stage.parentNode;
nextSibling = stage.nextSibling;
previewHost.appendChild(stage);
renderRegion();
options.requestPreviewRender();
window.pulseModal.show(modal);
});
modal.addEventListener('shown.bs.modal', function () {
options.requestPreviewRender();
regionSelect.focus();
});
modal.addEventListener('hidden.bs.modal', function () {
stopPreview();
active = false;
draft = Object.create(null);
if (home) home.insertBefore(stage, nextSibling);
options.requestPreviewRender();
openButton.focus();
});
modal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (button) {
button.removeAttribute('data-bs-dismiss');
button.addEventListener('click', function () { window.pulseModal.hide(modal); });
});
regionSelect.addEventListener('change', function () {
stopPreview();
renderRegion();
});
controls.bind(card, {
onChange: syncDraft,
onAdvanced: function () {
advanced.hidden = !advanced.hidden;
if (!advanced.hidden) controls.populateAdvanced(advancedFields, controls.read(card));
}
});
advancedFields.addEventListener('change', syncDraft);
document.getElementById('slide-animation-reset-region').addEventListener('click', function () {
stopPreview();
delete draft[selectedKey()];
renderRegion();
});
document.getElementById('slide-animation-reset-all').addEventListener('click', function () {
stopPreview();
draft = Object.create(null);
renderRegion();
});
document.getElementById('slide-animation-apply').addEventListener('click', function () {
stopPreview();
if (JSON.stringify(draft) !== baseline) {
options.setAnimationOverrides(draft);
options.markEdited();
}
window.pulseModal.hide(modal);
});
return { refresh: function () { openButton.disabled = active || !regions().length; } };
}
@@ -89,7 +89,7 @@ export function createSlideFormEditorController(options) {
return null;
}
return 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);
+38 -2
View File
@@ -2,6 +2,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
import { createSlideFormEditorController } from '/assets/js/slides/slide-form-editor.js';
import { 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 '<div class="slide-preview-region' + selected + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;">' + content + '</div>';
return '<div class="slide-preview-region' + selected + '" data-region-key="' + escapeHtml(region.region_key || '') + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;z-index:' + Math.max(1, Number(region.z_index) || 1) + ';">' + content + '</div>';
}).join('');
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();
@@ -0,0 +1,450 @@
export function createSlideLayoutEditor(options) {
var helpers = window.pulseRegionLayout;
var stage = options.stage;
var modal = document.getElementById('slide-layout-modal');
var workspace = document.getElementById('slide-layout-workspace');
var regionSelect = document.getElementById('slide-layout-region');
var hidden = document.getElementById('slide-layouts-json');
var openButton = document.getElementById('adjust-slide-layout');
var animationButton = document.getElementById('adjust-slide-animations');
var undoButton = document.getElementById('slide-layout-undo');
var redoButton = document.getElementById('slide-layout-redo');
var resetButton = document.getElementById('slide-layout-reset');
var snapInput = document.getElementById('slide-layout-snap');
var sharedButton = document.getElementById('save-layout-to-template');
var saveStatus = document.getElementById('slide-layout-save-status');
var sharedSaving = false;
var sharedBaseline = null;
var properties = document.getElementById('slide-layout-fields');
var layer = document.createElement('div');
layer.className = 'slide-layout-layer';
layer.hidden = true;
stage.appendChild(layer);
var fields = {};
['x', 'y', 'width', 'height', 'z_index', 'lock_ratio'].forEach(function (field) {
fields[field] = properties.querySelector('[name="region_' + (field === 'z_index' ? 'z' : field) + '[]"]');
fields[field].id = 'slide-layout-' + field;
});
var layouts = Object.create(null);
var selectedKey = '';
var history = [];
var historyIndex = 0;
var baseline = '';
var home = stage.parentNode;
var nextSibling = stage.nextSibling;
var active = false;
var applied = false;
var drag = null;
var guides = [];
async function readResponse(response) {
var data;
try { data = await response.json(); } catch (_error) { throw new Error('Unable to save the shared layout. Check your connection and sign-in status.'); }
if (!response.ok) throw new Error(data.error || 'Unable to save the shared layout.');
return data;
}
if (sharedButton) sharedButton.addEventListener('click', async function () {
if (sharedSaving) return;
if (Object.keys(fields).some(function (field) { return !fields[field].reportValidity(); })) return;
sharedSaving = true;
modal.setAttribute('aria-busy', 'true');
var controls = Array.from(modal.querySelectorAll('button, input, select')).map(function (input) {
var state = { input: input, disabled: input.disabled };
input.disabled = true;
return state;
});
saveStatus.classList.remove('text-danger');
saveStatus.textContent = 'Checking shared template...';
try {
var url = '/templates/' + encodeURIComponent(template().id) + '/layout';
var current = await readResponse(await fetch(url, { headers: { Accept: 'application/json' } }));
if (JSON.stringify(helpers.getTemplateLayoutSnapshot(current.template)) !== JSON.stringify(sharedBaseline)) {
throw new Error('The shared template layout has changed. Reload the slide before saving to the template.');
}
var count = Number(current.slideCount) || 0;
if (!window.confirm('Save this layout to "' + current.template.name + '"? It is used by ' + count + ' slide' + (count === 1 ? '' : 's') + '. Other slides keep their explicit overrides. Unsaved slide content is not saved.')) {
saveStatus.textContent = '';
return;
}
saveStatus.textContent = 'Saving shared layout...';
var result = await readResponse(await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ slideId: options.slideId || null, baseline: sharedBaseline, regions: helpers.getTemplateLayoutSnapshot(Object.assign({}, template(), { regions: regions().map(effective) })).regions })
}));
options.updateTemplate(result.template);
var inheritedLayouts = Object.create(null);
Object.keys(layouts).forEach(function (key) {
var animation = helpers.retainLayoutAnimation(layouts[key]);
if (animation) inheritedLayouts[key] = animation;
});
layouts = inheritedLayouts;
baseline = snapshot();
sharedBaseline = helpers.getTemplateLayoutSnapshot(template());
history = [baseline];
historyIndex = 0;
applied = true;
options.markEdited();
hidden.value = baseline;
sharedSaving = false;
saveStatus.textContent = '';
options.requestPreviewRender();
window.pulseModal.hide(modal);
if (typeof window.showToast === 'function') window.showToast('Shared template layout saved.', 'success');
} catch (error) {
saveStatus.textContent = error.message;
saveStatus.classList.add('text-danger');
} finally {
sharedSaving = false;
modal.removeAttribute('aria-busy');
controls.forEach(function (state) { state.input.disabled = state.disabled; });
render();
}
});
modal.addEventListener('hide.bs.modal', function (event) {
if (sharedSaving) event.preventDefault();
});
function template() { return options.getTemplate(); }
function canvas() {
var current = template();
return { width: Number(current && current.canvas_size_width) || 1920, height: Number(current && current.canvas_size_height) || 1080 };
}
function regions() { return template() && template().regions || []; }
function selected() {
return regions().find(function (region) { return region.region_key === selectedKey; });
}
function effective(region) {
return helpers.applyLayout(region, { layout: layouts[region.region_key] }, canvas());
}
function snapshot() { return JSON.stringify(layouts); }
function commit() {
var value = snapshot();
if (history[historyIndex] === value) return;
history = history.slice(0, historyIndex + 1);
history.push(value);
historyIndex = history.length - 1;
}
function update() {
hidden.value = snapshot();
render();
options.requestPreviewRender();
}
function change(box) {
if (!selected()) return;
var geometry = { x: box.x, y: box.y, width: box.width, height: box.height };
layouts[selectedKey] = helpers.normalizeLayout(Object.assign({}, layouts[selectedKey], geometry), canvas());
update();
}
function changeSettings(patch) {
if (!selected()) return;
var box = effective(selected());
var next = helpers.normalizeLayout(Object.assign({ x: box.x, y: box.y, width: box.width, height: box.height }, layouts[selectedKey], patch), canvas());
if (!next) return false;
layouts[selectedKey] = next;
commit();
update();
return true;
}
function render() {
openButton.disabled = active || !regions().length;
animationButton.disabled = active || !regions().length;
if (!active || sharedSaving) return;
if (!selected()) selectedKey = regions().length ? regions()[0].region_key : '';
var region = selected();
var box = region && effective(region);
Object.keys(fields).forEach(function (field) {
fields[field].disabled = !box;
fields[field].value = box && box[field] !== undefined && box[field] !== null ? box[field] : '';
fields[field].setCustomValidity('');
if (field === 'x' || field === 'width') fields[field].max = canvas().width;
if (field === 'y' || field === 'height') fields[field].max = canvas().height;
});
regionSelect.value = selectedKey;
undoButton.disabled = historyIndex === 0;
redoButton.disabled = historyIndex >= history.length - 1;
resetButton.disabled = !Object.hasOwn(layouts, selectedKey);
document.getElementById('slide-layout-reset-all').disabled = !Object.keys(layouts).length;
layer.replaceChildren();
regions().map(effective).sort(function (first, second) {
return Number(first.z_index || 1) - Number(second.z_index || 1);
}).forEach(function (item) {
var itemBox = effective(item);
var hit = document.createElement('div');
hit.className = 'designer-rect slide-layout-hit' + (item.region_key === selectedKey ? ' selected' : '');
hit.dataset.regionKey = item.region_key;
hit.setAttribute('role', 'button');
hit.setAttribute('aria-label', item.label || item.region_key);
hit.setAttribute('aria-pressed', String(item.region_key === selectedKey));
hit.tabIndex = 0;
hit.style.left = itemBox.x / canvas().width * 100 + '%';
hit.style.top = itemBox.y / canvas().height * 100 + '%';
hit.style.width = itemBox.width / canvas().width * 100 + '%';
hit.style.height = itemBox.height / canvas().height * 100 + '%';
var label = document.createElement('span');
label.className = 'designer-rect-label';
label.textContent = item.label || item.region_key;
hit.appendChild(label);
if (item.region_key === selectedKey) {
['nw', 'ne', 'sw', 'se'].forEach(function (direction) {
var handle = document.createElement('span');
handle.className = 'resize-handle ' + direction;
handle.dataset.direction = direction;
hit.appendChild(handle);
});
}
layer.appendChild(hit);
});
guides.forEach(function (guide) {
var line = document.createElement('div');
line.className = 'layout-alignment-guide layout-alignment-guide--' + guide.axis;
line.style[guide.axis === 'x' ? 'left' : 'top'] = guide.position / canvas()[guide.axis === 'x' ? 'width' : 'height'] * 100 + '%';
layer.appendChild(line);
});
}
function open() {
if (active || !regions().length || !window.pulseModal) return;
baseline = snapshot();
sharedBaseline = helpers.getTemplateLayoutSnapshot(template());
saveStatus.textContent = '';
saveStatus.classList.remove('text-danger');
history = [baseline];
historyIndex = 0;
applied = false;
active = true;
home = stage.parentNode;
nextSibling = stage.nextSibling;
regionSelect.replaceChildren();
regions().forEach(function (region) {
var option = document.createElement('option');
option.value = region.region_key;
option.textContent = region.label || region.region_key;
regionSelect.appendChild(option);
});
workspace.appendChild(stage);
stage.classList.add('slide-layout-stage');
stage.tabIndex = 0;
layer.hidden = false;
var main = document.querySelector('.app-main');
var sidebarEdge = window.innerWidth >= 992 && main ? Math.max(0, main.getBoundingClientRect().left) : 0;
modal.style.setProperty('--slide-layout-left', sidebarEdge + 'px');
render();
window.pulseModal.show(modal);
}
modal.addEventListener('shown.bs.modal', function () {
options.requestPreviewRender();
stage.focus();
});
modal.addEventListener('hidden.bs.modal', function () {
if (!applied) layouts = JSON.parse(baseline);
active = false;
drag = null;
guides = [];
layer.hidden = true;
stage.classList.remove('slide-layout-stage');
stage.removeAttribute('tabindex');
home.insertBefore(stage, nextSibling);
update();
openButton.focus();
});
openButton.addEventListener('click', open);
modal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (button) {
button.removeAttribute('data-bs-dismiss');
button.addEventListener('click', function () { window.pulseModal.hide(modal); });
});
document.getElementById('slide-layout-apply').addEventListener('click', function () {
applied = true;
if (snapshot() !== baseline) options.markEdited();
hidden.value = snapshot();
window.pulseModal.hide(modal);
});
regionSelect.addEventListener('change', function () {
selectedKey = regionSelect.value;
guides = [];
render();
});
function restoreHistory(offset) {
var index = historyIndex + offset;
if (index < 0 || index >= history.length) return;
historyIndex = index;
layouts = JSON.parse(history[index]);
guides = [];
update();
}
undoButton.addEventListener('click', function () { restoreHistory(-1); });
redoButton.addEventListener('click', function () { restoreHistory(1); });
resetButton.addEventListener('click', function () {
delete layouts[selectedKey];
commit();
update();
});
document.getElementById('slide-layout-reset-all').addEventListener('click', function () {
layouts = Object.create(null);
commit();
update();
});
Object.keys(fields).forEach(function (field) {
fields[field].addEventListener('keydown', function (event) {
if (event.key === 'Enter') { event.preventDefault(); fields[field].blur(); }
});
fields[field].addEventListener('change', function () {
if (field === 'lock_ratio') {
var ratioValue = fields[field].value.trim().replace(/\s+/g, '');
var parts = ratioValue.split(':').map(Number);
var patch = { lock_ratio: ratioValue };
if (/^\d+:\d+$/.test(ratioValue) && parts[0] > 0 && parts[1] > 0) {
var current = effective(selected());
var fitted = helpers.fitLayout({ x: current.x, y: current.y, width: current.width, height: current.width * parts[1] / parts[0] }, canvas(), parts[0] / parts[1], 'se');
Object.assign(patch, fitted);
}
if (!changeSettings(patch)) {
fields[field].setCustomValidity('Enter a positive ratio such as 16:9, or leave blank.');
fields[field].reportValidity();
}
return;
}
if (!selected() || fields[field].value === '' || !Number.isFinite(fields[field].valueAsNumber)) {
render();
return;
}
var box = effective(selected());
box[field] = fields[field].valueAsNumber;
if (field === 'z_index') { changeSettings({ z_index: box.z_index }); return; }
var ratio = String(box.lock_ratio || '').split(':').map(Number);
if (ratio[0] > 0 && ratio[1] > 0) {
if (field === 'width') box.height = Math.round(box.width * ratio[1] / ratio[0]);
if (field === 'height') box.width = Math.round(box.height * ratio[0] / ratio[1]);
}
change(helpers.fitLayout(box, canvas(), ratio[0] / ratio[1], 'se'));
commit();
render();
});
});
stage.addEventListener('pointerdown', function (event) {
if (!active || sharedSaving || event.button !== 0) return;
var hit = event.target.closest('.slide-layout-hit');
if (!hit) return;
event.preventDefault();
selectedKey = hit.dataset.regionKey;
drag = {
pointerId: event.pointerId,
startX: event.clientX, startY: event.clientY,
box: effective(selected()),
direction: event.target.dataset.direction || '',
snapshot: snapshot()
};
stage.setPointerCapture(event.pointerId);
stage.focus({ preventScroll: true });
render();
});
stage.addEventListener('pointermove', function (event) {
if (sharedSaving || !drag || event.pointerId !== drag.pointerId) return;
var bounds = stage.getBoundingClientRect();
var dx = (event.clientX - drag.startX) * canvas().width / bounds.width;
var dy = (event.clientY - drag.startY) * canvas().height / bounds.height;
if (Math.abs(event.clientX - drag.startX) + Math.abs(event.clientY - drag.startY) < 2) return;
var box = Object.assign({}, drag.box);
var direction = drag.direction;
var aspect = 0;
if (direction) {
if (direction.includes('w')) { box.x += dx; box.width -= dx; }
if (direction.includes('e')) box.width += dx;
if (direction.includes('n')) { box.y += dy; box.height -= dy; }
if (direction.includes('s')) box.height += dy;
var ratio = String(effective(selected()).lock_ratio || '').split(':').map(Number);
if (ratio[0] > 0 && ratio[1] > 0) {
aspect = ratio[0] / ratio[1];
if (Math.abs(dy * aspect) > Math.abs(dx)) box.width = Math.max(1, box.height * aspect);
box.height = Math.max(1, box.width / aspect);
if (direction.includes('w')) box.x = drag.box.x + drag.box.width - box.width;
if (direction.includes('n')) box.y = drag.box.y + drag.box.height - box.height;
}
box = helpers.fitLayout(box, canvas(), aspect, direction);
} else {
box.x = Math.max(0, Math.min(canvas().width - box.width, box.x + dx));
box.y = Math.max(0, Math.min(canvas().height - box.height, box.y + dy));
}
var peers = regions().filter(function (region) { return region.region_key !== selectedKey; }).map(effective);
var snapped = helpers.snapBox(box, peers, canvas(), snapInput.checked && !event.altKey ? 6 * canvas().width / bounds.width : -1, direction, aspect);
guides = snapped.guides;
change(snapped.box);
});
function endDrag(event) {
if (!drag || event.pointerId !== drag.pointerId) return;
if (event.type === 'pointercancel') layouts = JSON.parse(drag.snapshot);
if (stage.hasPointerCapture(event.pointerId)) stage.releasePointerCapture(event.pointerId);
drag = null;
guides = [];
commit();
update();
}
stage.addEventListener('pointerup', endDrag);
stage.addEventListener('pointercancel', endDrag);
stage.addEventListener('keydown', function (event) {
if (!active || sharedSaving) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault();
restoreHistory(event.shiftKey ? 1 : -1);
return;
}
var hit = event.target.closest('.slide-layout-hit');
if (hit) selectedKey = hit.dataset.regionKey;
if (!selected()) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
stage.focus();
render();
return;
}
var movements = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
var movement = movements[event.key];
if (!movement) return;
event.preventDefault();
var box = effective(selected());
var step = event.shiftKey ? 10 : 1;
box.x = Math.max(0, Math.min(canvas().width - box.width, box.x + movement[0] * step));
box.y = Math.max(0, Math.min(canvas().height - box.height, box.y + movement[1] * step));
change(box);
commit();
render();
stage.focus();
});
return {
apply: effective,
refresh: render,
getAnimationOverrides: function () {
var overrides = Object.create(null);
Object.keys(layouts).forEach(function (key) {
if (layouts[key] && layouts[key].animation_json !== undefined) overrides[key] = layouts[key].animation_json;
});
return overrides;
},
getAnimation: function (regionKey) {
var region = regions().find(function (item) { return item.region_key === regionKey; });
var override = layouts[regionKey] && layouts[regionKey].animation_json;
return override !== undefined ? override : String(region && region.animation_json || '{}');
},
setAnimationOverrides: function (overrides) {
regions().forEach(function (region) {
var key = region.region_key;
var current = layouts[key] ? Object.assign({}, layouts[key]) : {};
delete current.animation_json;
if (Object.prototype.hasOwnProperty.call(overrides, key)) current.animation_json = overrides[key];
if (Object.keys(current).length) layouts[key] = helpers.normalizeLayout(current, canvas());
else delete layouts[key];
});
commit();
update();
},
reset: function (content) {
layouts = Object.create(null);
regions().forEach(function (region) {
var layout = helpers.normalizeLayout(content && content[region.region_key] && content[region.region_key].layout, canvas());
if (layout) layouts[region.region_key] = layout;
});
hidden.value = snapshot();
render();
}
};
}
+44 -139
View File
@@ -20,6 +20,8 @@
: (Array.isArray(templateData) ? templateData : []);
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 '<div class="designer-rect' + selected + '" data-index="' + index + '" style="--designer-overlay-scale:1;left:' + renderedBox.left + 'px;top:' + renderedBox.top + 'px;width:' + renderedBox.width + 'px;height:' + renderedBox.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
}).join('');
alignmentGuides.forEach(function (guide) {
overlay.innerHTML += '<div class="layout-alignment-guide layout-alignment-guide--' + guide.axis + '" style="' + (guide.axis === 'x' ? 'left' : 'top') + ':' + (guide.position / canvasSize[guide.axis === 'x' ? 'width' : 'height'] * 100) + '%;"></div>';
});
if (draft) {
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;
+175 -1
View File
@@ -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 || []);
@@ -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
};
}
@@ -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])
};
}
+10 -22
View File
@@ -1,23 +1,11 @@
<div class="card border-0 shadow-sm mx-auto" style="max-width: 48rem;">
<div class="card-body p-4 p-md-5">
<div class="d-flex align-items-start gap-3">
<div class="fs-1 text-danger">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div>
<p class="text-uppercase text-muted fw-semibold mb-1">{{statusCode}}</p>
<h1 class="h3 mb-3">{{errorTitle}}</h1>
<p class="mb-4">{{errorMessage}}</p>
{{#if detail}}
<pre class="small bg-body-tertiary border rounded p-3 mb-4 text-wrap">{{detail}}</pre>
{{/if}}
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-primary" href="{{backUrl}}">{{backLabel}}</a>
{{#if searchUrl}}
<a class="btn btn-outline-secondary" href="{{searchUrl}}">Search</a>
{{/if}}
</div>
</div>
</div>
<section class="error-page-content text-center">
<p class="display-1 fw-bold text-{{statusVariant}} mb-3" aria-label="Error {{statusCode}}">{{statusCode}}</p>
<h1 class="h2 mb-3">{{errorTitle}}</h1>
<p class="text-body-secondary mb-4">{{errorMessage}}</p>
<div class="d-flex justify-content-center gap-2 flex-wrap">
{{#if searchUrl}}
<a class="btn btn-primary" href="{{searchUrl}}"><i class="bi bi-search me-2" aria-hidden="true"></i>Search</a>
{{/if}}
<a class="btn btn-outline-secondary" href="{{backUrl}}"><i class="bi bi-arrow-left me-2" aria-hidden="true"></i>{{backLabel}}</a>
</div>
</div>
</section>
+87 -1
View File
@@ -8,6 +8,7 @@
<div class="slide-editor-shell">
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
<form method="post" action="{{action}}" enctype="multipart/form-data" id="slide-form" class="slide-form-stack" {{{formAttrs}}}>
<input type="hidden" id="slide-layouts-json" name="region_layouts_json" value="{}" />
<div class="card card-outline card-primary admin-form-card slide-template-card">
<div class="card-header bg-body-tertiary">
<h3 class="card-title">Slide details</h3>
@@ -68,12 +69,92 @@
<div class="slide-preview-overlay" id="slide-preview-overlay"></div>
</div>
</div>
<button type="button" class="btn btn-outline-secondary w-100 mt-3" id="open-preview-popup">Open popup preview</button>
<div class="d-flex justify-content-end gap-2 mt-3">
<button type="button" class="btn btn-outline-secondary flex-grow-1" id="open-preview-popup"><i class="bi bi-box-arrow-up-right me-1" aria-hidden="true"></i>Open popup preview</button>
<button type="button" class="btn btn-outline-primary flex-shrink-0" id="adjust-slide-layout" title="Adjust layout" aria-label="Adjust layout" disabled><i class="bi bi-bounding-box" aria-hidden="true"></i></button>
<button type="button" class="btn btn-outline-secondary flex-shrink-0" id="adjust-slide-animations" title="Adjust animations" aria-label="Adjust animations" disabled><i class="bi bi-stars" aria-hidden="true"></i></button>
</div>
</div>
</aside>
</div>
</div>
{{#> modal-shell modalId="slide-layout-modal" modalLabelId="slide-layout-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable slide-layout-dialog"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="slide-layout-modal-label">Adjust layout <span class="text-body-secondary fs-6">This slide only</span></h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel layout changes"></button>
</div>
<div class="modal-body slide-layout-body">
<div class="slide-layout-workspace" id="slide-layout-workspace"></div>
<aside class="slide-layout-properties">
<label for="slide-layout-region" class="form-label">Region</label>
<select id="slide-layout-region" class="form-select mb-3"></select>
<div id="slide-layout-fields" class="d-grid gap-3">{{> signage/templates/region-layout-fields slideLayout=true}}</div>
<div class="form-check form-switch my-3"><input type="checkbox" id="slide-layout-snap" class="form-check-input" checked /><label for="slide-layout-snap" class="form-check-label">Snap to guides</label></div>
<div class="btn-group mb-3" role="group" aria-label="Layout history">
<button type="button" id="slide-layout-undo" class="btn btn-outline-secondary" title="Undo" aria-label="Undo"><i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i></button>
<button type="button" id="slide-layout-redo" class="btn btn-outline-secondary" title="Redo" aria-label="Redo"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i></button>
</div>
<button type="button" id="slide-layout-reset" class="btn btn-outline-secondary w-100 mb-2"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Reset region to template</button>
<button type="button" id="slide-layout-reset-all" class="btn btn-outline-secondary w-100"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Reset all to template</button>
</aside>
</div>
<div class="modal-footer">
<span id="slide-layout-save-status" class="small me-auto" role="status" aria-live="polite"></span>
<div class="btn-group dropup" role="group" aria-label="Layout save actions">
<button type="button" class="btn btn-primary" id="slide-layout-apply">Apply to this slide</button>
{{#if (hasPermission currentUser 'templates.update')}}
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false" title="Layout save options" aria-label="Layout save options"></button>
<div class="dropdown-menu dropdown-menu-end">
<button type="button" class="dropdown-item" id="save-layout-to-template">Save layout to shared template</button>
</div>
{{/if}}
</div>
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
</div>
{{/modal-shell}}
{{#> modal-shell modalId="slide-animation-modal" modalLabelId="slide-animation-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable slide-layout-dialog"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="slide-animation-modal-label">Adjust animations <span class="text-body-secondary fs-6">This slide only</span></h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel animation changes"></button>
</div>
<div class="modal-body slide-animation-body">
<section class="slide-animation-preview-panel">
<div id="slide-animation-preview"></div>
</section>
<div class="slide-animation-controls">
<div class="btn-group w-100 mb-3" role="group" aria-label="Animation preview">
<button type="button" class="btn btn-outline-secondary" data-slide-animation-preview="intro" aria-pressed="false"><i class="bi bi-play-fill me-1" aria-hidden="true" data-animation-preview-icon></i>Intro</button>
<button type="button" class="btn btn-outline-secondary" data-slide-animation-preview="outro" aria-pressed="false"><i class="bi bi-play-fill me-1" aria-hidden="true" data-animation-preview-icon></i>Outro</button>
<button type="button" class="btn btn-outline-secondary" data-slide-animation-preview="loop" aria-pressed="false"><i class="bi bi-play-fill me-1" aria-hidden="true" data-animation-preview-icon></i>Attention</button>
</div>
<label for="slide-animation-region" class="form-label">Region</label>
<select id="slide-animation-region" class="form-select mb-3"></select>
<div id="slide-animation-fields" class="d-grid gap-3">
{{> signage/templates/region-animation-controls}}
<div id="slide-animation-advanced" class="d-grid gap-3" hidden>
<h3 class="h6 mb-0">Advanced animation settings</h3>
<div class="slide-animation-advanced-fields" data-animation-advanced-fields>{{> signage/templates/animation-advanced-fields}}</div>
</div>
</div>
<button type="button" id="slide-animation-reset-region" class="btn btn-outline-secondary w-100 mt-3"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Reset region animations to template</button>
<button type="button" id="slide-animation-reset-all" class="btn btn-outline-secondary w-100 mt-2"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Reset all animations to template</button>
</div>
</div>
<div class="modal-footer">
<span id="slide-animation-save-status" class="small me-auto" role="status" aria-live="polite"></span>
<div class="btn-group dropup" role="group" aria-label="Animation save actions">
<button type="button" class="btn btn-primary" id="slide-animation-apply">Apply to this slide</button>
{{#if (hasPermission currentUser 'templates.update')}}
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false" title="Animation save options" aria-label="Animation save options"></button>
<div class="dropdown-menu dropdown-menu-end"><button type="button" class="dropdown-item" id="save-animations-to-template">Save animations to shared template</button></div>
{{/if}}
</div>
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
</div>
{{/modal-shell}}
{{#> modal-shell modalId="slide-image-cropper-modal" modalLabelId="slide-image-cropper-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header">
<div>
@@ -125,6 +206,11 @@
{{/if}}
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
<script src="/assets/js/lib/modal.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/region-layout.js?v={{appVersion}}"></script>
<script src="/assets/js/templates/animation-presets.js?v={{appVersion}}"></script>
<script src="/assets/js/templates/template-designer-utils.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/region-animation-controls.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/qr-code-svg.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/upload-utils.js?v={{appVersion}}"></script>
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
@@ -0,0 +1,33 @@
<div class="row g-3">
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent"><strong>Intro</strong></div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">Preset<select class="form-select" data-animation-modal-step="intro" data-animation-modal-field="preset">{{#each animationPresets.advanced.intro}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<label class="d-grid gap-1">Duration %<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="intro" data-animation-modal-field="duration_ms" placeholder="100" /></label>
<label class="d-grid gap-1">Delay ms<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="delay_ms" placeholder="0" /></label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent"><strong>Outro</strong></div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">Preset<select class="form-select" data-animation-modal-step="outro" data-animation-modal-field="preset">{{#each animationPresets.advanced.outro}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<label class="d-grid gap-1">Duration %<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="outro" data-animation-modal-field="duration_ms" placeholder="100" /></label>
<label class="d-grid gap-1">Delay ms<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="delay_ms" placeholder="0" /></label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent"><strong>Attention seekers</strong></div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">Preset<select class="form-select" data-animation-modal-step="loop" data-animation-modal-field="preset">{{#each animationPresets.advanced.attentionSeekers}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<label class="d-grid gap-1">Duration %<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="loop" data-animation-modal-field="duration_ms" placeholder="100" /></label>
<label class="d-grid gap-1">Delay ms<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="delay_ms" placeholder="0" /></label>
<label class="d-grid gap-1">Repeat<input class="form-control" type="number" min="1" max="999" step="1" data-animation-modal-step="loop" data-animation-modal-field="iterations" placeholder="1" /></label>
</div>
</div>
</div>
</div>
@@ -6,89 +6,7 @@
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-grid gap-4">
<div class="row g-3">
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Intro</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="intro" data-animation-modal-field="preset">
{{#each animationPresets.advanced.intro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration %
<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="intro" data-animation-modal-field="duration_ms" placeholder="100" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Outro</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="outro" data-animation-modal-field="preset">
{{#each animationPresets.advanced.outro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration %
<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="outro" data-animation-modal-field="duration_ms" placeholder="100" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Attention seekers</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="loop" data-animation-modal-field="preset">
{{#each animationPresets.advanced.attentionSeekers}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration %
<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="loop" data-animation-modal-field="duration_ms" placeholder="100" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
<label class="d-grid gap-1">
Repeat
<input class="form-control" type="number" min="1" max="999" step="1" data-animation-modal-step="loop" data-animation-modal-field="iterations" placeholder="1" />
</label>
</div>
</div>
</div>
</div>
</div>
<div class="modal-body d-grid gap-4">{{> signage/templates/animation-advanced-fields}}</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-animation-modal-reset>Reset to defaults</button>
<div class="d-flex gap-2">
+4 -57
View File
@@ -16,15 +16,16 @@
<div class="template-designer-layout">
<div class="card card-outline card-secondary admin-form-card mb-3">
<div class="card-header bg-body-tertiary">
<div class="card-header bg-body-tertiary d-flex align-items-center justify-content-between template-designer-main-header">
<h3 class="card-title">Designer</h3>
<div class="form-check form-switch template-snap-control mb-0 ms-auto"><label for="template-layout-snap" class="form-check-label">Snap to guides</label><input type="checkbox" id="template-layout-snap" class="form-check-input" checked /></div>
</div>
<div class="card-body">
<div class="template-designer-main">
<div class="designer-stage" id="designer-stage" style="aspect-ratio: {{template.canvas_size_width}} / {{template.canvas_size_height}};">
<img id="background-preview" src="{{template.background_image_path}}" alt="" style="{{#if template.background_image_path}}{{else}}display:none;{{/if}}" />
<div class="empty-bg" id="background-empty" style="opacity:0.4;{{#if template.background_image_path}}display:none;{{/if}}">No background image selected</div>
<div class="designer-overlay" id="designer-overlay"></div>
<div class="designer-overlay" id="designer-overlay" tabindex="0" role="group" aria-label="Template layout"></div>
</div>
</div>
</div>
@@ -168,61 +169,7 @@
<input type="hidden" name="region_type[]" value="text" />
<input type="hidden" name="region_key[]" value="" />
<input type="hidden" name="region_label[]" value="" />
<div class="region-field-grid region-field-grid--position">
<label>X<input class="form-control" type="number" name="region_x[]" value="80" required /></label>
<label>Y<input class="form-control" type="number" name="region_y[]" value="80" required /></label>
<label>Z-Index<input class="form-control" type="number" name="region_z[]" min="1" step="1" value="1" required /></label>
</div>
<div class="region-field-grid region-field-grid--size">
<label>Width<input class="form-control" type="number" name="region_width[]" min="1" step="1" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" min="1" step="1" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" maxlength="20" data-limit-text-length placeholder="Ratio, e.g. 16:9" /></label>
<label>Remove<button type="button" class="btn btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>
</div>
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>
Intro
<select class="form-select" data-animation-preset="intro">
{{#each animationPresets.basic.intro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="intro" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1">
<label>
Outro
<select class="form-select" data-animation-preset="outro">
{{#each animationPresets.basic.outro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="outro" hidden>Reset to basic</button>
</div>
</div>
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>
Attention seekers
<select class="form-select" data-animation-preset="loop">
{{#each animationPresets.basic.attentionSeekers}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="loop" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1">
<label>
Advanced
<button type="button" class="btn btn-outline-secondary w-100" data-animation-advanced-button>Advanced</button>
</label>
</div>
</div>
<textarea class="visually-hidden" name="region_animation_json[]" data-animation-json-input rows="1"></textarea>
{{> signage/templates/region-layout-fields}}
</div>
</div>
</div>
@@ -0,0 +1,18 @@
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>Intro<select class="form-select" data-animation-preset="intro">{{#each animationPresets.basic.intro}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="intro" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1">
<label>Outro<select class="form-select" data-animation-preset="outro">{{#each animationPresets.basic.outro}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="outro" hidden>Reset to basic</button>
</div>
</div>
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>Attention seekers<select class="form-select" data-animation-preset="loop">{{#each animationPresets.basic.attentionSeekers}}<option value="{{value}}">{{label}}</option>{{/each}}</select></label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="loop" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1"><label>Advanced<button type="button" class="btn btn-outline-secondary w-100" data-animation-advanced-button aria-expanded="false">Advanced</button></label></div>
</div>
<textarea class="visually-hidden" name="region_animation_json[]" data-animation-json-input rows="1"></textarea>
@@ -0,0 +1,14 @@
<div class="region-field-grid region-field-grid--position">
<label>X<input class="form-control" type="number" name="region_x[]" value="80" required /></label>
<label>Y<input class="form-control" type="number" name="region_y[]" value="80" required /></label>
<label>Z-Index<input class="form-control" type="number" name="region_z[]" min="1" step="1" value="1" required /></label>
</div>
<div class="region-field-grid region-field-grid--size">
<label>Width<input class="form-control" type="number" name="region_width[]" min="1" step="1" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" min="1" step="1" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" maxlength="20" data-limit-text-length placeholder="Ratio, e.g. 16:9" /></label>
{{#unless slideLayout}}<label>Remove<button type="button" class="btn btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>{{/unless}}
</div>
{{#unless slideLayout}}
{{> signage/templates/region-animation-controls}}
{{/unless}}