@strapi/content-manager 5.45.1 → 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.
- package/dist/admin/content-manager.js +26 -2
- package/dist/admin/content-manager.js.map +1 -1
- package/dist/admin/content-manager.mjs +26 -2
- package/dist/admin/content-manager.mjs.map +1 -1
- package/dist/admin/hooks/usePersistentQueryParams.js +4 -1
- package/dist/admin/hooks/usePersistentQueryParams.js.map +1 -1
- package/dist/admin/hooks/usePersistentQueryParams.mjs +4 -1
- package/dist/admin/hooks/usePersistentQueryParams.mjs.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/Blocks/Code.js +21 -4
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/Blocks/Code.js.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/Blocks/Code.mjs +19 -2
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/Blocks/Code.mjs.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/Blocks/Link.mjs +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.js +9 -6
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.js.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.mjs +10 -7
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.mjs.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.js +1 -34
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.js.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.mjs +3 -35
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.mjs.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.js +33 -18
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.js.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.mjs +34 -19
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.mjs.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/DefaultBlocksStore.js +22 -0
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/DefaultBlocksStore.js.map +1 -0
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/DefaultBlocksStore.mjs +20 -0
- package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/DefaultBlocksStore.mjs.map +1 -0
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/ComponentCategory.js +15 -4
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/ComponentCategory.js.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/ComponentCategory.mjs +16 -5
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/ComponentCategory.mjs.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/DynamicComponent.js +26 -4
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/DynamicComponent.js.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/DynamicComponent.mjs +26 -4
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/DynamicComponent.mjs.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/Field.js +31 -0
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/Field.js.map +1 -1
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/Field.mjs +31 -0
- package/dist/admin/pages/EditView/components/FormInputs/DynamicZone/Field.mjs.map +1 -1
- package/dist/admin/preview/components/InputPopover.js +3 -0
- package/dist/admin/preview/components/InputPopover.js.map +1 -1
- package/dist/admin/preview/components/InputPopover.mjs +3 -0
- package/dist/admin/preview/components/InputPopover.mjs.map +1 -1
- package/dist/admin/preview/hooks/usePreviewInputManager.js +24 -0
- package/dist/admin/preview/hooks/usePreviewInputManager.js.map +1 -1
- package/dist/admin/preview/hooks/usePreviewInputManager.mjs +24 -0
- package/dist/admin/preview/hooks/usePreviewInputManager.mjs.map +1 -1
- package/dist/admin/preview/utils/previewScript.js +616 -78
- package/dist/admin/preview/utils/previewScript.js.map +1 -1
- package/dist/admin/preview/utils/previewScript.mjs +616 -78
- package/dist/admin/preview/utils/previewScript.mjs.map +1 -1
- package/dist/admin/src/content-manager.d.ts +26 -0
- package/dist/admin/src/exports.d.ts +1 -0
- package/dist/admin/src/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.d.ts +14 -8
- package/dist/admin/src/pages/EditView/components/FormInputs/BlocksInput/DefaultBlocksStore.d.ts +3 -0
- package/dist/admin/src/pages/EditView/components/FormInputs/DynamicZone/ComponentCard.d.ts +1 -1
- package/dist/admin/src/pages/EditView/components/FormInputs/DynamicZone/DynamicComponent.d.ts +11 -1
- package/dist/server/homepage/services/homepage.js +12 -8
- package/dist/server/homepage/services/homepage.js.map +1 -1
- package/dist/server/homepage/services/homepage.mjs +12 -8
- package/dist/server/homepage/services/homepage.mjs.map +1 -1
- package/dist/server/services/metrics.js +1 -5
- package/dist/server/services/metrics.js.map +1 -1
- package/dist/server/services/metrics.mjs +1 -5
- package/dist/server/services/metrics.mjs.map +1 -1
- package/dist/server/src/homepage/services/homepage.d.ts.map +1 -1
- package/dist/server/src/services/metrics.d.ts.map +1 -1
- package/package.json +6 -6
|
@@ -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
|
|
326
|
+
const groups = new Map();
|
|
327
|
+
const elementToGroupKey = new Map();
|
|
157
328
|
const eventListeners = [];
|
|
158
329
|
const focusedHighlights = [];
|
|
159
|
-
const pendingClicks = new Map();
|
|
330
|
+
const pendingClicks = new Map();
|
|
160
331
|
let focusedField = null;
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
189
|
-
const existingTimeout = pendingClicks.get(element);
|
|
396
|
+
const existingTimeout = pendingClicks.get(group);
|
|
190
397
|
if (existingTimeout) {
|
|
191
398
|
window.clearTimeout(existingTimeout);
|
|
192
|
-
pendingClicks.delete(
|
|
399
|
+
pendingClicks.delete(group);
|
|
193
400
|
}
|
|
194
|
-
// Set up a delayed single-click handler
|
|
195
401
|
const timeout = window.setTimeout(()=>{
|
|
196
|
-
pendingClicks.delete(
|
|
197
|
-
// Send single-click hint notification
|
|
402
|
+
pendingClicks.delete(group);
|
|
198
403
|
sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_SINGLE_CLICK_HINT, null);
|
|
199
|
-
//
|
|
200
|
-
//
|
|
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
|
-
|
|
434
|
+
targetElement.dispatchEvent(newEvent);
|
|
217
435
|
}, DOUBLE_CLICK_TIMEOUT);
|
|
218
|
-
pendingClicks.set(
|
|
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
|
-
|
|
225
|
-
const existingTimeout = pendingClicks.get(element);
|
|
441
|
+
const existingTimeout = pendingClicks.get(group);
|
|
226
442
|
if (existingTimeout) {
|
|
227
443
|
clearTimeout(existingTimeout);
|
|
228
|
-
pendingClicks.delete(
|
|
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
|
-
|
|
487
|
+
groups.set(groupKey, group);
|
|
488
|
+
return group;
|
|
272
489
|
};
|
|
273
|
-
const
|
|
274
|
-
const
|
|
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(
|
|
494
|
+
pendingClicks.delete(group);
|
|
281
495
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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(
|
|
610
|
+
return Array.from(elementToGroupKey.keys());
|
|
302
611
|
},
|
|
303
|
-
get
|
|
304
|
-
return Array.from(
|
|
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).
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
994
|
+
// Set new focused field and highlight matching groups
|
|
456
995
|
highlightManager.setFocusedField(field);
|
|
457
|
-
|
|
996
|
+
const matchingGroups = highlightManager.findGroupForPath(field);
|
|
997
|
+
matchingGroups.forEach((group, index)=>{
|
|
458
998
|
if (index === 0) {
|
|
459
|
-
|
|
999
|
+
const first = group.elements.values().next().value;
|
|
1000
|
+
first?.scrollIntoView({
|
|
460
1001
|
behavior: 'smooth',
|
|
461
1002
|
block: 'center'
|
|
462
1003
|
});
|
|
463
1004
|
}
|
|
464
|
-
|
|
465
|
-
|
|
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
|
}
|