import { FolkShape } from '../labs/folk-shape'; import { ClientRectObserver, ClientRectObserverEntry } from './client-rect-observer'; import { TransformEvent } from './TransformEvent'; export type ClientRectObserverEntryCallback = (entry: ClientRectObserverEntry) => void; export type FolkObserverOptions = { iframeSelector?: string; }; interface IframeChild { rect: DOMRectReadOnly | null; callbacks: Set; } type PostMessageSendEvent = | { type: 'folk-observe-element'; selector: string } | { type: 'folk-unobserve-element'; selector: string }; class IframeObserver { #iframe; #observer; #iframeRect!: DOMRectReadOnly; #iframeChildren = new Map(); #isDisposed = false; get isDisposed() { return this.#isDisposed; } constructor(iframe: HTMLIFrameElement, observer: FolkObserver) { this.#iframe = iframe; this.#observer = observer; observer.observe(iframe, this.#iframeCallback); window.addEventListener('message', this.#onPostmessage); } observeChild(selector: string, callback: ClientRectObserverEntryCallback) { let child = this.#iframeChildren.get(selector); if (child === undefined) { child = { callbacks: new Set(), rect: null, }; this.#iframeChildren.set(selector, child); this.#postMessage({ type: 'folk-observe-element', selector }); } child.callbacks.add(callback); } unobserveChild(selector: string, callback: ClientRectObserverEntryCallback) { let child = this.#iframeChildren.get(selector); if (child === undefined) return; child.callbacks.delete(callback); if (child.callbacks.size === 0) { this.#iframeChildren.delete(selector); this.#postMessage({ type: 'folk-unobserve-element', selector }); } if (this.#iframeChildren.size === 0) { this.#observer.unobserve(this.#iframe, this.#iframeCallback); window.removeEventListener('message', this.#onPostmessage); this.#isDisposed = true; } } #iframeCallback = (entry: ClientRectObserverEntry) => { this.#iframeRect = entry.contentRect; for (const selector of this.#iframeChildren.keys()) { this.#updatedChildRect(selector); } }; #onPostmessage = (event: MessageEvent) => { if (event.source !== this.#iframe.contentWindow) return; switch (event.data.type) { case 'folk-iframe-ready': { for (const selector of this.#iframeChildren.keys()) { this.#postMessage({ type: 'folk-observe-element', selector }); } return; } case 'folk-element-change': { this.#updatedChildRect(event.data.selector, event.data.contentRect); return; } } }; #updatedChildRect(selector: string, rect?: DOMRectReadOnly) { const child = this.#iframeChildren.get(selector); if (child === undefined) return; if (rect) { child.rect = rect; } if (child.rect === null) return; const contentRect = DOMRectReadOnly.fromRect({ x: this.#iframeRect.x + child.rect.x, y: this.#iframeRect.y + child.rect.y, height: child.rect.height, width: child.rect.width, }); child.callbacks.forEach((callback) => callback({ target: this.#iframe, contentRect, }) ); } #postMessage(event: PostMessageSendEvent) { this.#iframe.contentWindow?.postMessage(event); } } export class FolkObserver { static #instance: FolkObserver | null = null; // singleton so we only observe elements once constructor() { if (FolkObserver.#instance === null) { FolkObserver.#instance = this; } return FolkObserver.#instance; } #elementMap = new WeakMap>(); #iframeMap = new WeakMap(); #vo = new ClientRectObserver((entries) => { for (const entry of entries) { this.#updateTarget(entry); } }); #updateTarget(entry: ClientRectObserverEntry) { const callbacks = this.#elementMap.get(entry.target); if (callbacks) { callbacks.forEach((callback) => callback(entry)); } } #onTransform = (event: TransformEvent) => { this.#updateTarget({ target: event.target as HTMLElement, contentRect: event.current }); }; observe( target: Element, callback: ClientRectObserverEntryCallback, { iframeSelector }: FolkObserverOptions = {} ): void { if (target instanceof HTMLIFrameElement && iframeSelector) { let iframeObserver = this.#iframeMap.get(target); if (iframeObserver === undefined) { iframeObserver = new IframeObserver(target, this); this.#iframeMap.set(target, iframeObserver); } iframeObserver.observeChild(iframeSelector, callback); return; } let callbacks = this.#elementMap.get(target); if (callbacks === undefined) { this.#elementMap.set(target, (callbacks = new Set())); if (target instanceof FolkShape) { target.addEventListener('transform', this.#onTransform); callback({ target, contentRect: target.getTransformDOMRect() }); } else { this.#vo.observe(target); } } else { const contentRect = target instanceof FolkShape ? target.getTransformDOMRect() : target.getBoundingClientRect(); callback({ target, contentRect }); } callbacks.add(callback); } unobserve( target: Element, callback: ClientRectObserverEntryCallback, { iframeSelector }: FolkObserverOptions = {} ): void { if (target instanceof HTMLIFrameElement && iframeSelector) { let iframeObserver = this.#iframeMap.get(target); if (iframeObserver === undefined) return; iframeObserver.unobserveChild(iframeSelector, callback); if (iframeObserver.isDisposed) { this.#iframeMap.delete(target); } return; } let callbacks = this.#elementMap.get(target); if (callbacks === undefined) return; callbacks.delete(callback); if (callbacks.size === 0) { if (target instanceof FolkShape) { target.removeEventListener('transform', this.#onTransform); } else { this.#vo.unobserve(target); } this.#elementMap.delete(target); } } } const regex = /(.*iframe.*)\s+(.*)/; export function parseDeepCSSSelector(selectorList: string): [Element, string | undefined][] { const array: [Element, string | undefined][] = []; for (const selector of selectorList.split(/,(?![^()]*\))/g)) { const [, elementSelector, iframeSelector] = regex.exec(selector) || [undefined, selector, undefined]; document.querySelectorAll(elementSelector).forEach((el) => { array.push([el, iframeSelector]); }); } return array; }