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

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

No files matched your search

+237 -2
View File
@@ -1,3 +1,8 @@
.error-page-content {
width: 100%;
max-width: 52rem;
}
.media-picker-item {
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
@@ -2351,6 +2356,236 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
background: #111;
}
#slide-layout-modal,
#slide-animation-modal {
left: var(--slide-layout-left, 0px);
width: calc(100% - var(--slide-layout-left, 0px));
}
.slide-layout-dialog {
width: calc(100% - 2rem);
max-width: none;
height: calc(100dvh - 4rem);
max-height: 860px;
min-height: 0;
margin: 2rem auto;
}
.slide-layout-dialog .modal-content {
height: 100%;
}
.slide-layout-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 21rem;
gap: 1.25rem;
min-height: 0;
}
.slide-layout-workspace {
container-type: size;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
}
.slide-layout-stage {
width: min(100%, calc(100cqh * var(--slide-layout-ratio, 1.777778)));
flex-shrink: 0;
overflow: visible;
outline: 1px solid var(--bs-border-color);
isolation: isolate;
touch-action: none;
}
.slide-layout-stage .slide-preview-canvas {
overflow: hidden;
}
.slide-layout-properties {
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 4px;
}
.slide-layout-properties .region-field-grid--size,
.slide-layout-properties .region-field-grid--position {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.slide-animation-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 21rem;
gap: 1.25rem;
min-height: 0;
}
.slide-animation-controls {
min-width: 0;
overflow-y: auto;
padding: 0 4px;
}
.slide-animation-controls .region-field-grid--animation {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.slide-animation-advanced-fields {
min-width: 0;
overflow-x: clip;
}
#slide-animation-advanced {
gap: 0.5rem !important;
}
.slide-animation-advanced-fields .row {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.5rem;
margin: 0;
}
.slide-animation-advanced-fields .row > * {
width: 100%;
max-width: 100%;
min-width: 0;
padding: 0;
}
.slide-animation-advanced-fields .card,
.slide-animation-advanced-fields .card-body,
.slide-animation-advanced-fields label {
min-width: 0;
}
.slide-animation-advanced-fields input,
.slide-animation-advanced-fields select {
width: 100%;
min-width: 0;
}
#slide-animation-advanced[hidden] {
display: none !important;
}
.slide-animation-preview-panel {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
}
#slide-animation-preview {
width: 100%;
}
#slide-animation-preview .slide-preview-stage {
width: 100%;
}
.slide-animation-preview-panel [data-slide-animation-preview][aria-pressed="true"] {
color: var(--bs-btn-active-color, #fff);
background-color: var(--bs-info);
border-color: var(--bs-info);
}
@media (max-width: 767.98px) {
.slide-animation-body {
grid-template-columns: minmax(0, 1fr);
}
.slide-animation-controls {
overflow: visible;
}
}
.template-designer-main-header::after {
display: none;
}
.template-snap-control {
display: flex;
align-items: center;
gap: 0.5rem;
padding-left: 0;
}
.template-snap-control .form-check-input {
float: none;
margin: 0;
flex: 0 0 auto;
order: 2;
}
.template-snap-control .form-check-label {
order: 1;
}
.slide-layout-layer[hidden] {
display: none;
}
.slide-layout-layer {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
--designer-overlay-scale: 1;
}
.slide-layout-hit {
pointer-events: auto;
}
.slide-layout-hit.selected {
z-index: 4;
}
.slide-layout-hit:focus-visible {
outline: 3px solid #ffc107;
}
.layout-alignment-guide {
position: absolute;
pointer-events: none;
z-index: 10;
background: #ff4081;
}
.layout-alignment-guide--x {
top: 0;
bottom: 0;
width: 1px;
}
.layout-alignment-guide--y {
left: 0;
right: 0;
height: 1px;
}
@media (max-width: 767.98px) {
.slide-layout-dialog {
width: calc(100% - 1rem);
height: calc(100dvh - 2rem);
margin: 1rem auto;
}
.slide-layout-body {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(14rem, 1fr) auto;
overflow-y: auto;
}
.slide-layout-properties {
overflow: visible;
}
}
.slide-preview-canvas {
position: absolute;
top: 0;
@@ -2812,8 +3047,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
background: rgba(13, 110, 253, 0.12);
box-sizing: border-box;
border-radius: 0;
min-width: 12px;
min-height: 12px;
min-width: 0;
min-height: 0;
cursor: move;
}
+9 -2
View File
@@ -59,9 +59,16 @@
return registry.renderEditorCardShell({
region: region,
headerActions: '<span class="chip">HTML</span>',
bodyClass: 'card-body p-3 d-grid gap-2',
bodyHtml: '' +
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>'
'<div class="d-flex align-items-center justify-content-between gap-2">' +
'<div class="muted">HTML is rendered in a sandboxed iframe preview.</div>' +
'<div class="btn-group btn-group-sm template-editor-size-controls" role="group" aria-label="Editor size controls">' +
'<button type="button" class="btn btn-outline-secondary" data-editor-size-action="decrease" aria-label="Decrease editor size" title="Decrease editor size">-</button>' +
'<button type="button" class="btn btn-outline-secondary" data-editor-size-action="increase" aria-label="Increase editor size" title="Increase editor size">+</button>' +
'</div>' +
'</div>' +
'<textarea name="region_html_' + region.id + '" class="form-control html-editor-source" data-region-id="' + region.id + '" rows="10" style="resize:none;" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>'
});
}
@@ -0,0 +1,83 @@
(function () {
function normalize(value) { return window.templateDesignerUtils.normalizeAnimationConfig(value); }
function normalizePreset(value) { return window.templateAnimationPresets.normalize(value); }
function read(card) {
var config = normalize(card.querySelector('[name="region_animation_json[]"]').value || '{}');
card.querySelectorAll('[data-animation-preset]').forEach(function (input) {
if (input.dataset.animationPresetCustom !== '1' && input.selectedIndex >= 0) {
config[input.dataset.animationPreset].preset = normalizePreset(input.value);
}
});
return config;
}
function write(card, value) {
var config = normalize(value);
card.querySelector('[name="region_animation_json[]"]').value = JSON.stringify(config);
card.querySelectorAll('[data-animation-preset]').forEach(function (input) {
var step = input.dataset.animationPreset;
var preset = config[step].preset;
var custom = !Array.from(input.options).some(function (option) { return option.value === preset; });
input.value = preset;
input.hidden = custom;
if (custom) input.dataset.animationPresetCustom = '1';
else delete input.dataset.animationPresetCustom;
var reset = card.querySelector('[data-animation-reset-button="' + step + '"]');
if (reset) reset.hidden = !custom;
});
return config;
}
function setPreset(card, step, preset) {
var config = read(card);
config[step] = Object.assign({}, config[step], {
preset: normalizePreset(preset), duration_ms: null, delay_ms: null,
iterations: null, easing: '', fill_mode: ''
});
return write(card, config);
}
function bind(card, options) {
card.querySelectorAll('[data-animation-preset]').forEach(function (input) {
input.addEventListener('change', function () {
setPreset(card, input.dataset.animationPreset, input.value);
options.onChange();
});
});
card.querySelectorAll('[data-animation-reset-button]').forEach(function (button) {
button.addEventListener('click', function (event) {
event.preventDefault();
setPreset(card, button.dataset.animationResetButton, 'none');
options.onChange();
});
});
var advanced = card.querySelector('[data-animation-advanced-button]');
if (advanced) advanced.addEventListener('click', options.onAdvanced);
}
function populateAdvanced(container, value) {
var config = normalize(value);
container.querySelectorAll('[data-animation-modal-field]').forEach(function (input) {
var step = config[input.dataset.animationModalStep];
var field = input.dataset.animationModalField;
var value = step[field];
if (field === 'duration_ms') value = Math.min(200, Math.max(1, Math.round(Number(value) / 10) || 100));
if (field === 'delay_ms') value = Number(value) || 0;
if (field === 'iterations') value = Math.min(999, Math.max(1, Number(value) || 1));
input.value = value;
});
}
function readAdvanced(container, value) {
var config = normalize(value);
container.querySelectorAll('[data-animation-modal-field]').forEach(function (input) {
var step = config[input.dataset.animationModalStep];
var field = input.dataset.animationModalField;
var value = input.value;
if (field === 'duration_ms') value = Math.min(200, Math.max(1, Math.round(Number(value)) || 100)) * 10;
else if (field === 'delay_ms') value = Math.max(0, Math.round(Number(value)) || 0);
else if (field === 'iterations') value = Math.min(999, Math.max(1, Math.round(Number(value)) || 1));
step[field] = value;
});
return normalize(config);
}
window.pulseRegionAnimationControls = {
read: read, write: write, setPreset: setPreset, bind: bind,
populateAdvanced: populateAdvanced, readAdvanced: readAdvanced
};
})();
+160
View File
@@ -0,0 +1,160 @@
(function (root, factory) {
var helpers = factory();
if (typeof module === 'object' && module.exports) {
module.exports = helpers;
} else {
root.pulseRegionLayout = helpers;
}
})(typeof window !== 'undefined' ? window : globalThis, function () {
function normalizeLayout(value, canvas) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
var fields = ['x', 'y', 'width', 'height'];
var hasGeometry = fields.some(function (field) { return Object.hasOwn(value, field); });
if (hasGeometry && !fields.every(function (field) {
return typeof value[field] === 'number' && Number.isFinite(value[field]);
})) return null;
if (!hasGeometry && !['z_index', 'lock_ratio', 'animation_json'].some(function (field) { return Object.hasOwn(value, field); })) return null;
var width = Math.max(1, Math.round(Number(canvas.width) || 1920));
var height = Math.max(1, Math.round(Number(canvas.height) || 1080));
var result = hasGeometry ? {
x: Math.max(0, Math.min(width - 1, Math.round(value.x))),
y: Math.max(0, Math.min(height - 1, Math.round(value.y))),
width: Math.max(1, Math.min(width, Math.round(value.width))),
height: Math.max(1, Math.min(height, Math.round(value.height)))
} : {};
if (hasGeometry) {
result.width = Math.min(result.width, width - result.x);
result.height = Math.min(result.height, height - result.y);
}
if (Object.hasOwn(value, 'z_index')) {
if (typeof value.z_index !== 'number' || !Number.isFinite(value.z_index)) return null;
result.z_index = Math.max(1, Math.round(value.z_index));
}
if (Object.hasOwn(value, 'lock_ratio')) {
var ratio = String(value.lock_ratio || '').trim().replace(/\s+/g, '');
if (ratio && (!/^\d+:\d+$/.test(ratio) || ratio.split(':').some(function (part) { return !(Number(part) > 0); }))) return null;
result.lock_ratio = ratio;
}
if (Object.hasOwn(value, 'animation_json')) {
var animation = value.animation_json;
try {
if (typeof animation === 'string') animation = JSON.parse(animation);
} catch (_error) { return null; }
if (!animation || typeof animation !== 'object' || Array.isArray(animation)) return null;
result.animation_json = animation;
}
return result;
}
function applyLayout(region, content, canvas) {
var layout = normalizeLayout(content && content.layout, canvas);
return Object.assign({}, region, layout || {});
}
function fitLayout(box, canvas, ratio, direction) {
box = { x: box.x, y: box.y, width: box.width, height: box.height };
if (!(ratio > 0)) return normalizeLayout(box, canvas);
var west = String(direction || '').includes('w');
var north = String(direction || '').includes('n');
var anchorX = west ? box.x + box.width : box.x;
var anchorY = north ? box.y + box.height : box.y;
var maxWidth = Math.max(1, west ? anchorX : canvas.width - anchorX);
var maxHeight = Math.max(1, north ? anchorY : canvas.height - anchorY);
var width = Math.max(1, Math.min(box.width, maxWidth, maxHeight * ratio));
var height = Math.max(1, width / ratio);
return normalizeLayout({
x: west ? anchorX - width : anchorX,
y: north ? anchorY - height : anchorY,
width: width, height: height
}, canvas);
}
function snapBox(box, peers, canvas, tolerance, direction, ratio) {
var result = Object.assign({}, box);
var guides = [];
['x', 'y'].forEach(function (axis) {
var size = axis === 'x' ? 'width' : 'height';
var startHandle = axis === 'x' ? 'w' : 'n';
var endHandle = axis === 'x' ? 'e' : 's';
var extent = Number(canvas[size]);
var targets = [0, extent / 2, extent];
peers.forEach(function (peer) {
targets.push(Number(peer[axis]), Number(peer[axis]) + Number(peer[size]) / 2, Number(peer[axis]) + Number(peer[size]));
});
var anchors = direction
? (direction.indexOf(startHandle) !== -1 ? [0] : direction.indexOf(endHandle) !== -1 ? [1] : [])
: [0, 0.5, 1];
var nearest = null;
anchors.forEach(function (anchor) {
targets.forEach(function (target) {
var delta = target - (box[axis] + box[size] * anchor);
if (Math.abs(delta) <= tolerance && (!nearest || Math.abs(delta) < Math.abs(nearest.delta))) {
nearest = { delta: delta, target: target, anchor: anchor };
}
});
});
if (!nearest) return;
if (!direction) {
result[axis] += nearest.delta;
} else if (nearest.anchor === 0) {
result[axis] += nearest.delta;
result[size] -= nearest.delta;
} else {
result[size] += nearest.delta;
}
guides.push({ axis: axis, position: nearest.target });
});
if (direction && ratio > 0) {
if (guides.some(function (guide) { return guide.axis === 'x'; })) {
result.height = result.width / ratio;
if (direction.includes('n')) result.y = box.y + box.height - result.height;
} else if (guides.some(function (guide) { return guide.axis === 'y'; })) {
result.width = result.height * ratio;
if (direction.includes('w')) result.x = box.x + box.width - result.width;
}
}
if (!direction) {
result.x = Math.max(0, Math.min(canvas.width - result.width, result.x));
result.y = Math.max(0, Math.min(canvas.height - result.height, result.y));
}
var normalized = fitLayout(result, canvas, direction ? ratio : 0, direction);
guides = guides.filter(function (guide) {
var size = guide.axis === 'x' ? 'width' : 'height';
return [0, 0.5, 1].some(function (anchor) {
return Math.abs(normalized[guide.axis] + normalized[size] * anchor - guide.position) <= 0.5;
});
});
return { box: normalized, guides: guides };
}
function getTemplateLayoutSnapshot(template) {
return {
width: Number(template.canvas_size_width),
height: Number(template.canvas_size_height),
regions: (template.regions || []).map(function (region) {
return {
id: Number(region.id), region_key: region.region_key,
x: Number(region.x), y: Number(region.y), width: Number(region.width), height: Number(region.height),
z_index: Math.max(1, Number(region.z_index) || 1), lock_ratio: String(region.lock_ratio || '')
};
}).sort(function (first, second) { return first.region_key < second.region_key ? -1 : first.region_key > second.region_key ? 1 : 0; })
};
}
function getTemplateAnimationSnapshot(template) {
return (template && Array.isArray(template.regions) ? template.regions : []).map(function (region) {
var animation = region.animation_json;
if (typeof animation === 'string') {
try { animation = JSON.parse(animation); } catch (_error) { animation = {}; }
}
return { id: Number(region.id), region_key: region.region_key, animation_json: animation || {} };
}).sort(function (first, second) { return first.region_key < second.region_key ? -1 : first.region_key > second.region_key ? 1 : 0; });
}
function retainLayoutAnimation(layout) {
return layout && Object.hasOwn(layout, 'animation_json')
? normalizeLayout({ animation_json: layout.animation_json }, {}) : null;
}
return { normalizeLayout: normalizeLayout, applyLayout: applyLayout, fitLayout: fitLayout, snapBox: snapBox, getTemplateLayoutSnapshot: getTemplateLayoutSnapshot, getTemplateAnimationSnapshot: getTemplateAnimationSnapshot, retainLayoutAnimation: retainLayoutAnimation };
});
@@ -0,0 +1,259 @@
export function createSlideAnimationEditor(options) {
var controls = window.pulseRegionAnimationControls;
var modal = document.getElementById('slide-animation-modal');
var openButton = document.getElementById('adjust-slide-animations');
var regionSelect = document.getElementById('slide-animation-region');
var card = document.getElementById('slide-animation-fields');
var advanced = document.getElementById('slide-animation-advanced');
var advancedFields = advanced.querySelector('[data-animation-advanced-fields]');
var stage = options.stage;
var previewHost = document.getElementById('slide-animation-preview');
var previewButtons = Array.prototype.slice.call(modal.querySelectorAll('[data-slide-animation-preview]'));
var sharedButton = document.getElementById('save-animations-to-template');
var saveStatus = document.getElementById('slide-animation-save-status');
var sharedSaving = false;
var active = false;
var draft = Object.create(null);
var baseline = '';
var sharedBaseline = null;
var previewMode = '';
var previewTimer = 0;
var previewToken = 0;
var home = null;
var nextSibling = null;
function template() { return options.getTemplate(); }
function regions() { return template() && template().regions || []; }
function selectedKey() { return regionSelect.value; }
function getConfig(regionKey) {
var region = regions().find(function (item) { return item.region_key === regionKey; });
return Object.prototype.hasOwnProperty.call(draft, regionKey)
? draft[regionKey]
: region && region.animation_json || '{}';
}
function getCurrentConfig() {
var config = controls.read(card);
return advanced.hidden ? config : controls.readAdvanced(advancedFields, config);
}
function syncDraft() {
if (!active || !selectedKey()) return;
stopPreview();
var config = getCurrentConfig();
controls.write(card, config);
draft[selectedKey()] = JSON.stringify(config);
updatePreviewButtons();
}
function renderRegion() {
var key = selectedKey();
var region = regions().find(function (item) { return item.region_key === key; });
if (!region) return;
controls.write(card, getConfig(key));
controls.populateAdvanced(advancedFields, getConfig(key));
advanced.hidden = true;
updatePreviewButtons();
document.getElementById('slide-animation-reset-region').disabled = !Object.prototype.hasOwnProperty.call(draft, key);
document.getElementById('slide-animation-reset-all').disabled = !Object.keys(draft).length;
}
function readResponse(response) {
return response.json().then(function (data) {
if (!response.ok) throw new Error(data.error || 'Unable to save shared animations.');
return data;
}, function () {
throw new Error('Unable to save shared animations. Check your connection and sign-in status.');
});
}
if (sharedButton) sharedButton.addEventListener('click', async function () {
if (sharedSaving) return;
stopPreview();
sharedSaving = true;
modal.setAttribute('aria-busy', 'true');
var controlsToRestore = Array.from(modal.querySelectorAll('button, input, select')).map(function (element) {
var state = { element: element, disabled: element.disabled };
element.disabled = true;
return state;
});
saveStatus.classList.remove('text-danger');
saveStatus.textContent = 'Checking shared animations...';
try {
var url = '/templates/' + encodeURIComponent(template().id) + '/animations';
var current = await readResponse(await fetch(url, { headers: { Accept: 'application/json' } }));
if (JSON.stringify(current.baseline) !== JSON.stringify(sharedBaseline)) {
throw new Error('The shared template animations have changed. Reload the slide before saving.');
}
var count = Number(current.slideCount) || 0;
if (!window.confirm('Save these animations to "' + current.template.name + '"? The template is used by ' + count + ' slide' + (count === 1 ? '' : 's') + '. Other slides keep their explicit animation overrides. Unsaved slide content is not saved.')) {
saveStatus.textContent = '';
return;
}
saveStatus.textContent = 'Saving shared animations...';
var animations = regions().map(function (region) {
return { region_key: region.region_key, animation_json: getConfig(region.region_key) };
});
var result = await readResponse(await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ slideId: options.slideId || null, baseline: sharedBaseline, animations: animations })
}));
options.updateTemplate(result.template);
draft = Object.create(null);
options.setAnimationOverrides(draft);
options.markEdited();
active = false;
saveStatus.textContent = '';
sharedSaving = false;
modal.removeAttribute('aria-busy');
window.pulseModal.hide(modal);
if (typeof window.showToast === 'function') window.showToast('Shared template animations saved.', 'success');
} catch (error) {
saveStatus.textContent = error.message;
saveStatus.classList.add('text-danger');
} finally {
sharedSaving = false;
modal.removeAttribute('aria-busy');
controlsToRestore.forEach(function (state) { state.element.disabled = state.disabled; });
renderRegion();
}
});
modal.addEventListener('hide.bs.modal', function (event) {
if (sharedSaving) event.preventDefault();
});
function getPreviewElements() {
return Array.prototype.slice.call(previewHost.querySelectorAll('.slide-preview-region[data-region-key]'));
}
function stopPreview() {
if (previewTimer) window.clearTimeout(previewTimer);
previewTimer = 0;
previewToken += 1;
previewMode = '';
getPreviewElements().forEach(function (element) {
element.className.split(/\s+/).forEach(function (className) {
if (className.indexOf('animate__') === 0) element.classList.remove(className);
});
['--animate-duration', '--animate-delay', '--animate-repeat'].forEach(function (property) { element.style.removeProperty(property); });
});
updatePreviewButtons();
}
function updatePreviewButtons() {
var modeKeys = { intro: 'intro', outro: 'outro', loop: 'loop' };
previewButtons.forEach(function (button) {
var mode = button.dataset.slideAnimationPreview;
var available = regions().some(function (region) {
var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(region.region_key));
return window.templateAnimationPresets.normalize(config[modeKeys[mode]].preset) !== 'none';
});
var disabled = !available || (previewMode && previewMode !== mode);
button.disabled = Boolean(disabled);
button.classList.toggle('btn-outline-secondary', Boolean(disabled));
button.classList.toggle('btn-info', !disabled);
button.setAttribute('aria-pressed', String(Boolean(previewMode === mode)));
var icon = button.querySelector('[data-animation-preview-icon]');
if (icon) icon.className = 'bi ' + (previewMode === mode ? 'bi-stop-fill' : 'bi-play-fill') + ' me-1';
});
}
function startPreview(mode) {
if (previewMode === mode) { stopPreview(); return; }
stopPreview();
previewMode = mode;
var token = previewToken;
var longest = 0;
getPreviewElements().forEach(function (element, index) {
var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(element.dataset.regionKey));
var step = config[mode] || {};
var preset = window.templateAnimationPresets.normalize(step.preset);
if (preset === 'none') return;
var delay = Math.max(0, Number(step.delay_ms) || 0) + index * 90;
var duration = Math.max(1, Number(step.duration_ms) || 1000);
var iterations = mode === 'loop' ? Math.max(1, Number(step.iterations) || 1) : 1;
element.style.setProperty('--animate-duration', duration + 'ms');
element.style.setProperty('--animate-delay', delay + 'ms');
element.style.setProperty('--animate-repeat', String(iterations));
element.classList.remove('animate__animated', 'animate__' + preset, 'animate__repeat-1');
void element.offsetWidth;
element.classList.add('animate__animated', 'animate__' + preset);
if (mode === 'loop') element.classList.add('animate__repeat-1');
longest = Math.max(longest, delay + duration * iterations);
});
updatePreviewButtons();
previewTimer = window.setTimeout(function () {
if (token === previewToken) stopPreview();
}, longest + 180);
}
previewButtons.forEach(function (button) {
button.addEventListener('click', function () { startPreview(button.dataset.slideAnimationPreview); });
});
openButton.addEventListener('click', function () {
if (!regions().length || !window.pulseModal) return;
regionSelect.replaceChildren();
regions().forEach(function (region) {
var option = document.createElement('option');
option.value = region.region_key;
option.textContent = region.label || region.region_key;
regionSelect.appendChild(option);
});
draft = options.getAnimationOverrides();
baseline = JSON.stringify(draft);
sharedBaseline = window.pulseRegionLayout.getTemplateAnimationSnapshot(template());
active = true;
var main = document.querySelector('.app-main');
var sidebarEdge = window.innerWidth >= 992 && main ? Math.max(0, main.getBoundingClientRect().left) : 0;
modal.style.setProperty('--slide-layout-left', sidebarEdge + 'px');
saveStatus.textContent = '';
saveStatus.classList.remove('text-danger');
home = stage.parentNode;
nextSibling = stage.nextSibling;
previewHost.appendChild(stage);
renderRegion();
options.requestPreviewRender();
window.pulseModal.show(modal);
});
modal.addEventListener('shown.bs.modal', function () {
options.requestPreviewRender();
regionSelect.focus();
});
modal.addEventListener('hidden.bs.modal', function () {
stopPreview();
active = false;
draft = Object.create(null);
if (home) home.insertBefore(stage, nextSibling);
options.requestPreviewRender();
openButton.focus();
});
modal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (button) {
button.removeAttribute('data-bs-dismiss');
button.addEventListener('click', function () { window.pulseModal.hide(modal); });
});
regionSelect.addEventListener('change', function () {
stopPreview();
renderRegion();
});
controls.bind(card, {
onChange: syncDraft,
onAdvanced: function () {
advanced.hidden = !advanced.hidden;
if (!advanced.hidden) controls.populateAdvanced(advancedFields, controls.read(card));
}
});
advancedFields.addEventListener('change', syncDraft);
document.getElementById('slide-animation-reset-region').addEventListener('click', function () {
stopPreview();
delete draft[selectedKey()];
renderRegion();
});
document.getElementById('slide-animation-reset-all').addEventListener('click', function () {
stopPreview();
draft = Object.create(null);
renderRegion();
});
document.getElementById('slide-animation-apply').addEventListener('click', function () {
stopPreview();
if (JSON.stringify(draft) !== baseline) {
options.setAnimationOverrides(draft);
options.markEdited();
}
window.pulseModal.hide(modal);
});
return { refresh: function () { openButton.disabled = active || !regions().length; } };
}
@@ -89,7 +89,7 @@ export function createSlideFormEditorController(options) {
return null;
}
return templateFields.querySelector('.editor-holder[data-region-id="' + regionId + '"]');
return templateFields.querySelector('.editor-holder[data-region-id="' + regionId + '"], .html-editor-source[data-region-id="' + regionId + '"]');
}
function getEditorHeight(regionId) {
@@ -734,6 +734,11 @@ export function createSlideFormEditorController(options) {
bindEditorSizeControls();
watchThemeChanges();
Array.prototype.forEach.call(templateFields.querySelectorAll('.html-editor-source'), function (source) {
var regionId = source.getAttribute('data-region-id');
applyEditorHeight(regionId, getEditorHeight(regionId), { silent: true });
});
var holders = Array.prototype.slice.call(templateFields.querySelectorAll('.editor-holder'));
return Promise.all(holders.map(function (holder) {
return createEditorForHolder(holder);
+38 -2
View File
@@ -2,6 +2,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
import { createSlideFormEditorController } from '/assets/js/slides/slide-form-editor.js';
import { createSlideFormRegionHelpers } from '/assets/js/slides/slide-form-regions.js';
import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-preview.js';
import { createSlideLayoutEditor } from '/assets/js/slides/slide-layout-editor.js';
import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-editor.js';
(function () {
var DEFAULT_FONT_SIZE = 32;
@@ -58,6 +60,37 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var sidebarBaseOffset = 16;
var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
var regionTypes = window.pulseRegionTypes || {};
var slideLayoutEditor = createSlideLayoutEditor({
slideId: slideEditorData.slideId,
stage: slidePreviewStage,
getTemplate: getSelectedTemplate,
requestPreviewRender: requestPreviewRender,
updateTemplate: function (updatedTemplate) {
Object.assign(getSelectedTemplate(), updatedTemplate);
},
markEdited: function () {
slideForm.dataset.dirty = 'true';
templateSelectorLock.arm();
templateSelectorLock.markEdited();
}
});
var slideAnimationEditor = createSlideAnimationEditor({
stage: slidePreviewStage,
slideId: slideEditorData.slideId,
getTemplate: getSelectedTemplate,
requestPreviewRender: requestPreviewRender,
updateTemplate: function (updatedTemplate) {
Object.assign(getSelectedTemplate(), updatedTemplate);
},
getAnimation: slideLayoutEditor.getAnimation,
getAnimationOverrides: slideLayoutEditor.getAnimationOverrides,
setAnimationOverrides: slideLayoutEditor.setAnimationOverrides,
markEdited: function () {
slideForm.dataset.dirty = 'true';
templateSelectorLock.arm();
templateSelectorLock.markEdited();
}
});
function escapeHtml(value) {
return String(value ?? '')
@@ -614,6 +647,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
currentPreviewCanvasWidth = canvasWidth;
currentPreviewCanvasHeight = canvasHeight;
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
slidePreviewStage.style.setProperty('--slide-layout-ratio', String(canvasWidth / canvasHeight));
applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path, template.background_gradient);
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
if (!(template.regions || []).length) {
@@ -651,7 +685,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
slidePreviewCanvas.style.height = canvasHeight + 'px';
slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')';
slidePreviewCanvas.style.transformOrigin = 'top left';
var regions = template.regions || [];
var regions = (template.regions || []).map(slideLayoutEditor.apply);
slidePreviewOverlay.innerHTML = regions.map(function (region) {
var box = getRegionBox(region, canvasWidth, canvasHeight);
var card = templateFields.querySelector('[data-region-id="' + region.id + '"]');
@@ -680,7 +714,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
: region.region_type === 'html'
? ' slide-preview-html-region'
: ' slide-preview-text-region';
return '<div class="slide-preview-region' + selected + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;">' + content + '</div>';
return '<div class="slide-preview-region' + selected + '" data-region-key="' + escapeHtml(region.region_key || '') + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;z-index:' + Math.max(1, Number(region.z_index) || 1) + ';">' + content + '</div>';
}).join('');
regions.forEach(function (region) {
@@ -692,6 +726,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
startPreviewVideoPlayback(slidePreviewOverlay);
slideLayoutEditor.refresh();
syncPopupPreview();
}
@@ -723,6 +758,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
clearImagePreviewUrls();
slideFormEditorController.destroyEditors();
var template = getTemplateById(templateSelect.value);
slideLayoutEditor.reset(Number(templateSelect.value) === Number(existingTemplateId) ? existingContent : {});
if (!template) {
templateFields.innerHTML = '';
requestPreviewRender();
@@ -0,0 +1,450 @@
export function createSlideLayoutEditor(options) {
var helpers = window.pulseRegionLayout;
var stage = options.stage;
var modal = document.getElementById('slide-layout-modal');
var workspace = document.getElementById('slide-layout-workspace');
var regionSelect = document.getElementById('slide-layout-region');
var hidden = document.getElementById('slide-layouts-json');
var openButton = document.getElementById('adjust-slide-layout');
var animationButton = document.getElementById('adjust-slide-animations');
var undoButton = document.getElementById('slide-layout-undo');
var redoButton = document.getElementById('slide-layout-redo');
var resetButton = document.getElementById('slide-layout-reset');
var snapInput = document.getElementById('slide-layout-snap');
var sharedButton = document.getElementById('save-layout-to-template');
var saveStatus = document.getElementById('slide-layout-save-status');
var sharedSaving = false;
var sharedBaseline = null;
var properties = document.getElementById('slide-layout-fields');
var layer = document.createElement('div');
layer.className = 'slide-layout-layer';
layer.hidden = true;
stage.appendChild(layer);
var fields = {};
['x', 'y', 'width', 'height', 'z_index', 'lock_ratio'].forEach(function (field) {
fields[field] = properties.querySelector('[name="region_' + (field === 'z_index' ? 'z' : field) + '[]"]');
fields[field].id = 'slide-layout-' + field;
});
var layouts = Object.create(null);
var selectedKey = '';
var history = [];
var historyIndex = 0;
var baseline = '';
var home = stage.parentNode;
var nextSibling = stage.nextSibling;
var active = false;
var applied = false;
var drag = null;
var guides = [];
async function readResponse(response) {
var data;
try { data = await response.json(); } catch (_error) { throw new Error('Unable to save the shared layout. Check your connection and sign-in status.'); }
if (!response.ok) throw new Error(data.error || 'Unable to save the shared layout.');
return data;
}
if (sharedButton) sharedButton.addEventListener('click', async function () {
if (sharedSaving) return;
if (Object.keys(fields).some(function (field) { return !fields[field].reportValidity(); })) return;
sharedSaving = true;
modal.setAttribute('aria-busy', 'true');
var controls = Array.from(modal.querySelectorAll('button, input, select')).map(function (input) {
var state = { input: input, disabled: input.disabled };
input.disabled = true;
return state;
});
saveStatus.classList.remove('text-danger');
saveStatus.textContent = 'Checking shared template...';
try {
var url = '/templates/' + encodeURIComponent(template().id) + '/layout';
var current = await readResponse(await fetch(url, { headers: { Accept: 'application/json' } }));
if (JSON.stringify(helpers.getTemplateLayoutSnapshot(current.template)) !== JSON.stringify(sharedBaseline)) {
throw new Error('The shared template layout has changed. Reload the slide before saving to the template.');
}
var count = Number(current.slideCount) || 0;
if (!window.confirm('Save this layout to "' + current.template.name + '"? It is used by ' + count + ' slide' + (count === 1 ? '' : 's') + '. Other slides keep their explicit overrides. Unsaved slide content is not saved.')) {
saveStatus.textContent = '';
return;
}
saveStatus.textContent = 'Saving shared layout...';
var result = await readResponse(await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ slideId: options.slideId || null, baseline: sharedBaseline, regions: helpers.getTemplateLayoutSnapshot(Object.assign({}, template(), { regions: regions().map(effective) })).regions })
}));
options.updateTemplate(result.template);
var inheritedLayouts = Object.create(null);
Object.keys(layouts).forEach(function (key) {
var animation = helpers.retainLayoutAnimation(layouts[key]);
if (animation) inheritedLayouts[key] = animation;
});
layouts = inheritedLayouts;
baseline = snapshot();
sharedBaseline = helpers.getTemplateLayoutSnapshot(template());
history = [baseline];
historyIndex = 0;
applied = true;
options.markEdited();
hidden.value = baseline;
sharedSaving = false;
saveStatus.textContent = '';
options.requestPreviewRender();
window.pulseModal.hide(modal);
if (typeof window.showToast === 'function') window.showToast('Shared template layout saved.', 'success');
} catch (error) {
saveStatus.textContent = error.message;
saveStatus.classList.add('text-danger');
} finally {
sharedSaving = false;
modal.removeAttribute('aria-busy');
controls.forEach(function (state) { state.input.disabled = state.disabled; });
render();
}
});
modal.addEventListener('hide.bs.modal', function (event) {
if (sharedSaving) event.preventDefault();
});
function template() { return options.getTemplate(); }
function canvas() {
var current = template();
return { width: Number(current && current.canvas_size_width) || 1920, height: Number(current && current.canvas_size_height) || 1080 };
}
function regions() { return template() && template().regions || []; }
function selected() {
return regions().find(function (region) { return region.region_key === selectedKey; });
}
function effective(region) {
return helpers.applyLayout(region, { layout: layouts[region.region_key] }, canvas());
}
function snapshot() { return JSON.stringify(layouts); }
function commit() {
var value = snapshot();
if (history[historyIndex] === value) return;
history = history.slice(0, historyIndex + 1);
history.push(value);
historyIndex = history.length - 1;
}
function update() {
hidden.value = snapshot();
render();
options.requestPreviewRender();
}
function change(box) {
if (!selected()) return;
var geometry = { x: box.x, y: box.y, width: box.width, height: box.height };
layouts[selectedKey] = helpers.normalizeLayout(Object.assign({}, layouts[selectedKey], geometry), canvas());
update();
}
function changeSettings(patch) {
if (!selected()) return;
var box = effective(selected());
var next = helpers.normalizeLayout(Object.assign({ x: box.x, y: box.y, width: box.width, height: box.height }, layouts[selectedKey], patch), canvas());
if (!next) return false;
layouts[selectedKey] = next;
commit();
update();
return true;
}
function render() {
openButton.disabled = active || !regions().length;
animationButton.disabled = active || !regions().length;
if (!active || sharedSaving) return;
if (!selected()) selectedKey = regions().length ? regions()[0].region_key : '';
var region = selected();
var box = region && effective(region);
Object.keys(fields).forEach(function (field) {
fields[field].disabled = !box;
fields[field].value = box && box[field] !== undefined && box[field] !== null ? box[field] : '';
fields[field].setCustomValidity('');
if (field === 'x' || field === 'width') fields[field].max = canvas().width;
if (field === 'y' || field === 'height') fields[field].max = canvas().height;
});
regionSelect.value = selectedKey;
undoButton.disabled = historyIndex === 0;
redoButton.disabled = historyIndex >= history.length - 1;
resetButton.disabled = !Object.hasOwn(layouts, selectedKey);
document.getElementById('slide-layout-reset-all').disabled = !Object.keys(layouts).length;
layer.replaceChildren();
regions().map(effective).sort(function (first, second) {
return Number(first.z_index || 1) - Number(second.z_index || 1);
}).forEach(function (item) {
var itemBox = effective(item);
var hit = document.createElement('div');
hit.className = 'designer-rect slide-layout-hit' + (item.region_key === selectedKey ? ' selected' : '');
hit.dataset.regionKey = item.region_key;
hit.setAttribute('role', 'button');
hit.setAttribute('aria-label', item.label || item.region_key);
hit.setAttribute('aria-pressed', String(item.region_key === selectedKey));
hit.tabIndex = 0;
hit.style.left = itemBox.x / canvas().width * 100 + '%';
hit.style.top = itemBox.y / canvas().height * 100 + '%';
hit.style.width = itemBox.width / canvas().width * 100 + '%';
hit.style.height = itemBox.height / canvas().height * 100 + '%';
var label = document.createElement('span');
label.className = 'designer-rect-label';
label.textContent = item.label || item.region_key;
hit.appendChild(label);
if (item.region_key === selectedKey) {
['nw', 'ne', 'sw', 'se'].forEach(function (direction) {
var handle = document.createElement('span');
handle.className = 'resize-handle ' + direction;
handle.dataset.direction = direction;
hit.appendChild(handle);
});
}
layer.appendChild(hit);
});
guides.forEach(function (guide) {
var line = document.createElement('div');
line.className = 'layout-alignment-guide layout-alignment-guide--' + guide.axis;
line.style[guide.axis === 'x' ? 'left' : 'top'] = guide.position / canvas()[guide.axis === 'x' ? 'width' : 'height'] * 100 + '%';
layer.appendChild(line);
});
}
function open() {
if (active || !regions().length || !window.pulseModal) return;
baseline = snapshot();
sharedBaseline = helpers.getTemplateLayoutSnapshot(template());
saveStatus.textContent = '';
saveStatus.classList.remove('text-danger');
history = [baseline];
historyIndex = 0;
applied = false;
active = true;
home = stage.parentNode;
nextSibling = stage.nextSibling;
regionSelect.replaceChildren();
regions().forEach(function (region) {
var option = document.createElement('option');
option.value = region.region_key;
option.textContent = region.label || region.region_key;
regionSelect.appendChild(option);
});
workspace.appendChild(stage);
stage.classList.add('slide-layout-stage');
stage.tabIndex = 0;
layer.hidden = false;
var main = document.querySelector('.app-main');
var sidebarEdge = window.innerWidth >= 992 && main ? Math.max(0, main.getBoundingClientRect().left) : 0;
modal.style.setProperty('--slide-layout-left', sidebarEdge + 'px');
render();
window.pulseModal.show(modal);
}
modal.addEventListener('shown.bs.modal', function () {
options.requestPreviewRender();
stage.focus();
});
modal.addEventListener('hidden.bs.modal', function () {
if (!applied) layouts = JSON.parse(baseline);
active = false;
drag = null;
guides = [];
layer.hidden = true;
stage.classList.remove('slide-layout-stage');
stage.removeAttribute('tabindex');
home.insertBefore(stage, nextSibling);
update();
openButton.focus();
});
openButton.addEventListener('click', open);
modal.querySelectorAll('[data-bs-dismiss="modal"]').forEach(function (button) {
button.removeAttribute('data-bs-dismiss');
button.addEventListener('click', function () { window.pulseModal.hide(modal); });
});
document.getElementById('slide-layout-apply').addEventListener('click', function () {
applied = true;
if (snapshot() !== baseline) options.markEdited();
hidden.value = snapshot();
window.pulseModal.hide(modal);
});
regionSelect.addEventListener('change', function () {
selectedKey = regionSelect.value;
guides = [];
render();
});
function restoreHistory(offset) {
var index = historyIndex + offset;
if (index < 0 || index >= history.length) return;
historyIndex = index;
layouts = JSON.parse(history[index]);
guides = [];
update();
}
undoButton.addEventListener('click', function () { restoreHistory(-1); });
redoButton.addEventListener('click', function () { restoreHistory(1); });
resetButton.addEventListener('click', function () {
delete layouts[selectedKey];
commit();
update();
});
document.getElementById('slide-layout-reset-all').addEventListener('click', function () {
layouts = Object.create(null);
commit();
update();
});
Object.keys(fields).forEach(function (field) {
fields[field].addEventListener('keydown', function (event) {
if (event.key === 'Enter') { event.preventDefault(); fields[field].blur(); }
});
fields[field].addEventListener('change', function () {
if (field === 'lock_ratio') {
var ratioValue = fields[field].value.trim().replace(/\s+/g, '');
var parts = ratioValue.split(':').map(Number);
var patch = { lock_ratio: ratioValue };
if (/^\d+:\d+$/.test(ratioValue) && parts[0] > 0 && parts[1] > 0) {
var current = effective(selected());
var fitted = helpers.fitLayout({ x: current.x, y: current.y, width: current.width, height: current.width * parts[1] / parts[0] }, canvas(), parts[0] / parts[1], 'se');
Object.assign(patch, fitted);
}
if (!changeSettings(patch)) {
fields[field].setCustomValidity('Enter a positive ratio such as 16:9, or leave blank.');
fields[field].reportValidity();
}
return;
}
if (!selected() || fields[field].value === '' || !Number.isFinite(fields[field].valueAsNumber)) {
render();
return;
}
var box = effective(selected());
box[field] = fields[field].valueAsNumber;
if (field === 'z_index') { changeSettings({ z_index: box.z_index }); return; }
var ratio = String(box.lock_ratio || '').split(':').map(Number);
if (ratio[0] > 0 && ratio[1] > 0) {
if (field === 'width') box.height = Math.round(box.width * ratio[1] / ratio[0]);
if (field === 'height') box.width = Math.round(box.height * ratio[0] / ratio[1]);
}
change(helpers.fitLayout(box, canvas(), ratio[0] / ratio[1], 'se'));
commit();
render();
});
});
stage.addEventListener('pointerdown', function (event) {
if (!active || sharedSaving || event.button !== 0) return;
var hit = event.target.closest('.slide-layout-hit');
if (!hit) return;
event.preventDefault();
selectedKey = hit.dataset.regionKey;
drag = {
pointerId: event.pointerId,
startX: event.clientX, startY: event.clientY,
box: effective(selected()),
direction: event.target.dataset.direction || '',
snapshot: snapshot()
};
stage.setPointerCapture(event.pointerId);
stage.focus({ preventScroll: true });
render();
});
stage.addEventListener('pointermove', function (event) {
if (sharedSaving || !drag || event.pointerId !== drag.pointerId) return;
var bounds = stage.getBoundingClientRect();
var dx = (event.clientX - drag.startX) * canvas().width / bounds.width;
var dy = (event.clientY - drag.startY) * canvas().height / bounds.height;
if (Math.abs(event.clientX - drag.startX) + Math.abs(event.clientY - drag.startY) < 2) return;
var box = Object.assign({}, drag.box);
var direction = drag.direction;
var aspect = 0;
if (direction) {
if (direction.includes('w')) { box.x += dx; box.width -= dx; }
if (direction.includes('e')) box.width += dx;
if (direction.includes('n')) { box.y += dy; box.height -= dy; }
if (direction.includes('s')) box.height += dy;
var ratio = String(effective(selected()).lock_ratio || '').split(':').map(Number);
if (ratio[0] > 0 && ratio[1] > 0) {
aspect = ratio[0] / ratio[1];
if (Math.abs(dy * aspect) > Math.abs(dx)) box.width = Math.max(1, box.height * aspect);
box.height = Math.max(1, box.width / aspect);
if (direction.includes('w')) box.x = drag.box.x + drag.box.width - box.width;
if (direction.includes('n')) box.y = drag.box.y + drag.box.height - box.height;
}
box = helpers.fitLayout(box, canvas(), aspect, direction);
} else {
box.x = Math.max(0, Math.min(canvas().width - box.width, box.x + dx));
box.y = Math.max(0, Math.min(canvas().height - box.height, box.y + dy));
}
var peers = regions().filter(function (region) { return region.region_key !== selectedKey; }).map(effective);
var snapped = helpers.snapBox(box, peers, canvas(), snapInput.checked && !event.altKey ? 6 * canvas().width / bounds.width : -1, direction, aspect);
guides = snapped.guides;
change(snapped.box);
});
function endDrag(event) {
if (!drag || event.pointerId !== drag.pointerId) return;
if (event.type === 'pointercancel') layouts = JSON.parse(drag.snapshot);
if (stage.hasPointerCapture(event.pointerId)) stage.releasePointerCapture(event.pointerId);
drag = null;
guides = [];
commit();
update();
}
stage.addEventListener('pointerup', endDrag);
stage.addEventListener('pointercancel', endDrag);
stage.addEventListener('keydown', function (event) {
if (!active || sharedSaving) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault();
restoreHistory(event.shiftKey ? 1 : -1);
return;
}
var hit = event.target.closest('.slide-layout-hit');
if (hit) selectedKey = hit.dataset.regionKey;
if (!selected()) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
stage.focus();
render();
return;
}
var movements = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
var movement = movements[event.key];
if (!movement) return;
event.preventDefault();
var box = effective(selected());
var step = event.shiftKey ? 10 : 1;
box.x = Math.max(0, Math.min(canvas().width - box.width, box.x + movement[0] * step));
box.y = Math.max(0, Math.min(canvas().height - box.height, box.y + movement[1] * step));
change(box);
commit();
render();
stage.focus();
});
return {
apply: effective,
refresh: render,
getAnimationOverrides: function () {
var overrides = Object.create(null);
Object.keys(layouts).forEach(function (key) {
if (layouts[key] && layouts[key].animation_json !== undefined) overrides[key] = layouts[key].animation_json;
});
return overrides;
},
getAnimation: function (regionKey) {
var region = regions().find(function (item) { return item.region_key === regionKey; });
var override = layouts[regionKey] && layouts[regionKey].animation_json;
return override !== undefined ? override : String(region && region.animation_json || '{}');
},
setAnimationOverrides: function (overrides) {
regions().forEach(function (region) {
var key = region.region_key;
var current = layouts[key] ? Object.assign({}, layouts[key]) : {};
delete current.animation_json;
if (Object.prototype.hasOwnProperty.call(overrides, key)) current.animation_json = overrides[key];
if (Object.keys(current).length) layouts[key] = helpers.normalizeLayout(current, canvas());
else delete layouts[key];
});
commit();
update();
},
reset: function (content) {
layouts = Object.create(null);
regions().forEach(function (region) {
var layout = helpers.normalizeLayout(content && content[region.region_key] && content[region.region_key].layout, canvas());
if (layout) layouts[region.region_key] = layout;
});
hidden.value = snapshot();
render();
}
};
}
+44 -139
View File
@@ -20,6 +20,8 @@
: (Array.isArray(templateData) ? templateData : []);
var stage = document.getElementById('designer-stage');
var overlay = document.getElementById('designer-overlay');
var snapInput = document.getElementById('template-layout-snap');
var alignmentGuides = [];
var previewCard = document.getElementById('template-preview-card');
var previewIntroButton = document.getElementById('template-preview-intro');
var previewOutButton = document.getElementById('template-preview-out');
@@ -557,104 +559,19 @@
}
function getAnimationConfig(card) {
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
if (introPresetInput && introPresetInput.dataset.animationPresetCustom !== '1' && introPresetInput.selectedIndex >= 0) {
animationConfig.intro.preset = normalizePreviewAnimation(introPresetInput.value);
}
if (outroPresetInput && outroPresetInput.dataset.animationPresetCustom !== '1' && outroPresetInput.selectedIndex >= 0) {
animationConfig.outro.preset = normalizePreviewAnimation(outroPresetInput.value);
}
if (loopPresetInput && loopPresetInput.dataset.animationPresetCustom !== '1' && loopPresetInput.selectedIndex >= 0) {
animationConfig.loop.preset = normalizePreviewAnimation(loopPresetInput.value);
}
return animationConfig;
}
function syncAnimationPresetCustomState(selectInput, resetButton, preset) {
if (!selectInput) {
return;
}
var normalizedPreset = normalizePreviewAnimation(preset);
var hasMatchingOption = Array.prototype.some.call(selectInput.options || [], function (option) {
return option && option.value === normalizedPreset;
});
if (hasMatchingOption) {
delete selectInput.dataset.animationPresetCustom;
selectInput.hidden = false;
if (resetButton) {
resetButton.hidden = true;
}
} else {
selectInput.dataset.animationPresetCustom = '1';
selectInput.hidden = true;
if (resetButton) {
resetButton.hidden = false;
resetButton.textContent = 'Reset to basic';
}
}
return window.pulseRegionAnimationControls.read(card);
}
function writeAnimationConfig(card, animationConfig) {
var normalized = normalizeAnimationConfig(animationConfig);
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
var introResetButton = card.querySelector('[data-animation-reset-button="intro"]');
var outroResetButton = card.querySelector('[data-animation-reset-button="outro"]');
var loopResetButton = card.querySelector('[data-animation-reset-button="loop"]');
if (hiddenAnimationInput) {
hiddenAnimationInput.value = JSON.stringify(normalized, null, 2);
}
if (introPresetInput) {
introPresetInput.value = normalized.intro.preset;
syncAnimationPresetCustomState(introPresetInput, introResetButton, normalized.intro.preset);
}
if (outroPresetInput) {
outroPresetInput.value = normalized.outro.preset;
syncAnimationPresetCustomState(outroPresetInput, outroResetButton, normalized.outro.preset);
}
if (loopPresetInput) {
loopPresetInput.value = normalized.loop.preset;
syncAnimationPresetCustomState(loopPresetInput, loopResetButton, normalized.loop.preset);
}
var normalized = window.pulseRegionAnimationControls.write(card, animationConfig);
updatePreviewCardVisibility();
return normalized;
}
function setAnimationPreset(card, stepName, preset) {
var config = getAnimationConfig(card);
if (config[stepName]) {
config[stepName].preset = normalizePreviewAnimation(preset);
config[stepName].duration_ms = null;
config[stepName].delay_ms = null;
config[stepName].iterations = null;
config[stepName].easing = '';
config[stepName].fill_mode = '';
writeAnimationConfig(card, config);
if (activeAnimationCard === card) {
populateAnimationModal(card);
}
}
}
function getAnimationModalField(stepName, fieldName) {
if (!animationAdvancedModal) {
return null;
}
return animationAdvancedModal.querySelector('[data-animation-modal-step="' + stepName + '"][data-animation-modal-field="' + fieldName + '"]');
window.pulseRegionAnimationControls.setPreset(card, stepName, preset);
updatePreviewCardVisibility();
if (activeAnimationCard === card) populateAnimationModal(card);
}
function populateAnimationModal(card) {
@@ -670,20 +587,7 @@
title.textContent = (label ? label.textContent : 'Region') + ' animation settings';
}
['intro', 'outro', 'loop'].forEach(function (stepName) {
var step = config[stepName] || normalizeAnimationStep({}, 'none');
var presetInput = getAnimationModalField(stepName, 'preset');
var durationInput = getAnimationModalField(stepName, 'duration_ms');
var delayInput = getAnimationModalField(stepName, 'delay_ms');
if (presetInput) { presetInput.value = step.preset; }
if (durationInput) { durationInput.value = String(getAnimationDurationPercent(step.duration_ms)); }
if (delayInput) { delayInput.value = step.delay_ms === null || step.delay_ms === undefined ? '0' : String(step.delay_ms); }
if (stepName === 'loop') {
var iterationsInput = getAnimationModalField(stepName, 'iterations');
if (iterationsInput) { iterationsInput.value = step.iterations === null || step.iterations === undefined ? '1' : String(Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, Number(step.iterations) || 1))); }
}
});
window.pulseRegionAnimationControls.populateAdvanced(animationAdvancedModal, config);
}
function readNumberField(value) {
@@ -719,45 +623,12 @@
}
}
function getAnimationDurationPercent(durationMs) {
var value = Number(durationMs);
if (!Number.isFinite(value) || value <= 0) {
return 100;
}
return Math.min(200, Math.max(1, Math.round(value / 10)));
}
function getAnimationDurationMs(durationPercent) {
var percent = Math.min(200, Math.max(1, readNumberField(durationPercent) || 100));
return percent * 10;
}
function readAnimationModalConfig() {
if (!animationAdvancedModal) {
return null;
}
var config = getAnimationConfig(activeAnimationCard) || { intro: {}, outro: {}, loop: {} };
['intro', 'outro', 'loop'].forEach(function (stepName) {
var presetInput = getAnimationModalField(stepName, 'preset');
var durationInput = getAnimationModalField(stepName, 'duration_ms');
var delayInput = getAnimationModalField(stepName, 'delay_ms');
var nextStep = Object.assign({}, config[stepName] || {});
nextStep.preset = presetInput ? presetInput.value : nextStep.preset;
nextStep.duration_ms = getAnimationDurationMs(durationInput ? durationInput.value : '');
nextStep.delay_ms = readNumberField(delayInput ? delayInput.value : '') || 0;
if (stepName === 'loop') {
var iterationsInput = getAnimationModalField(stepName, 'iterations');
nextStep.iterations = Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, readNumberField(iterationsInput ? iterationsInput.value : '') || 1));
}
config[stepName] = normalizeAnimationStep(nextStep, 'none');
});
return config;
return window.pulseRegionAnimationControls.readAdvanced(animationAdvancedModal, getAnimationConfig(activeAnimationCard));
}
function saveAnimationModal() {
@@ -1475,6 +1346,9 @@
var selected = index === selectedNow ? ' selected' : '';
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="--designer-overlay-scale:1;left:' + renderedBox.left + 'px;top:' + renderedBox.top + 'px;width:' + renderedBox.width + 'px;height:' + renderedBox.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
}).join('');
alignmentGuides.forEach(function (guide) {
overlay.innerHTML += '<div class="layout-alignment-guide layout-alignment-guide--' + guide.axis + '" style="' + (guide.axis === 'x' ? 'left' : 'top') + ':' + (guide.position / canvasSize[guide.axis === 'x' ? 'width' : 'height'] * 100) + '%;"></div>';
});
if (draft) {
var rect = overlayRect;
var size = canvasSize;
@@ -1633,6 +1507,15 @@
document.addEventListener('mouseup', upHandler);
}
function snapRegion(region, index, event, direction, aspect) {
var size = getCanvasSize();
var scale = getOverlayRect().width / size.width;
var peers = getCards().filter(function (_card, peerIndex) { return peerIndex !== index; }).map(readCard);
var snapped = window.pulseRegionLayout.snapBox(region, peers, size, snapInput.checked && !event.altKey ? 6 / Math.max(0.01, scale) : -1, direction, aspect && aspect.ratio);
alignmentGuides = snapped.guides;
return snapped.box;
}
function startMove(index, event) {
if (previewState.active) {
return;
@@ -1649,11 +1532,13 @@
var maxY = Math.max(0, size.height - startRegion.height);
var nextX = clamp(startRegion.x + dx, 0, maxX);
var nextY = clamp(startRegion.y + dy, 0, maxY);
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height });
writeCard(cardAt(index), snapRegion({ x: nextX, y: nextY, width: startRegion.width, height: startRegion.height }, index, moveEvent));
markTemplateFormDirty();
requestOverlayRender();
}
function upHandler() {
alignmentGuides = [];
requestOverlayRender();
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
@@ -1755,11 +1640,14 @@
if (next.width < 1) { if (dir.indexOf('w') !== -1) { next.x -= 1 - next.width; } next.width = 1; }
if (next.height < 1) { if (dir.indexOf('n') !== -1) { next.y -= 1 - next.height; } next.height = 1; }
next = fitRegionToCanvas(next, aspect);
next = snapRegion(next, index, moveEvent, dir, aspect);
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
markTemplateFormDirty();
requestOverlayRender();
}
function upHandler() {
alignmentGuides = [];
requestOverlayRender();
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
@@ -1931,12 +1819,14 @@
canvasHeightInput.addEventListener('input', render);
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
overlay.addEventListener('mousedown', function (event) {
if (event.button !== 0) return;
var rect = event.target.closest('.designer-rect');
if (rect) {
var index = Number(rect.getAttribute('data-index'));
var handle = event.target.closest('.resize-handle');
event.preventDefault();
setSelected(index);
overlay.focus({ preventScroll: true });
if (handle) {
resizeFromHandle(index, handle.getAttribute('data-dir'), event);
} else {
@@ -1950,6 +1840,21 @@
event.preventDefault();
setSelected(-1);
});
overlay.addEventListener('keydown', function (event) {
if (previewState.active || selectedIndex < 0 || !cardAt(selectedIndex)) return;
var movements = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
var movement = movements[event.key];
if (!movement) return;
event.preventDefault();
var region = readCard(cardAt(selectedIndex));
var size = getCanvasSize();
var step = event.shiftKey ? 10 : 1;
region.x = clamp(region.x + movement[0] * step, 0, Math.max(0, size.width - region.width));
region.y = clamp(region.y + movement[1] * step, 0, Math.max(0, size.height - region.height));
writeCard(cardAt(selectedIndex), region);
markTemplateFormDirty();
requestOverlayRender();
});
if (templateForm) {
templateForm.addEventListener('invalid', function (event) {
var target = event.target;