UNPKG

vlens

Version:

Data Centric Routing & Rendering Mini-Framework

1,019 lines (862 loc) 27.8 kB
import * as preact from "preact"; import * as css from "./css"; import * as rpc from "./rpc"; import * as cache from "./cache"; import * as refs from "./refs"; import * as geom from "./geom"; let global = window as any; export let dragging: any = null; // for user code to set to anything! export function setDragging(d: any) { dragging = d; } // Claude function _isTouchDevice() { return 'ontouchstart' in window || navigator.maxTouchPoints > 0; } // Claude 3.5 Sonnet function _isDragAndDropSupported() { const div = document.createElement('div'); return ('draggable' in div) || ('ondragstart' in div && 'ondrop' in div); } export const supportsDragDrop = _isDragAndDropSupported(); export const isTouchDevice = _isTouchDevice() let _debugVars: Record<string, any> = {}; export function debugVar(data: any) { Object.assign(_debugVars, data); } let frameMessages = new Map<string, any>() export function postFrameMessage(key: string, value: any) { frameMessages.set(key, value) } export function consumeFrameMessage<T = any>(key: string): T | null { let value = frameMessages.get(key) frameMessages.delete(key) return value } export let rootDiv = document.body; export let event: Event | null = null; // stores current event function _redraw(): number { // returns the time it took to render in ms if (rootDiv === null) { return 0; } if (_block_router) { return 0; } const t1 = performance.now(); const vdom = renderPageAndModals(gRootPage, gModalStack); preact.render(vdom, rootDiv); event = null; gesture = zeroFrameGesture() _debugVars = {}; frameMessages.clear() mousePrev = structuredClone(mouse) const t2 = performance.now(); const duration = t2 - t1; // if (duration > 5) { // console.info(`redraw ${(duration).toFixed(2)}ms`); // } return duration; } // global.redraw = redraw; let _redrawSchedule = 0; function _animationFrameRedraw() { _redrawSchedule = 0; _redraw(); } // schedules redraw as soon as possible // // this is so that the caller can do a bunch of stuff and then have // the redraw called only after they're done with everything or when // they have to yield for an async call // // Safe to schedule multiple times per frame; only the first time will schedule export function scheduleRedraw() { if (_redrawSchedule === 0) { _redrawSchedule = requestAnimationFrame(_animationFrameRedraw); } } let _deferredRedrawScheduled = 0; let _deferredRedrawDuration = 30; function _deferredRedraw() { _deferredRedrawScheduled = 0; let duration = _redraw(); _deferredRedrawDuration = Math.max(30, Math.round(duration * 4)); } export function deferRedraw() { if (_deferredRedrawScheduled == 0) { _deferredRedrawScheduled = setTimeout( _deferredRedraw, _deferredRedrawDuration, ); } } // @ts-ignore window.scheduleRedraw = scheduleRedraw; // @ts-ignore window.refs = refs; export function debugVarsPanel(): preact.ComponentChild { if (DEBUG) { return preact.h("div", { class: _clsDebugVars }, [ _showDebugEntries(_debugVars), ]); } else { return preact.h(preact.Fragment, {}) } } function _showDebugEntries(data: any, prefix = ""): preact.ComponentChild { return preact.h( preact.Fragment, {}, Object.entries(data).map(([key, value]) => { if (typeof value === "object") { return _showDebugEntries(value, prefix + key + "."); } else { return preact.h("div", {}, [`${prefix + key}: ${value}`]); } }), ); } const _clsDebugVars = css.cls("debug-vars", { position: "fixed", zIndex: "100000000", top: "0px", left: "0px", width: "content-fit", height: "content-fit", padding: "2px 4px", borderBottomRightRadius: "4px", fontFamily: "monospace", background: "hsla(0, 0%, 0%, 0.6)", color: "white", textShadow: "0px 0px 2px black", fontSize: "14px", pointerEvents: "none", ":empty": { display: "none", }, }); function nodeCustomEvent(node: Node, eventName: string, options?: object) { const event = new CustomEvent(eventName, options); node.dispatchEvent(event); // console.log(eventName, node) } function recursivelyDispatchCreateEvent(node: Node) { if (node instanceof Element && node.hasAttribute("listen-create")) { nodeCustomEvent(node, "create"); } node.childNodes.forEach(recursivelyDispatchCreateEvent); } const observer = new MutationObserver((mutationsList: MutationRecord[]) => { const t1 = performance.now(); let targets: Element[] = [] for (const mutation of mutationsList) { if (mutation.target instanceof Element) { if (mutation.target.hasAttribute("listen-mutate")) { nodeCustomEvent(mutation.target, "mutate", { bubbles: true }); targets.push(mutation.target) } } mutation.addedNodes.forEach(recursivelyDispatchCreateEvent); // mutation.removedNodes.forEach(node => nodeCustomEvent(node, 'destroy')); } /* const dur = performance.now() - t1; if (dur > 1) { console.log(`mutation dispatch ${dur.toFixed(2)}ms`, targets); } */ }); observer.observe(rootDiv, { subtree: true, childList: true, characterData: true, attributes: true, }); export function captureEvent(e: Event) { if (e instanceof KeyboardEvent) { if (e.isComposing) { return; } } if (e instanceof MouseEvent) { captureMouseLocation(e) } if (isTouchEvent(e)) { processFrameGesture(e) } event = e; // make it available to all renderers! scheduleRedraw(); } export let mouse = geom.zeroPoint(); export let mousePrev = geom.zeroPoint() // window.addEventListener("click", captureEvent) window.addEventListener("touch", captureEvent); window.addEventListener("touchstart", captureEvent); window.addEventListener("touchend", captureEvent); window.addEventListener("touchcancel", captureEvent); window.addEventListener("touchenter", captureEvent); window.addEventListener("touchleave", captureEvent); window.addEventListener("touchmove", captureEvent, { passive: false }); window.addEventListener("mousemove", captureEvent, { passive: false }); window.addEventListener("wheel", captureEvent); window.addEventListener("mousedown", captureEvent); window.addEventListener("mouseup", captureEvent); document.addEventListener("input", captureEvent); document.addEventListener("keydown", captureEvent); document.fonts.addEventListener('loadingdone', scheduleRedraw); function hasMouseDevice() { // js wtf return matchMedia('(pointer:fine)').matches } function isActuallyTouchEvent(e: MouseEvent) { return !hasMouseDevice() || (e as any).sourceCapabilities?.firesTouchEvents === true } function captureMouseLocation(e: MouseEvent) { if (isActuallyTouchEvent(e)) return mouse.x = e.clientX; mouse.y = e.clientY; } function passiveCapture(_event: Event) { deferRedraw(); } document.addEventListener("scroll", passiveCapture, { passive: true }); window.addEventListener("resize", passiveCapture, { passive: true }); export function isUnderMouse(el: Element | null | undefined, cursor = mouse): boolean { if (!el) { return false; } let underMouse = document.elementFromPoint(cursor.x, cursor.y); return el === underMouse || el.contains(underMouse); } export function elementRect(element: Element | null | undefined): geom.Rect { if (!element) { return geom.zeroRect(); } else { let r0 = element.getBoundingClientRect(); return { x: r0.x, y: r0.y, width: r0.width, height: r0.height, }; } } export type ClickLocation = "inside" | "outside" | "na"; export function clickLocationRelativeTo(el: HTMLElement | null, eventType: string = "mousedown") { if ( el && event && event.target instanceof HTMLElement && event.type === "mousedown" ) { if (el.contains(event.target)) { return "inside"; } else { return "outside"; } } else { return "na"; } } export function clickOutside(...elements: (HTMLElement|null)[]): boolean { if ( event instanceof MouseEvent && event.target instanceof HTMLElement && event.type === "mousedown" && event.buttons === 1 ) { for (let el of elements) { if (el && el.contains(event.target)) { return false } } return true; } else { // there's no click .. return false; } } export function keydownOn(el: HTMLElement | null): string { if (event && event.type === "keydown" && event.target === el) { return (event as KeyboardEvent).key; } else { return ""; } } export function eventTargetId(type: string, targetId: string): boolean { return Boolean(event && event.type === type && event.target === document.getElementById(targetId)) } export function inputEventOn(el: HTMLElement | null) { return el && event && event.type === "input" && event.target === el; } export function getEventTarget(): HTMLElement | null { if (event && event.target instanceof HTMLElement) { return event.target; } else { return null; } } // from https://web.dev/articles/canvas-hidipi export function getContext2D(canvas: HTMLCanvasElement): CanvasRenderingContext2D { // Get the device pixel ratio, falling back to 1. var dpr = window.devicePixelRatio || 1; // Get the size of the canvas in CSS pixels. var rect = canvas.getBoundingClientRect(); // Give the canvas pixel dimensions of their CSS // size * the device pixel ratio. canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; var ctx = canvas.getContext('2d')!; ctx.clearRect(0, 0, rect.width, rect.height); // Scale all drawing operations by the dpr, so you // don't have to worry about the difference. ctx.scale(dpr, dpr); return ctx; } export function getWindowSize(): geom.Size { return { width: Math.min(window.outerWidth, window.innerWidth), height: Math.min(window.outerHeight, window.innerHeight), }; } export function getScreenSize(): geom.Size { return { width: screen.availWidth, height: screen.availHeight, } } /* getImageSize fetches the image if its size is not already known. It's a good idea to call it as soon as possible even if the result is not going to be needed yet. */ const _imageSizes = new Map<string, geom.Size>(); const _waitingImages = new Set<string>(); export function getImageSize(url: string): geom.Size { const storedSize = _imageSizes.get(url); if (storedSize !== undefined) { return storedSize; } // we cannot find, lets fetch it // but if it's already being fetched, no need to fetch it again. if (!_waitingImages.has(url)) { _waitingImages.add(url); const img = new Image(); img.onload = () => { _waitingImages.delete(url); _imageSizes.set(url, { width: img.naturalWidth, height: img.naturalHeight, }); scheduleRedraw(); // console.log("downloaded", url) }; img.src = url; } return { width: 0, height: 0 }; } export type ResolverFn<R> = (v: R) => void; export type ModalViewFn<T = any, R = any> = ( vm: T, resolve: ResolverFn<R>, ) => preact.ComponentChild; interface RootPage<Data = any> { route: string; prefix: string; data: Data; view: (route: string, prefix: string, data: Data) => preact.ComponentChild; } let gRootPage: RootPage | null = null; export function setRootPage(r: RootPage) { gRootPage = r; } global.getRoot = () => gRootPage; export function getPageData(): unknown { return gRootPage?.data; } const clsModalBackdrop = css.cls("modal_backdrop", { position: "fixed", zIndex: "100000", top: "0", left: "0", right: "0", bottom: "0", background: "hsla(0, 0%, 0%, 0.5)", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", }); function modalContainer( zIndex: number, content: preact.ComponentChild, resolver: Function, clickOutsideValue?: any, ): preact.ComponentChild { const id = "modal_" + zIndex; // const style = `z-index: ${zIndex}`; // FIXME is this needed? const onMouseDown = (event: MouseEvent) => { // ignore events caused by event propagation // console.log("target", event.target, "currentTarget", event.currentTarget); if (event.target !== event.currentTarget) { return; } if (clickOutsideValue !== undefined) { resolver(clickOutsideValue); scheduleRedraw(); } }; return preact.h("div", { id: id, class: clsModalBackdrop, onMouseDown }, [ content, ]); } function renderPageAndModals( rootPage: RootPage | null, modalStack: ModalEntry[], ): preact.ComponentChild { let main: preact.ComponentChild; let modals: preact.ComponentChild[] = []; if (rootPage !== null) { main = rootPage.view(rootPage.route, rootPage.prefix, rootPage.data); } else { main = preact.h(preact.Fragment, {}); } const base_z_index = 10000; for (const [index, modalItem] of modalStack.entries()) { const modalContent = modalItem.view(modalItem.vm, modalItem.resolve); modals.push( modalContainer( base_z_index + 1 + index, modalContent, modalItem.resolve, modalItem.clickOutsideValue, ), ); } let modalsFragment = preact.h(preact.Fragment, {}, modals) let debugPanels = debugVarsPanel() return preact.h(preact.Fragment, {}, [main, modalsFragment, debugPanels]); } export type RouteHandler<Data = any> = { fetch: (route: string, prefix: string) => Promise<rpc.Response<Data>>; view: (route: string, prefix: string, data: Data) => preact.ComponentChild; } type FetchFn<Data> = RouteHandler<Data>["fetch"] type ViewFn<Data> = RouteHandler<Data>["view"] export interface RouteEntry<Data = any> { prefix: string; handler: () => Promise<RouteHandler<Data>> } export function routeEntry<Data>(prefix: string, fetch: FetchFn<Data>, view: ViewFn<Data>): RouteEntry<Data> { let handler = () => Promise.resolve({ fetch, view }) return { prefix, handler }; } export function routeHandler<Data>(prefix: string, handler: RouteEntry['handler']): RouteEntry<Data> { return { prefix, handler }; } let errorView: (route: string, prefix: string, error: string) => preact.ComponentChild; errorView = (_r, _p, e: string) => preact.h("h1", { children: e }); // default dummy error view export function setErrorView( pErrorView: (r: string, p: string, e: string) => preact.ComponentChild, ) { errorView = pErrorView; } export function setRoute(route: string) { // handle special case when view is trying to change the root // and it takes time but because view functions are called multiple times // it tries to set the same route again and again // TODO: perhaps setting route should block re-rendering until the route // data fetching is done if (_route_in_transition === route) { return } history.pushState({ ts: Date.now() }, "", route); onRouteChange(); } export function replaceRoute(route: string) { history.replaceState({ ts: Date.now() }, "", route); onRouteChange(); } export function getRoute(): string { return decodeURI(location.pathname) + location.search; } export type ParsedRoute = { pathname: string; searchParams: URLSearchParams; }; export function parseRoute(route: string): ParsedRoute { let url = new URL(route, location.origin); return { pathname: url.pathname, searchParams: url.searchParams, }; } export function getRouteParsed(): ParsedRoute { return parseRoute(getRoute()); } let gRoutes: RouteEntry[] = []; interface ModalEntry<T = any, R = any> { vm: T; resolve: ResolverFn<R>; view: ModalViewFn<T, R>; clickOutsideValue?: R; } let gModalStack: ModalEntry[] = []; export function openModal<T = any, R = any>( vm: T, view: ModalViewFn<T, R>, clickOutsideValue?: R, ): Promise<R> { const result = new Promise<R>((resolve) => { const entryIndex = gModalStack.length; const resolveAndClose = (result: R): void => { resolve(result); gModalStack.splice(entryIndex, 1); scheduleRedraw(); }; const entry: ModalEntry<T, R> = { vm, resolve: resolveAndClose, view, clickOutsideValue, }; gModalStack.push(entry); // Note: resolve will be called by the view }); scheduleRedraw(); return result; } export function closeTopModal() { let topIndex = gModalStack.length-1 gModalStack.splice(topIndex, 1); scheduleRedraw(); } let cleanupFunctions: Function[] = []; // register a cleanup function to be called when a page changes // for modules that can't be imported here due circularity export function registerCleanupFunction(fn: Function) { cleanupFunctions.push(fn); } // state to helps us set/restore scroll position across navigations let preNavStateId = 0; let postNavStateId = 0; let _block_router = false export function blockAndReload(url: string = "/") { _block_router = true location.href = url // location.reload() } let _route_in_transition: string | null = null async function onRouteChange() { if (_block_router) { return } if (_route_in_transition) { return } // console.log({preNavStateId, postNavStateId}) const routes = gRoutes; let route = getRoute(); let entry = routes.find(entry => route.startsWith(entry.prefix)) if (!entry) { // TODO: set a 404 or something return } _route_in_transition = route storePageScroll(preNavStateId, window.scrollY); let handler = await entry.handler() let [data, error] = await handler.fetch(route, entry.prefix) _route_in_transition = null // reset page data ... cache.clearCache(); refs.clearRefs(); gModalStack.splice(0) // remove all items (reset slice) for (let fn of cleanupFunctions) { fn(); } if (data) { setRootPage({ route, prefix: entry.prefix, data: data, view: handler.view, }); } else { setRootPage({ route, prefix: entry.prefix, data: error, view: errorView, }); } scheduleRedraw(); let desiredYScroll = retrievePageScroll(postNavStateId); requestAnimationFrame(() => { window.scrollTo(0, desiredYScroll); }); } let _scrolls = new Map<number, number>(); function storePageScroll(stateId: number, value: number) { _scrolls.set(stateId, value); } function retrievePageScroll(stateId: number) { return _scrolls.get(stateId) ?? 0; } // @ts-ignore window._scrolls = _scrolls function onPopState(event: PopStateEvent) { console.log("popstate:", event) if (event.state) { preNavStateId = postNavStateId; postNavStateId = event.state.ts; } onRouteChange() } export function initRoutes(routes: RouteEntry[]) { gRoutes = routes; window.addEventListener("popstate", onPopState); // initial state; we use 0 becuase that's the default value for preNavStateId history.replaceState({ ts: Date.now() }, ""); history.scrollRestoration = "auto"; // rewrite legacy urls if (window.location.pathname === '/' && !window.location.search && window.location.hash && window.location.hash.startsWith('#/')) { var newPath = window.location.hash.slice(1); history.replaceState({ ts: Date.now() }, '', newPath); } // handle link clicks document.addEventListener('click', (event) => { if (!(event.target instanceof Element)) { return } const link = event.target.closest('a[href]'); if (!link) { return } const href = link.getAttribute('href') ?? ''; if (href.startsWith('/')) { event.preventDefault(); let ts = Date.now() history.pushState({ ts }, '', href); preNavStateId = postNavStateId; postNavStateId = ts; onRouteChange(); } }); onRouteChange(); } interface Queryable { [key: string]: number | string; } export function queryString(params: Queryable): string { const elements: string[] = []; for (const key in params) { const value = String(params[key]); elements.push(key + "=" + encodeURIComponent(value)); } return elements.join("&"); } export function classes(...names: Array<string | false | undefined>): string { let stringNames: string[] = []; for (let name of names) { if (typeof name === "string") stringNames.push(name); } return stringNames.join(" "); } export async function waitUntil(t: number) { let now = Date.now(); let dur = t - now; if (dur <= 0) { return Promise.resolve(); } return new Promise((resolve) => setTimeout(resolve, dur)); } export function localStorageRead<T>(key: string): T | undefined { let raw = localStorage.getItem(key); if (raw === null) { return undefined; } try { return JSON.parse(raw); } catch { return undefined; } } export function localStorageSet<T>(key: string, value: T) { localStorage.setItem(key, JSON.stringify(value)) } export function focusAfterFrame<T extends HTMLElement>( ref: refs.Ref<T | null>, ) { setTimeout(() => { let el = refs.get(ref); if (el) { el.focus(); scheduleRedraw(); } }); } export function watchElementSize(ref: refs.Ref<HTMLElement | null>) { let height = elementRect(refs.get(ref)).height requestAnimationFrame(() => { let height2 = elementRect(refs.get(ref)).height if (height2 !== height) { scheduleRedraw() } }) } // ====== i18n ====== export type LANG = string; export const EN: LANG = "en"; export const AR: LANG = "ar"; export const JA: LANG = "ja"; export let lang = EN; export function setLang(v: string) { lang = v; } export type LocalizedTextMap = [LANG, string][]; export function selectLocalizedTextByLang(map: LocalizedTextMap, lang: string) { for (let [lang0, text0] of map) { if (lang === lang0) { return text0; } } return map[0][1]; } export function selectLocalizedText(map: LocalizedTextMap): string { return selectLocalizedTextByLang(map, lang); } // ================================================================= // ============ Gestures =================================== // ================================================================= type ITouch = { identifier: number clientX: number clientY: number radiusX: number radiusY: number rotationAngle: number force: number } type TouchData = { position: geom.Point radius: geom.Size force: number } function getTouchData(touch: ITouch): TouchData { return { position: { x: touch.clientX, y: touch.clientY }, radius: { width: touch.radiusX, height: touch.radiusY }, force: touch.force, } } // internal; for accumulating gesture data across frames export type GestureInfo = { target: Element, // internal panId: number, zoomId0: number, zoomId1: number, // internal panPointPrev: geom.Point, zoomVecPrev: geom.Point, // the (x,y) delta between the two touch points } export type FrameGesture = { target: Element isPanning: boolean isZooming: boolean /** How much in absolute pixels the scroll position should change */ panDelta: geom.Point, /** How much in absolute pixels did the distance between the touch points increase */ zoomDelta: number /** The center point between the two touch points */ zoomCenter: geom.Point } function zeroGesture(): GestureInfo { return { target: document.body, panId: -1, zoomId0: -1, zoomId1: -1, panPointPrev: geom.zeroPoint(), zoomVecPrev: geom.zeroPoint(), } } function zeroFrameGesture(): FrameGesture { return { target: document.body, isPanning: false, isZooming: false, panDelta: geom.zeroPoint(), zoomDelta: 0, zoomCenter: geom.zeroPoint(), } } export let _gesture = zeroGesture() export let gesture = zeroFrameGesture() function isTouchEvent(event: Event): event is TouchEvent { return (window['TouchEvent'] && event instanceof TouchEvent) } function touchClientPoint(t: Touch): geom.Point { return { x: t.clientX, y: t.clientY } } function getTouch(event: TouchEvent, touchId: number): Touch | null { return Array.from(event.touches).find(t => t.identifier === touchId) ?? null } function getZoomVecAndCenter(touch0: Touch, touch1: Touch): [geom.Point, geom.Point] { let p0 = touchClientPoint(touch0) let p1 = touchClientPoint(touch1) let vec = geom.pointMinus(p1, p0) let center = geom.pointMiddle(p0, p1) return [vec, center] } // Generated by Claude function processFrameGesture(event: TouchEvent) { if (event.touches.length === 0) { _gesture = zeroGesture() return } gesture.target = _gesture.target // Handle touch start if (event.type === 'touchstart') { if (event.touches.length === 1) { _gesture.target = event.target as Element; } if (event.touches.length === 1) { let touch = event.touches.item(0)! _gesture.panId = touch.identifier _gesture.panPointPrev = touchClientPoint(touch) gesture.isPanning = true; } else if (event.touches.length === 2) { let touch0 = event.touches.item(0)! let touch1 = event.touches.item(1)! _gesture.zoomId0 = touch0.identifier _gesture.zoomId1 = touch1.identifier let [zoomVec, _center] = getZoomVecAndCenter(touch0, touch1) _gesture.zoomVecPrev = zoomVec gesture.isZooming = true; } } if (event.type === 'touchmove') { let pan = getTouch(event, _gesture.panId) let zoom0 = getTouch(event, _gesture.zoomId0) let zoom1 = getTouch(event, _gesture.zoomId1) if (pan) { let point = touchClientPoint(pan) gesture.isPanning = true gesture.panDelta = geom.pointMinus(point, _gesture.panPointPrev) _gesture.panPointPrev = point } if (zoom0 && zoom1) { gesture.isZooming = true let [zoomVec, zoomCenter] = getZoomVecAndCenter(zoom0, zoom1) const prevDist = geom.vectorLength(_gesture.zoomVecPrev) const currDist = geom.vectorLength(zoomVec) gesture.zoomDelta = currDist - prevDist gesture.zoomCenter = zoomCenter _gesture.zoomVecPrev = zoomVec; } } }