From bf0f353175bf995cb758b6c51df8537828dad2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cchrisshank=E2=80=9D?= Date: Wed, 21 Aug 2024 13:06:40 -0700 Subject: [PATCH] arrow stuff --- src/arrows/abstract-arrow.ts | 120 ++++++++++++++++ src/arrows/scoped-propagator.ts | 12 ++ src/arrows/visual-observer.ts | 246 ++++++++++++++++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 src/arrows/abstract-arrow.ts create mode 100644 src/arrows/scoped-propagator.ts create mode 100644 src/arrows/visual-observer.ts diff --git a/src/arrows/abstract-arrow.ts b/src/arrows/abstract-arrow.ts new file mode 100644 index 0000000..efb0796 --- /dev/null +++ b/src/arrows/abstract-arrow.ts @@ -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 + ) {} +} diff --git a/src/arrows/scoped-propagator.ts b/src/arrows/scoped-propagator.ts new file mode 100644 index 0000000..a0fbc68 --- /dev/null +++ b/src/arrows/scoped-propagator.ts @@ -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 + ) {} +} diff --git a/src/arrows/visual-observer.ts b/src/arrows/visual-observer.ts new file mode 100644 index 0000000..0af2164 --- /dev/null +++ b/src/arrows/visual-observer.ts @@ -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(); + + #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., 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>(); + + #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); + } + } +}