@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.
@@ -2,7 +2,7 @@
2
2
  * vigilkids 产品专属 DOM 交互
3
3
  *
4
4
  * 使用场景:
5
- * - admin preview-host:走 Vue 组件渲染路径,不调用本模块
5
+ * - admin preview-host:预览渲染完成后激活 DOM 交互
6
6
  * - marketing 站点(Nuxt):innerHTML 直出 HTML 后 onMounted 激活交互
7
7
  *
8
8
  * 设计要点:
@@ -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(({ default: EmblaCarousel }) => EmblaCarousel);
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, viewport, binding) {
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, viewport, binding) {
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 cardCount = el.querySelectorAll(".recommend-card").length;
71
- resetCarouselState(el, viewport, binding);
72
- if (!shouldActivateCarousel(cardCount, isMobile))
73
- return;
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
- return;
77
- const currentCardCount = el.querySelectorAll(".recommend-card").length;
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
- return;
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
- return;
150
- const viewport = el.querySelector(".recommend-grid");
151
- if (!viewport)
152
- return;
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, viewport, binding);
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
- return;
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('[data-component="related-articles-carousel"]');
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, viewport, binding);
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 loadedInteractions = /* @__PURE__ */ new Set();
19
+ const productInteractionModules = /* @__PURE__ */ new Map();
21
20
  const productInteractionLoaders = {
22
- vigilkids: () => import("../interactions/vigilkids.mjs").then((m) => m.setupInteractions())
21
+ vigilkids: () => import("../interactions/vigilkids.mjs")
23
22
  };
24
- async function loadProductInteractions(productCode) {
25
- if (loadedInteractions.has(productCode))
26
- return;
23
+ async function loadProductInteractionModule(productCode) {
27
24
  const loader = productInteractionLoaders[productCode];
28
- if (loader) {
29
- await loader();
30
- loadedInteractions.add(productCode);
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
- const product = currentProduct.value || "vigilkids";
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
- loadProductInteractions(data.productCode);
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("div", {
108
- class: `article-content article-content--${currentProduct.value || "vigilkids"}`
109
- }, [h(SectionRenderer, {
110
- sectionsData: sectionsData.value,
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
- onBlockSettingUpdate: (sectionId, blockId, key, value) => {
123
- options.bridge.send({
124
- type: "inline-edit:block-update",
125
- payload: { sectionId, blockId, key, value }
126
- });
127
- },
128
- onInlineEditStart: (sectionId, key) => {
129
- inlineEditingField.value = { sectionId, key };
130
- options.bridge.send({ type: "inline-edit:start", payload: { sectionId, key } });
131
- },
132
- onInlineEditEnd: () => {
133
- inlineEditingField.value = null;
134
- options.bridge.send({ type: "inline-edit:end" });
135
- },
136
- onInlineEditUndoRedo: (action) => {
137
- options.bridge.send({ type: action === "undo" ? "shortcut:undo" : "shortcut:redo" });
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 class="article-custom-html article-content" :class="[productClass]" v-html="htmlContent" @click="handleContentClick" />
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="article-image">
85
+ <figure :class="figureClass">
38
86
  <img
39
- v-if="s.src"
40
- :alt="String(s.alt || '')"
41
- :src="safeUrl(String(s.src))"
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
- <figcaption v-if="s.caption" class="article-image__caption" v-bind="editableAttrs('caption')">
46
- {{ s.caption }}
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 v-if="s.title" class="article-recommend-title" v-bind="editableAttrs('title')" v-html="s.title" />
63
- <div class="recommend-grid">
64
- <a
65
- v-for="(item, i) in items"
66
- :key="item.id"
67
- :href="item.href"
68
- class="recommend-card"
69
- >
70
- <div class="recommend-card-image">
71
- <img
72
- :src="item.imageURL"
73
- :alt="item.imageAlt"
74
- >
75
- </div>
76
- <!-- eslint-disable-next-line vue/no-v-html -->
77
- <p class="recommend-card-title" v-html="item.title" />
78
- <!-- eslint-disable-next-line vue/no-v-html -->
79
- <p class="recommend-card-desc" v-html="item.description" />
80
- <span class="sr-only">{{ i + 1 }}</span>
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