import { Alignment, BlockQuote, Bold, ClassicEditor, ClipboardPipeline, Essentials, FontBackgroundColor, FontColor, FontFamily, FontSize, Heading, HorizontalLine, Indent, IndentBlock, Italic, InputTextView, Plugin, Paragraph, Strikethrough, Subscript, Superscript, Underline } from '/assets/js/vendor/ckeditor5/ckeditor5.js'; import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js'; (function () { var DEFAULT_FONT_SIZE = 32; var dataElement = document.getElementById('slide-editor-data'); if (!dataElement) { return; } var slideEditorData = {}; try { slideEditorData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {}; } catch (_error) { slideEditorData = {}; } var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : []; var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : []; var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : []; var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null; var existingContent = slideEditorData.existingContent || {}; var templateSelect = document.getElementById('template-select'); var templateFields = document.getElementById('template-fields'); var slideForm = document.getElementById('slide-form'); var slideEditorShell = document.querySelector('.slide-editor-shell'); var slideEditorSidebar = document.querySelector('.slide-editor-sidebar'); var slidePreviewMeta = document.getElementById('slide-preview-meta'); var slidePreviewStage = document.getElementById('slide-preview-stage'); var slidePreviewCanvas = document.getElementById('slide-preview-canvas'); var slidePreviewBackground = document.getElementById('slide-preview-background'); var slidePreviewEmpty = document.getElementById('slide-preview-empty'); var slidePreviewOverlay = document.getElementById('slide-preview-overlay'); var openPreviewPopupButton = document.getElementById('open-preview-popup'); var editorInstances = new Map(); var submitting = false; var previewRenderFrame = 0; var previewPopupWindow = null; var previewPopupRenderFrame = 0; var currentPreviewCanvasWidth = 0; var currentPreviewCanvasHeight = 0; var sidebarSyncFrame = 0; var sidebarTopOffset = 16; var templateSelectorLock = createTemplateSelectorLockController(templateSelect); class FontSizeInputUI extends Plugin { static get pluginName() { return 'FontSizeInputUI'; } init() { var editor = this.editor; var fontSizeCommand = editor.commands.get('fontSize'); var plugin = this; if (!fontSizeCommand) { return; } editor.ui.componentFactory.add('fontSizeInput', function (locale) { var view = new InputTextView(locale); view.extendTemplate({ attributes: { class: ['ck-font-size-input'] } }); view.set({ placeholder: 'Font size', ariaLabel: 'Font size' }); function getCurrentFontSizeValue() { var value = fontSizeCommand.value; if (value === null || value === undefined || value === '') { return String(DEFAULT_FONT_SIZE); } var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, ''))); return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : ''; } function syncValue() { if (view.element && document.activeElement === view.element) { return; } view.value = getCurrentFontSizeValue(); } view.on('render', function () { if (!view.element) { return; } view.element.type = 'text'; view.element.inputMode = 'numeric'; view.element.autocomplete = 'off'; view.element.spellcheck = false; view.element.style.width = '3.5em'; syncValue(); }); function applyValue() { if (!view.element) { return; } var size = Math.max(1, Math.round(Number(view.element.value || 0))); if (!size) { return; } editor.execute('fontSize', { value: size + 'px' }); } view.on('input', applyValue); view.on('blur', applyValue); view.on('keydown', function (_eventInfo, domEvent) { if (domEvent.key === 'Enter') { domEvent.preventDefault(); if (view.element) { view.element.blur(); } } }); plugin.listenTo(editor.model.document.selection, 'change:range', syncValue); plugin.listenTo(fontSizeCommand, 'change:value', syncValue); syncValue(); return view; }); } } class ForcePlainTextPaste extends Plugin { static get pluginName() { return 'ForcePlainTextPaste'; } static get requires() { return [ClipboardPipeline]; } init() { var editor = this.editor; var clipboardPipeline = editor.plugins.get(ClipboardPipeline); this.listenTo(clipboardPipeline, 'inputTransformation', function (_event, data) { var plainText = data.dataTransfer.getData('text/plain'); data.content = editor.data.processor.toView(plainTextToHtml(plainText)); }, { priority: 'high' }); } } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function plainTextToHtml(value) { var lines = String(value ?? '').replace(/\r\n?/g, '\n').split('\n'); var paragraphs = []; var currentParagraph = []; lines.forEach(function (line) { if (line === '') { if (currentParagraph.length) { paragraphs.push('

' + currentParagraph.join('
') + '

'); currentParagraph = []; } else { paragraphs.push('

'); } return; } currentParagraph.push(escapeHtml(line)); }); if (currentParagraph.length) { paragraphs.push('

' + currentParagraph.join('
') + '

'); } return paragraphs.length ? paragraphs.join('') : '

'; } function getTemplateById(id) { return templates.find(function (item) { return Number(item.id) === Number(id); }) || null; } function getCurrentRegionValue(region) { var current = existingContent[region.region_key]; return current && current.value !== undefined ? current.value : ''; } function normalizeEditorData(value) { return String(value || ''); } function requestPreviewRender() { if (previewRenderFrame) { return; } previewRenderFrame = window.requestAnimationFrame(function () { previewRenderFrame = 0; renderPreview(); }); } function getTemplateFieldControls() { return templateFields ? templateFields.querySelectorAll('input, textarea, select') : []; } function sanitizePreviewHtml(html) { var output = String(html || ''); output = output.replace(//gi, ''); output = output.replace(//gi, ''); return output.replace(/<[^>]+>/g, function (tag) { var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i); if (!match) { return ''; } var closing = Boolean(match[1]); var name = String(match[2] || '').toLowerCase(); var attrText = String(match[3] || ''); var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr'; var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; if (allowed.indexOf(name) === -1) { return ''; } if (closing) { return ''; } if (selfClosing) { return '<' + name + sanitizeTagAttributes(name, attrText) + '>'; } return '<' + name + sanitizeTagAttributes(name, attrText) + '>'; }); } function sanitizeTagAttributes(tagName, attrText) { var allowedAttributes = { a: ['href', 'title', 'target', 'rel', 'class', 'style'], blockquote: ['class', 'style'], div: ['class', 'style'], figure: ['class', 'style'], figcaption: ['class', 'style'], h1: ['class', 'style'], h2: ['class', 'style'], h3: ['class', 'style'], h4: ['class', 'style'], h5: ['class', 'style'], h6: ['class', 'style'], li: ['class', 'style'], ol: ['class', 'style', 'start'], p: ['class', 'style'], pre: ['class', 'style'], span: ['class', 'style'], table: ['class', 'style'], td: ['class', 'style', 'colspan', 'rowspan'], th: ['class', 'style', 'colspan', 'rowspan', 'scope'], tr: ['class', 'style'], ul: ['class', 'style'] }; var allowed = allowedAttributes[tagName] || []; if (!allowed.length) { return ''; } var attrs = []; attrText.replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) { var lowerKey = String(key || '').toLowerCase(); if (allowed.indexOf(lowerKey) === -1) { return ''; } var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { return ''; } if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) { return ''; } if (lowerKey === 'target') { var targetValue = String(value || '').trim(); if (targetValue === '_blank') { attrs.push(' target="_blank"'); if (attrs.indexOf(' rel="noreferrer noopener"') === -1) { attrs.push(' rel="noreferrer noopener"'); } return ''; } } attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"'); return ''; }); return attrs.join(''); } function renderPreviewListItem(item, tag) { if (item && typeof item === 'object') { var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : ''); var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : []; var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderPreviewListItem(child, tag); }).join('') + '' : ''; return '
  • ' + sanitizePreviewHtml(content || '') + nested + '
  • '; } return '
  • ' + sanitizePreviewHtml(item || '') + '
  • '; } function renderPreviewTable(data) { var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : []; if (!rows.length) { return ''; } var hasHeadings = Boolean(data.withHeadings); var body = rows.map(function (row, rowIndex) { var cells = Array.isArray(row) ? row : []; var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td'; var cellAttrs = cellTag === 'th' ? ' scope="col"' : ''; return '' + cells.map(function (cell) { return '<' + cellTag + cellAttrs + '>' + sanitizePreviewHtml(cell || '') + ''; }).join('') + ''; }).join(''); return '' + body + '
    '; } function renderPreviewBlock(block) { if (!block || !block.type || !block.data) { return ''; } if (block.type === 'header') { var level = Math.max(1, Math.min(6, Number(block.data.level || 2))); return '' + sanitizePreviewHtml(block.data.text || '') + ''; } if (block.type === 'list') { var tag = block.data.style === 'ordered' ? 'ol' : 'ul'; var items = Array.isArray(block.data.items) ? block.data.items : []; return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderPreviewListItem(item, tag); }).join('') + ''; } if (block.type === 'delimiter') { return '
    '; } if (block.type === 'code') { return '
    ' + sanitizePreviewHtml(block.data.code || '') + '
    '; } if (block.type === 'table') { return renderPreviewTable(block.data); } if (block.type === 'paragraph') { return '

    ' + sanitizePreviewHtml(block.data.text || '') + '

    '; } return ''; } function renderPreviewText(value) { var raw = String(value || ''); if (!raw) { return '
    Empty text
    '; } return sanitizePreviewHtml(raw); } function renderPreviewTextRegion(region, value, style, scale) { var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : ''; var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : ''; var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : ''; var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1)); var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1)); var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color; return '
    ' + renderPreviewText(value) + '
    '; } function renderPreviewWebpageRegion(value) { var src = String(value || '').trim(); if (!src) { return '
    Webpage
    '; } return ''; } function renderPreviewRtmpRegion(value, disableAudio) { var src = String(value || '').trim(); var label = src ? 'RTMP' : 'RTMP stream'; if (disableAudio) { label += ' (muted)'; } return '
    ' + label + '
    '; } function renderPreviewHtmlRegion(value) { var html = String(value || '').trim(); if (!html) { return '
    HTML
    '; } return ''; } function destroyEditors() { editorInstances.forEach(function (editor) { if (editor && typeof editor.destroy === 'function') { editor.destroy(); } }); editorInstances.clear(); } function getEditorHiddenInput(regionId) { var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); return card ? card.querySelector('input[type="hidden"][name="region_text_' + regionId + '"]') : null; } function getEditorFontSizeHiddenInput(regionId) { var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); return card ? card.querySelector('input[name="region_font_size_' + regionId + '"]') : null; } function normalizeFontSizeValue(value) { var raw = String(value || '').trim().toLowerCase(); if (/^\d+(?:\.\d+)?px$/.test(raw)) { return String(Math.max(1, Math.round(Number(raw.replace(/px$/, ''))))); } if (/^\d+(?:\.\d+)?$/.test(raw)) { return String(Math.max(1, Math.round(Number(raw)))); } return ''; } function syncEditorFontSizeHidden(regionId, editor) { var fontSizeHidden = getEditorFontSizeHiddenInput(regionId); if (!fontSizeHidden || !editor) { return; } var fontSizeCommand = editor.commands.get('fontSize'); fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand && fontSizeCommand.value) || String(DEFAULT_FONT_SIZE); } function getCurrentTextStyle(region) { var current = existingContent[region.region_key] || {}; return { font_family: String(current.font_family || region.font_family || 'Arial').trim() || 'Arial', font_size: Math.max(8, Number(current.font_size || DEFAULT_FONT_SIZE)), font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000' }; } function getRssFeedById(feedId) { var normalizedId = Number(feedId || 0); return rssFeeds.find(function (feed) { return Number(feed.id) === normalizedId; }) || null; } function getRssFieldList(feedId) { var feed = getRssFeedById(feedId); var sampleItem = feed && Array.isArray(feed.items) ? feed.items[0] : null; var sampleJson = sampleItem && sampleItem.itemJson && typeof sampleItem.itemJson === 'object' ? sampleItem.itemJson : null; function walkFields(value, prefix, output) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return; } Object.keys(value).forEach(function (key) { if (key === 'rawXml') { return; } var nextPath = prefix ? prefix + '.' + key : key; var nextValue = value[key]; if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue)) { walkFields(nextValue, nextPath, output); return; } if (output.indexOf(nextPath) === -1) { output.push(nextPath); } }); } var fields = []; walkFields(sampleJson, '', fields); return fields; } function updatePlaceholderChipList(card, fieldList, emptyLabel) { if (!card) { return; } var container = card.querySelector('[data-placeholder-chips]'); if (!container) { return; } var chips = Array.isArray(fieldList) ? fieldList.map(function (field) { return '{{' + escapeHtml(field) + '}}'; }).join('') : ''; container.innerHTML = chips || '' + escapeHtml(emptyLabel) + ''; } function updateRssPlaceholderChips(regionId, feedId) { var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); updatePlaceholderChipList(card, getRssFieldList(feedId), 'No RSS fields available.'); } function getCurrentRssConfig(region) { var current = existingContent[region.region_key] || {}; var parsedItemNumber = Math.max(1, Number(current.item_number || 1)); return { feed_id: current.feed_id === undefined || current.feed_id === null || current.feed_id === '' ? '' : Number(current.feed_id), item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, value: current.value !== undefined ? current.value : '' }; } function resolveRssPath(value, path) { var current = value; if (!path) { return current; } String(path).split('.').forEach(function (segment) { if (current === undefined || current === null) { current = ''; return; } current = current[segment]; }); return current === undefined || current === null ? '' : current; } function normalizeRssVariableName(value) { var next = String(value || '').trim(); return next || 'item'; } function substituteRssVariables(html, item) { var source = String(html || ''); return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) { if (!item || typeof item !== 'object') { return ''; } var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName; return escapeHtml(resolveRssPath(item, key || '')); }); } function getRssItem(region, rssConfig) { var feed = getRssFeedById(rssConfig.feed_id); var items = feed && Array.isArray(feed.items) ? feed.items : []; var parsedItemNumber = Math.max(1, Number(rssConfig.item_number || 1)); var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0; return items[index] || null; } function getRssPreviewFallback(item) { if (!item || typeof item !== 'object') { return '
    RSS item
    '; } var title = String(item.title || '').trim(); var description = String(item.description || '').trim(); var summary = []; if (title) { summary.push('

    ' + escapeHtml(title) + '

    '); } if (description) { summary.push('

    ' + sanitizePreviewHtml(description) + '

    '); } if (!summary.length) { return '
    RSS item
    '; } return summary.join(''); } function getApiSourceById(sourceId) { var normalizedId = Number(sourceId || 0); return apiSources.find(function (source) { return Number(source.id) === normalizedId; }) || null; } function getApiSourceItems(sourceId) { var source = getApiSourceById(sourceId); var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null; if (Array.isArray(responseJson)) { return responseJson; } if (responseJson && Array.isArray(responseJson.items)) { return responseJson.items; } if (responseJson && Array.isArray(responseJson.results)) { return responseJson.results; } if (responseJson && Array.isArray(responseJson.data)) { return responseJson.data; } return responseJson ? [responseJson] : []; } function getApiFieldList(sourceId) { var sampleItem = getApiSourceItems(sourceId)[0] || null; function walkFields(value, prefix, output) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return; } Object.keys(value).forEach(function (key) { var nextPath = prefix ? prefix + '.' + key : key; var nextValue = value[key]; if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue)) { walkFields(nextValue, nextPath, output); return; } if (output.indexOf(nextPath) === -1) { output.push(nextPath); } }); } var fields = []; walkFields(sampleItem && typeof sampleItem === 'object' ? sampleItem : null, '', fields); return fields; } function updateApiPlaceholderChips(regionId, sourceId) { var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); updatePlaceholderChipList(card, getApiFieldList(sourceId), 'No JSON fields available.'); } function getCurrentApiConfig(region) { var current = existingContent[region.region_key] || {}; var parsedItemNumber = Math.max(1, Number(current.item_number || 1)); return { source_id: current.source_id === undefined || current.source_id === null || current.source_id === '' ? '' : Number(current.source_id), item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, value: current.value !== undefined ? current.value : '' }; } function resolveApiPath(value, path) { var current = value; if (!path) { return current; } String(path).split('.').forEach(function (segment) { if (current === undefined || current === null) { current = ''; return; } current = current[segment]; }); return current === undefined || current === null ? '' : current; } function substituteApiVariables(html, item) { var source = String(html || ''); return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) { if (!item || typeof item !== 'object') { return ''; } var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName; return escapeHtml(resolveApiPath(item, key || '')); }); } function getApiItem(sourceId, itemNumber) { var items = getApiSourceItems(sourceId); var parsedItemNumber = Math.max(1, Number(itemNumber || 1)); var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0; return items[index] || null; } function getApiPreviewFallback(item) { if (!item || typeof item !== 'object') { return '
    API item
    '; } var title = String(item.title || item.name || '').trim(); var description = String(item.description || item.summary || item.text || '').trim(); var summary = []; if (title) { summary.push('

    ' + escapeHtml(title) + '

    '); } if (description) { summary.push('

    ' + sanitizePreviewHtml(description) + '

    '); } if (!summary.length) { return '
    API item
    '; } return summary.join(''); } function getTextStyleFromCard(_card, region) { return getCurrentTextStyle(region); } function getSelectedTemplate() { return templates.find(function (item) { return Number(item.id) === Number(templateSelect.value); }) || null; } function clearImagePreviewUrls() { templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) { if (input.dataset.previewUrl) { URL.revokeObjectURL(input.dataset.previewUrl); delete input.dataset.previewUrl; } }); } function getRegionBox(region, canvasWidth, canvasHeight) { return { left: Math.max(0, Math.round(Number(region.x || 0))), top: Math.max(0, Math.round(Number(region.y || 0))), width: Math.max(1, Math.round(Number(region.width || 0) || 1)), height: Math.max(1, Math.round(Number(region.height || 0) || 1)) }; } function getPreviewCanvasDimensions(template) { return { width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)), height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)) }; } function buildPreviewPopupHtml() { return '' + '' + '' + '' + '' + 'Slide preview' + '' + '' + '' + '' + '' + ''; } function syncPopupPreview() { if (!previewPopupWindow || previewPopupWindow.closed) { previewPopupWindow = null; return; } var popupDocument = previewPopupWindow.document; if (!popupDocument) { return; } var popupStage = popupDocument.getElementById('popup-preview-stage'); var popupCanvas = popupDocument.getElementById('popup-preview-canvas'); var template = getSelectedTemplate(); if (!popupStage || !popupCanvas) { if (previewPopupRenderFrame) { cancelAnimationFrame(previewPopupRenderFrame); } previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview); return; } if (!template) { popupStage.style.backgroundColor = '#111111'; popupCanvas.style.width = ''; popupCanvas.style.height = ''; popupCanvas.style.transform = ''; popupCanvas.innerHTML = slidePreviewCanvas.innerHTML; var popupEmpty = popupCanvas.querySelector('#slide-preview-empty'); var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay'); var popupBackground = popupCanvas.querySelector('#slide-preview-background'); if (popupBackground) { popupBackground.style.display = 'none'; } if (popupEmpty) { popupEmpty.style.display = 'flex'; popupEmpty.textContent = 'Select a template to preview the slide.'; } if (popupOverlay) { popupOverlay.innerHTML = ''; } return; } var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920)); var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080)); popupStage.style.backgroundColor = template.background_color || '#111111'; popupCanvas.style.width = canvasWidth + 'px'; popupCanvas.style.height = canvasHeight + 'px'; popupCanvas.innerHTML = slidePreviewCanvas.innerHTML; var popupBackground = popupCanvas.querySelector('#slide-preview-background'); var popupEmpty = popupCanvas.querySelector('#slide-preview-empty'); var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay'); if (popupBackground) { popupBackground.style.display = template.background_image_path ? 'block' : 'none'; popupBackground.src = template.background_image_path || ''; } if (popupEmpty) { popupEmpty.style.display = 'none'; } var stageRect = popupStage.getBoundingClientRect(); if (!stageRect.width || !stageRect.height) { if (previewPopupRenderFrame) { cancelAnimationFrame(previewPopupRenderFrame); } previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview); return; } var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1; popupCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; } function openPreviewPopup() { if (previewPopupWindow && !previewPopupWindow.closed) { previewPopupWindow.focus(); syncPopupPreview(); return; } var popupWidth = 1280; var popupHeight = 900; var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2)); var top = Math.max(0, Math.round((window.screen.height - popupHeight) / 2)); var popupFeatures = [ 'popup=yes', 'toolbar=no', 'location=no', 'menubar=no', 'directories=no', 'status=no', 'scrollbars=yes', 'resizable=yes', 'width=' + popupWidth, 'height=' + popupHeight, 'left=' + left, 'top=' + top ].join(','); previewPopupWindow = window.open('', 'slide-preview-popup', popupFeatures); if (!previewPopupWindow) { return; } previewPopupWindow.document.open(); previewPopupWindow.document.write(buildPreviewPopupHtml()); previewPopupWindow.document.close(); previewPopupWindow.addEventListener('resize', syncPopupPreview); previewPopupWindow.focus(); syncPopupPreview(); } function syncSlideEditorSidebar() { sidebarSyncFrame = 0; if (!slideEditorShell || !slideEditorSidebar) { return; } if (window.matchMedia && window.matchMedia('(max-width: 1199.98px)').matches) { slideEditorSidebar.style.transform = ''; return; } var shellRect = slideEditorShell.getBoundingClientRect(); var sidebarHeight = slideEditorSidebar.offsetHeight; var shellTop = window.scrollY + shellRect.top; var shellBottom = shellTop + slideEditorShell.offsetHeight; var maxTranslate = Math.max(0, shellBottom - shellTop - sidebarHeight - sidebarTopOffset); var desiredTranslate = Math.max(0, Math.min(window.scrollY + sidebarTopOffset - shellTop, maxTranslate)); slideEditorSidebar.style.transform = desiredTranslate ? 'translate3d(0, ' + desiredTranslate + 'px, 0)' : ''; } function requestSidebarSync() { if (sidebarSyncFrame) { return; } sidebarSyncFrame = window.requestAnimationFrame(syncSlideEditorSidebar); } function renderPreview() { var template = getSelectedTemplate(); if (!template) { slidePreviewMeta.textContent = '--'; slidePreviewBackground.style.display = 'none'; slidePreviewEmpty.style.display = 'flex'; slidePreviewEmpty.textContent = 'Select a template to preview the slide.'; slidePreviewCanvas.style.width = ''; slidePreviewCanvas.style.height = ''; slidePreviewOverlay.innerHTML = ''; slidePreviewOverlay.style.width = ''; slidePreviewOverlay.style.height = ''; slidePreviewOverlay.style.transform = ''; slidePreviewStage.style.aspectRatio = '16 / 9'; slidePreviewStage.style.backgroundColor = '#111111'; return; } var canvasSize = getPreviewCanvasDimensions(template); var canvasWidth = canvasSize.width; var canvasHeight = canvasSize.height; currentPreviewCanvasWidth = canvasWidth; currentPreviewCanvasHeight = canvasHeight; slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight; slidePreviewStage.style.backgroundColor = template.background_color || '#111111'; slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight; if (!(template.regions || []).length) { slidePreviewEmpty.style.display = 'flex'; slidePreviewEmpty.textContent = 'This template has no regions.'; slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none'; slidePreviewBackground.src = template.background_image_path || ''; slidePreviewCanvas.style.width = canvasWidth + 'px'; slidePreviewCanvas.style.height = canvasHeight + 'px'; slidePreviewCanvas.style.transform = ''; slidePreviewOverlay.innerHTML = ''; slidePreviewOverlay.style.width = ''; slidePreviewOverlay.style.height = ''; slidePreviewOverlay.style.transform = ''; return; } slidePreviewEmpty.style.display = 'none'; slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none'; slidePreviewBackground.src = template.background_image_path || ''; var stageRect = slidePreviewStage.getBoundingClientRect(); if (!stageRect.width || !stageRect.height) { if (previewRenderFrame) { cancelAnimationFrame(previewRenderFrame); } previewRenderFrame = requestAnimationFrame(function () { previewRenderFrame = 0; renderPreview(); }); return; } var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1; slidePreviewCanvas.style.width = canvasWidth + 'px'; slidePreviewCanvas.style.height = canvasHeight + 'px'; slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; slidePreviewCanvas.style.transformOrigin = 'top left'; var regions = template.regions || []; slidePreviewOverlay.innerHTML = regions.map(function (region) { var box = getRegionBox(region, canvasWidth, canvasHeight); var card = templateFields.querySelector('[data-region-id="' + region.id + '"]'); var hiddenInput = card && card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]'); var htmlInput = card && card.querySelector('textarea[name="region_html_' + region.id + '"]'); var imageHidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]'); var imageFileInput = card && card.querySelector('input[type="file"][name="region_image_' + region.id + '"]'); var webpageInput = card && card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]'); var rtmpInput = card && card.querySelector('input[type="url"][name="region_rtmp_' + region.id + '"]'); var disableAudioInput = card && card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]'); var rssFeedInput = card && card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]'); var rssItemInput = card && card.querySelector('input[name="region_rss_item_number_' + region.id + '"]'); var apiSourceInput = card && card.querySelector('select[name="region_api_source_id_' + region.id + '"]'); var apiItemInput = card && card.querySelector('input[name="region_api_item_number_' + region.id + '"]'); var existingRtmp = existingContent[region.region_key] || {}; var existingRss = existingContent[region.region_key] || {}; var existingApi = existingContent[region.region_key] || {}; var value = region.region_type === 'image' ? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : '')) : region.region_type === 'webpage' ? (webpageInput ? webpageInput.value : '') : region.region_type === 'rtmp' ? (rtmpInput ? rtmpInput.value : '') : region.region_type === 'rss' ? (hiddenInput ? hiddenInput.value : '') : region.region_type === 'api' ? (hiddenInput ? hiddenInput.value : '') : region.region_type === 'html' ? (htmlInput ? htmlInput.value : '') : (hiddenInput ? hiddenInput.value : ''); var style = region.region_type === 'image' ? null : region.region_type === 'webpage' ? null : region.region_type === 'rtmp' ? null : region.region_type === 'html' ? null : getTextStyleFromCard(card, region); var content = region.region_type === 'image' ? (value ? '' : '
    Image
    ') : region.region_type === 'webpage' ? renderPreviewWebpageRegion(value) : region.region_type === 'rtmp' ? renderPreviewRtmpRegion(value, disableAudioInput ? disableAudioInput.checked : (existingRtmp.disable_audio === undefined ? true : Boolean(existingRtmp.disable_audio))) : region.region_type === 'rss' ? (function () { var item = getRssItem(region, { feed_id: rssFeedInput ? rssFeedInput.value : existingRss.feed_id, item_number: rssItemInput ? rssItemInput.value : existingRss.item_number }); var rendered = substituteRssVariables(value, item); return renderPreviewTextRegion(region, rendered ? rendered : getRssPreviewFallback(item), style, scale); }()) : region.region_type === 'api' ? (function () { var item = getApiItem(apiSourceInput ? apiSourceInput.value : existingApi.source_id, apiItemInput ? apiItemInput.value : existingApi.item_number); var rendered = substituteApiVariables(value, item); return renderPreviewTextRegion(region, rendered ? rendered : getApiPreviewFallback(item), style, scale); }()) : region.region_type === 'html' ? renderPreviewHtmlRegion(value) : renderPreviewTextRegion(region, value, style, scale); var selected = region.region_type === 'image' ? ' slide-preview-image-region' : region.region_type === 'webpage' ? ' slide-preview-webpage-region' : region.region_type === 'rtmp' ? ' slide-preview-rtmp-region' : region.region_type === 'rss' ? ' slide-preview-rss-region' : region.region_type === 'api' ? ' slide-preview-api-region' : region.region_type === 'html' ? ' slide-preview-html-region' : ' slide-preview-text-region'; return '
    ' + content + '
    '; }).join(''); syncPopupPreview(); } function renderTextRegion(region) { var current = getCurrentRegionValue(region); return '' + '
    ' + '
    ' + '' + escapeHtml(region.label) + '' + '
    ' + '' + 'Text' + '
    ' + '
    ' + '
    ' + '
    ' + '' + '
    ' + '' + '' + '
    ' + '
    '; } function renderRssRegion(region) { var current = existingContent[region.region_key] || {}; var config = getCurrentRssConfig(region); var fieldList = getRssFieldList(config.feed_id); var placeholderChips = fieldList.map(function (field) { return '{{' + escapeHtml(field) + '}}'; }).join(''); var feedOptions = rssFeeds.map(function (feed) { var selected = Number(feed.id) === Number(config.feed_id) ? ' selected' : ''; return ''; }).join(''); return '' + '
    ' + '
    ' + '' + escapeHtml(region.label) + '' + '
    ' + '' + 'RSS' + '
    ' + '
    ' + '
    ' + '
    ' + '' + '
    ' + '
    ' + '
    ' + '' + '' + '
    ' + '
    ' + '' + '' + '
    ' + '
    ' + '' + '' + '
    Use placeholders like {{title}} or {{link}}. Available placeholders:
    ' + '
    ' + (placeholderChips || 'No RSS fields available.') + '
    ' + '
    ' + '
    '; } function renderApiRegion(region) { var current = existingContent[region.region_key] || {}; var config = getCurrentApiConfig(region); var fieldList = getApiFieldList(config.source_id); var placeholderChips = fieldList.map(function (field) { return '{{' + escapeHtml(field) + '}}'; }).join(''); var sourceOptions = apiSources.map(function (source) { var selected = Number(source.id) === Number(config.source_id) ? ' selected' : ''; return ''; }).join(''); return '' + '
    ' + '
    ' + '' + escapeHtml(region.label) + '' + '
    ' + '' + 'API' + '
    ' + '
    ' + '
    ' + '
    ' + '' + '
    ' + '
    ' + '
    ' + '' + '' + '
    ' + '
    ' + '' + '' + '
    ' + '
    ' + '' + '' + '
    Use placeholders like {{title}} or {{description}}. Available placeholders:
    ' + '
    ' + (placeholderChips || 'No JSON fields available.') + '
    ' + '
    ' + '
    '; } function renderWebpageRegion(region) { var current = getCurrentRegionValue(region); return '' + '
    ' + '
    ' + '' + escapeHtml(region.label) + '' + 'Webpage' + '
    ' + '
    ' + '' + '
    The webpage will be loaded in an iframe.
    ' + '
    ' + '
    '; } function renderRtmpRegion(region) { var current = existingContent[region.region_key] || {}; var url = String(current.value || '').trim(); var disableAudio = current.disable_audio === undefined ? true : Boolean(current.disable_audio); return '' + '
    ' + '
    ' + '' + escapeHtml(region.label) + '' + 'RTMP' + '
    ' + '
    ' + '' + '' + '
    Player controls stay hidden and the stream is not interactive.
    ' + '
    ' + '
    '; } function renderHtmlRegion(region) { var current = getCurrentRegionValue(region); return '' + '
    ' + '
    ' + '' + escapeHtml(region.label) + '' + 'HTML' + '
    ' + '
    ' + '' + '
    HTML is rendered in a sandboxed iframe preview.
    ' + '
    ' + '
    '; } function renderImageRegion(region) { var current = getCurrentRegionValue(region); return '' + '
    ' + '
    ' + '' + escapeHtml(region.label) + '' + 'Image' + '
    ' + '
    ' + '' + '' + (current ? '' : '
    No image selected.
    ') + '
    ' + '
    '; } function renderTemplate() { templateSelectorLock.reset(); clearImagePreviewUrls(); destroyEditors(); var template = getTemplateById(templateSelect.value); if (!template) { templateFields.innerHTML = ''; requestPreviewRender(); return; } templateFields.innerHTML = template.regions.length ? template.regions.map(function (region) { if (region.region_type === 'image') { return renderImageRegion(region); } if (region.region_type === 'webpage') { return renderWebpageRegion(region); } if (region.region_type === 'rtmp') { return renderRtmpRegion(region); } if (region.region_type === 'rss') { return renderRssRegion(region); } if (region.region_type === 'api') { return renderApiRegion(region); } if (region.region_type === 'html') { return renderHtmlRegion(region); } return renderTextRegion(region); }).join('') : '
    This template has no regions.
    '; templateFields.querySelectorAll('.ckeditor-holder').forEach(function (holder) { var regionId = holder.getAttribute('data-region-id'); var source = holder.querySelector('.ckeditor-source'); var hidden = getEditorHiddenInput(regionId); var editorConfig = { licenseKey: 'GPL', placeholder: 'Write content...', toolbar: { items: [ 'undo', 'redo', '|', 'fontSizeInput', 'fontFamily', 'fontColor', 'fontBackgroundColor', '|', 'bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript', '|', 'horizontalLine', 'blockQuote', '|', 'alignment', '|', 'outdent', 'indent' ], shouldNotGroupWhenFull: false }, plugins: [ Alignment, BlockQuote, Bold, Essentials, ForcePlainTextPaste, FontBackgroundColor, FontColor, FontFamily, FontSize, FontSizeInputUI, HorizontalLine, Indent, IndentBlock, Italic, Paragraph, Strikethrough, Subscript, Superscript, Underline ], fontFamily: { supportAllValues: true }, fontSize: { options: [20, 24, 28, 32, 36, 40, 44], supportAllValues: true }, initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : '')) }; ClassicEditor.create(source || holder, editorConfig).then(function (editor) { editorInstances.set(regionId, editor); var fontSizeCommand = editor.commands.get('fontSize'); if (hidden) { hidden.value = editor.getData(); } syncEditorFontSizeHidden(regionId, editor); if (fontSizeCommand) { fontSizeCommand.on('change:value', function () { syncEditorFontSizeHidden(regionId, editor); }); } editor.model.document.on('change:data', function () { var instance = editorInstances.get(regionId); if (instance !== editor) { return; } if (hidden) { hidden.value = editor.getData(); } templateSelectorLock.markEdited(); requestPreviewRender(); }); requestPreviewRender(); }).catch(function (error) { console.error('Failed to initialize CKEditor.', error); }); }); templateFields.querySelectorAll('[data-toggle-editor-height]').forEach(function (button) { button.addEventListener('click', function () { var regionId = button.getAttribute('data-toggle-editor-height'); var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); if (!card) { return; } var expanded = card.classList.toggle('is-expanded'); button.textContent = expanded ? 'Collapse' : 'Expand'; }); }); templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) { input.addEventListener('change', function () { if (input.dataset.previewUrl) { URL.revokeObjectURL(input.dataset.previewUrl); delete input.dataset.previewUrl; } if (input.files && input.files.length) { input.dataset.previewUrl = URL.createObjectURL(input.files[0]); } templateSelectorLock.markEdited(); requestPreviewRender(); }); }); templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) { input.addEventListener('input', function () { var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, ''); if (input.name.indexOf('region_rss_') === 0) { var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value; updateRssPlaceholderChips(regionId, rssFeed); } if (input.name.indexOf('region_api_') === 0) { var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value; updateApiPlaceholderChips(regionId, apiSource); } templateSelectorLock.markEdited(); requestPreviewRender(); }); input.addEventListener('change', function () { var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, ''); if (input.name.indexOf('region_rss_') === 0) { var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value; updateRssPlaceholderChips(regionId, rssFeed); } if (input.name.indexOf('region_api_') === 0) { var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value; updateApiPlaceholderChips(regionId, apiSource); } templateSelectorLock.markEdited(); requestPreviewRender(); }); }); if (existingTemplateId) { templateSelectorLock.arm(); templateSelectorLock.markEdited(); requestPreviewRender(); return; } window.requestAnimationFrame(function () { templateSelectorLock.arm(); }); requestPreviewRender(); } slideForm.addEventListener('submit', async function (event) { if (submitting) { return; } event.preventDefault(); var slideFormUrl = new URL(slideForm.action, window.location.href); var isCreateForm = slideFormUrl.pathname === '/slides'; var submitterValue = event.submitter && event.submitter.name === 'save_action' ? String(event.submitter.value || '').trim().toLowerCase() : ''; var saves = Array.prototype.map.call(templateFields.querySelectorAll('.ckeditor-holder'), function (holder) { var regionId = holder.getAttribute('data-region-id'); var editor = editorInstances.get(regionId); var hidden = getEditorHiddenInput(regionId); if (!editor || !hidden) { return Promise.resolve(); } syncEditorFontSizeHidden(regionId, editor); hidden.value = editor.getData(); return Promise.resolve(); }); submitting = true; try { await Promise.all(saves); var formData = new FormData(slideForm); if (event.submitter && event.submitter.name) { formData.set(event.submitter.name, event.submitter.value || ''); } var response = await fetch(slideForm.action, { method: (slideForm.method || 'POST').toUpperCase(), headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/html, application/json, text/plain, */*' }, body: formData, credentials: 'same-origin' }); if (!response.ok) { throw new Error(await response.text() || 'Unable to save slide.'); } if (submitterValue === 'close' || submitterValue === 'new') { var redirectUrl = submitterValue === 'close' ? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action) : String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action); window.location.replace(redirectUrl); return; } if (isCreateForm) { try { if (response.url) { window.location.replace(response.url); return; } } catch (_error) { // Fall through and show a toast if the redirect URL cannot be resolved. } slideForm.dataset.dirty = 'false'; showToast('Saved slide.', 'success'); return; } var responseText = await response.text(); var savedMessage = ''; try { var doc = new DOMParser().parseFromString(responseText || '', 'text/html'); var toastBody = doc.querySelector('.toast-body'); if (toastBody && toastBody.textContent) { savedMessage = toastBody.textContent.trim(); } } catch (_error) { savedMessage = ''; } showToast(savedMessage || 'Saved slide.', 'success'); slideForm.dataset.dirty = 'false'; } catch (error) { if (typeof showToast === 'function') { var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger'; showToast(error && error.message ? error.message : 'Unable to save slide.', variant); } else { window.alert(error && error.message ? error.message : 'Unable to save slide.'); } } finally { submitting = false; } }); slideForm.addEventListener('input', function () { slideForm.dataset.dirty = 'true'; }, true); slideForm.addEventListener('change', function () { slideForm.dataset.dirty = 'true'; }, true); if (openPreviewPopupButton) { openPreviewPopupButton.addEventListener('click', function () { openPreviewPopup(); }); } templateSelect.addEventListener('change', renderTemplate); templateFields.addEventListener('change', function () { templateSelectorLock.markEdited(); requestPreviewRender(); }); templateFields.addEventListener('input', function () { templateSelectorLock.markEdited(); requestPreviewRender(); }); if (existingTemplateId) { templateSelect.value = String(existingTemplateId); } renderTemplate(); if (window.ResizeObserver && slidePreviewStage) { var previewResizeObserver = new ResizeObserver(function () { requestPreviewRender(); }); previewResizeObserver.observe(slidePreviewStage); } requestSidebarSync(); window.addEventListener('scroll', requestSidebarSync, { passive: true }); window.addEventListener('resize', requestPreviewRender); window.addEventListener('resize', requestSidebarSync); window.addEventListener('beforeunload', function () { if (previewPopupWindow && !previewPopupWindow.closed) { previewPopupWindow.close(); } }); })();