arrow stuff

This commit is contained in:
“chrisshank” 2024-08-21 13:06:40 -07:00
parent 510596a4c9
commit bf0f353175
3 changed files with 378 additions and 0 deletions

View File

@ -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
) {}
}

View File

@ -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
) {}
}

View File

@ -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);
}
}
}