feat: integrate CKEditor layout plugins and add supporting import utilities
This commit is contained in:
@@ -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