/** * SIS Vietnam - Block Editor Configuration * * Built on Editor.js (https://editorjs.io) * * PLUGIN ARCHITECTURE: * ===================== * This editor uses a plugin registry pattern. The built-in block types * (Header, List, Quote, etc.) are registered by default. * * To add a CUSTOM BLOCK TYPE in the future: * * 1. Create a new JS file in /js/manage/editor-plugins/ * Example: /js/manage/editor-plugins/my-custom-block.js * * 2. In that file, register your tool BEFORE the editor initializes: * * window.SISEditorPlugins = window.SISEditorPlugins || {}; * window.SISEditorPlugins['myBlock'] = { * class: MyBlockClass, // Your Editor.js Tool class * inlineToolbar: true, // optional * config: { ... } // optional tool-specific config * }; * * 3. Load the script in your template BEFORE editor-config.js: * * * 4. The editor will automatically pick up all registered plugins. */ // Global plugin registry — external plugins register here window.SISEditorPlugins = window.SISEditorPlugins || {}; /** * Global Media Library Modal Handler * Callable from any Editor.js plugin via window.openSISMediaModal(callback) */ window.openSISMediaModal = function (callback) { if (window.SISMediaPicker && typeof window.SISMediaPicker.open === 'function') { window.SISMediaPicker.open(callback); } else { window.sisMediaSelectCallback = callback; var modalEl = document.getElementById('sisMediaLibraryModal'); if (modalEl && typeof $ !== 'undefined' && $.fn && $.fn.modal) { $(modalEl).modal('show'); } } }; /** * Dynamic Monaco Editor Script Loader Helper */ window.loadMonacoEditor = function () { if (window.monaco) return Promise.resolve(window.monaco); if (window._monacoLoadingPromise) return window._monacoLoadingPromise; window._monacoLoadingPromise = new Promise((resolve, reject) => { if (typeof require !== 'undefined' && require.config) { require(['vs/editor/editor.main'], function () { resolve(window.monaco); }); return; } const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js'; script.onload = () => { window.require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } }); window.require(['vs/editor/editor.main'], () => { resolve(window.monaco); }); }; script.onerror = (err) => reject(err); document.head.appendChild(script); }); return window._monacoLoadingPromise; }; class SISRawHtmlTool { static get toolbox() { return { title: 'Raw HTML', icon: '' }; } constructor({ data, api, readOnly }) { this.api = api; this.readOnly = readOnly; this.data = { html: (data && data.html) ? data.html : '', stretched: !!(data && data.stretched) }; this.textarea = null; this.editorInstance = null; } render() { const container = document.createElement('div'); container.style.width = '100%'; container.className = 'ce-raw-html-wrapper mb-3'; const stretchWrapper = document.createElement('div'); stretchWrapper.className = 'custom-control custom-switch mb-2'; this.stretchCheck = document.createElement('input'); this.stretchCheck.type = 'checkbox'; this.stretchCheck.className = 'custom-control-input'; this.stretchCheck.id = 'raw_stretch_' + Math.random().toString(36).substring(7); this.stretchCheck.checked = !!(this.data && this.data.stretched); const stretchLabel = document.createElement('label'); stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary'; stretchLabel.htmlFor = this.stretchCheck.id; stretchLabel.innerHTML = ' Stretch block to Full Screen Width (Independent Breakout)'; this.stretchCheck.addEventListener('change', () => { this.data.stretched = this.stretchCheck.checked; }); stretchWrapper.appendChild(this.stretchCheck); stretchWrapper.appendChild(stretchLabel); container.appendChild(stretchWrapper); // Header status bar const headerBar = document.createElement('div'); headerBar.className = 'bg-dark text-white p-2 rounded-top d-flex justify-content-between align-items-center small font-weight-bold'; headerBar.innerHTML = ' Raw HTML Code (Monaco IDE Editor)HTML5'; container.appendChild(headerBar); // Monaco Container const editorContainer = document.createElement('div'); editorContainer.className = 'monaco-editor-box border border-dark rounded-bottom'; editorContainer.style.height = '350px'; editorContainer.style.width = '100%'; container.appendChild(editorContainer); // Textarea fallback (hidden when Monaco is active) this.textarea = document.createElement('textarea'); this.textarea.className = 'form-control d-none'; this.textarea.value = this.data.html || ''; container.appendChild(this.textarea); if (this.readOnly) { this.stretchCheck.disabled = true; this.textarea.disabled = true; } // Initialize Monaco Editor window.loadMonacoEditor().then((monaco) => { if (!editorContainer || !document.body.contains(editorContainer)) return; this.editorInstance = monaco.editor.create(editorContainer, { value: this.data.html || '', language: 'html', theme: 'vs-dark', automaticLayout: true, minimap: { enabled: false }, fontSize: 13, lineNumbers: 'on', tabSize: 2, scrollBeyondLastLine: false, readOnly: !!this.readOnly }); this.editorInstance.onDidChangeModelContent(() => { this.data.html = this.editorInstance.getValue(); if (this.textarea) this.textarea.value = this.data.html; }); }).catch((err) => { console.warn('[SIS Raw HTML Tool] Monaco Editor CDN fallback to Textarea:', err); this.textarea.classList.remove('d-none'); this.textarea.style.minHeight = '300px'; this.textarea.style.backgroundColor = '#1e1e24'; this.textarea.style.color = '#66d9ef'; this.textarea.style.fontFamily = 'monospace'; editorContainer.style.display = 'none'; this.textarea.addEventListener('input', () => { this.data.html = this.textarea.value; }); }); return container; } save() { const htmlVal = this.editorInstance ? this.editorInstance.getValue() : (this.textarea ? this.textarea.value : (this.data.html || '')); return { html: htmlVal, stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched) }; } } class SISAccordionTool { static get toolbox() { return { title: 'Accordion / Collapse', icon: '' }; } constructor({ data, api, readOnly }) { this.api = api; this.readOnly = readOnly; this.data = { title: (data && data.title) ? data.title : '', content: (data && data.content) ? data.content : '', isOpen: !!(data && data.isOpen), stretched: !!(data && data.stretched) }; this.titleInput = null; this.contentInput = null; } render() { const container = document.createElement('div'); container.style.border = '1px solid #d1d3e2'; container.style.borderRadius = '6px'; container.style.padding = '12px'; container.style.background = '#ffffff'; container.style.marginBottom = '10px'; const headerLabel = document.createElement('label'); headerLabel.className = 'font-weight-bold text-primary small mb-1'; headerLabel.innerHTML = ' Accordion Title / Summary'; this.titleInput = document.createElement('input'); this.titleInput.type = 'text'; this.titleInput.className = 'form-control mb-2'; this.titleInput.placeholder = 'Enter accordion header/title...'; this.titleInput.value = this.data.title; const bodyLabel = document.createElement('label'); bodyLabel.className = 'font-weight-bold text-secondary small mb-1'; bodyLabel.innerHTML = ' Accordion Content / Details'; this.contentInput = document.createElement('textarea'); this.contentInput.className = 'form-control'; this.contentInput.style.minHeight = '100px'; this.contentInput.placeholder = 'Enter body content (HTML or text)...'; this.contentInput.value = this.data.content; if (this.readOnly) { this.titleInput.disabled = true; this.contentInput.disabled = true; } this.titleInput.addEventListener('input', () => { this.data.title = this.titleInput.value; }); this.contentInput.addEventListener('input', () => { this.data.content = this.contentInput.value; }); container.appendChild(headerLabel); container.appendChild(this.titleInput); container.appendChild(bodyLabel); container.appendChild(this.contentInput); // Open by default toggle const openWrapper = document.createElement('div'); openWrapper.className = 'custom-control custom-switch mt-2'; this.openCheck = document.createElement('input'); this.openCheck.type = 'checkbox'; this.openCheck.className = 'custom-control-input'; this.openCheck.id = 'acc_open_' + Math.random().toString(36).substring(7); this.openCheck.checked = !!(this.data && this.data.isOpen); const openLabel = document.createElement('label'); openLabel.className = 'custom-control-label small font-weight-bold text-primary'; openLabel.htmlFor = this.openCheck.id; openLabel.innerHTML = ' Expand / Uncollapse by default after loading page'; this.openCheck.addEventListener('change', () => { this.data.isOpen = this.openCheck.checked; }); openWrapper.appendChild(this.openCheck); openWrapper.appendChild(openLabel); container.appendChild(openWrapper); // Stretch toggle const stretchWrapper = document.createElement('div'); stretchWrapper.className = 'custom-control custom-switch mt-1'; this.stretchCheck = document.createElement('input'); this.stretchCheck.type = 'checkbox'; this.stretchCheck.className = 'custom-control-input'; this.stretchCheck.id = 'acc_stretch_' + Math.random().toString(36).substring(7); this.stretchCheck.checked = !!(this.data && this.data.stretched); const stretchLabel = document.createElement('label'); stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary'; stretchLabel.htmlFor = this.stretchCheck.id; stretchLabel.innerHTML = ' Stretch block to Full Screen Width (Independent Breakout)'; this.stretchCheck.addEventListener('change', () => { this.data.stretched = this.stretchCheck.checked; }); stretchWrapper.appendChild(this.stretchCheck); stretchWrapper.appendChild(stretchLabel); container.appendChild(stretchWrapper); return container; } save() { return { title: this.titleInput ? this.titleInput.value : this.data.title, content: this.contentInput ? this.contentInput.value : this.data.content, isOpen: this.openCheck ? this.openCheck.checked : !!(this.data && this.data.isOpen), stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched) }; } } class SISYouTubeTool { static get toolbox() { return { title: 'YouTube Video', icon: '' }; } constructor({ data, api, readOnly }) { this.api = api; this.readOnly = readOnly; this.data = { url: (data && data.url) ? data.url : '', thumbnailUrl: (data && data.thumbnailUrl) ? data.thumbnailUrl : '', caption: (data && data.caption) ? data.caption : '', stretched: !!(data && data.stretched) }; this.urlInput = null; this.thumbnailInput = null; this.captionInput = null; this.previewContainer = null; this.iframe = null; this.isIframeActive = false; } extractVideoId(input) { if (!input) return ''; var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/; var match = input.match(regExp); if (match && match[2].length === 11) { return match[2]; } if (input.length === 11) { return input; } return ''; } getEmbedUrl(videoId) { return videoId ? 'https://www.youtube.com/embed/' + videoId : ''; } getThumbnailUrl(videoId) { return videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : ''; } render() { const container = document.createElement('div'); container.style.border = '1px solid #e3e6f0'; container.style.borderRadius = '8px'; container.style.padding = '14px'; container.style.background = '#fff'; container.style.marginBottom = '12px'; const label = document.createElement('label'); label.className = 'font-weight-bold text-danger small mb-1'; label.innerHTML = ' YouTube Video Link / URL / ID'; this.urlInput = document.createElement('input'); this.urlInput.type = 'text'; this.urlInput.className = 'form-control mb-2'; this.urlInput.placeholder = 'Paste YouTube URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...'; this.urlInput.value = this.data.url; // Custom Thumbnail Field const thumbLabel = document.createElement('label'); thumbLabel.className = 'font-weight-bold text-secondary small mb-1'; thumbLabel.innerHTML = ' Custom Thumbnail Image URL (Optional - Overrides YouTube Cover)'; this.thumbnailInput = document.createElement('input'); this.thumbnailInput.type = 'text'; this.thumbnailInput.className = 'form-control form-control-sm mb-2'; this.thumbnailInput.placeholder = 'Custom Thumbnail Image URL (leave blank to auto-use YouTube cover)...'; this.thumbnailInput.value = this.data.thumbnailUrl; // Thumbnail Preview Container this.previewContainer = document.createElement('div'); this.previewContainer.className = 'yt-thumbnail-preview'; this.previewContainer.style.position = 'relative'; this.previewContainer.style.paddingBottom = '56.25%'; this.previewContainer.style.height = '0'; this.previewContainer.style.overflow = 'hidden'; this.previewContainer.style.background = '#111 center/cover no-repeat'; this.previewContainer.style.borderRadius = '6px'; this.previewContainer.style.marginBottom = '8px'; this.previewContainer.style.cursor = 'pointer'; this.previewContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)'; // Play Button Overlay const playBtn = document.createElement('div'); playBtn.className = 'yt-play-button-overlay'; playBtn.style.position = 'absolute'; playBtn.style.top = '50%'; playBtn.style.left = '50%'; playBtn.style.transform = 'translate(-50%, -50%)'; playBtn.style.transition = 'transform 0.2s ease'; playBtn.innerHTML = ''; this.previewContainer.appendChild(playBtn); // Update Thumbnail Image const updatePreview = () => { const customThumb = this.thumbnailInput.value.trim(); const videoId = this.extractVideoId(this.urlInput.value); const thumbUrl = customThumb || this.getThumbnailUrl(videoId); if (thumbUrl) { this.previewContainer.style.backgroundImage = 'url("' + thumbUrl + '")'; playBtn.style.display = 'block'; } else { this.previewContainer.style.backgroundImage = 'none'; this.previewContainer.style.backgroundColor = '#222'; } }; // Click thumbnail to play live video this.previewContainer.addEventListener('click', () => { const videoId = this.extractVideoId(this.urlInput.value); if (videoId && !this.isIframeActive) { this.isIframeActive = true; this.previewContainer.innerHTML = ''; const iframe = document.createElement('iframe'); iframe.style.position = 'absolute'; iframe.style.top = '0'; iframe.style.left = '0'; iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = '0'; iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'); iframe.setAttribute('allowfullscreen', 'true'); iframe.src = this.getEmbedUrl(videoId) + '?autoplay=1'; this.previewContainer.appendChild(iframe); } }); updatePreview(); this.captionInput = document.createElement('input'); this.captionInput.type = 'text'; this.captionInput.className = 'form-control form-control-sm mb-2'; this.captionInput.placeholder = 'Video caption (optional)...'; this.captionInput.value = this.data.caption; if (this.readOnly) { this.urlInput.disabled = true; this.thumbnailInput.disabled = true; this.captionInput.disabled = true; } this.urlInput.addEventListener('input', () => { const videoId = this.extractVideoId(this.urlInput.value); this.data.url = this.getEmbedUrl(videoId); this.isIframeActive = false; this.previewContainer.innerHTML = ''; this.previewContainer.appendChild(playBtn); updatePreview(); }); this.thumbnailInput.addEventListener('input', () => { this.data.thumbnailUrl = this.thumbnailInput.value.trim(); updatePreview(); }); this.captionInput.addEventListener('input', () => { this.data.caption = this.captionInput.value; }); container.appendChild(label); container.appendChild(this.urlInput); container.appendChild(thumbLabel); container.appendChild(this.thumbnailInput); container.appendChild(this.previewContainer); container.appendChild(this.captionInput); const stretchWrapper = document.createElement('div'); stretchWrapper.className = 'custom-control custom-switch mt-2'; this.stretchCheck = document.createElement('input'); this.stretchCheck.type = 'checkbox'; this.stretchCheck.className = 'custom-control-input'; this.stretchCheck.id = 'yt_stretch_' + Math.random().toString(36).substring(7); this.stretchCheck.checked = !!(this.data && this.data.stretched); const stretchLabel = document.createElement('label'); stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary'; stretchLabel.htmlFor = this.stretchCheck.id; stretchLabel.innerHTML = ' Stretch block to Full Screen Width (Independent Breakout)'; this.stretchCheck.addEventListener('change', () => { this.data.stretched = this.stretchCheck.checked; }); stretchWrapper.appendChild(this.stretchCheck); stretchWrapper.appendChild(stretchLabel); container.appendChild(stretchWrapper); return container; } save() { const videoId = this.extractVideoId(this.urlInput ? this.urlInput.value : this.data.url); return { url: this.getEmbedUrl(videoId) || this.data.url, thumbnailUrl: this.thumbnailInput ? this.thumbnailInput.value.trim() : (this.data.thumbnailUrl || ''), caption: this.captionInput ? this.captionInput.value : this.data.caption, stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched) }; } } // SISHeroBannerTool extracted to /js/manage/editor-plugins/hero-banner.js /** * Initialize the SIS Block Editor on a given holder element. * * @param {string} holderId - The DOM element ID for the editor container * @param {string} hiddenInputId - The DOM element ID for the hidden input storing JSON * @param {object|null} initialData - Pre-existing Editor.js JSON data to load * @param {boolean} [skipSubmitHandler=false] - If true, prevents automatic form submission handling * @returns {EditorJS} The editor instance */ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler = false) { if (typeof EditorJS === 'undefined') { console.error('[SIS Editor] Editor.js library is not loaded!'); return null; } // === Register Built-in Tools (Defensive Check for Globals) === var builtInTools = {}; if (typeof Header !== 'undefined') { builtInTools.header = { class: Header, inlineToolbar: ['link'], tunes: ['textStyling'], config: { placeholder: 'Header text...', levels: [1, 2, 3, 4, 5, 6], defaultLevel: 2 } }; } builtInTools.paragraph = { tunes: ['textStyling'] }; if (typeof NestedList !== 'undefined') { builtInTools.list = { class: NestedList, inlineToolbar: true, tunes: ['textStyling'], config: { defaultStyle: 'unordered' } }; } else if (typeof List !== 'undefined') { builtInTools.list = { class: List, inlineToolbar: true, tunes: ['textStyling'], config: { defaultStyle: 'unordered' } }; } if (typeof Quote !== 'undefined') { builtInTools.quote = { class: Quote, inlineToolbar: true, tunes: ['textStyling'], config: { quotePlaceholder: 'Enter a quote', captionPlaceholder: 'Quote\'s author' } }; } if (typeof Delimiter !== 'undefined') builtInTools.delimiter = { class: Delimiter }; if (typeof Table !== 'undefined') { builtInTools.table = { class: Table, inlineToolbar: true, tunes: ['textStyling'], config: { rows: 2, cols: 3 } }; } if (typeof CodeTool !== 'undefined') builtInTools.code = { class: CodeTool }; if (typeof Warning !== 'undefined') builtInTools.warning = { class: Warning, inlineToolbar: true, config: { titlePlaceholder: 'Title', messagePlaceholder: 'Message' } }; if (typeof Marker !== 'undefined') builtInTools.marker = { class: Marker }; if (typeof InlineCode !== 'undefined') builtInTools.inlineCode = { class: InlineCode }; if (typeof Underline !== 'undefined') builtInTools.underline = { class: Underline }; if (typeof ImageTool !== 'undefined') { builtInTools.image = { class: ImageTool, config: { endpoints: { byFile: '/api/manage/media/upload', byUrl: '/api/manage/media/fetchUrl' }, uploader: { uploadByUrl(url) { return Promise.resolve({ success: 1, file: { url: url } }); }, selectFile() { return new Promise((resolve, reject) => { if (window.SISMediaPicker) { SISMediaPicker.open((url, mediaObj, config) => { if (url) { resolve({ success: 1, file: { url: url, style: config ? config.style : '', cssClass: config ? config.cssClass : '', alt: config ? config.alt : '', customAttributes: config ? config.customAttributes : '', aspectRatio: config ? config.aspectRatio : '' } }); } else { reject('No image selected'); } }); } else { reject('SISMediaPicker not loaded'); } }); } }, field: 'file', types: 'image/*' } }; window.SISEditorPlugins.image = builtInTools.image; } if (typeof AttachesTool !== 'undefined') { builtInTools.attaches = { class: AttachesTool, config: { endpoint: '/api/manage/media/upload', field: 'file' } }; } // Custom SIS Tools (Always present) builtInTools.raw = { class: SISRawHtmlTool }; builtInTools.accordion = { class: SISAccordionTool }; builtInTools.youtube = { class: SISYouTubeTool }; if (typeof SISHeroBannerTool !== 'undefined') { builtInTools.hero = { class: SISHeroBannerTool }; } if (typeof SISStickyNavTool !== 'undefined') { builtInTools.stickyNav = { class: SISStickyNavTool }; } if (typeof TextStylingTune !== 'undefined') { builtInTools.textStyling = { class: TextStylingTune }; } // === Merge built-in tools with any registered plugins === var allTools = Object.assign({}, builtInTools, window.SISEditorPlugins); // Apply the textStyling tune to all block tools dynamically for (var toolName in allTools) { if (toolName !== 'textStyling') { if (!allTools[toolName].tunes) { allTools[toolName].tunes = ['textStyling']; } else if (!allTools[toolName].tunes.includes('textStyling')) { allTools[toolName].tunes.push('textStyling'); } } } // === Parse initial data === var editorData = null; if (initialData && typeof initialData === 'string') { try { editorData = JSON.parse(initialData); } catch (e) { console.warn('[SIS Editor] Could not parse initial data as JSON, starting empty.', e); editorData = null; } } else if (initialData && typeof initialData === 'object') { editorData = initialData; } // === Create the Editor === var editor = new EditorJS({ holder: holderId, tools: allTools, data: editorData || undefined, placeholder: 'Click here to start writing your page content...', autofocus: false, onReady: function() { console.log('[SIS Editor] Ready. Tools loaded:', Object.keys(allTools)); }, onChange: function(api, event) { // Auto-save to hidden input on every change api.saver.save().then(function(outputData) { var hiddenInput = document.getElementById(hiddenInputId); if (hiddenInput) { hiddenInput.value = JSON.stringify(outputData); } }); } }); // === Form submission handler === // Ensure the latest content is saved before form submit var hiddenInput = document.getElementById(hiddenInputId); var form = hiddenInput ? hiddenInput.closest('form') : document.querySelector('form'); if (form && !skipSubmitHandler) { var submitHandler = function(event) { event.preventDefault(); editor.save().then(function(outputData) { var hiddenInput = document.getElementById(hiddenInputId); if (hiddenInput) { hiddenInput.value = JSON.stringify(outputData); } // Now submit the form form.removeEventListener('submit', submitHandler); form.submit(); }).catch(function(error) { console.error('[SIS Editor] Save failed:', error); }); }; form.addEventListener('submit', submitHandler); } return editor; } /** * Setup IDE Code Editor styling and Tab indentation for custom CSS and JS textareas */ function setupCodeTextareas() { var codeSelectors = '#customCss, #customJs, #postCustomCss, #postCustomJs, textarea[name="customCss"], textarea[name="customJs"]'; document.querySelectorAll(codeSelectors).forEach(function(textarea) { if (!textarea || textarea.dataset.codeEditorInit) return; textarea.dataset.codeEditorInit = 'true'; // Enable Tab key indentation (inserts 2 spaces) textarea.addEventListener('keydown', function(e) { if (e.key === 'Tab') { e.preventDefault(); var start = this.selectionStart; var end = this.selectionEnd; this.value = this.value.substring(0, start) + ' ' + this.value.substring(end); this.selectionStart = this.selectionEnd = start + 2; } }); }); } // Auto-run code textareas setup on page load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', setupCodeTextareas); } else { setupCodeTextareas(); }