173 lines
5.7 KiB
TypeScript
173 lines
5.7 KiB
TypeScript
// <reference lib="dom" />
|
|
import { templateNoop, HTML_ESCAPES, xss } from "./string.ts"
|
|
|
|
export { HTML_ESCAPES }
|
|
|
|
/**
|
|
* Parses HTML, kinda smart.
|
|
*
|
|
* As a function, it just parses the string as HTML. When used as a tagged template, expressions are XSS escaped using {@link xss}, except Nodes, which are added to the tree.
|
|
*
|
|
* ```js
|
|
* html("<p>Hello world!</p>")
|
|
* // DocumentFragment
|
|
* // └─ p
|
|
* // └─ "Hello world!"
|
|
* html`<strong>
|
|
* ${html`<em>Look ma, nested HTML!</em>`}
|
|
* ${`<img src="/bogus/path" onerror="alert('xss')">`}
|
|
* </strong>`
|
|
*
|
|
* // DocumentFragment
|
|
* // └─ strong
|
|
* // ├─ em
|
|
* // │ └─ "Look ma, nested HTML!"
|
|
* // └─ "<img src="/bogus/path" onerror="alert('xss')">"
|
|
* ```
|
|
* @author MindfulMinun
|
|
* @since 2020-06-23
|
|
*/
|
|
export function html(content: string): DocumentFragment
|
|
export function html(strings: TemplateStringsArray, ...exprs: unknown[]): DocumentFragment
|
|
export function html(strings: TemplateStringsArray | string, ...exprs: unknown[]): DocumentFragment {
|
|
const temp = document.createElement("template")
|
|
let out = ""
|
|
|
|
if (typeof strings === "string") {
|
|
temp.innerHTML = strings
|
|
return temp.content.cloneNode(true) as DocumentFragment
|
|
}
|
|
|
|
// Join the strings together
|
|
for (let i = 0; i < strings.length; i++) {
|
|
// If it's another Node, put a template in its place so we can replace it later
|
|
if (exprs[i] instanceof Node) {
|
|
out += strings[i] + `<template data-replaceindex="${i}"></template>`
|
|
continue
|
|
}
|
|
// Otherwise, just join the expressions together, escaping dynamic content
|
|
out += strings[i] + (typeof exprs[i] != 'undefined' ? xss("" + exprs[i]) : "")
|
|
}
|
|
temp.innerHTML = out
|
|
const clone = temp.content.cloneNode(true) as DocumentFragment
|
|
|
|
// Replace all templates with the real nodes
|
|
clone.querySelectorAll("template[data-replaceindex]").forEach(node => {
|
|
if (!(node instanceof HTMLElement)) return
|
|
if (!node.parentNode) return
|
|
const index = parseInt(node.getAttribute("data-replaceindex") || "", 10)
|
|
node.parentNode.replaceChild(exprs[index] as Node, node);
|
|
})
|
|
|
|
return clone
|
|
}
|
|
|
|
/**
|
|
* Creates a text node for use in the DOM.
|
|
* @remarks
|
|
* The text content is escaped using
|
|
* the browser's built-in HTML escaping via `document.createTextNode`.
|
|
* @author MindfulMinun
|
|
* @since 2022-06-04
|
|
*/
|
|
export function textNode(templ: string): Text
|
|
export function textNode(templ: TemplateStringsArray, ...values: unknown[]): Text
|
|
export function textNode(templ: string | TemplateStringsArray, ...values: unknown[]): Text {
|
|
const string = templateNoop(templ, ...values)
|
|
return document.createTextNode(string)
|
|
}
|
|
|
|
/**
|
|
* FLIP is a mnemonic device for effective JavaScript animations: First, Last, Invert, Play.
|
|
* This helper class makes it easy to perform FLIP animations.
|
|
*
|
|
* https://aerotwist.com/blog/flip-your-animations/
|
|
*
|
|
* @author MindfulMinun
|
|
* @since 2022-12-31
|
|
*/
|
|
class _FlipAnimator {
|
|
el: Element
|
|
#first: DOMRect | null = null
|
|
|
|
constructor(el: Element) {
|
|
this.el = el
|
|
this.#first = null
|
|
}
|
|
|
|
/**
|
|
* This method immediately performs a FLIP animation.
|
|
* The caller must pass in a callback that describes the transition between the first and last states.
|
|
*/
|
|
async flip(cb: (this: Element, el: Element, rect: DOMRect) => Promise<void> | void, options: KeyframeAnimationOptions = {}) {
|
|
// Get the current state of the element
|
|
this.recordFirstState()
|
|
|
|
// Call the callback. The callback should change the element's DOMRect.
|
|
await cb.call(this.el, this.el, this.#first!)
|
|
|
|
// Animate the element from the first state to the last state
|
|
return this.animateFromFirst(options)
|
|
}
|
|
|
|
/**
|
|
* Captures the initial DOMRect of the element.
|
|
* The caller may also pass in a DOMRect to use instead of querying the DOM.
|
|
*/
|
|
recordFirstState(rect: DOMRect = this.el.getBoundingClientRect()) {
|
|
this.#first = rect
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Using the captured first state, this method will animate the element
|
|
* from the first state to the last state.
|
|
* Then this method will clear the DOMRects.
|
|
*/
|
|
animateFromFirst(options: KeyframeAnimationOptions = {}) {
|
|
if (!this.#first) throw new Error('No first state recorded.')
|
|
const last = this.el.getBoundingClientRect()
|
|
|
|
const deltaX = this.#first.left - last.left
|
|
const deltaY = this.#first.top - last.top
|
|
const scaleX = this.#first.width / last.width
|
|
const scaleY = this.#first.height / last.height
|
|
|
|
this.#first = null
|
|
|
|
return this.el.animate([
|
|
{ transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` },
|
|
{ transform: 'none' }
|
|
], {
|
|
duration: 300,
|
|
easing: 'ease-in-out',
|
|
...options
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Walks the DOM recursively, calling the callback for each node.
|
|
* @deprecated
|
|
* @param root - The root of the DOM walk
|
|
* @param callback - If this callback returns true, stop tree traversal.
|
|
* @param filters - A bitmask of what nodes to show. Defaults to `NodeFilter.SHOW_ELEMENT`.
|
|
* @author MindfulMinun
|
|
* @since 2020-06-29
|
|
*/
|
|
export function domWalker(
|
|
root: Node,
|
|
callback: (node: Node, root: Node, walker: TreeWalker) => boolean,
|
|
filters: number = NodeFilter.SHOW_ELEMENT
|
|
) {
|
|
const walker = document.createTreeWalker(root, filters);
|
|
let guard = false;
|
|
let current: Node | null = walker.currentNode
|
|
while (true) {
|
|
if (!current) break
|
|
if (guard) break
|
|
guard = callback.call(null, current, root, walker);
|
|
current = walker.nextNode()
|
|
}
|
|
}
|