5 lines
109 KiB
JSON
5 lines
109 KiB
JSON
{
|
|
"editor-config.js": "/**\n * SIS Vietnam - Block Editor Configuration\n * \n * Built on Editor.js (https://editorjs.io)\n * \n * PLUGIN ARCHITECTURE:\n * =====================\n * This editor uses a plugin registry pattern. The built-in block types\n * (Header, List, Quote, etc.) are registered by default.\n * \n * To add a CUSTOM BLOCK TYPE in the future:\n * \n * 1. Create a new JS file in /js/manage/editor-plugins/\n * Example: /js/manage/editor-plugins/my-custom-block.js\n * \n * 2. In that file, register your tool BEFORE the editor initializes:\n * \n * window.SISEditorPlugins = window.SISEditorPlugins || {};\n * window.SISEditorPlugins['myBlock'] = {\n * class: MyBlockClass, // Your Editor.js Tool class\n * inlineToolbar: true, // optional\n * config: { ... } // optional tool-specific config\n * };\n * \n * 3. Load the script in your template BEFORE editor-config.js:\n * <script src=\"/js/manage/editor-plugins/my-custom-block.js\"></script>\n * \n * 4. The editor will automatically pick up all registered plugins.\n */\n\n// Global plugin registry — external plugins register here\nwindow.SISEditorPlugins = window.SISEditorPlugins || {};\n\nclass SISRawHtmlTool {\n static get toolbox() {\n return {\n title: 'Raw HTML',\n icon: '<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"16 18 22 12 16 6\"></polyline><polyline points=\"8 6 2 12 8 18\"></polyline></svg>'\n };\n }\n\n constructor({ data, api, readOnly }) {\n this.api = api;\n this.readOnly = readOnly;\n this.data = {\n html: (data && data.html) ? data.html : '',\n stretched: !!(data && data.stretched)\n };\n this.textarea = null;\n }\n\n render() {\n const container = document.createElement('div');\n container.style.width = '100%';\n\n const stretchWrapper = document.createElement('div');\n stretchWrapper.className = 'custom-control custom-switch mb-2';\n this.stretchCheck = document.createElement('input');\n this.stretchCheck.type = 'checkbox';\n this.stretchCheck.className = 'custom-control-input';\n this.stretchCheck.id = 'raw_stretch_' + Math.random().toString(36).substring(7);\n this.stretchCheck.checked = !!(this.data && this.data.stretched);\n\n const stretchLabel = document.createElement('label');\n stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';\n stretchLabel.htmlFor = this.stretchCheck.id;\n stretchLabel.innerHTML = '<i class=\"fas fa-arrows-alt-h\"></i> Stretch block to Full Screen Width (Independent Breakout)';\n\n this.stretchCheck.addEventListener('change', () => {\n this.data.stretched = this.stretchCheck.checked;\n });\n\n stretchWrapper.appendChild(this.stretchCheck);\n stretchWrapper.appendChild(stretchLabel);\n\n this.textarea = document.createElement('textarea');\n this.textarea.className = 'form-control';\n this.textarea.style.fontFamily = 'monospace';\n this.textarea.style.fontSize = '13px';\n this.textarea.style.minHeight = '250px';\n this.textarea.style.whiteSpace = 'pre-wrap';\n this.textarea.value = this.data.html || '';\n\n if (this.readOnly) {\n this.textarea.disabled = true;\n this.stretchCheck.disabled = true;\n }\n\n this.textarea.addEventListener('input', () => {\n this.data.html = this.textarea.value;\n });\n\n container.appendChild(stretchWrapper);\n container.appendChild(this.textarea);\n return container;\n }\n\n save(container) {\n return {\n html: this.textarea ? this.textarea.value : this.data.html,\n stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)\n };\n }\n}\n\nclass SISAccordionTool {\n static get toolbox() {\n return {\n title: 'Accordion / Collapse',\n icon: '<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M6 9l6 6 6-6\"/></svg>'\n };\n }\n\n constructor({ data, api, readOnly }) {\n this.api = api;\n this.readOnly = readOnly;\n this.data = {\n title: (data && data.title) ? data.title : '',\n content: (data && data.content) ? data.content : '',\n isOpen: !!(data && data.isOpen),\n stretched: !!(data && data.stretched)\n };\n this.titleInput = null;\n this.contentInput = null;\n }\n\n render() {\n const container = document.createElement('div');\n container.style.border = '1px solid #d1d3e2';\n container.style.borderRadius = '6px';\n container.style.padding = '12px';\n container.style.background = '#ffffff';\n container.style.marginBottom = '10px';\n\n const headerLabel = document.createElement('label');\n headerLabel.className = 'font-weight-bold text-primary small mb-1';\n headerLabel.innerHTML = '<i class=\"fas fa-chevron-circle-down\"></i> Accordion Title / Summary';\n\n this.titleInput = document.createElement('input');\n this.titleInput.type = 'text';\n this.titleInput.className = 'form-control mb-2';\n this.titleInput.placeholder = 'Enter accordion header/title...';\n this.titleInput.value = this.data.title;\n\n const bodyLabel = document.createElement('label');\n bodyLabel.className = 'font-weight-bold text-secondary small mb-1';\n bodyLabel.innerHTML = '<i class=\"fas fa-align-left\"></i> Accordion Content / Details';\n\n this.contentInput = document.createElement('textarea');\n this.contentInput.className = 'form-control';\n this.contentInput.style.minHeight = '100px';\n this.contentInput.placeholder = 'Enter body content (HTML or text)...';\n this.contentInput.value = this.data.content;\n\n if (this.readOnly) {\n this.titleInput.disabled = true;\n this.contentInput.disabled = true;\n }\n\n this.titleInput.addEventListener('input', () => {\n this.data.title = this.titleInput.value;\n });\n\n this.contentInput.addEventListener('input', () => {\n this.data.content = this.contentInput.value;\n });\n\n container.appendChild(headerLabel);\n container.appendChild(this.titleInput);\n container.appendChild(bodyLabel);\n container.appendChild(this.contentInput);\n\n // Open by default toggle\n const openWrapper = document.createElement('div');\n openWrapper.className = 'custom-control custom-switch mt-2';\n this.openCheck = document.createElement('input');\n this.openCheck.type = 'checkbox';\n this.openCheck.className = 'custom-control-input';\n this.openCheck.id = 'acc_open_' + Math.random().toString(36).substring(7);\n this.openCheck.checked = !!(this.data && this.data.isOpen);\n\n const openLabel = document.createElement('label');\n openLabel.className = 'custom-control-label small font-weight-bold text-primary';\n openLabel.htmlFor = this.openCheck.id;\n openLabel.innerHTML = '<i class=\"fas fa-folder-open\"></i> Expand / Uncollapse by default after loading page';\n\n this.openCheck.addEventListener('change', () => {\n this.data.isOpen = this.openCheck.checked;\n });\n\n openWrapper.appendChild(this.openCheck);\n openWrapper.appendChild(openLabel);\n container.appendChild(openWrapper);\n\n // Stretch toggle\n const stretchWrapper = document.createElement('div');\n stretchWrapper.className = 'custom-control custom-switch mt-1';\n this.stretchCheck = document.createElement('input');\n this.stretchCheck.type = 'checkbox';\n this.stretchCheck.className = 'custom-control-input';\n this.stretchCheck.id = 'acc_stretch_' + Math.random().toString(36).substring(7);\n this.stretchCheck.checked = !!(this.data && this.data.stretched);\n\n const stretchLabel = document.createElement('label');\n stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';\n stretchLabel.htmlFor = this.stretchCheck.id;\n stretchLabel.innerHTML = '<i class=\"fas fa-arrows-alt-h\"></i> Stretch block to Full Screen Width (Independent Breakout)';\n\n this.stretchCheck.addEventListener('change', () => {\n this.data.stretched = this.stretchCheck.checked;\n });\n\n stretchWrapper.appendChild(this.stretchCheck);\n stretchWrapper.appendChild(stretchLabel);\n container.appendChild(stretchWrapper);\n\n return container;\n }\n\n save() {\n return {\n title: this.titleInput ? this.titleInput.value : this.data.title,\n content: this.contentInput ? this.contentInput.value : this.data.content,\n isOpen: this.openCheck ? this.openCheck.checked : !!(this.data && this.data.isOpen),\n stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)\n };\n }\n}\n\nclass SISYouTubeTool {\n static get toolbox() {\n return {\n title: 'YouTube Video',\n icon: '<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"red\"><path d=\"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z\"/></svg>'\n };\n }\n\n constructor({ data, api, readOnly }) {\n this.api = api;\n this.readOnly = readOnly;\n this.data = {\n url: (data && data.url) ? data.url : '',\n thumbnailUrl: (data && data.thumbnailUrl) ? data.thumbnailUrl : '',\n caption: (data && data.caption) ? data.caption : '',\n stretched: !!(data && data.stretched)\n };\n this.urlInput = null;\n this.thumbnailInput = null;\n this.captionInput = null;\n this.previewContainer = null;\n this.iframe = null;\n this.isIframeActive = false;\n }\n\n extractVideoId(input) {\n if (!input) return '';\n var regExp = /^.*(youtu.be\\/|v\\/|u\\/\\w\\/|embed\\/|watch\\?v=|\\&v=)([^#\\&\\?]*).*/;\n var match = input.match(regExp);\n if (match && match[2].length === 11) {\n return match[2];\n }\n if (input.length === 11) {\n return input;\n }\n return '';\n }\n\n getEmbedUrl(videoId) {\n return videoId ? 'https://www.youtube.com/embed/' + videoId : '';\n }\n\n getThumbnailUrl(videoId) {\n return videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : '';\n }\n\n render() {\n const container = document.createElement('div');\n container.style.border = '1px solid #e3e6f0';\n container.style.borderRadius = '8px';\n container.style.padding = '14px';\n container.style.background = '#fff';\n container.style.marginBottom = '12px';\n\n const label = document.createElement('label');\n label.className = 'font-weight-bold text-danger small mb-1';\n label.innerHTML = '<i class=\"fab fa-youtube\"></i> YouTube Video Link / URL / ID';\n\n this.urlInput = document.createElement('input');\n this.urlInput.type = 'text';\n this.urlInput.className = 'form-control mb-2';\n this.urlInput.placeholder = 'Paste YouTube URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...';\n this.urlInput.value = this.data.url;\n\n // Custom Thumbnail Field\n const thumbLabel = document.createElement('label');\n thumbLabel.className = 'font-weight-bold text-secondary small mb-1';\n thumbLabel.innerHTML = '<i class=\"fas fa-image\"></i> Custom Thumbnail Image URL (Optional - Overrides YouTube Cover)';\n\n this.thumbnailInput = document.createElement('input');\n this.thumbnailInput.type = 'text';\n this.thumbnailInput.className = 'form-control form-control-sm mb-2';\n this.thumbnailInput.placeholder = 'Custom Thumbnail Image URL (leave blank to auto-use YouTube cover)...';\n this.thumbnailInput.value = this.data.thumbnailUrl;\n\n // Thumbnail Preview Container\n this.previewContainer = document.createElement('div');\n this.previewContainer.className = 'yt-thumbnail-preview';\n this.previewContainer.style.position = 'relative';\n this.previewContainer.style.paddingBottom = '56.25%';\n this.previewContainer.style.height = '0';\n this.previewContainer.style.overflow = 'hidden';\n this.previewContainer.style.background = '#111 center/cover no-repeat';\n this.previewContainer.style.borderRadius = '6px';\n this.previewContainer.style.marginBottom = '8px';\n this.previewContainer.style.cursor = 'pointer';\n this.previewContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';\n\n // Play Button Overlay\n const playBtn = document.createElement('div');\n playBtn.className = 'yt-play-button-overlay';\n playBtn.style.position = 'absolute';\n playBtn.style.top = '50%';\n playBtn.style.left = '50%';\n playBtn.style.transform = 'translate(-50%, -50%)';\n playBtn.style.transition = 'transform 0.2s ease';\n playBtn.innerHTML = '<svg width=\"68\" height=\"48\" viewBox=\"0 0 68 48\"><path d=\"M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z\" fill=\"#ff0000\"/><path d=\"M45 24L27 14v20z\" fill=\"#ffffff\"/></svg>';\n \n this.previewContainer.appendChild(playBtn);\n\n // Update Thumbnail Image\n const updatePreview = () => {\n const customThumb = this.thumbnailInput.value.trim();\n const videoId = this.extractVideoId(this.urlInput.value);\n const thumbUrl = customThumb || this.getThumbnailUrl(videoId);\n if (thumbUrl) {\n this.previewContainer.style.backgroundImage = 'url(\"' + thumbUrl + '\")';\n playBtn.style.display = 'block';\n } else {\n this.previewContainer.style.backgroundImage = 'none';\n this.previewContainer.style.backgroundColor = '#222';\n }\n };\n\n // Click thumbnail to play live video\n this.previewContainer.addEventListener('click', () => {\n const videoId = this.extractVideoId(this.urlInput.value);\n if (videoId && !this.isIframeActive) {\n this.isIframeActive = true;\n this.previewContainer.innerHTML = '';\n const iframe = document.createElement('iframe');\n iframe.style.position = 'absolute';\n iframe.style.top = '0';\n iframe.style.left = '0';\n iframe.style.width = '100%';\n iframe.style.height = '100%';\n iframe.style.border = '0';\n iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share');\n iframe.setAttribute('allowfullscreen', 'true');\n iframe.src = this.getEmbedUrl(videoId) + '?autoplay=1';\n this.previewContainer.appendChild(iframe);\n }\n });\n\n updatePreview();\n\n this.captionInput = document.createElement('input');\n this.captionInput.type = 'text';\n this.captionInput.className = 'form-control form-control-sm mb-2';\n this.captionInput.placeholder = 'Video caption (optional)...';\n this.captionInput.value = this.data.caption;\n\n if (this.readOnly) {\n this.urlInput.disabled = true;\n this.thumbnailInput.disabled = true;\n this.captionInput.disabled = true;\n }\n\n this.urlInput.addEventListener('input', () => {\n const videoId = this.extractVideoId(this.urlInput.value);\n this.data.url = this.getEmbedUrl(videoId);\n this.isIframeActive = false;\n this.previewContainer.innerHTML = '';\n this.previewContainer.appendChild(playBtn);\n updatePreview();\n });\n\n this.thumbnailInput.addEventListener('input', () => {\n this.data.thumbnailUrl = this.thumbnailInput.value.trim();\n updatePreview();\n });\n\n this.captionInput.addEventListener('input', () => {\n this.data.caption = this.captionInput.value;\n });\n\n container.appendChild(label);\n container.appendChild(this.urlInput);\n container.appendChild(thumbLabel);\n container.appendChild(this.thumbnailInput);\n container.appendChild(this.previewContainer);\n container.appendChild(this.captionInput);\n\n const stretchWrapper = document.createElement('div');\n stretchWrapper.className = 'custom-control custom-switch mt-2';\n this.stretchCheck = document.createElement('input');\n this.stretchCheck.type = 'checkbox';\n this.stretchCheck.className = 'custom-control-input';\n this.stretchCheck.id = 'yt_stretch_' + Math.random().toString(36).substring(7);\n this.stretchCheck.checked = !!(this.data && this.data.stretched);\n\n const stretchLabel = document.createElement('label');\n stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';\n stretchLabel.htmlFor = this.stretchCheck.id;\n stretchLabel.innerHTML = '<i class=\"fas fa-arrows-alt-h\"></i> Stretch block to Full Screen Width (Independent Breakout)';\n\n this.stretchCheck.addEventListener('change', () => {\n this.data.stretched = this.stretchCheck.checked;\n });\n\n stretchWrapper.appendChild(this.stretchCheck);\n stretchWrapper.appendChild(stretchLabel);\n container.appendChild(stretchWrapper);\n\n return container;\n }\n\n save() {\n const videoId = this.extractVideoId(this.urlInput ? this.urlInput.value : this.data.url);\n return {\n url: this.getEmbedUrl(videoId) || this.data.url,\n thumbnailUrl: this.thumbnailInput ? this.thumbnailInput.value.trim() : (this.data.thumbnailUrl || ''),\n caption: this.captionInput ? this.captionInput.value : this.data.caption,\n stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)\n };\n }\n}\n\nclass SISHeroBannerTool {\n static get toolbox() {\n return {\n title: 'Hero Banner',\n icon: '<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"/><line x1=\"3\" y1=\"9\" x2=\"21\" y2=\"9\"/><line x1=\"9\" y1=\"21\" x2=\"9\" y2=\"9\"/></svg>'\n };\n }\n\n constructor({ data, api, readOnly }) {\n this.api = api;\n this.readOnly = readOnly;\n this.data = {\n title: (data && data.title) ? data.title : '',\n subtitle: (data && data.subtitle) ? data.subtitle : '',\n bgImage: (data && data.bgImage) ? data.bgImage : '',\n btnText: (data && data.btnText) ? data.btnText : '',\n btnLink: (data && data.btnLink) ? data.btnLink : '',\n height: (data && data.height) ? data.height : '350px',\n textAlign: (data && data.textAlign) ? data.textAlign : 'center',\n overlayOpacity: (data && data.overlayOpacity !== undefined) ? data.overlayOpacity : '0.4',\n stretched: (data && data.stretched !== undefined) ? !!data.stretched : true\n };\n }\n\n render() {\n const container = document.createElement('div');\n container.style.border = '1px solid #e3e6f0';\n container.style.borderRadius = '8px';\n container.style.padding = '14px';\n container.style.background = '#fff';\n container.style.marginBottom = '12px';\n\n const headerLabel = document.createElement('label');\n headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';\n headerLabel.innerHTML = '<i class=\"fas fa-image\"></i> Hero Banner Block Settings';\n container.appendChild(headerLabel);\n\n // Stretch toggle\n const stretchWrapper = document.createElement('div');\n stretchWrapper.className = 'custom-control custom-switch mb-3';\n const stretchCheck = document.createElement('input');\n stretchCheck.type = 'checkbox';\n stretchCheck.className = 'custom-control-input';\n stretchCheck.id = 'hero_stretch_' + Math.random().toString(36).substring(7);\n stretchCheck.checked = !!this.data.stretched;\n\n const stretchLabel = document.createElement('label');\n stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';\n stretchLabel.htmlFor = stretchCheck.id;\n stretchLabel.innerHTML = '<i class=\"fas fa-arrows-alt-h\"></i> Stretch Banner to Full Screen Width';\n\n stretchCheck.addEventListener('change', () => {\n this.data.stretched = stretchCheck.checked;\n });\n stretchWrapper.appendChild(stretchCheck);\n stretchWrapper.appendChild(stretchLabel);\n container.appendChild(stretchWrapper);\n\n // Inputs\n this.titleInput = this._createInput('Banner Title', 'e.g. Bệnh Viện Đa Khoa Quốc Tế S.I.S Cần Thơ', this.data.title);\n this.subtitleInput = this._createInput('Subtitle / Description', 'e.g. Trao niềm tin - Nhận sức khỏe...', this.data.subtitle);\n this.bgImageInput = this._createInput('Background Image URL', 'https://example.com/hero-banner.jpg', this.data.bgImage);\n \n // Row for Height & Button Link\n const configRow = document.createElement('div');\n configRow.className = 'form-row';\n\n const heightCol = document.createElement('div');\n heightCol.className = 'col-md-4 mb-2';\n this.heightInput = this._createInput('Banner Height (e.g. 350px, 500px, 60vh)', 'e.g. 350px', this.data.height);\n heightCol.appendChild(this.heightInput);\n\n const btnCol1 = document.createElement('div');\n btnCol1.className = 'col-md-4 mb-2';\n this.btnTextInput = this._createInput('Button Text (Optional)', 'e.g. Đặt Lịch Khám', this.data.btnText);\n btnCol1.appendChild(this.btnTextInput);\n\n const btnCol2 = document.createElement('div');\n btnCol2.className = 'col-md-4 mb-2';\n this.btnLinkInput = this._createInput('Button Link URL (Optional)', 'e.g. /dat-lich', this.data.btnLink);\n btnCol2.appendChild(this.btnLinkInput);\n\n configRow.appendChild(heightCol);\n configRow.appendChild(btnCol1);\n configRow.appendChild(btnCol2);\n\n // Live Preview Box\n const previewBox = document.createElement('div');\n previewBox.className = 'hero-banner-preview p-4 rounded text-white my-2';\n previewBox.style.position = 'relative';\n previewBox.style.minHeight = this.data.height || '350px';\n previewBox.style.display = 'flex';\n previewBox.style.flexDirection = 'column';\n previewBox.style.justifyContent = 'center';\n previewBox.style.alignItems = 'center';\n previewBox.style.textAlign = 'center';\n previewBox.style.backgroundSize = 'cover';\n previewBox.style.backgroundPosition = 'center';\n previewBox.style.overflow = 'hidden';\n\n const overlay = document.createElement('div');\n overlay.style.position = 'absolute';\n overlay.style.top = '0';\n overlay.style.left = '0';\n overlay.style.right = '0';\n overlay.style.bottom = '0';\n overlay.style.background = '#000';\n overlay.style.zIndex = '1';\n previewBox.appendChild(overlay);\n\n const contentBox = document.createElement('div');\n contentBox.style.position = 'relative';\n contentBox.style.zIndex = '2';\n previewBox.appendChild(contentBox);\n\n const updatePreview = () => {\n const bg = this.bgImageInput.querySelector('input').value.trim();\n const title = this.titleInput.querySelector('input').value.trim() || 'Hero Banner Title';\n const sub = this.subtitleInput.querySelector('input').value.trim();\n const btnT = this.btnTextInput.querySelector('input').value.trim();\n const bannerH = this.heightInput.querySelector('input').value.trim() || '350px';\n\n previewBox.style.minHeight = bannerH;\n previewBox.style.backgroundImage = bg ? 'url(\"' + bg + '\")' : 'linear-gradient(135deg, #002554, #881C1C)';\n overlay.style.opacity = this.data.overlayOpacity || '0.4';\n\n let html = '<h4 class=\"font-weight-bold mb-1 text-white\">' + title + '</h4>';\n if (sub) html += '<p class=\"small mb-2 text-light\">' + sub + '</p>';\n if (btnT) html += '<span class=\"btn btn-sm btn-danger font-weight-bold px-3\">' + btnT + '</span>';\n contentBox.innerHTML = html;\n };\n\n [this.titleInput, this.subtitleInput, this.bgImageInput].forEach(wrapper => {\n const input = wrapper.querySelector('input');\n input.addEventListener('input', updatePreview);\n if (this.readOnly) input.disabled = true;\n container.appendChild(wrapper);\n });\n\n [this.heightInput, this.btnTextInput, this.btnLinkInput].forEach(wrapper => {\n const input = wrapper.querySelector('input');\n input.addEventListener('input', updatePreview);\n if (this.readOnly) input.disabled = true;\n });\n\n container.appendChild(configRow);\n container.appendChild(previewBox);\n updatePreview();\n\n return container;\n }\n\n _createInput(labelText, placeholder, value) {\n const wrapper = document.createElement('div');\n wrapper.className = 'form-group mb-2';\n const lbl = document.createElement('label');\n lbl.className = 'small font-weight-bold text-secondary mb-1 d-block';\n lbl.innerText = labelText;\n const inp = document.createElement('input');\n inp.type = 'text';\n inp.className = 'form-control form-control-sm';\n inp.placeholder = placeholder;\n inp.value = value || '';\n wrapper.appendChild(lbl);\n wrapper.appendChild(inp);\n return wrapper;\n }\n\n save(blockContent) {\n const stretchCheck = blockContent.querySelector('.custom-control-input');\n return {\n title: this.titleInput ? this.titleInput.querySelector('input').value : this.data.title,\n subtitle: this.subtitleInput ? this.subtitleInput.querySelector('input').value : this.data.subtitle,\n bgImage: this.bgImageInput ? this.bgImageInput.querySelector('input').value : this.data.bgImage,\n btnText: this.btnTextInput ? this.btnTextInput.querySelector('input').value : this.data.btnText,\n btnLink: this.btnLinkInput ? this.btnLinkInput.querySelector('input').value : this.data.btnLink,\n height: this.heightInput ? this.heightInput.querySelector('input').value : this.data.height,\n textAlign: this.data.textAlign || 'center',\n overlayOpacity: this.data.overlayOpacity || '0.4',\n stretched: stretchCheck ? stretchCheck.checked : !!this.data.stretched\n };\n }\n}\n\nclass SISStickyNavTool {\n static get toolbox() {\n return {\n title: 'Sticky Nav / Sub-menu',\n icon: '<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\"/><line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\"/><line x1=\"3\" y1=\"18\" x2=\"21\" y2=\"18\"/></svg>'\n };\n }\n\n constructor({ data, api, readOnly }) {\n this.api = api;\n this.readOnly = readOnly;\n this.data = {\n title: (data && data.title) ? data.title : 'Quick Navigation',\n bgColor: (data && data.bgColor) ? data.bgColor : '#002554',\n items: (data && Array.isArray(data.items)) ? data.items : [\n { label: 'Giới thiệu', link: '#sec-intro' },\n { label: 'Chuyên khoa', link: '#sec-specialty' },\n { label: 'Quy trình', link: '#sec-process' }\n ],\n sticky: (data && data.sticky !== undefined) ? !!data.sticky : true,\n stretched: (data && data.stretched !== undefined) ? !!data.stretched : true\n };\n this.titleInput = null;\n this.itemsContainer = null;\n }\n\n render() {\n const container = document.createElement('div');\n container.style.border = '1px solid #e3e6f0';\n container.style.borderRadius = '8px';\n container.style.padding = '14px';\n container.style.background = '#fff';\n container.style.marginBottom = '12px';\n\n const headerLabel = document.createElement('label');\n headerLabel.className = 'font-weight-bold text-primary small mb-2 d-block';\n headerLabel.innerHTML = '<i class=\"fas fa-bars\"></i> Sticky Page Navigation Bar Settings';\n container.appendChild(headerLabel);\n\n // Mobile Title Group\n const titleGroup = document.createElement('div');\n titleGroup.className = 'form-group mb-3';\n const titleLabel = document.createElement('label');\n titleLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';\n titleLabel.innerText = 'Mobile Menu Dropdown Title';\n this.titleInput = document.createElement('input');\n this.titleInput.type = 'text';\n this.titleInput.className = 'form-control form-control-sm';\n this.titleInput.placeholder = 'e.g. Quick Navigation or Info For';\n this.titleInput.value = this.data.title || 'Quick Navigation';\n titleGroup.appendChild(titleLabel);\n titleGroup.appendChild(this.titleInput);\n container.appendChild(titleGroup);\n\n // Switches Row (Sticky + Stretched)\n const switchesRow = document.createElement('div');\n switchesRow.className = 'd-flex flex-wrap gap-3 mb-3';\n\n // Sticky switch\n const stickyWrapper = document.createElement('div');\n stickyWrapper.className = 'custom-control custom-switch mr-3';\n const stickyCheck = document.createElement('input');\n stickyCheck.type = 'checkbox';\n stickyCheck.className = 'custom-control-input';\n stickyCheck.id = 'nav_sticky_' + Math.random().toString(36).substring(7);\n stickyCheck.checked = !!this.data.sticky;\n\n const stickyLabel = document.createElement('label');\n stickyLabel.className = 'custom-control-label small font-weight-bold text-primary';\n stickyLabel.htmlFor = stickyCheck.id;\n stickyLabel.innerHTML = '<i class=\"fas fa-thumbtack\"></i> Sticky to Top on Scroll';\n\n stickyCheck.addEventListener('change', () => {\n this.data.sticky = stickyCheck.checked;\n });\n stickyWrapper.appendChild(stickyCheck);\n stickyWrapper.appendChild(stickyLabel);\n switchesRow.appendChild(stickyWrapper);\n\n // Stretch switch\n const stretchWrapper = document.createElement('div');\n stretchWrapper.className = 'custom-control custom-switch';\n this.stretchCheck = document.createElement('input');\n this.stretchCheck.type = 'checkbox';\n this.stretchCheck.className = 'custom-control-input';\n this.stretchCheck.id = 'nav_stretch_' + Math.random().toString(36).substring(7);\n this.stretchCheck.checked = !!this.data.stretched;\n\n const stretchLabel = document.createElement('label');\n stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';\n stretchLabel.htmlFor = this.stretchCheck.id;\n stretchLabel.innerHTML = '<i class=\"fas fa-arrows-alt-h\"></i> Stretch to Full Screen Width';\n\n this.stretchCheck.addEventListener('change', () => {\n this.data.stretched = this.stretchCheck.checked;\n });\n stretchWrapper.appendChild(this.stretchCheck);\n stretchWrapper.appendChild(stretchLabel);\n switchesRow.appendChild(stretchWrapper);\n\n container.appendChild(switchesRow);\n\n // Background Color Selection\n const bgGroup = document.createElement('div');\n bgGroup.className = 'form-group mb-3';\n const bgLabel = document.createElement('label');\n bgLabel.className = 'small font-weight-bold text-secondary mb-1 d-block';\n bgLabel.innerText = 'Background Style';\n\n this.bgSelect = document.createElement('select');\n this.bgSelect.className = 'form-control form-control-sm';\n this.bgSelect.innerHTML = `\n <option value=\"#002554\">Dark Blue (SIS Primary - #002554)</option>\n <option value=\"#881C1C\">Brand Red (#881C1C)</option>\n <option value=\"#1a252f\">Charcoal Dark (#1a252f)</option>\n <option value=\"#ffffff\">White (#ffffff)</option>\n `;\n this.bgSelect.value = this.data.bgColor || '#002554';\n bgGroup.appendChild(bgLabel);\n bgGroup.appendChild(this.bgSelect);\n container.appendChild(bgGroup);\n\n // Navigation Items List\n const itemsLabel = document.createElement('label');\n itemsLabel.className = 'small font-weight-bold text-secondary mb-2 d-block';\n itemsLabel.innerText = 'Navigation Links / Anchors';\n container.appendChild(itemsLabel);\n\n this.itemsContainer = document.createElement('div');\n this.itemsContainer.className = 'nav-items-list mb-2';\n container.appendChild(this.itemsContainer);\n\n const renderItemsInputs = () => {\n this.itemsContainer.innerHTML = '';\n this.data.items.forEach((item, index) => {\n const itemRow = document.createElement('div');\n itemRow.className = 'form-row mb-2 align-items-center';\n\n const colLabel = document.createElement('div');\n colLabel.className = 'col-md-5';\n const labelInp = document.createElement('input');\n labelInp.type = 'text';\n labelInp.className = 'form-control form-control-sm';\n labelInp.placeholder = 'Link Label (e.g. Giới thiệu)';\n labelInp.value = item.label || '';\n labelInp.addEventListener('input', () => {\n item.label = labelInp.value;\n updatePreview();\n });\n colLabel.appendChild(labelInp);\n\n const colLink = document.createElement('div');\n colLink.className = 'col-md-5';\n const linkInp = document.createElement('input');\n linkInp.type = 'text';\n linkInp.className = 'form-control form-control-sm';\n linkInp.placeholder = 'Target ID or URL (e.g. #sec-intro)';\n linkInp.value = item.link || '';\n linkInp.addEventListener('input', () => {\n item.link = linkInp.value;\n updatePreview();\n });\n colLink.appendChild(linkInp);\n\n const colDel = document.createElement('div');\n colDel.className = 'col-md-2';\n const delBtn = document.createElement('button');\n delBtn.type = 'button';\n delBtn.className = 'btn btn-sm btn-outline-danger btn-block';\n delBtn.innerHTML = '<i class=\"fas fa-trash\"></i>';\n delBtn.addEventListener('click', () => {\n this.data.items.splice(index, 1);\n renderItemsInputs();\n updatePreview();\n });\n colDel.appendChild(delBtn);\n\n itemRow.appendChild(colLabel);\n itemRow.appendChild(colLink);\n itemRow.appendChild(colDel);\n this.itemsContainer.appendChild(itemRow);\n });\n };\n\n const addBtn = document.createElement('button');\n addBtn.type = 'button';\n addBtn.className = 'btn btn-sm btn-outline-primary mb-3';\n addBtn.innerHTML = '<i class=\"fas fa-plus\"></i> Add Nav Link';\n addBtn.addEventListener('click', () => {\n this.data.items.push({ label: 'New Link', link: '#section' });\n renderItemsInputs();\n updatePreview();\n });\n container.appendChild(addBtn);\n\n // Live Preview Box\n const previewBox = document.createElement('div');\n previewBox.className = 'nav-preview p-2 rounded my-2 d-flex flex-wrap justify-content-center align-items-center gap-2';\n previewBox.style.transition = 'background 0.3s ease';\n container.appendChild(previewBox);\n\n const updatePreview = () => {\n const bg = this.bgSelect.value;\n const isLight = bg === '#ffffff';\n previewBox.style.background = bg;\n\n let html = '';\n this.data.items.forEach(item => {\n const label = item.label || 'Link';\n const textColor = isLight ? '#002554' : '#ffffff';\n html += `<span class=\"badge px-3 py-2 mr-2 mb-1\" style=\"background: rgba(255,255,255,0.15); color: ${textColor}; font-size: 13px; font-weight: 600;\"><i class=\"fas fa-link mr-1\"></i>${label}</span>`;\n });\n previewBox.innerHTML = html || '<span class=\"text-muted small\">No nav links added</span>';\n };\n\n this.bgSelect.addEventListener('change', () => {\n this.data.bgColor = this.bgSelect.value;\n updatePreview();\n });\n\n renderItemsInputs();\n updatePreview();\n\n return container;\n }\n\n save(blockContent) {\n return {\n title: this.titleInput ? this.titleInput.value : (this.data.title || 'Quick Navigation'),\n bgColor: this.bgSelect ? this.bgSelect.value : this.data.bgColor,\n items: this.data.items || [],\n sticky: this.data.sticky !== undefined ? !!this.data.sticky : true,\n stretched: this.stretchCheck ? this.stretchCheck.checked : !!this.data.stretched\n };\n }\n}\n\n/**\n * Initialize the SIS Block Editor on a given holder element.\n * \n * @param {string} holderId - The DOM element ID for the editor container\n * @param {string} hiddenInputId - The DOM element ID for the hidden input storing JSON\n * @param {object|null} initialData - Pre-existing Editor.js JSON data to load\n * @param {boolean} [skipSubmitHandler=false] - If true, prevents automatic form submission handling\n * @returns {EditorJS} The editor instance\n */\nfunction initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler = false) {\n if (typeof EditorJS === 'undefined') {\n console.error('[SIS Editor] Editor.js library is not loaded!');\n return null;\n }\n\n // === Register Built-in Tools (Defensive Check for Globals) ===\n var builtInTools = {};\n\n if (typeof Header !== 'undefined') {\n builtInTools.header = {\n class: Header,\n inlineToolbar: ['link'],\n config: {\n placeholder: 'Header text...',\n levels: [1, 2, 3, 4, 5, 6],\n defaultLevel: 2\n }\n };\n }\n\n if (typeof NestedList !== 'undefined') {\n builtInTools.list = {\n class: NestedList,\n inlineToolbar: true,\n config: { defaultStyle: 'unordered' }\n };\n } else if (typeof List !== 'undefined') {\n builtInTools.list = {\n class: List,\n inlineToolbar: true,\n config: { defaultStyle: 'unordered' }\n };\n }\n\n if (typeof Quote !== 'undefined') {\n builtInTools.quote = {\n class: Quote,\n inlineToolbar: true,\n config: {\n quotePlaceholder: 'Enter a quote',\n captionPlaceholder: 'Quote\\'s author'\n }\n };\n }\n\n if (typeof Delimiter !== 'undefined') builtInTools.delimiter = { class: Delimiter };\n if (typeof Table !== 'undefined') builtInTools.table = { class: Table, inlineToolbar: true, config: { rows: 2, cols: 3 } };\n if (typeof CodeTool !== 'undefined') builtInTools.code = { class: CodeTool };\n if (typeof Warning !== 'undefined') builtInTools.warning = { class: Warning, inlineToolbar: true, config: { titlePlaceholder: 'Title', messagePlaceholder: 'Message' } };\n if (typeof Marker !== 'undefined') builtInTools.marker = { class: Marker };\n if (typeof InlineCode !== 'undefined') builtInTools.inlineCode = { class: InlineCode };\n if (typeof Underline !== 'undefined') builtInTools.underline = { class: Underline };\n if (typeof ImageTool !== 'undefined') {\n builtInTools.image = {\n class: ImageTool,\n config: {\n endpoints: { \n byFile: '/api/manage/media/upload',\n byUrl: '/api/manage/media/fetchUrl'\n },\n uploader: {\n uploadByUrl(url) {\n return Promise.resolve({\n success: 1,\n file: { url: url }\n });\n }\n },\n field: 'file',\n types: 'image/*'\n }\n };\n window.SISEditorPlugins.image = builtInTools.image;\n }\n if (typeof AttachesTool !== 'undefined') {\n builtInTools.attaches = {\n class: AttachesTool,\n config: { endpoint: '/api/manage/media/upload', field: 'file' }\n };\n }\n\n // Custom SIS Tools (Always present)\n builtInTools.raw = { class: SISRawHtmlTool };\n builtInTools.accordion = { class: SISAccordionTool };\n builtInTools.youtube = { class: SISYouTubeTool };\n builtInTools.hero = { class: SISHeroBannerTool };\n builtInTools.stickyNav = { class: SISStickyNavTool };\n\n // === Merge built-in tools with any registered plugins ===\n var allTools = Object.assign({}, builtInTools, window.SISEditorPlugins);\n\n // === Parse initial data ===\n var editorData = null;\n if (initialData && typeof initialData === 'string') {\n try {\n editorData = JSON.parse(initialData);\n } catch (e) {\n console.warn('[SIS Editor] Could not parse initial data as JSON, starting empty.', e);\n editorData = null;\n }\n } else if (initialData && typeof initialData === 'object') {\n editorData = initialData;\n }\n\n // === Create the Editor ===\n var editor = new EditorJS({\n holder: holderId,\n tools: allTools,\n data: editorData || undefined,\n placeholder: 'Click here to start writing your page content...',\n autofocus: false,\n onReady: function() {\n console.log('[SIS Editor] Ready. Tools loaded:', Object.keys(allTools));\n },\n onChange: function(api, event) {\n // Auto-save to hidden input on every change\n api.saver.save().then(function(outputData) {\n var hiddenInput = document.getElementById(hiddenInputId);\n if (hiddenInput) {\n hiddenInput.value = JSON.stringify(outputData);\n }\n });\n }\n });\n\n // === Form submission handler ===\n // Ensure the latest content is saved before form submit\n var hiddenInput = document.getElementById(hiddenInputId);\n var form = hiddenInput ? hiddenInput.closest('form') : document.querySelector('form');\n if (form && !skipSubmitHandler) {\n var submitHandler = function(event) {\n event.preventDefault();\n editor.save().then(function(outputData) {\n var hiddenInput = document.getElementById(hiddenInputId);\n if (hiddenInput) {\n hiddenInput.value = JSON.stringify(outputData);\n }\n // Now submit the form\n form.removeEventListener('submit', submitHandler);\n form.submit();\n }).catch(function(error) {\n console.error('[SIS Editor] Save failed:', error);\n });\n };\n form.addEventListener('submit', submitHandler);\n }\n\n return editor;\n}\n\n/**\n * Setup IDE Code Editor styling and Tab indentation for custom CSS and JS textareas\n */\nfunction setupCodeTextareas() {\n var codeSelectors = '#customCss, #customJs, #postCustomCss, #postCustomJs, textarea[name=\"customCss\"], textarea[name=\"customJs\"]';\n document.querySelectorAll(codeSelectors).forEach(function(textarea) {\n if (!textarea || textarea.dataset.codeEditorInit) return;\n textarea.dataset.codeEditorInit = 'true';\n\n // Enable Tab key indentation (inserts 2 spaces)\n textarea.addEventListener('keydown', function(e) {\n if (e.key === 'Tab') {\n e.preventDefault();\n var start = this.selectionStart;\n var end = this.selectionEnd;\n this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);\n this.selectionStart = this.selectionEnd = start + 2;\n }\n });\n });\n}\n\n// Auto-run code textareas setup on page load\nif (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', setupCodeTextareas);\n} else {\n setupCodeTextareas();\n}\n",
|
|
"grid.js": "/**\n * Grid block tool for Editor.js.\n * Allows users to create responsive layouts with any number of columns and choose different content types, manual widths,\n * and dynamically instantiates other registered editor plugins inside columns.\n */\nclass GridTool {\n static get toolbox() {\n return {\n title: 'Grid / Columns',\n icon: '<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"9\"></rect><rect x=\"14\" y=\"3\" width=\"7\" height=\"9\"></rect><rect x=\"14\" y=\"15\" width=\"7\" height=\"6\"></rect><rect x=\"3\" y=\"15\" width=\"7\" height=\"6\"></rect></svg>'\n };\n }\n\n constructor({ data, api, readOnly }) {\n this.api = api;\n this.readOnly = readOnly;\n this.data = {\n cols: parseInt(data.cols) || 2\n };\n this.activeInstances = {};\n\n // Load column contents dynamically\n for (let i = 1; i <= 12; i++) {\n this.data[`type${i}`] = data[`type${i}`] || 'html';\n this.data[`col${i}`] = data[`col${i}`] || '';\n this.data[`imgUrl${i}`] = data[`imgUrl${i}`] || '';\n this.data[`ytUrl${i}`] = data[`ytUrl${i}`] || '';\n this.data[`accTitle${i}`] = data[`accTitle${i}`] || '';\n this.data[`accContent${i}`] = data[`accContent${i}`] || '';\n this.data[`class${i}`] = data[`class${i}`] || '';\n this.data[`id${i}`] = data[`id${i}`] || '';\n this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto\n }\n this.wrapper = undefined;\n }\n\n render() {\n this.wrapper = document.createElement('div');\n this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-grid-tool-wrapper';\n this.wrapper.style.fontFamily = 'inherit';\n\n const title = document.createElement('div');\n title.className = 'font-weight-bold text-primary small mb-3';\n title.innerHTML = '<i class=\"fas fa-columns\"></i> Multi-Column Grid Layout';\n this.wrapper.appendChild(title);\n\n // Layout Settings Row\n const settingsRow = document.createElement('div');\n settingsRow.className = 'form-row mb-3 pb-2 border-bottom';\n\n // 1. Columns\n const colDiv = document.createElement('div');\n colDiv.className = 'col-md-2 mb-2';\n colDiv.innerHTML = '<label class=\"small font-weight-bold text-secondary mb-1\">Columns</label>';\n const colInput = document.createElement('input');\n colInput.type = 'number';\n colInput.className = 'form-control form-control-sm';\n colInput.min = '1';\n colInput.max = '12';\n colInput.value = this.data.cols;\n if (this.readOnly) colInput.disabled = true;\n colInput.addEventListener('input', (e) => {\n let val = parseInt(e.target.value) || 2;\n if (val < 1) val = 1;\n if (val > 12) val = 12;\n this.data.cols = val;\n this._renderColumnInputs();\n });\n colDiv.appendChild(colInput);\n settingsRow.appendChild(colDiv);\n\n // 2. Global CSS Class\n const classDiv = document.createElement('div');\n classDiv.className = 'col-md-5 mb-2';\n classDiv.innerHTML = '<label class=\"small font-weight-bold text-secondary mb-1\">Grid CSS Class(es)</label>';\n const classInput = document.createElement('input');\n classInput.type = 'text';\n classInput.className = 'form-control form-control-sm';\n classInput.placeholder = 'e.g. my-custom-grid';\n classInput.value = this.data.globalClass || '';\n if (this.readOnly) classInput.disabled = true;\n classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());\n classDiv.appendChild(classInput);\n settingsRow.appendChild(classDiv);\n\n // 3. Global HTML ID\n const idDiv = document.createElement('div');\n idDiv.className = 'col-md-5 mb-2';\n idDiv.innerHTML = '<label class=\"small font-weight-bold text-secondary mb-1\">Grid HTML ID</label>';\n const idInput = document.createElement('input');\n idInput.type = 'text';\n idInput.className = 'form-control form-control-sm';\n idInput.placeholder = 'e.g. grid-section-1';\n idInput.value = this.data.globalId || '';\n if (this.readOnly) idInput.disabled = true;\n idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());\n idDiv.appendChild(idInput);\n settingsRow.appendChild(idDiv);\n\n this.wrapper.appendChild(settingsRow);\n\n // Columns inputs container\n this.inputsContainer = document.createElement('div');\n this.wrapper.appendChild(this.inputsContainer);\n\n this._renderColumnInputs();\n\n return this.wrapper;\n }\n\n _renderColumnInputs() {\n this.inputsContainer.innerHTML = '';\n \n const row = document.createElement('div');\n row.className = 'row';\n\n const count = this.data.cols;\n const colWidthClass = count === 1 ? 'col-12' : (count === 2 ? 'col-md-6' : (count === 3 ? 'col-md-4' : (count === 4 ? 'col-md-3' : 'col-md')));\n\n for (let i = 1; i <= count; i++) {\n const colDiv = document.createElement('div');\n colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`;\n colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';\n\n const headerRow = document.createElement('div');\n headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';\n\n const label = document.createElement('label');\n label.className = 'small font-weight-bold text-dark mb-0';\n label.innerText = `Col ${i}`;\n headerRow.appendChild(label);\n\n const controlsWrapper = document.createElement('div');\n controlsWrapper.className = 'd-flex align-items-center gap-1';\n\n // Width Selector\n const widthSelect = document.createElement('select');\n widthSelect.className = 'custom-select custom-select-sm mr-1';\n widthSelect.style.width = '75px';\n if (this.readOnly) widthSelect.disabled = true;\n\n const widthOpts = [\n { val: 0, lbl: 'Auto' },\n { val: 1, lbl: '1/12' },\n { val: 2, lbl: '2/12' },\n { val: 3, lbl: '3/12' },\n { val: 4, lbl: '4/12' },\n { val: 5, lbl: '5/12' },\n { val: 6, lbl: '6/12' },\n { val: 7, lbl: '7/12' },\n { val: 8, lbl: '8/12' },\n { val: 9, lbl: '9/12' },\n { val: 10, lbl: '10/12' },\n { val: 11, lbl: '11/12' },\n { val: 12, lbl: '12/12' }\n ];\n\n widthOpts.forEach(o => {\n const opt = document.createElement('option');\n opt.value = o.val;\n opt.innerText = o.lbl;\n opt.selected = this.data[`width${i}`] === o.val;\n widthSelect.appendChild(opt);\n });\n\n widthSelect.addEventListener('change', (e) => {\n this.data[`width${i}`] = parseInt(e.target.value) || 0;\n });\n\n controlsWrapper.appendChild(widthSelect);\n\n // Column Type Select (Built-ins + Registered plugins dynamically)\n const typeSelect = document.createElement('select');\n typeSelect.className = 'custom-select custom-select-sm';\n typeSelect.style.width = '100px';\n if (this.readOnly) typeSelect.disabled = true;\n\n // Base types\n const types = [\n { value: 'html', label: 'HTML/Text' },\n { value: 'image', label: 'Image' },\n { value: 'youtube', label: 'YouTube' },\n { value: 'accordion', label: 'Accordion' }\n ];\n\n // Dynamically add other registered plugins from window.SISEditorPlugins\n if (window.SISEditorPlugins) {\n Object.keys(window.SISEditorPlugins).forEach(key => {\n // Allow nesting any registered plugins (no exclusions)\n if (!types.some(t => t.value === key)) {\n types.push({ value: key, label: `[Plugin] ${key}` });\n }\n });\n }\n\n types.forEach(t => {\n const opt = document.createElement('option');\n opt.value = t.value;\n opt.innerText = t.label;\n opt.selected = this.data[`type${i}`] === t.value;\n typeSelect.appendChild(opt);\n });\n\n const contentDiv = document.createElement('div');\n\n typeSelect.addEventListener('change', (e) => {\n this.data[`type${i}`] = e.target.value;\n this._renderTypeSpecificInput(i, contentDiv);\n });\n\n controlsWrapper.appendChild(typeSelect);\n headerRow.appendChild(controlsWrapper);\n colDiv.appendChild(headerRow);\n colDiv.appendChild(contentDiv);\n row.appendChild(colDiv);\n\n this._renderTypeSpecificInput(i, contentDiv);\n }\n\n this.inputsContainer.appendChild(row);\n }\n\n _renderTypeSpecificInput(i, container) {\n container.innerHTML = '';\n \n // Remove tracking of previous instance\n if (this.activeInstances[i]) {\n delete this.activeInstances[i];\n }\n\n const typeContainer = document.createElement('div');\n typeContainer.className = 'mb-3';\n container.appendChild(typeContainer);\n\n const type = this.data[`type${i}`];\n\n // Check if type is a dynamic editor plugin registered in window.SISEditorPlugins\n if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {\n try {\n const pluginEntry = window.SISEditorPlugins[type];\n const pluginClass = pluginEntry.class;\n const pluginConfig = pluginEntry.config || {};\n let parsedData = {};\n if (this.data[`col${i}`]) {\n try {\n parsedData = typeof this.data[`col${i}`] === 'string' ? JSON.parse(this.data[`col${i}`]) : this.data[`col${i}`];\n } catch (e) {\n parsedData = { html: this.data[`col${i}`] }; // fallback\n }\n }\n const instance = new pluginClass({\n data: parsedData,\n api: this.api,\n readOnly: this.readOnly,\n config: pluginConfig,\n block: {\n id: 'nested-grid-' + i + '-' + Math.random().toString(36).substring(7),\n name: type,\n holder: typeContainer,\n isEmpty: false,\n selected: false,\n stretched: false,\n tunes: {}\n }\n });\n this.activeInstances[i] = instance;\n\n const element = instance.render();\n typeContainer.appendChild(element);\n\n // Fix for Editor.js Image plugin 404 errors (spinner hangs forever)\n if (type === 'image') {\n // Custom UI override for nested images\n const injectCustomButtons = () => {\n if (element.querySelector('.sis-custom-image-controls')) return;\n\n const fileBtn = element.querySelector('.cdx-button');\n if (fileBtn) {\n fileBtn.style.display = 'none'; // Hide native button\n }\n\n const controls = document.createElement('div');\n controls.className = 'sis-custom-image-controls mt-2 p-2 border rounded bg-light';\n controls.style.display = 'flex';\n controls.style.flexDirection = 'column';\n controls.style.gap = '8px';\n \n controls.innerHTML = `\n <div class=\"text-muted small text-center mb-1\"><i class=\"fas fa-cog\"></i> Image Controls</div>\n <button type=\"button\" class=\"btn btn-outline-primary btn-sm w-100\" id=\"btn-upload-img-${i}\"><i class=\"fas fa-upload\"></i> Upload Image</button>\n <button type=\"button\" class=\"btn btn-outline-info btn-sm w-100\" id=\"btn-fetch-url-${i}\"><i class=\"fas fa-link\"></i> Fetch URL</button>\n <button type=\"button\" class=\"btn btn-outline-secondary btn-sm w-100\" id=\"btn-media-lib-${i}\"><i class=\"fas fa-images\"></i> Media Library</button>\n <button type=\"button\" class=\"btn btn-outline-danger btn-sm w-100 mt-2\" id=\"btn-clear-img-${i}\"><i class=\"fas fa-trash\"></i> Remove Image</button>\n `;\n\n const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);\n if (uploadBtn && fileBtn) {\n uploadBtn.addEventListener('click', (e) => {\n e.preventDefault();\n fileBtn.click();\n });\n }\n \n const urlBtn = controls.querySelector(`#btn-fetch-url-${i}`);\n if (urlBtn) {\n urlBtn.addEventListener('click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n const url = prompt('Enter Image URL:');\n if (url && url.trim() !== '') {\n this.data[`col${i}`] = {\n file: { url: url.trim() },\n caption: '',\n withBorder: false,\n withBackground: false,\n stretched: false\n };\n this._renderTypeSpecificInput(i, container);\n }\n });\n }\n\n const mediaBtn = controls.querySelector(`#btn-media-lib-${i}`);\n if (mediaBtn) {\n mediaBtn.addEventListener('click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n if (typeof openMediaPickerModal === 'function') {\n openMediaPickerModal((url) => {\n if (url) {\n this.data[`col${i}`] = {\n file: { url: url },\n caption: '',\n withBorder: false,\n withBackground: false,\n stretched: false\n };\n this._renderTypeSpecificInput(i, container);\n }\n });\n } else {\n alert('Media Library is not yet configured on this system.');\n }\n });\n }\n\n const clearBtn = controls.querySelector(`#btn-clear-img-${i}`);\n if (clearBtn) {\n clearBtn.addEventListener('click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n if (confirm('Are you sure you want to remove this image?')) {\n this.data[`col${i}`] = '';\n this._renderTypeSpecificInput(i, container);\n }\n });\n }\n\n element.appendChild(controls);\n };\n injectCustomButtons();\n\n const handleImageError = () => {\n element.classList.remove('image-tool--loading');\n element.classList.add('image-tool--empty');\n const preloader = element.querySelector('.image-tool__image-preloader');\n if (preloader) preloader.style.display = 'none';\n };\n\n const observer = new MutationObserver((mutations) => {\n mutations.forEach(mutation => {\n mutation.addedNodes.forEach(node => {\n if (node.tagName === 'IMG') {\n node.addEventListener('error', handleImageError);\n }\n });\n });\n });\n observer.observe(element, { childList: true, subtree: true });\n \n const existingImg = element.querySelector('img');\n if (existingImg) existingImg.addEventListener('error', handleImageError);\n }\n } catch (err) {\n console.error(`Failed to render plugin ${type} inside GridTool:`, err);\n typeContainer.innerHTML = `<div class=\"alert alert-danger small\">Error loading plugin ${type}</div>`;\n }\n } else if (type === 'html') {\n const textarea = document.createElement('textarea');\n textarea.className = 'form-control form-control-sm';\n textarea.style.minHeight = '140px';\n textarea.style.fontSize = '12px';\n textarea.placeholder = `HTML / Text for Column ${i}...`;\n textarea.value = typeof this.data[`col${i}`] === 'object' ? JSON.stringify(this.data[`col${i}`]) : this.data[`col${i}`] || '';\n if (this.readOnly) textarea.disabled = true;\n\n textarea.addEventListener('input', (e) => {\n this.data[`col${i}`] = e.target.value;\n });\n typeContainer.appendChild(textarea);\n\n } else if (type === 'image') {\n const inputGroup = document.createElement('div');\n inputGroup.className = 'input-group input-group-sm mb-2';\n\n const input = document.createElement('input');\n input.type = 'text';\n input.className = 'form-control form-control-sm';\n input.placeholder = 'Paste image URL here...';\n input.value = this.data[`imgUrl${i}`] || '';\n if (this.readOnly) input.disabled = true;\n\n const appendDiv = document.createElement('div');\n appendDiv.className = 'input-group-append';\n\n const chooseBtn = document.createElement('button');\n chooseBtn.type = 'button';\n chooseBtn.className = 'btn btn-outline-secondary';\n chooseBtn.innerText = 'Choose';\n if (this.readOnly) chooseBtn.disabled = true;\n chooseBtn.addEventListener('click', () => {\n if (typeof openMediaPickerModal === 'function') {\n openMediaPickerModal((selectedUrl) => {\n input.value = selectedUrl;\n this.data[`imgUrl${i}`] = selectedUrl;\n previewImg.src = selectedUrl;\n previewBox.style.display = 'block';\n });\n } else {\n alert('Media Picker modal helper is not loaded.');\n }\n });\n\n const uploadBtn = document.createElement('button');\n uploadBtn.type = 'button';\n uploadBtn.className = 'btn btn-outline-primary';\n uploadBtn.innerText = 'Upload';\n if (this.readOnly) uploadBtn.disabled = true;\n\n const fileInput = document.createElement('input');\n fileInput.type = 'file';\n fileInput.accept = 'image/*';\n fileInput.style.display = 'none';\n\n uploadBtn.addEventListener('click', () => fileInput.click());\n fileInput.addEventListener('change', async (e) => {\n const file = e.target.files[0];\n if (!file) return;\n\n const formData = new FormData();\n formData.append('image', file);\n\n uploadBtn.disabled = true;\n uploadBtn.innerText = '...';\n\n try {\n const response = await fetch('/api/manage/media/upload', {\n method: 'POST',\n body: formData\n });\n const result = await response.json();\n if (result.success && result.file && result.file.url) {\n const url = result.file.url;\n input.value = url;\n this.data[`imgUrl${i}`] = url;\n previewImg.src = url;\n previewBox.style.display = 'block';\n } else {\n alert('Upload failed: ' + (result.message || 'Unknown error'));\n }\n } catch (err) {\n console.error(err);\n alert('Upload error occurred.');\n } finally {\n uploadBtn.disabled = false;\n uploadBtn.innerText = 'Upload';\n }\n });\n\n appendDiv.appendChild(chooseBtn);\n appendDiv.appendChild(uploadBtn);\n inputGroup.appendChild(input);\n inputGroup.appendChild(appendDiv);\n typeContainer.appendChild(inputGroup);\n typeContainer.appendChild(fileInput);\n\n const previewBox = document.createElement('div');\n previewBox.className = 'border rounded p-1 text-center bg-light';\n previewBox.style.display = this.data[`imgUrl${i}`] ? 'block' : 'none';\n previewBox.style.maxHeight = '150px';\n previewBox.style.overflow = 'hidden';\n\n const previewImg = document.createElement('img');\n previewImg.src = this.data[`imgUrl${i}`] || '';\n previewImg.style.maxWidth = '100%';\n previewImg.style.maxHeight = '140px';\n previewImg.style.objectFit = 'contain';\n\n previewBox.appendChild(previewImg);\n typeContainer.appendChild(previewBox);\n\n input.addEventListener('input', (e) => {\n const val = e.target.value.trim();\n this.data[`imgUrl${i}`] = val;\n if (val) {\n previewImg.src = val;\n previewBox.style.display = 'block';\n } else {\n previewBox.style.display = 'none';\n }\n });\n\n } else if (type === 'youtube') {\n const input = document.createElement('input');\n input.type = 'text';\n input.className = 'form-control form-control-sm mb-2';\n input.placeholder = 'Paste YouTube video URL...';\n input.value = this.data[`ytUrl${i}`] || '';\n if (this.readOnly) input.disabled = true;\n\n const preview = document.createElement('div');\n preview.className = 'small text-muted p-2 bg-light border rounded';\n preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';\n\n input.addEventListener('input', (e) => {\n this.data[`ytUrl${i}`] = e.target.value.trim();\n });\n\n typeContainer.appendChild(input);\n typeContainer.appendChild(preview);\n\n } else if (type === 'accordion') {\n const titleInput = document.createElement('input');\n titleInput.type = 'text';\n titleInput.className = 'form-control form-control-sm mb-2';\n titleInput.placeholder = 'Accordion Title...';\n titleInput.value = this.data[`accTitle${i}`] || '';\n if (this.readOnly) titleInput.disabled = true;\n\n const contentTextarea = document.createElement('textarea');\n contentTextarea.className = 'form-control form-control-sm';\n contentTextarea.style.minHeight = '90px';\n contentTextarea.style.fontSize = '12px';\n contentTextarea.placeholder = 'Accordion Body HTML...';\n contentTextarea.value = this.data[`accContent${i}`] || '';\n if (this.readOnly) contentTextarea.disabled = true;\n\n titleInput.addEventListener('input', (e) => {\n this.data[`accTitle${i}`] = e.target.value;\n });\n contentTextarea.addEventListener('input', (e) => {\n this.data[`accContent${i}`] = e.target.value;\n });\n\n typeContainer.appendChild(titleInput);\n typeContainer.appendChild(contentTextarea);\n }\n\n // Advanced Settings (ID and Class)\n const advancedHeader = document.createElement('div');\n advancedHeader.className = 'font-weight-bold text-muted small mb-2';\n advancedHeader.style.fontSize = '10px';\n advancedHeader.innerText = 'ADVANCED SETTINGS';\n container.appendChild(advancedHeader);\n\n const advRow = document.createElement('div');\n advRow.className = 'form-row';\n\n const classCol = document.createElement('div');\n classCol.className = 'col';\n const classInput = document.createElement('input');\n classInput.type = 'text';\n classInput.className = 'form-control form-control-sm';\n classInput.style.fontSize = '11px';\n classInput.placeholder = 'CSS Class(es)...';\n classInput.value = this.data[`class${i}`] || '';\n if (this.readOnly) classInput.disabled = true;\n classInput.addEventListener('input', (e) => {\n this.data[`class${i}`] = e.target.value.trim();\n });\n classCol.appendChild(classInput);\n\n const idCol = document.createElement('div');\n idCol.className = 'col';\n const idInput = document.createElement('input');\n idInput.type = 'text';\n idInput.className = 'form-control form-control-sm';\n idInput.style.fontSize = '11px';\n idInput.placeholder = 'HTML ID...';\n idInput.value = this.data[`id${i}`] || '';\n if (this.readOnly) idInput.disabled = true;\n idInput.addEventListener('input', (e) => {\n this.data[`id${i}`] = e.target.value.trim();\n });\n idCol.appendChild(idInput);\n\n advRow.appendChild(classCol);\n advRow.appendChild(idCol);\n container.appendChild(advRow);\n }\n\n save(blockContent) {\n const savedData = {\n cols: this.data.cols,\n globalClass: this.data.globalClass || '',\n globalId: this.data.globalId || ''\n };\n for (let i = 1; i <= this.data.cols; i++) {\n const type = this.data[`type${i}`] || 'html';\n savedData[`type${i}`] = type;\n \n // If it is a dynamically nested plugin, save its output data\n if (this.activeInstances[i]) {\n try {\n const pluginData = this.activeInstances[i].save();\n savedData[`col${i}`] = pluginData;\n } catch (e) {\n console.error(`Failed to save plugin ${type} inside GridTool:`, e);\n savedData[`col${i}`] = this.data[`col${i}`];\n }\n } else {\n savedData[`col${i}`] = this.data[`col${i}`] || '';\n }\n\n savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || '';\n savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || '';\n savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || '';\n savedData[`accContent${i}`] = this.data[`accContent${i}`] || '';\n savedData[`class${i}`] = this.data[`class${i}`] || '';\n savedData[`id${i}`] = this.data[`id${i}`] || '';\n savedData[`width${i}`] = this.data[`width${i}`] || 0;\n }\n return savedData;\n }\n}\n\n// Register the plugin globally\nwindow.SISEditorPlugins = window.SISEditorPlugins || {};\nwindow.SISEditorPlugins['grid'] = {\n class: GridTool\n};\n",
|
|
"flex.js": "/**\n * Flex block tool for Editor.js.\n * Allows users to create highly custom Flexbox layouts with alignment, direction, gaps, and custom column items.\n * Supports instantiating other registered plugins inside columns dynamically.\n */\nclass FlexTool {\n static get toolbox() {\n return {\n title: 'Flex Layout',\n icon: '<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"></rect><line x1=\"9\" y1=\"3\" x2=\"9\" y2=\"21\"></line><line x1=\"15\" y1=\"3\" x2=\"15\" y2=\"21\"></line></svg>'\n };\n }\n\n constructor({ data, api, readOnly }) {\n this.api = api;\n this.readOnly = readOnly;\n this.data = {\n cols: parseInt(data.cols) || 2,\n direction: data.direction || 'row',\n justify: data.justify || 'start',\n align: data.align || 'stretch',\n gap: data.gap || '3' // standard Bootstrap gap level\n };\n this.activeInstances = {};\n\n // Load items content dynamically\n for (let i = 1; i <= 12; i++) {\n this.data[`type${i}`] = data[`type${i}`] || 'html';\n this.data[`col${i}`] = data[`col${i}`] || '';\n this.data[`imgUrl${i}`] = data[`imgUrl${i}`] || '';\n this.data[`ytUrl${i}`] = data[`ytUrl${i}`] || '';\n this.data[`accTitle${i}`] = data[`accTitle${i}`] || '';\n this.data[`accContent${i}`] = data[`accContent${i}`] || '';\n this.data[`class${i}`] = data[`class${i}`] || '';\n this.data[`id${i}`] = data[`id${i}`] || '';\n this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto\n }\n this.wrapper = undefined;\n }\n\n render() {\n this.wrapper = document.createElement('div');\n this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-flex-tool-wrapper';\n this.wrapper.style.fontFamily = 'inherit';\n\n const title = document.createElement('div');\n title.className = 'font-weight-bold text-primary small mb-3';\n title.innerHTML = '<i class=\"fas fa-boxes\"></i> Custom Flexbox Layout Builder';\n this.wrapper.appendChild(title);\n\n // Container Flex Settings Row\n const settingsRow = document.createElement('div');\n settingsRow.className = 'form-row mb-3 pb-3 border-bottom';\n\n // 1. Direction\n const dirCol = document.createElement('div');\n dirCol.className = 'col-md-3 col-sm-6 mb-2';\n dirCol.innerHTML = '<label class=\"small font-weight-bold text-secondary mb-1\">Direction</label>';\n const dirSelect = document.createElement('select');\n dirSelect.className = 'custom-select custom-select-sm';\n const dirOpts = [\n { val: 'row', lbl: 'Row' },\n { val: 'row-reverse', lbl: 'Row Reverse' },\n { val: 'column', lbl: 'Column' },\n { val: 'column-reverse', lbl: 'Column Reverse' }\n ];\n dirOpts.forEach(o => {\n const opt = document.createElement('option');\n opt.value = o.val;\n opt.innerText = o.lbl;\n opt.selected = this.data.direction === o.val;\n dirSelect.appendChild(opt);\n });\n dirSelect.addEventListener('change', (e) => this.data.direction = e.target.value);\n dirCol.appendChild(dirSelect);\n settingsRow.appendChild(dirCol);\n\n // 2. Justify Content\n const justifyCol = document.createElement('div');\n justifyCol.className = 'col-md-3 col-sm-6 mb-2';\n justifyCol.innerHTML = '<label class=\"small font-weight-bold text-secondary mb-1\">Justify Content</label>';\n const justifySelect = document.createElement('select');\n justifySelect.className = 'custom-select custom-select-sm';\n const justifyOpts = [\n { val: 'start', lbl: 'Start' },\n { val: 'center', lbl: 'Center' },\n { val: 'end', lbl: 'End' },\n { val: 'between', lbl: 'Space Between' },\n { val: 'around', lbl: 'Space Around' },\n { val: 'evenly', lbl: 'Space Evenly' }\n ];\n justifyOpts.forEach(o => {\n const opt = document.createElement('option');\n opt.value = o.val;\n opt.innerText = o.lbl;\n opt.selected = this.data.justify === o.val;\n justifySelect.appendChild(opt);\n });\n justifySelect.addEventListener('change', (e) => this.data.justify = e.target.value);\n justifyCol.appendChild(justifySelect);\n settingsRow.appendChild(justifyCol);\n\n // 3. Align Items\n const alignCol = document.createElement('div');\n alignCol.className = 'col-md-3 col-sm-6 mb-2';\n alignCol.innerHTML = '<label class=\"small font-weight-bold text-secondary mb-1\">Align Items</label>';\n const alignSelect = document.createElement('select');\n alignSelect.className = 'custom-select custom-select-sm';\n const alignOpts = [\n { val: 'stretch', lbl: 'Stretch' },\n { val: 'start', lbl: 'Start' },\n { val: 'center', lbl: 'Center' },\n { val: 'end', lbl: 'End' }\n ];\n alignOpts.forEach(o => {\n const opt = document.createElement('option');\n opt.value = o.val;\n opt.innerText = o.lbl;\n opt.selected = this.data.align === o.val;\n alignSelect.appendChild(opt);\n });\n alignSelect.addEventListener('change', (e) => this.data.align = e.target.value);\n alignCol.appendChild(alignSelect);\n settingsRow.appendChild(alignCol);\n\n // 4. Gap Level\n const gapCol = document.createElement('div');\n gapCol.className = 'col-md-3 col-sm-6 mb-2';\n gapCol.innerHTML = '<label class=\"small font-weight-bold text-secondary mb-1\">Gap Level</label>';\n const gapSelect = document.createElement('select');\n gapSelect.className = 'custom-select custom-select-sm';\n const gapOpts = [\n { val: '0', lbl: 'None' },\n { val: '1', lbl: 'Extra Small' },\n { val: '2', lbl: 'Small' },\n { val: '3', lbl: 'Medium' },\n { val: '4', lbl: 'Large' },\n { val: '5', lbl: 'Extra Large' }\n ];\n gapOpts.forEach(o => {\n const opt = document.createElement('option');\n opt.value = o.val;\n opt.innerText = o.lbl;\n opt.selected = this.data.gap === o.val;\n gapSelect.appendChild(opt);\n });\n gapSelect.addEventListener('change', (e) => this.data.gap = e.target.value);\n gapCol.appendChild(gapSelect);\n settingsRow.appendChild(gapCol);\n\n this.wrapper.appendChild(settingsRow);\n\n // Background Settings Row\n const bgSettingsRow = document.createElement('div');\n bgSettingsRow.className = 'form-row mb-3 pb-2 border-bottom';\n\n // 1. Flex Items (Cols)\n const colDiv = document.createElement('div');\n colDiv.className = 'col-md-2 mb-2';\n colDiv.innerHTML = '<label class=\"small font-weight-bold text-secondary mb-1\">Flex Items</label>';\n const input = document.createElement('input');\n input.type = 'number';\n input.className = 'form-control form-control-sm';\n input.min = '1';\n input.max = '12';\n input.value = this.data.cols;\n if (this.readOnly) input.disabled = true;\n input.addEventListener('input', (e) => {\n let val = parseInt(e.target.value) || 2;\n if (val < 1) val = 1;\n if (val > 12) val = 12;\n this.data.cols = val;\n this._renderColumnInputs();\n });\n colDiv.appendChild(input);\n bgSettingsRow.appendChild(colDiv);\n\n // 2. Global CSS Class\n const classDiv = document.createElement('div');\n classDiv.className = 'col-md-5 mb-2';\n classDiv.innerHTML = '<label class=\"small font-weight-bold text-secondary mb-1\">Flex CSS Class(es)</label>';\n const classInput = document.createElement('input');\n classInput.type = 'text';\n classInput.className = 'form-control form-control-sm';\n classInput.placeholder = 'e.g. my-custom-flex';\n classInput.value = this.data.globalClass || '';\n if (this.readOnly) classInput.disabled = true;\n classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());\n classDiv.appendChild(classInput);\n bgSettingsRow.appendChild(classDiv);\n\n // 3. Global HTML ID\n const idDiv = document.createElement('div');\n idDiv.className = 'col-md-5 mb-2';\n idDiv.innerHTML = '<label class=\"small font-weight-bold text-secondary mb-1\">Flex HTML ID</label>';\n const idInput = document.createElement('input');\n idInput.type = 'text';\n idInput.className = 'form-control form-control-sm';\n idInput.placeholder = 'e.g. flex-section-1';\n idInput.value = this.data.globalId || '';\n if (this.readOnly) idInput.disabled = true;\n idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());\n idDiv.appendChild(idInput);\n bgSettingsRow.appendChild(idDiv);\n\n this.wrapper.appendChild(bgSettingsRow);\n\n // Columns inputs container\n this.inputsContainer = document.createElement('div');\n this.wrapper.appendChild(this.inputsContainer);\n\n this._renderColumnInputs();\n\n return this.wrapper;\n }\n\n _renderColumnInputs() {\n this.inputsContainer.innerHTML = '';\n \n const row = document.createElement('div');\n row.className = 'row';\n\n const count = this.data.cols;\n const colWidthClass = count === 1 ? 'col-12' : (count === 2 ? 'col-md-6' : (count === 3 ? 'col-md-4' : (count === 4 ? 'col-md-3' : 'col-md')));\n\n for (let i = 1; i <= count; i++) {\n const colDiv = document.createElement('div');\n colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`;\n colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';\n\n const headerRow = document.createElement('div');\n headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';\n\n const label = document.createElement('label');\n label.className = 'small font-weight-bold text-dark mb-0';\n label.innerText = `Item ${i}`;\n headerRow.appendChild(label);\n\n const controlsWrapper = document.createElement('div');\n controlsWrapper.className = 'd-flex align-items-center gap-1';\n\n // Width Selector\n const widthSelect = document.createElement('select');\n widthSelect.className = 'custom-select custom-select-sm mr-1';\n widthSelect.style.width = '75px';\n if (this.readOnly) widthSelect.disabled = true;\n\n const widthOpts = [\n { val: 0, lbl: 'Auto' },\n { val: 1, lbl: '1/12' },\n { val: 2, lbl: '2/12' },\n { val: 3, lbl: '3/12' },\n { val: 4, lbl: '4/12' },\n { val: 5, lbl: '5/12' },\n { val: 6, lbl: '6/12' },\n { val: 7, lbl: '7/12' },\n { val: 8, lbl: '8/12' },\n { val: 9, lbl: '9/12' },\n { val: 10, lbl: '10/12' },\n { val: 11, lbl: '11/12' },\n { val: 12, lbl: '12/12' }\n ];\n\n widthOpts.forEach(o => {\n const opt = document.createElement('option');\n opt.value = o.val;\n opt.innerText = o.lbl;\n opt.selected = this.data[`width${i}`] === o.val;\n widthSelect.appendChild(opt);\n });\n\n widthSelect.addEventListener('change', (e) => {\n this.data[`width${i}`] = parseInt(e.target.value) || 0;\n });\n\n controlsWrapper.appendChild(widthSelect);\n\n // Column Type Select (Built-ins + Registered plugins dynamically)\n const typeSelect = document.createElement('select');\n typeSelect.className = 'custom-select custom-select-sm';\n typeSelect.style.width = '100px';\n if (this.readOnly) typeSelect.disabled = true;\n\n const types = [\n { value: 'html', label: 'HTML/Text' },\n { value: 'image', label: 'Image' },\n { value: 'youtube', label: 'YouTube' },\n { value: 'accordion', label: 'Accordion' }\n ];\n\n if (window.SISEditorPlugins) {\n Object.keys(window.SISEditorPlugins).forEach(key => {\n // Allow nesting any registered plugins (no exclusions)\n if (!types.some(t => t.value === key)) {\n types.push({ value: key, label: `[Plugin] ${key}` });\n }\n });\n }\n\n types.forEach(t => {\n const opt = document.createElement('option');\n opt.value = t.value;\n opt.innerText = t.label;\n opt.selected = this.data[`type${i}`] === t.value;\n typeSelect.appendChild(opt);\n });\n\n const contentDiv = document.createElement('div');\n\n typeSelect.addEventListener('change', (e) => {\n this.data[`type${i}`] = e.target.value;\n this._renderTypeSpecificInput(i, contentDiv);\n });\n\n controlsWrapper.appendChild(typeSelect);\n headerRow.appendChild(controlsWrapper);\n colDiv.appendChild(headerRow);\n colDiv.appendChild(contentDiv);\n row.appendChild(colDiv);\n\n this._renderTypeSpecificInput(i, contentDiv);\n }\n\n this.inputsContainer.appendChild(row);\n }\n\n _renderTypeSpecificInput(i, container) {\n container.innerHTML = '';\n \n // Remove tracking of previous instance\n if (this.activeInstances[i]) {\n delete this.activeInstances[i];\n }\n\n const typeContainer = document.createElement('div');\n typeContainer.className = 'mb-3';\n container.appendChild(typeContainer);\n\n const type = this.data[`type${i}`];\n\n // Check if type is a dynamic editor plugin registered in window.SISEditorPlugins\n if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {\n try {\n const pluginEntry = window.SISEditorPlugins[type];\n const pluginClass = pluginEntry.class;\n const pluginConfig = pluginEntry.config || {};\n let parsedData = {};\n if (this.data[`col${i}`]) {\n try {\n parsedData = typeof this.data[`col${i}`] === 'string' ? JSON.parse(this.data[`col${i}`]) : this.data[`col${i}`];\n } catch (e) {\n parsedData = { html: this.data[`col${i}`] }; // fallback\n }\n }\n const instance = new pluginClass({\n data: parsedData,\n api: this.api,\n readOnly: this.readOnly,\n config: pluginConfig,\n block: {\n id: 'nested-flex-' + i + '-' + Math.random().toString(36).substring(7),\n name: type,\n holder: typeContainer,\n isEmpty: false,\n selected: false,\n stretched: false,\n tunes: {}\n }\n });\n this.activeInstances[i] = instance;\n\n const element = instance.render();\n typeContainer.appendChild(element);\n\n // Fix for Editor.js Image plugin 404 errors (spinner hangs forever)\n if (type === 'image') {\n // Custom UI override for nested images\n const injectCustomButtons = () => {\n if (element.querySelector('.sis-custom-image-controls')) return;\n\n const fileBtn = element.querySelector('.cdx-button');\n if (fileBtn) {\n fileBtn.style.display = 'none'; // Hide native button\n }\n\n const controls = document.createElement('div');\n controls.className = 'sis-custom-image-controls mt-2 p-2 border rounded bg-light';\n controls.style.display = 'flex';\n controls.style.flexDirection = 'column';\n controls.style.gap = '8px';\n \n controls.innerHTML = `\n <div class=\"text-muted small text-center mb-1\"><i class=\"fas fa-cog\"></i> Image Controls</div>\n <button type=\"button\" class=\"btn btn-outline-primary btn-sm w-100\" id=\"btn-upload-img-${i}\"><i class=\"fas fa-upload\"></i> Upload Image</button>\n <button type=\"button\" class=\"btn btn-outline-info btn-sm w-100\" id=\"btn-fetch-url-${i}\"><i class=\"fas fa-link\"></i> Fetch URL</button>\n <button type=\"button\" class=\"btn btn-outline-secondary btn-sm w-100\" id=\"btn-media-lib-${i}\"><i class=\"fas fa-images\"></i> Media Library</button>\n <button type=\"button\" class=\"btn btn-outline-danger btn-sm w-100 mt-2\" id=\"btn-clear-img-${i}\"><i class=\"fas fa-trash\"></i> Remove Image</button>\n `;\n\n const uploadBtn = controls.querySelector(`#btn-upload-img-${i}`);\n if (uploadBtn && fileBtn) {\n uploadBtn.addEventListener('click', (e) => {\n e.preventDefault();\n fileBtn.click();\n });\n }\n \n const urlBtn = controls.querySelector(`#btn-fetch-url-${i}`);\n if (urlBtn) {\n urlBtn.addEventListener('click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n const url = prompt('Enter Image URL:');\n if (url && url.trim() !== '') {\n this.data[`col${i}`] = {\n file: { url: url.trim() },\n caption: '',\n withBorder: false,\n withBackground: false,\n stretched: false\n };\n this._renderTypeSpecificInput(i, container);\n }\n });\n }\n\n const mediaBtn = controls.querySelector(`#btn-media-lib-${i}`);\n if (mediaBtn) {\n mediaBtn.addEventListener('click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n if (typeof openMediaPickerModal === 'function') {\n openMediaPickerModal((url) => {\n if (url) {\n this.data[`col${i}`] = {\n file: { url: url },\n caption: '',\n withBorder: false,\n withBackground: false,\n stretched: false\n };\n this._renderTypeSpecificInput(i, container);\n }\n });\n } else {\n alert('Media Library is not yet configured on this system.');\n }\n });\n }\n\n const clearBtn = controls.querySelector(`#btn-clear-img-${i}`);\n if (clearBtn) {\n clearBtn.addEventListener('click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n if (confirm('Are you sure you want to remove this image?')) {\n this.data[`col${i}`] = '';\n this._renderTypeSpecificInput(i, container);\n }\n });\n }\n\n element.appendChild(controls);\n };\n injectCustomButtons();\n\n const handleImageError = () => {\n element.classList.remove('image-tool--loading');\n element.classList.add('image-tool--empty');\n const preloader = element.querySelector('.image-tool__image-preloader');\n if (preloader) preloader.style.display = 'none';\n };\n\n const observer = new MutationObserver((mutations) => {\n mutations.forEach(mutation => {\n mutation.addedNodes.forEach(node => {\n if (node.tagName === 'IMG') {\n node.addEventListener('error', handleImageError);\n }\n });\n });\n });\n observer.observe(element, { childList: true, subtree: true });\n \n const existingImg = element.querySelector('img');\n if (existingImg) existingImg.addEventListener('error', handleImageError);\n }\n } catch (err) {\n console.error(`Failed to render plugin ${type} inside FlexTool:`, err);\n typeContainer.innerHTML = `<div class=\"alert alert-danger small\">Error loading plugin ${type}</div>`;\n }\n } else if (type === 'html') {\n const textarea = document.createElement('textarea');\n textarea.className = 'form-control form-control-sm';\n textarea.style.minHeight = '140px';\n textarea.style.fontSize = '12px';\n textarea.placeholder = `HTML / Text for Item ${i}...`;\n textarea.value = typeof this.data[`col${i}`] === 'object' ? JSON.stringify(this.data[`col${i}`]) : this.data[`col${i}`] || '';\n if (this.readOnly) textarea.disabled = true;\n\n textarea.addEventListener('input', (e) => {\n this.data[`col${i}`] = e.target.value;\n });\n typeContainer.appendChild(textarea);\n\n } else if (type === 'image') {\n const inputGroup = document.createElement('div');\n inputGroup.className = 'input-group input-group-sm mb-2';\n\n const input = document.createElement('input');\n input.type = 'text';\n input.className = 'form-control form-control-sm';\n input.placeholder = 'Paste image URL here...';\n input.value = this.data[`imgUrl${i}`] || '';\n if (this.readOnly) input.disabled = true;\n\n const appendDiv = document.createElement('div');\n appendDiv.className = 'input-group-append';\n\n const chooseBtn = document.createElement('button');\n chooseBtn.type = 'button';\n chooseBtn.className = 'btn btn-outline-secondary';\n chooseBtn.innerText = 'Choose';\n if (this.readOnly) chooseBtn.disabled = true;\n chooseBtn.addEventListener('click', () => {\n if (typeof openMediaPickerModal === 'function') {\n openMediaPickerModal((selectedUrl) => {\n input.value = selectedUrl;\n this.data[`imgUrl${i}`] = selectedUrl;\n previewImg.src = selectedUrl;\n previewBox.style.display = 'block';\n });\n } else {\n alert('Media Picker modal helper is not loaded.');\n }\n });\n\n const uploadBtn = document.createElement('button');\n uploadBtn.type = 'button';\n uploadBtn.className = 'btn btn-outline-primary';\n uploadBtn.innerText = 'Upload';\n if (this.readOnly) uploadBtn.disabled = true;\n\n const fileInput = document.createElement('input');\n fileInput.type = 'file';\n fileInput.accept = 'image/*';\n fileInput.style.display = 'none';\n\n uploadBtn.addEventListener('click', () => fileInput.click());\n fileInput.addEventListener('change', async (e) => {\n const file = e.target.files[0];\n if (!file) return;\n\n const formData = new FormData();\n formData.append('image', file);\n\n uploadBtn.disabled = true;\n uploadBtn.innerText = '...';\n\n try {\n const response = await fetch('/api/manage/media/upload', {\n method: 'POST',\n body: formData\n });\n const result = await response.json();\n if (result.success && result.file && result.file.url) {\n const url = result.file.url;\n input.value = url;\n this.data[`imgUrl${i}`] = url;\n previewImg.src = url;\n previewBox.style.display = 'block';\n } else {\n alert('Upload failed: ' + (result.message || 'Unknown error'));\n }\n } catch (err) {\n console.error(err);\n alert('Upload error occurred.');\n } finally {\n uploadBtn.disabled = false;\n uploadBtn.innerText = 'Upload';\n }\n });\n\n appendDiv.appendChild(chooseBtn);\n appendDiv.appendChild(uploadBtn);\n inputGroup.appendChild(input);\n inputGroup.appendChild(appendDiv);\n typeContainer.appendChild(inputGroup);\n typeContainer.appendChild(fileInput);\n\n const previewBox = document.createElement('div');\n previewBox.className = 'border rounded p-1 text-center bg-light';\n previewBox.style.display = this.data[`imgUrl${i}`] ? 'block' : 'none';\n previewBox.style.maxHeight = '150px';\n previewBox.style.overflow = 'hidden';\n\n const previewImg = document.createElement('img');\n previewImg.src = this.data[`imgUrl${i}`] || '';\n previewImg.style.maxWidth = '100%';\n previewImg.style.maxHeight = '140px';\n previewImg.style.objectFit = 'contain';\n\n previewBox.appendChild(previewImg);\n typeContainer.appendChild(previewBox);\n\n input.addEventListener('input', (e) => {\n const val = e.target.value.trim();\n this.data[`imgUrl${i}`] = val;\n if (val) {\n previewImg.src = val;\n previewBox.style.display = 'block';\n } else {\n previewBox.style.display = 'none';\n }\n });\n\n } else if (type === 'youtube') {\n const input = document.createElement('input');\n input.type = 'text';\n input.className = 'form-control form-control-sm mb-2';\n input.placeholder = 'Paste YouTube video URL...';\n input.value = this.data[`ytUrl${i}`] || '';\n if (this.readOnly) input.disabled = true;\n\n const preview = document.createElement('div');\n preview.className = 'small text-muted p-2 bg-light border rounded';\n preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';\n\n input.addEventListener('input', (e) => {\n this.data[`ytUrl${i}`] = e.target.value.trim();\n });\n\n typeContainer.appendChild(input);\n typeContainer.appendChild(preview);\n\n } else if (type === 'accordion') {\n const titleInput = document.createElement('input');\n titleInput.type = 'text';\n titleInput.className = 'form-control form-control-sm mb-2';\n titleInput.placeholder = 'Accordion Title...';\n titleInput.value = this.data[`accTitle${i}`] || '';\n if (this.readOnly) titleInput.disabled = true;\n\n const contentTextarea = document.createElement('textarea');\n contentTextarea.className = 'form-control form-control-sm';\n contentTextarea.style.minHeight = '90px';\n contentTextarea.style.fontSize = '12px';\n contentTextarea.placeholder = 'Accordion Body HTML...';\n contentTextarea.value = this.data[`accContent${i}`] || '';\n if (this.readOnly) contentTextarea.disabled = true;\n\n titleInput.addEventListener('input', (e) => {\n this.data[`accTitle${i}`] = e.target.value;\n });\n contentTextarea.addEventListener('input', (e) => {\n this.data[`accContent${i}`] = e.target.value;\n });\n\n typeContainer.appendChild(titleInput);\n typeContainer.appendChild(contentTextarea);\n }\n\n // Advanced Settings (ID and Class)\n const advancedHeader = document.createElement('div');\n advancedHeader.className = 'font-weight-bold text-muted small mb-2';\n advancedHeader.style.fontSize = '10px';\n advancedHeader.innerText = 'ADVANCED SETTINGS';\n container.appendChild(advancedHeader);\n\n const advRow = document.createElement('div');\n advRow.className = 'form-row';\n\n const classCol = document.createElement('div');\n classCol.className = 'col';\n const classInput = document.createElement('input');\n classInput.type = 'text';\n classInput.className = 'form-control form-control-sm';\n classInput.style.fontSize = '11px';\n classInput.placeholder = 'CSS Class(es)...';\n classInput.value = this.data[`class${i}`] || '';\n if (this.readOnly) classInput.disabled = true;\n classInput.addEventListener('input', (e) => {\n this.data[`class${i}`] = e.target.value.trim();\n });\n classCol.appendChild(classInput);\n\n const idCol = document.createElement('div');\n idCol.className = 'col';\n const idInput = document.createElement('input');\n idInput.type = 'text';\n idInput.className = 'form-control form-control-sm';\n idInput.style.fontSize = '11px';\n idInput.placeholder = 'HTML ID...';\n idInput.value = this.data[`id${i}`] || '';\n if (this.readOnly) idInput.disabled = true;\n idInput.addEventListener('input', (e) => {\n this.data[`id${i}`] = e.target.value.trim();\n });\n idCol.appendChild(idInput);\n\n advRow.appendChild(classCol);\n advRow.appendChild(idCol);\n container.appendChild(advRow);\n }\n\n save(blockContent) {\n const savedData = {\n cols: this.data.cols,\n direction: this.data.direction,\n justify: this.data.justify,\n align: this.data.align,\n gap: this.data.gap,\n globalClass: this.data.globalClass || '',\n globalId: this.data.globalId || ''\n };\n for (let i = 1; i <= this.data.cols; i++) {\n const type = this.data[`type${i}`] || 'html';\n savedData[`type${i}`] = type;\n \n // If it is a dynamically nested plugin, save its output data\n if (this.activeInstances[i]) {\n try {\n const pluginData = this.activeInstances[i].save();\n savedData[`col${i}`] = pluginData;\n } catch (e) {\n console.error(`Failed to save plugin ${type} inside FlexTool:`, e);\n savedData[`col${i}`] = this.data[`col${i}`];\n }\n } else {\n savedData[`col${i}`] = this.data[`col${i}`] || '';\n }\n\n savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || '';\n savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || '';\n savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || '';\n savedData[`accContent${i}`] = this.data[`accContent${i}`] || '';\n savedData[`class${i}`] = this.data[`class${i}`] || '';\n savedData[`id${i}`] = this.data[`id${i}`] || '';\n savedData[`width${i}`] = this.data[`width${i}`] || 0;\n }\n return savedData;\n }\n}\n\n// Register the plugin globally\nwindow.SISEditorPlugins = window.SISEditorPlugins || {};\nwindow.SISEditorPlugins['flex'] = {\n class: FlexTool\n};\n"
|
|
} |