feat: integrate CKEditor layout plugins and add supporting import utilities
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user