import type { JSX } from "solid-js"; import { render } from "solid-js/web"; import { markUiRoot } from "../../ui"; import { externalDom } from "./external"; const MANAGED_DOM_NODE_CLASS = "ehpeek-managed"; const HIDDEN_ORIGINAL_DOM_NODE_CLASS = "ehpeek-hide-original-node"; const EHPEEK_ANCHOR_ATTRIBUTE = "data-ehpeek-anchor"; const mountedNodes = new WeakMap void>(); let managedDocumentElement: ManagedDomNode | null = null; let managedBody: ManagedDomNode | null = null; export type DomNodeFilter = ( node: DomNode, ) => boolean; export type DomApply = Readonly>; export interface DomChilds { readonly [name: string]: DomDescription; } type DomDefinitionBase = { readonly apply: DomApply; readonly childs: DomChilds; readonly kind: "node"; readonly selector: string; readonly __element?: HTMLElement; }; export type DomDescription = DomChilds | DomDefinitionBase; type EmptyDomChilds = Readonly>; export type DomDefinition< TElement extends HTMLElement = HTMLElement, TApply extends DomApply = DomApply, TChilds extends DomChilds = EmptyDomChilds, > = { readonly apply: TApply; readonly childs: TChilds; readonly kind: "node"; readonly selector: string; readonly __element?: TElement; } & TChilds; type DomOptions< TApply extends DomApply, TChilds extends DomChilds, > = { readonly apply?: TApply; readonly childs?: TChilds; }; type BoundDomChilds = { readonly [TKey in keyof TChilds]: BoundDom; }; type BoundDomNode< TElement extends HTMLElement, TApply extends DomApply, > = { all(): DomNode[]; clone(): ManagedDomNode | null; cloneAll(): ManagedDomNode[]; inplace(): ManagedDomNode | null; inplaceAll(): ManagedDomNode[]; move(): ManagedDomNode | null; moveAll(): ManagedDomNode[]; one(): DomNode | null; requery(): DomNode[]; }; export type BoundDom = TDescription extends DomDefinition ? BoundDomNode & BoundDomChilds : TDescription extends DomChilds ? BoundDomChilds : never; const emptyDomApply = {} as const; const emptyDomChilds = {} as const; function defineDomNode() { return < const TApply extends DomApply = typeof emptyDomApply, const TChilds extends DomChilds = typeof emptyDomChilds, >( selector: string, options: DomOptions = {}, ): DomDefinition => { const childs = options.childs ?? emptyDomChilds as TChilds; return Object.assign({ apply: options.apply ?? emptyDomApply as TApply, childs, kind: "node" as const, selector, }, childs); }; } export const query = defineDomNode(); export const anchor = defineDomNode(); export const area = defineDomNode(); export const button = defineDomNode(); export const cell = defineDomNode(); export const control = defineDomNode(); export const form = defineDomNode(); export const image = defineDomNode(); export const input = defineDomNode(); export const option = defineDomNode(); export const row = defineDomNode(); export const script = defineDomNode(); export const select = defineDomNode(); export const table = defineDomNode(); export const textarea = defineDomNode(); export function cls< const TApply extends DomApply = typeof emptyDomApply, const TChilds extends DomChilds = typeof emptyDomChilds, >( name: string, options: DomOptions = {}, ): DomDefinition { return query(`.${name}`, options); } export function id< const TApply extends DomApply = typeof emptyDomApply, const TChilds extends DomChilds = typeof emptyDomChilds, >( name: string, options: DomOptions = {}, ): DomDefinition { return query(`#${name}`, options); } export function tag< const TTag extends keyof HTMLElementTagNameMap, const TApply extends DomApply = typeof emptyDomApply, const TChilds extends DomChilds = typeof emptyDomChilds, >( name: TTag, options: DomOptions = {}, ): DomDefinition { return defineDomNode()(name, options); } function domSelector( source: string | DomDefinition, ): string { return typeof source === "string" ? source : source.selector; } export function originalPageNode( node: DomNode, ): boolean { return node.closest(externalDom.retainedOriginalPageSelector) === null; } export function retainedOriginalPageNode( node: DomNode, ): boolean { return node.closest(externalDom.retainedOriginalPageSelector) !== null; } export function anyDomNode(): boolean { return true; } export type ManagedDomElements = Record< string, ManagedDomNode | ManagedDomNode[] | null >; /** Creates a uniquely named managed mount for one component feature. */ export function createAnchor( name: string, ): ManagedDomNode | null { const selector = `[${EHPEEK_ANCHOR_ATTRIBUTE}="${CSS.escape(name)}"]`; if (document.querySelector(selector)) { return null; } const anchor = document.createElement("div"); anchor.setAttribute(EHPEEK_ANCHOR_ATTRIBUTE, name); return DomNode.from(anchor).inplace(); } /** Creates an EhPeek-owned managed element without touching original-page DOM. */ export function createManagedElement( tagName: K, ): ManagedDomNode; export function createManagedElement< K extends keyof HTMLElementTagNameMap, const TApply extends DomApply, >( tagName: K, apply: TApply, ): ManagedDomNode; export function createManagedElement( tagName: K, apply: DomApply = emptyDomApply, ): ManagedDomNode { return ManagedDomNode.from(document.createElement(tagName), apply); } /** Acquires the document element for page-level feature transforms. */ export function documentElement(): ManagedDomNode { managedDocumentElement ??= DomNode.from(document.documentElement).inplace(); return managedDocumentElement; } /** Acquires the document body for page-level feature transforms. */ export function documentBody(): ManagedDomNode { managedBody ??= DomNode.from(document.body).inplace(); return managedBody; } /** * Read-only access to original-page DOM before ownership is decided. * Selector queries exclude retained translated copies unless the caller explicitly requests them for data extraction. */ export class DomNode { readonly #node: T; private constructor(node: T) { this.#node = node; } static from(node: T): DomNode { return new DomNode(node); } use( description: TDescription, ): BoundDom { return bindDom(description, () => [this]).bound; } one< TElement extends HTMLElement, TApply extends DomApply, TChilds extends DomChilds, >( source: DomDefinition, filter?: DomNodeFilter, ): DomNode | null; one( source: string, filter?: DomNodeFilter, ): DomNode | null; one( source: string | DomDefinition, filter: DomNodeFilter = originalPageNode, ): DomNode | null { return Array.from( this.#node.querySelectorAll(domSelector(source)), DomNode.from, ).find(filter) ?? null; } all< TElement extends HTMLElement, TApply extends DomApply, TChilds extends DomChilds, >( source: DomDefinition, filter?: DomNodeFilter, ): DomNode[]; all( source: string, filter?: DomNodeFilter, ): DomNode[]; all( source: string | DomDefinition, filter: DomNodeFilter = originalPageNode, ): DomNode[] { return Array.from(this.#node.querySelectorAll(domSelector(source))) .map(DomNode.from) .filter(filter); } parent(this: DomNode): DomNode | null { const parent = this.#node.parentElement; return parent ? DomNode.from(parent) : null; } children(this: DomNode): DomNode[] { return Array.from(this.#node.children, (child) => DomNode.from(child as HTMLElement)); } closest< TElement extends HTMLElement, TApply extends DomApply, TChilds extends DomChilds, >( this: DomNode, source: DomDefinition, ): DomNode | null; closest( this: DomNode, source: string, ): DomNode | null; closest( this: DomNode, source: string | DomDefinition, ): DomNode | null { const element = this.#node.closest(domSelector(source)); return element ? DomNode.from(element) : null; } matches(this: DomNode, source: string | DomDefinition): boolean { return this.#node.matches(domSelector(source)); } previous(this: DomNode): DomNode | null { const previous = this.#node.previousElementSibling; return previous instanceof HTMLElement ? DomNode.from(previous) : null; } form(this: DomNode): DomNode | null { return this.#node.form ? DomNode.from(this.#node.form) : null; } childElementCount(): number { return this.#node.childElementCount; } text(): string { return this.#node.textContent?.trim() ?? ""; } attribute(this: DomNode, name: string): string | null { return this.#node.getAttribute(name); } hasAttribute(this: DomNode, name: string): boolean { return this.#node.hasAttribute(name); } attributeNames(this: DomNode): string[] { return this.#node.getAttributeNames(); } hasClass(this: DomNode, className: string): boolean { return this.#node.classList.contains(className); } computedStyle(this: DomNode): CSSStyleDeclaration { return window.getComputedStyle(this.#node); } imageSize(this: DomNode): { height: number; width: number } { return { height: this.#node.naturalHeight || this.#node.height || Number(this.#node.getAttribute("height") || ""), width: this.#node.naturalWidth || this.#node.width || Number(this.#node.getAttribute("width") || ""), }; } inputValue(this: DomNode): string { return this.#node.value; } checked(this: DomNode): boolean { return this.#node.checked; } selected(this: DomNode): boolean { return this.#node.selected; } sameNode(other: DomNode): boolean { return this.#node === other.#node; } observe( source: string | DomDefinition, onObserved: (node: DomNode) => void | (() => void), options: MutationObserverInit = { childList: true, subtree: true }, ): () => void { const seen: DomNode[] = []; const cleanups: Array<() => void> = []; const scan = () => { for (const node of this.all(domSelector(source))) { if (seen.some((candidate) => candidate.sameNode(node))) { continue; } seen.push(node); const cleanup = onObserved(node); if (cleanup) { cleanups.push(cleanup); } } }; const observer = new MutationObserver(scan); scan(); observer.observe(this.#node, options); return () => { observer.disconnect(); cleanups.forEach((cleanup) => cleanup()); }; } inplace( this: DomNode, ): ManagedDomNode; inplace( this: DomNode, apply: TApply, ): ManagedDomNode; inplace( this: DomNode, apply: DomApply = emptyDomApply, ): ManagedDomNode { return ManagedDomNode.from(this.#node, apply); } move(this: DomNode): ManagedDomNode; move( this: DomNode, apply: TApply, ): ManagedDomNode; move( this: DomNode, apply: DomApply = emptyDomApply, ): ManagedDomNode { const managed = this.inplace(apply); managed.remove(); return managed; } clone( this: DomNode, deep?: boolean, ): ManagedDomNode; clone( this: DomNode, apply: TApply, deep?: boolean, ): ManagedDomNode; clone( this: DomNode, applyOrDeep: DomApply | boolean = emptyDomApply, deep = true, ): ManagedDomNode { const apply = typeof applyOrDeep === "boolean" ? emptyDomApply : applyOrDeep; const cloneDeep = typeof applyOrDeep === "boolean" ? applyOrDeep : deep; return ManagedDomNode.from( this.#node.cloneNode(cloneDeep) as T & HTMLElement, apply, ); } } function bindDom( description: TDescription, scopes: () => DomNode[], ): { bound: BoundDom; invalidate: () => void } { const children: Array<{ invalidate: () => void }> = []; const invalidateChildren = () => { for (const child of children) { child.invalidate(); } }; const definition = isDomDefinition(description) ? description : null; const nodeController = definition ? bindDomNode(definition, scopes, invalidateChildren) : null; const bound = nodeController?.bound ?? {}; const childScopes = definition ? () => nodeController?.bound.all() ?? [] : scopes; const childs = definition?.childs ?? description as DomChilds; for (const [name, child] of Object.entries(childs)) { if (name in bound) { throw new Error(`Original DOM child name is reserved: ${name}`); } const childController = bindDom(child, childScopes); children.push(childController); Object.assign(bound, { [name]: childController.bound }); } return { bound: bound as BoundDom, invalidate: nodeController?.invalidate ?? invalidateChildren, }; } function isDomDefinition( description: DomDescription, ): description is DomDefinitionBase { return "kind" in description && description.kind === "node"; } function bindDomNode( description: DomDefinitionBase, scopes: () => DomNode[], invalidateChildren: () => void, ): { bound: BoundDomNode; invalidate: () => void; } { let cached: DomNode[] | undefined; const queryNodes = () => scopes() .flatMap((scope) => scope.all(description.selector)); const resolve = () => { cached ??= queryNodes(); return cached; }; const invalidate = () => { cached = undefined; invalidateChildren(); }; return { bound: { all: () => [...resolve()], clone: () => resolve()[0]?.clone(description.apply) ?? null, cloneAll: () => resolve().map((node) => node.clone(description.apply)), inplace: () => resolve()[0]?.inplace(description.apply) ?? null, inplaceAll: () => resolve().map((node) => node.inplace(description.apply)), move: () => resolve()[0]?.move(description.apply) ?? null, moveAll: () => resolve().map((node) => node.move(description.apply)), one: () => resolve()[0] ?? null, requery: () => { cached = queryNodes(); invalidateChildren(); return [...cached]; }, }, invalidate, }; } /** A node owned by EhPeek and therefore safe to mount or mutate. */ export class ManagedDomNode< T extends HTMLElement = HTMLElement, TApply extends string = never, > { readonly Component: () => T; readonly #apply: DomApply; readonly #node: T; private constructor(element: T, apply: DomApply) { this.#apply = apply; this.#node = element; this.Component = () => this.#node; } static from( element: TElement, ): ManagedDomNode; static from< TElement extends HTMLElement, const TApply extends DomApply, >( element: TElement, apply: TApply, ): ManagedDomNode; static from( element: TElement, apply: DomApply = emptyDomApply, ): ManagedDomNode { if (__EHPEEK_DEBUG__) { element.classList.add(MANAGED_DOM_NODE_CLASS); } return new ManagedDomNode(element, apply); } apply(...names: TApply[]): this { const classes = names.map((name) => { const className = this.#apply[name]; if (!className) { throw new Error(`Unknown original DOM application: ${name}`); } return className; }); this.#node.classList.add(...classes); return this; } all< TElement extends HTMLElement, TDomApply extends DomApply, TChilds extends DomChilds, >( source: DomDefinition, ): ManagedDomNode[]; all( source: string, ): ManagedDomNode[]; all( source: string | DomDefinition, ): ManagedDomNode[] { const apply = typeof source === "string" ? emptyDomApply : source.apply; return Array.from( this.#node.querySelectorAll(domSelector(source)), (node) => ManagedDomNode.from(node, apply), ); } rect(): DOMRect { return this.#node.getBoundingClientRect(); } readAttribute(name: string): string | null { return this.#node.getAttribute(name); } imageSize(this: ManagedDomNode): { height: number; width: number } { return { height: this.#node.naturalHeight || this.#node.height || Number(this.#node.getAttribute("height") || ""), width: this.#node.naturalWidth || this.#node.width || Number(this.#node.getAttribute("width") || ""), }; } setAttributes(values: Readonly>): this { for (const [name, value] of Object.entries(values)) { this.#node.setAttribute(name, value); } return this; } removeAttributes(...names: string[]): this { for (const name of names) { this.#node.removeAttribute(name); } return this; } addClasses(...names: string[]): this { this.#node.classList.add(...names); return this; } removeClasses(...names: string[]): this { this.#node.classList.remove(...names); return this; } replaceClasses(value: string): this { this.#node.className = value; return this; } styles(values: Readonly>, priority = ""): this { for (const [property, value] of Object.entries(values)) { this.#node.style.setProperty(property, value, priority); } return this; } removeStyles(...properties: string[]): this { for (const property of properties) { this.#node.style.removeProperty(property); } return this; } removeAllStyles(): this { this.#node.removeAttribute("style"); return this; } attribute(name: string, value: string): this { this.#node.setAttribute(name, value); return this; } click(): void { this.#node.click(); } mount(view: () => JSX.Element): void { mountedNodes.get(this.#node)?.(); this.#node.replaceChildren(); markUiRoot(this.#node); mountedNodes.set(this.#node, render(view, this.#node)); } remove(): void { mountedNodes.get(this.#node)?.(); mountedNodes.delete(this.#node); this.#node.remove(); } replaceWith(replacement: ManagedDomNode | Node): void { this.#node.replaceWith( replacement instanceof ManagedDomNode ? replacement.#node : replacement, ); } before(sibling: ManagedDomNode | Node): void { this.#node.before(sibling instanceof ManagedDomNode ? sibling.#node : sibling); } after(sibling: ManagedDomNode | Node): void { this.#node.after(sibling instanceof ManagedDomNode ? sibling.#node : sibling); } append(...children: ManagedDomNode[]): this { this.#node.append(...children.map((child) => child.#node)); return this; } prepend(child: ManagedDomNode | Node): void { this.#node.prepend(child instanceof ManagedDomNode ? child.#node : child); } setTextUnlessInput(text: string): void { if (!(this.#node instanceof HTMLInputElement)) { this.#node.textContent = text; } } setHidden(hidden: boolean): this { this.#node.hidden = hidden; return this; } hideOriginal(): this { this.#node.classList.add(HIDDEN_ORIGINAL_DOM_NODE_CLASS); return this; } replaceChildren(...children: Array): void { this.#node.replaceChildren(...children.map((child) => child instanceof ManagedDomNode ? child.#node : child)); } listen( type: K, listener: (event: HTMLElementEventMap[K]) => void, options?: boolean | AddEventListenerOptions, ): () => void { this.#node.addEventListener(type, listener, options); return () => this.#node.removeEventListener(type, listener, options); } observe( onChange: () => void, options: MutationObserverInit = { childList: true, subtree: true }, ): () => void { const observer = new MutationObserver(onChange); observer.observe(this.#node, options); return () => observer.disconnect(); } focus(this: ManagedDomNode): void { this.#node.focus(); } scrollIntoView(options?: ScrollIntoViewOptions): void { this.#node.scrollIntoView(options); } isNode(node: Node): boolean { return this.#node === node; } contains(node: Node): boolean { return this.#node.contains(node); } matches(source: string | DomDefinition): boolean { return this.#node.matches(domSelector(source)); } copyAttributesTo(target: ManagedDomNode): void { for (const attribute of Array.from(this.#node.attributes)) { target.#node.setAttribute(attribute.name, attribute.value); } } setInputValue(this: ManagedDomNode, value: string): void { this.#node.value = value; } inputValue(this: ManagedDomNode): string { return this.#node.value; } setSelected(this: ManagedDomNode, selected: boolean): void { this.#node.selected = selected; } dispatchInput(this: ManagedDomNode): void { this.#node.dispatchEvent(new Event("input", { bubbles: true })); } mirrorContentTo(target: HTMLElement): () => void { const update = () => { target.replaceChildren( ...Array.from(this.#node.childNodes, (node) => node.cloneNode(true)), ); const language = this.#node.getAttribute("lang"); if (language) { target.setAttribute("lang", language); } else { target.removeAttribute("lang"); } }; update(); return this.observe(update, { attributes: true, attributeFilter: ["lang"], characterData: true, childList: true, subtree: true, }); } }