import { type Cartesian2D, cartesian2DtoPolar2D, type Polar2D, } from "./Coordinates.js"; import { ignore, iife } from "./Function.js"; import { toKebabCase } from "./String.js"; import { type Override } from "./Types.js"; /** * credit goes to puppeteer types */ export namespace Selector { type CombinatorTokens = [" ", ">", "+", "~", "|", "|"]; type BeginSubclassSelectorTokens = [".", "#", "[", ":"]; type FlatmapSplitWithDelemiters< Inputs extends readonly string[], Delemiters extends readonly string[], Acc extends string[] = [], > = Inputs extends [infer FirstInput, ...infer RestInputs] ? FirstInput extends string ? RestInputs extends readonly string[] ? FlatmapSplitWithDelemiters< RestInputs, Delemiters, [...Acc, ...SplitWithDelemiters] > : Acc : Acc : Acc; type Split< Input extends string, Delimiter extends string, Acc extends string[] = [], > = Input extends `${infer Prefix}${Delimiter}${infer Suffix}` ? Split : [...Acc, Input]; type SplitWithDelemiters< Input extends string, Delemiters extends readonly string[], > = Delemiters extends [infer FirstDelemiter, ...infer RestDelemiters] ? FirstDelemiter extends string ? RestDelemiters extends readonly string[] ? FlatmapSplitWithDelemiters< Split, RestDelemiters > : never : never : [Input]; type Drop< Arr extends readonly unknown[], Remove, Acc extends unknown[] = [], > = Arr extends [infer Head, ...infer Tail] ? Head extends Remove ? Drop : Drop : Acc; type CompoundSelectorsOfComplexSelector = SplitWithDelemiters< ComplexSelector, CombinatorTokens > extends infer IntermediateTokens ? IntermediateTokens extends readonly string[] ? Drop : never : never; type NonEmptyReadonlyArray = [T, ...(readonly T[])]; type Last> = Arr extends [ infer Head, ...infer Tail, ] ? Tail extends NonEmptyReadonlyArray ? Last : Head : never; type TypeSelectorOfCompoundSelector = SplitWithDelemiters< CompoundSelector, BeginSubclassSelectorTokens > extends infer CompoundSelectorTokens ? CompoundSelectorTokens extends [infer TypeSelector, ...any[]] ? TypeSelector extends "" ? unknown : TypeSelector : never : never; type TypeSelectorOfComplexSelector = CompoundSelectorsOfComplexSelector extends infer CompoundSelectors ? CompoundSelectors extends NonEmptyReadonlyArray ? Last extends infer LastCompoundSelector ? LastCompoundSelector extends string ? TypeSelectorOfCompoundSelector : never : never : unknown : never; export type ElementFor< TagName extends keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap, > = TagName extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[TagName] : TagName extends keyof SVGElementTagNameMap ? SVGElementTagNameMap[TagName] : never; export type NodeFor = TypeSelectorOfComplexSelector extends infer TypeSelector ? TypeSelector extends | keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap ? ElementFor : Element : never; } /** * select all matches of the given css selector. optionally check for visibility as well. */ export const selectAll = ( container: Element, selector: Selector, opts: { /** * whether or not the element must be visible * @default false */ visible?: boolean; } = {}, ) => { const elements = Array.from( container.querySelectorAll(selector), ) as Selector.NodeFor[]; if ((opts.visible ??= false)) { return elements.filter((element) => element.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true, opacityProperty: true, }), ); } return elements; }; /** * return the first match of the given css selector. If no match is found an error is thrown */ export const select = ( ...params: Parameters> ) => { const [element] = selectAll(...params); if (element == null) { throw new Error(`could not find element matching selector '${params[1]}'`); } return element as Selector.NodeFor; }; /** * return all ancestors of a node until the root node */ export const getAncestors = (from: Element) => { const ancestors = [] as Element[]; let cursor = from; while (cursor.parentElement != null) { ancestors.push((cursor = cursor.parentElement)); } return ancestors; }; /** * aggregate all previous siblings */ export const allPreviousSiblings = (from: Element) => { let cursor = from.previousElementSibling; const siblings = [] as Element[]; while (cursor != null) { siblings.push(cursor); cursor = cursor.previousElementSibling; } return siblings; }; /** * return the node that matches the given css selector in backward scanning direction. * If no such node is found returns undefined */ export const previous = ( selector: Selector, from: Node, opts: { boundary?: Node } = {}, ): Selector.NodeFor | undefined => { const tw = document.createTreeWalker( opts.boundary ?? document, NodeFilter.SHOW_ELEMENT, ); tw.currentNode = from; while (tw.previousNode()) { if ( tw.currentNode.nodeType === Node.ELEMENT_NODE && (tw.currentNode as HTMLElement).matches(selector) ) { return tw.currentNode as Selector.NodeFor; } } }; /** * return the node that matches the given css selector in forward scanning direction. * If no such node is found returns undefined */ export const next = ( selector: Selector, from: Node, opts: { boundary?: Node } = {}, ): Selector.NodeFor | undefined => { const tw = document.createTreeWalker( opts.boundary ?? document, NodeFilter.SHOW_ELEMENT, ); tw.currentNode = from; while (tw.nextNode()) { if ( tw.currentNode.nodeType === Node.ELEMENT_NODE && (tw.currentNode as HTMLElement).matches(selector) ) { return tw.currentNode as Selector.NodeFor; } } }; /** * performs a forward scan of the given nodes children and returns all nodes * that match the given predicate */ export const filterChildren = ( predicate: (node: Node) => node is Filtered, ofElement: Node, ): Filtered[] => { const filtered = [] as Filtered[]; const tw = document.createTreeWalker(ofElement, NodeFilter.SHOW_ALL); while (tw.nextNode()) { if (predicate(tw.currentNode)) { filtered.push(tw.currentNode); } } return filtered; }; /** * create a new dom element. * * This works similarly to jsx under the hood, so you can rather easily * declare elements instead of imperatively manipulating them yourself. */ export const create = ( tagname: K, attributes: Partial< Override< HTMLElementTagNameMap[K], { style: Partial; part: string } > & { [key: `data-${string}`]: string | boolean | null | undefined; } > = {}, children: Iterable = [], ) => { const element = document.createElement(tagname); Object.entries(attributes).forEach(([key, value]) => { if (typeof value !== "function") { if (value == null) { return; } element.setAttribute( iife(() => { if (key === "className") { return "class"; } if (key === "contentEditable" || key === "tabIndex") { return key.toLowerCase(); } return toKebabCase(key); }), value === true ? "" : value, ); } element[key as keyof typeof element] = value; }); Object.assign(element.style, attributes.style); for (const child of children) { element.append(child); } return element; }; /** * create a new text node */ export const text = (text: string | number) => document.createTextNode(text.toString()); /** * get the closest attribute to a from a given element on upwards. * Can optionally be bounded to another element to prevent scanning * up to root */ export function getClosestAttribute( attribute: string, from: HTMLElement, root = null as null | HTMLElement, ): string | null { const value = from.getAttribute(attribute); if (value != null) { return value; } return from.parentElement == null || from.parentElement === root ? null : getClosestAttribute(attribute, from.parentElement); } /** * get the closest parent that matches a given selector. * * This is different from `HTMLElement.closest` in that it never matches the element itself. */ export function getClosestParent( selector: Selector, from: HTMLElement, ) { return (from.parentElement?.closest(selector) ?? null) as Selector.NodeFor | null; } /** * create a new event from _at least_ a given type string. * * If the passed argument already is an event it is returned * unchanged. This is because cloning events has some pitfalls, * chief among which is that you can't clone trusted events and * re-dispatching trusted events as untrusted events will often * lead to the browser ignoring those events. */ export const createEvent = ( event: { type: Type } & (( Type extends keyof HTMLElementEventMap ? HTMLElementEventMap[Type] : CustomEvent ) extends infer Event ? Event extends CustomEvent ? (unknown extends Detail ? {} : { detail: Detail }) & EventInit : EventInit : never), ) => (event instanceof Event ? event : new CustomEvent(event.type, { bubbles: true, ...event, })) as Type extends keyof HTMLElementEventMap ? HTMLElementEventMap[Type] : typeof event & CustomEvent; /** * dispatch a new custom event */ export const dispatch = ( on: EventTarget, event: Parameters>[0], ) => on.dispatchEvent(event instanceof Event ? event : createEvent(event)); /** * all native events */ export const allEvents = iife(() => Object.keys(globalThis) .filter((key) => key.startsWith("on")) .map((key) => key.slice(2)) .concat(["focusin", "focusout"]), ); export type ForwardEventListenerOptions = AddEventListenerOptions & { /** * you can override the composed path of the forwarded event * to give consumers a way to identify the original target */ keepComposedPath?: boolean; /** * check whether an event should be forwarded or not. */ predicate?: (e: Event) => boolean; }; export type ForwardEventOptions = AddEventListenerOptions & ForwardEventListenerOptions & { eventNames?: string[]; } & ( | { source: EventTarget; target: EventTarget } | { source?: EventTarget; target: EventTarget; predicate: NonNullable; } ); /** * sometimes you want to capture events before they reach their intended * target and re-dispatch them as if they had fired on something else */ export function forwardEvents(options: ForwardEventOptions): void; /** * @deprecated please pass ForwardOptions instead */ export function forwardEvents( from: EventTarget, to: EventTarget, eventNames?: string[], eventListenerOptions?: ForwardEventListenerOptions, ): void; export function forwardEvents( fromOrOptions: EventTarget | ForwardEventOptions, to?: EventTarget, forwardEventNames?: string[], ForwardEventListenerOptions?: ForwardEventListenerOptions, ) { const { predicate, eventNames = allEvents, keepComposedPath, source, target, ...eventListenerOptions } = fromOrOptions instanceof EventTarget ? { ...ForwardEventListenerOptions, source: fromOrOptions, predicate: ForwardEventListenerOptions?.predicate, eventNames: forwardEventNames, target: to!, } : { ...fromOrOptions, source: fromOrOptions.source ?? fromOrOptions.target, predicate: fromOrOptions.predicate, eventNames: fromOrOptions.eventNames, }; for (const eventName of eventNames) { source.addEventListener( eventName, (e) => { if (predicate != null && !predicate(e)) { return; } e.stopImmediatePropagation(); const clone = new (e.constructor as any)(e.type, e) as Event; if (e.defaultPrevented) { clone.preventDefault(); } if (keepComposedPath) { clone.composedPath = () => { return e.composedPath(); }; } dispatch(target, clone); if (clone.defaultPrevented) { e.preventDefault(); } }, eventListenerOptions, ); } } /** * returns a nodes index in its parents children */ export const indexInParent = (element: Element) => element.parentElement == null ? -1 : Array.from(element.parentElement.children).indexOf(element); /** * swap child elements by index. * * Does not perform any sanity checks; you are responsible for making sure there are elements at the given indices. */ export const swapIndices = (inElement: Element, a: number, b: number) => { inElement.insertBefore( inElement.children.item(Math.max(a, b))!, inElement.children.item(Math.min(a, b)), ); }; /** * watches the attribute on *the closest parent that has it set at the time of * calling this function* * * Exercise caution when using this function as the element that has the attribute * you're interested in might change. */ export const watchClosestAttribute = ( attribute: string, from: Element, callback: (current: string | null) => void, opts: { /** * specify an AbortSignal to disconnect the observer */ signal?: AbortSignal; /** * an optional "boundary" to prevent watching attributes * that exist "too high up" in the tree */ root?: Element; } = {}, ): Disposable => { const disposable: Disposable = { [Symbol.dispose]: ignore, }; const parentWithAttribute = from.closest(`[${attribute}]`); if ( parentWithAttribute == null || (opts.root != null && !opts.root.contains(parentWithAttribute)) ) { return disposable; } const attributeObserver = new MutationObserver((records) => { for (const record of records) { if ( record.type === "attributes" && record.attributeName === attribute && record.target instanceof Element ) { const current = record.target.getAttribute(attribute); callback(current); } } }); attributeObserver.observe(parentWithAttribute, { attributes: true, attributeFilter: [attribute], }); disposable[Symbol.dispose] = () => attributeObserver.disconnect(); opts?.signal?.addEventListener("abort", disposable[Symbol.dispose]); return disposable; }; /** * build a string from classnames. * * This is pretty much the same as the npm package classnames, but I got tired of installing that * and in the sense of this being the "prelude to web dev" I've written my own here.. */ export const classNames = ( ...classes: Array | string | undefined> ) => classes .reduce((classList, item) => { if (item == null) { return classList; } if (typeof item === "string") { classList.push(item); } if (typeof item != "string") { Object.entries(item).forEach(([className, enabled]) => { if (enabled) { classList.push(className); } }); } return classList; }, [] as string[]) .join(" "); /** * returns polar coordinates of where the given event happened * in relation to the center of the given element. * * This is useful e.g. to determine whether an event happened * in the upper or lower half and such. */ export const getPolarCoordinatesFromCenter = ( event: MouseEvent, element: HTMLElement, ): Polar2D => { return cartesian2DtoPolar2D([ event.offsetX - element.offsetWidth / 2, event.offsetY - element.offsetHeight / 2, ]); }; /** * extended information sourced from {@MDN getBoundingClientRect} */ export const getClientRect = (element: HTMLElement) => { const { left, top, width, height } = element.getBoundingClientRect(); return { left, right: left + width, top, bottom: top + height, center: [left + width / 2, top + height / 2] as Cartesian2D, }; }; /** * returns a cartesian delta of an event position relative to an elements center */ export const getEventOffsetFromCenter = ( event: MouseEvent, target: HTMLElement, ): Cartesian2D => { const { center: [x, y], } = getClientRect(target); return [event.clientX - x, event.clientY - y] as Cartesian2D; }; export const isEventInLeftHalf = (event: MouseEvent, target: HTMLElement) => { const [offsetX] = getEventOffsetFromCenter(event, target); return offsetX < 0; }; export const isEventInRightHalf = (event: MouseEvent, target: HTMLElement) => { const [offsetX] = getEventOffsetFromCenter(event, target); return offsetX >= 0; }; export const isEventInTopHalf = (event: MouseEvent, target: HTMLElement) => { const [, offsetY] = getEventOffsetFromCenter(event, target); return offsetY < 0; }; export const isEventInBottomHalf = (event: MouseEvent, target: HTMLElement) => { const [, offsetY] = getEventOffsetFromCenter(event, target); return offsetY >= 0; }; /** * removes existing selection ranges and creates a new range that spans * the given char-index range in the given element. This is focused on * the text content of the given element, so it will skip/span child element * boundaries. */ export const selectText = ( on: Node, from: number = 0, to = on.textContent?.length ?? 0, ) => { if (on.nodeType !== Node.TEXT_NODE) { return; } const selection = getSelection(); if (selection == null) { return; } const range = document.createRange(); range.setStart(on, from >= 0 ? from : on.textContent!.length - from); range.setEnd(on, to >= 0 ? to : on.textContent!.length - to); selection.removeAllRanges(); selection.addRange(range); }; /** * create a way to attach event listeners and pre-bind common options. * * This is useful to avoid passing the same parameters over and over again. * * @example * ```ts * const on = createEventRegistrar(someInput, someAbortController); * on("change", handleChange) * on(["focusin", "focusout"], handleFocusChange) * ``` */ export const createEventRegistrar = (on: HTMLElement, options: Parameters[2]) => ( eventName: EventName | EventName[], handler: Parameters< NoInfer extends keyof HTMLElementEventMap ? typeof on.addEventListener : typeof on.addEventListener >[1], overrideOptions?: typeof options, ) => { const optionsToObject = ( opts: typeof options, ): AddEventListenerOptions | undefined => opts == null ? opts : typeof opts === "boolean" ? { capture: opts } : { // can't use object spreading here because it's possible // to pass in objects that match the structure but don't // enumerate their properties - e.g. AbortControllers. signal: opts.signal, capture: opts.capture, once: opts.once, passive: opts.passive, }; const mergedOptions = { ...optionsToObject(options), ...optionsToObject(overrideOptions), }; const attach = (eventName: string) => on.addEventListener(eventName, handler as EventListener, mergedOptions); if (Array.isArray(eventName)) { eventName.forEach(attach); } else { attach(eventName); } }; /** * little helper to create an "enum" like structure that exposes * event names regarding changes to the attributes of the given type. * * Keep in mind that this does not yet attach any event * listener or such. You still have to use the resulting * strings to do that yourself. * * @deprecated usage of this functionality is implicitly coupled to simple-custom-elements library and bears no functionality on its own. Have a look at {@link createKeyNameProxy} for an alternative in combination with simple-custom-elements * * @example * * ```ts * @customElement({tagname: "my-ce"}) * class MyCe extends HTMLElement { * @attribute() * foo = ""; * } * const Events = createEventNameProxy();` * someDiv.addEventListener( * Events.fooChanged, // event name is created on usage * someEventListener, * ); * * someDiv.dispatchEvent(new Event(Events.clear)); * ``` */ export const createEventNameProxy = < Element extends EventTarget, AdditionalEventNames extends string[], >() => new Proxy( {} as { [k in keyof Element as `${k & string}Changed`]-?: `${k & string}Changed`; } & { [k in AdditionalEventNames[number]]: k; }, { get: (_, p) => { return String(p); }, }, ); /** * create a proxy object that saves the passed keys. * * Think of it as making a typescript union of strings * accessible at runtime without using reflection. * * @example * ``` * type Events = { * foo: CustomEvent; * bar: CustomEvent; * } * const events = createKeyNameProxy(); * * declare global { * // merge your custom events into the global event map * // this way they're integrating nicely with `addEventListener` * interface HTMLElementEventMap extends Events {} * } * ``` */ export const createKeyNameProxy = >() => new Proxy({} as { [k in keyof Keys]: k }, { get: (proxy, key) => (proxy[key as keyof Keys] ??= key as string), }); /** * creates a virtual text cursor relative to the given element. * * With this you can get and set a cursors position without * consideration for child elements - only the user visible * text counts. */ export const createCursor = (relativeTo: HTMLElement) => { const treeWalker = document.createTreeWalker(relativeTo, NodeFilter.SHOW_ALL); type Cursor = NonNullable>; const getNodeValue = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { return node.nodeValue ?? ""; } if (node.nodeType === Node.ELEMENT_NODE) { return node instanceof HTMLBRElement ? "\n" : ""; } return ""; }; const getPosition = () => { const selection = getSelection(); const range = !selection?.rangeCount ? undefined : selection?.getRangeAt(0); if (range == null) { return; } treeWalker.currentNode = range.startContainer; let previousText = range.startContainer.nodeValue?.slice(0, range.startOffset) ?? (range.startContainer.nodeType === Node.ELEMENT_NODE ? (range.startContainer as HTMLElement).innerText : ""); while (treeWalker.previousNode()) { previousText = `${getNodeValue(treeWalker.currentNode as Text | HTMLBRElement)}${previousText}`; } const lines = previousText.split("\n"); return { x: lines.at(-1)!.length, y: lines.length - 1, }; }; const setPosition = (cursor: Cursor) => { const position = { x: 0, y: 0 }; while (treeWalker.nextNode()) { const nodeValue = getNodeValue( treeWalker.currentNode as HTMLBRElement | Text, ); if (nodeValue === "\n") { position.y++; position.x = 0; } else { position.x += nodeValue.length; } if (position.y >= cursor.y && position.x >= cursor.x) { const offset = cursor.x - Math.max(0, position.x - nodeValue.length); const range = document.createRange(); range.setStart(treeWalker.currentNode, offset); const selection = getSelection(); selection?.removeAllRanges(); selection?.addRange(range); return; } } }; return { getPosition, setPosition, }; };