arrow stuff
This commit is contained in:
parent
510596a4c9
commit
bf0f353175
|
|
@ -0,0 +1,120 @@
|
|||
import { VisualObserverEntry, VisualObserverManager } from './visual-observer';
|
||||
|
||||
const visualObserver = new VisualObserverManager();
|
||||
|
||||
export class AbstractArrow extends HTMLElement {
|
||||
static tagName = 'abstract-arrow';
|
||||
|
||||
static register() {
|
||||
customElements.define(this.tagName, this);
|
||||
}
|
||||
|
||||
static observedAttributes = ['source', 'target'];
|
||||
|
||||
#source = '';
|
||||
/** A CSS selector for the source of the arrow. */
|
||||
get source() {
|
||||
return this.#source;
|
||||
}
|
||||
|
||||
set source(source) {
|
||||
this.setAttribute('source', source);
|
||||
}
|
||||
|
||||
#sourceRect!: DOMRectReadOnly;
|
||||
#sourceElement: Element | null = null;
|
||||
#sourceCallback = (entry: VisualObserverEntry) => {
|
||||
this.#sourceRect = entry.contentRect;
|
||||
this.update();
|
||||
};
|
||||
|
||||
#target = '';
|
||||
/** A CSS selector for the target of the arrow. */
|
||||
get target() {
|
||||
return this.#target;
|
||||
}
|
||||
|
||||
set target(target) {
|
||||
this.setAttribute('target', target);
|
||||
}
|
||||
|
||||
#targetRect!: DOMRectReadOnly;
|
||||
#targetElement: Element | null = null;
|
||||
#targetCallback = (entry: VisualObserverEntry) => {
|
||||
this.#targetRect = entry.contentRect;
|
||||
this.update();
|
||||
};
|
||||
|
||||
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
|
||||
if (name === 'source') {
|
||||
this.#source = newValue;
|
||||
this.observeSource();
|
||||
} else if (name === 'target') {
|
||||
this.#target = newValue;
|
||||
this.observeTarget();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.unobserveSource();
|
||||
this.unobserveTarget();
|
||||
}
|
||||
|
||||
observeSource() {
|
||||
this.unobserveSource();
|
||||
const el = document.querySelector(this.source);
|
||||
|
||||
if (el === null) {
|
||||
throw new Error('source is not a valid element');
|
||||
}
|
||||
|
||||
this.#sourceElement = el;
|
||||
visualObserver.observe(this.#sourceElement, this.#sourceCallback);
|
||||
}
|
||||
|
||||
unobserveSource() {
|
||||
if (this.#sourceElement === null) return;
|
||||
|
||||
visualObserver.unobserve(this.#sourceElement, this.#sourceCallback);
|
||||
}
|
||||
|
||||
observeTarget() {
|
||||
this.unobserveTarget();
|
||||
this.#targetElement = document.querySelector(this.target);
|
||||
|
||||
if (!this.#targetElement) {
|
||||
throw new Error('target is not a valid element');
|
||||
}
|
||||
|
||||
visualObserver.observe(this.#targetElement, this.#targetCallback);
|
||||
}
|
||||
|
||||
unobserveTarget() {
|
||||
if (this.#targetElement === null) return;
|
||||
|
||||
visualObserver.unobserve(this.#targetElement, this.#targetCallback);
|
||||
}
|
||||
|
||||
update() {
|
||||
if (
|
||||
this.#sourceRect === undefined ||
|
||||
this.#targetRect === undefined ||
|
||||
this.#sourceElement === null ||
|
||||
this.#targetElement === null
|
||||
)
|
||||
return;
|
||||
|
||||
this.render(this.#sourceRect, this.#targetRect, this.#sourceElement, this.#targetElement);
|
||||
}
|
||||
|
||||
render(
|
||||
// @ts-ignore
|
||||
sourceRect: DOMRectReadOnly,
|
||||
// @ts-ignore
|
||||
targetRect: DOMRectReadOnly,
|
||||
// @ts-ignore
|
||||
sourceElement: Element,
|
||||
// @ts-ignore
|
||||
targetElement: Element
|
||||
) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { AbstractArrow } from './abstract-arrow';
|
||||
|
||||
export class ScopedPropagator extends AbstractArrow {
|
||||
static tagName = 'scoped-propagator';
|
||||
|
||||
render(
|
||||
sourceRect: DOMRectReadOnly,
|
||||
targetRect: DOMRectReadOnly,
|
||||
sourceElement: Element,
|
||||
targetElement: Element
|
||||
) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
export interface VisualObserverEntry {
|
||||
target: Element;
|
||||
contentRect: DOMRectReadOnly;
|
||||
isAppearing: boolean;
|
||||
}
|
||||
|
||||
export interface VisualObserverCallback {
|
||||
(this: VisualObserver, entries: VisualObserverEntry[], observer: VisualObserver): void;
|
||||
}
|
||||
|
||||
interface VisualObserverElement {
|
||||
io: IntersectionObserver | null;
|
||||
threshold: number;
|
||||
isFirstUpdate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an observer that notifies when an element is resized, moved, or added/removed from the DOM.
|
||||
*/
|
||||
export class VisualObserver {
|
||||
#root = document.documentElement;
|
||||
#rootRect = this.#root.getBoundingClientRect();
|
||||
|
||||
#entries: VisualObserverEntry[] = [];
|
||||
#rafId = 0;
|
||||
|
||||
#callback: VisualObserverCallback;
|
||||
|
||||
constructor(callback: VisualObserverCallback) {
|
||||
this.#callback = callback;
|
||||
}
|
||||
|
||||
#elements = new Map<Element, VisualObserverElement>();
|
||||
|
||||
#resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
const rootEntry = entries.find((entry) => entry.target === this.#root);
|
||||
// Any time the root element resizes we need to refresh all the observed elements.
|
||||
if (rootEntry !== undefined) {
|
||||
this.#rootRect = rootEntry.contentRect;
|
||||
|
||||
this.#elements.forEach((_, target) => {
|
||||
// Why force a refresh? we really just need to reset the IntersectionObserver?
|
||||
this.#appendEntry(this.#refreshElement(target));
|
||||
});
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
this.#appendEntry(this.#refreshElement(entry.target));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
#appendEntry(entry: VisualObserverEntry) {
|
||||
// deduplicate the same target
|
||||
this.#entries.push(entry);
|
||||
|
||||
if (this.#rafId === 0) {
|
||||
this.#rafId = requestAnimationFrame(this.#flush);
|
||||
}
|
||||
}
|
||||
|
||||
#flush = () => {
|
||||
const entries = this.#entries;
|
||||
this.#entries = [];
|
||||
this.#rafId = 0;
|
||||
this.#callback(entries, this);
|
||||
};
|
||||
|
||||
// We should be guaranteed that each `IntersectionObserver` only observes one element.
|
||||
#onIntersection = ([
|
||||
{ target, intersectionRatio, boundingClientRect },
|
||||
]: IntersectionObserverEntry[]) => {
|
||||
const el = this.#elements.get(target);
|
||||
|
||||
if (el === undefined) return;
|
||||
|
||||
if (intersectionRatio !== el.threshold) {
|
||||
// It's possible for the watched element to not be at perfect 1.0 visibility when we create
|
||||
// the IntersectionObserver. This has a couple of causes:
|
||||
// - elements being on partial pixels
|
||||
// - elements being hidden offscreen (e.g., <html> has `overflow: hidden`)
|
||||
// - delays: if your DOM change occurs due to e.g., page resize, you can see elements
|
||||
// behind their actual position
|
||||
//
|
||||
// In all of these cases, refresh but with this lower ratio of threshold. When the element
|
||||
// moves beneath _that_ new value, the user will get notified.
|
||||
|
||||
if (el.isFirstUpdate) {
|
||||
el.threshold =
|
||||
intersectionRatio === 0.0
|
||||
? 0.0000001 // just needs to be non-zero
|
||||
: intersectionRatio;
|
||||
}
|
||||
|
||||
this.#appendEntry(this.#refreshElement(target, boundingClientRect));
|
||||
}
|
||||
|
||||
el.isFirstUpdate = false;
|
||||
};
|
||||
|
||||
#refreshElement(
|
||||
target: Element,
|
||||
contentRect: DOMRectReadOnly = target.getBoundingClientRect()
|
||||
): VisualObserverEntry {
|
||||
// Assume el exists
|
||||
const el = this.#elements.get(target)!;
|
||||
|
||||
el.io?.disconnect();
|
||||
el.io = null;
|
||||
|
||||
const { left, top, height, width } = contentRect;
|
||||
|
||||
// Don't create a IntersectionObserver until the target has a size.
|
||||
if (width === 0 && height === 0) {
|
||||
return {
|
||||
target,
|
||||
contentRect,
|
||||
isAppearing: false,
|
||||
};
|
||||
}
|
||||
|
||||
const root = this.#root;
|
||||
const floor = Math.floor;
|
||||
const x = left + root.scrollLeft;
|
||||
const y = top + root.scrollTop;
|
||||
|
||||
// `${insetTop}px ${insetRight}px ${insetBottom}px ${insetLeft}px`;
|
||||
const rootMargin = `${-floor(y)}px ${-floor(this.#rootRect.width - (x + width))}px ${-floor(
|
||||
this.#rootRect.height - (y + height)
|
||||
)}px ${-floor(x)}px`;
|
||||
|
||||
// Reset the threshold and isFirstUpdate before creating a new Intersection Observer.
|
||||
const { threshold } = el;
|
||||
el.threshold = 1;
|
||||
el.isFirstUpdate = true;
|
||||
|
||||
el.io = new IntersectionObserver(this.#onIntersection, {
|
||||
root,
|
||||
rootMargin,
|
||||
threshold,
|
||||
});
|
||||
|
||||
el.io.observe(target);
|
||||
|
||||
return {
|
||||
target,
|
||||
contentRect: DOMRectReadOnly.fromRect({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}),
|
||||
isAppearing: true,
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.#elements.forEach((el) => el.io?.disconnect());
|
||||
this.#elements.clear();
|
||||
this.#resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
observe(target: Element): void {
|
||||
if (this.#elements.has(target)) return;
|
||||
|
||||
if (this.#elements.size === 0) {
|
||||
this.#resizeObserver.observe(this.#root);
|
||||
}
|
||||
|
||||
this.#elements.set(target, {
|
||||
io: null,
|
||||
threshold: 1,
|
||||
isFirstUpdate: true,
|
||||
});
|
||||
|
||||
// The resize observer will be called immediately, so we don't have to manually refresh.
|
||||
this.#resizeObserver.observe(target);
|
||||
}
|
||||
|
||||
takeRecords(): VisualObserverEntry[] {
|
||||
if (this.#rafId === 0) return [];
|
||||
|
||||
const entries = this.#entries;
|
||||
this.#entries = [];
|
||||
cancelAnimationFrame(this.#rafId);
|
||||
this.#rafId = 0;
|
||||
return entries;
|
||||
}
|
||||
|
||||
unobserve(target: Element): void {
|
||||
const el = this.#elements.get(target);
|
||||
|
||||
if (el === undefined) return;
|
||||
|
||||
this.#resizeObserver.unobserve(target);
|
||||
|
||||
el.io?.disconnect();
|
||||
|
||||
this.#elements.delete(target);
|
||||
|
||||
if (this.#elements.size === 0) {
|
||||
this.#resizeObserver.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type VisualObserverEntryCallback = (entry: VisualObserverEntry) => void;
|
||||
|
||||
export class VisualObserverManager {
|
||||
#elementMap = new WeakMap<Element, Set<VisualObserverEntryCallback>>();
|
||||
|
||||
#vo = new VisualObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const callbacks = this.#elementMap.get(entry.target);
|
||||
|
||||
if (callbacks) {
|
||||
callbacks.forEach((callback) => callback(entry));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observe(target: Element, callback: VisualObserverEntryCallback): void {
|
||||
let callbacks = this.#elementMap.get(target);
|
||||
|
||||
if (callbacks === undefined) {
|
||||
this.#vo.observe(target);
|
||||
this.#elementMap.set(target, (callbacks = new Set()));
|
||||
} else {
|
||||
callback({ target, contentRect: target.getBoundingClientRect(), isAppearing: true });
|
||||
}
|
||||
|
||||
callbacks.add(callback);
|
||||
}
|
||||
|
||||
unobserve(target: Element, callback: VisualObserverEntryCallback): void {
|
||||
let callbacks = this.#elementMap.get(target);
|
||||
|
||||
if (callbacks === undefined) return;
|
||||
|
||||
callbacks.delete(callback);
|
||||
|
||||
if (callbacks.size === 0) {
|
||||
this.#vo.unobserve(target);
|
||||
this.#elementMap.delete(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue