@dotcms/uve 1.5.1-next.2010 → 1.5.1-next.2023

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/internal.cjs.js CHANGED
@@ -398,7 +398,7 @@ exports.getDotContainerAttributes = _public.getDotContainerAttributes;
398
398
  exports.getDotContentletAttributes = _public.getDotContentletAttributes;
399
399
  exports.getUVEState = _public.getUVEState;
400
400
  exports.isValidBlocks = _public.isValidBlocks;
401
- exports.observeDocumentHeight = _public.observeDocumentHeight;
401
+ exports.readContentletDataset = _public.readContentletDataset;
402
402
  exports.setBounds = _public.setBounds;
403
403
  exports.__BASE_TINYMCE_CONFIG_WITH_NO_DEFAULT__ = __BASE_TINYMCE_CONFIG_WITH_NO_DEFAULT__;
404
404
  exports.__DEFAULT_TINYMCE_CONFIG__ = __DEFAULT_TINYMCE_CONFIG__;
package/internal.esm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { g as getUVEState, s as sendMessageToUVE } from './public.esm.js';
2
- export { C as CUSTOM_NO_COMPONENT, D as DEVELOPMENT_MODE, f as DOT_SECTION_ID_PREFIX, E as EMPTY_CONTAINER_STYLE_ANGULAR, h as EMPTY_CONTAINER_STYLE_REACT, j as END_CLASS, P as PRODUCTION_MODE, S as START_CLASS, T as TEMP_EMPTY_CONTENTLET, k as TEMP_EMPTY_CONTENTLET_TYPE, _ as __UVE_EVENTS__, l as __UVE_EVENT_ERROR_FALLBACK__, m as combineClasses, n as computeScrollIsInBottom, a as createUVESubscription, o as findDotCMSElement, p as findDotCMSVTLData, q as getClosestDotCMSContainerData, t as getColumnPositionClasses, v as getContainersData, w as getContentletsInContainer, x as getDotCMSContainerData, y as getDotCMSContentletsBound, z as getDotCMSPageBounds, A as getDotContainerAttributes, B as getDotContentletAttributes, F as isValidBlocks, G as observeDocumentHeight, H as setBounds } from './public.esm.js';
2
+ export { C as CUSTOM_NO_COMPONENT, D as DEVELOPMENT_MODE, f as DOT_SECTION_ID_PREFIX, E as EMPTY_CONTAINER_STYLE_ANGULAR, h as EMPTY_CONTAINER_STYLE_REACT, j as END_CLASS, P as PRODUCTION_MODE, S as START_CLASS, T as TEMP_EMPTY_CONTENTLET, k as TEMP_EMPTY_CONTENTLET_TYPE, _ as __UVE_EVENTS__, l as __UVE_EVENT_ERROR_FALLBACK__, m as combineClasses, n as computeScrollIsInBottom, a as createUVESubscription, o as findDotCMSElement, p as findDotCMSVTLData, q as getClosestDotCMSContainerData, t as getColumnPositionClasses, v as getContainersData, w as getContentletsInContainer, x as getDotCMSContainerData, y as getDotCMSContentletsBound, z as getDotCMSPageBounds, A as getDotContainerAttributes, B as getDotContentletAttributes, F as isValidBlocks, G as readContentletDataset, H as setBounds } from './public.esm.js';
3
3
  import { UVE_MODE, DotCMSUVEAction } from '@dotcms/types';
4
4
  import '@dotcms/types/internal';
5
5
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotcms/uve",
3
- "version": "1.5.1-next.2010",
3
+ "version": "1.5.1-next.2023",
4
4
  "description": "Official JavaScript library for interacting with Universal Visual Editor (UVE)",
5
5
  "repository": {
6
6
  "type": "git",
package/public.cjs.js CHANGED
@@ -356,6 +356,27 @@ function getDotContainerAttributes({
356
356
  'data-dot-uuid': uuid
357
357
  };
358
358
  }
359
+ /**
360
+ * Read a contentlet's dataset attributes off a DOM element and return a
361
+ * normalized contentlet object. Mirrors the shape consumed by the editor's
362
+ * SET_BOUNDS and CONTENTLET_CLICKED events. Optionally parses the
363
+ * `dotStyleProperties` JSON when present.
364
+ */
365
+ function readContentletDataset(element) {
366
+ const dataset = element.dataset ?? {};
367
+ return {
368
+ identifier: dataset['dotIdentifier'],
369
+ title: dataset['dotTitle'],
370
+ inode: dataset['dotInode'],
371
+ contentType: dataset['dotType'],
372
+ baseType: dataset['dotBasetype'],
373
+ widgetTitle: dataset['dotWidgetTitle'],
374
+ onNumberOfPages: dataset['dotOnNumberOfPages'],
375
+ ...(dataset['dotStyleProperties'] && {
376
+ dotStyleProperties: JSON.parse(dataset['dotStyleProperties'])
377
+ })
378
+ };
379
+ }
359
380
 
360
381
  /**
361
382
  * Subscribes to content changes in the UVE editor
@@ -403,29 +424,124 @@ function onPageReload(callback) {
403
424
  event: types.UVEEventType.PAGE_RELOAD
404
425
  };
405
426
  }
427
+ const AUTO_BOUNDS_DEBOUNCE_MS = 100;
406
428
  /**
407
- * Subscribes to request bounds events in the UVE editor
429
+ * The single bounds-sync channel. Observes the iframe document and
430
+ * every `[data-dot-object="container"]` with a single ResizeObserver,
431
+ * debounces the trailing edge by {@link AUTO_BOUNDS_DEBOUNCE_MS}ms, and
432
+ * emits the full `getDotCMSPageBounds(...)` payload whenever the layout
433
+ * settles. Also listens on `scroll` (since scrolling moves contentlets
434
+ * without changing layout) and on `UVE_FLUSH_BOUNDS` (the editor's
435
+ * "give me bounds NOW, skip the debounce" message used during drag).
436
+ *
437
+ * Re-runs `querySelectorAll` and the observer wiring whenever a
438
+ * MutationObserver detects child changes that touch container nodes,
439
+ * so containers that mount/unmount after page-load are picked up
440
+ * automatically.
408
441
  *
409
- * @param {UVEEventHandler} callback - Function to be called when bounds are requested
410
- * @returns {Object} Object containing unsubscribe function and event type
411
- * @returns {Function} .unsubscribe - Function to remove the event listener
412
- * @returns {UVEEventType} .event - The event type being subscribed to
413
442
  * @internal
414
443
  */
415
- function onRequestBounds(callback) {
416
- const messageCallback = event => {
417
- if (event.data.name === internal.__DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS) {
418
- const containers = Array.from(document.querySelectorAll('[data-dot-object="container"]'));
419
- const positionData = getDotCMSPageBounds(containers);
420
- callback(positionData);
444
+ function onAutoBounds(callback) {
445
+ let debounceTimer = null;
446
+ let observed = [];
447
+ const emit = () => {
448
+ const containers = Array.from(document.querySelectorAll('[data-dot-object="container"]'));
449
+ callback(getDotCMSPageBounds(containers));
450
+ };
451
+ const scheduleEmit = () => {
452
+ if (debounceTimer !== null) {
453
+ clearTimeout(debounceTimer);
454
+ }
455
+ debounceTimer = setTimeout(() => {
456
+ debounceTimer = null;
457
+ emit();
458
+ }, AUTO_BOUNDS_DEBOUNCE_MS);
459
+ };
460
+ const resizeObserver = new ResizeObserver(() => {
461
+ scheduleEmit();
462
+ });
463
+ const observeAll = () => {
464
+ // Tear down previous observations before re-wiring.
465
+ for (const el of observed) {
466
+ resizeObserver.unobserve(el);
467
+ }
468
+ observed = Array.from(document.querySelectorAll('[data-dot-object="container"]'));
469
+ resizeObserver.observe(document.documentElement);
470
+ for (const container of observed) {
471
+ resizeObserver.observe(container);
421
472
  }
422
473
  };
423
- window.addEventListener('message', messageCallback);
474
+ observeAll();
475
+ // Containers can mount/unmount after the page first paints (route
476
+ // changes in headless apps, lazy-loaded sections, etc.). Re-wire only
477
+ // when a node carrying [data-dot-object="container"] is added or
478
+ // removed — ignoring text/attribute churn keeps this observer cheap on
479
+ // busy pages.
480
+ const containsContainerNode = nodes => {
481
+ for (let i = 0; i < nodes.length; i++) {
482
+ const node = nodes[i];
483
+ if (node.nodeType !== Node.ELEMENT_NODE) {
484
+ continue;
485
+ }
486
+ const el = node;
487
+ if (el.matches?.('[data-dot-object="container"]') || el.querySelector?.('[data-dot-object="container"]')) {
488
+ return true;
489
+ }
490
+ }
491
+ return false;
492
+ };
493
+ const mutationObserver = new MutationObserver(mutations => {
494
+ for (const m of mutations) {
495
+ if (m.type !== 'childList') continue;
496
+ if (containsContainerNode(m.addedNodes) || containsContainerNode(m.removedNodes)) {
497
+ observeAll();
498
+ scheduleEmit();
499
+ return;
500
+ }
501
+ }
502
+ });
503
+ // The SDK script can run from <head> before <body> exists. Fall back to
504
+ // <html> in that case — childList+subtree on the documentElement still
505
+ // catches container nodes that mount once <body> arrives.
506
+ mutationObserver.observe(document.body ?? document.documentElement, {
507
+ childList: true,
508
+ subtree: true
509
+ });
510
+ // Scrolling inside the iframe doesn't change layout, so ResizeObserver
511
+ // doesn't fire, but every contentlet's viewport-relative position
512
+ // (getBoundingClientRect) does change. Re-emit bounds after each
513
+ // scroll burst settles so the editor's pinned selected overlay
514
+ // re-anchors to the on-screen position.
515
+ const onScroll = () => scheduleEmit();
516
+ window.addEventListener('scroll', onScroll, {
517
+ passive: true
518
+ });
519
+ // Flush channel: the editor occasionally needs an immediate snapshot
520
+ // of bounds (drag enter, where the dropzone has to know container
521
+ // rectangles before the user moves another pixel). Bypass the
522
+ // debounce timer and emit synchronously.
523
+ const onFlush = event => {
524
+ if (event?.data?.name !== internal.__DOTCMS_UVE_EVENT__.UVE_FLUSH_BOUNDS) return;
525
+ if (debounceTimer !== null) {
526
+ clearTimeout(debounceTimer);
527
+ debounceTimer = null;
528
+ }
529
+ emit();
530
+ };
531
+ window.addEventListener('message', onFlush);
424
532
  return {
425
533
  unsubscribe: () => {
426
- window.removeEventListener('message', messageCallback);
534
+ if (debounceTimer !== null) {
535
+ clearTimeout(debounceTimer);
536
+ debounceTimer = null;
537
+ }
538
+ resizeObserver.disconnect();
539
+ mutationObserver.disconnect();
540
+ window.removeEventListener('scroll', onScroll);
541
+ window.removeEventListener('message', onFlush);
542
+ observed = [];
427
543
  },
428
- event: types.UVEEventType.REQUEST_BOUNDS
544
+ event: types.UVEEventType.AUTO_BOUNDS
429
545
  };
430
546
  }
431
547
  /**
@@ -486,18 +602,34 @@ function onScrollToSection(callback) {
486
602
  };
487
603
  }
488
604
  /**
489
- * Subscribes to contentlet hover events in the UVE editor
605
+ * Subscribes to contentlet hover events in the UVE editor.
606
+ *
607
+ * The callback is invoked with a payload while the pointer is over a
608
+ * DotCMS element, and once with `null` when the pointer leaves the last
609
+ * reported element (transitions onto dead space). The editor uses the
610
+ * `null` signal to clear the hover overlay so it doesn't linger over
611
+ * areas that no longer have a contentlet under the pointer.
490
612
  *
491
- * @param {UVEEventHandler} callback - Function to be called when a contentlet is hovered
613
+ * @param {UVEEventHandler} callback - Function to be called when hover state changes
492
614
  * @returns {Object} Object containing unsubscribe function and event type
493
615
  * @returns {Function} .unsubscribe - Function to remove the event listener
494
616
  * @returns {UVEEventType} .event - The event type being subscribed to
495
617
  * @internal
496
618
  */
497
619
  function onContentletHovered(callback) {
620
+ let hasHover = false;
498
621
  const pointerMoveCallback = event => {
499
622
  const foundElement = findDotCMSElement(event.target);
500
- if (!foundElement) return;
623
+ if (!foundElement) {
624
+ // Transitioning from a hovered contentlet to dead space — emit
625
+ // a single null so the editor can clear its hover overlay.
626
+ // Subsequent moves over dead space are no-ops.
627
+ if (hasHover) {
628
+ hasHover = false;
629
+ callback(null);
630
+ }
631
+ return;
632
+ }
501
633
  const {
502
634
  x,
503
635
  y,
@@ -514,18 +646,7 @@ function onContentletHovered(callback) {
514
646
  baseType: TEMP_EMPTY_CONTENTLET,
515
647
  onNumberOfPages: 1
516
648
  };
517
- const contentlet = {
518
- identifier: foundElement.dataset?.['dotIdentifier'],
519
- title: foundElement.dataset?.['dotTitle'],
520
- inode: foundElement.dataset?.['dotInode'],
521
- contentType: foundElement.dataset?.['dotType'],
522
- baseType: foundElement.dataset?.['dotBasetype'],
523
- widgetTitle: foundElement.dataset?.['dotWidgetTitle'],
524
- onNumberOfPages: foundElement.dataset?.['dotOnNumberOfPages'],
525
- ...(foundElement.dataset?.['dotStyleProperties'] && {
526
- dotStyleProperties: JSON.parse(foundElement.dataset['dotStyleProperties'])
527
- })
528
- };
649
+ const contentlet = readContentletDataset(foundElement);
529
650
  const vtlFiles = findDotCMSVTLData(foundElement);
530
651
  const contentletPayload = {
531
652
  container:
@@ -542,8 +663,15 @@ function onContentletHovered(callback) {
542
663
  height,
543
664
  payload: contentletPayload
544
665
  };
666
+ hasHover = true;
545
667
  callback(contentletHoveredPayload);
546
668
  };
669
+ // We intentionally do not fire null on document `pointerleave`: the
670
+ // editor's hover toolbar lives in the parent window (outside the
671
+ // iframe), so leaving the iframe usually means the user is heading
672
+ // for the toolbar. Killing the overlay there would yank the toolbar
673
+ // away just as the user reaches for it. Dead-space-inside-iframe
674
+ // is already covered by the `pointermove` null branch above.
547
675
  document.addEventListener('pointermove', pointerMoveCallback);
548
676
  return {
549
677
  unsubscribe: () => {
@@ -552,6 +680,91 @@ function onContentletHovered(callback) {
552
680
  event: types.UVEEventType.CONTENTLET_HOVERED
553
681
  };
554
682
  }
683
+ /**
684
+ * Subscribes to contentlet click events in the UVE editor.
685
+ *
686
+ * The editor's hover overlay is `pointer-events: none` so wheel events pass
687
+ * through to the iframe. We detect the user's selection click here instead and
688
+ * post it back to the editor.
689
+ *
690
+ * @param {UVEEventHandler} callback - Function to be called when a contentlet is clicked
691
+ * @returns {Object} Object containing unsubscribe function and event type
692
+ * @internal
693
+ */
694
+ function onContentletClicked(callback) {
695
+ // Track the last selected contentlet so a second click on the same one
696
+ // lets the page's native click through (links, accordions, etc.). The
697
+ // first click is "select"; subsequent clicks on the selected contentlet
698
+ // are "interact with the page".
699
+ let lastSelectedInode;
700
+ const clickCallback = event => {
701
+ const foundElement = findDotCMSElement(event.target);
702
+ if (!foundElement) return;
703
+ const isContainer = foundElement.dataset?.['dotObject'] === 'container';
704
+ // Only emit for contentlet clicks; an empty container click is a no-op
705
+ // for selection purposes (there's nothing to select).
706
+ if (isContainer) return;
707
+ const inode = foundElement.dataset?.['dotInode'];
708
+ // If the user is clicking the already-selected contentlet, let the
709
+ // page handle the click natively (link navigation, button handlers,
710
+ // form submission). The editor selection toolbar already exposes the
711
+ // edit/delete/etc actions; the contentlet's own UI should still work.
712
+ if (inode && inode === lastSelectedInode) {
713
+ return;
714
+ }
715
+ // First click on this contentlet (or a different one) — select it in
716
+ // the editor and block the page's natural click. Capture phase +
717
+ // preventDefault + stopPropagation suppresses both the default action
718
+ // and any subscribers further down the tree.
719
+ event.preventDefault();
720
+ event.stopPropagation();
721
+ lastSelectedInode = inode;
722
+ const {
723
+ x,
724
+ y,
725
+ width,
726
+ height
727
+ } = foundElement.getBoundingClientRect();
728
+ const contentlet = readContentletDataset(foundElement);
729
+ const vtlFiles = findDotCMSVTLData(foundElement);
730
+ callback({
731
+ x,
732
+ y,
733
+ width,
734
+ height,
735
+ payload: {
736
+ container: foundElement.dataset?.['dotContainer'] ? JSON.parse(foundElement.dataset?.['dotContainer']) : getClosestDotCMSContainerData(foundElement),
737
+ contentlet,
738
+ vtlFiles
739
+ }
740
+ });
741
+ };
742
+ // The editor clears its selection on canvas resize / scroll. When that
743
+ // happens, our lastSelectedInode is stale: a click on what used to be the
744
+ // selected contentlet would be treated as a passthrough (page click) even
745
+ // though the editor no longer has it selected. Listen for the
746
+ // UVE_SELECTION_CLEARED message and reset the tracker.
747
+ const selectionClearedCallback = event => {
748
+ if (event?.data?.name === internal.__DOTCMS_UVE_EVENT__.UVE_SELECTION_CLEARED) {
749
+ lastSelectedInode = undefined;
750
+ }
751
+ };
752
+ // Capture phase so we run BEFORE the page's own click handlers and can
753
+ // preventDefault/stopPropagation effectively.
754
+ document.addEventListener('click', clickCallback, {
755
+ capture: true
756
+ });
757
+ window.addEventListener('message', selectionClearedCallback);
758
+ return {
759
+ unsubscribe: () => {
760
+ document.removeEventListener('click', clickCallback, {
761
+ capture: true
762
+ });
763
+ window.removeEventListener('message', selectionClearedCallback);
764
+ },
765
+ event: types.UVEEventType.CONTENTLET_CLICKED
766
+ };
767
+ }
555
768
 
556
769
  /**
557
770
  * Events that can be subscribed to in the UVE
@@ -566,17 +779,31 @@ const __UVE_EVENTS__ = {
566
779
  [types.UVEEventType.PAGE_RELOAD]: callback => {
567
780
  return onPageReload(callback);
568
781
  },
569
- [types.UVEEventType.REQUEST_BOUNDS]: callback => {
570
- return onRequestBounds(callback);
571
- },
572
782
  [types.UVEEventType.IFRAME_SCROLL]: callback => {
573
783
  return onIframeScroll(callback);
574
784
  },
575
785
  [types.UVEEventType.CONTENTLET_HOVERED]: callback => {
576
786
  return onContentletHovered(callback);
577
787
  },
788
+ [types.UVEEventType.CONTENTLET_CLICKED]: callback => {
789
+ return onContentletClicked(callback);
790
+ },
578
791
  [types.UVEEventType.SCROLL_TO_SECTION]: callback => {
579
792
  return onScrollToSection(callback);
793
+ },
794
+ // SELECTION_CLEARED is editor→SDK only. No public subscriber surface;
795
+ // onContentletClicked listens for the underlying postMessage internally
796
+ // to reset its lastSelectedInode tracker.
797
+ [types.UVEEventType.SELECTION_CLEARED]: _callback => {
798
+ return {
799
+ unsubscribe: () => {
800
+ /* no-op: SELECTION_CLEARED has no consumer-facing subscription */
801
+ },
802
+ event: types.UVEEventType.SELECTION_CLEARED
803
+ };
804
+ },
805
+ [types.UVEEventType.AUTO_BOUNDS]: callback => {
806
+ return onAutoBounds(callback);
580
807
  }
581
808
  };
582
809
  /**
@@ -744,97 +971,6 @@ function createUVESubscription(eventType, callback) {
744
971
  return eventCallback(callback);
745
972
  }
746
973
 
747
- /**
748
- * Observes rendered document height changes and notifies the caller after layout settles.
749
- *
750
- * Uses ResizeObserver on <html> for layout/viewport-driven changes and MutationObserver
751
- * on <body> to catch DOM additions/removals that may shrink the page without a resize.
752
- * Measurement reads `body.offsetHeight`, which tracks actual content height and
753
- * decreases correctly after DOM removals, unaffected by CSS min-height on the html element.
754
- */
755
- function observeDocumentHeight({
756
- onHeightChange,
757
- documentRef = document,
758
- windowRef = window,
759
- debounceMs = 50
760
- }) {
761
- const html = documentRef.documentElement;
762
- const body = documentRef.body;
763
- let debounceTimer = null;
764
- let rafOuter = null;
765
- let rafInner = null;
766
- let lastHeight = null;
767
- let destroyed = false;
768
- const measureAndNotify = () => {
769
- const height = body.offsetHeight;
770
- if (!height || height === lastHeight) {
771
- return;
772
- }
773
- lastHeight = height;
774
- onHeightChange(height);
775
- };
776
- const scheduleNotify = () => {
777
- if (destroyed) {
778
- return;
779
- }
780
- if (debounceTimer !== null) {
781
- clearTimeout(debounceTimer);
782
- }
783
- debounceTimer = setTimeout(() => {
784
- debounceTimer = null;
785
- if (destroyed) {
786
- return;
787
- }
788
- rafOuter = windowRef.requestAnimationFrame(() => {
789
- rafOuter = null;
790
- if (destroyed) {
791
- return;
792
- }
793
- rafInner = windowRef.requestAnimationFrame(() => {
794
- rafInner = null;
795
- if (!destroyed) {
796
- measureAndNotify();
797
- }
798
- });
799
- });
800
- }, debounceMs);
801
- };
802
- const onLoad = () => scheduleNotify();
803
- if (documentRef.readyState === 'complete' || documentRef.readyState === 'interactive') {
804
- scheduleNotify();
805
- } else {
806
- windowRef.addEventListener('load', onLoad);
807
- }
808
- const resizeObserver = new ResizeObserver(() => scheduleNotify());
809
- resizeObserver.observe(html);
810
- const mutationObserver = new MutationObserver(() => scheduleNotify());
811
- mutationObserver.observe(documentRef.body ?? html, {
812
- childList: true,
813
- subtree: true
814
- });
815
- return {
816
- destroy: () => {
817
- destroyed = true;
818
- if (debounceTimer !== null) {
819
- clearTimeout(debounceTimer);
820
- debounceTimer = null;
821
- }
822
- if (rafOuter !== null) {
823
- windowRef.cancelAnimationFrame(rafOuter);
824
- rafOuter = null;
825
- }
826
- if (rafInner !== null) {
827
- windowRef.cancelAnimationFrame(rafInner);
828
- rafInner = null;
829
- }
830
- lastHeight = null;
831
- resizeObserver.disconnect();
832
- mutationObserver.disconnect();
833
- windowRef.removeEventListener('load', onLoad);
834
- }
835
- };
836
- }
837
-
838
974
  /**
839
975
  * Sets the bounds of the containers in the editor.
840
976
  * Retrieves the containers from the DOM and sends their position data to the editor.
@@ -999,9 +1135,6 @@ function registerUVEEvents() {
999
1135
  const pageReloadSubscription = createUVESubscription(types.UVEEventType.PAGE_RELOAD, () => {
1000
1136
  window.location.reload();
1001
1137
  });
1002
- const requestBoundsSubscription = createUVESubscription(types.UVEEventType.REQUEST_BOUNDS, bounds => {
1003
- setBounds(bounds);
1004
- });
1005
1138
  const iframeScrollSubscription = createUVESubscription(types.UVEEventType.IFRAME_SCROLL, direction => {
1006
1139
  if (window.scrollY === 0 && direction === 'up' || computeScrollIsInBottom() && direction === 'down') {
1007
1140
  // If the iframe scroll is at the top or bottom, do not send anything.
@@ -1021,14 +1154,30 @@ function registerUVEEvents() {
1021
1154
  payload: contentletHovered
1022
1155
  });
1023
1156
  });
1157
+ const contentletClickedSubscription = createUVESubscription(types.UVEEventType.CONTENTLET_CLICKED, contentletClicked => {
1158
+ sendMessageToUVE({
1159
+ action: types.DotCMSUVEAction.SET_SELECTED_CONTENTLET,
1160
+ payload: contentletClicked
1161
+ });
1162
+ });
1024
1163
  const scrollToSectionSubscription = createUVESubscription(types.UVEEventType.SCROLL_TO_SECTION, payload => {
1025
1164
  sendMessageToUVE({
1026
1165
  action: types.DotCMSUVEAction.SECTION_OFFSET,
1027
1166
  payload
1028
1167
  });
1029
1168
  });
1169
+ // The single bounds-sync channel. The SDK observes layout changes
1170
+ // inside the iframe (media-query reflows, image/font load shifts,
1171
+ // container mount/unmount, scroll, etc.) and emits SET_BOUNDS on the
1172
+ // trailing edge of a debounce window. The editor can also send a
1173
+ // UVE_FLUSH_BOUNDS message to request an immediate synchronous emit
1174
+ // (used during drag/drop, where the dropzone needs current bounds
1175
+ // before the user moves another pixel).
1176
+ const autoBoundsSubscription = createUVESubscription(types.UVEEventType.AUTO_BOUNDS, bounds => {
1177
+ setBounds(bounds);
1178
+ });
1030
1179
  return {
1031
- subscriptions: [pageReloadSubscription, requestBoundsSubscription, iframeScrollSubscription, contentletHoveredSubscription, scrollToSectionSubscription]
1180
+ subscriptions: [pageReloadSubscription, iframeScrollSubscription, contentletHoveredSubscription, contentletClickedSubscription, scrollToSectionSubscription, autoBoundsSubscription]
1032
1181
  };
1033
1182
  }
1034
1183
  /**
@@ -1071,60 +1220,6 @@ function listenBlockEditorInlineEvent() {
1071
1220
  }
1072
1221
  };
1073
1222
  }
1074
- /**
1075
- * Returns whether iframe height must be synchronized via postMessage.
1076
- *
1077
- * Same-origin parents can measure iframe content directly, so they do not need
1078
- * child-driven height reporting. Cross-origin parents cannot access the iframe
1079
- * DOM, so they still need the reporter fallback.
1080
- */
1081
- function shouldReportIframeHeightToParent() {
1082
- if (window.parent === window) {
1083
- return false;
1084
- }
1085
- try {
1086
- const parentDocument = window.parent.document;
1087
- return !parentDocument;
1088
- } catch {
1089
- return true;
1090
- }
1091
- }
1092
- /**
1093
- * Reports the iframe document height to the parent UVE shell via postMessage.
1094
- *
1095
- * Uses ResizeObserver on <html> for viewport/font/image-driven size changes, and
1096
- * MutationObserver on <body> to catch DOM removals (e.g. contentlets deleted by
1097
- * the editor) that shrink the page without triggering a resize event.
1098
- *
1099
- * Measurement reads `document.documentElement.offsetHeight` — the actual rendered
1100
- * height of the <html> element after layout. `scrollHeight` is intentionally avoided
1101
- * because it does not reliably decrease when content is removed from the DOM.
1102
- *
1103
- * Height sends are coalesced to at most one per double-requestAnimationFrame pair
1104
- * so they always run after layout and paint have settled.
1105
- *
1106
- * @returns {{ destroyHeightReporter: () => void }} Cleanup function that removes
1107
- * all listeners and disconnects the observers.
1108
- */
1109
- function reportIframeHeight() {
1110
- const {
1111
- destroy
1112
- } = observeDocumentHeight({
1113
- onHeightChange: height => {
1114
- sendMessageToUVE({
1115
- action: types.DotCMSUVEAction.IFRAME_HEIGHT,
1116
- payload: {
1117
- height
1118
- }
1119
- });
1120
- }
1121
- });
1122
- return {
1123
- destroyHeightReporter: () => {
1124
- destroy();
1125
- }
1126
- };
1127
- }
1128
1223
  const listenBlockEditorClick = () => {
1129
1224
  const editBlockEditorNodes = document.querySelectorAll('[data-block-editor-content]');
1130
1225
  if (!editBlockEditorNodes.length) {
@@ -1330,17 +1425,11 @@ function initUVE(config = {}) {
1330
1425
  const {
1331
1426
  destroyListenBlockEditorInlineEvent
1332
1427
  } = listenBlockEditorInlineEvent();
1333
- const {
1334
- destroyHeightReporter
1335
- } = shouldReportIframeHeightToParent() ? reportIframeHeight() : {
1336
- destroyHeightReporter: () => undefined
1337
- };
1338
1428
  return {
1339
1429
  destroyUVESubscriptions: () => {
1340
1430
  subscriptions.forEach(subscription => subscription.unsubscribe());
1341
1431
  destroyScrollHandler();
1342
1432
  destroyListenBlockEditorInlineEvent();
1343
- destroyHeightReporter();
1344
1433
  }
1345
1434
  };
1346
1435
  }
@@ -1378,7 +1467,7 @@ exports.getUVEState = getUVEState;
1378
1467
  exports.initInlineEditing = initInlineEditing;
1379
1468
  exports.initUVE = initUVE;
1380
1469
  exports.isValidBlocks = isValidBlocks;
1381
- exports.observeDocumentHeight = observeDocumentHeight;
1470
+ exports.readContentletDataset = readContentletDataset;
1382
1471
  exports.reorderMenu = reorderMenu;
1383
1472
  exports.sendMessageToUVE = sendMessageToUVE;
1384
1473
  exports.setBounds = setBounds;
package/public.esm.js CHANGED
@@ -354,6 +354,27 @@ function getDotContainerAttributes({
354
354
  'data-dot-uuid': uuid
355
355
  };
356
356
  }
357
+ /**
358
+ * Read a contentlet's dataset attributes off a DOM element and return a
359
+ * normalized contentlet object. Mirrors the shape consumed by the editor's
360
+ * SET_BOUNDS and CONTENTLET_CLICKED events. Optionally parses the
361
+ * `dotStyleProperties` JSON when present.
362
+ */
363
+ function readContentletDataset(element) {
364
+ const dataset = element.dataset ?? {};
365
+ return {
366
+ identifier: dataset['dotIdentifier'],
367
+ title: dataset['dotTitle'],
368
+ inode: dataset['dotInode'],
369
+ contentType: dataset['dotType'],
370
+ baseType: dataset['dotBasetype'],
371
+ widgetTitle: dataset['dotWidgetTitle'],
372
+ onNumberOfPages: dataset['dotOnNumberOfPages'],
373
+ ...(dataset['dotStyleProperties'] && {
374
+ dotStyleProperties: JSON.parse(dataset['dotStyleProperties'])
375
+ })
376
+ };
377
+ }
357
378
 
358
379
  /**
359
380
  * Subscribes to content changes in the UVE editor
@@ -401,29 +422,124 @@ function onPageReload(callback) {
401
422
  event: UVEEventType.PAGE_RELOAD
402
423
  };
403
424
  }
425
+ const AUTO_BOUNDS_DEBOUNCE_MS = 100;
404
426
  /**
405
- * Subscribes to request bounds events in the UVE editor
427
+ * The single bounds-sync channel. Observes the iframe document and
428
+ * every `[data-dot-object="container"]` with a single ResizeObserver,
429
+ * debounces the trailing edge by {@link AUTO_BOUNDS_DEBOUNCE_MS}ms, and
430
+ * emits the full `getDotCMSPageBounds(...)` payload whenever the layout
431
+ * settles. Also listens on `scroll` (since scrolling moves contentlets
432
+ * without changing layout) and on `UVE_FLUSH_BOUNDS` (the editor's
433
+ * "give me bounds NOW, skip the debounce" message used during drag).
434
+ *
435
+ * Re-runs `querySelectorAll` and the observer wiring whenever a
436
+ * MutationObserver detects child changes that touch container nodes,
437
+ * so containers that mount/unmount after page-load are picked up
438
+ * automatically.
406
439
  *
407
- * @param {UVEEventHandler} callback - Function to be called when bounds are requested
408
- * @returns {Object} Object containing unsubscribe function and event type
409
- * @returns {Function} .unsubscribe - Function to remove the event listener
410
- * @returns {UVEEventType} .event - The event type being subscribed to
411
440
  * @internal
412
441
  */
413
- function onRequestBounds(callback) {
414
- const messageCallback = event => {
415
- if (event.data.name === __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS) {
416
- const containers = Array.from(document.querySelectorAll('[data-dot-object="container"]'));
417
- const positionData = getDotCMSPageBounds(containers);
418
- callback(positionData);
442
+ function onAutoBounds(callback) {
443
+ let debounceTimer = null;
444
+ let observed = [];
445
+ const emit = () => {
446
+ const containers = Array.from(document.querySelectorAll('[data-dot-object="container"]'));
447
+ callback(getDotCMSPageBounds(containers));
448
+ };
449
+ const scheduleEmit = () => {
450
+ if (debounceTimer !== null) {
451
+ clearTimeout(debounceTimer);
452
+ }
453
+ debounceTimer = setTimeout(() => {
454
+ debounceTimer = null;
455
+ emit();
456
+ }, AUTO_BOUNDS_DEBOUNCE_MS);
457
+ };
458
+ const resizeObserver = new ResizeObserver(() => {
459
+ scheduleEmit();
460
+ });
461
+ const observeAll = () => {
462
+ // Tear down previous observations before re-wiring.
463
+ for (const el of observed) {
464
+ resizeObserver.unobserve(el);
465
+ }
466
+ observed = Array.from(document.querySelectorAll('[data-dot-object="container"]'));
467
+ resizeObserver.observe(document.documentElement);
468
+ for (const container of observed) {
469
+ resizeObserver.observe(container);
419
470
  }
420
471
  };
421
- window.addEventListener('message', messageCallback);
472
+ observeAll();
473
+ // Containers can mount/unmount after the page first paints (route
474
+ // changes in headless apps, lazy-loaded sections, etc.). Re-wire only
475
+ // when a node carrying [data-dot-object="container"] is added or
476
+ // removed — ignoring text/attribute churn keeps this observer cheap on
477
+ // busy pages.
478
+ const containsContainerNode = nodes => {
479
+ for (let i = 0; i < nodes.length; i++) {
480
+ const node = nodes[i];
481
+ if (node.nodeType !== Node.ELEMENT_NODE) {
482
+ continue;
483
+ }
484
+ const el = node;
485
+ if (el.matches?.('[data-dot-object="container"]') || el.querySelector?.('[data-dot-object="container"]')) {
486
+ return true;
487
+ }
488
+ }
489
+ return false;
490
+ };
491
+ const mutationObserver = new MutationObserver(mutations => {
492
+ for (const m of mutations) {
493
+ if (m.type !== 'childList') continue;
494
+ if (containsContainerNode(m.addedNodes) || containsContainerNode(m.removedNodes)) {
495
+ observeAll();
496
+ scheduleEmit();
497
+ return;
498
+ }
499
+ }
500
+ });
501
+ // The SDK script can run from <head> before <body> exists. Fall back to
502
+ // <html> in that case — childList+subtree on the documentElement still
503
+ // catches container nodes that mount once <body> arrives.
504
+ mutationObserver.observe(document.body ?? document.documentElement, {
505
+ childList: true,
506
+ subtree: true
507
+ });
508
+ // Scrolling inside the iframe doesn't change layout, so ResizeObserver
509
+ // doesn't fire, but every contentlet's viewport-relative position
510
+ // (getBoundingClientRect) does change. Re-emit bounds after each
511
+ // scroll burst settles so the editor's pinned selected overlay
512
+ // re-anchors to the on-screen position.
513
+ const onScroll = () => scheduleEmit();
514
+ window.addEventListener('scroll', onScroll, {
515
+ passive: true
516
+ });
517
+ // Flush channel: the editor occasionally needs an immediate snapshot
518
+ // of bounds (drag enter, where the dropzone has to know container
519
+ // rectangles before the user moves another pixel). Bypass the
520
+ // debounce timer and emit synchronously.
521
+ const onFlush = event => {
522
+ if (event?.data?.name !== __DOTCMS_UVE_EVENT__.UVE_FLUSH_BOUNDS) return;
523
+ if (debounceTimer !== null) {
524
+ clearTimeout(debounceTimer);
525
+ debounceTimer = null;
526
+ }
527
+ emit();
528
+ };
529
+ window.addEventListener('message', onFlush);
422
530
  return {
423
531
  unsubscribe: () => {
424
- window.removeEventListener('message', messageCallback);
532
+ if (debounceTimer !== null) {
533
+ clearTimeout(debounceTimer);
534
+ debounceTimer = null;
535
+ }
536
+ resizeObserver.disconnect();
537
+ mutationObserver.disconnect();
538
+ window.removeEventListener('scroll', onScroll);
539
+ window.removeEventListener('message', onFlush);
540
+ observed = [];
425
541
  },
426
- event: UVEEventType.REQUEST_BOUNDS
542
+ event: UVEEventType.AUTO_BOUNDS
427
543
  };
428
544
  }
429
545
  /**
@@ -484,18 +600,34 @@ function onScrollToSection(callback) {
484
600
  };
485
601
  }
486
602
  /**
487
- * Subscribes to contentlet hover events in the UVE editor
603
+ * Subscribes to contentlet hover events in the UVE editor.
604
+ *
605
+ * The callback is invoked with a payload while the pointer is over a
606
+ * DotCMS element, and once with `null` when the pointer leaves the last
607
+ * reported element (transitions onto dead space). The editor uses the
608
+ * `null` signal to clear the hover overlay so it doesn't linger over
609
+ * areas that no longer have a contentlet under the pointer.
488
610
  *
489
- * @param {UVEEventHandler} callback - Function to be called when a contentlet is hovered
611
+ * @param {UVEEventHandler} callback - Function to be called when hover state changes
490
612
  * @returns {Object} Object containing unsubscribe function and event type
491
613
  * @returns {Function} .unsubscribe - Function to remove the event listener
492
614
  * @returns {UVEEventType} .event - The event type being subscribed to
493
615
  * @internal
494
616
  */
495
617
  function onContentletHovered(callback) {
618
+ let hasHover = false;
496
619
  const pointerMoveCallback = event => {
497
620
  const foundElement = findDotCMSElement(event.target);
498
- if (!foundElement) return;
621
+ if (!foundElement) {
622
+ // Transitioning from a hovered contentlet to dead space — emit
623
+ // a single null so the editor can clear its hover overlay.
624
+ // Subsequent moves over dead space are no-ops.
625
+ if (hasHover) {
626
+ hasHover = false;
627
+ callback(null);
628
+ }
629
+ return;
630
+ }
499
631
  const {
500
632
  x,
501
633
  y,
@@ -512,18 +644,7 @@ function onContentletHovered(callback) {
512
644
  baseType: TEMP_EMPTY_CONTENTLET,
513
645
  onNumberOfPages: 1
514
646
  };
515
- const contentlet = {
516
- identifier: foundElement.dataset?.['dotIdentifier'],
517
- title: foundElement.dataset?.['dotTitle'],
518
- inode: foundElement.dataset?.['dotInode'],
519
- contentType: foundElement.dataset?.['dotType'],
520
- baseType: foundElement.dataset?.['dotBasetype'],
521
- widgetTitle: foundElement.dataset?.['dotWidgetTitle'],
522
- onNumberOfPages: foundElement.dataset?.['dotOnNumberOfPages'],
523
- ...(foundElement.dataset?.['dotStyleProperties'] && {
524
- dotStyleProperties: JSON.parse(foundElement.dataset['dotStyleProperties'])
525
- })
526
- };
647
+ const contentlet = readContentletDataset(foundElement);
527
648
  const vtlFiles = findDotCMSVTLData(foundElement);
528
649
  const contentletPayload = {
529
650
  container:
@@ -540,8 +661,15 @@ function onContentletHovered(callback) {
540
661
  height,
541
662
  payload: contentletPayload
542
663
  };
664
+ hasHover = true;
543
665
  callback(contentletHoveredPayload);
544
666
  };
667
+ // We intentionally do not fire null on document `pointerleave`: the
668
+ // editor's hover toolbar lives in the parent window (outside the
669
+ // iframe), so leaving the iframe usually means the user is heading
670
+ // for the toolbar. Killing the overlay there would yank the toolbar
671
+ // away just as the user reaches for it. Dead-space-inside-iframe
672
+ // is already covered by the `pointermove` null branch above.
545
673
  document.addEventListener('pointermove', pointerMoveCallback);
546
674
  return {
547
675
  unsubscribe: () => {
@@ -550,6 +678,91 @@ function onContentletHovered(callback) {
550
678
  event: UVEEventType.CONTENTLET_HOVERED
551
679
  };
552
680
  }
681
+ /**
682
+ * Subscribes to contentlet click events in the UVE editor.
683
+ *
684
+ * The editor's hover overlay is `pointer-events: none` so wheel events pass
685
+ * through to the iframe. We detect the user's selection click here instead and
686
+ * post it back to the editor.
687
+ *
688
+ * @param {UVEEventHandler} callback - Function to be called when a contentlet is clicked
689
+ * @returns {Object} Object containing unsubscribe function and event type
690
+ * @internal
691
+ */
692
+ function onContentletClicked(callback) {
693
+ // Track the last selected contentlet so a second click on the same one
694
+ // lets the page's native click through (links, accordions, etc.). The
695
+ // first click is "select"; subsequent clicks on the selected contentlet
696
+ // are "interact with the page".
697
+ let lastSelectedInode;
698
+ const clickCallback = event => {
699
+ const foundElement = findDotCMSElement(event.target);
700
+ if (!foundElement) return;
701
+ const isContainer = foundElement.dataset?.['dotObject'] === 'container';
702
+ // Only emit for contentlet clicks; an empty container click is a no-op
703
+ // for selection purposes (there's nothing to select).
704
+ if (isContainer) return;
705
+ const inode = foundElement.dataset?.['dotInode'];
706
+ // If the user is clicking the already-selected contentlet, let the
707
+ // page handle the click natively (link navigation, button handlers,
708
+ // form submission). The editor selection toolbar already exposes the
709
+ // edit/delete/etc actions; the contentlet's own UI should still work.
710
+ if (inode && inode === lastSelectedInode) {
711
+ return;
712
+ }
713
+ // First click on this contentlet (or a different one) — select it in
714
+ // the editor and block the page's natural click. Capture phase +
715
+ // preventDefault + stopPropagation suppresses both the default action
716
+ // and any subscribers further down the tree.
717
+ event.preventDefault();
718
+ event.stopPropagation();
719
+ lastSelectedInode = inode;
720
+ const {
721
+ x,
722
+ y,
723
+ width,
724
+ height
725
+ } = foundElement.getBoundingClientRect();
726
+ const contentlet = readContentletDataset(foundElement);
727
+ const vtlFiles = findDotCMSVTLData(foundElement);
728
+ callback({
729
+ x,
730
+ y,
731
+ width,
732
+ height,
733
+ payload: {
734
+ container: foundElement.dataset?.['dotContainer'] ? JSON.parse(foundElement.dataset?.['dotContainer']) : getClosestDotCMSContainerData(foundElement),
735
+ contentlet,
736
+ vtlFiles
737
+ }
738
+ });
739
+ };
740
+ // The editor clears its selection on canvas resize / scroll. When that
741
+ // happens, our lastSelectedInode is stale: a click on what used to be the
742
+ // selected contentlet would be treated as a passthrough (page click) even
743
+ // though the editor no longer has it selected. Listen for the
744
+ // UVE_SELECTION_CLEARED message and reset the tracker.
745
+ const selectionClearedCallback = event => {
746
+ if (event?.data?.name === __DOTCMS_UVE_EVENT__.UVE_SELECTION_CLEARED) {
747
+ lastSelectedInode = undefined;
748
+ }
749
+ };
750
+ // Capture phase so we run BEFORE the page's own click handlers and can
751
+ // preventDefault/stopPropagation effectively.
752
+ document.addEventListener('click', clickCallback, {
753
+ capture: true
754
+ });
755
+ window.addEventListener('message', selectionClearedCallback);
756
+ return {
757
+ unsubscribe: () => {
758
+ document.removeEventListener('click', clickCallback, {
759
+ capture: true
760
+ });
761
+ window.removeEventListener('message', selectionClearedCallback);
762
+ },
763
+ event: UVEEventType.CONTENTLET_CLICKED
764
+ };
765
+ }
553
766
 
554
767
  /**
555
768
  * Events that can be subscribed to in the UVE
@@ -564,17 +777,31 @@ const __UVE_EVENTS__ = {
564
777
  [UVEEventType.PAGE_RELOAD]: callback => {
565
778
  return onPageReload(callback);
566
779
  },
567
- [UVEEventType.REQUEST_BOUNDS]: callback => {
568
- return onRequestBounds(callback);
569
- },
570
780
  [UVEEventType.IFRAME_SCROLL]: callback => {
571
781
  return onIframeScroll(callback);
572
782
  },
573
783
  [UVEEventType.CONTENTLET_HOVERED]: callback => {
574
784
  return onContentletHovered(callback);
575
785
  },
786
+ [UVEEventType.CONTENTLET_CLICKED]: callback => {
787
+ return onContentletClicked(callback);
788
+ },
576
789
  [UVEEventType.SCROLL_TO_SECTION]: callback => {
577
790
  return onScrollToSection(callback);
791
+ },
792
+ // SELECTION_CLEARED is editor→SDK only. No public subscriber surface;
793
+ // onContentletClicked listens for the underlying postMessage internally
794
+ // to reset its lastSelectedInode tracker.
795
+ [UVEEventType.SELECTION_CLEARED]: _callback => {
796
+ return {
797
+ unsubscribe: () => {
798
+ /* no-op: SELECTION_CLEARED has no consumer-facing subscription */
799
+ },
800
+ event: UVEEventType.SELECTION_CLEARED
801
+ };
802
+ },
803
+ [UVEEventType.AUTO_BOUNDS]: callback => {
804
+ return onAutoBounds(callback);
578
805
  }
579
806
  };
580
807
  /**
@@ -742,97 +969,6 @@ function createUVESubscription(eventType, callback) {
742
969
  return eventCallback(callback);
743
970
  }
744
971
 
745
- /**
746
- * Observes rendered document height changes and notifies the caller after layout settles.
747
- *
748
- * Uses ResizeObserver on <html> for layout/viewport-driven changes and MutationObserver
749
- * on <body> to catch DOM additions/removals that may shrink the page without a resize.
750
- * Measurement reads `body.offsetHeight`, which tracks actual content height and
751
- * decreases correctly after DOM removals, unaffected by CSS min-height on the html element.
752
- */
753
- function observeDocumentHeight({
754
- onHeightChange,
755
- documentRef = document,
756
- windowRef = window,
757
- debounceMs = 50
758
- }) {
759
- const html = documentRef.documentElement;
760
- const body = documentRef.body;
761
- let debounceTimer = null;
762
- let rafOuter = null;
763
- let rafInner = null;
764
- let lastHeight = null;
765
- let destroyed = false;
766
- const measureAndNotify = () => {
767
- const height = body.offsetHeight;
768
- if (!height || height === lastHeight) {
769
- return;
770
- }
771
- lastHeight = height;
772
- onHeightChange(height);
773
- };
774
- const scheduleNotify = () => {
775
- if (destroyed) {
776
- return;
777
- }
778
- if (debounceTimer !== null) {
779
- clearTimeout(debounceTimer);
780
- }
781
- debounceTimer = setTimeout(() => {
782
- debounceTimer = null;
783
- if (destroyed) {
784
- return;
785
- }
786
- rafOuter = windowRef.requestAnimationFrame(() => {
787
- rafOuter = null;
788
- if (destroyed) {
789
- return;
790
- }
791
- rafInner = windowRef.requestAnimationFrame(() => {
792
- rafInner = null;
793
- if (!destroyed) {
794
- measureAndNotify();
795
- }
796
- });
797
- });
798
- }, debounceMs);
799
- };
800
- const onLoad = () => scheduleNotify();
801
- if (documentRef.readyState === 'complete' || documentRef.readyState === 'interactive') {
802
- scheduleNotify();
803
- } else {
804
- windowRef.addEventListener('load', onLoad);
805
- }
806
- const resizeObserver = new ResizeObserver(() => scheduleNotify());
807
- resizeObserver.observe(html);
808
- const mutationObserver = new MutationObserver(() => scheduleNotify());
809
- mutationObserver.observe(documentRef.body ?? html, {
810
- childList: true,
811
- subtree: true
812
- });
813
- return {
814
- destroy: () => {
815
- destroyed = true;
816
- if (debounceTimer !== null) {
817
- clearTimeout(debounceTimer);
818
- debounceTimer = null;
819
- }
820
- if (rafOuter !== null) {
821
- windowRef.cancelAnimationFrame(rafOuter);
822
- rafOuter = null;
823
- }
824
- if (rafInner !== null) {
825
- windowRef.cancelAnimationFrame(rafInner);
826
- rafInner = null;
827
- }
828
- lastHeight = null;
829
- resizeObserver.disconnect();
830
- mutationObserver.disconnect();
831
- windowRef.removeEventListener('load', onLoad);
832
- }
833
- };
834
- }
835
-
836
972
  /**
837
973
  * Sets the bounds of the containers in the editor.
838
974
  * Retrieves the containers from the DOM and sends their position data to the editor.
@@ -997,9 +1133,6 @@ function registerUVEEvents() {
997
1133
  const pageReloadSubscription = createUVESubscription(UVEEventType.PAGE_RELOAD, () => {
998
1134
  window.location.reload();
999
1135
  });
1000
- const requestBoundsSubscription = createUVESubscription(UVEEventType.REQUEST_BOUNDS, bounds => {
1001
- setBounds(bounds);
1002
- });
1003
1136
  const iframeScrollSubscription = createUVESubscription(UVEEventType.IFRAME_SCROLL, direction => {
1004
1137
  if (window.scrollY === 0 && direction === 'up' || computeScrollIsInBottom() && direction === 'down') {
1005
1138
  // If the iframe scroll is at the top or bottom, do not send anything.
@@ -1019,14 +1152,30 @@ function registerUVEEvents() {
1019
1152
  payload: contentletHovered
1020
1153
  });
1021
1154
  });
1155
+ const contentletClickedSubscription = createUVESubscription(UVEEventType.CONTENTLET_CLICKED, contentletClicked => {
1156
+ sendMessageToUVE({
1157
+ action: DotCMSUVEAction.SET_SELECTED_CONTENTLET,
1158
+ payload: contentletClicked
1159
+ });
1160
+ });
1022
1161
  const scrollToSectionSubscription = createUVESubscription(UVEEventType.SCROLL_TO_SECTION, payload => {
1023
1162
  sendMessageToUVE({
1024
1163
  action: DotCMSUVEAction.SECTION_OFFSET,
1025
1164
  payload
1026
1165
  });
1027
1166
  });
1167
+ // The single bounds-sync channel. The SDK observes layout changes
1168
+ // inside the iframe (media-query reflows, image/font load shifts,
1169
+ // container mount/unmount, scroll, etc.) and emits SET_BOUNDS on the
1170
+ // trailing edge of a debounce window. The editor can also send a
1171
+ // UVE_FLUSH_BOUNDS message to request an immediate synchronous emit
1172
+ // (used during drag/drop, where the dropzone needs current bounds
1173
+ // before the user moves another pixel).
1174
+ const autoBoundsSubscription = createUVESubscription(UVEEventType.AUTO_BOUNDS, bounds => {
1175
+ setBounds(bounds);
1176
+ });
1028
1177
  return {
1029
- subscriptions: [pageReloadSubscription, requestBoundsSubscription, iframeScrollSubscription, contentletHoveredSubscription, scrollToSectionSubscription]
1178
+ subscriptions: [pageReloadSubscription, iframeScrollSubscription, contentletHoveredSubscription, contentletClickedSubscription, scrollToSectionSubscription, autoBoundsSubscription]
1030
1179
  };
1031
1180
  }
1032
1181
  /**
@@ -1069,60 +1218,6 @@ function listenBlockEditorInlineEvent() {
1069
1218
  }
1070
1219
  };
1071
1220
  }
1072
- /**
1073
- * Returns whether iframe height must be synchronized via postMessage.
1074
- *
1075
- * Same-origin parents can measure iframe content directly, so they do not need
1076
- * child-driven height reporting. Cross-origin parents cannot access the iframe
1077
- * DOM, so they still need the reporter fallback.
1078
- */
1079
- function shouldReportIframeHeightToParent() {
1080
- if (window.parent === window) {
1081
- return false;
1082
- }
1083
- try {
1084
- const parentDocument = window.parent.document;
1085
- return !parentDocument;
1086
- } catch {
1087
- return true;
1088
- }
1089
- }
1090
- /**
1091
- * Reports the iframe document height to the parent UVE shell via postMessage.
1092
- *
1093
- * Uses ResizeObserver on <html> for viewport/font/image-driven size changes, and
1094
- * MutationObserver on <body> to catch DOM removals (e.g. contentlets deleted by
1095
- * the editor) that shrink the page without triggering a resize event.
1096
- *
1097
- * Measurement reads `document.documentElement.offsetHeight` — the actual rendered
1098
- * height of the <html> element after layout. `scrollHeight` is intentionally avoided
1099
- * because it does not reliably decrease when content is removed from the DOM.
1100
- *
1101
- * Height sends are coalesced to at most one per double-requestAnimationFrame pair
1102
- * so they always run after layout and paint have settled.
1103
- *
1104
- * @returns {{ destroyHeightReporter: () => void }} Cleanup function that removes
1105
- * all listeners and disconnects the observers.
1106
- */
1107
- function reportIframeHeight() {
1108
- const {
1109
- destroy
1110
- } = observeDocumentHeight({
1111
- onHeightChange: height => {
1112
- sendMessageToUVE({
1113
- action: DotCMSUVEAction.IFRAME_HEIGHT,
1114
- payload: {
1115
- height
1116
- }
1117
- });
1118
- }
1119
- });
1120
- return {
1121
- destroyHeightReporter: () => {
1122
- destroy();
1123
- }
1124
- };
1125
- }
1126
1221
  const listenBlockEditorClick = () => {
1127
1222
  const editBlockEditorNodes = document.querySelectorAll('[data-block-editor-content]');
1128
1223
  if (!editBlockEditorNodes.length) {
@@ -1328,19 +1423,13 @@ function initUVE(config = {}) {
1328
1423
  const {
1329
1424
  destroyListenBlockEditorInlineEvent
1330
1425
  } = listenBlockEditorInlineEvent();
1331
- const {
1332
- destroyHeightReporter
1333
- } = shouldReportIframeHeightToParent() ? reportIframeHeight() : {
1334
- destroyHeightReporter: () => undefined
1335
- };
1336
1426
  return {
1337
1427
  destroyUVESubscriptions: () => {
1338
1428
  subscriptions.forEach(subscription => subscription.unsubscribe());
1339
1429
  destroyScrollHandler();
1340
1430
  destroyListenBlockEditorInlineEvent();
1341
- destroyHeightReporter();
1342
1431
  }
1343
1432
  };
1344
1433
  }
1345
1434
 
1346
- export { getDotContainerAttributes as A, getDotContentletAttributes as B, CUSTOM_NO_COMPONENT as C, DEVELOPMENT_MODE as D, EMPTY_CONTAINER_STYLE_ANGULAR as E, isValidBlocks as F, observeDocumentHeight as G, setBounds as H, PRODUCTION_MODE as P, START_CLASS as S, TEMP_EMPTY_CONTENTLET as T, __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, TEMP_EMPTY_CONTENTLET_TYPE as k, __UVE_EVENT_ERROR_FALLBACK__ as l, combineClasses as m, computeScrollIsInBottom as n, findDotCMSElement as o, findDotCMSVTLData as p, getClosestDotCMSContainerData as q, reorderMenu as r, sendMessageToUVE as s, getColumnPositionClasses as t, updateNavigation as u, getContainersData as v, getContentletsInContainer as w, getDotCMSContainerData as x, getDotCMSContentletsBound as y, getDotCMSPageBounds as z };
1435
+ export { getDotContainerAttributes as A, getDotContentletAttributes as B, CUSTOM_NO_COMPONENT as C, DEVELOPMENT_MODE as D, EMPTY_CONTAINER_STYLE_ANGULAR as E, isValidBlocks as F, readContentletDataset as G, setBounds as H, PRODUCTION_MODE as P, START_CLASS as S, TEMP_EMPTY_CONTENTLET as T, __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, TEMP_EMPTY_CONTENTLET_TYPE as k, __UVE_EVENT_ERROR_FALLBACK__ as l, combineClasses as m, computeScrollIsInBottom as n, findDotCMSElement as o, findDotCMSVTLData as p, getClosestDotCMSContainerData as q, reorderMenu as r, sendMessageToUVE as s, getColumnPositionClasses as t, updateNavigation as u, getContainersData as v, getContentletsInContainer as w, getDotCMSContainerData as x, getDotCMSContentletsBound as y, getDotCMSPageBounds as z };
@@ -26,15 +26,22 @@ export declare function onPageReload(callback: UVEEventHandler): {
26
26
  event: UVEEventType;
27
27
  };
28
28
  /**
29
- * Subscribes to request bounds events in the UVE editor
29
+ * The single bounds-sync channel. Observes the iframe document and
30
+ * every `[data-dot-object="container"]` with a single ResizeObserver,
31
+ * debounces the trailing edge by {@link AUTO_BOUNDS_DEBOUNCE_MS}ms, and
32
+ * emits the full `getDotCMSPageBounds(...)` payload whenever the layout
33
+ * settles. Also listens on `scroll` (since scrolling moves contentlets
34
+ * without changing layout) and on `UVE_FLUSH_BOUNDS` (the editor's
35
+ * "give me bounds NOW, skip the debounce" message used during drag).
36
+ *
37
+ * Re-runs `querySelectorAll` and the observer wiring whenever a
38
+ * MutationObserver detects child changes that touch container nodes,
39
+ * so containers that mount/unmount after page-load are picked up
40
+ * automatically.
30
41
  *
31
- * @param {UVEEventHandler} callback - Function to be called when bounds are requested
32
- * @returns {Object} Object containing unsubscribe function and event type
33
- * @returns {Function} .unsubscribe - Function to remove the event listener
34
- * @returns {UVEEventType} .event - The event type being subscribed to
35
42
  * @internal
36
43
  */
37
- export declare function onRequestBounds(callback: UVEEventHandler): {
44
+ export declare function onAutoBounds(callback: UVEEventHandler): {
38
45
  unsubscribe: () => void;
39
46
  event: UVEEventType;
40
47
  };
@@ -66,9 +73,15 @@ export declare function onScrollToSection(callback: UVEEventHandler): {
66
73
  event: UVEEventType;
67
74
  };
68
75
  /**
69
- * Subscribes to contentlet hover events in the UVE editor
76
+ * Subscribes to contentlet hover events in the UVE editor.
70
77
  *
71
- * @param {UVEEventHandler} callback - Function to be called when a contentlet is hovered
78
+ * The callback is invoked with a payload while the pointer is over a
79
+ * DotCMS element, and once with `null` when the pointer leaves the last
80
+ * reported element (transitions onto dead space). The editor uses the
81
+ * `null` signal to clear the hover overlay so it doesn't linger over
82
+ * areas that no longer have a contentlet under the pointer.
83
+ *
84
+ * @param {UVEEventHandler} callback - Function to be called when hover state changes
72
85
  * @returns {Object} Object containing unsubscribe function and event type
73
86
  * @returns {Function} .unsubscribe - Function to remove the event listener
74
87
  * @returns {UVEEventType} .event - The event type being subscribed to
@@ -78,3 +91,18 @@ export declare function onContentletHovered(callback: UVEEventHandler): {
78
91
  unsubscribe: () => void;
79
92
  event: UVEEventType;
80
93
  };
94
+ /**
95
+ * Subscribes to contentlet click events in the UVE editor.
96
+ *
97
+ * The editor's hover overlay is `pointer-events: none` so wheel events pass
98
+ * through to the iframe. We detect the user's selection click here instead and
99
+ * post it back to the editor.
100
+ *
101
+ * @param {UVEEventHandler} callback - Function to be called when a contentlet is clicked
102
+ * @returns {Object} Object containing unsubscribe function and event type
103
+ * @internal
104
+ */
105
+ export declare function onContentletClicked(callback: UVEEventHandler): {
106
+ unsubscribe: () => void;
107
+ event: UVEEventType;
108
+ };
package/src/internal.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export * from './internal/index';
2
2
  export * from './lib/core/core.utils';
3
- export * from './lib/dom/document-height-observer';
4
3
  export * from './lib/dom/dom.utils';
5
4
  export * from './lib/editor/internal';
6
5
  export { defineStyleEditorSchema, normalizeForm, registerStyleEditorSchemas } from './lib/style-editor/internal';
@@ -203,3 +203,19 @@ export declare const getContentletsInContainer: (dotCMSPageAsset: DotCMSPageAsse
203
203
  * // Returns: { 'data-dot-object': 'container', 'data-dot-identifier': 'cont1', ... }
204
204
  */
205
205
  export declare function getDotContainerAttributes({ uuid, identifier, acceptTypes, maxContentlets }: EditableContainerData): DotContainerAttributes;
206
+ /**
207
+ * Read a contentlet's dataset attributes off a DOM element and return a
208
+ * normalized contentlet object. Mirrors the shape consumed by the editor's
209
+ * SET_BOUNDS and CONTENTLET_CLICKED events. Optionally parses the
210
+ * `dotStyleProperties` JSON when present.
211
+ */
212
+ export declare function readContentletDataset(element: HTMLElement): {
213
+ dotStyleProperties?: any;
214
+ identifier: string | undefined;
215
+ title: string | undefined;
216
+ inode: string | undefined;
217
+ contentType: string | undefined;
218
+ baseType: string | undefined;
219
+ widgetTitle: string | undefined;
220
+ onNumberOfPages: string | undefined;
221
+ };
@@ -51,34 +51,6 @@ export declare function setClientIsReady(config?: DotCMSPageResponse): void;
51
51
  export declare function listenBlockEditorInlineEvent(): {
52
52
  destroyListenBlockEditorInlineEvent: () => void;
53
53
  };
54
- /**
55
- * Returns whether iframe height must be synchronized via postMessage.
56
- *
57
- * Same-origin parents can measure iframe content directly, so they do not need
58
- * child-driven height reporting. Cross-origin parents cannot access the iframe
59
- * DOM, so they still need the reporter fallback.
60
- */
61
- export declare function shouldReportIframeHeightToParent(): boolean;
62
- /**
63
- * Reports the iframe document height to the parent UVE shell via postMessage.
64
- *
65
- * Uses ResizeObserver on <html> for viewport/font/image-driven size changes, and
66
- * MutationObserver on <body> to catch DOM removals (e.g. contentlets deleted by
67
- * the editor) that shrink the page without triggering a resize event.
68
- *
69
- * Measurement reads `document.documentElement.offsetHeight` — the actual rendered
70
- * height of the <html> element after layout. `scrollHeight` is intentionally avoided
71
- * because it does not reliably decrease when content is removed from the DOM.
72
- *
73
- * Height sends are coalesced to at most one per double-requestAnimationFrame pair
74
- * so they always run after layout and paint have settled.
75
- *
76
- * @returns {{ destroyHeightReporter: () => void }} Cleanup function that removes
77
- * all listeners and disconnects the observers.
78
- */
79
- export declare function reportIframeHeight(): {
80
- destroyHeightReporter: () => void;
81
- };
82
54
  /**
83
55
  * Injects UVE editor styles for empty containers and contentlets into the page.
84
56
  * Provides visual placeholders so editors can identify and interact with empty areas.
@@ -1,18 +0,0 @@
1
- export interface ObserveDocumentHeightOptions {
2
- onHeightChange: (height: number) => void;
3
- documentRef?: Document;
4
- windowRef?: Window;
5
- debounceMs?: number;
6
- }
7
- export interface DocumentHeightObserverHandle {
8
- destroy: () => void;
9
- }
10
- /**
11
- * Observes rendered document height changes and notifies the caller after layout settles.
12
- *
13
- * Uses ResizeObserver on <html> for layout/viewport-driven changes and MutationObserver
14
- * on <body> to catch DOM additions/removals that may shrink the page without a resize.
15
- * Measurement reads `body.offsetHeight`, which tracks actual content height and
16
- * decreases correctly after DOM removals, unaffected by CSS min-height on the html element.
17
- */
18
- export declare function observeDocumentHeight({ onHeightChange, documentRef, windowRef, debounceMs }: ObserveDocumentHeightOptions): DocumentHeightObserverHandle;