@v4fire/client
Version:
V4Fire client core library
473 lines (393 loc) • 11.7 kB
text/typescript
/* eslint-disable @typescript-eslint/no-invalid-this */
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/
import symbolGenerator from 'core/symbol';
import {
SHADOW_BROKEN,
SHADOW_PREVIEW,
SHADOW_MAIN,
ID
} from 'core/dom/image/const';
import Factory from 'core/dom/image/factory';
import Lifecycle from 'core/dom/image/lifecycle';
import type { ImageOptions, ImagePlaceholderOptions, InitValue, DefaultImagePlaceholderOptions, ShadowElState, OptionsResolver, ImageNode, ImageStage, ImagePlaceholderType } from 'core/dom/image/interface';
export const
$$ = symbolGenerator();
export default class ImageLoader {
/** @see [[Factory]] */
readonly factory: Factory = new Factory();
/** @see [[Lifecycle]] */
readonly lifecycle: Lifecycle = new Lifecycle(this);
/**
* Normalizes the specified directive value
* @param value
*/
static normalizeOptions<T extends ImageOptions | ImagePlaceholderOptions = ImageOptions>(value: InitValue): T {
if (Object.isString(value)) {
return <T>{
src: value
};
}
return <T>value;
}
/**
* Default `broken` image options
*/
protected defaultBrokenImageOptions?: DefaultImagePlaceholderOptions;
/**
* Default `preview` image options
*/
protected defaultPreviewImageOptions?: DefaultImagePlaceholderOptions;
/**
* Default `preview` fake element
*/
protected defaultPreviewShadowState?: ShadowElState;
/**
* Default `broken` fake element
*/
protected defaultBrokenShadowState?: ShadowElState;
/**
* Default `optionsResolver` function
*/
defaultOptionsResolver?: OptionsResolver = (opts) => opts;
/**
* Sets the default `broken` image
* @param opts
*/
setDefaultBrokenImage(opts: string | ImagePlaceholderOptions): void {
this.defaultBrokenImageOptions = ImageLoader.normalizeOptions<ImagePlaceholderOptions>(opts);
this.defaultBrokenImageOptions.isDefault = true;
this.cacheDefaultImage(this.defaultBrokenImageOptions, 'broken');
}
/**
* Sets the default `preview` image
* @param opts
*/
setDefaultPreviewImage(opts: string | ImagePlaceholderOptions): void {
this.defaultPreviewImageOptions = ImageLoader.normalizeOptions<ImagePlaceholderOptions>(opts);
this.defaultPreviewImageOptions.isDefault = true;
this.cacheDefaultImage(this.defaultPreviewImageOptions, 'preview');
}
/**
* Initializes rendering of an image to the specified element
*
* @param el
* @param value
*/
init(el: HTMLElement, value: InitValue): void {
const
normalized = ImageLoader.normalizeOptions(value);
const mainOpts: ImageOptions = this.resolveOptions({
preview: 'preview' in normalized ? normalized.preview : this.defaultPreviewImageOptions,
broken: 'broken' in normalized ? normalized.broken : this.defaultBrokenImageOptions,
optionsResolver: 'optionsResolver' in normalized ? normalized.optionsResolver : this.defaultOptionsResolver,
...normalized
});
const
typedEl = <ImageNode>el;
if (mainOpts.preview != null) {
const
previewPlaceholderOptions = ImageLoader.normalizeOptions<ImagePlaceholderOptions>(mainOpts.preview),
isDefault = Object.isTruly((<DefaultImagePlaceholderOptions>previewPlaceholderOptions).isDefault);
// If the provided `preview` image matches with the default – reuse the default `preview` shadow state
typedEl[SHADOW_PREVIEW] = isDefault ?
this.mergeDefaultShadowState(mainOpts, 'preview') :
this.factory.shadowState(el, previewPlaceholderOptions, mainOpts, 'preview');
}
if (mainOpts.broken != null) {
const
brokenPlaceholderOptions = ImageLoader.normalizeOptions<ImagePlaceholderOptions>(mainOpts.broken),
isDefault = Object.isTruly((<DefaultImagePlaceholderOptions>brokenPlaceholderOptions).isDefault);
// If the provided `broken` image matches with the default – reuse the default `broken` shadow state
typedEl[SHADOW_BROKEN] = isDefault ?
this.mergeDefaultShadowState(mainOpts, 'broken') :
this.factory.shadowState(el, brokenPlaceholderOptions, mainOpts, 'broken');
}
typedEl[SHADOW_MAIN] = this.factory.shadowState(el, mainOpts, mainOpts, 'main');
typedEl[ID] = String(Math.random());
this.setAltAttr(el, mainOpts.alt);
if (!this.isImg(el)) {
this.setInitialBackgroundSizeAttrs(el, typedEl[SHADOW_MAIN], typedEl[SHADOW_PREVIEW]);
}
this.lifecycle.init(typedEl);
}
/**
* Updates the state of the specified element
*
* @param el
* @param [value]
* @param [oldValue]
*/
update(el: HTMLElement, value?: InitValue, oldValue?: InitValue): void {
value = value != null ? ImageLoader.normalizeOptions(value) : undefined;
oldValue = oldValue != null ? ImageLoader.normalizeOptions(oldValue) : undefined;
if (value?.handleUpdate == null) {
return;
}
if (this.isEqual(value, oldValue)) {
return;
}
this.clearShadowState(<ImageNode>el);
this.init(el, value);
}
/**
* Returns true if the specified element is an instance of `HTMLImageElement`
* @param el
*/
isImg(el: HTMLElement): el is HTMLImageElement {
return el instanceof HTMLImageElement;
}
/**
* Clears the specified element state
* @param el
*/
clearElement(el: HTMLElement): void {
if (this.isImg(el)) {
el.src = '';
} else {
this.clearBackgroundStyles(el);
}
this.clearShadowState(el);
return this.setAltAttr(el, '');
}
/**
* Renders an image to the specified element
*
* @param el
* @param state
*/
render(el: ImageNode, state: ShadowElState): void {
this.setLifecycleClass(el, state);
if (this.isImg(el)) {
this.setImgProps(el, state);
} else {
this.setBackgroundStyles(el, state);
}
}
/**
* Returns a shadow state of the element by the specified type
*
* @param el
* @param type
*/
getShadowStateByType(el: ImageNode, type: ImageStage): CanUndef<ShadowElState> {
if (type === 'main') {
return el[SHADOW_MAIN];
}
return el[type === 'preview' ? SHADOW_PREVIEW : SHADOW_BROKEN];
}
/**
* Sets lifecycle class to the specified element
*
* @param el
* @param state
* @param [type] – if not specified, the value will be taken from `state`
*/
setLifecycleClass(el: ImageNode, state: ShadowElState, type?: ImageStage): void {
const
{mainOptions} = state,
ctx = state.mainOptions.ctx?.unsafe;
if (ctx == null) {
return;
}
if (mainOptions.stageClasses === true) {
if (ctx.block == null) {
return;
}
const classMap = {
initial: ctx.block.getFullElName('v-image', 'initial', 'true'),
preview: ctx.block.getFullElName('v-image', 'preview', 'true'),
main: ctx.block.getFullElName('v-image', 'main', 'true'),
broken: ctx.block.getFullElName('v-image', 'broken', 'true')
};
el.classList.remove(classMap.preview, classMap.main, classMap.broken, classMap.initial);
el.classList.add(classMap[type ?? state.stageType]);
}
}
/**
* Resolves the given operation options
* @param opts
*/
protected resolveOptions(opts: ImageOptions): ImageOptions {
if (opts.optionsResolver != null) {
return opts.optionsResolver(opts);
}
return opts;
}
/**
* Merges the default image state with the provided options
*
* @param mainImageOptions
* @param type
*/
protected mergeDefaultShadowState(
mainImageOptions: ImageOptions,
type: ImagePlaceholderType
): CanUndef<ShadowElState> {
const
defaultShadowState = type === 'preview' ? this.defaultPreviewShadowState : this.defaultBrokenShadowState;
if (defaultShadowState != null) {
return {
...defaultShadowState,
mainOptions: mainImageOptions
};
}
}
/**
* Creates a cache for the default image
*
* @param options
* @param type
*/
protected cacheDefaultImage(options: ImagePlaceholderOptions, type: ImagePlaceholderType): void {
const
dummy = document.createElement('div'),
state = this.factory.shadowState(dummy, options, options, type);
if (type === 'broken') {
this.defaultBrokenShadowState = state;
}
if (type === 'preview') {
this.defaultPreviewShadowState = state;
}
}
/**
* Clears a shadow state of the specified element
* @param el
*/
protected clearShadowState(el: HTMLElement | ImageNode): void {
if (el[SHADOW_MAIN] == null) {
return;
}
const async = (<ShadowElState>el[SHADOW_MAIN]).mainOptions.ctx?.unsafe.$async;
for (let i = 0, shadows = [SHADOW_PREVIEW, SHADOW_MAIN, SHADOW_BROKEN]; i < shadows.length; i++) {
const shadow = shadows[i];
if (el[shadow] != null) {
el[shadow]?.loadPromise != null && async?.clearPromise(el[shadow].loadPromise);
delete el[shadow];
}
}
}
/**
* Sets an image attributes to the specified el
*
* @param el
* @param state
*/
protected setImgProps(el: ImageNode, state: ShadowElState): void {
if (!this.isImg(el)) {
return;
}
el.src = state.imgNode.currentSrc;
}
/**
* Sets background CSS styles to the specified element
*
* @param el
* @param state
*/
protected setBackgroundStyles(el: ImageNode, state: ShadowElState): void {
const
{bgOptions} = state.selfOptions;
const
beforeImg = bgOptions?.beforeImg ?? [],
afterImg = bgOptions?.afterImg ?? [],
img = `url("${state.imgNode.currentSrc}")`;
const
backgroundImage = Array.concat([], beforeImg, img, afterImg).join(','),
backgroundPosition = bgOptions?.position ?? '',
backgroundRepeat = bgOptions?.repeat ?? '',
backgroundSize = bgOptions?.size ?? '',
paddingBottom = this.calculatePaddingByRatio(state, bgOptions?.ratio);
Object.assign(el.style, {
backgroundImage,
backgroundSize,
backgroundPosition,
backgroundRepeat,
paddingBottom
});
}
/**
* Clears background CSS styles of the specified element
* @param el
*/
protected clearBackgroundStyles(el: HTMLElement): void {
Object.assign(el.style, {
backgroundImage: '',
backgroundSize: '',
backgroundPosition: '',
backgroundRepeat: '',
paddingBottom: ''
});
}
/**
* Sets initially calculated padding to the specified element
*
* @param el
* @param mainState
* @param previewState
*/
protected setInitialBackgroundSizeAttrs(
el: HTMLElement,
mainState: ShadowElState,
previewState?: ShadowElState
): void {
const
ratio = previewState?.selfOptions.bgOptions?.ratio ?? mainState.selfOptions.bgOptions?.ratio;
if (ratio == null) {
return;
}
el.style.paddingBottom = this.calculatePaddingByRatio(mainState, ratio);
}
/**
* Calculates `padding-bottom` based on the specified ratio
*
* @param state
* @param [ratio]
*/
protected calculatePaddingByRatio(state: ShadowElState, ratio?: number): string {
if (ratio == null) {
const
{imgNode} = state,
{naturalHeight, naturalWidth} = imgNode;
if (naturalHeight > 0 || naturalWidth > 0) {
const calculated = naturalHeight === 0 ? 1 : naturalWidth / naturalHeight;
return `${(1 / calculated) * 100}`;
}
return '';
}
return `${(1 / ratio) * 100}%`;
}
/**
* Sets an `alt` attribute or `aria-label` for the specified element
*
* @param el
* @param alt
*/
protected setAltAttr(el: HTMLElement, alt: CanUndef<string>): void {
if (this.isImg(el)) {
el.alt = alt ?? '';
return;
}
if (alt == null || alt === '') {
el.removeAttribute('role');
el.removeAttribute('aria-label');
} else {
el.setAttribute('role', 'img');
el.setAttribute('aria-label', alt);
}
}
/**
* Returns true if the specified options are equal
*
* @param a
* @param b
*/
protected isEqual(a: CanUndef<ImageOptions>, b: CanUndef<ImageOptions>): boolean {
return Object.fastCompare(a, b);
}
}