251 lines
6.6 KiB
TypeScript
251 lines
6.6 KiB
TypeScript
import { FolkShape } from '../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<ClientRectObserverEntryCallback>;
|
|
}
|
|
|
|
type PostMessageSendEvent =
|
|
| { type: 'folk-observe-element'; selector: string }
|
|
| { type: 'folk-unobserve-element'; selector: string };
|
|
|
|
class IframeObserver {
|
|
#iframe;
|
|
#observer;
|
|
#iframeRect!: DOMRectReadOnly;
|
|
#iframeChildren = new Map<string, IframeChild>();
|
|
#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<Element, Set<ClientRectObserverEntryCallback>>();
|
|
#iframeMap = new WeakMap<HTMLIFrameElement, IframeObserver>();
|
|
|
|
#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;
|
|
}
|