import { ExportNs } from '../../esl-utils/environment/export-ns' ; import { bind } from '../../esl-utils/decorators/bind' ; import { CSSClassUtils } from '../../esl-utils/dom/class' ; import { ESLBaseElement , attr , boolAttr } from '../../esl-base-element/core' ; import { EventUtils } from '../../esl-utils/dom/events' ; import { ESLMediaRuleList } from '../../esl-media-query/core' ; import { TraversingQuery } from '../../esl-traversing-query/core/esl-traversing-query' ; import { getIObserver } from './esl-image-iobserver' ; import { STRATEGIES } from './esl-image-strategies' ; import type { ESLImageRenderStrategy , ESLImageStrategyMap } from './esl-image-strategies' ; type LoadState = 'error' | 'loaded' | 'ready' ; const isLoadState = ( state ) : state is LoadState => [ 'error' , 'loaded' , 'ready' ] . includes ( state ) ; author Alexey Stsefanovich (ala'n), Yuliya Adamskaya
  @ ExportNs ( 'Image' ) export class ESLImage extends ESLBaseElement { public static is = 'esl-image' ; public static DEFAULT_CONTAINER_CLS = 'img-container-loaded' ; public static get STRATEGIES ( ) : ESLImageStrategyMap { return STRATEGIES ; } static get EMPTY_IMAGE ( ) : string { return 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' ; } static get observedAttributes ( ) : string [ ] { return [ 'alt' , 'role' , 'mode' , 'aria-label' , 'data-src' , 'data-src-base' , 'lazy-triggered' ] ; } @ attr ( ) public alt : string ; @ attr ( { defaultValue : 'save-ratio' } ) public mode : string ; @ attr ( { dataAttr : true } ) public src : string ; @ attr ( { dataAttr : true } ) public srcBase : string ; @ attr ( { defaultValue : 'none' } ) public lazy : 'auto' | 'manual' | 'none' | '' ; @ boolAttr ( ) public lazyTriggered : boolean ; @ boolAttr ( ) public refreshOnUpdate : boolean ; @ attr ( { defaultValue : 'inner-image' } ) public innerImageClass : string ; @ attr ( { defaultValue : null } ) public containerClass : string | null ; @ attr ( { defaultValue : '::parent' } ) public containerClassTarget : string ; @ attr ( { defaultValue : 'ready' } ) public containerClassState : string ; @ boolAttr ( { readonly : true } ) public readonly ready : boolean ; @ boolAttr ( { readonly : true } ) public readonly loaded : boolean ; @ boolAttr ( { readonly : true } ) public readonly error : boolean ; private _strategy : ESLImageRenderStrategy | null ; private _innerImg : HTMLImageElement | null ; private _srcRules : ESLMediaRuleList < string > ; private _currentSrc : string ; private _detachLazyTrigger : ( ) => void ; private _shadowImageElement : HTMLImageElement ; protected connectedCallback ( ) { super . connectedCallback ( ) ; this . alt = this . alt || this . getAttribute ( 'aria-label' ) || this . getAttribute ( 'data-alt' ) || '' ; this . updateA11y ( ) ; this . srcRules . addListener ( this . _onMediaMatchChange ) ; if ( this . lazyObservable ) { this . removeAttribute ( 'lazy-triggered' ) ; getIObserver ( ) . observe ( this ) ; this . _detachLazyTrigger = function ( ) : void { getIObserver ( ) . unobserve ( this ) ; this . _detachLazyTrigger = null ; } ; } this . refresh ( ) ; } protected disconnectedCallback ( ) { super . disconnectedCallback ( ) ; this . _detachLazyTrigger && this . _detachLazyTrigger ( ) ; if ( this . _srcRules ) { this . _srcRules . removeListener ( this . _onMediaMatchChange ) ; } } protected attributeChangedCallback ( attrName , oldVal , newVal ) { if ( ! this . connected || oldVal === newVal ) return ; switch ( attrName ) { case 'aria-label' : this . alt = newVal || '' ; break ; case 'alt' : case 'role' : this . updateA11y ( ) ; break ; case 'data-src' : this . srcRules = ESLMediaRuleList . parse ( newVal ) ; this . refresh ( ) ; break ; case 'data-src-base' : this . refresh ( ) ; break ; case 'mode' : this . changeMode ( oldVal , newVal ) ; break ; case 'lazy-triggered' : this . lazyTriggered && this . update ( ) ; break ; } } public get srcRules ( ) : ESLMediaRuleList < string > { if ( ! this . _srcRules ) { this . srcRules = ESLMediaRuleList . parse ( this . src ) ; } return this . _srcRules ; } public set srcRules ( rules ) { if ( this . _srcRules ) { this . _srcRules . removeListener ( this . _onMediaMatchChange ) ; } this . _srcRules = rules ; this . _srcRules . addListener ( this . _onMediaMatchChange ) ; } public get currentSrc ( ) : string { return this . _currentSrc ; } public get empty ( ) : boolean { return ! this . _currentSrc || ESLImage . isEmptyImage ( this . _currentSrc ) ; } public get canUpdate ( ) : boolean { return this . lazyTriggered || this . lazy === 'none' ; } public get lazyObservable ( ) : boolean { return this . lazy !== 'none' && this . lazy !== 'manual' ; } public get originalWidth ( ) : number { return this . _shadowImageElement ? this . _shadowImageElement . width : 0 ; } public get originalHeight ( ) : number { return this . _shadowImageElement ? this . _shadowImageElement . height : 0 ; } public triggerLoad ( ) { this . setAttribute ( 'lazy-triggered' , '' ) ; } protected changeMode ( oldVal , newVal ) { oldVal = oldVal || 'save-ratio' ; newVal = newVal || 'save-ratio' ; if ( oldVal === newVal ) return ; if ( ! STRATEGIES [ newVal ] ) { this . mode = oldVal ; throw new Error ( 'ESL Image: Unsupported mode: ' + newVal ) ; } this . clearImage ( ) ; if ( this . loaded ) this . syncImage ( ) ; } protected update ( force = false ) { if ( ! this . canUpdate ) return ; const src = this . getPath ( this . srcRules . activeValue ) ; if ( this . _currentSrc !== src || ! this . ready || force ) { this . _currentSrc = src ; this . _shadowImg . src = src ; if ( this . refreshOnUpdate || ! this . ready ) { this . syncImage ( ) ; } if ( this . _shadowImg . complete ) { this . _shadowImgError ? this . _onError ( ) : this . _onLoad ( ) ; } } this . _detachLazyTrigger && this . _detachLazyTrigger ( ) ; } protected updateA11y ( ) { const role = this . getAttribute ( 'role' ) || 'img' ; this . setAttribute ( 'role' , role ) ; this . innerImage && ( this . innerImage . alt = this . alt ) ; if ( role === 'img' ) this . setAttribute ( 'aria-label' , this . alt ) ; } protected getPath ( src ) { if ( ! src || src === '0' || src === 'none' ) { return ESLImage . EMPTY_IMAGE ; } return this . srcBase + src ; } public refresh ( ) { this . removeAttribute ( 'loaded' ) ; this . removeAttribute ( 'ready' ) ; this . updateContainerClasses ( ) ; this . clearImage ( ) ; this . update ( true ) ; } private syncImage ( ) { const strategy = STRATEGIES [ this . mode ] ; this . _strategy = strategy ; strategy && strategy . apply ( this , this . _shadowImg ) ; } private clearImage ( ) { this . _strategy && this . _strategy . clear ( this ) ; this . _strategy = null ; } public get innerImage ( ) : HTMLImageElement | null { return this . _innerImg ; } public attachInnerImage ( ) { if ( ! this . _innerImg ) { this . _innerImg = this . querySelector ( `img.${ this . innerImageClass }` ) || this . _shadowImg . cloneNode ( ) as HTMLImageElement ; this . _innerImg . className = this . innerImageClass ; } if ( ! this . _innerImg . parentNode ) { this . appendChild ( this . _innerImg ) ; } this . _innerImg . alt = this . alt ; return this . _innerImg ; } public removeInnerImage ( ) { if ( ! this . innerImage ) return ; this . removeChild ( this . innerImage ) ; setTimeout ( ( ) => { if ( this . _innerImg && ! this . _innerImg . parentNode ) { this . _innerImg = null ; } } ) ; } protected get _shadowImg ( ) : HTMLImageElement { if ( ! this . _shadowImageElement ) { this . _shadowImageElement = new Image ( ) ; this . _shadowImageElement . onload = this . _onLoad ; this . _shadowImageElement . onerror = this . _onError ; } return this . _shadowImageElement ; } protected get _shadowImgError ( ) : boolean { if ( ! this . _shadowImg . complete ) return false ; if ( this . _shadowImg . src . substr ( - 4 ) === '.svg' ) return false ; return this . _shadowImg . naturalHeight <= 0 ; } @ bind private _onLoad ( ) { this . syncImage ( ) ; this . _onReadyState ( true ) ; this . updateContainerClasses ( ) ; } @ bind private _onError ( ) { this . _onReadyState ( false ) ; this . updateContainerClasses ( ) ; } @ bind private _onMediaMatchChange ( ) { this . update ( ) ; } private _onReadyState ( successful ) { if ( this . ready ) return ; this . toggleAttribute ( 'loaded' , successful ) ; this . toggleAttribute ( 'error' , ! successful ) ; this . toggleAttribute ( 'ready' , true ) ; this . $$fire ( successful ? 'load' : 'error' ) ; this . $$fire ( 'ready' ) ; } public updateContainerClasses ( ) { if ( this . containerClass === null ) return ; const cls = this . containerClass || ( this . constructor as typeof ESLImage ) . DEFAULT_CONTAINER_CLS ; const state = isLoadState ( this . containerClassState ) && this [ this . containerClassState ] ; const targetEl = TraversingQuery . first ( this . containerClassTarget , this ) as HTMLElement ; targetEl && CSSClassUtils . toggle ( targetEl , cls , state ) ; } public $$fire ( eventName , eventInit = { bubbles : false } ) { return EventUtils . dispatch ( this , eventName , eventInit ) ; } public static isEmptyImage ( src ) { return src === ESLImage . EMPTY_IMAGE ; } } declare global { export interface ESLLibrary { Image : typeof ESLImage ; } export interface HTMLElementTagNameMap { 'esl-image' : ESLImage ; } }