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

atinc / ngx-tethys / edbc1d43-1648-411a-a6bc-f24c9aa3f654

27 Mar 2025 06:13AM UTC coverage: 90.236% (+0.06%) from 90.179%
edbc1d43-1648-411a-a6bc-f24c9aa3f654

push

circleci

web-flow
Merge pull request #3282 from atinc/v19.0.0-next

5598 of 6865 branches covered (81.54%)

Branch coverage included in aggregate %.

8 of 8 new or added lines in 7 files covered. (100.0%)

157 existing lines in 46 files now uncovered.

13357 of 14141 relevant lines covered (94.46%)

992.51 hits per line

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

81.29
/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
    ViewChild,
14
    ViewEncapsulation,
15
    inject
16
} from '@angular/core';
17
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
18
import { DomSanitizer } from '@angular/platform-browser';
19
import { ThyAction, ThyActions } from 'ngx-tethys/action';
1✔
20
import { ThyCopyDirective, ThyCopyEvent } from 'ngx-tethys/copy';
21
import { ThyDialog } from 'ngx-tethys/dialog';
22
import { ThyDivider } from 'ngx-tethys/divider';
23
import { ThyFullscreen } from 'ngx-tethys/fullscreen';
1✔
24
import { ThyIcon } from 'ngx-tethys/icon';
1✔
25
import { ThyLoading } from 'ngx-tethys/loading';
1✔
26
import { ThyNotifyService } from 'ngx-tethys/notify';
1✔
27
import { ThyTooltipDirective } from 'ngx-tethys/tooltip';
28
import { getClientSize, getFitContentPosition, getOffset, helpers, humanizeBytes, isNumber, isUndefinedOrNull } from 'ngx-tethys/util';
29
import { Observable, fromEvent } from 'rxjs';
30
import { InternalImageInfo, ThyImageInfo, ThyImagePreviewMode, ThyImagePreviewOperation, ThyImagePreviewOptions } from '../image.class';
31
import { fetchImageBlob } from '../utils';
32

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

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

68
    @Output() downloadClicked: EventEmitter<ThyImageInfo> = new EventEmitter();
2✔
69

70
    private readonly destroyRef = inject(DestroyRef);
71

72
    images: InternalImageInfo[] = [];
73
    previewIndex: number = 0;
74
    previewConfig: ThyImagePreviewOptions;
75
    previewImageTransform = '';
76
    previewImageWrapperTransform = '';
1✔
77
    zoomDisabled = false;
78
    zoom: number = 1;
79
    position = { ...initialPosition };
80
    isDragging = false;
81
    isLoadingDone = false;
82
    isFullScreen = false;
83
    isInsideScreen = true;
84
    currentImageMode: ThyImagePreviewMode = 'original-scale';
1✔
85
    previewOperations: ThyImagePreviewOperation[];
86
    defaultPreviewOperations: ThyImagePreviewOperation[] = [
87
        {
88
            icon: 'zoom-out',
89
            name: '缩小',
90
            action: (image: ThyImageInfo) => {
91
                this.zoomOut();
92
            },
1✔
93
            type: 'zoom-out'
94
        },
95
        {
96
            icon: 'zoom-in',
97
            name: '放大',
98
            action: (image: ThyImageInfo) => {
99
                this.zoomIn();
100
            },
1✔
101
            type: 'zoom-in'
102
        },
103
        {
104
            icon: 'one-to-one',
105
            name: '原始比例',
106
            action: (image: ThyImageInfo) => {
107
                this.setOriginalSize();
108
            },
2✔
109
            type: 'original-scale'
110
        },
111
        {
112
            icon: 'max-view',
113
            name: '适应屏幕',
114
            action: () => {
115
                this.setFitScreen();
116
            },
1✔
117
            type: 'fit-screen'
118
        },
119
        {
120
            icon: 'expand-arrows',
121
            name: '全屏显示',
122
            action: () => {
123
                this.fullScreen();
124
            },
125
            type: 'full-screen'
126
        },
127
        {
128
            icon: 'rotate-right',
503✔
129
            name: '旋转',
503✔
130
            action: (image: ThyImageInfo) => {
487!
131
                this.rotateRight();
132
            },
503✔
133
            type: 'rotate-right'
134
        },
135
        {
38✔
136
            icon: 'download',
38!
UNCOV
137
            name: '下载',
×
138
            action: (image: ThyImageInfo) => {
139
                this.download(image);
38✔
140
            },
141
            type: 'download'
142
        },
26✔
143
        {
10!
144
            icon: 'preview',
145
            name: '查看原图',
10!
146
            action: () => {
147
                this.viewOriginal();
148
            },
149
            type: 'view-original'
150
        },
151
        {
18✔
152
            icon: 'link-insert',
18✔
153
            name: '复制链接',
18✔
154
            type: 'copyLink'
155
        }
156
    ];
17✔
157

158
    private rotate: number;
159

2✔
160
    get previewImage(): InternalImageInfo {
161
        const image = this.images[this.previewIndex];
162
        if (image.size) {
18✔
163
            image.size = isNumber(image.size) ? humanizeBytes(image.size) : image.size;
164
        }
UNCOV
165
        return image;
×
166
    }
167

168
    get previewImageOriginSrc() {
169
        let imageSrc = this.previewImage.origin?.src || this.previewImage.src;
170
        if (imageSrc.startsWith('./')) {
1✔
171
            return window.location.host + '/' + imageSrc.split('./')[1];
1✔
172
        }
1✔
173
        return imageSrc;
1✔
174
    }
1✔
175

1✔
176
    get defaultZoom(): number {
1✔
177
        if (this.previewConfig?.zoom && this.previewConfig?.zoom > 0) {
178
            return this.previewConfig.zoom >= IMAGE_MAX_ZOOM
179
                ? IMAGE_MAX_ZOOM
1✔
180
                : this.previewConfig.zoom <= IMAGE_MIN_ZOOM
1✔
181
                  ? IMAGE_MIN_ZOOM
1✔
182
                  : this.previewConfig.zoom;
183
        }
184
    }
5✔
185

5✔
186
    @ViewChild('imgRef', { static: false }) imageRef!: ElementRef<HTMLImageElement>;
5✔
187
    @ViewChild('imagePreviewWrapper', { static: true }) imagePreviewWrapper!: ElementRef<HTMLElement>;
5!
188

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

6!
204
            fromEvent(this.imagePreviewWrapper.nativeElement, 'mousedown')
6✔
205
                .pipe(takeUntilDestroyed(this.destroyRef))
206
                .subscribe(() => {
UNCOV
207
                    this.isDragging = !this.isInsideScreen && true;
×
208
                });
209
        });
6✔
210
    }
6✔
211

6!
UNCOV
212
    setOriginalSize() {
×
213
        this.reset();
214
        this.currentImageMode = 'fit-screen';
6✔
215
        this.zoom = 1;
216
        this.updatePreviewImageTransform();
217
        this.calculateInsideScreen();
218
        this.isLoadingDone = true;
21✔
219
        this.cdr.detectChanges();
21!
220
    }
221

×
UNCOV
222
    setFitScreen() {
×
223
        this.reset();
224
        this.isInsideScreen = true;
225
        this.updatePreviewImage();
21!
UNCOV
226
    }
×
227

228
    useDefaultZoomUpdate(isUpdateImageWrapper: boolean) {
21✔
229
        this.zoom = this.defaultZoom;
5✔
230
        this.isLoadingDone = true;
231
        this.updatePreviewImageTransform();
232
        if (isUpdateImageWrapper) {
16✔
233
            this.updatePreviewImageWrapperTransform();
234
        }
235
        this.cdr.detectChanges();
236
    }
237

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

263
    updatePreviewImage() {
264
        this.resolvePreviewImage().subscribe(result => {
18✔
265
            if (!result) {
1✔
266
                // error
1✔
267
                this.isLoadingDone = true;
5✔
268
                return;
4✔
269
            }
270
            // image size
271
            if (!this.previewImage.size && this.previewImage.blob) {
1✔
272
                this.previewImage.size = humanizeBytes(this.previewImage.blob.size);
273
            }
274
            if (this.defaultZoom) {
275
                this.useDefaultZoomUpdate(true);
276
            } else {
17✔
277
                this.useCalculateZoomUpdate();
278
            }
18✔
279
        });
18✔
280
    }
281

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

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

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

344
    zoomIn(): void {
UNCOV
345
        if (this.zoom < IMAGE_MAX_ZOOM) {
×
346
            this.zoom = Math.min(this.zoom + 0.1, IMAGE_MAX_ZOOM);
347
            this.calculateInsideScreen();
348
            this.updatePreviewImageTransform();
349
            this.position = { ...initialPosition };
2✔
350
        }
1✔
351
    }
1✔
352

1✔
353
    zoomOut(): void {
1✔
354
        if (this.zoom > IMAGE_MIN_ZOOM) {
355
            this.zoom = Math.max(this.zoom - 0.1, IMAGE_MIN_ZOOM);
356
            this.calculateInsideScreen();
357
            this.updatePreviewImageTransform();
2✔
358
            this.position = { ...initialPosition };
1✔
359
        }
1✔
360
    }
1✔
361

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

×
373
    viewOriginal() {
×
374
        window.open(this.previewImage?.origin?.src || this.previewImage.src, '_blank');
375
    }
376

377
    rotateRight(): void {
378
        this.rotate += 90;
379
        this.updatePreviewImageTransform();
×
380
    }
×
UNCOV
381

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

394
    copyLink(event: ThyCopyEvent) {
5✔
395
        if (event.isSuccess) {
396
            this.notifyService.success('复制图片地址成功');
1✔
397
        } else {
398
            this.notifyService.error('复制图片地址失败');
399
        }
400
    }
401

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

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

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

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

448
    private updatePreviewImageTransform(): void {
449
        this.previewImageTransform = `scale3d(${this.zoom}, ${this.zoom}, 1) rotate(${this.rotate}deg)`;
450
    }
451

452
    private updatePreviewImageWrapperTransform(): void {
453
        this.previewImageWrapperTransform = `translate3d(${this.position.x}px, ${this.position.y}px, 0)`;
454
    }
455
}
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

© 2026 Coveralls, Inc