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(); } }; }