@dotcms/uve 1.6.0 → 1.7.0-next.37

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/public.esm.js CHANGED
@@ -1,6 +1,20 @@
1
1
  import { UVEEventType, UVE_MODE, DotCMSUVEAction } from '@dotcms/types';
2
2
  import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal';
3
3
 
4
+ /**
5
+ * Sentinel values for the placeholder contentlet used when the UVE represents
6
+ * an empty container (e.g. hover / selection without a real contentlet).
7
+ *
8
+ * @internal
9
+ */
10
+ const TEMP_EMPTY_CONTENTLET = 'TEMP_EMPTY_CONTENTLET';
11
+ /**
12
+ * Placeholder `contentType` for {@link TEMP_EMPTY_CONTENTLET}.
13
+ *
14
+ * @internal
15
+ */
16
+ const TEMP_EMPTY_CONTENTLET_TYPE = 'TEMP_EMPTY_CONTENTLET_TYPE';
17
+
4
18
  /**
5
19
  * Calculates the bounding information for each page element within the given containers.
6
20
  *
@@ -241,6 +255,38 @@ function getDotContentletAttributes(contentlet, container) {
241
255
  })
242
256
  };
243
257
  }
258
+ /**
259
+ *
260
+ * Returns the minimal set of contentlet data attributes required by DotCMS
261
+ * Analytics (impression & click tracking) to identify a contentlet.
262
+ *
263
+ * Used in live mode where the full editor metadata is stripped but Analytics
264
+ * still needs to resolve the contentlet behind an impression/click.
265
+ *
266
+ * @param {DotCMSBasicContentlet} contentlet - The contentlet to get the attributes for
267
+ * @returns {DotAnalyticsContentletAttributes} The Analytics-required data attributes
268
+ */
269
+ function getAnalyticsContentletAttributes(contentlet) {
270
+ return {
271
+ 'data-dot-identifier': contentlet?.identifier,
272
+ 'data-dot-inode': contentlet?.inode,
273
+ 'data-dot-title': contentlet?.['widgetTitle'] || contentlet?.title,
274
+ 'data-dot-type': contentlet?.contentType,
275
+ 'data-dot-basetype': contentlet?.baseType
276
+ };
277
+ }
278
+ /**
279
+ *
280
+ * Checks whether DotCMS Analytics is initialized and active on the page.
281
+ *
282
+ * The SDKs use this in live mode to decide whether to keep the minimal
283
+ * contentlet attributes Analytics depends on.
284
+ *
285
+ * @returns {boolean} `true` when analytics is active, otherwise `false`
286
+ */
287
+ function isDotAnalyticsActive() {
288
+ return typeof window !== 'undefined' && window[ANALYTICS_ACTIVE_WINDOW_KEY] === true;
289
+ }
244
290
  /**
245
291
  *
246
292
  *
@@ -340,6 +386,59 @@ function getDotContainerAttributes({
340
386
  'data-dot-uuid': uuid
341
387
  };
342
388
  }
389
+ /**
390
+ * Read a contentlet's dataset attributes off a DOM element and return a
391
+ * normalized contentlet object. Mirrors the shape consumed by the editor's
392
+ * SET_BOUNDS and CONTENTLET_CLICKED events. Optionally parses the
393
+ * `dotStyleProperties` JSON when present.
394
+ */
395
+ function readContentletDataset(element) {
396
+ const dataset = element.dataset ?? {};
397
+ return {
398
+ identifier: dataset['dotIdentifier'],
399
+ title: dataset['dotTitle'],
400
+ inode: dataset['dotInode'],
401
+ contentType: dataset['dotType'],
402
+ baseType: dataset['dotBasetype'],
403
+ widgetTitle: dataset['dotWidgetTitle'],
404
+ onNumberOfPages: dataset['dotOnNumberOfPages'],
405
+ ...(dataset['dotStyleProperties'] && {
406
+ dotStyleProperties: JSON.parse(dataset['dotStyleProperties'])
407
+ })
408
+ };
409
+ }
410
+ /**
411
+ * Returns Zone.js's *unpatched* native `addEventListener` / `removeEventListener`
412
+ * bound to `target`, falling back to the standard methods when Zone.js is absent.
413
+ *
414
+ * UVE reuses a single iframe and rewrites it with `document.open()/write()/close()`
415
+ * on every in-editor navigation. When Zone.js is loaded inside that iframe it runs
416
+ * in global-events mode: one native "gateway" listener per (target, eventType)
417
+ * plus a JS-level task list stored on the target node. `document.open()` tears down
418
+ * the native gateway on the persistent `window`/`document` nodes, but the task list
419
+ * survives on them, so Zone sees "already registered" on re-init and skips
420
+ * re-installing the native gateway — the listener silently goes dead after the
421
+ * first navigation.
422
+ *
423
+ * Hover/click dodge this by binding to `document.documentElement`, a node that
424
+ * `write()` recreates fresh. `scroll`/`message` (on `window`) and
425
+ * `DOMContentLoaded` (on `document`) can't: none of them fire on `<html>`, so
426
+ * there is no fresh node to rebind to. Going through Zone's native (untracked)
427
+ * methods sidesteps the dedup entirely, so the listener rebinds cleanly after
428
+ * every rewrite.
429
+ */
430
+ function getNativeEventBinder(target) {
431
+ const zone = globalThis.Zone;
432
+ const symbolFor = name => typeof zone?.__symbol__ === 'function' ? zone.__symbol__(name) : `__zone_symbol__${name}`;
433
+ const source = target;
434
+ const base = target;
435
+ const nativeAdd = source[symbolFor('addEventListener')];
436
+ const nativeRemove = source[symbolFor('removeEventListener')];
437
+ return {
438
+ addEventListener: (nativeAdd ?? base.addEventListener).bind(base),
439
+ removeEventListener: (nativeRemove ?? base.removeEventListener).bind(base)
440
+ };
441
+ }
343
442
 
344
443
  /**
345
444
  * Subscribes to content changes in the UVE editor
@@ -356,10 +455,12 @@ function onContentChanges(callback) {
356
455
  callback(event.data.payload);
357
456
  }
358
457
  };
359
- window.addEventListener('message', messageCallback);
458
+ // Native binder: survives the iframe rewrite under Zone.js. See getNativeEventBinder.
459
+ const nativeWindow = getNativeEventBinder(window);
460
+ nativeWindow.addEventListener('message', messageCallback);
360
461
  return {
361
462
  unsubscribe: () => {
362
- window.removeEventListener('message', messageCallback);
463
+ nativeWindow.removeEventListener('message', messageCallback);
363
464
  },
364
465
  event: UVEEventType.CONTENT_CHANGES
365
466
  };
@@ -379,37 +480,135 @@ function onPageReload(callback) {
379
480
  callback();
380
481
  }
381
482
  };
382
- window.addEventListener('message', messageCallback);
483
+ // Native binder: survives the iframe rewrite under Zone.js. See getNativeEventBinder.
484
+ const nativeWindow = getNativeEventBinder(window);
485
+ nativeWindow.addEventListener('message', messageCallback);
383
486
  return {
384
487
  unsubscribe: () => {
385
- window.removeEventListener('message', messageCallback);
488
+ nativeWindow.removeEventListener('message', messageCallback);
386
489
  },
387
490
  event: UVEEventType.PAGE_RELOAD
388
491
  };
389
492
  }
493
+ const AUTO_BOUNDS_DEBOUNCE_MS = 100;
390
494
  /**
391
- * Subscribes to request bounds events in the UVE editor
495
+ * The single bounds-sync channel. Observes the iframe document and
496
+ * every `[data-dot-object="container"]` with a single ResizeObserver,
497
+ * debounces the trailing edge by {@link AUTO_BOUNDS_DEBOUNCE_MS}ms, and
498
+ * emits the full `getDotCMSPageBounds(...)` payload whenever the layout
499
+ * settles. Also listens on `scroll` (since scrolling moves contentlets
500
+ * without changing layout) and on `UVE_FLUSH_BOUNDS` (the editor's
501
+ * "give me bounds NOW, skip the debounce" message used during drag).
502
+ *
503
+ * Re-runs `querySelectorAll` and the observer wiring whenever a
504
+ * MutationObserver detects child changes that touch container nodes,
505
+ * so containers that mount/unmount after page-load are picked up
506
+ * automatically.
392
507
  *
393
- * @param {UVEEventHandler} callback - Function to be called when bounds are requested
394
- * @returns {Object} Object containing unsubscribe function and event type
395
- * @returns {Function} .unsubscribe - Function to remove the event listener
396
- * @returns {UVEEventType} .event - The event type being subscribed to
397
508
  * @internal
398
509
  */
399
- function onRequestBounds(callback) {
400
- const messageCallback = event => {
401
- if (event.data.name === __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS) {
402
- const containers = Array.from(document.querySelectorAll('[data-dot-object="container"]'));
403
- const positionData = getDotCMSPageBounds(containers);
404
- callback(positionData);
510
+ function onAutoBounds(callback) {
511
+ let debounceTimer = null;
512
+ let observed = [];
513
+ const emit = () => {
514
+ const containers = Array.from(document.querySelectorAll('[data-dot-object="container"]'));
515
+ callback(getDotCMSPageBounds(containers));
516
+ };
517
+ const scheduleEmit = () => {
518
+ if (debounceTimer !== null) {
519
+ clearTimeout(debounceTimer);
405
520
  }
521
+ debounceTimer = setTimeout(() => {
522
+ debounceTimer = null;
523
+ emit();
524
+ }, AUTO_BOUNDS_DEBOUNCE_MS);
406
525
  };
407
- window.addEventListener('message', messageCallback);
526
+ const resizeObserver = new ResizeObserver(() => {
527
+ scheduleEmit();
528
+ });
529
+ const observeAll = () => {
530
+ // Tear down previous observations before re-wiring.
531
+ for (const el of observed) {
532
+ resizeObserver.unobserve(el);
533
+ }
534
+ observed = Array.from(document.querySelectorAll('[data-dot-object="container"]'));
535
+ resizeObserver.observe(document.documentElement);
536
+ for (const container of observed) {
537
+ resizeObserver.observe(container);
538
+ }
539
+ };
540
+ observeAll();
541
+ // Containers can mount/unmount after the page first paints (route
542
+ // changes in headless apps, lazy-loaded sections, etc.). Re-wire only
543
+ // when a node carrying [data-dot-object="container"] is added or
544
+ // removed — ignoring text/attribute churn keeps this observer cheap on
545
+ // busy pages.
546
+ const containsContainerNode = nodes => {
547
+ for (let i = 0; i < nodes.length; i++) {
548
+ const node = nodes[i];
549
+ if (node.nodeType !== Node.ELEMENT_NODE) {
550
+ continue;
551
+ }
552
+ const el = node;
553
+ if (el.matches?.('[data-dot-object="container"]') || el.querySelector?.('[data-dot-object="container"]')) {
554
+ return true;
555
+ }
556
+ }
557
+ return false;
558
+ };
559
+ const mutationObserver = new MutationObserver(mutations => {
560
+ for (const m of mutations) {
561
+ if (m.type !== 'childList') continue;
562
+ if (containsContainerNode(m.addedNodes) || containsContainerNode(m.removedNodes)) {
563
+ observeAll();
564
+ scheduleEmit();
565
+ return;
566
+ }
567
+ }
568
+ });
569
+ // The SDK script can run from <head> before <body> exists. Fall back to
570
+ // <html> in that case — childList+subtree on the documentElement still
571
+ // catches container nodes that mount once <body> arrives.
572
+ mutationObserver.observe(document.body ?? document.documentElement, {
573
+ childList: true,
574
+ subtree: true
575
+ });
576
+ // Scrolling doesn't change layout (ResizeObserver won't fire) but moves
577
+ // every contentlet, so re-emit bounds after the scroll settles to re-anchor
578
+ // the selected overlay. Native binder so it survives the iframe rewrite
579
+ // under Zone.js. See getNativeEventBinder.
580
+ const nativeWindow = getNativeEventBinder(window);
581
+ const onScroll = () => scheduleEmit();
582
+ nativeWindow.addEventListener('scroll', onScroll, {
583
+ passive: true
584
+ });
585
+ // Flush channel: the editor occasionally needs an immediate snapshot
586
+ // of bounds (drag enter, where the dropzone has to know container
587
+ // rectangles before the user moves another pixel). Bypass the
588
+ // debounce timer and emit synchronously.
589
+ const onFlush = event => {
590
+ if (event?.data?.name !== __DOTCMS_UVE_EVENT__.UVE_FLUSH_BOUNDS) return;
591
+ if (debounceTimer !== null) {
592
+ clearTimeout(debounceTimer);
593
+ debounceTimer = null;
594
+ }
595
+ emit();
596
+ };
597
+ // Native binder (reused from `scroll` above): survives the iframe rewrite too.
598
+ nativeWindow.addEventListener('message', onFlush);
408
599
  return {
409
600
  unsubscribe: () => {
410
- window.removeEventListener('message', messageCallback);
601
+ if (debounceTimer !== null) {
602
+ clearTimeout(debounceTimer);
603
+ debounceTimer = null;
604
+ }
605
+ resizeObserver.disconnect();
606
+ mutationObserver.disconnect();
607
+ nativeWindow.removeEventListener('scroll', onScroll);
608
+ nativeWindow.removeEventListener('message', onFlush);
609
+ observed = [];
411
610
  },
412
- event: UVEEventType.REQUEST_BOUNDS
611
+ event: UVEEventType.AUTO_BOUNDS
413
612
  };
414
613
  }
415
614
  /**
@@ -428,10 +627,12 @@ function onIframeScroll(callback) {
428
627
  callback(direction);
429
628
  }
430
629
  };
431
- window.addEventListener('message', messageCallback);
630
+ // Native binder: survives the iframe rewrite under Zone.js. See getNativeEventBinder.
631
+ const nativeWindow = getNativeEventBinder(window);
632
+ nativeWindow.addEventListener('message', messageCallback);
432
633
  return {
433
634
  unsubscribe: () => {
434
- window.removeEventListener('message', messageCallback);
635
+ nativeWindow.removeEventListener('message', messageCallback);
435
636
  },
436
637
  event: UVEEventType.IFRAME_SCROLL
437
638
  };
@@ -461,27 +662,45 @@ function onScrollToSection(callback) {
461
662
  offsetTop: el.offsetTop
462
663
  });
463
664
  };
464
- window.addEventListener('message', messageCallback);
665
+ // Native binder: survives the iframe rewrite under Zone.js. See getNativeEventBinder.
666
+ const nativeWindow = getNativeEventBinder(window);
667
+ nativeWindow.addEventListener('message', messageCallback);
465
668
  return {
466
669
  unsubscribe: () => {
467
- window.removeEventListener('message', messageCallback);
670
+ nativeWindow.removeEventListener('message', messageCallback);
468
671
  },
469
672
  event: UVEEventType.SCROLL_TO_SECTION
470
673
  };
471
674
  }
472
675
  /**
473
- * Subscribes to contentlet hover events in the UVE editor
676
+ * Subscribes to contentlet hover events in the UVE editor.
677
+ *
678
+ * The callback is invoked with a payload while the pointer is over a
679
+ * DotCMS element, and once with `null` when the pointer leaves the last
680
+ * reported element (transitions onto dead space). The editor uses the
681
+ * `null` signal to clear the hover overlay so it doesn't linger over
682
+ * areas that no longer have a contentlet under the pointer.
474
683
  *
475
- * @param {UVEEventHandler} callback - Function to be called when a contentlet is hovered
684
+ * @param {UVEEventHandler} callback - Function to be called when hover state changes
476
685
  * @returns {Object} Object containing unsubscribe function and event type
477
686
  * @returns {Function} .unsubscribe - Function to remove the event listener
478
687
  * @returns {UVEEventType} .event - The event type being subscribed to
479
688
  * @internal
480
689
  */
481
690
  function onContentletHovered(callback) {
691
+ let hasHover = false;
482
692
  const pointerMoveCallback = event => {
483
693
  const foundElement = findDotCMSElement(event.target);
484
- if (!foundElement) return;
694
+ if (!foundElement) {
695
+ // Transitioning from a hovered contentlet to dead space — emit
696
+ // a single null so the editor can clear its hover overlay.
697
+ // Subsequent moves over dead space are no-ops.
698
+ if (hasHover) {
699
+ hasHover = false;
700
+ callback(null);
701
+ }
702
+ return;
703
+ }
485
704
  const {
486
705
  x,
487
706
  y,
@@ -490,26 +709,15 @@ function onContentletHovered(callback) {
490
709
  } = foundElement.getBoundingClientRect();
491
710
  const isContainer = foundElement.dataset?.['dotObject'] === 'container';
492
711
  const contentletForEmptyContainer = {
493
- identifier: 'TEMP_EMPTY_CONTENTLET',
494
- title: 'TEMP_EMPTY_CONTENTLET',
495
- contentType: 'TEMP_EMPTY_CONTENTLET_TYPE',
712
+ identifier: TEMP_EMPTY_CONTENTLET,
713
+ title: TEMP_EMPTY_CONTENTLET,
714
+ contentType: TEMP_EMPTY_CONTENTLET_TYPE,
496
715
  inode: 'TEMPY_EMPTY_CONTENTLET_INODE',
497
- widgetTitle: 'TEMP_EMPTY_CONTENTLET',
498
- baseType: 'TEMP_EMPTY_CONTENTLET',
716
+ widgetTitle: TEMP_EMPTY_CONTENTLET,
717
+ baseType: TEMP_EMPTY_CONTENTLET,
499
718
  onNumberOfPages: 1
500
719
  };
501
- const contentlet = {
502
- identifier: foundElement.dataset?.['dotIdentifier'],
503
- title: foundElement.dataset?.['dotTitle'],
504
- inode: foundElement.dataset?.['dotInode'],
505
- contentType: foundElement.dataset?.['dotType'],
506
- baseType: foundElement.dataset?.['dotBasetype'],
507
- widgetTitle: foundElement.dataset?.['dotWidgetTitle'],
508
- onNumberOfPages: foundElement.dataset?.['dotOnNumberOfPages'],
509
- ...(foundElement.dataset?.['dotStyleProperties'] && {
510
- dotStyleProperties: JSON.parse(foundElement.dataset['dotStyleProperties'])
511
- })
512
- };
720
+ const contentlet = readContentletDataset(foundElement);
513
721
  const vtlFiles = findDotCMSVTLData(foundElement);
514
722
  const contentletPayload = {
515
723
  container:
@@ -526,16 +734,114 @@ function onContentletHovered(callback) {
526
734
  height,
527
735
  payload: contentletPayload
528
736
  };
737
+ hasHover = true;
529
738
  callback(contentletHoveredPayload);
530
739
  };
531
- document.addEventListener('pointermove', pointerMoveCallback);
740
+ // We intentionally do not fire null on `pointerleave`: the editor's hover
741
+ // toolbar lives in the parent window, so leaving the iframe usually means
742
+ // the user is reaching for it — killing the overlay would yank it away.
743
+ //
744
+ // Bind to `document.documentElement` (a fresh node on each iframe rewrite)
745
+ // rather than `document`, which goes dead under Zone.js after a rewrite.
746
+ // `pointermove` bubbles to <html>, so no-Zone behavior is identical. See
747
+ // getNativeEventBinder for the full rationale.
748
+ document.documentElement.addEventListener('pointermove', pointerMoveCallback);
532
749
  return {
533
750
  unsubscribe: () => {
534
- document.removeEventListener('pointermove', pointerMoveCallback);
751
+ document.documentElement.removeEventListener('pointermove', pointerMoveCallback);
535
752
  },
536
753
  event: UVEEventType.CONTENTLET_HOVERED
537
754
  };
538
755
  }
756
+ /**
757
+ * Subscribes to contentlet click events in the UVE editor.
758
+ *
759
+ * The editor's hover overlay is `pointer-events: none` so wheel events pass
760
+ * through to the iframe. We detect the user's selection click here instead and
761
+ * post it back to the editor.
762
+ *
763
+ * @param {UVEEventHandler} callback - Function to be called when a contentlet is clicked
764
+ * @returns {Object} Object containing unsubscribe function and event type
765
+ * @internal
766
+ */
767
+ function onContentletClicked(callback) {
768
+ // Track the last selected contentlet so a second click on the same one
769
+ // lets the page's native click through (links, accordions, etc.). The
770
+ // first click is "select"; subsequent clicks on the selected contentlet
771
+ // are "interact with the page".
772
+ let lastSelectedInode;
773
+ const clickCallback = event => {
774
+ const foundElement = findDotCMSElement(event.target);
775
+ if (!foundElement) return;
776
+ const isContainer = foundElement.dataset?.['dotObject'] === 'container';
777
+ // Only emit for contentlet clicks; an empty container click is a no-op
778
+ // for selection purposes (there's nothing to select).
779
+ if (isContainer) return;
780
+ const inode = foundElement.dataset?.['dotInode'];
781
+ // If the user is clicking the already-selected contentlet, let the
782
+ // page handle the click natively (link navigation, button handlers,
783
+ // form submission). The editor selection toolbar already exposes the
784
+ // edit/delete/etc actions; the contentlet's own UI should still work.
785
+ if (inode && inode === lastSelectedInode) {
786
+ return;
787
+ }
788
+ // First click on this contentlet (or a different one) — select it in
789
+ // the editor and block the page's natural click. Capture phase +
790
+ // preventDefault + stopPropagation suppresses both the default action
791
+ // and any subscribers further down the tree.
792
+ event.preventDefault();
793
+ event.stopPropagation();
794
+ lastSelectedInode = inode;
795
+ const {
796
+ x,
797
+ y,
798
+ width,
799
+ height
800
+ } = foundElement.getBoundingClientRect();
801
+ const contentlet = readContentletDataset(foundElement);
802
+ const vtlFiles = findDotCMSVTLData(foundElement);
803
+ callback({
804
+ x,
805
+ y,
806
+ width,
807
+ height,
808
+ payload: {
809
+ container: foundElement.dataset?.['dotContainer'] ? JSON.parse(foundElement.dataset?.['dotContainer']) : getClosestDotCMSContainerData(foundElement),
810
+ contentlet,
811
+ vtlFiles
812
+ }
813
+ });
814
+ };
815
+ // The editor clears its selection on canvas resize / scroll. When that
816
+ // happens, our lastSelectedInode is stale: a click on what used to be the
817
+ // selected contentlet would be treated as a passthrough (page click) even
818
+ // though the editor no longer has it selected. Listen for the
819
+ // UVE_SELECTION_CLEARED message and reset the tracker.
820
+ const selectionClearedCallback = event => {
821
+ if (event?.data?.name === __DOTCMS_UVE_EVENT__.UVE_SELECTION_CLEARED) {
822
+ lastSelectedInode = undefined;
823
+ }
824
+ };
825
+ // Capture phase so we run BEFORE the page's own click handlers. Bound to
826
+ // `document.documentElement` (a fresh node on each iframe rewrite) rather
827
+ // than `document`, which goes dead under Zone.js after a rewrite; capture on
828
+ // <html> still runs first. See getNativeEventBinder for the full rationale.
829
+ document.documentElement.addEventListener('click', clickCallback, {
830
+ capture: true
831
+ });
832
+ // Native binder: survives the iframe rewrite under Zone.js. See getNativeEventBinder.
833
+ const nativeWindow = getNativeEventBinder(window);
834
+ nativeWindow.addEventListener('message', selectionClearedCallback);
835
+ return {
836
+ unsubscribe: () => {
837
+ document.documentElement.removeEventListener('click', clickCallback, {
838
+ capture: true
839
+ });
840
+ nativeWindow.removeEventListener('message', selectionClearedCallback);
841
+ },
842
+ event: UVEEventType.CONTENTLET_CLICKED
843
+ };
844
+ }
539
845
 
540
846
  /**
541
847
  * Events that can be subscribed to in the UVE
@@ -550,17 +856,31 @@ const __UVE_EVENTS__ = {
550
856
  [UVEEventType.PAGE_RELOAD]: callback => {
551
857
  return onPageReload(callback);
552
858
  },
553
- [UVEEventType.REQUEST_BOUNDS]: callback => {
554
- return onRequestBounds(callback);
555
- },
556
859
  [UVEEventType.IFRAME_SCROLL]: callback => {
557
860
  return onIframeScroll(callback);
558
861
  },
559
862
  [UVEEventType.CONTENTLET_HOVERED]: callback => {
560
863
  return onContentletHovered(callback);
561
864
  },
865
+ [UVEEventType.CONTENTLET_CLICKED]: callback => {
866
+ return onContentletClicked(callback);
867
+ },
562
868
  [UVEEventType.SCROLL_TO_SECTION]: callback => {
563
869
  return onScrollToSection(callback);
870
+ },
871
+ // SELECTION_CLEARED is editor→SDK only. No public subscriber surface;
872
+ // onContentletClicked listens for the underlying postMessage internally
873
+ // to reset its lastSelectedInode tracker.
874
+ [UVEEventType.SELECTION_CLEARED]: _callback => {
875
+ return {
876
+ unsubscribe: () => {
877
+ /* no-op: SELECTION_CLEARED has no consumer-facing subscription */
878
+ },
879
+ event: UVEEventType.SELECTION_CLEARED
880
+ };
881
+ },
882
+ [UVEEventType.AUTO_BOUNDS]: callback => {
883
+ return onAutoBounds(callback);
564
884
  }
565
885
  };
566
886
  /**
@@ -642,6 +962,29 @@ const CUSTOM_NO_COMPONENT = 'CustomNoComponent';
642
962
  * @internal
643
963
  */
644
964
  const DOT_SECTION_ID_PREFIX = 'dot-section-';
965
+ /**
966
+ * Window flag set by `@dotcms/analytics` when content analytics is initialized
967
+ * and active on the page.
968
+ *
969
+ * @important This value is intentionally duplicated from `@dotcms/analytics`
970
+ * (`ANALYTICS_WINDOWS_ACTIVE_KEY` in dot-analytics.constants.ts). The SDKs read
971
+ * it in live mode to decide whether to keep the minimal contentlet attributes
972
+ * Analytics depends on. Both constants MUST stay in sync.
973
+ *
974
+ * @internal
975
+ */
976
+ const ANALYTICS_ACTIVE_WINDOW_KEY = '__dotAnalyticsActive__';
977
+ /**
978
+ * Event dispatched by `@dotcms/analytics` once analytics is ready. The SDKs
979
+ * listen for it so live-mode contentlets can re-render with the attributes
980
+ * Analytics needs, regardless of initialization order.
981
+ *
982
+ * @important Kept in sync with the `dotcms:analytics:ready` event dispatched by
983
+ * `@dotcms/analytics` (initializeContentAnalytics).
984
+ *
985
+ * @internal
986
+ */
987
+ const ANALYTICS_READY_EVENT = 'dotcms:analytics:ready';
645
988
 
646
989
  /**
647
990
  * Gets the current state of the Universal Visual Editor (UVE).
@@ -728,97 +1071,6 @@ function createUVESubscription(eventType, callback) {
728
1071
  return eventCallback(callback);
729
1072
  }
730
1073
 
731
- /**
732
- * Observes rendered document height changes and notifies the caller after layout settles.
733
- *
734
- * Uses ResizeObserver on <html> for layout/viewport-driven changes and MutationObserver
735
- * on <body> to catch DOM additions/removals that may shrink the page without a resize.
736
- * Measurement reads `body.offsetHeight`, which tracks actual content height and
737
- * decreases correctly after DOM removals, unaffected by CSS min-height on the html element.
738
- */
739
- function observeDocumentHeight({
740
- onHeightChange,
741
- documentRef = document,
742
- windowRef = window,
743
- debounceMs = 50
744
- }) {
745
- const html = documentRef.documentElement;
746
- const body = documentRef.body;
747
- let debounceTimer = null;
748
- let rafOuter = null;
749
- let rafInner = null;
750
- let lastHeight = null;
751
- let destroyed = false;
752
- const measureAndNotify = () => {
753
- const height = body.offsetHeight;
754
- if (!height || height === lastHeight) {
755
- return;
756
- }
757
- lastHeight = height;
758
- onHeightChange(height);
759
- };
760
- const scheduleNotify = () => {
761
- if (destroyed) {
762
- return;
763
- }
764
- if (debounceTimer !== null) {
765
- clearTimeout(debounceTimer);
766
- }
767
- debounceTimer = setTimeout(() => {
768
- debounceTimer = null;
769
- if (destroyed) {
770
- return;
771
- }
772
- rafOuter = windowRef.requestAnimationFrame(() => {
773
- rafOuter = null;
774
- if (destroyed) {
775
- return;
776
- }
777
- rafInner = windowRef.requestAnimationFrame(() => {
778
- rafInner = null;
779
- if (!destroyed) {
780
- measureAndNotify();
781
- }
782
- });
783
- });
784
- }, debounceMs);
785
- };
786
- const onLoad = () => scheduleNotify();
787
- if (documentRef.readyState === 'complete' || documentRef.readyState === 'interactive') {
788
- scheduleNotify();
789
- } else {
790
- windowRef.addEventListener('load', onLoad);
791
- }
792
- const resizeObserver = new ResizeObserver(() => scheduleNotify());
793
- resizeObserver.observe(html);
794
- const mutationObserver = new MutationObserver(() => scheduleNotify());
795
- mutationObserver.observe(documentRef.body ?? html, {
796
- childList: true,
797
- subtree: true
798
- });
799
- return {
800
- destroy: () => {
801
- destroyed = true;
802
- if (debounceTimer !== null) {
803
- clearTimeout(debounceTimer);
804
- debounceTimer = null;
805
- }
806
- if (rafOuter !== null) {
807
- windowRef.cancelAnimationFrame(rafOuter);
808
- rafOuter = null;
809
- }
810
- if (rafInner !== null) {
811
- windowRef.cancelAnimationFrame(rafInner);
812
- rafInner = null;
813
- }
814
- lastHeight = null;
815
- resizeObserver.disconnect();
816
- mutationObserver.disconnect();
817
- windowRef.removeEventListener('load', onLoad);
818
- }
819
- };
820
- }
821
-
822
1074
  /**
823
1075
  * Sets the bounds of the containers in the editor.
824
1076
  * Retrieves the containers from the DOM and sends their position data to the editor.
@@ -921,7 +1173,6 @@ const isValidBlocks = blocks => {
921
1173
  };
922
1174
  };
923
1175
 
924
- /* eslint-disable @typescript-eslint/no-explicit-any */
925
1176
  /**
926
1177
  * Sets up scroll event handlers for the window to notify the editor about scroll events.
927
1178
  * Adds listeners for both 'scroll' and 'scrollend' events, sending appropriate messages
@@ -938,12 +1189,19 @@ function scrollHandler() {
938
1189
  action: DotCMSUVEAction.IFRAME_SCROLL_END
939
1190
  });
940
1191
  };
941
- window.addEventListener('scroll', scrollCallback);
942
- window.addEventListener('scrollend', scrollEndCallback);
1192
+ // Native binder: scroll survives the iframe rewrite under Zone.js. It can't
1193
+ // move to a fresh `documentElement` like hover/click (viewport scroll only
1194
+ // fires on `window`). See getNativeEventBinder.
1195
+ const {
1196
+ addEventListener,
1197
+ removeEventListener
1198
+ } = getNativeEventBinder(window);
1199
+ addEventListener('scroll', scrollCallback);
1200
+ addEventListener('scrollend', scrollEndCallback);
943
1201
  return {
944
1202
  destroyScrollHandler: () => {
945
- window.removeEventListener('scroll', scrollCallback);
946
- window.removeEventListener('scrollend', scrollEndCallback);
1203
+ removeEventListener('scroll', scrollCallback);
1204
+ removeEventListener('scrollend', scrollEndCallback);
947
1205
  }
948
1206
  };
949
1207
  }
@@ -983,9 +1241,6 @@ function registerUVEEvents() {
983
1241
  const pageReloadSubscription = createUVESubscription(UVEEventType.PAGE_RELOAD, () => {
984
1242
  window.location.reload();
985
1243
  });
986
- const requestBoundsSubscription = createUVESubscription(UVEEventType.REQUEST_BOUNDS, bounds => {
987
- setBounds(bounds);
988
- });
989
1244
  const iframeScrollSubscription = createUVESubscription(UVEEventType.IFRAME_SCROLL, direction => {
990
1245
  if (window.scrollY === 0 && direction === 'up' || computeScrollIsInBottom() && direction === 'down') {
991
1246
  // If the iframe scroll is at the top or bottom, do not send anything.
@@ -1005,14 +1260,30 @@ function registerUVEEvents() {
1005
1260
  payload: contentletHovered
1006
1261
  });
1007
1262
  });
1263
+ const contentletClickedSubscription = createUVESubscription(UVEEventType.CONTENTLET_CLICKED, contentletClicked => {
1264
+ sendMessageToUVE({
1265
+ action: DotCMSUVEAction.SET_SELECTED_CONTENTLET,
1266
+ payload: contentletClicked
1267
+ });
1268
+ });
1008
1269
  const scrollToSectionSubscription = createUVESubscription(UVEEventType.SCROLL_TO_SECTION, payload => {
1009
1270
  sendMessageToUVE({
1010
1271
  action: DotCMSUVEAction.SECTION_OFFSET,
1011
1272
  payload
1012
1273
  });
1013
1274
  });
1275
+ // The single bounds-sync channel. The SDK observes layout changes
1276
+ // inside the iframe (media-query reflows, image/font load shifts,
1277
+ // container mount/unmount, scroll, etc.) and emits SET_BOUNDS on the
1278
+ // trailing edge of a debounce window. The editor can also send a
1279
+ // UVE_FLUSH_BOUNDS message to request an immediate synchronous emit
1280
+ // (used during drag/drop, where the dropzone needs current bounds
1281
+ // before the user moves another pixel).
1282
+ const autoBoundsSubscription = createUVESubscription(UVEEventType.AUTO_BOUNDS, bounds => {
1283
+ setBounds(bounds);
1284
+ });
1014
1285
  return {
1015
- subscriptions: [pageReloadSubscription, requestBoundsSubscription, iframeScrollSubscription, contentletHoveredSubscription, scrollToSectionSubscription]
1286
+ subscriptions: [pageReloadSubscription, iframeScrollSubscription, contentletHoveredSubscription, contentletClickedSubscription, scrollToSectionSubscription, autoBoundsSubscription]
1016
1287
  };
1017
1288
  }
1018
1289
  /**
@@ -1044,68 +1315,19 @@ function listenBlockEditorInlineEvent() {
1044
1315
  }
1045
1316
  };
1046
1317
  }
1047
- // If the page is not fully loaded, listen for the DOMContentLoaded event
1318
+ // Native binder: `DOMContentLoaded` fires on `document` (not `<html>`), so
1319
+ // it survives the iframe rewrite under Zone.js. See getNativeEventBinder.
1048
1320
  const handleDOMContentLoaded = () => {
1049
1321
  listenBlockEditorClick();
1050
1322
  };
1051
- document.addEventListener('DOMContentLoaded', handleDOMContentLoaded);
1052
- return {
1053
- destroyListenBlockEditorInlineEvent: () => {
1054
- document.removeEventListener('DOMContentLoaded', handleDOMContentLoaded);
1055
- }
1056
- };
1057
- }
1058
- /**
1059
- * Returns whether iframe height must be synchronized via postMessage.
1060
- *
1061
- * Same-origin parents can measure iframe content directly, so they do not need
1062
- * child-driven height reporting. Cross-origin parents cannot access the iframe
1063
- * DOM, so they still need the reporter fallback.
1064
- */
1065
- function shouldReportIframeHeightToParent() {
1066
- if (window.parent === window) {
1067
- return false;
1068
- }
1069
- try {
1070
- const parentDocument = window.parent.document;
1071
- return !parentDocument;
1072
- } catch {
1073
- return true;
1074
- }
1075
- }
1076
- /**
1077
- * Reports the iframe document height to the parent UVE shell via postMessage.
1078
- *
1079
- * Uses ResizeObserver on <html> for viewport/font/image-driven size changes, and
1080
- * MutationObserver on <body> to catch DOM removals (e.g. contentlets deleted by
1081
- * the editor) that shrink the page without triggering a resize event.
1082
- *
1083
- * Measurement reads `document.documentElement.offsetHeight` — the actual rendered
1084
- * height of the <html> element after layout. `scrollHeight` is intentionally avoided
1085
- * because it does not reliably decrease when content is removed from the DOM.
1086
- *
1087
- * Height sends are coalesced to at most one per double-requestAnimationFrame pair
1088
- * so they always run after layout and paint have settled.
1089
- *
1090
- * @returns {{ destroyHeightReporter: () => void }} Cleanup function that removes
1091
- * all listeners and disconnects the observers.
1092
- */
1093
- function reportIframeHeight() {
1094
1323
  const {
1095
- destroy
1096
- } = observeDocumentHeight({
1097
- onHeightChange: height => {
1098
- sendMessageToUVE({
1099
- action: DotCMSUVEAction.IFRAME_HEIGHT,
1100
- payload: {
1101
- height
1102
- }
1103
- });
1104
- }
1105
- });
1324
+ addEventListener,
1325
+ removeEventListener
1326
+ } = getNativeEventBinder(document);
1327
+ addEventListener('DOMContentLoaded', handleDOMContentLoaded);
1106
1328
  return {
1107
- destroyHeightReporter: () => {
1108
- destroy();
1329
+ destroyListenBlockEditorInlineEvent: () => {
1330
+ removeEventListener('DOMContentLoaded', handleDOMContentLoaded);
1109
1331
  }
1110
1332
  };
1111
1333
  }
@@ -1314,19 +1536,13 @@ function initUVE(config = {}) {
1314
1536
  const {
1315
1537
  destroyListenBlockEditorInlineEvent
1316
1538
  } = listenBlockEditorInlineEvent();
1317
- const {
1318
- destroyHeightReporter
1319
- } = shouldReportIframeHeightToParent() ? reportIframeHeight() : {
1320
- destroyHeightReporter: () => undefined
1321
- };
1322
1539
  return {
1323
1540
  destroyUVESubscriptions: () => {
1324
1541
  subscriptions.forEach(subscription => subscription.unsubscribe());
1325
1542
  destroyScrollHandler();
1326
1543
  destroyListenBlockEditorInlineEvent();
1327
- destroyHeightReporter();
1328
1544
  }
1329
1545
  };
1330
1546
  }
1331
1547
 
1332
- export { getDotContentletAttributes as A, isValidBlocks as B, CUSTOM_NO_COMPONENT as C, DEVELOPMENT_MODE as D, EMPTY_CONTAINER_STYLE_ANGULAR as E, observeDocumentHeight as F, setBounds as G, PRODUCTION_MODE as P, START_CLASS as S, __UVE_EVENTS__ as _, createUVESubscription as a, enableBlockEditorInline as b, createContentlet as c, initUVE as d, editContentlet as e, DOT_SECTION_ID_PREFIX as f, getUVEState as g, EMPTY_CONTAINER_STYLE_REACT as h, initInlineEditing as i, END_CLASS as j, __UVE_EVENT_ERROR_FALLBACK__ as k, combineClasses as l, computeScrollIsInBottom as m, findDotCMSElement as n, findDotCMSVTLData as o, getClosestDotCMSContainerData as p, getColumnPositionClasses as q, reorderMenu as r, sendMessageToUVE as s, getContainersData as t, updateNavigation as u, getContentletsInContainer as v, getDotCMSContainerData as w, getDotCMSContentletsBound as x, getDotCMSPageBounds as y, getDotContainerAttributes as z };
1548
+ export { ANALYTICS_ACTIVE_WINDOW_KEY as A, getAnalyticsContentletAttributes as B, CUSTOM_NO_COMPONENT as C, DEVELOPMENT_MODE as D, END_CLASS as E, isDotAnalyticsActive as F, getContainersData as G, getContentletsInContainer as H, getDotContainerAttributes as I, readContentletDataset as J, getNativeEventBinder as K, setBounds as L, isValidBlocks as M, PRODUCTION_MODE as P, START_CLASS as S, TEMP_EMPTY_CONTENTLET as T, __UVE_EVENTS__ as _, enableBlockEditorInline as a, createContentlet as b, createUVESubscription as c, initUVE as d, editContentlet as e, __UVE_EVENT_ERROR_FALLBACK__ as f, getUVEState as g, EMPTY_CONTAINER_STYLE_REACT as h, initInlineEditing as i, EMPTY_CONTAINER_STYLE_ANGULAR as j, DOT_SECTION_ID_PREFIX as k, ANALYTICS_READY_EVENT as l, TEMP_EMPTY_CONTENTLET_TYPE as m, getDotCMSPageBounds as n, getDotCMSContentletsBound as o, getDotCMSContainerData as p, getClosestDotCMSContainerData as q, reorderMenu as r, sendMessageToUVE as s, findDotCMSElement as t, updateNavigation as u, findDotCMSVTLData as v, computeScrollIsInBottom as w, combineClasses as x, getColumnPositionClasses as y, getDotContentletAttributes as z };