• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

atinc / ngx-tethys / 68ef226c-f83e-44c1-b8ed-e420a83c5d84

28 May 2025 10:31AM UTC coverage: 10.352% (-80.0%) from 90.316%
68ef226c-f83e-44c1-b8ed-e420a83c5d84

Pull #3460

circleci

pubuzhixing8
chore: xxx
Pull Request #3460: refactor(icon): migrate signal input #TINFR-1476

132 of 6823 branches covered (1.93%)

Branch coverage included in aggregate %.

10 of 14 new or added lines in 1 file covered. (71.43%)

11648 existing lines in 344 files now uncovered.

2078 of 14525 relevant lines covered (14.31%)

6.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

2.87
/src/image/preview/image-preview.component.ts
1
import { CdkDrag } from '@angular/cdk/drag-drop';
2

3
import {
4
    ChangeDetectionStrategy,
5
    ChangeDetectorRef,
6
    Component,
7
    DestroyRef,
8
    ElementRef,
9
    EventEmitter,
10
    NgZone,
11
    OnInit,
12
    Output,
13
    Signal,
14
    ViewChild,
15
    ViewEncapsulation,
16
    inject
17
} from '@angular/core';
18
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
19
import { DomSanitizer } from '@angular/platform-browser';
20
import { ThyAction, ThyActions } from 'ngx-tethys/action';
1✔
21
import { ThyCopyDirective, ThyCopyEvent } from 'ngx-tethys/copy';
22
import { ThyDialog } from 'ngx-tethys/dialog';
23
import { ThyDivider } from 'ngx-tethys/divider';
24
import { ThyFullscreen } from 'ngx-tethys/fullscreen';
1✔
25
import { ThyIcon } from 'ngx-tethys/icon';
1✔
26
import { ThyLoading } from 'ngx-tethys/loading';
1✔
27
import { ThyNotifyService } from 'ngx-tethys/notify';
1✔
28
import { ThyTooltipDirective } from 'ngx-tethys/tooltip';
29
import { getClientSize, getFitContentPosition, getOffset, helpers, humanizeBytes, isNumber, isUndefinedOrNull } from 'ngx-tethys/util';
30
import { Observable, fromEvent } from 'rxjs';
31
import { InternalImageInfo, ThyImageInfo, ThyImagePreviewMode, ThyImagePreviewOperation, ThyImagePreviewOptions } from '../image.class';
32
import { fetchImageBlob } from '../utils';
33
import { injectLocale, ThyImageLocale } from 'ngx-tethys/i18n';
1✔
34

UNCOV
35
const initialPosition = {
×
UNCOV
36
    x: 0,
×
UNCOV
37
    y: 0
×
UNCOV
38
};
×
UNCOV
39
const IMAGE_MAX_ZOOM = 3;
×
UNCOV
40
const IMAGE_MIN_ZOOM = 0.1;
×
UNCOV
41
const HORIZONTAL_SPACE = 100 * 2; // left: 100px; right: 100px
×
UNCOV
42
const VERTICAL_SPACE = 96 + 106; // top: 96px; bottom: 106px
×
UNCOV
43

×
UNCOV
44
/**
×
UNCOV
45
 * 图片预览组件
×
UNCOV
46
 * @name thy-image-preview
×
UNCOV
47
 * @order 20
×
UNCOV
48
 */
×
UNCOV
49
@Component({
×
UNCOV
50
    selector: 'thy-image-preview',
×
UNCOV
51
    exportAs: 'thyImagePreview',
×
UNCOV
52
    templateUrl: './image-preview.component.html',
×
UNCOV
53
    changeDetection: ChangeDetectionStrategy.OnPush,
×
UNCOV
54
    encapsulation: ViewEncapsulation.None,
×
UNCOV
55
    host: {
×
UNCOV
56
        class: 'thy-image-preview-wrap',
×
UNCOV
57
        '[class.thy-image-preview-moving]': 'isDragging'
×
58
    },
59
    imports: [ThyTooltipDirective, ThyAction, CdkDrag, ThyLoading, ThyIcon, ThyActions, ThyDivider, ThyCopyDirective]
60
})
61
export class ThyImagePreview implements OnInit {
UNCOV
62
    thyDialog = inject(ThyDialog);
×
63
    thyFullscreen = inject(ThyFullscreen);
64
    private cdr = inject(ChangeDetectorRef);
65
    private ngZone = inject(NgZone);
66
    private notifyService = inject(ThyNotifyService);
67
    private host = inject<ElementRef<HTMLElement>>(ElementRef);
68
    private sanitizer = inject(DomSanitizer);
69
    public locale: Signal<ThyImageLocale> = injectLocale('image');
UNCOV
70

×
71
    @Output() downloadClicked: EventEmitter<ThyImageInfo> = new EventEmitter();
72

73
    private readonly destroyRef = inject(DestroyRef);
74

75
    images: InternalImageInfo[] = [];
76
    previewIndex: number = 0;
77
    previewConfig: ThyImagePreviewOptions;
UNCOV
78
    previewImageTransform = '';
×
79
    previewImageWrapperTransform = '';
80
    zoomDisabled = false;
81
    zoom: number = 1;
82
    position = { ...initialPosition };
83
    isDragging = false;
84
    isLoadingDone = false;
85
    isFullScreen = false;
UNCOV
86
    isInsideScreen = true;
×
87
    currentImageMode: ThyImagePreviewMode = 'original-scale';
88
    previewOperations: ThyImagePreviewOperation[];
89
    defaultPreviewOperations: ThyImagePreviewOperation[] = [
90
        {
91
            icon: 'zoom-out',
92
            name: this.locale().zoomOut,
93
            action: (image: ThyImageInfo) => {
UNCOV
94
                this.zoomOut();
×
95
            },
96
            type: 'zoom-out'
97
        },
98
        {
99
            icon: 'zoom-in',
100
            name: this.locale().zoomIn,
101
            action: (image: ThyImageInfo) => {
UNCOV
102
                this.zoomIn();
×
103
            },
104
            type: 'zoom-in'
105
        },
106
        {
107
            icon: 'one-to-one',
108
            name: this.locale().originalSize,
109
            action: (image: ThyImageInfo) => {
UNCOV
110
                this.setOriginalSize();
×
111
            },
112
            type: 'original-scale'
113
        },
114
        {
115
            icon: 'max-view',
116
            name: this.locale().fitToScreen,
117
            action: () => {
UNCOV
118
                this.setFitScreen();
×
119
            },
120
            type: 'fit-screen'
121
        },
122
        {
123
            icon: 'expand-arrows',
124
            name: this.locale().fullScreen,
125
            action: () => {
126
                this.fullScreen();
127
            },
128
            type: 'full-screen'
129
        },
UNCOV
130
        {
×
UNCOV
131
            icon: 'rotate-right',
×
UNCOV
132
            name: this.locale().spin,
×
133
            action: (image: ThyImageInfo) => {
UNCOV
134
                this.rotateRight();
×
135
            },
136
            type: 'rotate-right'
UNCOV
137
        },
×
UNCOV
138
        {
×
139
            icon: 'download',
×
140
            name: this.locale().download,
UNCOV
141
            action: (image: ThyImageInfo) => {
×
142
                this.download(image);
143
            },
UNCOV
144
            type: 'download'
×
UNCOV
145
        },
×
146
        {
147
            icon: 'preview',
×
148
            name: this.locale().viewOriginal,
149
            action: () => {
150
                this.viewOriginal();
151
            },
152
            type: 'view-original'
UNCOV
153
        },
×
UNCOV
154
        {
×
UNCOV
155
            icon: 'link-insert',
×
156
            name: this.locale().copyLink,
157
            type: 'copyLink'
UNCOV
158
        }
×
159
    ];
160

UNCOV
161
    private rotate: number;
×
162

163
    get previewImage(): InternalImageInfo {
UNCOV
164
        const image = this.images[this.previewIndex];
×
165
        if (image.size) {
166
            image.size = isNumber(image.size) ? humanizeBytes(image.size) : image.size;
167
        }
×
168
        return image;
169
    }
170

171
    get previewImageOriginSrc() {
UNCOV
172
        let imageSrc = this.previewImage.origin?.src || this.previewImage.src;
×
UNCOV
173
        if (imageSrc.startsWith('./')) {
×
UNCOV
174
            return window.location.host + '/' + imageSrc.split('./')[1];
×
UNCOV
175
        }
×
UNCOV
176
        return imageSrc;
×
UNCOV
177
    }
×
UNCOV
178

×
179
    get defaultZoom(): number {
180
        if (this.previewConfig?.zoom && this.previewConfig?.zoom > 0) {
UNCOV
181
            return this.previewConfig.zoom >= IMAGE_MAX_ZOOM
×
UNCOV
182
                ? IMAGE_MAX_ZOOM
×
UNCOV
183
                : this.previewConfig.zoom <= IMAGE_MIN_ZOOM
×
184
                  ? IMAGE_MIN_ZOOM
185
                  : this.previewConfig.zoom;
UNCOV
186
        }
×
UNCOV
187
    }
×
UNCOV
188

×
UNCOV
189
    @ViewChild('imgRef', { static: false }) imageRef!: ElementRef<HTMLImageElement>;
×
UNCOV
190
    @ViewChild('imagePreviewWrapper', { static: true }) imagePreviewWrapper!: ElementRef<HTMLElement>;
×
191

UNCOV
192
    ngOnInit(): void {
×
193
        this.initPreview();
194
        this.ngZone.runOutsideAngular(() => {
UNCOV
195
            fromEvent(this.host.nativeElement, 'click')
×
UNCOV
196
                .pipe(takeUntilDestroyed(this.destroyRef))
×
UNCOV
197
                .subscribe(event => {
×
UNCOV
198
                    if (
×
UNCOV
199
                        (event.target === event.currentTarget ||
×
UNCOV
200
                            (this.isInsideScreen && this.imagePreviewWrapper.nativeElement.contains(event.target as HTMLElement))) &&
×
UNCOV
201
                        !this.previewConfig?.disableClose
×
UNCOV
202
                    ) {
×
UNCOV
203
                        this.ngZone.run(() => !this.isFullScreen && this.thyDialog.close());
×
UNCOV
204
                    }
×
UNCOV
205
                });
×
UNCOV
206

×
207
            fromEvent(this.imagePreviewWrapper.nativeElement, 'mousedown')
208
                .pipe(takeUntilDestroyed(this.destroyRef))
209
                .subscribe(() => {
×
210
                    this.isDragging = !this.isInsideScreen && true;
UNCOV
211
                });
×
UNCOV
212
        });
×
UNCOV
213
    }
×
214

×
215
    setOriginalSize() {
UNCOV
216
        this.reset();
×
217
        this.currentImageMode = 'fit-screen';
218
        this.zoom = 1;
219
        this.updatePreviewImageTransform();
UNCOV
220
        this.calculateInsideScreen();
×
UNCOV
221
        this.isLoadingDone = true;
×
222
        this.cdr.detectChanges();
223
    }
×
224

×
225
    setFitScreen() {
226
        this.reset();
UNCOV
227
        this.isInsideScreen = true;
×
228
        this.updatePreviewImage();
×
229
    }
UNCOV
230

×
UNCOV
231
    useDefaultZoomUpdate(isUpdateImageWrapper: boolean) {
×
232
        this.zoom = this.defaultZoom;
233
        this.isLoadingDone = true;
UNCOV
234
        this.updatePreviewImageTransform();
×
235
        if (isUpdateImageWrapper) {
236
            this.updatePreviewImageWrapperTransform();
237
        }
238
        this.cdr.detectChanges();
UNCOV
239
    }
×
UNCOV
240

×
UNCOV
241
    useCalculateZoomUpdate(isUpdateImageWrapper?: boolean) {
×
UNCOV
242
        let img = new Image();
×
UNCOV
243
        img.src = this.previewImage.src;
×
UNCOV
244
        img.onload = () => {
×
245
            const { width: offsetWidth, height: offsetHeight } = getClientSize();
UNCOV
246
            const innerWidth = offsetWidth - HORIZONTAL_SPACE;
×
UNCOV
247
            const innerHeight = offsetHeight - VERTICAL_SPACE;
×
UNCOV
248
            const { naturalWidth, naturalHeight } = img;
×
249
            const xRatio = innerWidth / naturalWidth;
250
            const yRatio = innerHeight / naturalHeight;
UNCOV
251
            const zoom = Math.min(xRatio, yRatio);
×
UNCOV
252
            if (zoom > 1) {
×
UNCOV
253
                this.zoom = 1;
×
UNCOV
254
            } else {
×
UNCOV
255
                this.zoom = zoom;
×
UNCOV
256
            }
×
UNCOV
257
            this.isLoadingDone = true;
×
258
            this.updatePreviewImageTransform();
259
            if (isUpdateImageWrapper) {
×
260
                this.updatePreviewImageWrapperTransform();
×
261
            }
262
            this.cdr.detectChanges();
263
        };
264
    }
265

UNCOV
266
    updatePreviewImage() {
×
UNCOV
267
        this.resolvePreviewImage().subscribe(result => {
×
UNCOV
268
            if (!result) {
×
UNCOV
269
                // error
×
UNCOV
270
                this.isLoadingDone = true;
×
271
                return;
272
            }
UNCOV
273
            // image size
×
274
            if (!this.previewImage.size && this.previewImage.blob) {
275
                this.previewImage.size = humanizeBytes(this.previewImage.blob.size);
276
            }
277
            if (this.defaultZoom) {
UNCOV
278
                this.useDefaultZoomUpdate(true);
×
279
            } else {
UNCOV
280
                this.useCalculateZoomUpdate();
×
UNCOV
281
            }
×
282
        });
283
    }
UNCOV
284

×
UNCOV
285
    resolvePreviewImage() {
×
UNCOV
286
        return new Observable<Boolean>(subscriber => {
×
287
            if (this.previewImage.src.startsWith('blob:')) {
288
                this.previewImage.objectURL = this.sanitizer.bypassSecurityTrustUrl(this.previewImage.src);
UNCOV
289
                subscriber.next(true);
×
UNCOV
290
                subscriber.complete();
×
UNCOV
291
                return;
×
UNCOV
292
            }
×
UNCOV
293
            if (this.previewImage.objectURL || !this.previewConfig.resolveSize) {
×
UNCOV
294
                subscriber.next(true);
×
295
                subscriber.complete();
296
            } else {
297
                fetchImageBlob(this.previewImage.src).subscribe(
UNCOV
298
                    blob => {
×
UNCOV
299
                        const urlCreator = window.URL || window.webkitURL;
×
UNCOV
300
                        const objectURL = urlCreator.createObjectURL(blob);
×
UNCOV
301
                        this.previewImage.objectURL = this.sanitizer.bypassSecurityTrustUrl(objectURL);
×
UNCOV
302
                        this.previewImage.blob = blob;
×
303
                        subscriber.next(true);
304
                        subscriber.complete();
305
                    },
UNCOV
306
                    error => {
×
UNCOV
307
                        subscriber.next(false);
×
UNCOV
308
                        subscriber.complete();
×
UNCOV
309
                    }
×
UNCOV
310
                );
×
311
            }
312
        });
313
    }
UNCOV
314

×
UNCOV
315
    initPreview() {
×
UNCOV
316
        if (Array.isArray(this.previewConfig?.operations) && this.previewConfig?.operations.length) {
×
UNCOV
317
            const defaultOperationsMap = helpers.keyBy(this.defaultPreviewOperations, 'type');
×
318
            this.previewOperations = this.previewConfig?.operations.map(operation => {
×
319
                if (helpers.isString(operation) && defaultOperationsMap[operation]) {
320
                    return defaultOperationsMap[operation];
UNCOV
321
                } else {
×
322
                    return operation as ThyImagePreviewOperation;
323
                }
324
            });
UNCOV
325
        } else {
×
326
            this.previewOperations = this.defaultPreviewOperations;
327
        }
UNCOV
328
        this.rotate = this.previewConfig?.rotate ?? 0;
×
UNCOV
329
        this.updatePreviewImage();
×
330
    }
331

UNCOV
332
    download(image: ThyImageInfo) {
×
UNCOV
333
        this.downloadClicked.emit(image);
×
UNCOV
334
        const src = image.origin?.src || image.src;
×
335
        fetchImageBlob(src)
336
            .pipe(takeUntilDestroyed(this.destroyRef))
UNCOV
337
            .subscribe(blob => {
×
338
                const urlCreator = window.URL || window.webkitURL;
×
339
                const objectURL = urlCreator.createObjectURL(blob);
×
340
                let a = document.createElement('a');
341
                a.download = image.name || 'default.png';
342
                a.href = objectURL;
343
                a.click();
×
344
            });
×
345
    }
346

347
    zoomIn(): void {
×
348
        if (this.zoom < IMAGE_MAX_ZOOM) {
349
            this.zoom = Math.min(this.zoom + 0.1, IMAGE_MAX_ZOOM);
350
            this.calculateInsideScreen();
UNCOV
351
            this.updatePreviewImageTransform();
×
UNCOV
352
            this.position = { ...initialPosition };
×
UNCOV
353
        }
×
UNCOV
354
    }
×
UNCOV
355

×
356
    zoomOut(): void {
357
        if (this.zoom > IMAGE_MIN_ZOOM) {
358
            this.zoom = Math.max(this.zoom - 0.1, IMAGE_MIN_ZOOM);
UNCOV
359
            this.calculateInsideScreen();
×
UNCOV
360
            this.updatePreviewImageTransform();
×
UNCOV
361
            this.position = { ...initialPosition };
×
UNCOV
362
        }
×
UNCOV
363
    }
×
364

365
    calculateInsideScreen() {
366
        const width = this.imageRef.nativeElement.offsetWidth * this.zoom;
367
        const height = this.imageRef.nativeElement.offsetHeight * this.zoom;
×
368
        const { width: clientWidth, height: clientHeight } = getClientSize();
×
369
        if (width >= clientWidth || height >= clientHeight) {
×
370
            this.isInsideScreen = false;
×
371
        } else {
×
372
            this.isInsideScreen = true;
×
373
        }
×
374
    }
×
375

×
376
    viewOriginal() {
377
        window.open(this.previewImage?.origin?.src || this.previewImage.src, '_blank');
378
    }
379

380
    rotateRight(): void {
381
        this.rotate += 90;
×
382
        this.updatePreviewImageTransform();
×
383
    }
×
384

385
    fullScreen(): void {
386
        const targetElement = this.host.nativeElement.querySelector('.thy-image-preview');
UNCOV
387
        this.isFullScreen = true;
×
UNCOV
388
        const fullscreenRef = this.thyFullscreen.launch({
×
UNCOV
389
            target: targetElement
×
UNCOV
390
        });
×
391
        fullscreenRef.afterExited().subscribe(() => {
392
            this.isFullScreen = false;
UNCOV
393
            this.cdr.markForCheck();
×
394
        });
395
    }
UNCOV
396

×
397
    copyLink(event: ThyCopyEvent) {
398
        if (event.isSuccess) {
1✔
399
            this.notifyService.success(this.locale().copySuccess);
400
        } else {
401
            this.notifyService.error(this.locale().copyError);
402
        }
403
    }
404

1✔
405
    prev() {
406
        if (this.previewIndex > 0) {
407
            this.previewIndex--;
408
            this.isLoadingDone = false;
409
            this.reset();
410
            this.updatePreviewImage();
411
        }
412
    }
413

414
    next() {
415
        if (this.previewIndex < this.images.length - 1) {
416
            this.previewIndex++;
417
            this.isLoadingDone = false;
418
            this.reset();
419
            this.updatePreviewImage();
420
        }
421
    }
422

423
    dragReleased() {
424
        this.isDragging = false;
425
        const width = this.imageRef.nativeElement.offsetWidth * this.zoom;
426
        const height = this.imageRef.nativeElement.offsetHeight * this.zoom;
427
        const { left, top } = getOffset(this.imageRef.nativeElement, window);
428
        const { width: clientWidth, height: clientHeight } = getClientSize();
429
        const isRotate = this.rotate % 180 !== 0;
430
        const fitContentParams = {
431
            width: isRotate ? height : width,
432
            height: isRotate ? width : height,
433
            left,
434
            top,
435
            clientWidth,
436
            clientHeight
437
        };
438
        const fitContentPos = getFitContentPosition(fitContentParams);
439
        if (!isUndefinedOrNull(fitContentPos.x) || !isUndefinedOrNull(fitContentPos.y)) {
440
            this.position = { ...this.position, ...fitContentPos };
441
        }
442
    }
443

444
    private reset(): void {
445
        this.currentImageMode = 'original-scale';
446
        this.rotate = this.previewConfig?.rotate ?? 0;
447
        this.position = { ...initialPosition };
448
        this.cdr.detectChanges();
449
    }
450

451
    private updatePreviewImageTransform(): void {
452
        this.previewImageTransform = `scale3d(${this.zoom}, ${this.zoom}, 1) rotate(${this.rotate}deg)`;
453
    }
454

455
    private updatePreviewImageWrapperTransform(): void {
456
        this.previewImageWrapperTransform = `translate3d(${this.position.x}px, ${this.position.y}px, 0)`;
457
    }
458
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc