observer iframe rects

This commit is contained in:
“chrisshank” 2024-12-16 15:04:52 -08:00
parent 2d158d1875
commit b2d8bb1088
6 changed files with 191 additions and 96 deletions

View File

@ -23,6 +23,8 @@
iframe {
width: 100%;
height: 100%;
/* margins cause infinite resize loops */
box-sizing: border-box;
}
</style>
</head>
@ -34,7 +36,7 @@
<iframe id="frame2" src="./sticky-html-arrow.html"></iframe>
</folk-shape>
<folk-rope source="#frame1 >>> #box1" target="#frame2 >>> #box1"></folk-rope>
<folk-rope source="#frame1 >>> #box1" target="#frame2 >>> #box2"></folk-rope>
<script type="module">
import '../src/standalone/folk-shape.ts';

View File

@ -34,6 +34,8 @@
height: 100%;
position: absolute;
background-color: white;
/* margins cause infinite resize loops */
box-sizing: border-box;
&:nth-child(1) {
left: 50px;

View File

@ -171,12 +171,8 @@ export class ClientRectObserver {
}
takeRecords(): ClientRectObserverEntry[] {
if (this.#rafId === 0) return [];
const entries = this.#entries;
this.#entries = [];
cancelAnimationFrame(this.#rafId);
this.#rafId = 0;
return entries;
}

View File

@ -4,6 +4,130 @@ 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;
@ -16,6 +140,7 @@ export class FolkObserver {
}
#elementMap = new WeakMap<Element, Set<ClientRectObserverEntryCallback>>();
#iframeMap = new WeakMap<HTMLIFrameElement, IframeObserver>();
#vo = new ClientRectObserver((entries) => {
for (const entry of entries) {
@ -35,29 +160,62 @@ export class FolkObserver {
this.#updateTarget({ target: event.target as HTMLElement, contentRect: event.current });
};
observe(target: Element, callback: ClientRectObserverEntryCallback): void {
let callbacks = this.#elementMap.get(target);
observe(
target: Element,
callback: ClientRectObserverEntryCallback,
{ iframeSelector }: FolkObserverOptions = {}
): void {
if (target instanceof HTMLIFrameElement && iframeSelector) {
let iframeObserver = this.#iframeMap.get(target);
const isFolkShape = target instanceof FolkShape;
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 (isFolkShape) {
if (target instanceof FolkShape) {
target.addEventListener('transform', this.#onTransform);
callback({ target, contentRect: target.getTransformDOMRect() });
} else {
this.#vo.observe(target);
}
} else {
const contentRect = isFolkShape ? target.getTransformDOMRect() : target.getBoundingClientRect();
const contentRect = target instanceof FolkShape ? target.getTransformDOMRect() : target.getBoundingClientRect();
callback({ target, contentRect });
}
callbacks.add(callback);
}
unobserve(target: Element, callback: ClientRectObserverEntryCallback): void {
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;
@ -75,6 +233,6 @@ export class FolkObserver {
}
}
export function parseCSSSelector(selector: string): string[] {
export function parseDeepCSSSelector(selector: string): string[] {
return selector.split('>>>').map((s) => s.trim());
}

View File

@ -1,52 +1,7 @@
import { FolkShape } from '../folk-shape.ts';
import { ClientRectObserverManager, ClientRectObserverEntry } from './client-rect-observer.ts';
import { TransformEvent } from './TransformEvent.ts';
import { ClientRectObserverEntry } from './client-rect-observer.ts';
import { FolkObserver } from './folk-observer.ts';
const clientRectObserver = new ClientRectObserverManager();
interface ObservedElementEntry {
selector: string;
element: Element;
count: number;
}
class ObservedElements {
#elements: ObservedElementEntry[] = [];
observe(selector: string) {
let entry = this.#elements.find((e) => e.selector === selector);
if (entry === undefined) {
entry = { selector, element: document.querySelector(selector)!, count: 0 };
this.#elements.push(entry);
}
entry.count += 1;
return entry.element;
}
unobserve(selector: string) {
const entryIndex = this.#elements.findIndex((e) => e.selector === selector);
const entry = this.#elements[entryIndex];
if (entry === undefined) return;
entry.count -= 1;
if (entry.count === 0) {
this.#elements.splice(entryIndex, 1);
}
}
getElement(selector: string) {
return this.#elements.find((e) => e.selector === selector)?.element;
}
getSelector(element: Element) {
return this.#elements.find((e) => e.element === element)?.selector;
}
}
const folkObserver = new FolkObserver();
// If this page is framed in then mock inject the following post message script
if (window.parent !== window) {
@ -58,15 +13,7 @@ if (window.parent !== window) {
window.parent.postMessage({
type: 'folk-element-change',
selector: observedSelectors.get(entry.target),
boundingBox: entry.contentRect,
});
}
function onGeometryChange(event: TransformEvent) {
window.parent.postMessage({
type: 'folk-element-change',
selector: observedSelectors.get(event.target),
boundingBox: (event.target as FolkShape)?.getTransformDOMRect(),
contentRect: entry.contentRect.toJSON(),
});
}
@ -81,17 +28,7 @@ if (window.parent !== window) {
observedElements.set(selector, element);
observedSelectors.set(element, selector);
if (element instanceof FolkShape) {
element.addEventListener('transform', onGeometryChange);
window.parent.postMessage({
type: 'folk-element-change',
selector: selector,
boundingBox: element.getTransformDOMRect(),
});
} else {
clientRectObserver.observe(element, boundingBoxCallback);
}
folkObserver.observe(element, boundingBoxCallback);
return;
}
case 'folk-unobserve-element': {
@ -100,15 +37,7 @@ if (window.parent !== window) {
if (element === undefined) return;
if (element instanceof FolkShape) {
element.removeEventListener('transform', onGeometryChange);
observedElements.delete(selector);
observedSelectors.delete(element);
} else {
clientRectObserver.unobserve(element, boundingBoxCallback);
}
return;
folkObserver.unobserve(element, boundingBoxCallback);
}
}
});

View File

@ -1,6 +1,6 @@
import { parseVertex } from './common/utils.ts';
import { ClientRectObserverEntry } from './common/client-rect-observer.ts';
import { FolkObserver } from './common/folk-observer.ts';
import { FolkObserver, parseDeepCSSSelector } from './common/folk-observer.ts';
import { FolkElement } from './common/folk-element.ts';
import { property, state } from '@lit/reactive-element/decorators.js';
import { css, CSSResultGroup, PropertyValues } from '@lit/reactive-element';
@ -19,12 +19,16 @@ export class FolkBaseConnection extends FolkElement {
@property({ type: String, reflect: true }) source?: string;
#sourceIframeSelector: string | undefined = undefined;
@state() sourceElement: Element | null = null;
@state() sourceRect: DOMRectReadOnly | null = null;
@property({ type: String, reflect: true }) target?: string;
#targetIframeSelector: string | undefined = undefined;
@state() targetRect: DOMRectReadOnly | null = null;
@state() targetElement: Element | null = null;
@ -48,7 +52,9 @@ export class FolkBaseConnection extends FolkElement {
if (vertex) {
this.sourceRect = DOMRectReadOnly.fromRect(vertex);
} else {
this.sourceElement = document.querySelector(this.source);
const [selector, iframeSelector] = parseDeepCSSSelector(this.source);
this.#sourceIframeSelector = iframeSelector;
this.sourceElement = document.querySelector(selector);
}
}
@ -56,7 +62,7 @@ export class FolkBaseConnection extends FolkElement {
if (this.sourceElement === null) {
this.sourceRect = null;
} else {
folkObserver.observe(this.sourceElement, this.#sourceCallback);
folkObserver.observe(this.sourceElement, this.#sourceCallback, { iframeSelector: this.#sourceIframeSelector });
}
}
@ -70,7 +76,9 @@ export class FolkBaseConnection extends FolkElement {
if (vertex) {
this.targetRect = DOMRectReadOnly.fromRect(vertex);
} else {
this.targetElement = document.querySelector(this.target);
const [selector, iframeSelector] = parseDeepCSSSelector(this.target);
this.#targetIframeSelector = iframeSelector;
this.targetElement = document.querySelector(selector);
}
}
@ -78,7 +86,7 @@ export class FolkBaseConnection extends FolkElement {
if (this.targetElement === null) {
this.targetRect = null;
} else {
folkObserver.observe(this.targetElement, this.#targetCallback);
folkObserver.observe(this.targetElement, this.#targetCallback, { iframeSelector: this.#targetIframeSelector });
}
}
}
@ -90,7 +98,7 @@ export class FolkBaseConnection extends FolkElement {
#unobserveSource() {
if (this.sourceElement === null) return;
folkObserver.unobserve(this.sourceElement, this.#sourceCallback);
folkObserver.unobserve(this.sourceElement, this.#sourceCallback, { iframeSelector: this.#sourceIframeSelector });
}
#targetCallback = (entry: ClientRectObserverEntry) => {
@ -99,6 +107,6 @@ export class FolkBaseConnection extends FolkElement {
#unobserveTarget() {
if (this.targetElement === null) return;
folkObserver.unobserve(this.targetElement, this.#targetCallback);
folkObserver.unobserve(this.targetElement, this.#targetCallback, { iframeSelector: this.#targetIframeSelector });
}
}