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