import { Alignment, BlockQuote, Bold, ClassicEditor, Essentials, FontBackgroundColor, FontColor, FontFamily, FontSize, Heading, HorizontalLine, Indent, IndentBlock, Italic, InputTextView, Plugin, Paragraph, Strikethrough, Subscript, Superscript, Underline } from '/assets/js/ckeditor5/ckeditor5.js'; (function () { 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 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 slidePreviewMeta = document.getElementById('slide-preview-meta'); var slidePreviewStage = document.getElementById('slide-preview-stage'); var slidePreviewBackground = document.getElementById('slide-preview-background'); var slidePreviewEmpty = document.getElementById('slide-preview-empty'); var slidePreviewOverlay = document.getElementById('slide-preview-overlay'); var editorInstances = new Map(); var submitting = false; 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 ''; } 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; 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; }); } } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } 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 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 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 size = Math.max(1, Math.round(Number(String(value || '').replace(/[^0-9.]/g, '')))); return size ? String(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 || 24)), font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000' }; } 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 renderPreview() { var template = getSelectedTemplate(); if (!template) { slidePreviewMeta.textContent = 'Select a template to preview the slide.'; slidePreviewBackground.style.display = 'none'; slidePreviewEmpty.style.display = 'flex'; slidePreviewEmpty.textContent = 'Select a template to preview the slide.'; slidePreviewOverlay.innerHTML = ''; slidePreviewOverlay.style.width = ''; slidePreviewOverlay.style.height = ''; slidePreviewOverlay.style.transform = ''; slidePreviewStage.style.aspectRatio = '16 / 9'; return; } var canvasWidth = Math.max(1, Number(template.canvas_size_width || 1920)); var canvasHeight = Math.max(1, Number(template.canvas_size_height || 1080)); slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight; slidePreviewMeta.textContent = template.name + ' • ' + 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 || ''; 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(); var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1; slidePreviewOverlay.style.width = canvasWidth + 'px'; slidePreviewOverlay.style.height = canvasHeight + 'px'; slidePreviewOverlay.style.transform = 'scale(' + scale + ')'; slidePreviewOverlay.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 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 === 'html' ? (htmlInput ? htmlInput.value : '') : (hiddenInput ? hiddenInput.value : ''); var style = region.region_type === 'image' ? null : region.region_type === 'webpage' ? 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 === '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 === 'html' ? ' slide-preview-html-region' : ' slide-preview-text-region'; return '
    ' + content + '
    '; }).join(''); } function renderTextRegion(region) { var current = getCurrentRegionValue(region); return '' + '
    ' + '
    ' + '' + escapeHtml(region.label) + '' + 'Text' + '
    ' + '
    ' + '' + '
    ' + '' + '' + '
    '; } function renderWebpageRegion(region) { var current = getCurrentRegionValue(region); return '' + '
    ' + '
    ' + '' + escapeHtml(region.label) + '' + 'Webpage' + '
    ' + '' + '
    The webpage will be loaded in an iframe.
    ' + '
    '; } 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() { clearImagePreviewUrls(); destroyEditors(); var template = getTemplateById(templateSelect.value); if (!template) { templateFields.innerHTML = ''; renderPreview(); 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 === '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', '|', 'heading', '|', 'fontSizeInput', 'fontFamily', 'fontColor', 'fontBackgroundColor', '|', 'bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript', '|', 'horizontalLine', 'blockQuote', '|', 'alignment', '|', 'outdent', 'indent' ], shouldNotGroupWhenFull: false }, plugins: [ Alignment, BlockQuote, Bold, Essentials, FontBackgroundColor, FontColor, FontFamily, FontSize, FontSizeInputUI, Heading, HorizontalLine, Indent, IndentBlock, Italic, Paragraph, Strikethrough, Subscript, Superscript, Underline ], fontFamily: { supportAllValues: true }, fontSize: { options: [20, 24, 28, 32, 36, 40, 44], supportAllValues: true }, heading: { options: [ { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' }, { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' }, { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' }, { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' }, { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' } ] }, initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : '')) }; ClassicEditor.create(source || holder, editorConfig).then(function (editor) { editorInstances.set(regionId, editor); var fontSizeHidden = getEditorFontSizeHiddenInput(regionId); var fontSizeCommand = editor.commands.get('fontSize'); if (hidden) { hidden.value = editor.getData(); } if (fontSizeHidden && fontSizeCommand) { fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value; fontSizeCommand.on('change:value', function () { fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value; }); } editor.model.document.on('change:data', function () { var instance = editorInstances.get(regionId); if (instance !== editor) { return; } if (hidden) { hidden.value = editor.getData(); } renderPreview(); }); renderPreview(); }).catch(function (error) { console.error('Failed to initialize CKEditor.', error); }); }); 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]); } renderPreview(); }); }); renderPreview(); } slideForm.addEventListener('submit', async function (event) { if (submitting) { return; } event.preventDefault(); 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(); } hidden.value = editor.getData(); return Promise.resolve(); }); submitting = true; try { await Promise.all(saves); var response = await fetch(slideForm.action, { method: (slideForm.method || 'POST').toUpperCase(), headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/html, application/json, text/plain, */*' }, body: new FormData(slideForm), credentials: 'same-origin' }); if (!response.ok) { throw new Error(await response.text() || 'Unable to save slide.'); } 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.'); window.location.href = response.url || slideForm.action; } finally { submitting = false; } }); templateSelect.addEventListener('change', renderTemplate); templateFields.addEventListener('change', renderPreview); templateFields.addEventListener('input', renderPreview); if (existingTemplateId) { templateSelect.value = String(existingTemplateId); } renderTemplate(); window.addEventListener('resize', renderPreview); })();