@strapi/content-manager 5.45.1 → 5.46.1

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.
Files changed (145) hide show
  1. package/dist/admin/constants/hooks.js +5 -0
  2. package/dist/admin/constants/hooks.js.map +1 -1
  3. package/dist/admin/constants/hooks.mjs +5 -0
  4. package/dist/admin/constants/hooks.mjs.map +1 -1
  5. package/dist/admin/content-manager.js +26 -2
  6. package/dist/admin/content-manager.js.map +1 -1
  7. package/dist/admin/content-manager.mjs +26 -2
  8. package/dist/admin/content-manager.mjs.map +1 -1
  9. package/dist/admin/history/components/VersionInputRenderer.js +1 -1
  10. package/dist/admin/history/components/VersionInputRenderer.js.map +1 -1
  11. package/dist/admin/history/components/VersionInputRenderer.mjs +1 -1
  12. package/dist/admin/history/components/VersionInputRenderer.mjs.map +1 -1
  13. package/dist/admin/hooks/usePersistentQueryParams.js +4 -1
  14. package/dist/admin/hooks/usePersistentQueryParams.js.map +1 -1
  15. package/dist/admin/hooks/usePersistentQueryParams.mjs +4 -1
  16. package/dist/admin/hooks/usePersistentQueryParams.mjs.map +1 -1
  17. package/dist/admin/pages/ComponentConfigurationPage.js +2 -45
  18. package/dist/admin/pages/ComponentConfigurationPage.js.map +1 -1
  19. package/dist/admin/pages/ComponentConfigurationPage.mjs +3 -46
  20. package/dist/admin/pages/ComponentConfigurationPage.mjs.map +1 -1
  21. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/Blocks/Code.js +21 -4
  22. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/Blocks/Code.js.map +1 -1
  23. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/Blocks/Code.mjs +19 -2
  24. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/Blocks/Code.mjs.map +1 -1
  25. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/Blocks/Link.mjs +1 -1
  26. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.js +9 -6
  27. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.js.map +1 -1
  28. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.mjs +10 -7
  29. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.mjs.map +1 -1
  30. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.js +1 -34
  31. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.js.map +1 -1
  32. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.mjs +3 -35
  33. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.mjs.map +1 -1
  34. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.js +33 -18
  35. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.js.map +1 -1
  36. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.mjs +34 -19
  37. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.mjs.map +1 -1
  38. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/DefaultBlocksStore.js +22 -0
  39. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/DefaultBlocksStore.js.map +1 -0
  40. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/DefaultBlocksStore.mjs +20 -0
  41. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/DefaultBlocksStore.mjs.map +1 -0
  42. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/ComponentCategory.js +15 -4
  43. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/ComponentCategory.js.map +1 -1
  44. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/ComponentCategory.mjs +16 -5
  45. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/ComponentCategory.mjs.map +1 -1
  46. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/DynamicComponent.js +26 -4
  47. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/DynamicComponent.js.map +1 -1
  48. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/DynamicComponent.mjs +26 -4
  49. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/DynamicComponent.mjs.map +1 -1
  50. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/Field.js +31 -0
  51. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/Field.js.map +1 -1
  52. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/Field.mjs +31 -0
  53. package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/Field.mjs.map +1 -1
  54. package/dist/admin/pages/ListConfiguration/ListConfigurationPage.js +11 -3
  55. package/dist/admin/pages/ListConfiguration/ListConfigurationPage.js.map +1 -1
  56. package/dist/admin/pages/ListConfiguration/ListConfigurationPage.mjs +11 -3
  57. package/dist/admin/pages/ListConfiguration/ListConfigurationPage.mjs.map +1 -1
  58. package/dist/admin/pages/ListView/ListViewPage.js +1 -0
  59. package/dist/admin/pages/ListView/ListViewPage.js.map +1 -1
  60. package/dist/admin/pages/ListView/ListViewPage.mjs +1 -0
  61. package/dist/admin/pages/ListView/ListViewPage.mjs.map +1 -1
  62. package/dist/admin/pages/ListView/components/Filters.js +38 -4
  63. package/dist/admin/pages/ListView/components/Filters.js.map +1 -1
  64. package/dist/admin/pages/ListView/components/Filters.mjs +39 -5
  65. package/dist/admin/pages/ListView/components/Filters.mjs.map +1 -1
  66. package/dist/admin/pages/formatComponentConfigurationEditLayout.js +58 -0
  67. package/dist/admin/pages/formatComponentConfigurationEditLayout.js.map +1 -0
  68. package/dist/admin/pages/formatComponentConfigurationEditLayout.mjs +56 -0
  69. package/dist/admin/pages/formatComponentConfigurationEditLayout.mjs.map +1 -0
  70. package/dist/admin/preview/components/InputPopover.js +3 -0
  71. package/dist/admin/preview/components/InputPopover.js.map +1 -1
  72. package/dist/admin/preview/components/InputPopover.mjs +3 -0
  73. package/dist/admin/preview/components/InputPopover.mjs.map +1 -1
  74. package/dist/admin/preview/hooks/usePreviewInputManager.js +24 -0
  75. package/dist/admin/preview/hooks/usePreviewInputManager.js.map +1 -1
  76. package/dist/admin/preview/hooks/usePreviewInputManager.mjs +24 -0
  77. package/dist/admin/preview/hooks/usePreviewInputManager.mjs.map +1 -1
  78. package/dist/admin/preview/utils/previewScript.js +616 -78
  79. package/dist/admin/preview/utils/previewScript.js.map +1 -1
  80. package/dist/admin/preview/utils/previewScript.mjs +616 -78
  81. package/dist/admin/preview/utils/previewScript.mjs.map +1 -1
  82. package/dist/admin/src/constants/hooks.d.ts +23 -0
  83. package/dist/admin/src/content-manager.d.ts +26 -0
  84. package/dist/admin/src/exports.d.ts +2 -0
  85. package/dist/admin/src/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.d.ts +14 -8
  86. package/dist/admin/src/pages/EditView/components/FormInputs/BlocksInput/DefaultBlocksStore.d.ts +3 -0
  87. package/dist/admin/src/pages/EditView/components/FormInputs/DynamicZone/ComponentCard.d.ts +1 -1
  88. package/dist/admin/src/pages/EditView/components/FormInputs/DynamicZone/DynamicComponent.d.ts +11 -1
  89. package/dist/admin/src/pages/EditView/components/FormInputs/Relations/Relations.d.ts +9 -5
  90. package/dist/admin/src/pages/EditView/components/FormInputs/Wysiwyg/EditorLayout.d.ts +4 -2
  91. package/dist/admin/src/pages/EditView/components/FormInputs/Wysiwyg/WysiwygStyles.d.ts +38 -6
  92. package/dist/admin/src/pages/EditView/components/FormLayout.d.ts +27 -5
  93. package/dist/admin/src/pages/ListView/components/Filters.d.ts +3 -4
  94. package/dist/admin/src/pages/formatComponentConfigurationEditLayout.d.ts +15 -0
  95. package/dist/admin/translations/cs.json.js +0 -1
  96. package/dist/admin/translations/cs.json.js.map +1 -1
  97. package/dist/admin/translations/cs.json.mjs +0 -1
  98. package/dist/admin/translations/cs.json.mjs.map +1 -1
  99. package/dist/admin/translations/de.json.js +0 -1
  100. package/dist/admin/translations/de.json.js.map +1 -1
  101. package/dist/admin/translations/de.json.mjs +0 -1
  102. package/dist/admin/translations/de.json.mjs.map +1 -1
  103. package/dist/admin/translations/en.json.js +0 -1
  104. package/dist/admin/translations/en.json.js.map +1 -1
  105. package/dist/admin/translations/en.json.mjs +0 -1
  106. package/dist/admin/translations/en.json.mjs.map +1 -1
  107. package/dist/admin/translations/es.json.js +0 -1
  108. package/dist/admin/translations/es.json.js.map +1 -1
  109. package/dist/admin/translations/es.json.mjs +0 -1
  110. package/dist/admin/translations/es.json.mjs.map +1 -1
  111. package/dist/admin/translations/fr.json.js +0 -1
  112. package/dist/admin/translations/fr.json.js.map +1 -1
  113. package/dist/admin/translations/fr.json.mjs +0 -1
  114. package/dist/admin/translations/fr.json.mjs.map +1 -1
  115. package/dist/admin/translations/nl.json.js +0 -1
  116. package/dist/admin/translations/nl.json.js.map +1 -1
  117. package/dist/admin/translations/nl.json.mjs +0 -1
  118. package/dist/admin/translations/nl.json.mjs.map +1 -1
  119. package/dist/admin/translations/pl.json.js +0 -1
  120. package/dist/admin/translations/pl.json.js.map +1 -1
  121. package/dist/admin/translations/pl.json.mjs +0 -1
  122. package/dist/admin/translations/pl.json.mjs.map +1 -1
  123. package/dist/admin/translations/ru.json.js +0 -1
  124. package/dist/admin/translations/ru.json.js.map +1 -1
  125. package/dist/admin/translations/ru.json.mjs +0 -1
  126. package/dist/admin/translations/ru.json.mjs.map +1 -1
  127. package/dist/admin/translations/uk.json.js +0 -1
  128. package/dist/admin/translations/uk.json.js.map +1 -1
  129. package/dist/admin/translations/uk.json.mjs +0 -1
  130. package/dist/admin/translations/uk.json.mjs.map +1 -1
  131. package/dist/admin/translations/zh-Hans.json.js +0 -1
  132. package/dist/admin/translations/zh-Hans.json.js.map +1 -1
  133. package/dist/admin/translations/zh-Hans.json.mjs +0 -1
  134. package/dist/admin/translations/zh-Hans.json.mjs.map +1 -1
  135. package/dist/server/homepage/services/homepage.js +12 -8
  136. package/dist/server/homepage/services/homepage.js.map +1 -1
  137. package/dist/server/homepage/services/homepage.mjs +12 -8
  138. package/dist/server/homepage/services/homepage.mjs.map +1 -1
  139. package/dist/server/services/metrics.js +1 -5
  140. package/dist/server/services/metrics.js.map +1 -1
  141. package/dist/server/services/metrics.mjs +1 -5
  142. package/dist/server/services/metrics.mjs.map +1 -1
  143. package/dist/server/src/homepage/services/homepage.d.ts.map +1 -1
  144. package/dist/server/src/services/metrics.d.ts.map +1 -1
  145. package/package.json +7 -7
@@ -40,6 +40,138 @@ const previewScript = (config)=>{
40
40
  const getElementsByPath = (path)=>{
41
41
  return document.querySelectorAll(`[${SOURCE_ATTRIBUTE}*="path=${path}"]`);
42
42
  };
43
+ const isMediaElement = (element)=>{
44
+ return element.tagName === 'IMG' || element.tagName === 'VIDEO' || element.tagName === 'AUDIO';
45
+ };
46
+ /**
47
+ * When a media field's mime type changes (e.g. image -> video), we can't
48
+ * swap the rendered DOM tag in place — the host framework (e.g. React)
49
+ * owns the original element and throws on removeChild if we replaceChild
50
+ * it. Instead we leave the original where it is, hide it, and insert our
51
+ * own sibling element of the correct tag. The injection lives outside the
52
+ * host framework's virtual DOM, so reconciliation never touches it.
53
+ *
54
+ * On save the host framework re-renders with the saved data and unmounts
55
+ * the original, leaving our injection orphaned next to its replacement —
56
+ * which is what was producing duplicate previews. The childList observer
57
+ * below uses these maps to drop the injection whenever its associated
58
+ * original is removed.
59
+ */ const originalToInjection = new Map();
60
+ const injectionToOriginal = new Map();
61
+ const trackInjection = (original, injection)=>{
62
+ originalToInjection.set(original, injection);
63
+ injectionToOriginal.set(injection, original);
64
+ };
65
+ const untrackInjection = (injection)=>{
66
+ const original = injectionToOriginal.get(injection);
67
+ if (original) originalToInjection.delete(original);
68
+ injectionToOriginal.delete(injection);
69
+ };
70
+ /**
71
+ * When a media field is cleared, the original media element gets hidden and
72
+ * has no bounding box, so the highlight system stops drawing a clickable
73
+ * area for it. That leaves the user no way to re-add media from the preview.
74
+ * We insert a sized placeholder div carrying the original's source attribute
75
+ * so the highlight system keeps tracking the spot. The placeholder is
76
+ * tracked in the same injection maps as a real injection, so the next
77
+ * `setMediaElement` call (when the user picks new media) tears it down and
78
+ * restores/replaces the original element via the existing flow.
79
+ */ const PLACEHOLDER_ATTRIBUTE = 'data-strapi-media-placeholder';
80
+ /**
81
+ * For a multi-media field, growing the rendered DOM (e.g. 1 image → 2)
82
+ * needs a slot for the new item, but the host framework only re-renders
83
+ * the gallery on save. We synthesize the missing slots by cloning the
84
+ * last rendered item and marking each clone with this attribute so the
85
+ * ghost-sweep below can drop them once the framework eventually re-renders
86
+ * with the saved value.
87
+ */ const CLONE_ATTRIBUTE = 'data-strapi-media-clone';
88
+ /**
89
+ * Galleries commonly wrap each media element in its own layout cell
90
+ * (e.g. `<div class="grid"><div><img/></div><div><img/></div></div>`),
91
+ * so cloning just the IMG makes the new item stack inside the existing
92
+ * wrapper. We walk up to the closest ancestor whose parent uses a sibling
93
+ * layout (flex/grid) and clone that instead, marking the cloned wrapper
94
+ * so cleanup removes the wrapper rather than leaving an empty cell.
95
+ */ const CLONE_WRAPPER_ATTRIBUTE = 'data-strapi-media-clone-wrapper';
96
+ const createMediaPlaceholder = (sourceAttr, rect)=>{
97
+ const placeholder = document.createElement('div');
98
+ placeholder.setAttribute(PLACEHOLDER_ATTRIBUTE, '');
99
+ placeholder.setAttribute(SOURCE_ATTRIBUTE, sourceAttr);
100
+ const width = rect.width > 0 ? rect.width : 200;
101
+ const height = rect.height > 0 ? rect.height : 200;
102
+ placeholder.style.cssText = `
103
+ display: inline-block;
104
+ width: ${width}px;
105
+ height: ${height}px;
106
+ background-color: rgba(0, 0, 0, 0.04);
107
+ border: 2px dashed rgba(0, 0, 0, 0.15);
108
+ border-radius: 4px;
109
+ box-sizing: border-box;
110
+ vertical-align: top;
111
+ `;
112
+ return placeholder;
113
+ };
114
+ /**
115
+ * Form values for media fields carry the raw Strapi URL (often relative,
116
+ * e.g. "/uploads/foo.jpg"). The host framework typically renders the
117
+ * original element with an absolute URL (resolved against the Strapi
118
+ * backend, not the iframe origin). If we naively set the relative form
119
+ * value as src, the browser resolves it against the iframe origin and
120
+ * fails to load. Resolve against the original element's existing src
121
+ * origin so the swap targets the same backend.
122
+ */ const resolveMediaUrl = (originalEl, rawUrl)=>{
123
+ if (/^(?:[a-z]+:)?\/\//i.test(rawUrl) || rawUrl.startsWith('data:')) {
124
+ return rawUrl;
125
+ }
126
+ const currentSrc = originalEl.getAttribute('src');
127
+ if (!currentSrc) return rawUrl;
128
+ try {
129
+ const currentUrl = new URL(currentSrc, window.location.href);
130
+ return new URL(rawUrl, currentUrl.origin).href;
131
+ } catch {
132
+ return rawUrl;
133
+ }
134
+ };
135
+ /**
136
+ * Extract a recognizable media filename from a URL string. Works for
137
+ * direct backend URLs, relative paths, and proxy URLs that embed the
138
+ * real path in a query parameter (e.g. Next.js's "/_next/image?url=...").
139
+ * Used to detect whether the rendered element already shows the same
140
+ * asset as the form value, so we can skip src updates that would only
141
+ * substitute one URL representation for another.
142
+ */ const getMediaFilename = (raw)=>{
143
+ if (!raw) return '';
144
+ let decoded = raw;
145
+ try {
146
+ decoded = decodeURIComponent(raw);
147
+ } catch {}
148
+ const match = decoded.match(/([^/?#&=\s]+\.(?:jpg|jpeg|png|gif|webp|avif|svg|mp4|mov|webm|ogg|m4v|mp3|wav|m4a|aac|flac|opus))(?:[?#&]|$)/i);
149
+ return match ? match[1].toLowerCase() : '';
150
+ };
151
+ /**
152
+ * Get the field path to use for focusing a media field.
153
+ * - For IMG/VIDEO elements: the path was already normalized (stripped of .url) during stega decoding
154
+ * - For tracked injections (e.g., placeholder divs we insert when media is cleared): the source
155
+ * attribute was copied from the post-normalization media element, so it's already correct
156
+ * - For non-media elements with model=plugin::upload.file (e.g., caption text): strip the last
157
+ * segment to focus the parent media field (e.g., "hero.caption" -> "hero")
158
+ */ const getFieldPathForMedia = (sourceAttr, element)=>{
159
+ if (isMediaElement(element) || injectionToOriginal.has(element)) {
160
+ return sourceAttr;
161
+ }
162
+ // For non-media elements, check if it's a media asset field
163
+ const params = new URLSearchParams(sourceAttr);
164
+ if (params.get('model') === 'plugin::upload.file') {
165
+ const elementPath = params.get('path');
166
+ if (elementPath) {
167
+ // Strip the last segment (e.g., "hero.caption" -> "hero")
168
+ const parentPath = elementPath.split('.').slice(0, -1).join('.');
169
+ params.set('path', parentPath);
170
+ return params.toString();
171
+ }
172
+ }
173
+ return sourceAttr;
174
+ };
43
175
  /* -----------------------------------------------------------------------------------------------
44
176
  * Functionality pieces
45
177
  * ---------------------------------------------------------------------------------------------*/ const setupStegaDOMObserver = async ()=>{
@@ -50,6 +182,33 @@ const previewScript = (config)=>{
50
182
  // eslint-disable-next-line import/no-unresolved
51
183
  'https://cdn.jsdelivr.net/npm/@vercel/stega@0.1.2/+esm');
52
184
  const applyStegaToElement = (element)=>{
185
+ // Handle img and video tags - check src attribute for stega encoding
186
+ if (isMediaElement(element)) {
187
+ const src = element.getAttribute('src');
188
+ if (src) {
189
+ try {
190
+ const result = stegaDecode(src);
191
+ if (result && 'strapiSource' in result) {
192
+ // Parse the source and remove .url suffix to point to the media field
193
+ const sourceValue = result.strapiSource;
194
+ const pathMatch = sourceValue.match(/path=([^&]+)/);
195
+ if (pathMatch) {
196
+ const originalPath = pathMatch[1];
197
+ // Remove .url to get the media field path
198
+ const mediaPath = originalPath.replace(/\.url$/, '');
199
+ const newSource = sourceValue.replace(`path=${originalPath}`, `path=${mediaPath}`);
200
+ element.setAttribute(SOURCE_ATTRIBUTE, newSource);
201
+ }
202
+ }
203
+ // Clean the src attribute so the resource can load
204
+ const cleanedSrc = stegaClean(src);
205
+ if (cleanedSrc !== src) {
206
+ element.setAttribute('src', cleanedSrc);
207
+ }
208
+ } catch (error) {}
209
+ }
210
+ return;
211
+ }
53
212
  const directTextNodes = Array.from(element.childNodes).filter((node)=>node.nodeType === Node.TEXT_NODE);
54
213
  const directTextContent = directTextNodes.map((node)=>node.textContent || '').join('');
55
214
  if (directTextContent) {
@@ -94,12 +253,23 @@ const previewScript = (config)=>{
94
253
  if (mutation.type === 'characterData' && mutation.target.parentElement) {
95
254
  applyStegaToElement(mutation.target.parentElement);
96
255
  }
256
+ // Handle src attribute changes for img/video elements
257
+ if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
258
+ const target = mutation.target;
259
+ if (isMediaElement(target)) {
260
+ applyStegaToElement(target);
261
+ }
262
+ }
97
263
  });
98
264
  });
99
265
  observer.observe(document, {
100
266
  childList: true,
101
267
  subtree: true,
102
- characterData: true
268
+ characterData: true,
269
+ attributes: true,
270
+ attributeFilter: [
271
+ 'src'
272
+ ]
103
273
  });
104
274
  return observer;
105
275
  };
@@ -153,51 +323,99 @@ const previewScript = (config)=>{
153
323
  return overlay;
154
324
  };
155
325
  const createHighlightManager = (overlay)=>{
156
- const elementsToHighlight = new Map();
326
+ const groups = new Map();
327
+ const elementToGroupKey = new Map();
157
328
  const eventListeners = [];
158
329
  const focusedHighlights = [];
159
- const pendingClicks = new Map(); // number is timeout id
330
+ const pendingClicks = new Map();
160
331
  let focusedField = null;
161
- const drawHighlight = (target, highlight)=>{
162
- if (!highlight) return;
163
- const rect = target.getBoundingClientRect();
164
- highlight.style.width = `${rect.width + HIGHLIGHT_PADDING * 2}px`;
165
- highlight.style.height = `${rect.height + HIGHLIGHT_PADDING * 2}px`;
166
- highlight.style.transform = `translate(${rect.left - HIGHLIGHT_PADDING}px, ${rect.top - HIGHLIGHT_PADDING}px)`;
167
- };
168
- const updateAllHighlights = ()=>{
169
- elementsToHighlight.forEach((highlight, element)=>{
170
- drawHighlight(element, highlight);
332
+ const computeGroupRect = (group)=>{
333
+ let minLeft = Infinity;
334
+ let minTop = Infinity;
335
+ let maxRight = -Infinity;
336
+ let maxBottom = -Infinity;
337
+ let any = false;
338
+ group.elements.forEach((el)=>{
339
+ const r = el.getBoundingClientRect();
340
+ if (r.width === 0 && r.height === 0) return;
341
+ any = true;
342
+ if (r.left < minLeft) minLeft = r.left;
343
+ if (r.top < minTop) minTop = r.top;
344
+ if (r.right > maxRight) maxRight = r.right;
345
+ if (r.bottom > maxBottom) maxBottom = r.bottom;
171
346
  });
347
+ if (!any) return null;
348
+ return {
349
+ left: minLeft,
350
+ top: minTop,
351
+ width: maxRight - minLeft,
352
+ height: maxBottom - minTop
353
+ };
172
354
  };
173
- const createHighlightForElement = (element)=>{
174
- if (elementsToHighlight.has(element)) {
175
- // Already has a highlight
355
+ const drawGroup = (group)=>{
356
+ const rect = computeGroupRect(group);
357
+ if (!rect) {
358
+ group.highlight.style.display = 'none';
176
359
  return;
177
360
  }
361
+ group.highlight.style.display = '';
362
+ group.highlight.style.width = `${rect.width + HIGHLIGHT_PADDING * 2}px`;
363
+ group.highlight.style.height = `${rect.height + HIGHLIGHT_PADDING * 2}px`;
364
+ group.highlight.style.transform = `translate(${rect.left - HIGHLIGHT_PADDING}px, ${rect.top - HIGHLIGHT_PADDING}px)`;
365
+ };
366
+ const updateAllHighlights = ()=>{
367
+ groups.forEach(drawGroup);
368
+ };
369
+ /**
370
+ * Pick the underlying source element under the pointer so single-click
371
+ * redispatch hits the specific item the user clicked, even when the group
372
+ * highlight covers several elements (multi-media gallery).
373
+ */ const pickElementAtPoint = (group, x, y)=>{
374
+ for (const el of group.elements){
375
+ const r = el.getBoundingClientRect();
376
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
377
+ return el;
378
+ }
379
+ }
380
+ const first = group.elements.values().next().value;
381
+ return first ?? null;
382
+ };
383
+ const createGroup = (groupKey)=>{
178
384
  const highlight = document.createElement('div');
179
385
  highlight.className = 'strapi-highlight';
386
+ const group = {
387
+ highlight,
388
+ elements: new Set()
389
+ };
180
390
  const clickHandler = (event)=>{
181
- // Skip if this is a re-dispatched event from our delayed handler to avoid infinite loops
182
391
  if (event.__strapi_redispatched) {
183
392
  return;
184
393
  }
185
- // Prevent the immediate action for interactive elements
186
394
  event.preventDefault();
187
395
  event.stopPropagation();
188
- // Clear any existing timeout for this element
189
- const existingTimeout = pendingClicks.get(element);
396
+ const existingTimeout = pendingClicks.get(group);
190
397
  if (existingTimeout) {
191
398
  window.clearTimeout(existingTimeout);
192
- pendingClicks.delete(element);
399
+ pendingClicks.delete(group);
193
400
  }
194
- // Set up a delayed single-click handler
195
401
  const timeout = window.setTimeout(()=>{
196
- pendingClicks.delete(element);
197
- // Send single-click hint notification
402
+ pendingClicks.delete(group);
198
403
  sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_SINGLE_CLICK_HINT, null);
199
- // Re-trigger the click on the underlying element after the double-click timeout
200
- // Create a new event to dispatch with a marker to prevent re-handling
404
+ // Pick the specific underlying element under the pointer so the
405
+ // redispatched click targets the right item in a multi-element group
406
+ const underlying = pickElementAtPoint(group, event.clientX, event.clientY);
407
+ if (!underlying) return;
408
+ let targetElement = underlying;
409
+ if (isMediaElement(underlying)) {
410
+ let parent = underlying.parentElement;
411
+ while(parent){
412
+ if (parent.tagName === 'A' || parent.tagName === 'BUTTON' || parent.hasAttribute('onclick') || parent.getAttribute('role') === 'button' || parent.getAttribute('role') === 'link') {
413
+ targetElement = parent;
414
+ break;
415
+ }
416
+ parent = parent.parentElement;
417
+ }
418
+ }
201
419
  const newEvent = new MouseEvent('click', {
202
420
  bubbles: true,
203
421
  cancelable: true,
@@ -213,38 +431,38 @@ const previewScript = (config)=>{
213
431
  metaKey: event.metaKey
214
432
  });
215
433
  newEvent.__strapi_redispatched = true;
216
- element.dispatchEvent(newEvent);
434
+ targetElement.dispatchEvent(newEvent);
217
435
  }, DOUBLE_CLICK_TIMEOUT);
218
- pendingClicks.set(element, timeout);
436
+ pendingClicks.set(group, timeout);
219
437
  };
220
438
  const doubleClickHandler = (event)=>{
221
- // Prevent the default behavior on double-click
222
439
  event.preventDefault();
223
440
  event.stopPropagation();
224
- // Clear any pending single-click action
225
- const existingTimeout = pendingClicks.get(element);
441
+ const existingTimeout = pendingClicks.get(group);
226
442
  if (existingTimeout) {
227
443
  clearTimeout(existingTimeout);
228
- pendingClicks.delete(element);
229
- }
230
- const sourceAttribute = element.getAttribute(SOURCE_ATTRIBUTE);
231
- if (sourceAttribute) {
232
- const rect = element.getBoundingClientRect();
233
- sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_FOCUS_INTENT, {
234
- path: sourceAttribute,
235
- position: {
236
- top: rect.top,
237
- left: rect.left,
238
- right: rect.right,
239
- bottom: rect.bottom,
240
- width: rect.width,
241
- height: rect.height
242
- }
243
- });
444
+ pendingClicks.delete(group);
244
445
  }
446
+ const anchor = pickElementAtPoint(group, event.clientX, event.clientY);
447
+ if (!anchor) return;
448
+ const sourceAttribute = anchor.getAttribute(SOURCE_ATTRIBUTE);
449
+ if (!sourceAttribute) return;
450
+ const path = getFieldPathForMedia(sourceAttribute, anchor);
451
+ const rect = computeGroupRect(group);
452
+ if (!rect) return;
453
+ sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_FOCUS_INTENT, {
454
+ path,
455
+ position: {
456
+ top: rect.top,
457
+ left: rect.left,
458
+ right: rect.left + rect.width,
459
+ bottom: rect.top + rect.height,
460
+ width: rect.width,
461
+ height: rect.height
462
+ }
463
+ });
245
464
  };
246
465
  const mouseDownHandler = (event)=>{
247
- // Prevent default multi click to select behavior
248
466
  if (event.detail >= 2) {
249
467
  event.preventDefault();
250
468
  }
@@ -252,7 +470,6 @@ const previewScript = (config)=>{
252
470
  highlight.addEventListener('click', clickHandler);
253
471
  highlight.addEventListener('dblclick', doubleClickHandler);
254
472
  highlight.addEventListener('mousedown', mouseDownHandler);
255
- // Store event listeners for cleanup
256
473
  eventListeners.push({
257
474
  element: highlight,
258
475
  type: 'click',
@@ -266,28 +483,120 @@ const previewScript = (config)=>{
266
483
  type: 'mousedown',
267
484
  handler: mouseDownHandler
268
485
  });
269
- elementsToHighlight.set(element, highlight);
270
486
  overlay.appendChild(highlight);
271
- drawHighlight(element, highlight);
487
+ groups.set(groupKey, group);
488
+ return group;
272
489
  };
273
- const removeHighlightForElement = (element)=>{
274
- const highlight = elementsToHighlight.get(element);
275
- if (!highlight) return;
276
- // Clear any pending click timeout for this element
277
- const pendingTimeout = pendingClicks.get(element);
490
+ const destroyGroup = (groupKey, group)=>{
491
+ const pendingTimeout = pendingClicks.get(group);
278
492
  if (pendingTimeout) {
279
493
  window.clearTimeout(pendingTimeout);
280
- pendingClicks.delete(element);
494
+ pendingClicks.delete(group);
281
495
  }
282
- highlight.remove();
283
- elementsToHighlight.delete(element);
284
- // Remove event listeners for this highlight
285
- const listenersToRemove = eventListeners.filter((listener)=>listener.element === highlight);
496
+ const focusedIndex = focusedHighlights.indexOf(group.highlight);
497
+ if (focusedIndex !== -1) {
498
+ focusedHighlights.splice(focusedIndex, 1);
499
+ }
500
+ group.highlight.remove();
501
+ const listenersToRemove = eventListeners.filter((listener)=>listener.element === group.highlight);
286
502
  listenersToRemove.forEach(({ element, type, handler })=>{
287
503
  element.removeEventListener(type, handler);
288
504
  });
289
- // Mutate eventListeners to remove listeners for this highlight
290
- eventListeners.splice(0, eventListeners.length, ...eventListeners.filter((listener)=>listener.element !== highlight));
505
+ eventListeners.splice(0, eventListeners.length, ...eventListeners.filter((listener)=>listener.element !== group.highlight));
506
+ groups.delete(groupKey);
507
+ };
508
+ const createHighlightForElement = (element)=>{
509
+ if (elementToGroupKey.has(element)) {
510
+ return;
511
+ }
512
+ const groupKey = element.getAttribute(SOURCE_ATTRIBUTE);
513
+ if (!groupKey) return;
514
+ let group = groups.get(groupKey);
515
+ // When a framework-rendered media element joins a group that still
516
+ // holds stale ghosts from a previous clear (a standalone placeholder,
517
+ // our injection paired with a hidden placeholder, or a clone we
518
+ // synthesized to give an added array item a slot), sweep the ghosts
519
+ // so we don't end up with two visible elements stacked on top of
520
+ // each other after a save commits a real value.
521
+ if (group && isMediaElement(element) && !injectionToOriginal.has(element) && !element.hasAttribute(CLONE_ATTRIBUTE)) {
522
+ const ghostInjections = [];
523
+ const ghostPlaceholders = [];
524
+ const ghostClones = [];
525
+ group.elements.forEach((el)=>{
526
+ if (injectionToOriginal.has(el)) {
527
+ ghostInjections.push(el);
528
+ } else if (el.hasAttribute(CLONE_ATTRIBUTE)) {
529
+ ghostClones.push(el);
530
+ } else if (el.hasAttribute(PLACEHOLDER_ATTRIBUTE)) {
531
+ ghostPlaceholders.push(el);
532
+ }
533
+ });
534
+ ghostInjections.forEach((injection)=>{
535
+ const orig = injectionToOriginal.get(injection);
536
+ untrackInjection(injection);
537
+ if (orig?.hasAttribute(CLONE_ATTRIBUTE)) {
538
+ // Tag-swap injection of a clone (e.g. user added a video as
539
+ // the new item, swapping our cloned IMG for a VIDEO). Remove
540
+ // the wrapper so we don't leave an empty layout cell behind.
541
+ const wrapper = orig.closest(`[${CLONE_WRAPPER_ATTRIBUTE}]`);
542
+ (wrapper ?? orig).remove();
543
+ } else if (orig?.hasAttribute(PLACEHOLDER_ATTRIBUTE)) {
544
+ orig.remove();
545
+ }
546
+ group?.elements.delete(injection);
547
+ elementToGroupKey.delete(injection);
548
+ injection.remove();
549
+ });
550
+ ghostPlaceholders.forEach((placeholder)=>{
551
+ group?.elements.delete(placeholder);
552
+ elementToGroupKey.delete(placeholder);
553
+ placeholder.remove();
554
+ });
555
+ // Remove the wrapper if we cloned an ancestor for layout — falling
556
+ // back to the clone itself when it's its own root. Removing via
557
+ // .remove() also triggers the mutation observer's removedNodes
558
+ // branch, which untracks any injection paired with descendants.
559
+ ghostClones.forEach((clone)=>{
560
+ group?.elements.delete(clone);
561
+ elementToGroupKey.delete(clone);
562
+ const wrapper = clone.closest(`[${CLONE_WRAPPER_ATTRIBUTE}]`);
563
+ (wrapper ?? clone).remove();
564
+ });
565
+ }
566
+ if (!group) {
567
+ group = createGroup(groupKey);
568
+ }
569
+ group.elements.add(element);
570
+ elementToGroupKey.set(element, groupKey);
571
+ drawGroup(group);
572
+ };
573
+ const removeHighlightForElement = (element)=>{
574
+ const groupKey = elementToGroupKey.get(element);
575
+ if (!groupKey) return;
576
+ const group = groups.get(groupKey);
577
+ if (!group) return;
578
+ group.elements.delete(element);
579
+ elementToGroupKey.delete(element);
580
+ if (group.elements.size === 0) {
581
+ destroyGroup(groupKey, group);
582
+ } else {
583
+ drawGroup(group);
584
+ }
585
+ };
586
+ /**
587
+ * Resolve groups whose elements match a focused field path. We delegate
588
+ * to `getElementsByPath` so this preserves the same loose substring match
589
+ * the highlight system has always used (e.g. focusing `hero` matches
590
+ * `hero.caption` too).
591
+ */ const findGroupForPath = (path)=>{
592
+ const matched = new Set();
593
+ getElementsByPath(path).forEach((el)=>{
594
+ const key = elementToGroupKey.get(el);
595
+ if (!key) return;
596
+ const group = groups.get(key);
597
+ if (group) matched.add(group);
598
+ });
599
+ return Array.from(matched);
291
600
  };
292
601
  // Process all existing elements with source attributes
293
602
  const initialElements = window.document.querySelectorAll(`[${SOURCE_ATTRIBUTE}]`);
@@ -298,16 +607,17 @@ const previewScript = (config)=>{
298
607
  });
299
608
  return {
300
609
  get elements () {
301
- return Array.from(elementsToHighlight.keys());
610
+ return Array.from(elementToGroupKey.keys());
302
611
  },
303
- get highlights () {
304
- return Array.from(elementsToHighlight.values());
612
+ get groups () {
613
+ return Array.from(groups.values());
305
614
  },
306
615
  updateAllHighlights,
307
616
  eventListeners,
308
617
  focusedHighlights,
309
618
  createHighlightForElement,
310
619
  removeHighlightForElement,
620
+ findGroupForPath,
311
621
  setFocusedField: (field)=>{
312
622
  focusedField = field;
313
623
  },
@@ -408,6 +718,22 @@ const previewScript = (config)=>{
408
718
  if (node.nodeType === Node.ELEMENT_NODE) {
409
719
  const element = node;
410
720
  highlightManager.removeHighlightForElement(element);
721
+ // If a tracked media original (or its ancestor) was removed —
722
+ // typically when the host framework re-renders after save and
723
+ // unmounts the previous element — drop our matching injection
724
+ // so we don't end up with duplicate previews next to the new one.
725
+ // This applies to placeholders too: once the framework owns
726
+ // the layout for a cleared field (and likely renders its own
727
+ // empty state), our placeholder would just stack next to it.
728
+ originalToInjection.forEach((injection, original)=>{
729
+ if (element === original || element.contains(original)) {
730
+ untrackInjection(injection);
731
+ injection.remove();
732
+ } else if (element === injection || element.contains(injection)) {
733
+ // Our injection itself was removed (e.g. parent unmounted)
734
+ untrackInjection(injection);
735
+ }
736
+ });
411
737
  }
412
738
  });
413
739
  }
@@ -428,17 +754,230 @@ const previewScript = (config)=>{
428
754
  };
429
755
  };
430
756
  const setupEventHandlers = (highlightManager)=>{
757
+ const setMediaElement = (el, url, mime)=>{
758
+ const original = injectionToOriginal.get(el) ?? el;
759
+ const injection = originalToInjection.get(original) ?? null;
760
+ const removeInjection = ()=>{
761
+ if (!injection) return;
762
+ // Move the source attribute back to the original so highlights track it again
763
+ const sourceAttr = injection.getAttribute(SOURCE_ATTRIBUTE);
764
+ if (sourceAttr) original.setAttribute(SOURCE_ATTRIBUTE, sourceAttr);
765
+ untrackInjection(injection);
766
+ injection.remove();
767
+ };
768
+ if (!url) {
769
+ // Capture rect from whatever's currently visible before any DOM
770
+ // changes, so the placeholder can be sized to match.
771
+ const visibleEl = injection ?? original;
772
+ const rect = visibleEl.getBoundingClientRect();
773
+ // removeInjection moves the source attr back to original, so we read
774
+ // it after.
775
+ removeInjection();
776
+ const sourceAttr = original.getAttribute(SOURCE_ATTRIBUTE);
777
+ if (!sourceAttr) {
778
+ original.style.display = 'none';
779
+ return;
780
+ }
781
+ // Hide the host-framework-managed original. We deliberately keep
782
+ // its `src` attribute around so `resolveMediaUrl` can still derive
783
+ // the backend origin when the user later picks a new media file
784
+ // with a relative URL.
785
+ original.style.display = 'none';
786
+ // Move the source attr onto a placeholder div so the highlight
787
+ // system keeps tracking the spot and the user can click to re-add
788
+ // media.
789
+ original.removeAttribute(SOURCE_ATTRIBUTE);
790
+ const placeholder = createMediaPlaceholder(sourceAttr, rect);
791
+ original.parentNode?.insertBefore(placeholder, original.nextSibling);
792
+ trackInjection(original, placeholder);
793
+ return;
794
+ }
795
+ const desiredTag = mime?.startsWith('image/') ? 'IMG' : mime?.startsWith('video/') ? 'VIDEO' : mime?.startsWith('audio/') ? 'AUDIO' : null;
796
+ // If the rendered element already shows the same media file as the
797
+ // form value, do nothing. Compare by filename so we're agnostic to
798
+ // URL representation — the host framework may have rendered a proxy
799
+ // URL (e.g. Next.js's "/_next/image?url=...") that wouldn't match a
800
+ // raw form-value URL string-wise but resolves to the same asset.
801
+ const active = injection ?? original;
802
+ const newFilename = getMediaFilename(url);
803
+ const currentFilename = getMediaFilename(active.getAttribute('src') ?? '');
804
+ const tagAlreadyMatches = !desiredTag || active.tagName === desiredTag;
805
+ if (tagAlreadyMatches && newFilename && newFilename === currentFilename) {
806
+ active.style.display = '';
807
+ return;
808
+ }
809
+ // Resolve relative form-value URLs against the existing src's origin
810
+ // so the swap targets the Strapi backend, not the iframe origin.
811
+ const resolvedUrl = resolveMediaUrl(original, url);
812
+ // No mime info — fall back to updating whatever's active
813
+ if (!desiredTag) {
814
+ if (active.getAttribute('src') !== resolvedUrl) {
815
+ active.setAttribute('src', resolvedUrl);
816
+ }
817
+ active.style.display = '';
818
+ return;
819
+ }
820
+ // Original's tag matches: restore it (removing any previous injection)
821
+ if (original.tagName === desiredTag) {
822
+ removeInjection();
823
+ if (original.getAttribute('src') !== resolvedUrl) {
824
+ original.setAttribute('src', resolvedUrl);
825
+ }
826
+ original.style.display = '';
827
+ return;
828
+ }
829
+ // Existing injection of the right tag: just update its src
830
+ if (injection && injection.tagName === desiredTag) {
831
+ if (injection.getAttribute('src') !== resolvedUrl) {
832
+ injection.setAttribute('src', resolvedUrl);
833
+ }
834
+ return;
835
+ }
836
+ // Need a fresh injection of the correct tag
837
+ removeInjection();
838
+ const newInjection = document.createElement(desiredTag);
839
+ // Mirror the original's attributes so styling/classes carry over.
840
+ // Skip src/srcset — those carry the *previous* media's URL and would
841
+ // make the new element try to load it (a video element fed an image
842
+ // URL renders a permanent "No supported format" error). We set src
843
+ // explicitly below.
844
+ // For audio, skip visual-sizing attributes too — image/video classes
845
+ // (e.g. aspect-square, h-96) don't suit a horizontal audio control
846
+ // and produce a giant empty box around the player.
847
+ const isAudio = desiredTag === 'AUDIO';
848
+ const skipForAudio = new Set([
849
+ 'class',
850
+ 'style',
851
+ 'width',
852
+ 'height'
853
+ ]);
854
+ Array.from(original.attributes).forEach((attr)=>{
855
+ if (attr.name === 'id' || attr.name === 'src' || attr.name === 'srcset') return;
856
+ if (isAudio && skipForAudio.has(attr.name)) return;
857
+ newInjection.setAttribute(attr.name, attr.value);
858
+ });
859
+ newInjection.setAttribute('src', resolvedUrl);
860
+ if (desiredTag === 'VIDEO' || desiredTag === 'AUDIO') {
861
+ newInjection.setAttribute('controls', '');
862
+ }
863
+ if (!isAudio) {
864
+ newInjection.style.cssText = original.style.cssText;
865
+ }
866
+ newInjection.style.display = '';
867
+ // Hide the host-framework-managed original and let highlights track our injection
868
+ original.style.display = 'none';
869
+ original.removeAttribute(SOURCE_ATTRIBUTE);
870
+ original.parentNode?.insertBefore(newInjection, original.nextSibling);
871
+ trackInjection(original, newInjection);
872
+ };
431
873
  const handleMessage = (event)=>{
432
874
  if (!event.data?.type) return;
433
875
  // The user typed in an input, reflect the change in the preview
434
876
  if (event.data.type === INTERNAL_EVENTS.STRAPI_FIELD_CHANGE) {
435
877
  const { field, value } = event.data.payload;
436
878
  if (!field) return;
437
- getElementsByPath(field).forEach((element)=>{
438
- if (element instanceof HTMLElement) {
439
- element.textContent = value || '';
879
+ const matchedElements = Array.from(getElementsByPath(field)).filter((el)=>el instanceof HTMLElement);
880
+ // A cleared media field is represented by a placeholder div sitting
881
+ // where the (hidden or unmounted) original used to be. Treat it like
882
+ // a media element here so the next change routes through
883
+ // setMediaElement and tears the placeholder down. Standalone
884
+ // placeholders (those whose original was unmounted by the host
885
+ // framework after save) are not in the injection maps but still
886
+ // need to be matchable.
887
+ const isMediaTarget = (el)=>isMediaElement(el) || injectionToOriginal.has(el) || el.hasAttribute(PLACEHOLDER_ATTRIBUTE);
888
+ // Multi-media field: value is an array of media items. After the
889
+ // server change, every item shares `path=<field>`, so we map array
890
+ // items to media elements in DOM order. Removing items reuses
891
+ // existing elements (extras get cleared into placeholders); adding
892
+ // items synthesizes new slots by cloning the last rendered element,
893
+ // since the host framework only re-renders the gallery on save.
894
+ if (Array.isArray(value)) {
895
+ const mediaEls = matchedElements.filter(isMediaTarget);
896
+ if (mediaEls.length > 0 && mediaEls.length < value.length) {
897
+ const lastEl = mediaEls[mediaEls.length - 1];
898
+ // Walk up until we reach an ancestor whose parent lays out
899
+ // children as siblings (flex/grid). Record indices so we can
900
+ // re-find the media element inside each cloned wrapper.
901
+ const indices = [];
902
+ let template = lastEl;
903
+ let parent = template.parentElement;
904
+ const SIBLING_LAYOUTS = new Set([
905
+ 'flex',
906
+ 'inline-flex',
907
+ 'grid',
908
+ 'inline-grid'
909
+ ]);
910
+ const MAX_DEPTH = 5;
911
+ for(let depth = 0; parent && depth < MAX_DEPTH; depth++){
912
+ if (SIBLING_LAYOUTS.has(window.getComputedStyle(parent).display)) {
913
+ break;
914
+ }
915
+ indices.push(Array.prototype.indexOf.call(parent.children, template));
916
+ template = parent;
917
+ parent = parent.parentElement;
918
+ }
919
+ let insertAfter = template;
920
+ for(let i = mediaEls.length; i < value.length; i++){
921
+ const wrapperClone = template.cloneNode(true);
922
+ let cloneMedia = wrapperClone;
923
+ for(let j = indices.length - 1; j >= 0; j--){
924
+ cloneMedia = cloneMedia.children[indices[j]];
925
+ }
926
+ cloneMedia.setAttribute(CLONE_ATTRIBUTE, '');
927
+ cloneMedia.removeAttribute('id');
928
+ if (wrapperClone !== cloneMedia) {
929
+ wrapperClone.setAttribute(CLONE_WRAPPER_ATTRIBUTE, '');
930
+ }
931
+ insertAfter.parentNode?.insertBefore(wrapperClone, insertAfter.nextSibling);
932
+ mediaEls.push(cloneMedia);
933
+ insertAfter = wrapperClone;
934
+ }
440
935
  }
441
- });
936
+ mediaEls.forEach((el, index)=>{
937
+ const item = value[index];
938
+ if (item && typeof item === 'object') {
939
+ const url = item.url;
940
+ const mime = item.mime;
941
+ setMediaElement(el, url || null, mime);
942
+ } else {
943
+ setMediaElement(el, null);
944
+ }
945
+ });
946
+ } else {
947
+ matchedElements.forEach((element)=>{
948
+ if (isMediaTarget(element)) {
949
+ const url = typeof value === 'object' && value !== null ? value.url : value;
950
+ const mime = typeof value === 'object' && value !== null ? value.mime : undefined;
951
+ setMediaElement(element, url || null, mime);
952
+ } else {
953
+ element.textContent = value || '';
954
+ }
955
+ });
956
+ }
957
+ // Handle nested media asset fields (caption, alt, etc.)
958
+ // These are identified by model=plugin::upload.file in the source attribute
959
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
960
+ const allSourceElements = document.querySelectorAll(`[${SOURCE_ATTRIBUTE}]`);
961
+ allSourceElements.forEach((element)=>{
962
+ const sourceAttr = element.getAttribute(SOURCE_ATTRIBUTE);
963
+ if (!sourceAttr) return;
964
+ // Parse the source attribute as URL search params
965
+ const params = new URLSearchParams(sourceAttr);
966
+ const model = params.get('model');
967
+ const elementPath = params.get('path');
968
+ // Only process media asset fields
969
+ if (model !== 'plugin::upload.file' || !elementPath) return;
970
+ // Check if this element's path starts with the field path (e.g., "hero.caption" starts with "hero")
971
+ if (elementPath.startsWith(`${field}.`)) {
972
+ // Extract the property name (e.g., "caption" from "hero.caption")
973
+ const propertyName = elementPath.slice(field.length + 1);
974
+ const propertyValue = value[propertyName];
975
+ if (element instanceof HTMLElement && propertyValue !== undefined) {
976
+ element.textContent = propertyValue || '';
977
+ }
978
+ }
979
+ });
980
+ }
442
981
  // Update highlight dimensions since the new text content may affect them
443
982
  highlightManager.updateAllHighlights();
444
983
  return;
@@ -452,20 +991,19 @@ const previewScript = (config)=>{
452
991
  highlight.classList.remove('strapi-highlight-focused');
453
992
  });
454
993
  highlightManager.focusedHighlights.length = 0;
455
- // Set new focused field and highlight matching elements
994
+ // Set new focused field and highlight matching groups
456
995
  highlightManager.setFocusedField(field);
457
- getElementsByPath(field).forEach((element, index)=>{
996
+ const matchingGroups = highlightManager.findGroupForPath(field);
997
+ matchingGroups.forEach((group, index)=>{
458
998
  if (index === 0) {
459
- element.scrollIntoView({
999
+ const first = group.elements.values().next().value;
1000
+ first?.scrollIntoView({
460
1001
  behavior: 'smooth',
461
1002
  block: 'center'
462
1003
  });
463
1004
  }
464
- const highlight = highlightManager.highlights[Array.from(highlightManager.elements).indexOf(element)];
465
- if (highlight) {
466
- highlight.classList.add('strapi-highlight-focused');
467
- highlightManager.focusedHighlights.push(highlight);
468
- }
1005
+ group.highlight.classList.add('strapi-highlight-focused');
1006
+ highlightManager.focusedHighlights.push(group.highlight);
469
1007
  });
470
1008
  return;
471
1009
  }