feat: integrate CKEditor layout plugins and add supporting import utilities

This commit is contained in:
2026-08-01 09:27:13 +07:00
parent 1c5d881bca
commit 4bf6e82f82
10 changed files with 3515 additions and 52 deletions
@@ -0,0 +1,591 @@
/**
* Flex block tool for Editor.js.
* Allows users to create highly custom Flexbox layouts with alignment, direction, gaps, and custom column items.
* Supports instantiating other registered plugins inside columns dynamically.
*/
class FlexTool {
static get toolbox() {
return {
title: 'Flex Layout',
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>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
cols: parseInt(data.cols) || 2,
direction: data.direction || 'row',
justify: data.justify || 'start',
align: data.align || 'stretch',
gap: data.gap || '3' // standard Bootstrap gap level
};
this.activeInstances = {};
// Load items content dynamically
for (let i = 1; i <= 12; i++) {
this.data[`type${i}`] = data[`type${i}`] || 'html';
this.data[`col${i}`] = data[`col${i}`] || '';
this.data[`imgUrl${i}`] = data[`imgUrl${i}`] || '';
this.data[`ytUrl${i}`] = data[`ytUrl${i}`] || '';
this.data[`accTitle${i}`] = data[`accTitle${i}`] || '';
this.data[`accContent${i}`] = data[`accContent${i}`] || '';
this.data[`class${i}`] = data[`class${i}`] || '';
this.data[`id${i}`] = data[`id${i}`] || '';
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-flex-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
const title = document.createElement('div');
title.className = 'font-weight-bold text-primary small mb-3';
title.innerHTML = '<i class="fas fa-boxes"></i> Custom Flexbox Layout Builder';
this.wrapper.appendChild(title);
// Container Flex Settings Row
const settingsRow = document.createElement('div');
settingsRow.className = 'form-row mb-3 pb-3 border-bottom';
// 1. Direction
const dirCol = document.createElement('div');
dirCol.className = 'col-md-3 col-sm-6 mb-2';
dirCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Direction</label>';
const dirSelect = document.createElement('select');
dirSelect.className = 'custom-select custom-select-sm';
const dirOpts = [
{ val: 'row', lbl: 'Row' },
{ val: 'row-reverse', lbl: 'Row Reverse' },
{ val: 'column', lbl: 'Column' },
{ val: 'column-reverse', lbl: 'Column Reverse' }
];
dirOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.direction === o.val;
dirSelect.appendChild(opt);
});
dirSelect.addEventListener('change', (e) => this.data.direction = e.target.value);
dirCol.appendChild(dirSelect);
settingsRow.appendChild(dirCol);
// 2. Justify Content
const justifyCol = document.createElement('div');
justifyCol.className = 'col-md-3 col-sm-6 mb-2';
justifyCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Justify Content</label>';
const justifySelect = document.createElement('select');
justifySelect.className = 'custom-select custom-select-sm';
const justifyOpts = [
{ val: 'start', lbl: 'Start' },
{ val: 'center', lbl: 'Center' },
{ val: 'end', lbl: 'End' },
{ val: 'between', lbl: 'Space Between' },
{ val: 'around', lbl: 'Space Around' },
{ val: 'evenly', lbl: 'Space Evenly' }
];
justifyOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.justify === o.val;
justifySelect.appendChild(opt);
});
justifySelect.addEventListener('change', (e) => this.data.justify = e.target.value);
justifyCol.appendChild(justifySelect);
settingsRow.appendChild(justifyCol);
// 3. Align Items
const alignCol = document.createElement('div');
alignCol.className = 'col-md-3 col-sm-6 mb-2';
alignCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Align Items</label>';
const alignSelect = document.createElement('select');
alignSelect.className = 'custom-select custom-select-sm';
const alignOpts = [
{ val: 'stretch', lbl: 'Stretch' },
{ val: 'start', lbl: 'Start' },
{ val: 'center', lbl: 'Center' },
{ val: 'end', lbl: 'End' }
];
alignOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.align === o.val;
alignSelect.appendChild(opt);
});
alignSelect.addEventListener('change', (e) => this.data.align = e.target.value);
alignCol.appendChild(alignSelect);
settingsRow.appendChild(alignCol);
// 4. Gap Level
const gapCol = document.createElement('div');
gapCol.className = 'col-md-3 col-sm-6 mb-2';
gapCol.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Gap Level</label>';
const gapSelect = document.createElement('select');
gapSelect.className = 'custom-select custom-select-sm';
const gapOpts = [
{ val: '0', lbl: 'None' },
{ val: '1', lbl: 'Extra Small' },
{ val: '2', lbl: 'Small' },
{ val: '3', lbl: 'Medium' },
{ val: '4', lbl: 'Large' },
{ val: '5', lbl: 'Extra Large' }
];
gapOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data.gap === o.val;
gapSelect.appendChild(opt);
});
gapSelect.addEventListener('change', (e) => this.data.gap = e.target.value);
gapCol.appendChild(gapSelect);
settingsRow.appendChild(gapCol);
this.wrapper.appendChild(settingsRow);
// Layout Columns Number Input
const countGroup = document.createElement('div');
countGroup.className = 'form-group mb-3';
countGroup.innerHTML = '<label class="small font-weight-bold text-secondary mb-1 d-block">Number of Flex Items (1 - 12)</label>';
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.style.width = '100px';
input.min = '1';
input.max = '12';
input.value = this.data.cols;
if (this.readOnly) input.disabled = true;
input.addEventListener('input', (e) => {
let val = parseInt(e.target.value) || 2;
if (val < 1) val = 1;
if (val > 12) val = 12;
this.data.cols = val;
this._renderColumnInputs();
});
countGroup.appendChild(input);
this.wrapper.appendChild(countGroup);
// Columns inputs container
this.inputsContainer = document.createElement('div');
this.wrapper.appendChild(this.inputsContainer);
this._renderColumnInputs();
return this.wrapper;
}
_renderColumnInputs() {
this.inputsContainer.innerHTML = '';
const row = document.createElement('div');
row.className = 'row';
const count = this.data.cols;
const colWidthClass = count === 1 ? 'col-12' : (count === 2 ? 'col-md-6' : (count === 3 ? 'col-md-4' : (count === 4 ? 'col-md-3' : 'col-md')));
for (let i = 1; i <= count; i++) {
const colDiv = document.createElement('div');
colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`;
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
const headerRow = document.createElement('div');
headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';
const label = document.createElement('label');
label.className = 'small font-weight-bold text-dark mb-0';
label.innerText = `Item ${i}`;
headerRow.appendChild(label);
const controlsWrapper = document.createElement('div');
controlsWrapper.className = 'd-flex align-items-center gap-1';
// Width Selector
const widthSelect = document.createElement('select');
widthSelect.className = 'custom-select custom-select-sm mr-1';
widthSelect.style.width = '75px';
if (this.readOnly) widthSelect.disabled = true;
const widthOpts = [
{ val: 0, lbl: 'Auto' },
{ val: 1, lbl: '1/12' },
{ val: 2, lbl: '2/12' },
{ val: 3, lbl: '3/12' },
{ val: 4, lbl: '4/12' },
{ val: 5, lbl: '5/12' },
{ val: 6, lbl: '6/12' },
{ val: 7, lbl: '7/12' },
{ val: 8, lbl: '8/12' },
{ val: 9, lbl: '9/12' },
{ val: 10, lbl: '10/12' },
{ val: 11, lbl: '11/12' },
{ val: 12, lbl: '12/12' }
];
widthOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data[`width${i}`] === o.val;
widthSelect.appendChild(opt);
});
widthSelect.addEventListener('change', (e) => {
this.data[`width${i}`] = parseInt(e.target.value) || 0;
});
controlsWrapper.appendChild(widthSelect);
// Column Type Select (Built-ins + Registered plugins dynamically)
const typeSelect = document.createElement('select');
typeSelect.className = 'custom-select custom-select-sm';
typeSelect.style.width = '100px';
if (this.readOnly) typeSelect.disabled = true;
const types = [
{ value: 'html', label: 'HTML/Text' },
{ value: 'image', label: 'Image' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'accordion', label: 'Accordion' }
];
if (window.SISEditorPlugins) {
Object.keys(window.SISEditorPlugins).forEach(key => {
// Avoid self-nesting flex to prevent recursion issues
if (key !== 'flex' && !types.some(t => t.value === key)) {
types.push({ value: key, label: `[Plugin] ${key}` });
}
});
}
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.innerText = t.label;
opt.selected = this.data[`type${i}`] === t.value;
typeSelect.appendChild(opt);
});
const contentDiv = document.createElement('div');
typeSelect.addEventListener('change', (e) => {
this.data[`type${i}`] = e.target.value;
this._renderTypeSpecificInput(i, contentDiv);
});
controlsWrapper.appendChild(typeSelect);
headerRow.appendChild(controlsWrapper);
colDiv.appendChild(headerRow);
colDiv.appendChild(contentDiv);
row.appendChild(colDiv);
this._renderTypeSpecificInput(i, contentDiv);
}
this.inputsContainer.appendChild(row);
}
_renderTypeSpecificInput(i, container) {
container.innerHTML = '';
// Remove tracking of previous instance
if (this.activeInstances[i]) {
delete this.activeInstances[i];
}
const typeContainer = document.createElement('div');
typeContainer.className = 'mb-3';
container.appendChild(typeContainer);
const type = this.data[`type${i}`];
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
try {
const pluginClass = window.SISEditorPlugins[type].class;
let parsedData = {};
if (this.data[`col${i}`]) {
try {
parsedData = typeof this.data[`col${i}`] === 'string' ? JSON.parse(this.data[`col${i}`]) : this.data[`col${i}`];
} catch (e) {
parsedData = { html: this.data[`col${i}`] }; // fallback
}
}
const instance = new pluginClass({
data: parsedData,
api: this.api,
readOnly: this.readOnly
});
this.activeInstances[i] = instance;
const element = instance.render();
typeContainer.appendChild(element);
} catch (err) {
console.error(`Failed to render plugin ${type} inside FlexTool:`, err);
typeContainer.innerHTML = `<div class="alert alert-danger small">Error loading plugin ${type}</div>`;
}
} else if (type === 'html') {
const textarea = document.createElement('textarea');
textarea.className = 'form-control form-control-sm';
textarea.style.minHeight = '140px';
textarea.style.fontSize = '12px';
textarea.placeholder = `HTML / Text for Item ${i}...`;
textarea.value = typeof this.data[`col${i}`] === 'object' ? JSON.stringify(this.data[`col${i}`]) : this.data[`col${i}`] || '';
if (this.readOnly) textarea.disabled = true;
textarea.addEventListener('input', (e) => {
this.data[`col${i}`] = e.target.value;
});
typeContainer.appendChild(textarea);
} else if (type === 'image') {
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm mb-2';
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm';
input.placeholder = 'Paste image URL here...';
input.value = this.data[`imgUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const chooseBtn = document.createElement('button');
chooseBtn.type = 'button';
chooseBtn.className = 'btn btn-outline-secondary';
chooseBtn.innerText = 'Choose';
if (this.readOnly) chooseBtn.disabled = true;
chooseBtn.addEventListener('click', () => {
if (typeof openMediaPickerModal === 'function') {
openMediaPickerModal((selectedUrl) => {
input.value = selectedUrl;
this.data[`imgUrl${i}`] = selectedUrl;
previewImg.src = selectedUrl;
previewBox.style.display = 'block';
});
} else {
alert('Media Picker modal helper is not loaded.');
}
});
const uploadBtn = document.createElement('button');
uploadBtn.type = 'button';
uploadBtn.className = 'btn btn-outline-primary';
uploadBtn.innerText = 'Upload';
if (this.readOnly) uploadBtn.disabled = true;
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
uploadBtn.disabled = true;
uploadBtn.innerText = '...';
try {
const response = await fetch('/api/manage/media/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success && result.file && result.file.url) {
const url = result.file.url;
input.value = url;
this.data[`imgUrl${i}`] = url;
previewImg.src = url;
previewBox.style.display = 'block';
} else {
alert('Upload failed: ' + (result.message || 'Unknown error'));
}
} catch (err) {
console.error(err);
alert('Upload error occurred.');
} finally {
uploadBtn.disabled = false;
uploadBtn.innerText = 'Upload';
}
});
appendDiv.appendChild(chooseBtn);
appendDiv.appendChild(uploadBtn);
inputGroup.appendChild(input);
inputGroup.appendChild(appendDiv);
typeContainer.appendChild(inputGroup);
typeContainer.appendChild(fileInput);
const previewBox = document.createElement('div');
previewBox.className = 'border rounded p-1 text-center bg-light';
previewBox.style.display = this.data[`imgUrl${i}`] ? 'block' : 'none';
previewBox.style.maxHeight = '150px';
previewBox.style.overflow = 'hidden';
const previewImg = document.createElement('img');
previewImg.src = this.data[`imgUrl${i}`] || '';
previewImg.style.maxWidth = '100%';
previewImg.style.maxHeight = '140px';
previewImg.style.objectFit = 'contain';
previewBox.appendChild(previewImg);
typeContainer.appendChild(previewBox);
input.addEventListener('input', (e) => {
const val = e.target.value.trim();
this.data[`imgUrl${i}`] = val;
if (val) {
previewImg.src = val;
previewBox.style.display = 'block';
} else {
previewBox.style.display = 'none';
}
});
} else if (type === 'youtube') {
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm mb-2';
input.placeholder = 'Paste YouTube video URL...';
input.value = this.data[`ytUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const preview = document.createElement('div');
preview.className = 'small text-muted p-2 bg-light border rounded';
preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';
input.addEventListener('input', (e) => {
this.data[`ytUrl${i}`] = e.target.value.trim();
});
typeContainer.appendChild(input);
typeContainer.appendChild(preview);
} else if (type === 'accordion') {
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control form-control-sm mb-2';
titleInput.placeholder = 'Accordion Title...';
titleInput.value = this.data[`accTitle${i}`] || '';
if (this.readOnly) titleInput.disabled = true;
const contentTextarea = document.createElement('textarea');
contentTextarea.className = 'form-control form-control-sm';
contentTextarea.style.minHeight = '90px';
contentTextarea.style.fontSize = '12px';
contentTextarea.placeholder = 'Accordion Body HTML...';
contentTextarea.value = this.data[`accContent${i}`] || '';
if (this.readOnly) contentTextarea.disabled = true;
titleInput.addEventListener('input', (e) => {
this.data[`accTitle${i}`] = e.target.value;
});
contentTextarea.addEventListener('input', (e) => {
this.data[`accContent${i}`] = e.target.value;
});
typeContainer.appendChild(titleInput);
typeContainer.appendChild(contentTextarea);
}
// Advanced Settings (ID and Class)
const advancedHeader = document.createElement('div');
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
advancedHeader.style.fontSize = '10px';
advancedHeader.innerText = 'ADVANCED SETTINGS';
container.appendChild(advancedHeader);
const advRow = document.createElement('div');
advRow.className = 'form-row';
const classCol = document.createElement('div');
classCol.className = 'col';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.style.fontSize = '11px';
classInput.placeholder = 'CSS Class(es)...';
classInput.value = this.data[`class${i}`] || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => {
this.data[`class${i}`] = e.target.value.trim();
});
classCol.appendChild(classInput);
const idCol = document.createElement('div');
idCol.className = 'col';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.style.fontSize = '11px';
idInput.placeholder = 'HTML ID...';
idInput.value = this.data[`id${i}`] || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => {
this.data[`id${i}`] = e.target.value.trim();
});
idCol.appendChild(idInput);
advRow.appendChild(classCol);
advRow.appendChild(idCol);
container.appendChild(advRow);
}
save(blockContent) {
const savedData = {
cols: this.data.cols,
direction: this.data.direction,
justify: this.data.justify,
align: this.data.align,
gap: this.data.gap
};
for (let i = 1; i <= this.data.cols; i++) {
const type = this.data[`type${i}`] || 'html';
savedData[`type${i}`] = type;
// If it is a dynamically nested plugin, save its output data
if (this.activeInstances[i]) {
try {
const pluginData = this.activeInstances[i].save();
savedData[`col${i}`] = pluginData;
} catch (e) {
console.error(`Failed to save plugin ${type} inside FlexTool:`, e);
savedData[`col${i}`] = this.data[`col${i}`];
}
} else {
savedData[`col${i}`] = this.data[`col${i}`] || '';
}
savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || '';
savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || '';
savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || '';
savedData[`accContent${i}`] = this.data[`accContent${i}`] || '';
savedData[`class${i}`] = this.data[`class${i}`] || '';
savedData[`id${i}`] = this.data[`id${i}`] || '';
savedData[`width${i}`] = this.data[`width${i}`] || 0;
}
return savedData;
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['flex'] = {
class: FlexTool
};
@@ -0,0 +1,487 @@
/**
* Grid block tool for Editor.js.
* Allows users to create responsive layouts with any number of columns and choose different content types, manual widths,
* and dynamically instantiates other registered editor plugins inside columns.
*/
class GridTool {
static get toolbox() {
return {
title: 'Grid / Columns',
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>'
};
}
constructor({ data, api, readOnly }) {
this.api = api;
this.readOnly = readOnly;
this.data = {
cols: parseInt(data.cols) || 2
};
this.activeInstances = {};
// Load column contents dynamically
for (let i = 1; i <= 12; i++) {
this.data[`type${i}`] = data[`type${i}`] || 'html';
this.data[`col${i}`] = data[`col${i}`] || '';
this.data[`imgUrl${i}`] = data[`imgUrl${i}`] || '';
this.data[`ytUrl${i}`] = data[`ytUrl${i}`] || '';
this.data[`accTitle${i}`] = data[`accTitle${i}`] || '';
this.data[`accContent${i}`] = data[`accContent${i}`] || '';
this.data[`class${i}`] = data[`class${i}`] || '';
this.data[`id${i}`] = data[`id${i}`] || '';
this.data[`width${i}`] = parseInt(data[`width${i}`]) || 0; // 0 means auto
}
this.wrapper = undefined;
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-grid-tool-wrapper';
this.wrapper.style.fontFamily = 'inherit';
const title = document.createElement('div');
title.className = 'font-weight-bold text-primary small mb-3';
title.innerHTML = '<i class="fas fa-columns"></i> Multi-Column Grid Layout';
this.wrapper.appendChild(title);
// Layout Columns Number Input
const selectorGroup = document.createElement('div');
selectorGroup.className = 'form-group mb-3';
const label = document.createElement('label');
label.className = 'small font-weight-bold text-secondary mb-1 d-block';
label.innerText = 'Number of Columns (1 - 12)';
selectorGroup.appendChild(label);
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.style.width = '100px';
input.min = '1';
input.max = '12';
input.value = this.data.cols;
if (this.readOnly) input.disabled = true;
input.addEventListener('input', (e) => {
let val = parseInt(e.target.value) || 2;
if (val < 1) val = 1;
if (val > 12) val = 12;
this.data.cols = val;
this._renderColumnInputs();
});
selectorGroup.appendChild(input);
this.wrapper.appendChild(selectorGroup);
// Columns inputs container
this.inputsContainer = document.createElement('div');
this.wrapper.appendChild(this.inputsContainer);
this._renderColumnInputs();
return this.wrapper;
}
_renderColumnInputs() {
this.inputsContainer.innerHTML = '';
const row = document.createElement('div');
row.className = 'row';
const count = this.data.cols;
const colWidthClass = count === 1 ? 'col-12' : (count === 2 ? 'col-md-6' : (count === 3 ? 'col-md-4' : (count === 4 ? 'col-md-3' : 'col-md')));
for (let i = 1; i <= count; i++) {
const colDiv = document.createElement('div');
colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`;
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
const headerRow = document.createElement('div');
headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';
const label = document.createElement('label');
label.className = 'small font-weight-bold text-dark mb-0';
label.innerText = `Col ${i}`;
headerRow.appendChild(label);
const controlsWrapper = document.createElement('div');
controlsWrapper.className = 'd-flex align-items-center gap-1';
// Width Selector
const widthSelect = document.createElement('select');
widthSelect.className = 'custom-select custom-select-sm mr-1';
widthSelect.style.width = '75px';
if (this.readOnly) widthSelect.disabled = true;
const widthOpts = [
{ val: 0, lbl: 'Auto' },
{ val: 1, lbl: '1/12' },
{ val: 2, lbl: '2/12' },
{ val: 3, lbl: '3/12' },
{ val: 4, lbl: '4/12' },
{ val: 5, lbl: '5/12' },
{ val: 6, lbl: '6/12' },
{ val: 7, lbl: '7/12' },
{ val: 8, lbl: '8/12' },
{ val: 9, lbl: '9/12' },
{ val: 10, lbl: '10/12' },
{ val: 11, lbl: '11/12' },
{ val: 12, lbl: '12/12' }
];
widthOpts.forEach(o => {
const opt = document.createElement('option');
opt.value = o.val;
opt.innerText = o.lbl;
opt.selected = this.data[`width${i}`] === o.val;
widthSelect.appendChild(opt);
});
widthSelect.addEventListener('change', (e) => {
this.data[`width${i}`] = parseInt(e.target.value) || 0;
});
controlsWrapper.appendChild(widthSelect);
// Column Type Select (Built-ins + Registered plugins dynamically)
const typeSelect = document.createElement('select');
typeSelect.className = 'custom-select custom-select-sm';
typeSelect.style.width = '100px';
if (this.readOnly) typeSelect.disabled = true;
// Base types
const types = [
{ value: 'html', label: 'HTML/Text' },
{ value: 'image', label: 'Image' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'accordion', label: 'Accordion' }
];
// Dynamically add other registered plugins from window.SISEditorPlugins
if (window.SISEditorPlugins) {
Object.keys(window.SISEditorPlugins).forEach(key => {
// Avoid self-nesting grid to prevent recursion issues
if (key !== 'grid' && !types.some(t => t.value === key)) {
types.push({ value: key, label: `[Plugin] ${key}` });
}
});
}
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.innerText = t.label;
opt.selected = this.data[`type${i}`] === t.value;
typeSelect.appendChild(opt);
});
const contentDiv = document.createElement('div');
typeSelect.addEventListener('change', (e) => {
this.data[`type${i}`] = e.target.value;
this._renderTypeSpecificInput(i, contentDiv);
});
controlsWrapper.appendChild(typeSelect);
headerRow.appendChild(controlsWrapper);
colDiv.appendChild(headerRow);
colDiv.appendChild(contentDiv);
row.appendChild(colDiv);
this._renderTypeSpecificInput(i, contentDiv);
}
this.inputsContainer.appendChild(row);
}
_renderTypeSpecificInput(i, container) {
container.innerHTML = '';
// Remove tracking of previous instance
if (this.activeInstances[i]) {
delete this.activeInstances[i];
}
const typeContainer = document.createElement('div');
typeContainer.className = 'mb-3';
container.appendChild(typeContainer);
const type = this.data[`type${i}`];
// Check if type is a dynamic editor plugin registered in window.SISEditorPlugins
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
try {
const pluginClass = window.SISEditorPlugins[type].class;
let parsedData = {};
if (this.data[`col${i}`]) {
try {
parsedData = typeof this.data[`col${i}`] === 'string' ? JSON.parse(this.data[`col${i}`]) : this.data[`col${i}`];
} catch (e) {
parsedData = { html: this.data[`col${i}`] }; // fallback
}
}
const instance = new pluginClass({
data: parsedData,
api: this.api,
readOnly: this.readOnly
});
this.activeInstances[i] = instance;
const element = instance.render();
typeContainer.appendChild(element);
} catch (err) {
console.error(`Failed to render plugin ${type} inside GridTool:`, err);
typeContainer.innerHTML = `<div class="alert alert-danger small">Error loading plugin ${type}</div>`;
}
} else if (type === 'html') {
const textarea = document.createElement('textarea');
textarea.className = 'form-control form-control-sm';
textarea.style.minHeight = '140px';
textarea.style.fontSize = '12px';
textarea.placeholder = `HTML / Text for Column ${i}...`;
textarea.value = typeof this.data[`col${i}`] === 'object' ? JSON.stringify(this.data[`col${i}`]) : this.data[`col${i}`] || '';
if (this.readOnly) textarea.disabled = true;
textarea.addEventListener('input', (e) => {
this.data[`col${i}`] = e.target.value;
});
typeContainer.appendChild(textarea);
} else if (type === 'image') {
const inputGroup = document.createElement('div');
inputGroup.className = 'input-group input-group-sm mb-2';
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm';
input.placeholder = 'Paste image URL here...';
input.value = this.data[`imgUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const appendDiv = document.createElement('div');
appendDiv.className = 'input-group-append';
const chooseBtn = document.createElement('button');
chooseBtn.type = 'button';
chooseBtn.className = 'btn btn-outline-secondary';
chooseBtn.innerText = 'Choose';
if (this.readOnly) chooseBtn.disabled = true;
chooseBtn.addEventListener('click', () => {
if (typeof openMediaPickerModal === 'function') {
openMediaPickerModal((selectedUrl) => {
input.value = selectedUrl;
this.data[`imgUrl${i}`] = selectedUrl;
previewImg.src = selectedUrl;
previewBox.style.display = 'block';
});
} else {
alert('Media Picker modal helper is not loaded.');
}
});
const uploadBtn = document.createElement('button');
uploadBtn.type = 'button';
uploadBtn.className = 'btn btn-outline-primary';
uploadBtn.innerText = 'Upload';
if (this.readOnly) uploadBtn.disabled = true;
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
uploadBtn.disabled = true;
uploadBtn.innerText = '...';
try {
const response = await fetch('/api/manage/media/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success && result.file && result.file.url) {
const url = result.file.url;
input.value = url;
this.data[`imgUrl${i}`] = url;
previewImg.src = url;
previewBox.style.display = 'block';
} else {
alert('Upload failed: ' + (result.message || 'Unknown error'));
}
} catch (err) {
console.error(err);
alert('Upload error occurred.');
} finally {
uploadBtn.disabled = false;
uploadBtn.innerText = 'Upload';
}
});
appendDiv.appendChild(chooseBtn);
appendDiv.appendChild(uploadBtn);
inputGroup.appendChild(input);
inputGroup.appendChild(appendDiv);
typeContainer.appendChild(inputGroup);
typeContainer.appendChild(fileInput);
const previewBox = document.createElement('div');
previewBox.className = 'border rounded p-1 text-center bg-light';
previewBox.style.display = this.data[`imgUrl${i}`] ? 'block' : 'none';
previewBox.style.maxHeight = '150px';
previewBox.style.overflow = 'hidden';
const previewImg = document.createElement('img');
previewImg.src = this.data[`imgUrl${i}`] || '';
previewImg.style.maxWidth = '100%';
previewImg.style.maxHeight = '140px';
previewImg.style.objectFit = 'contain';
previewBox.appendChild(previewImg);
typeContainer.appendChild(previewBox);
input.addEventListener('input', (e) => {
const val = e.target.value.trim();
this.data[`imgUrl${i}`] = val;
if (val) {
previewImg.src = val;
previewBox.style.display = 'block';
} else {
previewBox.style.display = 'none';
}
});
} else if (type === 'youtube') {
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm mb-2';
input.placeholder = 'Paste YouTube video URL...';
input.value = this.data[`ytUrl${i}`] || '';
if (this.readOnly) input.disabled = true;
const preview = document.createElement('div');
preview.className = 'small text-muted p-2 bg-light border rounded';
preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';
input.addEventListener('input', (e) => {
this.data[`ytUrl${i}`] = e.target.value.trim();
});
typeContainer.appendChild(input);
typeContainer.appendChild(preview);
} else if (type === 'accordion') {
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control form-control-sm mb-2';
titleInput.placeholder = 'Accordion Title...';
titleInput.value = this.data[`accTitle${i}`] || '';
if (this.readOnly) titleInput.disabled = true;
const contentTextarea = document.createElement('textarea');
contentTextarea.className = 'form-control form-control-sm';
contentTextarea.style.minHeight = '90px';
contentTextarea.style.fontSize = '12px';
contentTextarea.placeholder = 'Accordion Body HTML...';
contentTextarea.value = this.data[`accContent${i}`] || '';
if (this.readOnly) contentTextarea.disabled = true;
titleInput.addEventListener('input', (e) => {
this.data[`accTitle${i}`] = e.target.value;
});
contentTextarea.addEventListener('input', (e) => {
this.data[`accContent${i}`] = e.target.value;
});
typeContainer.appendChild(titleInput);
typeContainer.appendChild(contentTextarea);
}
// Advanced Settings (ID and Class)
const advancedHeader = document.createElement('div');
advancedHeader.className = 'font-weight-bold text-muted small mb-2';
advancedHeader.style.fontSize = '10px';
advancedHeader.innerText = 'ADVANCED SETTINGS';
container.appendChild(advancedHeader);
const advRow = document.createElement('div');
advRow.className = 'form-row';
const classCol = document.createElement('div');
classCol.className = 'col';
const classInput = document.createElement('input');
classInput.type = 'text';
classInput.className = 'form-control form-control-sm';
classInput.style.fontSize = '11px';
classInput.placeholder = 'CSS Class(es)...';
classInput.value = this.data[`class${i}`] || '';
if (this.readOnly) classInput.disabled = true;
classInput.addEventListener('input', (e) => {
this.data[`class${i}`] = e.target.value.trim();
});
classCol.appendChild(classInput);
const idCol = document.createElement('div');
idCol.className = 'col';
const idInput = document.createElement('input');
idInput.type = 'text';
idInput.className = 'form-control form-control-sm';
idInput.style.fontSize = '11px';
idInput.placeholder = 'HTML ID...';
idInput.value = this.data[`id${i}`] || '';
if (this.readOnly) idInput.disabled = true;
idInput.addEventListener('input', (e) => {
this.data[`id${i}`] = e.target.value.trim();
});
idCol.appendChild(idInput);
advRow.appendChild(classCol);
advRow.appendChild(idCol);
container.appendChild(advRow);
}
save(blockContent) {
const savedData = {
cols: this.data.cols
};
for (let i = 1; i <= this.data.cols; i++) {
const type = this.data[`type${i}`] || 'html';
savedData[`type${i}`] = type;
// If it is a dynamically nested plugin, save its output data
if (this.activeInstances[i]) {
try {
const pluginData = this.activeInstances[i].save();
savedData[`col${i}`] = pluginData;
} catch (e) {
console.error(`Failed to save plugin ${type} inside GridTool:`, e);
savedData[`col${i}`] = this.data[`col${i}`];
}
} else {
savedData[`col${i}`] = this.data[`col${i}`] || '';
}
savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || '';
savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || '';
savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || '';
savedData[`accContent${i}`] = this.data[`accContent${i}`] || '';
savedData[`class${i}`] = this.data[`class${i}`] || '';
savedData[`id${i}`] = this.data[`id${i}`] || '';
savedData[`width${i}`] = this.data[`width${i}`] || 0;
}
return savedData;
}
}
// Register the plugin globally
window.SISEditorPlugins = window.SISEditorPlugins || {};
window.SISEditorPlugins['grid'] = {
class: GridTool
};
@@ -429,6 +429,8 @@
<!-- Custom SIS Editor Plugins -->
<script th:src="@{/js/manage/editor-plugins/html-snippet.js}"></script>
<script th:src="@{/js/manage/editor-plugins/timeline.js}"></script>
<script th:src="@{/js/manage/editor-plugins/grid.js}"></script>
<script th:src="@{/js/manage/editor-plugins/flex.js}"></script>
<!-- Init Editor -->
<script th:src="@{/js/manage/editor-config.js}"></script>
@@ -290,57 +290,178 @@
<img th:src="${item['imageUrl']}" alt="Timeline Image" loading="lazy" />
</div>
</div>
<div class="sis-timeline__img-slot sis-timeline__img-slot-empty" th:unless="${item['imageUrl'] != null and !#strings.isEmpty(item['imageUrl'])}">
</div>
<div
class="sis-timeline__img-slot sis-timeline__img-slot-empty"
th:unless="${item['imageUrl'] != null and !#strings.isEmpty(item['imageUrl'])}"
></div>
</th:block>
</div>
</div>
</section>
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const syncTimelineHeights = () => {
if (window.innerWidth < 1024) {
document.querySelectorAll('.sis-timeline__entry, .sis-timeline__img-slot').forEach(el => {
el.style.minHeight = '';
el.style.marginBottom = '';
});
return;
}
// Use the specific timeline section ID in case there are multiple
const section = document.querySelector('#timeline-[[${stat != null ? stat.index : 0}]]');
if (!section) return;
const texts = section.querySelectorAll('.sis-timeline__entry');
const imgs = section.querySelectorAll('.sis-timeline__img-slot');
for (let i = 0; i < texts.length; i++) {
if (imgs[i]) {
// reset inline styles first to measure natural height
texts[i].style.minHeight = '';
texts[i].style.marginBottom = '';
imgs[i].style.minHeight = '';
imgs[i].style.marginBottom = '';
const textH = texts[i].getBoundingClientRect().height;
const imgH = imgs[i].getBoundingClientRect().height;
const maxH = Math.max(textH, imgH);
texts[i].style.minHeight = maxH + 'px';
imgs[i].style.minHeight = maxH + 'px';
// sync bottom margin so they space out identically
texts[i].style.marginBottom = '4rem';
imgs[i].style.marginBottom = '4rem';
}
}
};
syncTimelineHeights();
window.addEventListener('resize', syncTimelineHeights);
// Run once more after images load
window.addEventListener('load', syncTimelineHeights);
document.addEventListener('DOMContentLoaded', function () {
const syncTimelineHeights = () => {
if (window.innerWidth < 1024) {
document.querySelectorAll('.sis-timeline__entry, .sis-timeline__img-slot').forEach(el => {
el.style.minHeight = '';
el.style.marginBottom = '';
});
return;
}
// Use the specific timeline section ID in case there are multiple
const section = document.querySelector('#timeline-[[${stat != null ? stat.index : 0}]]');
if (!section) return;
const texts = section.querySelectorAll('.sis-timeline__entry');
const imgs = section.querySelectorAll('.sis-timeline__img-slot');
for (let i = 0; i < texts.length; i++) {
if (imgs[i]) {
// reset inline styles first to measure natural height
texts[i].style.minHeight = '';
texts[i].style.marginBottom = '';
imgs[i].style.minHeight = '';
imgs[i].style.marginBottom = '';
const textH = texts[i].getBoundingClientRect().height;
const imgH = imgs[i].getBoundingClientRect().height;
const maxH = Math.max(textH, imgH);
texts[i].style.minHeight = maxH + 'px';
imgs[i].style.minHeight = maxH + 'px';
// sync bottom margin so they space out identically
texts[i].style.marginBottom = '4rem';
imgs[i].style.marginBottom = '4rem';
}
}
};
syncTimelineHeights();
window.addEventListener('resize', syncTimelineHeights);
// Run once more after images load
window.addEventListener('load', syncTimelineHeights);
});
</script>
</th:block>
</div>
<!-- 15. Grid / Columns Block -->
<th:block th:if="${block['type'] == 'grid'}">
<div class="container my-4">
<div class="row">
<th:block th:each="i : ${#numbers.sequence(1, block.data['cols'])}">
<div
th:id="${block.data['id' + i] != null and !#strings.isEmpty(block.data['id' + i])} ? ${block.data['id' + i]} : null"
th:class="${block.data['width' + i] != null and block.data['width' + i] > 0} ? ('col-lg-' + block.data['width' + i] + ' col-md-6 col-12 mb-4') : (${block.data['cols'] == 1} ? 'col-12 mb-4' : (${block.data['cols'] == 2} ? 'col-md-6 col-12 mb-4' : (${block.data['cols'] == 3} ? 'col-lg-4 col-md-6 col-12 mb-4' : (${block.data['cols'] == 4} ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))"
th:classappend="${block.data['class' + i] != null and !#strings.isEmpty(block.data['class' + i])} ? ${block.data['class' + i]} : ''"
>
<div th:replace=":: renderCell(type=${block.data['type' + i]}, i=${i}, data=${block.data})"></div>
</div>
</th:block>
</div>
</div>
</th:block>
<!-- 16. Flex Layout Block -->
<th:block th:if="${block['type'] == 'flex'}">
<div class="container my-4">
<div
style="display: flex; flex-wrap: wrap;"
th:styleappend="'flex-direction: ' + ${block.data['direction']} + '; justify-content: ' + (${block.data['justify'] == 'start' ? 'flex-start' : (block.data['justify'] == 'end' ? 'flex-end' : (block.data['justify'] == 'between' ? 'space-between' : (block.data['justify'] == 'around' ? 'space-around' : (block.data['justify'] == 'evenly' ? 'space-evenly' : 'center'))))}) + '; align-items: ' + (${block.data['align'] == 'start' ? 'flex-start' : (block.data['align'] == 'end' ? 'flex-end' : block.data['align'])} ) + '; gap: ' + (${block.data['gap'] == '0' ? '0' : (block.data['gap'] == '1' ? '0.25rem' : (block.data['gap'] == '2' ? '0.5rem' : (block.data['gap'] == '3' ? '1rem' : (block.data['gap'] == '4' ? '1.5rem' : '2rem'))))}) + ';'"
>
<th:block th:each="i : ${#numbers.sequence(1, block.data['cols'])}">
<div
th:id="${block.data['id' + i] != null and !#strings.isEmpty(block.data['id' + i])} ? ${block.data['id' + i]} : null"
th:class="${block.data['width' + i] != null and block.data['width' + i] > 0} ? ('col-lg-' + block.data['width' + i] + ' col-md-6 col-12 mb-4') : (${block.data['cols'] == 1} ? 'col-12 mb-4' : (${block.data['cols'] == 2} ? 'col-md-6 col-12 mb-4' : (${block.data['cols'] == 3} ? 'col-lg-4 col-md-6 col-12 mb-4' : (${block.data['cols'] == 4} ? 'col-lg-3 col-md-6 col-12 mb-4' : 'col-lg col-md-6 col-12 mb-4'))))"
th:classappend="${block.data['class' + i] != null and !#strings.isEmpty(block.data['class' + i])} ? ${block.data['class' + i]} : ''"
>
<div th:replace=":: renderCell(type=${block.data['type' + i]}, i=${i}, data=${block.data})"></div>
</div>
</th:block>
</div>
</div>
</th:block>
<!-- Shared Cell Content Renderer Fragment -->
<th:block th:fragment="renderCell(type, i, data)">
<!-- Type 1: HTML / Text -->
<th:block th:if="${type == null or type == 'html'}" th:utext="${data['col' + i]}"></th:block>
<!-- Type 2: Image -->
<th:block th:if="${type == 'image'}">
<div class="sis-grid-image-wrapper text-center">
<img th:if="${data['imgUrl' + i] != null and !#strings.isEmpty(data['imgUrl' + i])}" th:src="${data['imgUrl' + i]}" class="img-fluid rounded shadow-sm" alt="Grid Image" />
</div>
</th:block>
<!-- Type 3: YouTube Video -->
<th:block th:if="${type == 'youtube'}">
<div class="sis-youtube-container text-center">
<div
class="sis-yt-thumb-wrapper"
th:data-embed-url="${data['ytUrl' + i]}"
style="
position: relative;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
border-radius: 8px;
background-color: #111;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
margin: 0 auto;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
"
th:styleappend="${(data['ytUrl' + i] != null and #strings.contains(data['ytUrl' + i], 'youtu.be/')) ? 'background-image: url(' + 'https://img.youtube.com/vi/' + #strings.substringAfter(data['ytUrl' + i], 'youtu.be/') + '/hqdefault.jpg' + ');' : ((data['ytUrl' + i] != null and #strings.contains(data['ytUrl' + i], 'v=')) ? 'background-image: url(' + 'https://img.youtube.com/vi/' + #strings.substringBefore(#strings.substringAfter(data['ytUrl' + i], 'v='), '&') + '/hqdefault.jpg' + ');' : ((data['ytUrl' + i] != null and #strings.contains(data['ytUrl' + i], 'embed/')) ? 'background-image: url(' + 'https://img.youtube.com/vi/' + #strings.substringBefore(#strings.substringAfter(data['ytUrl' + i], 'embed/'), '?') + '/hqdefault.jpg' + ');' : ''))}"
>
<div class="sis-yt-play-btn" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); transition: transform 0.2s ease">
<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="#f00"></path>
<polygon points="26,12 26,36 48,24" fill="#fff"></polygon>
</svg>
</div>
</div>
</div>
</th:block>
<!-- Type 4: Accordion -->
<th:block th:if="${type == 'accordion'}">
<details class="sis-accordion-item mb-3">
<summary class="sis-accordion-title font-weight-bold p-3 bg-light border rounded" style="cursor: pointer; user-select: none">
<span th:utext="${data['accTitle' + i]}">Accordion Title</span>
</summary>
<div class="sis-accordion-body p-3 border border-top-0 rounded-bottom" th:utext="${data['accContent' + i]}"></div>
</details>
</th:block>
<!-- Nested Plugin: html-snippet -->
<th:block th:if="${type == 'html-snippet' and data['col' + i] != null and data['col' + i]['id'] != null}">
<div th:replace="~{fragments/snippets :: snippet(id=${data['col' + i]['id']})}"></div>
</th:block>
<!-- Nested Plugin: timeline -->
<th:block th:if="${type == 'timeline' and data['col' + i] != null and data['col' + i]['items'] != null}">
<section class="sis-timeline">
<div class="sis-timeline__grid">
<div class="sis-timeline__text-col" style="width: 100%;">
<div class="sis-timeline__line"></div>
<th:block th:each="item : ${data['col' + i]['items']}">
<div class="sis-timeline__entry">
<div class="sis-timeline__dot"></div>
<div class="sis-timeline__entry-content">
<div class="sis-timeline__date" th:utext="${item['date']}"></div>
<div class="sis-timeline__desc" th:utext="${item['description']}"></div>
<div class="sis-timeline__mobile-img" th:if="${item['imageUrl'] != null and !#strings.isEmpty(item['imageUrl'])}">
<img th:src="${item['imageUrl']}" alt="Timeline Image" loading="lazy" />
</div>
</div>
</div>
</th:block>
</div>
</div>
</section>
</th:block>
</th:block>
</th:block>
<style>
html {