@strapi/content-manager 5.53.0 → 5.55.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 (138) hide show
  1. package/dist/admin/components/ActionsDrawer.js +17 -6
  2. package/dist/admin/components/ActionsDrawer.js.map +1 -1
  3. package/dist/admin/components/ActionsDrawer.mjs +19 -8
  4. package/dist/admin/components/ActionsDrawer.mjs.map +1 -1
  5. package/dist/admin/components/LeftMenu.js +131 -52
  6. package/dist/admin/components/LeftMenu.js.map +1 -1
  7. package/dist/admin/components/LeftMenu.mjs +133 -54
  8. package/dist/admin/components/LeftMenu.mjs.map +1 -1
  9. package/dist/admin/history/components/VersionsList.js +37 -43
  10. package/dist/admin/history/components/VersionsList.js.map +1 -1
  11. package/dist/admin/history/components/VersionsList.mjs +37 -43
  12. package/dist/admin/history/components/VersionsList.mjs.map +1 -1
  13. package/dist/admin/hooks/useContentManagerInitData.js +3 -2
  14. package/dist/admin/hooks/useContentManagerInitData.js.map +1 -1
  15. package/dist/admin/hooks/useContentManagerInitData.mjs +3 -2
  16. package/dist/admin/hooks/useContentManagerInitData.mjs.map +1 -1
  17. package/dist/admin/hooks/useDocumentActions.js +3 -1
  18. package/dist/admin/hooks/useDocumentActions.js.map +1 -1
  19. package/dist/admin/hooks/useDocumentActions.mjs +4 -2
  20. package/dist/admin/hooks/useDocumentActions.mjs.map +1 -1
  21. package/dist/admin/layout.js +2 -4
  22. package/dist/admin/layout.js.map +1 -1
  23. package/dist/admin/layout.mjs +2 -4
  24. package/dist/admin/layout.mjs.map +1 -1
  25. package/dist/admin/modules/app.js +3 -1
  26. package/dist/admin/modules/app.js.map +1 -1
  27. package/dist/admin/modules/app.mjs +3 -1
  28. package/dist/admin/modules/app.mjs.map +1 -1
  29. package/dist/admin/pages/EditView/EditViewPage.js +15 -20
  30. package/dist/admin/pages/EditView/EditViewPage.js.map +1 -1
  31. package/dist/admin/pages/EditView/EditViewPage.mjs +15 -20
  32. package/dist/admin/pages/EditView/EditViewPage.mjs.map +1 -1
  33. package/dist/admin/pages/EditView/components/DocumentActions.js +2 -0
  34. package/dist/admin/pages/EditView/components/DocumentActions.js.map +1 -1
  35. package/dist/admin/pages/EditView/components/DocumentActions.mjs +2 -0
  36. package/dist/admin/pages/EditView/components/DocumentActions.mjs.map +1 -1
  37. package/dist/admin/pages/EditView/components/EditorToolbarObserver.js +74 -7
  38. package/dist/admin/pages/EditView/components/EditorToolbarObserver.js.map +1 -1
  39. package/dist/admin/pages/EditView/components/EditorToolbarObserver.mjs +74 -7
  40. package/dist/admin/pages/EditView/components/EditorToolbarObserver.mjs.map +1 -1
  41. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.js +7 -2
  42. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.js.map +1 -1
  43. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.mjs +7 -2
  44. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.mjs.map +1 -1
  45. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.js +59 -2
  46. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.js.map +1 -1
  47. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.mjs +60 -3
  48. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.mjs.map +1 -1
  49. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksInput.js +4 -1
  50. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksInput.js.map +1 -1
  51. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksInput.mjs +4 -1
  52. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksInput.mjs.map +1 -1
  53. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.js +4 -1
  54. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.js.map +1 -1
  55. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.mjs +4 -1
  56. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/BlocksToolbar.mjs.map +1 -1
  57. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/EditorLayout.js +2 -1
  58. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/EditorLayout.js.map +1 -1
  59. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/EditorLayout.mjs +2 -1
  60. package/dist/admin/pages/EditView/components/FormInputs/BlocksInput/EditorLayout.mjs.map +1 -1
  61. package/dist/admin/pages/EditView/components/FormInputs/Relations/RelationModal.js +58 -1
  62. package/dist/admin/pages/EditView/components/FormInputs/Relations/RelationModal.js.map +1 -1
  63. package/dist/admin/pages/EditView/components/FormInputs/Relations/RelationModal.mjs +58 -2
  64. package/dist/admin/pages/EditView/components/FormInputs/Relations/RelationModal.mjs.map +1 -1
  65. package/dist/admin/pages/EditView/components/FormInputs/Wysiwyg/WysiwygNav.js +25 -2
  66. package/dist/admin/pages/EditView/components/FormInputs/Wysiwyg/WysiwygNav.js.map +1 -1
  67. package/dist/admin/pages/EditView/components/FormInputs/Wysiwyg/WysiwygNav.mjs +5 -2
  68. package/dist/admin/pages/EditView/components/FormInputs/Wysiwyg/WysiwygNav.mjs.map +1 -1
  69. package/dist/admin/pages/EditView/components/InputRenderer.js +5 -1
  70. package/dist/admin/pages/EditView/components/InputRenderer.js.map +1 -1
  71. package/dist/admin/pages/EditView/components/InputRenderer.mjs +6 -2
  72. package/dist/admin/pages/EditView/components/InputRenderer.mjs.map +1 -1
  73. package/dist/admin/pages/ListView/ListViewPage.js +1 -0
  74. package/dist/admin/pages/ListView/ListViewPage.js.map +1 -1
  75. package/dist/admin/pages/ListView/ListViewPage.mjs +1 -0
  76. package/dist/admin/pages/ListView/ListViewPage.mjs.map +1 -1
  77. package/dist/admin/preview/components/InputPopover.js +64 -46
  78. package/dist/admin/preview/components/InputPopover.js.map +1 -1
  79. package/dist/admin/preview/components/InputPopover.mjs +66 -49
  80. package/dist/admin/preview/components/InputPopover.mjs.map +1 -1
  81. package/dist/admin/preview/hooks/usePreviewInputManager.js +23 -7
  82. package/dist/admin/preview/hooks/usePreviewInputManager.js.map +1 -1
  83. package/dist/admin/preview/hooks/usePreviewInputManager.mjs +25 -9
  84. package/dist/admin/preview/hooks/usePreviewInputManager.mjs.map +1 -1
  85. package/dist/admin/preview/pages/Preview.js +19 -0
  86. package/dist/admin/preview/pages/Preview.js.map +1 -1
  87. package/dist/admin/preview/pages/Preview.mjs +20 -1
  88. package/dist/admin/preview/pages/Preview.mjs.map +1 -1
  89. package/dist/admin/preview/utils/constants.js +9 -3
  90. package/dist/admin/preview/utils/constants.js.map +1 -1
  91. package/dist/admin/preview/utils/constants.mjs +9 -3
  92. package/dist/admin/preview/utils/constants.mjs.map +1 -1
  93. package/dist/admin/src/components/ActionsDrawer.d.ts +1 -0
  94. package/dist/admin/src/modules/app.d.ts +2 -0
  95. package/dist/admin/src/pages/EditView/components/EditorToolbarObserver.d.ts +2 -1
  96. package/dist/admin/src/pages/EditView/components/FormInputs/BlocksInput/BlocksContent.d.ts +5 -1
  97. package/dist/admin/src/pages/EditView/components/FormInputs/BlocksInput/BlocksEditor.d.ts +5 -0
  98. package/dist/admin/src/pages/EditView/components/FormInputs/BlocksInput/BlocksInput.d.ts +5 -0
  99. package/dist/admin/src/pages/EditView/components/FormInputs/BlocksInput/EditorLayout.d.ts +2 -1
  100. package/dist/admin/src/pages/EditView/components/FormInputs/Relations/RelationModal.d.ts +14 -1
  101. package/dist/admin/src/preview/components/InputPopover.d.ts +2 -1
  102. package/dist/admin/src/preview/pages/Preview.d.ts +1 -0
  103. package/dist/admin/src/preview/utils/constants.d.ts +8 -1
  104. package/dist/admin/src/services/init.d.ts +1 -0
  105. package/dist/admin/src/utils/contentStructure.d.ts +59 -0
  106. package/dist/admin/utils/contentStructure.js +136 -0
  107. package/dist/admin/utils/contentStructure.js.map +1 -0
  108. package/dist/admin/utils/contentStructure.mjs +130 -0
  109. package/dist/admin/utils/contentStructure.mjs.map +1 -0
  110. package/dist/server/controllers/init.js +7 -2
  111. package/dist/server/controllers/init.js.map +1 -1
  112. package/dist/server/controllers/init.mjs +7 -2
  113. package/dist/server/controllers/init.mjs.map +1 -1
  114. package/dist/server/preview/controllers/previewScript.js +548 -22
  115. package/dist/server/services/content-structure.js +53 -0
  116. package/dist/server/services/content-structure.js.map +1 -0
  117. package/dist/server/services/content-structure.mjs +51 -0
  118. package/dist/server/services/content-structure.mjs.map +1 -0
  119. package/dist/server/services/index.js +2 -0
  120. package/dist/server/services/index.js.map +1 -1
  121. package/dist/server/services/index.mjs +2 -0
  122. package/dist/server/services/index.mjs.map +1 -1
  123. package/dist/server/src/controllers/index.d.ts +1 -1
  124. package/dist/server/src/controllers/init.d.ts +1 -1
  125. package/dist/server/src/controllers/init.d.ts.map +1 -1
  126. package/dist/server/src/index.d.ts +6 -1
  127. package/dist/server/src/index.d.ts.map +1 -1
  128. package/dist/server/src/services/content-structure.d.ts +8 -0
  129. package/dist/server/src/services/content-structure.d.ts.map +1 -0
  130. package/dist/server/src/services/index.d.ts +5 -0
  131. package/dist/server/src/services/index.d.ts.map +1 -1
  132. package/dist/server/src/utils/index.d.ts +2 -0
  133. package/dist/server/src/utils/index.d.ts.map +1 -1
  134. package/dist/server/utils/index.js.map +1 -1
  135. package/dist/server/utils/index.mjs.map +1 -1
  136. package/dist/shared/contracts/init.d.ts +2 -0
  137. package/dist/shared/contracts/init.d.ts.map +1 -1
  138. package/package.json +5 -5
@@ -82,6 +82,62 @@ function previewScript(config) {
82
82
  return document.querySelectorAll(`[${SOURCE_ATTRIBUTE}*="path=${path}"]`);
83
83
  };
84
84
 
85
+ /**
86
+ * Group key for the highlight manager. For most fields this is the raw
87
+ * source attribute (so identical sources share one highlight, which is what
88
+ * gives multi-media galleries their single bounding box). For blocks fields,
89
+ * every encoded marker shares the same `path` and `fieldPath` (the blocks
90
+ * field path). We drop `path` from the key so all marked elements cluster
91
+ * into a single highlight whose rect spans the entire rendered field.
92
+ * @param {string} sourceAttr
93
+ * @returns {string}
94
+ */
95
+ const deriveGroupKey = (sourceAttr) => {
96
+ const params = new URLSearchParams(sourceAttr);
97
+ const fieldPath = params.get('fieldPath');
98
+ if (!fieldPath) return sourceAttr;
99
+ params.delete('path');
100
+ return params.toString();
101
+ };
102
+
103
+ /**
104
+ * Returns true when value looks like a Strapi blocks AST. Inlined here because
105
+ * this script is serialised and injected into iframes where imports are unavailable.
106
+ * @param {unknown} value
107
+ * @returns {boolean}
108
+ */
109
+ const isBlocksValue = (value) => {
110
+ if (!Array.isArray(value) || value.length === 0) return false;
111
+ return value.every(
112
+ (n) =>
113
+ n !== null &&
114
+ typeof n === 'object' &&
115
+ 'type' in n &&
116
+ 'children' in n &&
117
+ Array.isArray(/** @type {{ children: unknown }} */ (n).children)
118
+ );
119
+ };
120
+
121
+ /**
122
+ * Blocks live updates are rendered by the host frontend (e.g. via BlocksRenderer).
123
+ * Re-dispatch the change on the iframe window so integrators can subscribe with
124
+ * `window.addEventListener('strapiFieldChange', …)` without the preview script
125
+ * attempting to patch the DOM. The admin panel also posts the same payload via
126
+ * `postMessage`, which hosts can listen for on `window` 'message' events.
127
+ *
128
+ * 'strapiFieldChange' is a public event — host apps depend on this name, so it
129
+ * is intentionally hardcoded rather than read from INTERNAL_EVENTS config.
130
+ * @param {string} field
131
+ * @param {unknown} value
132
+ */
133
+ const forwardBlocksFieldChange = (field, value) => {
134
+ window.dispatchEvent(
135
+ new CustomEvent('strapiFieldChange', {
136
+ detail: { field, value },
137
+ })
138
+ );
139
+ };
140
+
85
141
  /**
86
142
  * @param {Element} element
87
143
  * @returns {boolean}
@@ -287,6 +343,25 @@ function previewScript(config) {
287
343
  return sourceAttr;
288
344
  };
289
345
 
346
+ /**
347
+ * Resolve the source attribute to use when opening the popover for a clicked
348
+ * element. Blocks markers carry a `fieldPath`; redirect the popover to that
349
+ * field. Falls back to the media-specific normalization for everything else.
350
+ * @param {string} sourceAttr
351
+ * @param {Element} element
352
+ * @returns {string}
353
+ */
354
+ const getFocusPath = (sourceAttr, element) => {
355
+ const params = new URLSearchParams(sourceAttr);
356
+ const fieldPath = params.get('fieldPath');
357
+ if (fieldPath) {
358
+ params.set('path', fieldPath);
359
+ params.delete('fieldPath');
360
+ return params.toString();
361
+ }
362
+ return getFieldPathForMedia(sourceAttr, element);
363
+ };
364
+
290
365
  /* -----------------------------------------------------------------------------------------------
291
366
  * Functionality pieces
292
367
  * ---------------------------------------------------------------------------------------------*/
@@ -314,7 +389,9 @@ function previewScript(config) {
314
389
  * @param {Element} element
315
390
  */
316
391
  const applyStegaToElement = (element) => {
317
- // Handle img and video tags - check src attribute for stega encoding
392
+ // Handle img and video tags - check src and alt attributes for stega encoding.
393
+ // The src path handles media fields; the alt path handles blocks image blocks
394
+ // (the url is intentionally not encoded to avoid corrupting the src attribute).
318
395
  if (isMediaElement(element)) {
319
396
  const src = element.getAttribute('src');
320
397
  if (src) {
@@ -339,6 +416,26 @@ function previewScript(config) {
339
416
  }
340
417
  } catch (error) {}
341
418
  }
419
+
420
+ // Blocks image markers are encoded into the alt attribute (not the src).
421
+ // If the alt carries a marker that is not yet superseded by a src-derived one,
422
+ // apply it and clean the visible alt text.
423
+ if (!element.hasAttribute(SOURCE_ATTRIBUTE)) {
424
+ const alt = element.getAttribute('alt');
425
+ if (alt) {
426
+ try {
427
+ const result = stegaDecode(alt);
428
+ if (result && 'strapiSource' in result) {
429
+ element.setAttribute(SOURCE_ATTRIBUTE, result.strapiSource);
430
+ }
431
+ const cleanedAlt = stegaClean(alt);
432
+ if (cleanedAlt !== alt) {
433
+ element.setAttribute('alt', cleanedAlt);
434
+ }
435
+ } catch (error) {}
436
+ }
437
+ }
438
+
342
439
  return;
343
440
  }
344
441
 
@@ -505,10 +602,265 @@ function previewScript(config) {
505
602
  /** @type {string | null} */
506
603
  let focusedField = null;
507
604
 
605
+ /**
606
+ * Blocks fields fully delegate live-typing renders to the host frontend
607
+ * (see forwardBlocksFieldChange) — new or resized block content never gets
608
+ * a stega tag of its own, so the per-element ResizeObserver used for every
609
+ * other field type can't see it. Observing the field's own container
610
+ * instead picks up any size change inside it (new blocks, growing or
611
+ * shrinking text, removed blocks) without requiring each child to be
612
+ * individually tagged. computeGroupRect already reads the container's
613
+ * rect fresh on every call — this only makes sure something re-triggers
614
+ * that read while the user is typing, instead of waiting for the next
615
+ * explicit rescan.
616
+ * @type {Map<string, HTMLElement>}
617
+ */
618
+ const observedContainers = new Map();
619
+ const containerResizeObserver = new ResizeObserver(() => {
620
+ updateAllHighlights();
621
+ });
622
+
623
+ /**
624
+ * Block-level HTML tags produced by standard Strapi blocks renderers.
625
+ * Each corresponds to exactly one top-level Slate block in the editor.
626
+ */
627
+ const BLOCK_LEVEL_TAGS = [
628
+ 'P',
629
+ 'H1',
630
+ 'H2',
631
+ 'H3',
632
+ 'H4',
633
+ 'H5',
634
+ 'H6',
635
+ 'UL',
636
+ 'OL',
637
+ 'BLOCKQUOTE',
638
+ 'PRE',
639
+ ];
640
+
641
+ /**
642
+ * Maximum area a candidate blocks container may have, relative to the union
643
+ * of the group's own marked elements. A real field container is only
644
+ * slightly larger than its content (padding, empty trailing blocks). A
645
+ * candidate many times larger is a page-level layout element that merely
646
+ * happens to contain block-level children, and using it would stretch the
647
+ * highlight across unrelated content.
648
+ */
649
+ const MAX_CONTAINER_AREA_RATIO = 6;
650
+
651
+ /**
652
+ * Extra height added below a blocks field's marked content so empty trailing
653
+ * blocks (which produce no stega span) stay hoverable and clickable. Always
654
+ * clamped to the field's own bounds — see computeGroupRect.
655
+ */
656
+ const BLOCKS_TRAILING_BUFFER = 80;
657
+
658
+ /**
659
+ * Union rect of a group's marked elements, ignoring zero-sized ones.
660
+ * @param {HighlightGroup} group
661
+ * @returns {{ left: number, top: number, width: number, height: number } | null}
662
+ */
663
+ const getGroupUnionRect = (group) => {
664
+ let minLeft = Infinity;
665
+ let minTop = Infinity;
666
+ let maxRight = -Infinity;
667
+ let maxBottom = -Infinity;
668
+ let any = false;
669
+ group.elements.forEach((el) => {
670
+ const r = el.getBoundingClientRect();
671
+ if (r.width === 0 && r.height === 0) return;
672
+ any = true;
673
+ if (r.left < minLeft) minLeft = r.left;
674
+ if (r.top < minTop) minTop = r.top;
675
+ if (r.right > maxRight) maxRight = r.right;
676
+ if (r.bottom > maxBottom) maxBottom = r.bottom;
677
+ });
678
+ if (!any) return null;
679
+ return { left: minLeft, top: minTop, width: maxRight - minLeft, height: maxBottom - minTop };
680
+ };
681
+
682
+ /**
683
+ * Find the DOM element that wraps the entire rendered output of a blocks
684
+ * field — the direct parent of all top-level block elements (`<p>`, `<h1>`,
685
+ * etc.). Used to derive a tight, always-current bounding rect for the
686
+ * highlight, including empty blocks and container padding that stega spans
687
+ * cannot cover.
688
+ *
689
+ * Strategy: walk up from the first stega span until we find an element
690
+ * whose direct children include at least one block-level element. This
691
+ * mirrors the fallback in findBlockIndex and handles all DOM shapes
692
+ * correctly — including lists where NCA would land on <li> (not the
693
+ * container) and single-span groups where there is no useful NCA.
694
+ *
695
+ * The block-level-tag test alone is not sufficient: a blocks field whose
696
+ * blocks produce no block-level tags (e.g. a field containing only an
697
+ * image, which renders as <img>) has no matching ancestor inside the field,
698
+ * so the walk escapes into page layout and matches an unrelated container.
699
+ * That stretched the highlight over the whole page and — because highlights
700
+ * sit on top and swallow clicks — made the rest of the preview unclickable.
701
+ * We therefore reject candidates whose area dwarfs the group's own content.
702
+ *
703
+ * @param {HighlightGroup} group
704
+ * @returns {HTMLElement | null}
705
+ */
706
+ const findBlocksContainer = (group) => {
707
+ const firstEl = group.elements.values().next().value;
708
+ if (!firstEl) return null;
709
+
710
+ const union = getGroupUnionRect(group);
711
+ const unionArea = union ? union.width * union.height : 0;
712
+
713
+ let el = firstEl.parentElement;
714
+ while (el && el !== document.body && el !== document.documentElement) {
715
+ // Skip list elements — <li> can have a nested <ul>/<ol> as a direct child, and
716
+ // @strapi/blocks-react-renderer places nested lists directly inside <ul>/<ol>
717
+ // (not wrapped in a <li>), so list containers also satisfy the block-level check
718
+ // while being blocks themselves, not the field container.
719
+ if (
720
+ el.tagName !== 'LI' &&
721
+ el.tagName !== 'UL' &&
722
+ el.tagName !== 'OL' &&
723
+ Array.from(el.children).some((c) => BLOCK_LEVEL_TAGS.includes(c.tagName))
724
+ ) {
725
+ const r = el.getBoundingClientRect();
726
+ const area = r.width * r.height;
727
+ // Accept only a container that stays proportionate to the field's own
728
+ // content. Bail out entirely rather than climbing further: everything
729
+ // above an over-large candidate is even larger.
730
+ if (unionArea > 0 && area > unionArea * MAX_CONTAINER_AREA_RATIO) {
731
+ return null;
732
+ }
733
+ return el;
734
+ }
735
+ el = el.parentElement;
736
+ }
737
+ return null;
738
+ };
739
+
740
+ /**
741
+ * Resolves the trusted container for a blocks field's group, preferring a
742
+ * still-attached, already-observed container over re-running the
743
+ * ratio-guarded discovery walk.
744
+ *
745
+ * findBlocksContainer's area guard only needs to protect *first*
746
+ * discovery (telling a real field container apart from an unrelated
747
+ * page-level ancestor we escaped into, e.g. an image-only field with no
748
+ * block-level tags of its own). Once a container has been established for
749
+ * a groupKey, live edits inside it (e.g. typing a new, unmarked image
750
+ * block into a field whose only marked content is a short paragraph) can
751
+ * legitimately dwarf the originally-marked content — re-running the guard
752
+ * on every redraw would then reject the same, still-correct container and
753
+ * collapse the highlight down to a too-small fallback box. The container
754
+ * element itself doesn't change across those edits (the host re-renders
755
+ * its children, not the field's own wrapper), so trusting it is safe as
756
+ * long as it's still in the document.
757
+ * @param {string} groupKey
758
+ * @param {HighlightGroup} group
759
+ * @returns {HTMLElement | null}
760
+ */
761
+ const getBlocksContainer = (groupKey, group) => {
762
+ const cached = observedContainers.get(groupKey);
763
+ if (cached && document.contains(cached)) {
764
+ return cached;
765
+ }
766
+ return findBlocksContainer(group);
767
+ };
768
+
769
+ /**
770
+ * Keeps a blocks group's container under observation as its membership
771
+ * changes. Safe to call every time an element joins the group — re-finding
772
+ * and re-observing the same container is a no-op; it only does real work
773
+ * when the container genuinely changes or disappears (e.g. an image-only
774
+ * field where findBlocksContainer's area guard rejects every candidate).
775
+ * @param {string} groupKey
776
+ * @param {HighlightGroup} group
777
+ */
778
+ const syncContainerObservation = (groupKey, group) => {
779
+ const firstEl = group.elements.values().next().value;
780
+ const sourceAttr = firstEl?.getAttribute(SOURCE_ATTRIBUTE);
781
+ const isBlocksField = !!sourceAttr && new URLSearchParams(sourceAttr).has('fieldPath');
782
+ if (!isBlocksField) return;
783
+
784
+ const container = getBlocksContainer(groupKey, group);
785
+ const previousContainer = observedContainers.get(groupKey);
786
+ if (container === previousContainer) return;
787
+
788
+ if (previousContainer) {
789
+ containerResizeObserver.unobserve(previousContainer);
790
+ }
791
+ if (container) {
792
+ containerResizeObserver.observe(container);
793
+ observedContainers.set(groupKey, container);
794
+ } else {
795
+ observedContainers.delete(groupKey);
796
+ }
797
+ };
798
+
799
+ /**
800
+ * Find the 0-based index of the Slate block that was clicked in a blocks
801
+ * field. We cannot use `group.elements.indexOf(anchor)` because
802
+ * `group.elements` has one entry per stega text-span, not one per block —
803
+ * formatted text (bold, italic…) produces multiple spans per block, so an
804
+ * element-list index is not the same as the Slate block index.
805
+ *
806
+ * Uses the same trusted-container resolution as computeGroupRect
807
+ * (getBlocksContainer) rather than its own walk-up-with-area-guard, so
808
+ * that double-clicking a legitimately marked block next to a large
809
+ * unmarked one (e.g. an image typed in next to a short paragraph) doesn't
810
+ * spuriously fail the guard and lose the block index.
811
+ * @param {HTMLElement} anchor - stega span that was clicked
812
+ * @param {string} groupKey
813
+ * @param {HighlightGroup} group
814
+ * @returns {number} 0-based block index, or -1 if it cannot be determined
815
+ */
816
+ const findBlockIndex = (anchor, groupKey, group) => {
817
+ const fieldContainer = getBlocksContainer(groupKey, group);
818
+ if (!fieldContainer) return -1;
819
+
820
+ // Walk up from anchor to its direct-child-of-container ancestor
821
+ let blockEl = anchor;
822
+ while (blockEl.parentElement && blockEl.parentElement !== fieldContainer) {
823
+ blockEl = /** @type {HTMLElement} */ (blockEl.parentElement);
824
+ }
825
+ if (blockEl.parentElement !== fieldContainer) return -1;
826
+
827
+ // Use the full children list (not just block-level tags) so that every
828
+ // Slate block maps to its correct DOM position. Empty paragraphs render
829
+ // as <br> and images may render as <img> or custom elements — filtering
830
+ // by tag name would make the index drift whenever one of these appears
831
+ // before the clicked block.
832
+ return Array.from(fieldContainer.children).indexOf(blockEl);
833
+ };
834
+
508
835
  /**
509
836
  * @param {HighlightGroup} group
837
+ * @param {string} groupKey
510
838
  */
511
- const computeGroupRect = (group) => {
839
+ const computeGroupRect = (group, groupKey) => {
840
+ if (group.elements.size === 0) return null;
841
+
842
+ // For blocks fields (identified by fieldPath in the source attribute),
843
+ // derive the highlight from the field container's bounding rect so that
844
+ // empty blocks and container padding are always included — a fixed pixel
845
+ // buffer on the span union is too brittle when content grows or shrinks.
846
+ const firstEl = group.elements.values().next().value;
847
+ const firstSourceAttr = firstEl?.getAttribute(SOURCE_ATTRIBUTE);
848
+ const isBlocksField =
849
+ !!firstSourceAttr && new URLSearchParams(firstSourceAttr).has('fieldPath');
850
+
851
+ if (isBlocksField) {
852
+ const container = getBlocksContainer(groupKey, group);
853
+ if (container) {
854
+ const r = container.getBoundingClientRect();
855
+ if (r.width > 0 || r.height > 0) {
856
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
857
+ }
858
+ }
859
+ }
860
+
861
+ // Non-blocks fields (and blocks fallback): union of all non-zero span rects.
862
+ // For blocks fields add a buffer so that empty trailing blocks are still
863
+ // clickable even when they produce no stega spans.
512
864
  let minLeft = Infinity;
513
865
  let minTop = Infinity;
514
866
  let maxRight = -Infinity;
@@ -524,19 +876,45 @@ function previewScript(config) {
524
876
  if (r.bottom > maxBottom) maxBottom = r.bottom;
525
877
  });
526
878
  if (!any) return null;
879
+
880
+ let bottom = maxBottom;
881
+ if (isBlocksField) {
882
+ // The buffer keeps empty trailing blocks clickable, but it must not spill
883
+ // past the field itself — otherwise it covers whatever the host renders
884
+ // below (e.g. the next field's label) and swallows its clicks.
885
+ //
886
+ // Clamp it to the field's own extent: walk up while each ancestor still
887
+ // starts at the marked content's top edge. Such an ancestor wraps only
888
+ // this field, so its bottom includes trailing blocks (which carry no
889
+ // stega span) while stopping short of sibling content. The first
890
+ // ancestor that starts higher up belongs to the surrounding layout.
891
+ const limit = maxBottom + BLOCKS_TRAILING_BUFFER;
892
+ let fieldBottom = maxBottom;
893
+ const firstElement = /** @type {HTMLElement} */ (firstEl);
894
+ let el = firstElement?.parentElement;
895
+ while (el && el !== document.body && el !== document.documentElement) {
896
+ const r = el.getBoundingClientRect();
897
+ if (r.height === 0 || r.top < minTop - 1) break;
898
+ if (r.bottom > fieldBottom) fieldBottom = r.bottom;
899
+ el = el.parentElement;
900
+ }
901
+ bottom = Math.min(limit, Math.max(maxBottom, fieldBottom));
902
+ }
903
+
527
904
  return {
528
905
  left: minLeft,
529
906
  top: minTop,
530
907
  width: maxRight - minLeft,
531
- height: maxBottom - minTop,
908
+ height: bottom - minTop,
532
909
  };
533
910
  };
534
911
 
535
912
  /**
536
913
  * @param {HighlightGroup} group
914
+ * @param {string} groupKey
537
915
  */
538
- const drawGroup = (group) => {
539
- const rect = computeGroupRect(group);
916
+ const drawGroup = (group, groupKey) => {
917
+ const rect = computeGroupRect(group, groupKey);
540
918
  if (!rect) {
541
919
  group.highlight.style.display = 'none';
542
920
  return;
@@ -548,28 +926,49 @@ function previewScript(config) {
548
926
  };
549
927
 
550
928
  const updateAllHighlights = () => {
551
- groups.forEach(drawGroup);
929
+ groups.forEach((group, groupKey) => drawGroup(group, groupKey));
552
930
  };
553
931
 
554
932
  /**
555
933
  * Pick the underlying source element under the pointer so single-click
556
934
  * redispatch hits the specific item the user clicked, even when the group
557
935
  * highlight covers several elements (multi-media gallery).
936
+ * When no element rect contains the point (e.g. click in empty space within
937
+ * a blocks field, or on an unmarked node like a code block or an image with
938
+ * no alt text), falls back to the nearest element by distance to rect.
939
+ *
940
+ * `exact` tells callers whether the returned element actually contains the
941
+ * point or is just the nearest fallback — the blocks double-click handler
942
+ * needs this to avoid deriving a Slate block index from an element the user
943
+ * didn't actually click on.
558
944
  *
559
945
  * @param {HighlightGroup} group
560
946
  * @param {number} x
561
947
  * @param {number} y
562
- * @returns {HTMLElement | null}
948
+ * @returns {{ element: HTMLElement | null, exact: boolean }}
563
949
  */
564
950
  const pickElementAtPoint = (group, x, y) => {
565
951
  for (const el of group.elements) {
566
952
  const r = el.getBoundingClientRect();
567
953
  if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
568
- return el;
954
+ return { element: el, exact: true };
569
955
  }
570
956
  }
571
- const first = group.elements.values().next().value;
572
- return first ?? null;
957
+ // No exact hit — find the nearest element by squared distance to its rect.
958
+ let nearest = null;
959
+ let minDist = Infinity;
960
+ for (const el of group.elements) {
961
+ const r = el.getBoundingClientRect();
962
+ if (r.width === 0 && r.height === 0) continue;
963
+ const dx = Math.max(r.left - x, 0, x - r.right);
964
+ const dy = Math.max(r.top - y, 0, y - r.bottom);
965
+ const dist = dx * dx + dy * dy;
966
+ if (dist < minDist) {
967
+ minDist = dist;
968
+ nearest = el;
969
+ }
970
+ }
971
+ return { element: nearest ?? group.elements.values().next().value ?? null, exact: false };
573
972
  };
574
973
 
575
974
  /**
@@ -593,6 +992,10 @@ function previewScript(config) {
593
992
  event.preventDefault();
594
993
  event.stopPropagation();
595
994
 
995
+ // Notify admin immediately so it can close any open popover.
996
+ // (highlights call stopPropagation so the document-level listener below won't fire)
997
+ sendMessage(INTERNAL_EVENTS.STRAPI_IFRAME_CLICK, null);
998
+
596
999
  const existingTimeout = pendingClicks.get(group);
597
1000
  if (existingTimeout) {
598
1001
  window.clearTimeout(existingTimeout);
@@ -606,7 +1009,7 @@ function previewScript(config) {
606
1009
 
607
1010
  // Pick the specific underlying element under the pointer so the
608
1011
  // redispatched click targets the right item in a multi-element group
609
- const underlying = pickElementAtPoint(group, event.clientX, event.clientY);
1012
+ const { element: underlying } = pickElementAtPoint(group, event.clientX, event.clientY);
610
1013
  if (!underlying) return;
611
1014
 
612
1015
  /** @type {HTMLElement} */
@@ -662,13 +1065,65 @@ function previewScript(config) {
662
1065
  pendingClicks.delete(group);
663
1066
  }
664
1067
 
665
- const anchor = pickElementAtPoint(group, event.clientX, event.clientY);
1068
+ const { element: anchor, exact } = pickElementAtPoint(group, event.clientX, event.clientY);
666
1069
  if (!anchor) return;
667
1070
  const sourceAttribute = anchor.getAttribute(SOURCE_ATTRIBUTE);
668
1071
  if (!sourceAttribute) return;
669
- const path = getFieldPathForMedia(sourceAttribute, anchor);
670
- const rect = computeGroupRect(group);
1072
+ const path = getFocusPath(sourceAttribute, anchor);
1073
+ // For blocks fields, use the specific clicked element's rect so the
1074
+ // popover opens adjacent to the clicked block, not the entire field.
1075
+ // If the stega element is zero-width (empty block with only invisible
1076
+ // chars), walk up to the nearest block-level ancestor that has a real
1077
+ // width so the popover isn't anchored to a point at the far edge of a line.
1078
+ const isBlocksField = new URLSearchParams(sourceAttribute).has('fieldPath');
1079
+ let rect;
1080
+ if (isBlocksField) {
1081
+ let anchorRect = anchor.getBoundingClientRect();
1082
+ if (anchorRect.width <= 5) {
1083
+ let el = anchor.parentElement;
1084
+ while (el && el !== document.documentElement) {
1085
+ const r = el.getBoundingClientRect();
1086
+ if (r.width > 5) {
1087
+ anchorRect = r;
1088
+ break;
1089
+ }
1090
+ el = el.parentElement;
1091
+ }
1092
+ }
1093
+ rect = anchorRect;
1094
+ } else {
1095
+ rect = computeGroupRect(group, groupKey);
1096
+ }
671
1097
  if (!rect) return;
1098
+ let blockIndex = -1;
1099
+ if (isBlocksField) {
1100
+ if (exact) {
1101
+ blockIndex = findBlockIndex(anchor, groupKey, group);
1102
+ } else {
1103
+ // No exact hit on a marked element — resolve the real clicked
1104
+ // element's own position instead of guessing from the fallback
1105
+ // anchor above (nearest marked sibling), which is only reliable
1106
+ // for "which field", not "which block". findBlockIndex only
1107
+ // needs a genuine DOM descendant of the field container, not a
1108
+ // marked one, so this works whether the element is unmarked
1109
+ // because it's not yet saved (live-typed content) or unmarked by
1110
+ // design (code blocks, alt-less images) — no stega marker needed
1111
+ // either way.
1112
+ // elementsFromPoint (not elementFromPoint) skips past the
1113
+ // highlight overlay itself, which is what actually received this
1114
+ // dblclick and would otherwise be the only hit at this point.
1115
+ const clickedElement = document
1116
+ .elementsFromPoint(event.clientX, event.clientY)
1117
+ .find((el) => !overlay.contains(el));
1118
+ if (clickedElement) {
1119
+ blockIndex = findBlockIndex(
1120
+ /** @type {HTMLElement} */ (clickedElement),
1121
+ groupKey,
1122
+ group
1123
+ );
1124
+ }
1125
+ }
1126
+ }
672
1127
  sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_FOCUS_INTENT, {
673
1128
  path,
674
1129
  position: {
@@ -679,6 +1134,7 @@ function previewScript(config) {
679
1134
  width: rect.width,
680
1135
  height: rect.height,
681
1136
  },
1137
+ blockIndex: blockIndex >= 0 ? blockIndex : null,
682
1138
  });
683
1139
  };
684
1140
 
@@ -719,6 +1175,12 @@ function previewScript(config) {
719
1175
  * @param {HighlightGroup} group
720
1176
  */
721
1177
  const destroyGroup = (groupKey, group) => {
1178
+ const observedContainer = observedContainers.get(groupKey);
1179
+ if (observedContainer) {
1180
+ containerResizeObserver.unobserve(observedContainer);
1181
+ observedContainers.delete(groupKey);
1182
+ }
1183
+
722
1184
  const pendingTimeout = pendingClicks.get(group);
723
1185
  if (pendingTimeout) {
724
1186
  window.clearTimeout(pendingTimeout);
@@ -754,8 +1216,9 @@ function previewScript(config) {
754
1216
  if (elementToGroupKey.has(element)) {
755
1217
  return;
756
1218
  }
757
- const groupKey = element.getAttribute(SOURCE_ATTRIBUTE);
758
- if (!groupKey) return;
1219
+ const sourceAttr = element.getAttribute(SOURCE_ATTRIBUTE);
1220
+ if (!sourceAttr) return;
1221
+ const groupKey = deriveGroupKey(sourceAttr);
759
1222
 
760
1223
  let group = groups.get(groupKey);
761
1224
 
@@ -828,7 +1291,8 @@ function previewScript(config) {
828
1291
  }
829
1292
  group.elements.add(element);
830
1293
  elementToGroupKey.set(element, groupKey);
831
- drawGroup(group);
1294
+ drawGroup(group, groupKey);
1295
+ syncContainerObservation(groupKey, group);
832
1296
  };
833
1297
 
834
1298
  /**
@@ -846,7 +1310,7 @@ function previewScript(config) {
846
1310
  if (group.elements.size === 0) {
847
1311
  destroyGroup(groupKey, group);
848
1312
  } else {
849
- drawGroup(group);
1313
+ drawGroup(group, groupKey);
850
1314
  }
851
1315
  };
852
1316
 
@@ -879,6 +1343,28 @@ function previewScript(config) {
879
1343
  }
880
1344
  });
881
1345
 
1346
+ /**
1347
+ * Reconcile tracked elements with the current DOM. Removes any elements
1348
+ * that are no longer mounted, registers any new ones, and redraws all
1349
+ * highlights. Called after the editor popover closes to correct staleness
1350
+ * from live-preview updates that changed the field's rendered height.
1351
+ */
1352
+ const rescan = () => {
1353
+ // Prune elements that were removed from the DOM while the popover was open
1354
+ for (const element of [...elementToGroupKey.keys()]) {
1355
+ if (!document.contains(element)) {
1356
+ removeHighlightForElement(element);
1357
+ }
1358
+ }
1359
+ // Register any elements that appeared during editing
1360
+ document.querySelectorAll(`[${SOURCE_ATTRIBUTE}]`).forEach((element) => {
1361
+ if (element instanceof HTMLElement) {
1362
+ createHighlightForElement(element); // no-op for already-tracked elements
1363
+ }
1364
+ });
1365
+ updateAllHighlights();
1366
+ };
1367
+
882
1368
  return {
883
1369
  get elements() {
884
1370
  return Array.from(elementToGroupKey.keys());
@@ -887,6 +1373,7 @@ function previewScript(config) {
887
1373
  return Array.from(groups.values());
888
1374
  },
889
1375
  updateAllHighlights,
1376
+ rescan,
890
1377
  eventListeners,
891
1378
  focusedHighlights,
892
1379
  createHighlightForElement,
@@ -901,6 +1388,9 @@ function previewScript(config) {
901
1388
  pendingClicks.forEach((timeout) => clearTimeout(timeout));
902
1389
  pendingClicks.clear();
903
1390
  },
1391
+ disconnectContainerObserver: () => {
1392
+ containerResizeObserver.disconnect();
1393
+ },
904
1394
  };
905
1395
  };
906
1396
 
@@ -1214,11 +1704,22 @@ function previewScript(config) {
1214
1704
  if (!event.data?.type) return;
1215
1705
  if (event.source !== window.parent || event.origin !== parentOrigin) return;
1216
1706
 
1217
- // The user typed in an input, reflect the change in the preview
1218
- if (event.data.type === INTERNAL_EVENTS.STRAPI_FIELD_CHANGE) {
1219
- const { field, value } = event.data.payload;
1707
+ // The user typed in an input, reflect the change in the preview.
1708
+ // 'strapiFieldChange' is a public event name — host apps also depend on it.
1709
+ if (event.data.type === 'strapiFieldChange') {
1710
+ const { field, value, type } = event.data.payload;
1220
1711
  if (!field) return;
1221
1712
 
1713
+ // Blocks fields are identified by the `type` key in the payload (set by
1714
+ // usePreviewInputManager). isBlocksValue is a fallback for any value that
1715
+ // looks like a blocks AST without an explicit type. Both paths delegate to
1716
+ // the host frontend so that null/[] clears are forwarded correctly instead
1717
+ // of falling through to DOM text patching.
1718
+ if (type === 'blocks' || isBlocksValue(value)) {
1719
+ forwardBlocksFieldChange(field, value);
1720
+ return;
1721
+ }
1722
+
1222
1723
  const matchedElements = /** @type {HTMLElement[]} */ (
1223
1724
  Array.from(getElementsByPath(field)).filter((el) => el instanceof HTMLElement)
1224
1725
  );
@@ -1367,6 +1868,15 @@ function previewScript(config) {
1367
1868
  return;
1368
1869
  }
1369
1870
 
1871
+ // The editor popover just closed; rescan groups after the next frame so
1872
+ // any in-flight React renders in the iframe have committed first.
1873
+ if (event.data.type === INTERNAL_EVENTS.STRAPI_RESCAN_HIGHLIGHTS) {
1874
+ requestAnimationFrame(() => {
1875
+ highlightManager.rescan();
1876
+ });
1877
+ return;
1878
+ }
1879
+
1370
1880
  // The user is no longer focusing an input, remove the highlights
1371
1881
  if (event.data.type === INTERNAL_EVENTS.STRAPI_FIELD_BLUR) {
1372
1882
  const { field } = event.data.payload;
@@ -1382,6 +1892,13 @@ function previewScript(config) {
1382
1892
 
1383
1893
  window.addEventListener('message', handleMessage);
1384
1894
 
1895
+ // Notify admin of any click on non-highlighted page areas so it can close an open popover.
1896
+ // Highlight click handlers call stopPropagation, so this only fires for background clicks.
1897
+ const documentClickHandler = () => {
1898
+ sendMessage(INTERNAL_EVENTS.STRAPI_IFRAME_CLICK, null);
1899
+ };
1900
+ document.addEventListener('click', documentClickHandler);
1901
+
1385
1902
  // Add the message handler to the cleanup list
1386
1903
  const messageEventListener = {
1387
1904
  element: window,
@@ -1389,7 +1906,13 @@ function previewScript(config) {
1389
1906
  handler: /** @type {EventListener} */ (handleMessage),
1390
1907
  };
1391
1908
 
1392
- return [...highlightManager.eventListeners, messageEventListener];
1909
+ const documentClickEventListener = {
1910
+ element: /** @type {any} */ (document),
1911
+ type: /** @type {keyof HTMLElementEventMap} */ ('click'),
1912
+ handler: /** @type {EventListener} */ (documentClickHandler),
1913
+ };
1914
+
1915
+ return [...highlightManager.eventListeners, messageEventListener, documentClickEventListener];
1393
1916
  };
1394
1917
 
1395
1918
  /**
@@ -1417,6 +1940,9 @@ function previewScript(config) {
1417
1940
  // Clear all pending click timeouts
1418
1941
  highlightManager.clearAllPendingClicks();
1419
1942
 
1943
+ // Stop watching blocks-field containers for live resize
1944
+ highlightManager.disconnectContainerObserver();
1945
+
1420
1946
  // Remove highlight event listeners
1421
1947
  eventHandlers.forEach(({ element, type, handler }) => {
1422
1948
  element.removeEventListener(type, handler);