Files
pulse-signage/src/web/public/js/slides/slide-layout-editor.js
T
lzstealth 24f1603b43
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 2m2s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s
Release v2.18.0: template default content
2026-10-24 18:03:38 +01:00

451 lines
19 KiB
JavaScript

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: allRegions().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 allRegions() { return template() && template().regions || []; }
function regions() { return allRegions().filter(function (region) { return !(region.read_only === true || Number(region.read_only) === 1); }); }
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 || !allRegions().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();
}
};
}