Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modernize #25

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
env: {}

# FILE GENERATED WITH: npx ghat fregante/ghatemplates/node
# SOURCE: https://github.com/fregante/ghatemplates
# OPTIONS: {"exclude":["Test"]}

name: CI
on:
- pull_request
- push
jobs:
Lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: package.json
- name: install
run: npm ci || npm install
- name: XO
run: npx xo
Test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: package.json
- name: install
run: npm ci || npm install
- name: build
run: npm run build --if-present
- name: Vitest
run: npx vitest
Build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: package.json
- name: install
run: npm ci || npm install
- name: build
run: npm run build
3 changes: 0 additions & 3 deletions .travis.yml

This file was deleted.

123 changes: 123 additions & 0 deletions index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {test, assert} from 'vitest';
import {loadImage, loadImages} from './index.js';

declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention -- Must extend it
interface HTMLImageElement {
_loadMetadata(): void;
_load(): void;
}
}

// @ts-expect-error Just a fake class for testing
globalThis.Image = class Image {
#src = '';
#complete = false;
#naturalWidth = 0;
#naturalHeight = 0;

_loadMetadata() {
this.#naturalWidth = 16;
this.#naturalHeight = 16;
}

_load() {
this.#complete = true;
}

get src() {
return this.#src;
}

set src(value) {
this.#src = value;
}

get complete() {
return this.#complete;
}

set complete(value) {
this.#complete = value;
}

get naturalWidth() {
return this.#naturalWidth;
}

set naturalWidth(value) {
this.#naturalWidth = value;
}

get naturalHeight() {
return this.#naturalHeight;
}

set naturalHeight(value) {
this.#naturalHeight = value;
}

decode() {
return new Promise<void>(resolve => {
setInterval(() => {
if (this.#complete) {
resolve();
}
}, 5);
});
}
};

const source = 'https://avatars.githubusercontent.com/u/1402241?s=96&v=4';
const source2 = 'https://avatars.githubusercontent.com/u/1402241';

test('loadImage', async () => {
const info = loadImage(source);

assert.equal(info.image.src, source);
assert.isFalse(info.image.complete);

info.image._loadMetadata();
await info.metadata();

assert.isFalse(info.image.complete);
assert.equal(info.image.naturalWidth, 16);
assert.equal(info.image.naturalHeight, 16);

info.image._load();
assert.equal(await info.loaded(), info.image);

assert.isTrue(info.image.complete);
});

test('loadImages', async () => {
const images = loadImages([source, source2]);

console.log(images);

assert.equal(images.results[0].image.src, source);
assert.equal(images.results[1].image.src, source2);

for (const info of images.results) {
assert.isFalse(info.image.complete);
info.image._loadMetadata();
}

await images.metadata();

for (const info of images.results) {
assert.isFalse(info.image.complete);
assert.equal(info.image.naturalWidth, 16);
assert.equal(info.image.naturalHeight, 16);
}

for (const info of images.results) {
info.image._load();
}

await images.loaded();

for (const info of images.results) {
assert.isTrue(info.image.complete);
}
});
140 changes: 77 additions & 63 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,97 @@
type Input = string | HTMLImageElement;
type Output = HTMLImageElement;
type Attributes = Record<string, string>;

type ImagePromise = Promise<HTMLImageElement> & {
image: HTMLImageElement;
};
type Result<Image = HTMLImageElement> = Readonly<{
image: Image;
loaded: () => Promise<Image>;
metadata: () => Promise<Image>;
}> ;

function isArrayLike(input: any): input is ArrayLike<Input> {
return input.length !== undefined;
function setAttributes(image: HTMLImageElement, attributes: Attributes): void {
for (const [attribute, value] of Object.entries(attributes)) {
image.setAttribute(attribute, value);
}
}

function loadSingleImage(image: HTMLImageElement): ImagePromise {
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
if (image.naturalWidth) {
// If the browser can determine the naturalWidth the image is already loaded successfully
resolve(image);
} else if (image.complete) {
// If the image is complete but the naturalWidth is 0px it is probably broken
reject(image);
} else {
image.addEventListener('load', fulfill);
image.addEventListener('error', fulfill);
}

function fulfill(): void {
if (image.naturalWidth) {
resolve(image);
} else {
reject(image);
function lazyPromise<Image = HTMLImageElement>(function_: () => Promise<Image>): () => Promise<Image> {
let promise: Promise<Image> | undefined;
return () => {
promise ??= function_();

return promise;
};
}

function load(image: HTMLImageElement): Result {
const result = {
image,
loaded: lazyPromise(async () => {
await image.decode();
return image;
}),
metadata: lazyPromise(async () => new Promise<HTMLImageElement>((resolve, reject) => {
const check = () => {
if (image.naturalWidth) {
resolve(image);
clearInterval(interval);
return true;
}

if (image.complete) {
// If the image is complete but the naturalWidth is 0px it's probably broken
reject(new Error('Unable to load image metadata'));
clearInterval(interval);
return true;
}

return false;
};

if (check()) {
return;
}

image.removeEventListener('load', fulfill);
image.removeEventListener('error', fulfill);
}
});
const interval = setInterval(check, 100);

return Object.assign(promise, {image});
}
result.loaded().then(resolve, reject);
})),
};

function loadImages(input: Input, attributes?: Attributes): ImagePromise;
function loadImages(input: ArrayLike<Input>, attributes?: Attributes): Promise<Output[]>;
function loadImages(input: Input | ArrayLike<Input>, attributes: Attributes = {}): ImagePromise | Promise<Output[]> {
if (input instanceof HTMLImageElement) {
return loadSingleImage(input);
}
return Object.freeze(result);
}

export function loadImage(input: Input, attributes: Attributes = {}): Result {
if (typeof input === 'string') {
/* Create a <img> from a string */
const src = input;
// Create a <img> from a string
const image = new Image();
Object.keys(attributes).forEach(
name => image.setAttribute(name, attributes[name])
);
image.src = src;
return loadSingleImage(image);
// Set attributes before `src`
setAttributes(image, attributes);

image.src = input;
return load(image);
}

if (isArrayLike(input)) {
// Momentarily ignore errors
const reflect = (img: Input): Promise<HTMLImageElement | Error> => loadImages(img, attributes).catch((error: Error) => error);
const reflected = [].map.call(input, reflect) as Array<HTMLImageElement | Error>;
const tsFix = Promise.all(reflected).then((results: Array<HTMLImageElement | Error>) => {
const loaded = results.filter((x: any): x is HTMLImageElement => x.naturalWidth);
if (loaded.length === results.length) {
return loaded;
}
if (input instanceof HTMLImageElement) {
setAttributes(input, attributes);
return load(input);
}

return Promise.reject({
loaded,
errored: results.filter((x: any): x is Error => !x.naturalWidth)
});
});
throw new TypeError('Expected image or string, got ' + typeof input);
}

// Variables named `tsFix` are only here because TypeScript hates Promise-returning functions.
return tsFix;
export function loadImages(inputs: Input[], attributes: Attributes = {}): Readonly<{
results: Result[];
loaded: () => Promise<HTMLImageElement[]>;
metadata: () => Promise<HTMLImageElement[]>;
}> {
if (Array.isArray(inputs)) {
const results = inputs.map(input => loadImage(input, attributes));
return Object.freeze({
results,
loaded: lazyPromise(async () => Promise.all(results.map(result => result.loaded()))),
metadata: lazyPromise(async () => Promise.all(results.map(result => result.metadata()))),
});
}

const tsFix = Promise.reject(new TypeError('input is not an image, a URL string, or an array of them.'));
return tsFix;
throw new TypeError('Expected array; got ' + typeof inputs);
}

export default loadImages;
18 changes: 12 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
"vanilla"
],
"repository": "fregante/image-promise",
"funding": "https://github.com/sponsors/fregante",
"license": "MIT",
"author": "Federico Brigante <[email protected]> (bfred.it)",
"author": "Federico Brigante <[email protected]> (https://fregante.com)",
"type": "module",
"module": "index.js",
"exports": "./index.js",
"main": "./index.js",
"types": "./index.d.ts",
"files": [
"index.js",
Expand All @@ -43,9 +45,13 @@
}
},
"devDependencies": {
"@sindresorhus/tsconfig": "^0.7.0",
"type-fest": "^0.13.1",
"typescript": "^3.8.3",
"xo": "^0.30.0"
"@sindresorhus/tsconfig": "^5.0.0",
"type-fest": "^4.20.0",
"typescript": "^5.4.5",
"vitest": "^1.6.0",
"xo": "^0.58.0"
},
"engines": {
"node": ">=20"
}
}
Loading