Release 2.16.0
This commit is contained in:
1 parent
821593dd8f
commit
3514d514a3
36 files changed
+2329
-346
No files matched your search
@@ -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)),
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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])
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
Reference in new issue
Block a user