start work on better masonry layout

This commit is contained in:
rubikscraft 2022-05-09 17:02:59 +02:00
parent 9a5859c30b
commit 9e5d6c6f89
No known key found for this signature in database
GPG key ID: 3570A2BB18A63D9F
19 changed files with 368 additions and 39 deletions

View file

@ -24,6 +24,8 @@
"@angular/platform-browser": "^14.0.0-next.15",
"@angular/platform-browser-dynamic": "^14.0.0-next.15",
"@angular/router": "^14.0.0-next.15",
"@ng-web-apis/common": "^2.0.0",
"@ng-web-apis/resize-observer": "^1.0.3",
"@ngui/common": "^1.0.0",
"bootstrap": "^5.1.3",
"fuse.js": "^6.5.3",
@ -47,6 +49,7 @@
"@fontsource/material-icons-outlined": "^4.5.4",
"@fontsource/roboto": "^4.5.5",
"@types/node": "^17.0.30",
"@types/resize-observer-browser": "^0.1.7",
"@types/validator": "^13.7.2",
"typescript": "4.6.4"
}

View file

@ -0,0 +1,54 @@
import { Directive, ElementRef, Inject } from '@angular/core';
import {
boxExtractor,
ResizeObserverDirective,
ResizeObserverService,
RESIZE_OPTION_BOX,
} from '@ng-web-apis/resize-observer';
import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator';
import { map, Observable } from 'rxjs';
@Directive({
selector: '[masonry-item]',
providers: [
ResizeObserverService,
{
provide: RESIZE_OPTION_BOX,
deps: [ElementRef],
useFactory: boxExtractor,
},
],
})
export class MasonryItemDirective {
private lastEntry: ResizeObserverEntry | null = null;
private resizeObserver: Observable<ResizeObserverEntry>;
constructor(
private element: ElementRef<HTMLElement>,
@Inject(ResizeObserverService)
resize: Observable<ResizeObserverEntry[]>
) {
this.resizeObserver = resize.pipe(map((entries) => entries[0]));
this.subscribeResize();
}
@AutoUnsubscribe()
private subscribeResize() {
return this.resizeObserver.subscribe((value) => {
this.lastEntry = value;
});
}
public getElement() {
return this.element.nativeElement;
}
public getSize() {
return this.resizeObserver;
}
public getLastSize() {
return this.lastEntry;
}
}

View file

@ -0,0 +1 @@
<ng-content></ng-content>

View file

@ -0,0 +1,34 @@
import {
AfterViewInit,
Component,
ContentChildren,
ElementRef,
OnChanges,
OnInit,
QueryList,
SimpleChanges,
ViewChild,
} from '@angular/core';
import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator';
import { MasonryItemDirective } from './masonry-item.directive';
@Component({
selector: 'masonry',
templateUrl: './masonry.component.html',
styleUrls: ['./masonry.component.scss'],
})
export class MasonryComponent implements AfterViewInit {
@ContentChildren(MasonryItemDirective)
content: QueryList<MasonryItemDirective>;
ngAfterViewInit(): void {
this.subscribeContent();
}
@AutoUnsubscribe()
private subscribeContent() {
return this.content.changes.subscribe((items) => {
console.log('a', items.toArray());
});
}
}

View file

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MasonryComponent } from './masonry.component';
import { MasonryItemDirective } from './masonry-item.directive';
@NgModule({
declarations: [MasonryComponent, MasonryItemDirective],
imports: [CommonModule],
exports: [MasonryComponent, MasonryItemDirective],
})
export class MasonryModule {}

View file

@ -1,4 +1,6 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Input,
@ -25,6 +27,7 @@ enum PicsurImgState {
selector: 'picsur-img',
templateUrl: './picsur-img.component.html',
styleUrls: ['./picsur-img.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PicsurImgComponent implements OnChanges {
private readonly logger = new Logger('ZodImgComponent');
@ -39,7 +42,8 @@ export class PicsurImgComponent implements OnChanges {
constructor(
private qoiWorker: QoiWorkerService,
private apiService: ApiService
private apiService: ApiService,
private changeDetector: ChangeDetectorRef
) {}
ngOnChanges(changes: SimpleChanges): void {
@ -50,6 +54,7 @@ export class PicsurImgComponent implements OnChanges {
let url = this.imageURL ?? '';
if (!URLRegex.test(url)) {
this.state = PicsurImgState.Loading;
this.changeDetector.markForCheck();
return;
}
@ -61,6 +66,8 @@ export class PicsurImgComponent implements OnChanges {
}
})
.catch((e) => this.logger.error);
}
private async update(url: string): AsyncFailable<void> {
@ -80,6 +87,7 @@ export class PicsurImgComponent implements OnChanges {
} else {
this.state = PicsurImgState.Image;
}
this.changeDetector.markForCheck();
}
private async getMime(url: string): AsyncFailable<FullMime> {

View file

@ -1,9 +1,33 @@
<masonry>
<div *ngFor="let image of sourceImages" class="m-2" masonry-item>
<mat-card>
<mat-card-header>
<mat-card-title>Image by you</mat-card-title>
<mat-card-subtitle>
Uploaded {{ image.created | amTimeAgo }}
</mat-card-subtitle>
</mat-card-header>
<picsur-img
mat-card-image
[src]="getThumbnailUrl(image)"
alt="Image uploaded by you"
>
</picsur-img>
<mat-card-actions>
<button mat-stroked-button (click)="viewImage(image)">View</button>
<button mat-button color="warn">Delete</button>
</mat-card-actions>
</mat-card>
</div>
</masonry>
<div class="column-wrapper" *ngIf="images !== null">
<div
*ngFor="let dummy of [].constructor(columns); let i = index"
*ngFor="let column of images"
class="column"
#column
>
<div *ngFor="let image of images | masonry : columns : i" class="m-2">
<div *ngFor="let image of column" class="m-2" #imgdiv (waResizeObserver)="onResize($event, image, imgdiv)">
<mat-card>
<mat-card-header>
<mat-card-title>Image by you</mat-card-title>
@ -25,3 +49,30 @@
</div>
</div>
</div>
<!-- <div class="paginator">
<button class="mat-stroked-button" mat-icon-button>
<mat-icon>first_page</mat-icon>
</button>
<button class="mat-stroked-button" mat-icon-button>
<mat-icon>chevron_left</mat-icon>
</button>
<button class="mat-stroked-button" mat-icon-button>
<span>1</span>
</button>
<button class="mat-stroked-button" mat-icon-button>
<span>2</span>
</button>
<button class="mat-stroked-button" color="accent" mat-icon-button>
<span>3</span>
</button>
<button class="mat-stroked-button" mat-icon-button>
<span>4</span>
</button>
<button class="mat-stroked-button" mat-icon-button>
<mat-icon>chevron_right</mat-icon>
</button>
<button class="mat-stroked-button" mat-icon-button>
<mat-icon>last_page</mat-icon>
</button>
</div> -->

View file

@ -9,3 +9,13 @@
flex-basis: 0;
flex-direction: column;
}
.paginator {
display: flex;
flex-direction: row;
justify-content: center;
button {
margin-inline: .5em;
}
}

View file

@ -1,4 +1,11 @@
import { Component, OnInit } from '@angular/core';
import {
Component,
ElementRef,
OnInit,
QueryList,
ViewChildren,
ViewRef,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator';
import { SupportedMime } from 'picsur-shared/dist/dto/mimes.dto';
@ -19,8 +26,15 @@ import { UtilService } from 'src/app/util/util-module/util.service';
export class ImagesComponent implements OnInit {
private readonly logger: Logger = new Logger('ImagesComponent');
images: EImage[] | null = null;
columns = 3;
@ViewChildren('column')
columnsChild: QueryList<ElementRef<HTMLDivElement>>;
sourceImages: EImage[] | null = null;
private elementSizes: { [key: string]: number } = {};
private elements: { [key: string]: HTMLElement } = {};
private desiredColumns = 1;
images: EImage[][] | null = null;
constructor(
private readonly route: ActivatedRoute,
@ -42,22 +56,90 @@ export class ImagesComponent implements OnInit {
return this.logger.error(result.getReason());
}
this.images = result.images;
this.sourceImages = result.images;
this.sortMasonryRender();
}
@AutoUnsubscribe()
private subscribeMobile() {
return this.bootstrapService.screenSize().subscribe((size) => {
if (size <= BSScreenSize.sm) {
this.columns = 1;
this.desiredColumns = 1;
} else if (size <= BSScreenSize.lg) {
this.columns = 2;
this.desiredColumns = 2;
} else {
this.columns = 3;
this.desiredColumns = 3;
}
this.sortMasonryRender();
});
}
private sortMasonryRender() {
if (!this.sourceImages) {
this.images = null;
return;
}
this.elements = {};
this.elementSizes = {};
const columnSizes: number[] = [];
const columns: EImage[][] = [];
for (let i = 0; i < this.desiredColumns; i++) {
columnSizes.push(0);
columns.push([]);
}
for (let i = 0; i < this.sourceImages.length; i++) {
columns[i % this.desiredColumns].push(this.sourceImages[i]);
}
this.images = columns;
}
private sortMasonry() {
if (!this.sourceImages) {
this.images = null;
return;
}
const elementImages = this.sourceImages.map((img) => ({
element: this.elements[img.id],
height: this.elementSizes[img.id],
}));
if (
elementImages.find(
(test) => test.element === undefined || test.height === undefined
) !== undefined
) {
return;
}
for (let { element } of elementImages) {
element.parentElement?.removeChild(element);
}
const columnSizes: number[] = this.columnsChild.map((column) => 0);
for (let i = 0; i < elementImages.length; i++) {
const { element, height } = elementImages[i];
let minColumn = 0;
let minColumnSize = columnSizes[0];
for (let j = 0; j < columnSizes.length; j++) {
const distributed_j = (j + i) % columnSizes.length;
const columnSize = columnSizes[distributed_j];
if (columnSize <= minColumnSize) {
minColumn = distributed_j;
minColumnSize = columnSize;
}
}
this.columnsChild.toArray()[minColumn].nativeElement.appendChild(element);
columnSizes[minColumn] += height;
}
}
getThumbnailUrl(image: EImage) {
return (
this.imageService.GetImageURL(image.id, SupportedMime.QOI) + '?height=480'
@ -67,4 +149,17 @@ export class ImagesComponent implements OnInit {
viewImage(image: EImage) {
this.router.navigate(['/view', image.id]);
}
onResize(
[event]: ResizeObserverEntry[],
image: EImage,
element: HTMLElement
) {
this.elements[image.id] = element;
if (this.elementSizes[image.id] !== event.contentRect.height) {
this.elementSizes[image.id] = event.contentRect.height;
this.sortMasonry();
}
}
}

View file

@ -7,6 +7,10 @@ import { ImagesComponent } from './images.component';
import { ImagesRoutingModule } from './images.routing.module';
import { MasonryPipe } from './masonry.pipe';
import { MomentModule } from 'ngx-moment';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatIconModule } from '@angular/material/icon';
import { ResizeObserverModule } from '@ng-web-apis/resize-observer';
import { MasonryModule } from 'src/app/components/masonry/masonry.module';
@NgModule({
declarations: [ImagesComponent, MasonryPipe],
@ -15,6 +19,10 @@ import { MomentModule } from 'ngx-moment';
ImagesRoutingModule,
MatCardModule,
MatButtonModule,
MatPaginatorModule,
MatIconModule,
ResizeObserverModule,
MasonryModule,
PicsurImgModule,
MomentModule,
],

View file

@ -74,7 +74,7 @@
class="mat-elevation-z2"
[pageSizeOptions]="pageSizeOptions"
[pageSize]="startingPageSize"
[showFirstLastButtons]="bootstrapService.isDesktop() | async"
[showFirstLastButtons]="bootstrapService.isNotMobile() | async"
[hidePageSize]="bootstrapService.isMobile() | async"
aria-label="Select page of roles"
>

View file

@ -1,4 +1,4 @@
import { Injectable } from '@angular/core';
import { Inject, Injectable } from '@angular/core';
import { ApiResponseSchema } from 'picsur-shared/dist/dto/api/api.dto';
import { Mime2Ext } from 'picsur-shared/dist/dto/mimes.dto';
import { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types';
@ -10,6 +10,7 @@ import { z } from 'zod';
import { MultiPartRequest } from '../../models/dto/multi-part-request.dto';
import { Logger } from '../logger/logger.service';
import { KeyService } from '../storage/key.service';
import { WINDOW } from '@ng-web-apis/common';
/*
Proud of this, it works so smoooth
@ -27,7 +28,10 @@ export class ApiService {
return this.errorSubject.asObservable();
}
constructor(private keyService: KeyService) {}
constructor(
private readonly keyService: KeyService,
@Inject(WINDOW) readonly windowRef: Window
) {}
public async get<T extends z.AnyZodObject>(
type: ZodDtoStatic<T>,
@ -176,7 +180,7 @@ export class ApiService {
if (isJSON) headers['Content-Type'] = 'application/json';
options.headers = headers;
return await window.fetch(url, options);
return await this.windowRef.fetch(url, options);
} catch (e) {
this.errorSubject.next({
error: e,

View file

@ -1,10 +1,11 @@
import { Injectable } from '@angular/core';
import { Inject, Injectable } from '@angular/core';
import { LOCATION, WINDOW } from '@ng-web-apis/common';
import {
ImageDeleteRequest,
ImageDeleteResponse,
ImageListRequest,
ImageListResponse,
ImageUploadResponse
ImageUploadResponse,
} from 'picsur-shared/dist/dto/api/image-manage.dto';
import { ImageMetaResponse } from 'picsur-shared/dist/dto/api/image.dto';
import { ImageLinks } from 'picsur-shared/dist/dto/image-links.dto';
@ -19,7 +20,10 @@ import { ApiService } from './api.service';
providedIn: 'root',
})
export class ImageService {
constructor(private api: ApiService) {}
constructor(
private api: ApiService,
@Inject(LOCATION) readonly location: Location,
) {}
public async UploadImage(image: File): AsyncFailable<string> {
const result = await this.api.postForm(
@ -81,7 +85,7 @@ export class ImageService {
// Non api calls
public GetImageURL(image: string, mime: string | null): string {
const baseURL = window.location.protocol + '//' + window.location.host;
const baseURL = this.location.protocol + '//' + this.location.host;
const extension = mime !== null ? Mime2Ext(mime) ?? 'error' : null;
return `${baseURL}/i/${image}${extension !== null ? '.' + extension : ''}`;

View file

@ -1,9 +1,7 @@
import { Injectable } from '@angular/core';
import { Inject, Injectable } from '@angular/core';
import { HISTORY } from '@ng-web-apis/common';
import { InfoResponse } from 'picsur-shared/dist/dto/api/info.dto';
import {
AsyncFailable,
Fail, HasFailed
} from 'picsur-shared/dist/types';
import { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types';
import { SemVerRegex } from 'picsur-shared/dist/util/common-regex';
import { BehaviorSubject } from 'rxjs';
import { SnackBarType } from 'src/app/models/dto/snack-bar-type.dto';
@ -25,7 +23,11 @@ export class InfoService {
private infoSubject = new BehaviorSubject<ServerInfo>(new ServerInfo());
constructor(private api: ApiService, private utilService: UtilService) {
constructor(
private readonly api: ApiService,
private readonly utilService: UtilService,
@Inject(HISTORY) private readonly history: History
) {
this.checkCompatibility().catch(this.logger.error);
}
@ -104,7 +106,7 @@ export class InfoService {
} else {
this.checkCompatibility();
// Go to previous page
window.history.back();
this.history.back();
}
});
}

View file

@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { Inject, Injectable } from '@angular/core';
import { SESSION_STORAGE } from '@ng-web-apis/common';
import { AsyncFailable, Failable, HasFailed } from 'picsur-shared/dist/types';
interface dataWrapper<T> {
@ -12,15 +13,7 @@ interface dataWrapper<T> {
export class CacheService {
private readonly cacheExpiresMS = 1000 * 60 * 60;
private storage: Storage;
constructor() {
if (window.sessionStorage) {
this.storage = window.sessionStorage;
} else {
throw new Error('Session storage is not supported');
}
}
constructor(@Inject(SESSION_STORAGE) private readonly storage: Storage) {}
public set<T>(key: string, value: T): void {
const data: dataWrapper<T> = {

View file

@ -48,6 +48,10 @@ router-outlet ~ * {
width: initial !important;
}
button.mat-icon-button {
line-height: unset;
}
.row {
width: 100%;
}

View file

@ -72,6 +72,7 @@
},
"settings": {
"vsicons.presets.angular": true,
"skipRefreshExplorerOnWindowFocus": true
"skipRefreshExplorerOnWindowFocus": true,
"angular.log": "verbose"
}
}

View file

@ -1373,7 +1373,8 @@
secure-json-parse "^2.4.0"
stream-wormhole "^1.1.0"
"@fastify/static@^5.0.0", fastify-static@^4.7.0, "fastify-static@npm:@fastify/static":
"@fastify/static@^5.0.0", "fastify-static@npm:@fastify/static":
name fastify-static
version "5.0.2"
resolved "https://registry.yarnpkg.com/@fastify/static/-/static-5.0.2.tgz#46cee887393b422f4b10a46a14e970a64dd086d4"
integrity sha512-HvyXZ5a7hUHoSBRq9jKUuKIUCkHMkCDcmiAeEmixXlGOx8pEWx3NYOIaiivcjWa6/NLvfdUT+t/jzfVQ2PA7Gw==
@ -1607,6 +1608,20 @@
dependencies:
uuid "8.3.2"
"@ng-web-apis/common@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@ng-web-apis/common/-/common-2.0.0.tgz#6a90f2eb575a595e6fee36f7f57ec38ca901aa17"
integrity sha512-2Vnp4WTEqKZArhbKLgD1JIKjsDa3hWCa67OWaRWRH5sgX5xneVVaIAvC8gVpiCfl2p1Roen2kxfyYngx7G64SQ==
dependencies:
tslib "^2.2.0"
"@ng-web-apis/resize-observer@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@ng-web-apis/resize-observer/-/resize-observer-1.0.3.tgz#7f594f588d6706bfdeab514dec6249b5afc44534"
integrity sha512-ddmhxlca4knmN7BicgPTBScYXNTEKKF3z2WXPgmhOTxhVOyg/HHRDtq5nDljJt1eEaut2gcnhgDm4/6eGfedWw==
dependencies:
tslib "^1.9.0"
"@ngtools/webpack@14.0.0-next.12":
version "14.0.0-next.12"
resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-14.0.0-next.12.tgz#6920a8a54abd2a5bc41f671ed4e18d8b3be0502b"
@ -1942,6 +1957,11 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/resize-observer-browser@^0.1.7":
version "0.1.7"
resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz#294aaadf24ac6580b8fbd1fe3ab7b59fe85f9ef3"
integrity sha512-G9eN0Sn0ii9PWQ3Vl72jDPgeJwRWhv2Qk/nQkJuWmRmOB4HX3/BhD5SE1dZs/hzPZL/WKnvF0RHdTSG54QJFyg==
"@types/retry@0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
@ -4006,6 +4026,27 @@ fastify-plugin@^3.0.0:
resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-3.0.1.tgz#79e84c29f401020f38b524f59f2402103fd21ed2"
integrity sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA==
"fastify-static-deprecated@npm:fastify-static@4.6.1":
version "4.6.1"
resolved "https://registry.yarnpkg.com/fastify-static/-/fastify-static-4.6.1.tgz#687131da76f1d4391fb8b47f71ea2118cdc85803"
integrity sha512-vy7N28U4AMhuOim12ZZWHulEE6OQKtzZbHgiB8Zj4llUuUQXPka0WHAQI3njm1jTCx4W6fixUHfpITxweMtAIA==
dependencies:
content-disposition "^0.5.3"
encoding-negotiator "^2.0.1"
fastify-plugin "^3.0.0"
glob "^7.1.4"
p-limit "^3.1.0"
readable-stream "^3.4.0"
send "^0.17.1"
fastify-static@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/fastify-static/-/fastify-static-4.7.0.tgz#e802658d69c1dcddb380b9afc2456d467a3494be"
integrity sha512-zZhCfJv/hkmud2qhWqpU3K9XVAuy3+IV8Tp9BC5J5U+GyA2XwoB6h8lh9GqpEIqdXOw01WyWQllV7dOWVyAlXg==
dependencies:
fastify-static-deprecated "npm:fastify-static@4.6.1"
process-warning "^1.0.0"
fastify@3.28.0:
version "3.28.0"
resolved "https://registry.yarnpkg.com/fastify/-/fastify-3.28.0.tgz#14d939a2f176b82af1094de7abcb0b2d83bcff8f"
@ -5404,7 +5445,12 @@ minimatch@^3.0.4, minimatch@^3.1.2:
dependencies:
brace-expansion "^1.1.7"
minimist@1.2.6, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6, "minimist@npm:minimist-lite":
minimist@1.2.6, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
"minimist@npm:minimist-lite":
version "2.2.1"
resolved "https://registry.yarnpkg.com/minimist-lite/-/minimist-lite-2.2.1.tgz#abb71db2c9b454d7cf4496868c03e9802de9934d"
integrity sha512-RSrWIRWGYoM2TDe102s7aIyeSipXMIXKb1fSHYx1tAbxAV0z4g2xR6ra3oPzkTqFb0EIUz1H3A/qvYYeDd+/qQ==
@ -7866,7 +7912,7 @@ tslib@^1.8.1, tslib@^1.9.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0:
tslib@^2.0.0, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==