@vigilkids/section-renderer-vue 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/interactions/vigilkids.d.ts +1 -1
- package/dist/interactions/vigilkids.mjs +44 -52
- package/dist/preview/createPreviewApp.mjs +90 -50
- package/dist/sections/article/shared/ArticleCustomHtml.vue +42 -2
- package/dist/sections/article/shared/ArticleImage.vue +59 -13
- package/dist/sections/article/vigilkids/ArticleRelatedArticles.vue +22 -23
- package/dist/sections/article/vigilkids/ArticleSubheading.vue +3 -1
- package/dist/sections/article/vigilkids/product-card/ProductCardCtaGroup.vue +12 -5
- package/dist/sections/article/visiva/ArticleSubheading.vue +6 -4
- package/dist/styles/products/vigilkids.css +1 -1
- package/dist/styles/products/visiva.css +1 -1
- package/dist/styles/utilities.css +1 -1
- package/dist/utils/article-anchor.d.ts +1 -0
- package/dist/utils/article-anchor.mjs +14 -0
- package/dist/utils/article-image.d.ts +9 -0
- package/dist/utils/article-image.mjs +45 -0
- package/package.json +1 -1
|
@@ -4,27 +4,26 @@ let emblaCarouselPromise = null;
|
|
|
4
4
|
let faqDelegateBound = false;
|
|
5
5
|
function loadEmblaCarousel() {
|
|
6
6
|
if (!emblaCarouselPromise)
|
|
7
|
-
emblaCarouselPromise = import("embla-carousel").then(
|
|
7
|
+
emblaCarouselPromise = import("embla-carousel").then(
|
|
8
|
+
({ default: EmblaCarousel }) => EmblaCarousel
|
|
9
|
+
);
|
|
8
10
|
return emblaCarouselPromise;
|
|
9
11
|
}
|
|
10
12
|
function addMediaQueryListener(mediaQuery, handler) {
|
|
11
13
|
if ("addEventListener" in mediaQuery)
|
|
12
14
|
mediaQuery.addEventListener("change", handler);
|
|
13
|
-
else
|
|
14
|
-
mediaQuery.addListener(handler);
|
|
15
|
+
else mediaQuery.addListener(handler);
|
|
15
16
|
}
|
|
16
17
|
function removeMediaQueryListener(mediaQuery, handler) {
|
|
17
18
|
if ("removeEventListener" in mediaQuery)
|
|
18
19
|
mediaQuery.removeEventListener("change", handler);
|
|
19
|
-
else
|
|
20
|
-
mediaQuery.removeListener(handler);
|
|
20
|
+
else mediaQuery.removeListener(handler);
|
|
21
21
|
}
|
|
22
22
|
function shouldActivateCarousel(cardCount, isMobile) {
|
|
23
23
|
return isMobile ? cardCount > 1 : cardCount > 3;
|
|
24
24
|
}
|
|
25
25
|
function clearDotHandlers(binding) {
|
|
26
|
-
if (!binding)
|
|
27
|
-
return;
|
|
26
|
+
if (!binding) return;
|
|
28
27
|
binding.dotHandlers.forEach((handler, dot) => {
|
|
29
28
|
dot.removeEventListener("click", handler);
|
|
30
29
|
});
|
|
@@ -33,14 +32,13 @@ function clearDotHandlers(binding) {
|
|
|
33
32
|
function setCarouselActive(el, active) {
|
|
34
33
|
el.dataset.carouselActive = active ? "1" : "0";
|
|
35
34
|
}
|
|
36
|
-
function resetCarouselState(el,
|
|
35
|
+
function resetCarouselState(el, binding) {
|
|
37
36
|
const embla = emblaInstances.get(el);
|
|
38
37
|
if (embla) {
|
|
39
38
|
embla.destroy();
|
|
40
39
|
emblaInstances.delete(el);
|
|
41
40
|
}
|
|
42
41
|
clearDotHandlers(binding);
|
|
43
|
-
viewport.classList.remove("recommend-slider-ready");
|
|
44
42
|
setCarouselActive(el, false);
|
|
45
43
|
el.querySelectorAll(".recommend-card").forEach((card) => {
|
|
46
44
|
card.classList.remove("is-active");
|
|
@@ -63,21 +61,27 @@ function syncCarouselState(el, embla, snapCount) {
|
|
|
63
61
|
card.classList.toggle("is-active", index === activeIndex);
|
|
64
62
|
});
|
|
65
63
|
}
|
|
66
|
-
function refreshCarousel(el,
|
|
64
|
+
function refreshCarousel(el, container, binding) {
|
|
67
65
|
binding.revision += 1;
|
|
68
66
|
const revision = binding.revision;
|
|
69
67
|
const isMobile = binding.mediaQuery.matches;
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
const viewport = el.querySelector(".recommend-viewport");
|
|
69
|
+
if (!viewport) return;
|
|
70
|
+
const slides = Array.from(container.children).filter(
|
|
71
|
+
(node) => node instanceof HTMLElement && node.classList.contains("recommend-card")
|
|
72
|
+
);
|
|
73
|
+
const cardCount = slides.length;
|
|
74
|
+
resetCarouselState(el, binding);
|
|
75
|
+
if (!shouldActivateCarousel(cardCount, isMobile)) return;
|
|
74
76
|
void loadEmblaCarousel().then((EmblaCarousel) => {
|
|
75
|
-
if (carouselBindings.get(el) !== binding || binding.revision !== revision)
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
if (carouselBindings.get(el) !== binding || binding.revision !== revision) return;
|
|
78
|
+
const currentSlides = Array.from(container.children).filter(
|
|
79
|
+
(node) => node instanceof HTMLElement && node.classList.contains("recommend-card")
|
|
80
|
+
);
|
|
81
|
+
const currentCardCount = currentSlides.length;
|
|
78
82
|
const currentIsMobile = binding.mediaQuery.matches;
|
|
79
|
-
if (!shouldActivateCarousel(currentCardCount, currentIsMobile))
|
|
80
|
-
|
|
83
|
+
if (!shouldActivateCarousel(currentCardCount, currentIsMobile)) return;
|
|
84
|
+
setCarouselActive(el, true);
|
|
81
85
|
const embla = EmblaCarousel(viewport, {
|
|
82
86
|
loop: false,
|
|
83
87
|
align: "start",
|
|
@@ -86,18 +90,16 @@ function refreshCarousel(el, viewport, binding) {
|
|
|
86
90
|
const snapCount = embla.scrollSnapList().length;
|
|
87
91
|
if (snapCount < 2) {
|
|
88
92
|
embla.destroy();
|
|
93
|
+
setCarouselActive(el, false);
|
|
89
94
|
return;
|
|
90
95
|
}
|
|
91
96
|
emblaInstances.set(el, embla);
|
|
92
|
-
viewport.classList.add("recommend-slider-ready");
|
|
93
|
-
setCarouselActive(el, true);
|
|
94
97
|
const dots = el.querySelectorAll(".recommend-dot");
|
|
95
98
|
dots.forEach((dot, index) => {
|
|
96
99
|
const visible = index < snapCount;
|
|
97
100
|
dot.hidden = !visible;
|
|
98
101
|
dot.classList.toggle("active", visible && index === 0);
|
|
99
|
-
if (!visible)
|
|
100
|
-
return;
|
|
102
|
+
if (!visible) return;
|
|
101
103
|
const clickHandler = () => {
|
|
102
104
|
embla.scrollTo(index);
|
|
103
105
|
};
|
|
@@ -114,16 +116,13 @@ function refreshCarousel(el, viewport, binding) {
|
|
|
114
116
|
});
|
|
115
117
|
}
|
|
116
118
|
function setupFaqAccordion() {
|
|
117
|
-
if (faqDelegateBound)
|
|
118
|
-
return;
|
|
119
|
+
if (faqDelegateBound) return;
|
|
119
120
|
faqDelegateBound = true;
|
|
120
121
|
document.addEventListener("click", (e) => {
|
|
121
122
|
const faqTitle = e.target.closest?.(".faq-item-title");
|
|
122
|
-
if (!faqTitle)
|
|
123
|
-
return;
|
|
123
|
+
if (!faqTitle) return;
|
|
124
124
|
const faqItem = faqTitle.closest(".faq-item");
|
|
125
|
-
if (!faqItem)
|
|
126
|
-
return;
|
|
125
|
+
if (!faqItem) return;
|
|
127
126
|
const faqList = faqItem.closest(".faq-list");
|
|
128
127
|
const isActive = faqItem.classList.contains("active");
|
|
129
128
|
if (faqList) {
|
|
@@ -131,29 +130,27 @@ function setupFaqAccordion() {
|
|
|
131
130
|
if (item !== faqItem) {
|
|
132
131
|
item.classList.remove("active");
|
|
133
132
|
const body2 = item.querySelector(".faq-item-answer");
|
|
134
|
-
if (body2)
|
|
135
|
-
body2.style.maxHeight = "0";
|
|
133
|
+
if (body2) body2.style.maxHeight = "0";
|
|
136
134
|
}
|
|
137
135
|
});
|
|
138
136
|
}
|
|
139
137
|
faqItem.classList.toggle("active", !isActive);
|
|
140
138
|
const body = faqItem.querySelector(".faq-item-answer");
|
|
141
|
-
if (body)
|
|
142
|
-
body.style.maxHeight = isActive ? "0" : `${body.scrollHeight}px`;
|
|
139
|
+
if (body) body.style.maxHeight = isActive ? "0" : `${body.scrollHeight}px`;
|
|
143
140
|
});
|
|
144
141
|
}
|
|
145
142
|
export function setupCarousel(root = document) {
|
|
146
143
|
const els = root.querySelectorAll('[data-component="related-articles-carousel"]');
|
|
147
144
|
els.forEach((el) => {
|
|
148
|
-
if (carouselBindings.has(el))
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
145
|
+
if (carouselBindings.has(el)) return;
|
|
146
|
+
const viewport = el.querySelector(".recommend-viewport");
|
|
147
|
+
if (!viewport) return;
|
|
148
|
+
const container = el.querySelector(".recommend-grid");
|
|
149
|
+
if (!container) return;
|
|
153
150
|
const mediaQuery = window.matchMedia("(max-width: 1024px)");
|
|
154
151
|
let binding;
|
|
155
152
|
const mediaQueryHandler = () => {
|
|
156
|
-
refreshCarousel(el,
|
|
153
|
+
refreshCarousel(el, container, binding);
|
|
157
154
|
};
|
|
158
155
|
binding = {
|
|
159
156
|
dotHandlers: /* @__PURE__ */ new Map(),
|
|
@@ -170,18 +167,15 @@ export function setupRetentionBanner(root = document) {
|
|
|
170
167
|
const els = root.querySelectorAll('[data-component="retention-banner"]');
|
|
171
168
|
els.forEach((el) => {
|
|
172
169
|
const key = el.dataset.dismissKey;
|
|
173
|
-
if (!key)
|
|
174
|
-
return;
|
|
170
|
+
if (!key) return;
|
|
175
171
|
const storageKey = `retention-banner-dismissed:${key}`;
|
|
176
172
|
if (localStorage.getItem(storageKey)) {
|
|
177
173
|
el.style.display = "none";
|
|
178
174
|
return;
|
|
179
175
|
}
|
|
180
176
|
const closeBtn = el.querySelector(".retention-banner-close");
|
|
181
|
-
if (!closeBtn)
|
|
182
|
-
|
|
183
|
-
if (closeBtn.dataset.bound === "1")
|
|
184
|
-
return;
|
|
177
|
+
if (!closeBtn) return;
|
|
178
|
+
if (closeBtn.dataset.bound === "1") return;
|
|
185
179
|
closeBtn.dataset.bound = "1";
|
|
186
180
|
closeBtn.addEventListener("click", () => {
|
|
187
181
|
localStorage.setItem(storageKey, "1");
|
|
@@ -195,24 +189,22 @@ export function setupInteractions(root = document) {
|
|
|
195
189
|
setupRetentionBanner(root);
|
|
196
190
|
}
|
|
197
191
|
export function destroyInteractions(root = document) {
|
|
198
|
-
const carousels = root.querySelectorAll(
|
|
192
|
+
const carousels = root.querySelectorAll(
|
|
193
|
+
'[data-component="related-articles-carousel"]'
|
|
194
|
+
);
|
|
199
195
|
carousels.forEach((el) => {
|
|
200
|
-
const viewport = el.querySelector(".recommend-grid");
|
|
201
|
-
if (!viewport)
|
|
202
|
-
return;
|
|
203
196
|
const binding = carouselBindings.get(el);
|
|
204
197
|
if (binding) {
|
|
205
198
|
binding.revision += 1;
|
|
206
199
|
removeMediaQueryListener(binding.mediaQuery, binding.mediaQueryHandler);
|
|
207
200
|
carouselBindings.delete(el);
|
|
208
201
|
}
|
|
209
|
-
resetCarouselState(el,
|
|
202
|
+
resetCarouselState(el, binding);
|
|
210
203
|
});
|
|
211
204
|
const banners = root.querySelectorAll('[data-component="retention-banner"]');
|
|
212
205
|
banners.forEach((el) => {
|
|
213
206
|
el.style.display = "";
|
|
214
207
|
const closeBtn = el.querySelector(".retention-banner-close");
|
|
215
|
-
if (closeBtn)
|
|
216
|
-
delete closeBtn.dataset.bound;
|
|
208
|
+
if (closeBtn) delete closeBtn.dataset.bound;
|
|
217
209
|
});
|
|
218
210
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { detectUndoRedo, isEditableTarget, isModKey } from "@vigilkids/section-core";
|
|
2
|
-
import { createApp, h, provide, ref } from "vue";
|
|
2
|
+
import { createApp, h, nextTick, provide, ref } from "vue";
|
|
3
3
|
import { resolveContentVariables } from "../utils/content-variables.mjs";
|
|
4
4
|
import { SectionRendererPlugin } from "../plugin.mjs";
|
|
5
5
|
import SectionRenderer from "../renderer/SectionRenderer.vue";
|
|
@@ -9,26 +9,44 @@ const productCSSLoaders = {
|
|
|
9
9
|
visiva: () => import("../styles/products/visiva.css")
|
|
10
10
|
};
|
|
11
11
|
async function loadProductCSS(productCode) {
|
|
12
|
-
if (loadedProducts.has(productCode))
|
|
13
|
-
return;
|
|
12
|
+
if (loadedProducts.has(productCode)) return;
|
|
14
13
|
const loader = productCSSLoaders[productCode];
|
|
15
14
|
if (loader) {
|
|
16
15
|
await loader();
|
|
17
16
|
loadedProducts.add(productCode);
|
|
18
17
|
}
|
|
19
18
|
}
|
|
20
|
-
const
|
|
19
|
+
const productInteractionModules = /* @__PURE__ */ new Map();
|
|
21
20
|
const productInteractionLoaders = {
|
|
22
|
-
vigilkids: () => import("../interactions/vigilkids.mjs")
|
|
21
|
+
vigilkids: () => import("../interactions/vigilkids.mjs")
|
|
23
22
|
};
|
|
24
|
-
async function
|
|
25
|
-
if (loadedInteractions.has(productCode))
|
|
26
|
-
return;
|
|
23
|
+
async function loadProductInteractionModule(productCode) {
|
|
27
24
|
const loader = productInteractionLoaders[productCode];
|
|
28
|
-
if (loader)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
if (!loader) return null;
|
|
26
|
+
if (!productInteractionModules.has(productCode))
|
|
27
|
+
productInteractionModules.set(productCode, loader());
|
|
28
|
+
return await productInteractionModules.get(productCode);
|
|
29
|
+
}
|
|
30
|
+
function getPreviewRoot(selector) {
|
|
31
|
+
return document.querySelector(selector) ?? document;
|
|
32
|
+
}
|
|
33
|
+
async function refreshProductInteractions(selector, productCode) {
|
|
34
|
+
const module = await loadProductInteractionModule(productCode);
|
|
35
|
+
if (!module) return;
|
|
36
|
+
const root = getPreviewRoot(selector);
|
|
37
|
+
module.destroyInteractions?.(root);
|
|
38
|
+
await nextTick();
|
|
39
|
+
module.setupInteractions(root);
|
|
40
|
+
}
|
|
41
|
+
function createInteractionRefreshScheduler(selector, getProductCode) {
|
|
42
|
+
let timer = null;
|
|
43
|
+
return () => {
|
|
44
|
+
if (timer) clearTimeout(timer);
|
|
45
|
+
timer = setTimeout(() => {
|
|
46
|
+
timer = null;
|
|
47
|
+
void refreshProductInteractions(selector, getProductCode());
|
|
48
|
+
}, 0);
|
|
49
|
+
};
|
|
32
50
|
}
|
|
33
51
|
export function createPreviewApp(selector, options) {
|
|
34
52
|
const sectionsData = ref({
|
|
@@ -40,6 +58,10 @@ export function createPreviewApp(selector, options) {
|
|
|
40
58
|
const rawHtmlContent = ref(null);
|
|
41
59
|
const currentProduct = ref("");
|
|
42
60
|
const inlineEditingField = ref(null);
|
|
61
|
+
const scheduleInteractionRefresh = createInteractionRefreshScheduler(
|
|
62
|
+
selector,
|
|
63
|
+
() => currentProduct.value || "vigilkids"
|
|
64
|
+
);
|
|
43
65
|
const app = createApp({
|
|
44
66
|
setup() {
|
|
45
67
|
provide("currentProduct", currentProduct);
|
|
@@ -54,12 +76,12 @@ export function createPreviewApp(selector, options) {
|
|
|
54
76
|
}
|
|
55
77
|
}
|
|
56
78
|
sectionsData.value = data;
|
|
79
|
+
scheduleInteractionRefresh();
|
|
57
80
|
});
|
|
58
81
|
options.bridge.on("html:preview", (payload) => {
|
|
59
82
|
const data = payload;
|
|
60
83
|
rawHtmlContent.value = resolveContentVariables(data.html);
|
|
61
|
-
|
|
62
|
-
loadProductInteractions(product);
|
|
84
|
+
scheduleInteractionRefresh();
|
|
63
85
|
});
|
|
64
86
|
options.bridge.on("section:select", (payload) => {
|
|
65
87
|
const data = payload;
|
|
@@ -94,8 +116,8 @@ export function createPreviewApp(selector, options) {
|
|
|
94
116
|
}
|
|
95
117
|
if (data.productCode) {
|
|
96
118
|
currentProduct.value = data.productCode;
|
|
97
|
-
loadProductCSS(data.productCode);
|
|
98
|
-
|
|
119
|
+
void loadProductCSS(data.productCode);
|
|
120
|
+
scheduleInteractionRefresh();
|
|
99
121
|
}
|
|
100
122
|
});
|
|
101
123
|
function handleSectionClick(sectionId) {
|
|
@@ -104,47 +126,65 @@ export function createPreviewApp(selector, options) {
|
|
|
104
126
|
return () => rawHtmlContent.value !== null ? h("div", {
|
|
105
127
|
class: `article-content article-content--${currentProduct.value || "vigilkids"}`,
|
|
106
128
|
innerHTML: rawHtmlContent.value
|
|
107
|
-
}) : h(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
editorMode: true,
|
|
112
|
-
selectedSectionId: selectedSectionId.value,
|
|
113
|
-
allSectionsSelected: allSectionsSelected.value,
|
|
114
|
-
productCode: currentProduct.value || void 0,
|
|
115
|
-
onSectionClick: handleSectionClick,
|
|
116
|
-
onSettingUpdate: (sectionId, key, value) => {
|
|
117
|
-
options.bridge.send({
|
|
118
|
-
type: "inline-edit:update",
|
|
119
|
-
payload: { sectionId, key, value }
|
|
120
|
-
});
|
|
129
|
+
}) : h(
|
|
130
|
+
"div",
|
|
131
|
+
{
|
|
132
|
+
class: `article-content article-content--${currentProduct.value || "vigilkids"}`
|
|
121
133
|
},
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
[
|
|
135
|
+
h(SectionRenderer, {
|
|
136
|
+
sectionsData: sectionsData.value,
|
|
137
|
+
editorMode: true,
|
|
138
|
+
selectedSectionId: selectedSectionId.value,
|
|
139
|
+
allSectionsSelected: allSectionsSelected.value,
|
|
140
|
+
productCode: currentProduct.value || void 0,
|
|
141
|
+
onSectionClick: handleSectionClick,
|
|
142
|
+
onSettingUpdate: (sectionId, key, value) => {
|
|
143
|
+
options.bridge.send({
|
|
144
|
+
type: "inline-edit:update",
|
|
145
|
+
payload: { sectionId, key, value }
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
onBlockSettingUpdate: (sectionId, blockId, key, value) => {
|
|
149
|
+
options.bridge.send({
|
|
150
|
+
type: "inline-edit:block-update",
|
|
151
|
+
payload: { sectionId, blockId, key, value }
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
onInlineEditStart: (sectionId, key) => {
|
|
155
|
+
inlineEditingField.value = { sectionId, key };
|
|
156
|
+
options.bridge.send({ type: "inline-edit:start", payload: { sectionId, key } });
|
|
157
|
+
},
|
|
158
|
+
onInlineEditEnd: () => {
|
|
159
|
+
inlineEditingField.value = null;
|
|
160
|
+
options.bridge.send({ type: "inline-edit:end" });
|
|
161
|
+
},
|
|
162
|
+
onInlineEditUndoRedo: (action) => {
|
|
163
|
+
options.bridge.send({
|
|
164
|
+
type: action === "undo" ? "shortcut:undo" : "shortcut:redo"
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
]
|
|
169
|
+
);
|
|
140
170
|
}
|
|
141
171
|
});
|
|
142
172
|
app.use(SectionRendererPlugin);
|
|
143
173
|
app.mount(selector);
|
|
174
|
+
const previewRoot = getPreviewRoot(selector);
|
|
175
|
+
if (previewRoot instanceof HTMLElement) {
|
|
176
|
+
const observer = new MutationObserver((records) => {
|
|
177
|
+
const hasChildMutation = records.some((record) => record.type === "childList");
|
|
178
|
+
if (hasChildMutation) scheduleInteractionRefresh();
|
|
179
|
+
});
|
|
180
|
+
observer.observe(previewRoot, {
|
|
181
|
+
childList: true,
|
|
182
|
+
subtree: true
|
|
183
|
+
});
|
|
184
|
+
}
|
|
144
185
|
import("../interactions/common.mjs").then((m) => m.setupCommonInteractions());
|
|
145
186
|
document.addEventListener("keydown", (e) => {
|
|
146
|
-
if (isEditableTarget(e))
|
|
147
|
-
return;
|
|
187
|
+
if (isEditableTarget(e)) return;
|
|
148
188
|
const undoRedoAction = detectUndoRedo(e);
|
|
149
189
|
if (undoRedoAction) {
|
|
150
190
|
e.preventDefault();
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
import type { BlockData } from '@vigilkids/section-core'
|
|
3
3
|
import type { Ref } from 'vue'
|
|
4
4
|
|
|
5
|
-
import { computed, inject } from 'vue'
|
|
5
|
+
import { computed, inject, nextTick, onMounted, ref, watch } from 'vue'
|
|
6
6
|
|
|
7
7
|
import { renderContent } from '../prosemirror'
|
|
8
|
+
import {
|
|
9
|
+
applyArticleImageInlineStyle,
|
|
10
|
+
normalizeArticleImageDimension,
|
|
11
|
+
} from '../../../utils/article-image'
|
|
8
12
|
import { resolveContentVariables } from '../../../utils/content-variables'
|
|
9
13
|
|
|
10
14
|
const props = defineProps<{
|
|
@@ -27,6 +31,36 @@ const htmlContent = computed(() => {
|
|
|
27
31
|
return resolveContentVariables(renderContent(raw))
|
|
28
32
|
})
|
|
29
33
|
|
|
34
|
+
const contentRef = ref<HTMLElement | null>(null)
|
|
35
|
+
|
|
36
|
+
function normalizeSizedImages() {
|
|
37
|
+
const root = contentRef.value
|
|
38
|
+
if (!root) return
|
|
39
|
+
|
|
40
|
+
root
|
|
41
|
+
.querySelectorAll<HTMLImageElement>('img[width], img[height]')
|
|
42
|
+
.forEach((image) => {
|
|
43
|
+
if (image.classList.contains('hot-icon') || image.classList.contains('icon')) return
|
|
44
|
+
|
|
45
|
+
const width = normalizeArticleImageDimension(image.getAttribute('width'))
|
|
46
|
+
const height = normalizeArticleImageDimension(image.getAttribute('height'))
|
|
47
|
+
applyArticleImageInlineStyle(image, width, height)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function syncSizedImages() {
|
|
52
|
+
await nextTick()
|
|
53
|
+
normalizeSizedImages()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onMounted(() => {
|
|
57
|
+
void syncSizedImages()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
watch(htmlContent, () => {
|
|
61
|
+
void syncSizedImages()
|
|
62
|
+
}, { flush: 'post' })
|
|
63
|
+
|
|
30
64
|
// ── v-html 内容的交互处理 ──
|
|
31
65
|
// SectionWrapper 的 @click.stop 阻止事件冒泡到 document,
|
|
32
66
|
// document 级事件委托无法捕获 v-html 内的点击,
|
|
@@ -74,5 +108,11 @@ function handleFaqToggle(faqTitle: Element) {
|
|
|
74
108
|
</script>
|
|
75
109
|
|
|
76
110
|
<template>
|
|
77
|
-
<div
|
|
111
|
+
<div
|
|
112
|
+
ref="contentRef"
|
|
113
|
+
class="article-custom-html article-content"
|
|
114
|
+
:class="[productClass]"
|
|
115
|
+
v-html="htmlContent"
|
|
116
|
+
@click="handleContentClick"
|
|
117
|
+
/>
|
|
78
118
|
</template>
|
|
@@ -5,6 +5,10 @@ import { safeUrl } from '@vigilkids/section-core'
|
|
|
5
5
|
import { computed } from 'vue'
|
|
6
6
|
|
|
7
7
|
import { useInlineEdit } from '../../../composables/useInlineEdit'
|
|
8
|
+
import {
|
|
9
|
+
buildArticleImageInlineStyle,
|
|
10
|
+
normalizeArticleImageDimension,
|
|
11
|
+
} from '../../../utils/article-image'
|
|
8
12
|
|
|
9
13
|
const props = defineProps<{
|
|
10
14
|
blockOrder: string[]
|
|
@@ -23,31 +27,73 @@ const emit = defineEmits<{
|
|
|
23
27
|
|
|
24
28
|
const s = computed(() => props.settings)
|
|
25
29
|
|
|
30
|
+
const allowedAlignments = ['left', 'center', 'right'] as const
|
|
31
|
+
const allowedSizeModes = ['standard', 'wide', 'full', 'custom'] as const
|
|
32
|
+
const allowedFits = ['contain', 'cover'] as const
|
|
33
|
+
const allowedCornerRadii = ['none', 'sm', 'md', 'lg', 'xl'] as const
|
|
34
|
+
|
|
35
|
+
function normalizeOption<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
|
36
|
+
const normalized = typeof value === 'string' ? value : ''
|
|
37
|
+
return (allowed as readonly string[]).includes(normalized) ? (normalized as T) : fallback
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const alignment = computed(() => normalizeOption(s.value.alignment, allowedAlignments, 'center'))
|
|
41
|
+
const sizeMode = computed(() => normalizeOption(s.value.size_mode, allowedSizeModes, 'standard'))
|
|
42
|
+
const fit = computed(() => normalizeOption(s.value.fit, allowedFits, 'contain'))
|
|
43
|
+
const cornerRadius = computed(() =>
|
|
44
|
+
normalizeOption(s.value.corner_radius, allowedCornerRadii, 'lg'),
|
|
45
|
+
)
|
|
46
|
+
const src = computed(() => (typeof s.value.src === 'string' ? String(s.value.src).trim() : ''))
|
|
47
|
+
const alt = computed(() => (typeof s.value.alt === 'string' ? String(s.value.alt) : ''))
|
|
48
|
+
const caption = computed(() =>
|
|
49
|
+
typeof s.value.caption === 'string' ? String(s.value.caption).trim() : '',
|
|
50
|
+
)
|
|
51
|
+
const customWidth = computed(() =>
|
|
52
|
+
sizeMode.value === 'custom' ? normalizeArticleImageDimension(s.value.custom_width) : undefined,
|
|
53
|
+
)
|
|
54
|
+
const customHeight = computed(() =>
|
|
55
|
+
sizeMode.value === 'custom' ? normalizeArticleImageDimension(s.value.custom_height) : undefined,
|
|
56
|
+
)
|
|
57
|
+
const figureClass = computed(() => [
|
|
58
|
+
'article-image',
|
|
59
|
+
`article-image--align-${alignment.value}`,
|
|
60
|
+
`article-image--size-${sizeMode.value}`,
|
|
61
|
+
`article-image--fit-${fit.value}`,
|
|
62
|
+
`article-image--radius-${cornerRadius.value}`,
|
|
63
|
+
])
|
|
64
|
+
const imageAttrs = computed(() => ({
|
|
65
|
+
width: customWidth.value,
|
|
66
|
+
height: customHeight.value,
|
|
67
|
+
}))
|
|
68
|
+
const imageStyle = computed(() =>
|
|
69
|
+
sizeMode.value === 'custom'
|
|
70
|
+
? buildArticleImageInlineStyle(customWidth.value, customHeight.value)
|
|
71
|
+
: {},
|
|
72
|
+
)
|
|
73
|
+
|
|
26
74
|
const { editableAttrs } = useInlineEdit({
|
|
27
75
|
editorMode: () => !!props.editorMode,
|
|
28
76
|
onSettingUpdate: (key, value) => emit('update:setting', key, value),
|
|
29
77
|
onBlockSettingUpdate: (blockId, key, value) => emit('update:block-setting', blockId, key, value),
|
|
30
|
-
onEditStart: key => emit('inline-edit-start', key),
|
|
78
|
+
onEditStart: (key) => emit('inline-edit-start', key),
|
|
31
79
|
onEditEnd: () => emit('inline-edit-end'),
|
|
32
|
-
onUndoRedo: action => emit('undo-redo', action),
|
|
80
|
+
onUndoRedo: (action) => emit('undo-redo', action),
|
|
33
81
|
})
|
|
34
82
|
</script>
|
|
35
83
|
|
|
36
84
|
<template>
|
|
37
|
-
<figure class="
|
|
85
|
+
<figure :class="figureClass">
|
|
38
86
|
<img
|
|
39
|
-
v-if="
|
|
40
|
-
:alt="
|
|
41
|
-
:src="safeUrl(
|
|
87
|
+
v-if="src"
|
|
88
|
+
:alt="alt"
|
|
89
|
+
:src="safeUrl(src)"
|
|
90
|
+
:style="imageStyle"
|
|
42
91
|
class="article-image__img"
|
|
43
92
|
loading="lazy"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
v-bind="imageAttrs"
|
|
94
|
+
/>
|
|
95
|
+
<figcaption v-if="caption" class="article-image__caption" v-bind="editableAttrs('caption')">
|
|
96
|
+
{{ caption }}
|
|
47
97
|
</figcaption>
|
|
48
98
|
</figure>
|
|
49
99
|
</template>
|
|
50
|
-
|
|
51
|
-
<style scoped>
|
|
52
|
-
.article-image{text-align:center}.article-image__img{height:auto;margin:10px auto 30px;max-width:500px;-o-object-fit:cover;object-fit:cover;width:auto}.article-image__caption{color:#999;font-size:14px;margin-bottom:10px;margin-top:-20px;text-align:center}@media (max-width:515px){.article-image__img{max-width:100%;width:auto}}
|
|
53
|
-
</style>
|
|
@@ -24,7 +24,7 @@ const s = computed(() => props.settings)
|
|
|
24
24
|
|
|
25
25
|
const items = computed(() =>
|
|
26
26
|
props.blockOrder
|
|
27
|
-
.filter(id => props.blocks[id]?.type === 'related_item')
|
|
27
|
+
.filter((id) => props.blocks[id]?.type === 'related_item')
|
|
28
28
|
.map((id) => {
|
|
29
29
|
const block = props.blocks[id]!
|
|
30
30
|
const article = block.settings.article as ArticleReferenceSnapshot | null | undefined
|
|
@@ -50,35 +50,34 @@ const { editableAttrs } = useInlineEdit({
|
|
|
50
50
|
editorMode: () => !!props.editorMode,
|
|
51
51
|
onSettingUpdate: (key, value) => emit('update:setting', key, value),
|
|
52
52
|
onBlockSettingUpdate: (blockId, key, value) => emit('update:block-setting', blockId, key, value),
|
|
53
|
-
onEditStart: key => emit('inline-edit-start', key),
|
|
53
|
+
onEditStart: (key) => emit('inline-edit-start', key),
|
|
54
54
|
onEditEnd: () => emit('inline-edit-end'),
|
|
55
|
-
onUndoRedo: action => emit('undo-redo', action),
|
|
55
|
+
onUndoRedo: (action) => emit('undo-redo', action),
|
|
56
56
|
})
|
|
57
57
|
</script>
|
|
58
58
|
|
|
59
59
|
<template>
|
|
60
60
|
<section class="article-recommend" data-component="related-articles-carousel">
|
|
61
61
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
62
|
-
<h2
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
>
|
|
70
|
-
<
|
|
71
|
-
<
|
|
72
|
-
:src="item.imageURL"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
</a>
|
|
62
|
+
<h2
|
|
63
|
+
v-if="s.title"
|
|
64
|
+
class="article-recommend-title"
|
|
65
|
+
v-bind="editableAttrs('title')"
|
|
66
|
+
v-html="s.title"
|
|
67
|
+
/>
|
|
68
|
+
<div class="recommend-viewport">
|
|
69
|
+
<div class="recommend-grid">
|
|
70
|
+
<a v-for="(item, i) in items" :key="item.id" :href="item.href" class="recommend-card">
|
|
71
|
+
<div class="recommend-card-image">
|
|
72
|
+
<img :src="item.imageURL" :alt="item.imageAlt" />
|
|
73
|
+
</div>
|
|
74
|
+
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
75
|
+
<p class="recommend-card-title" v-html="item.title" />
|
|
76
|
+
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
77
|
+
<p class="recommend-card-desc" v-html="item.description" />
|
|
78
|
+
<span class="sr-only">{{ i + 1 }}</span>
|
|
79
|
+
</a>
|
|
80
|
+
</div>
|
|
82
81
|
</div>
|
|
83
82
|
<div v-if="showDots && items.length > 1" class="recommend-dots">
|
|
84
83
|
<span
|