UNPKG

web-utility

Version:

Web front-end toolkit based on TypeScript

370 lines (293 loc) 10.4 kB
import { URLData } from './URL'; import { HTMLProps, HTMLField, CSSStyles, CSSObject } from './DOM-type'; import { Constructor, isEmpty, assertInheritance, toHyphenCase } from './data'; import { toJSValue } from './parser'; export const XMLNamespace = { html: 'http://www.w3.org/1999/xhtml', svg: 'http://www.w3.org/2000/svg', math: 'http://www.w3.org/1998/Math/MathML' }; const templateMap: Record<string, Element> = {}; export function templateOf(tagName: string) { if (templateMap[tagName]) return templateMap[tagName]; const spawn = document.createElement('template'); spawn.innerHTML = `<${tagName} />`; return (templateMap[tagName] = spawn.content.firstElementChild!); } export function elementTypeOf(tagName: string) { if (tagName.includes('-')) return 'html'; const [prefix, localName] = tagName.split(':'); if (localName) return prefix === 'html' ? 'html' : 'xml'; const node = templateOf(tagName); return node instanceof HTMLElement && !(node instanceof HTMLUnknownElement) ? 'html' : 'xml'; } export function isHTMLElementClass<T extends Constructor<HTMLElement>>( Class: any ): Class is T { return assertInheritance(Class, HTMLElement); } const nameMap = new WeakMap<Constructor<HTMLElement>, string>(); export function tagNameOf(Class: CustomElementConstructor) { const name = nameMap.get(Class); if (name) return name; var { tagName } = new Class(); nameMap.set(Class, (tagName = tagName.toLowerCase())); return tagName; } export function isDOMReadOnly<T extends keyof HTMLElementTagNameMap>( tagName: T, propertyName: keyof HTMLProps<HTMLElementTagNameMap[T]> ) { /** * fetch from https://html.spec.whatwg.org/ */ const ReadOnly_Properties: [Constructor<HTMLElement>, string[]][] = [ [HTMLLinkElement, ['sizes']], [HTMLIFrameElement, ['sandbox']], [HTMLObjectElement, ['form']], [HTMLInputElement, ['form', 'list']], [HTMLButtonElement, ['form']], [HTMLSelectElement, ['form']], [HTMLTextAreaElement, ['form']], [HTMLOutputElement, ['form']], [HTMLFieldSetElement, ['form']] ]; const template = templateOf(tagName); for (const [Class, keys] of ReadOnly_Properties) if (template instanceof Class && keys.includes(propertyName as string)) return true; return false; } export function parseDOM(HTML: string) { const spawn = document.createElement('template'); spawn.innerHTML = HTML; return [...spawn.content.childNodes].map(node => { node.remove(); return node; }); } export function stringifyDOM(node: Node) { return new XMLSerializer() .serializeToString(node) .replace(/ xmlns="http:\/\/www.w3.org\/1999\/xhtml"/g, ''); } export function* walkDOM<T extends Node = Node>( root: Node, type?: Node['nodeType'] ): Generator<T> { const children = [...root.childNodes]; if (isEmpty(type) || type === root.nodeType) yield root as T; for (const node of children) yield* walkDOM(node, type); } export function getVisibleText(root: Element) { var text = ''; for (const { nodeType, parentElement, nodeValue } of walkDOM(root)) if ( nodeType === Node.TEXT_NODE && parentElement.getAttribute('aria-hidden') !== 'true' ) { const { width, height } = parentElement.getBoundingClientRect(); if (width && height) text += nodeValue.trim().replace(/\s+/g, ' '); } return text; } /** * Split a DOM tree into Pages like PDF files * * @param pageHeight the default value is A4 paper's height * @param pageWidth the default value is A4 paper's width */ export function splitPages( { offsetWidth, children }: HTMLElement, pageHeight = 841.89, pageWidth = 595.28 ) { const scrollHeight = (pageHeight / pageWidth) * offsetWidth; var offset = 0; return [...children].reduce((pages, node) => { var { offsetTop: top, offsetHeight: height } = node as HTMLElement; top += offset; var bottom = top + height; const bottomOffset = bottom / scrollHeight; const topIndex = ~~(top / scrollHeight), bottomIndex = ~~bottomOffset; if (topIndex !== bottomIndex) offset += height - bottomOffset; (pages[bottomIndex] ||= []).push(node); return pages; }, [] as Element[][]); } export interface CSSOptions extends Pick< HTMLLinkElement, 'title' | 'media' | 'crossOrigin' | 'integrity' > { alternate?: boolean; } export function importCSS( URI: string, { alternate, ...options }: CSSOptions = {} as CSSOptions ) { const style = [...document.styleSheets].find(({ href }) => href === URI); if (style) return Promise.resolve(style); const link = document.createElement('link'); return new Promise<CSSStyleSheet>((resolve, reject) => { link.onload = () => resolve(link.sheet); link.onerror = (_1, _2, _3, _4, error) => reject(error); Object.assign(link, options); link.rel = (alternate ? 'alternate ' : '') + 'stylesheet'; link.href = URI; document.head.append(link); }); } export function stringifyCSS( data: CSSStyles | CSSObject, depth = 0, indent = ' ' ): string { const padding = indent.repeat(depth); return Object.entries(data) .map(([key, value]) => typeof value !== 'object' ? `${padding}${toHyphenCase(key)}: ${value};` : `${padding}${key} { ${stringifyCSS(value as CSSObject, depth + 1, indent)} ${padding}}` ) .join('\n'); } export function insertToCursor(...nodes: Node[]) { const fragment = document.createDocumentFragment(); fragment.append(...nodes); for (const node of walkDOM(fragment)) if ( ![1, 3, 11].includes(node.nodeType) || ['meta', 'title', 'link', 'script'].includes( node.nodeName.toLowerCase() ) ) (node as ChildNode).replaceWith(...node.childNodes); const selection = globalThis.getSelection(); if (!selection) return; const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(fragment); } export function scrollTo( selector: string, root?: Element, align?: ScrollLogicalPosition, justify?: ScrollLogicalPosition ) { const [_, ID] = /^#(.+)/.exec(selector) || []; if (ID === 'top') window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }); else (root || document) .querySelector(ID ? `[id="${ID}"]` : selector) ?.scrollIntoView({ behavior: 'smooth', block: align, inline: justify }); } export interface ScrollEvent { target: HTMLHeadingElement; links: (HTMLAnchorElement | HTMLAreaElement)[]; } export function watchScroll( box: HTMLElement, handler: (event: ScrollEvent) => any, depth = 6 ) { return Array.from( box.querySelectorAll<HTMLHeadingElement>( Array.from(new Array(depth), (_, index) => `h${++index}`) + '' ), header => { new IntersectionObserver(([item]) => { if (!item.isIntersecting) return; const target = item.target as HTMLHeadingElement; handler({ target, links: [ ...target.ownerDocument.querySelectorAll< HTMLAnchorElement | HTMLAreaElement >(`[href="#${target.id}"]`) ] }); }).observe(header); if (!header.id.trim()) header.id = header.textContent.trim().replace(/\W+/g, '-'); return { level: +header.tagName[1], id: header.id, text: header.textContent.trim() }; } ); } export function watchVisible( root: Element, handler: (visible: boolean) => any ) { var last = document.visibilityState === 'visible' ? 1 : 0; function change(state: number) { if (state === 3 || last === 3) handler(state === 3); last = state; } new IntersectionObserver(([{ isIntersecting }]) => change(isIntersecting ? last | 2 : last & 1) ).observe(root); document.addEventListener('visibilitychange', () => change(document.visibilityState === 'visible' ? last | 1 : last & 2) ); } export function formToJSON<T extends object = URLData<File>>( form: HTMLFormElement | HTMLFieldSetElement ) { const data = {} as T; for (const field of form.elements) { let { name, value, checked, defaultValue, selectedOptions, files } = field as HTMLField; const type = (field as HTMLField).type as string; if (!name || value === '') continue; const box = type !== 'fieldset' && field.closest('fieldset'); if (box && box !== form) continue; let parsedValue: any = value; switch (type) { case 'radio': case 'checkbox': if (checked) parsedValue = defaultValue ? toJSValue(defaultValue) : true; else continue; break; case 'select-multiple': parsedValue = Array.from(selectedOptions, ({ value }) => toJSValue(value) ); break; case 'fieldset': parsedValue = formToJSON(field as HTMLFieldSetElement); break; case 'file': parsedValue = files && Array.from(files); break; case 'date': case 'datetime-local': case 'month': case 'hidden': case 'number': case 'range': case 'select-one': parsedValue = toJSValue(value); } if (name in data) data[name] = [].concat(data[name], parsedValue); else data[name] = !(parsedValue instanceof Array) || !isEmpty(parsedValue[1]) ? parsedValue : parsedValue[0]; } return data; }