Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -614,31 +614,24 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit {
return;
}

this.dragDropService.setupDragEvents(
this.uveStore,
this.iframe,
this.customDragImage,
this.contentWindow,
this.host,
{
onDrop: (event) => this.handleDrop(event),
onDragEnter: () => {
// Handled in dragDropService
},
onDragOver: () => {
// Handled in dragDropService
},
onDragLeave: () => {
this.uveStore.resetEditorProperties();
},
onDragEnd: () => {
this.uveStore.resetEditorProperties();
},
onDragStart: () => {
// Handled in dragDropService
}
this.dragDropService.setupDragEvents(this.uveStore, this.iframe, this.customDragImage, {
onDrop: (event) => this.handleDrop(event),
onDragEnter: () => {
// Handled in dragDropService
},
onDragOver: () => {
// Handled in dragDropService
},
onDragLeave: () => {
this.uveStore.resetEditorProperties();
},
onDragEnd: () => {
this.uveStore.resetEditorProperties();
},
onDragStart: () => {
// Handled in dragDropService
}
);
});
}

private handleUveMessage(message: PostMessage): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

import { filter } from 'rxjs/operators';

import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal';
import { WINDOW } from '@dotcms/utils';

import { IFRAME_SCROLL_ZONE } from '../../shared/consts';
import { EDITOR_STATE } from '../../shared/enums';
import { UVEStore } from '../../store/dot-uve.store';
import { TEMPORAL_DRAG_ITEM, getDragItemData } from '../../utils';
import { UveIframeMessengerService } from '../iframe-messenger/uve-iframe-messenger.service';

export interface DragDropHandlers {
onDrop: (event: DragEvent) => void;
Expand All @@ -26,13 +26,12 @@ export interface DragDropHandlers {
export class DotUveDragDropService {
private readonly window = inject(WINDOW);
private readonly destroyRef = inject(DestroyRef);
Comment thread
rjvelazco marked this conversation as resolved.
private readonly iframeMessenger = inject(UveIframeMessengerService);

setupDragEvents(
uveStore: InstanceType<typeof UVEStore>,
iframe: ElementRef<HTMLIFrameElement>,
customDragImage: ElementRef<HTMLDivElement>,
contentWindow: Window | null,
host: string,
handlers: DragDropHandlers
): void {
// Drag start
Expand All @@ -53,6 +52,12 @@ export class DotUveDragDropService {
return;
}

// Flush bounds at drag start. `setEditorDragItem` is deferred to
// the next frame (to snapshot the drag image first), so the
// `dragenter` handler can fire before it and bail without
// flushing — leaving the dropzone with empty bounds and no
// targets. Flushing here guarantees bounds regardless of that race.
this.iframeMessenger.flushBounds();
requestAnimationFrame(() => uveStore.setEditorDragItem(data));
});

Expand All @@ -75,12 +80,7 @@ export class DotUveDragDropService {
uveStore.setEditorState(EDITOR_STATE.DRAGGING);
// Drag enter: dropzone needs current bounds before any
// pixel of movement, so flush past the auto-bounds debounce.
contentWindow?.postMessage(
{
name: __DOTCMS_UVE_EVENT__.UVE_FLUSH_BOUNDS
},
host
);
this.iframeMessenger.flushBounds();

if (dragItem) {
return;
Expand All @@ -92,10 +92,7 @@ export class DotUveDragDropService {

// Drag end
fromEvent(this.window, 'dragend')
.pipe(
takeUntilDestroyed(this.destroyRef),
filter((event: DragEvent) => event.dataTransfer?.dropEffect === 'none')
)
.pipe(takeUntilDestroyed(this.destroyRef))
Comment thread
rjvelazco marked this conversation as resolved.
Comment thread
rjvelazco marked this conversation as resolved.
Comment thread
rjvelazco marked this conversation as resolved.
Comment thread
rjvelazco marked this conversation as resolved.
.subscribe(() => {
handlers.onDragEnd();
});
Expand Down Expand Up @@ -145,10 +142,7 @@ export class DotUveDragDropService {

uveStore.updateEditorScrollDragState();

contentWindow?.postMessage(
{ name: __DOTCMS_UVE_EVENT__.UVE_SCROLL_INSIDE_IFRAME, direction },
host
);
this.iframeMessenger.scrollInsideIframe(direction);

handlers.onDragOver(event);
});
Expand Down
80 changes: 49 additions & 31 deletions core-web/libs/sdk/uve/src/internal/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
findDotCMSVTLData,
getClosestDotCMSContainerData,
getDotCMSPageBounds,
getNativeEventBinder,
readContentletDataset
} from '../lib/dom/dom.utils';

Expand All @@ -28,11 +29,13 @@ export function onContentChanges(callback: UVEEventHandler) {
}
};

window.addEventListener('message', messageCallback);
// Native binder: survives the iframe rewrite under Zone.js. See getNativeEventBinder.
const nativeWindow = getNativeEventBinder(window);
nativeWindow.addEventListener('message', messageCallback);

return {
unsubscribe: () => {
window.removeEventListener('message', messageCallback);
nativeWindow.removeEventListener('message', messageCallback);
},
event: UVEEventType.CONTENT_CHANGES
};
Expand All @@ -54,11 +57,13 @@ export function onPageReload(callback: UVEEventHandler) {
}
};

window.addEventListener('message', messageCallback);
// Native binder: survives the iframe rewrite under Zone.js. See getNativeEventBinder.
const nativeWindow = getNativeEventBinder(window);
nativeWindow.addEventListener('message', messageCallback);

return {
unsubscribe: () => {
window.removeEventListener('message', messageCallback);
nativeWindow.removeEventListener('message', messageCallback);
},
event: UVEEventType.PAGE_RELOAD
};
Expand Down Expand Up @@ -162,13 +167,13 @@ export function onAutoBounds(callback: UVEEventHandler) {
subtree: true
});

// Scrolling inside the iframe doesn't change layout, so ResizeObserver
// doesn't fire, but every contentlet's viewport-relative position
// (getBoundingClientRect) does change. Re-emit bounds after each
// scroll burst settles so the editor's pinned selected overlay
// re-anchors to the on-screen position.
// Scrolling doesn't change layout (ResizeObserver won't fire) but moves
// every contentlet, so re-emit bounds after the scroll settles to re-anchor
// the selected overlay. Native binder so it survives the iframe rewrite
// under Zone.js. See getNativeEventBinder.
const nativeWindow = getNativeEventBinder(window);
const onScroll = () => scheduleEmit();
window.addEventListener('scroll', onScroll, { passive: true });
nativeWindow.addEventListener('scroll', onScroll, { passive: true });

// Flush channel: the editor occasionally needs an immediate snapshot
// of bounds (drag enter, where the dropzone has to know container
Expand All @@ -182,7 +187,8 @@ export function onAutoBounds(callback: UVEEventHandler) {
}
emit();
};
window.addEventListener('message', onFlush);
// Native binder (reused from `scroll` above): survives the iframe rewrite too.
nativeWindow.addEventListener('message', onFlush);

return {
unsubscribe: () => {
Expand All @@ -192,8 +198,8 @@ export function onAutoBounds(callback: UVEEventHandler) {
}
resizeObserver.disconnect();
mutationObserver.disconnect();
window.removeEventListener('scroll', onScroll);
window.removeEventListener('message', onFlush);
nativeWindow.removeEventListener('scroll', onScroll);
nativeWindow.removeEventListener('message', onFlush);
observed = [];
},
event: UVEEventType.AUTO_BOUNDS
Expand All @@ -218,11 +224,13 @@ export function onIframeScroll(callback: UVEEventHandler) {
}
};

window.addEventListener('message', messageCallback);
// Native binder: survives the iframe rewrite under Zone.js. See getNativeEventBinder.
const nativeWindow = getNativeEventBinder(window);
nativeWindow.addEventListener('message', messageCallback);

return {
unsubscribe: () => {
window.removeEventListener('message', messageCallback);
nativeWindow.removeEventListener('message', messageCallback);
},
event: UVEEventType.IFRAME_SCROLL
};
Expand Down Expand Up @@ -255,11 +263,13 @@ export function onScrollToSection(callback: UVEEventHandler) {
callback({ sectionIndex, offsetTop: el.offsetTop });
};

window.addEventListener('message', messageCallback);
// Native binder: survives the iframe rewrite under Zone.js. See getNativeEventBinder.
const nativeWindow = getNativeEventBinder(window);
nativeWindow.addEventListener('message', messageCallback);

return {
unsubscribe: () => {
window.removeEventListener('message', messageCallback);
nativeWindow.removeEventListener('message', messageCallback);
},
event: UVEEventType.SCROLL_TO_SECTION
};
Expand Down Expand Up @@ -337,17 +347,19 @@ export function onContentletHovered(callback: UVEEventHandler) {
callback(contentletHoveredPayload);
};

// We intentionally do not fire null on document `pointerleave`: the
// editor's hover toolbar lives in the parent window (outside the
// iframe), so leaving the iframe usually means the user is heading
// for the toolbar. Killing the overlay there would yank the toolbar
// away just as the user reaches for it. Dead-space-inside-iframe
// is already covered by the `pointermove` null branch above.
document.addEventListener('pointermove', pointerMoveCallback);
// We intentionally do not fire null on `pointerleave`: the editor's hover
// toolbar lives in the parent window, so leaving the iframe usually means
// the user is reaching for it — killing the overlay would yank it away.
//
// Bind to `document.documentElement` (a fresh node on each iframe rewrite)
// rather than `document`, which goes dead under Zone.js after a rewrite.
// `pointermove` bubbles to <html>, so no-Zone behavior is identical. See
// getNativeEventBinder for the full rationale.
document.documentElement.addEventListener('pointermove', pointerMoveCallback);

return {
unsubscribe: () => {
document.removeEventListener('pointermove', pointerMoveCallback);
document.documentElement.removeEventListener('pointermove', pointerMoveCallback);
},
event: UVEEventType.CONTENTLET_HOVERED
};
Expand Down Expand Up @@ -431,15 +443,21 @@ export function onContentletClicked(callback: UVEEventHandler) {
}
};

// Capture phase so we run BEFORE the page's own click handlers and can
// preventDefault/stopPropagation effectively.
document.addEventListener('click', clickCallback, { capture: true });
window.addEventListener('message', selectionClearedCallback);
// Capture phase so we run BEFORE the page's own click handlers. Bound to
// `document.documentElement` (a fresh node on each iframe rewrite) rather
// than `document`, which goes dead under Zone.js after a rewrite; capture on
// <html> still runs first. See getNativeEventBinder for the full rationale.
document.documentElement.addEventListener('click', clickCallback, { capture: true });
// Native binder: survives the iframe rewrite under Zone.js. See getNativeEventBinder.
const nativeWindow = getNativeEventBinder(window);
nativeWindow.addEventListener('message', selectionClearedCallback);

return {
unsubscribe: () => {
document.removeEventListener('click', clickCallback, { capture: true });
window.removeEventListener('message', selectionClearedCallback);
document.documentElement.removeEventListener('click', clickCallback, {
capture: true
});
nativeWindow.removeEventListener('message', selectionClearedCallback);
},
event: UVEEventType.CONTENTLET_CLICKED
};
Expand Down
42 changes: 42 additions & 0 deletions core-web/libs/sdk/uve/src/lib/dom/dom.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,45 @@ export function readContentletDataset(element: HTMLElement) {
})
};
}

/**
* Returns Zone.js's *unpatched* native `addEventListener` / `removeEventListener`
* bound to `target`, falling back to the standard methods when Zone.js is absent.
*
* UVE reuses a single iframe and rewrites it with `document.open()/write()/close()`
* on every in-editor navigation. When Zone.js is loaded inside that iframe it runs
* in global-events mode: one native "gateway" listener per (target, eventType)
* plus a JS-level task list stored on the target node. `document.open()` tears down
* the native gateway on the persistent `window`/`document` nodes, but the task list
* survives on them, so Zone sees "already registered" on re-init and skips
* re-installing the native gateway — the listener silently goes dead after the
* first navigation.
*
* Hover/click dodge this by binding to `document.documentElement`, a node that
* `write()` recreates fresh. `scroll`/`message` (on `window`) and
* `DOMContentLoaded` (on `document`) can't: none of them fire on `<html>`, so
* there is no fresh node to rebind to. Going through Zone's native (untracked)
* methods sidesteps the dedup entirely, so the listener rebinds cleanly after
* every rewrite.
*/
export function getNativeEventBinder<T extends Window | Document>(
target: T
): Pick<T, 'addEventListener' | 'removeEventListener'> {
const zone = (globalThis as { Zone?: { __symbol__?: (name: string) => string } }).Zone;
const symbolFor = (name: string): string =>
typeof zone?.__symbol__ === 'function' ? zone.__symbol__(name) : `__zone_symbol__${name}`;

const source = target as unknown as Record<string, unknown>;
const base = target as EventTarget;
const nativeAdd = source[symbolFor('addEventListener')] as
| EventTarget['addEventListener']
| undefined;
const nativeRemove = source[symbolFor('removeEventListener')] as
| EventTarget['removeEventListener']
| undefined;

return {
addEventListener: (nativeAdd ?? base.addEventListener).bind(base),
removeEventListener: (nativeRemove ?? base.removeEventListener).bind(base)
} as Pick<T, 'addEventListener' | 'removeEventListener'>;
}
34 changes: 34 additions & 0 deletions core-web/libs/sdk/uve/src/script/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,40 @@ describe('scrollHandler', () => {

expect(postMessageSpy).not.toHaveBeenCalled();
});

it('binds through Zone.js native listeners when Zone is present, not the patched ones', () => {
// Simulate a Zone.js-loaded page: expose the unpatched native methods
// under the __zone_symbol__ keys the way Zone.js stashes them.
const nativeAdd = jest.fn();
const nativeRemove = jest.fn();
const win = window as unknown as Record<string, unknown>;

try {
win['__zone_symbol__addEventListener'] = nativeAdd;
win['__zone_symbol__removeEventListener'] = nativeRemove;
(globalThis as unknown as { Zone: unknown }).Zone = {
__symbol__: (name: string) => `__zone_symbol__${name}`
};
const patchedAddSpy = jest.spyOn(window, 'addEventListener');

({ destroyScrollHandler } = scrollHandler());

expect(nativeAdd).toHaveBeenCalledWith('scroll', expect.any(Function));
expect(nativeAdd).toHaveBeenCalledWith('scrollend', expect.any(Function));
// Must NOT register on the Zone-patched window.addEventListener, which
// goes dead after the iframe's document.open()/write()/close() rewrites.
expect(patchedAddSpy).not.toHaveBeenCalledWith('scroll', expect.any(Function));

destroyScrollHandler();
expect(nativeRemove).toHaveBeenCalledWith('scroll', expect.any(Function));
expect(nativeRemove).toHaveBeenCalledWith('scrollend', expect.any(Function));
} finally {
// Always clean up so Zone never leaks into other tests/suites.
delete win['__zone_symbol__addEventListener'];
delete win['__zone_symbol__removeEventListener'];
delete (globalThis as unknown as { Zone?: unknown }).Zone;
}
});
});

describe('addClassToEmptyContentlets', () => {
Expand Down
Loading
Loading