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
@@ -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 {