2023-05-10 23:50:58 +08:00
import { debounce } from 'throttle-debounce' ;
2024-08-10 17:46:48 +08:00
import type { Promisable } from 'type-fest' ;
import type $ from 'jquery' ;
2023-05-10 23:50:58 +08:00
2024-11-26 09:24:56 +08:00
type ArrayLikeIterable < T > = ArrayLike < T > & Iterable < T > ; // for NodeListOf and Array
type ElementArg = Element | string | ArrayLikeIterable < Element > | ReturnType < typeof $ > ;
2024-11-10 16:26:42 +08:00
type ElementsCallback < T extends Element > = ( el : T ) = > Promisable < any > ;
2024-08-10 17:46:48 +08:00
type ElementsCallbackWithArgs = ( el : Element , . . . args : any [ ] ) = > Promisable < any > ;
function elementsCall ( el : ElementArg , func : ElementsCallbackWithArgs , . . . args : any [ ] ) {
2023-02-22 01:09:03 +08:00
if ( typeof el === 'string' || el instanceof String ) {
2024-08-10 17:46:48 +08:00
el = document . querySelectorAll ( el as string ) ;
2023-02-19 12:06:14 +08:00
}
if ( el instanceof Node ) {
func ( el , . . . args ) ;
} else if ( el . length !== undefined ) {
// this works for: NodeList, HTMLCollection, Array, jQuery
2024-11-08 14:04:24 +08:00
for ( const e of ( el as ArrayLikeIterable < Element > ) ) {
2023-02-19 12:06:14 +08:00
func ( e , . . . args ) ;
}
} else {
throw new Error ( 'invalid argument to be shown/hidden' ) ;
}
}
2023-02-22 01:09:03 +08:00
/ * *
2024-08-10 17:46:48 +08:00
* @param el Element
2023-02-22 01:09:03 +08:00
* @param force force = true to show or force = false to hide , undefined to toggle
* /
2024-08-10 17:46:48 +08:00
function toggleShown ( el : Element , force : boolean ) {
2023-02-19 12:06:14 +08:00
if ( force === true ) {
2024-03-25 02:23:38 +08:00
el . classList . remove ( 'tw-hidden' ) ;
2023-02-19 12:06:14 +08:00
} else if ( force === false ) {
2024-03-25 02:23:38 +08:00
el . classList . add ( 'tw-hidden' ) ;
2023-02-19 12:06:14 +08:00
} else if ( force === undefined ) {
2024-03-25 02:23:38 +08:00
el . classList . toggle ( 'tw-hidden' ) ;
2023-02-19 12:06:14 +08:00
} else {
throw new Error ( 'invalid force argument' ) ;
}
}
2024-08-10 17:46:48 +08:00
export function showElem ( el : ElementArg ) {
2023-02-19 12:06:14 +08:00
elementsCall ( el , toggleShown , true ) ;
}
2024-08-10 17:46:48 +08:00
export function hideElem ( el : ElementArg ) {
2023-02-19 12:06:14 +08:00
elementsCall ( el , toggleShown , false ) ;
}
2024-08-10 17:46:48 +08:00
export function toggleElem ( el : ElementArg , force? : boolean ) {
2023-02-19 12:06:14 +08:00
elementsCall ( el , toggleShown , force ) ;
}
2023-04-02 06:40:22 +08:00
2024-08-10 17:46:48 +08:00
export function isElemHidden ( el : ElementArg ) {
const res : boolean [ ] = [ ] ;
2024-03-25 02:23:38 +08:00
elementsCall ( el , ( e ) = > res . push ( e . classList . contains ( 'tw-hidden' ) ) ) ;
2023-05-10 23:50:58 +08:00
if ( res . length > 1 ) throw new Error ( ` isElemHidden doesn't work for multiple elements ` ) ;
return res [ 0 ] ;
}
2024-11-10 16:26:42 +08:00
function applyElemsCallback < T extends Element > ( elems : ArrayLikeIterable < T > , fn? : ElementsCallback < T > ) : ArrayLikeIterable < T > {
2024-03-31 23:39:50 +08:00
if ( fn ) {
for ( const el of elems ) {
fn ( el ) ;
}
}
return elems ;
}
2024-11-10 16:26:42 +08:00
export function queryElemSiblings < T extends Element > ( el : Element , selector = '*' , fn? : ElementsCallback < T > ) : ArrayLikeIterable < T > {
2024-11-08 14:04:24 +08:00
const elems = Array . from ( el . parentNode . children ) as T [ ] ;
return applyElemsCallback < T > ( elems . filter ( ( child : Element ) = > {
2024-08-10 17:46:48 +08:00
return child !== el && child . matches ( selector ) ;
} ) , fn ) ;
2024-03-31 23:39:50 +08:00
}
// it works like jQuery.children: only the direct children are selected
2024-11-10 16:26:42 +08:00
export function queryElemChildren < T extends Element > ( parent : Element | ParentNode , selector = '*' , fn? : ElementsCallback < T > ) : ArrayLikeIterable < T > {
2024-11-26 23:10:45 +08:00
if ( window . vitest ) {
// bypass the vitest bug: it doesn't support ":scope >"
const selected = Array . from < T > ( parent . children as any ) . filter ( ( child ) = > child . matches ( selector ) ) ;
return applyElemsCallback < T > ( selected , fn ) ;
}
2024-11-08 14:04:24 +08:00
return applyElemsCallback < T > ( parent . querySelectorAll ( ` :scope > ${ selector } ` ) , fn ) ;
2024-02-25 03:11:51 +08:00
}
2024-11-08 14:04:24 +08:00
// it works like parent.querySelectorAll: all descendants are selected
// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent
2024-11-10 16:26:42 +08:00
export function queryElems < T extends Element > ( parent : Element | ParentNode , selector : string , fn? : ElementsCallback < T > ) : ArrayLikeIterable < T > {
2024-11-08 14:04:24 +08:00
return applyElemsCallback < T > ( parent . querySelectorAll ( selector ) , fn ) ;
2024-04-19 00:45:50 +08:00
}
2024-08-10 17:46:48 +08:00
export function onDomReady ( cb : ( ) = > Promisable < void > ) {
2023-04-02 06:40:22 +08:00
if ( document . readyState === 'loading' ) {
document . addEventListener ( 'DOMContentLoaded' , cb ) ;
} else {
cb ( ) ;
}
}
2023-04-08 01:03:29 +08:00
2024-02-08 10:42:18 +08:00
// checks whether an element is owned by the current document, and whether it is a document fragment or element node
// if it is, it means it is a "normal" element managed by us, which can be modified safely.
2024-11-08 14:04:24 +08:00
export function isDocumentFragmentOrElementNode ( el : Node ) {
2024-02-08 10:42:18 +08:00
try {
return el . ownerDocument === document && el . nodeType === Node . ELEMENT_NODE || el . nodeType === Node . DOCUMENT_FRAGMENT_NODE ;
} catch {
// in case the el is not in the same origin, then the access to nodeType would fail
return false ;
}
}
2023-04-08 01:03:29 +08:00
// autosize a textarea to fit content. Based on
// https://github.com/github/textarea-autosize
// ---------------------------------------------------------------------
// Copyright (c) 2018 GitHub, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
// ---------------------------------------------------------------------
2024-08-10 17:46:48 +08:00
export function autosize ( textarea : HTMLTextAreaElement , { viewportMarginBottom = 0 } : { viewportMarginBottom? : number } = { } ) {
2023-04-08 01:03:29 +08:00
let isUserResized = false ;
// lastStyleHeight and initialStyleHeight are CSS values like '100px'
2024-08-10 17:46:48 +08:00
let lastMouseX : number ;
let lastMouseY : number ;
let lastStyleHeight : string ;
let initialStyleHeight : string ;
2023-04-08 01:03:29 +08:00
2024-08-10 17:46:48 +08:00
function onUserResize ( event : MouseEvent ) {
2023-04-08 01:03:29 +08:00
if ( isUserResized ) return ;
if ( lastMouseX !== event . clientX || lastMouseY !== event . clientY ) {
const newStyleHeight = textarea . style . height ;
if ( lastStyleHeight && lastStyleHeight !== newStyleHeight ) {
isUserResized = true ;
}
lastStyleHeight = newStyleHeight ;
}
lastMouseX = event . clientX ;
lastMouseY = event . clientY ;
}
function overflowOffset() {
let offsetTop = 0 ;
let el = textarea ;
while ( el !== document . body && el !== null ) {
offsetTop += el . offsetTop || 0 ;
2024-08-10 17:46:48 +08:00
el = el . offsetParent as HTMLTextAreaElement ;
2023-04-08 01:03:29 +08:00
}
const top = offsetTop - document . defaultView . scrollY ;
const bottom = document . documentElement . clientHeight - ( top + textarea . offsetHeight ) ;
return { top , bottom } ;
}
function resizeToFit() {
if ( isUserResized ) return ;
if ( textarea . offsetWidth <= 0 && textarea . offsetHeight <= 0 ) return ;
try {
const { top , bottom } = overflowOffset ( ) ;
const isOutOfViewport = top < 0 || bottom < 0 ;
const computedStyle = getComputedStyle ( textarea ) ;
const topBorderWidth = parseFloat ( computedStyle . borderTopWidth ) ;
const bottomBorderWidth = parseFloat ( computedStyle . borderBottomWidth ) ;
const isBorderBox = computedStyle . boxSizing === 'border-box' ;
const borderAddOn = isBorderBox ? topBorderWidth + bottomBorderWidth : 0 ;
2024-10-31 12:19:15 +08:00
const adjustedViewportMarginBottom = Math . min ( bottom , viewportMarginBottom ) ;
2023-04-08 01:03:29 +08:00
const curHeight = parseFloat ( computedStyle . height ) ;
const maxHeight = curHeight + bottom - adjustedViewportMarginBottom ;
textarea . style . height = 'auto' ;
let newHeight = textarea . scrollHeight + borderAddOn ;
if ( isOutOfViewport ) {
// it is already out of the viewport:
// * if the textarea is expanding: do not resize it
if ( newHeight > curHeight ) {
newHeight = curHeight ;
}
// * if the textarea is shrinking, shrink line by line (just use the
// scrollHeight). do not apply max-height limit, otherwise the page
// flickers and the textarea jumps
} else {
// * if it is in the viewport, apply the max-height limit
newHeight = Math . min ( maxHeight , newHeight ) ;
}
textarea . style . height = ` ${ newHeight } px ` ;
lastStyleHeight = textarea . style . height ;
} finally {
// ensure that the textarea is fully scrolled to the end, when the cursor
// is at the end during an input event
if ( textarea . selectionStart === textarea . selectionEnd &&
textarea . selectionStart === textarea . value . length ) {
textarea . scrollTop = textarea . scrollHeight ;
}
}
}
function onFormReset() {
isUserResized = false ;
if ( initialStyleHeight !== undefined ) {
textarea . style . height = initialStyleHeight ;
} else {
textarea . style . removeProperty ( 'height' ) ;
}
}
textarea . addEventListener ( 'mousemove' , onUserResize ) ;
textarea . addEventListener ( 'input' , resizeToFit ) ;
textarea . form ? . addEventListener ( 'reset' , onFormReset ) ;
initialStyleHeight = textarea . style . height ? ? undefined ;
if ( textarea . value ) resizeToFit ( ) ;
return {
resizeToFit ,
destroy() {
textarea . removeEventListener ( 'mousemove' , onUserResize ) ;
textarea . removeEventListener ( 'input' , resizeToFit ) ;
textarea . form ? . removeEventListener ( 'reset' , onFormReset ) ;
2024-03-22 22:06:53 +08:00
} ,
2023-04-08 01:03:29 +08:00
} ;
}
2023-05-10 23:50:58 +08:00
2024-08-10 17:46:48 +08:00
export function onInputDebounce ( fn : ( ) = > Promisable < any > ) {
2023-05-10 23:50:58 +08:00
return debounce ( 300 , fn ) ;
}
2023-10-11 20:34:21 +08:00
2024-08-10 17:46:48 +08:00
type LoadableElement = HTMLEmbedElement | HTMLIFrameElement | HTMLImageElement | HTMLScriptElement | HTMLTrackElement ;
2023-10-11 20:34:21 +08:00
// Set the `src` attribute on an element and returns a promise that resolves once the element
2024-08-10 17:46:48 +08:00
// has loaded or errored.
export function loadElem ( el : LoadableElement , src : string ) {
2023-10-11 20:34:21 +08:00
return new Promise ( ( resolve ) = > {
el . addEventListener ( 'load' , ( ) = > resolve ( true ) , { once : true } ) ;
el . addEventListener ( 'error' , ( ) = > resolve ( false ) , { once : true } ) ;
el . src = src ;
} ) ;
}
2023-12-15 07:26:36 +08:00
// some browsers like PaleMoon don't have "SubmitEvent" support, so polyfill it by a tricky method: use the last clicked button as submitter
// it can't use other transparent polyfill patches because PaleMoon also doesn't support "addEventListener(capture)"
const needSubmitEventPolyfill = typeof SubmitEvent === 'undefined' ;
export function submitEventSubmitter ( e ) {
2024-02-18 04:48:10 +08:00
e = e . originalEvent ? ? e ; // if the event is wrapped by jQuery, use "originalEvent", otherwise, use the event itself
2023-12-15 07:26:36 +08:00
return needSubmitEventPolyfill ? ( e . target . _submitter || null ) : e . submitter ;
}
function submitEventPolyfillListener ( e ) {
const form = e . target . closest ( 'form' ) ;
if ( ! form ) return ;
form . _submitter = e . target . closest ( 'button:not([type]), button[type="submit"], input[type="submit"]' ) ;
}
export function initSubmitEventPolyfill() {
if ( ! needSubmitEventPolyfill ) return ;
console . warn ( ` This browser doesn't have "SubmitEvent" support, use a tricky method to polyfill ` ) ;
document . body . addEventListener ( 'click' , submitEventPolyfillListener ) ;
document . body . addEventListener ( 'focus' , submitEventPolyfillListener ) ;
}
2024-02-20 18:37:37 +08:00
/ * *
* Check if an element is visible , equivalent to jQuery ' s ` :visible ` pseudo .
* Note : This function doesn ' t account for all possible visibility scenarios .
* /
2024-08-29 00:32:38 +08:00
export function isElemVisible ( element : HTMLElement ) : boolean {
2024-02-20 18:37:37 +08:00
if ( ! element ) return false ;
2024-11-07 04:21:53 +08:00
// checking element.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout
return Boolean ( ( element . offsetWidth || element . offsetHeight || element . getClientRects ( ) . length ) && element . style . display !== 'none' ) ;
2024-02-20 18:37:37 +08:00
}
2024-03-08 23:15:58 +08:00
// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
2024-08-10 17:46:48 +08:00
export function replaceTextareaSelection ( textarea : HTMLTextAreaElement , text : string ) {
2024-03-08 23:15:58 +08:00
const before = textarea . value . slice ( 0 , textarea . selectionStart ? ? undefined ) ;
const after = textarea . value . slice ( textarea . selectionEnd ? ? undefined ) ;
let success = true ;
textarea . contentEditable = 'true' ;
try {
2024-10-31 12:19:15 +08:00
success = document . execCommand ( 'insertText' , false , text ) ; // eslint-disable-line @typescript-eslint/no-deprecated
2024-03-08 23:15:58 +08:00
} catch {
success = false ;
}
textarea . contentEditable = 'false' ;
if ( success && ! textarea . value . slice ( 0 , textarea . selectionStart ? ? undefined ) . endsWith ( text ) ) {
success = false ;
}
if ( ! success ) {
textarea . value = ` ${ before } ${ text } ${ after } ` ;
textarea . dispatchEvent ( new CustomEvent ( 'change' , { bubbles : true , cancelable : true } ) ) ;
}
}
2024-06-07 21:42:31 +08:00
// Warning: Do not enter any unsanitized variables here
2024-11-21 22:09:16 +08:00
export function createElementFromHTML < T extends HTMLElement > ( htmlString : string ) : T {
htmlString = htmlString . trim ( ) ;
// some tags like "tr" are special, it must use a correct parent container to create
if ( htmlString . startsWith ( '<tr' ) ) {
const container = document . createElement ( 'table' ) ;
container . innerHTML = htmlString ;
return container . querySelector < T > ( 'tr' ) ;
}
2024-06-07 21:42:31 +08:00
const div = document . createElement ( 'div' ) ;
2024-11-21 22:09:16 +08:00
div . innerHTML = htmlString ;
return div . firstChild as T ;
2024-06-07 21:42:31 +08:00
}
2024-06-27 01:01:20 +08:00
2024-10-31 04:06:36 +08:00
export function createElementFromAttrs ( tagName : string , attrs : Record < string , any > , . . . children : ( Node | string ) [ ] ) : HTMLElement {
2024-06-27 01:01:20 +08:00
const el = document . createElement ( tagName ) ;
2024-10-31 04:06:36 +08:00
for ( const [ key , value ] of Object . entries ( attrs || { } ) ) {
2024-06-27 01:01:20 +08:00
if ( value === undefined || value === null ) continue ;
2024-08-02 03:06:03 +08:00
if ( typeof value === 'boolean' ) {
2024-06-27 01:01:20 +08:00
el . toggleAttribute ( key , value ) ;
} else {
el . setAttribute ( key , String ( value ) ) ;
}
2024-10-31 04:06:36 +08:00
}
for ( const child of children ) {
el . append ( child instanceof Node ? child : document.createTextNode ( child ) ) ;
2024-06-27 01:01:20 +08:00
}
return el ;
}
2024-06-27 21:58:38 +08:00
2024-08-10 17:46:48 +08:00
export function animateOnce ( el : Element , animationClassName : string ) : Promise < void > {
2024-06-27 21:58:38 +08:00
return new Promise ( ( resolve ) = > {
el . addEventListener ( 'animationend' , function onAnimationEnd() {
el . classList . remove ( animationClassName ) ;
el . removeEventListener ( 'animationend' , onAnimationEnd ) ;
resolve ( ) ;
} , { once : true } ) ;
el . classList . add ( animationClassName ) ;
} ) ;
}
2024-11-07 04:21:53 +08:00
export function querySingleVisibleElem < T extends HTMLElement > ( parent : Element , selector : string ) : T | null {
const elems = parent . querySelectorAll < HTMLElement > ( selector ) ;
const candidates = Array . from ( elems ) . filter ( isElemVisible ) ;
if ( candidates . length > 1 ) throw new Error ( ` Expected exactly one visible element matching selector " ${ selector } ", but found ${ candidates . length } ` ) ;
return candidates . length ? candidates [ 0 ] as T : null ;
}
2024-11-21 22:09:16 +08:00
export function addDelegatedEventListener < T extends HTMLElement > ( parent : Node , type : string , selector : string , listener : ( elem : T , e : Event ) = > void | Promise < any > , options? : boolean | AddEventListenerOptions ) {
parent . addEventListener ( type , ( e : Event ) = > {
const elem = ( e . target as HTMLElement ) . closest ( selector ) ;
if ( ! elem ) return ;
listener ( elem as T , e ) ;
} , options ) ;
}