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

atinc / ngx-tethys / b746e6c1-e302-40ae-8fb9-86f5adc2c193

20 May 2025 10:35AM UTC coverage: 90.219% (-0.002%) from 90.221%
b746e6c1-e302-40ae-8fb9-86f5adc2c193

Pull #3441

circleci

web-flow
Merge branch 'master' into #TINFR-1764
Pull Request #3441: feat(resizable): migration signal for resizable #TINFR-1764 @wumeimin

5548 of 6810 branches covered (81.47%)

Branch coverage included in aggregate %.

42 of 42 new or added lines in 4 files covered. (100.0%)

3 existing lines in 2 files now uncovered.

13610 of 14425 relevant lines covered (94.35%)

905.93 hits per line

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

94.76
/src/resizable/resizable.directive.ts
1
import {
2
    Directive,
3
    OnDestroy,
4
    ElementRef,
5
    Renderer2,
6
    NgZone,
7
    inject,
8
    DestroyRef,
9
    numberAttribute,
10
    input,
11
    output,
12
    afterNextRender,
13
    signal
14
} from '@angular/core';
1✔
15
import { ThyResizableService } from './resizable.service';
16
import { Platform } from '@angular/cdk/platform';
31✔
17
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
31✔
18
import { ThyResizeHandleMouseDownEvent } from './interface';
31✔
19
import { ThyResizeEvent } from './interface';
31✔
20
import { getEventWithPoint, ensureInBounds, setCompatibleStyle } from './utils';
31✔
21
import { fromEvent } from 'rxjs';
31✔
22
import { coerceBooleanProperty } from 'ngx-tethys/util';
31✔
23

31✔
24
/**
31✔
25
 * 调整尺寸
31✔
26
 * @name thyResizable
31✔
27
 */
31✔
28
@Directive({
31✔
29
    selector: '[thyResizable]',
31✔
30
    providers: [ThyResizableService],
31✔
31
    host: {
31✔
32
        class: 'thy-resizable',
31✔
33
        '[class.thy-resizable-resizing]': 'resizing()',
31✔
34
        '[class.thy-resizable-disabled]': 'thyDisabled()'
31✔
35
    }
31✔
36
})
31✔
37
export class ThyResizableDirective implements OnDestroy {
31✔
38
    private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
31✔
39
    private renderer = inject(Renderer2);
31✔
40
    private platform = inject(Platform);
31✔
41
    private ngZone = inject(NgZone);
29✔
42
    private thyResizableService = inject(ThyResizableService);
1✔
43

44
    /**
28✔
45
     * 调整尺寸的边界
28✔
46
     * @default parent
28✔
47
     * @type 'window' | 'parent' | ElementRef<HTMLElement>
28✔
48
     */
28✔
49
    readonly thyBounds = input<'window' | 'parent' | ElementRef<HTMLElement>>('parent');
50

51
    /**
28✔
52
     * 最大高度(超过边界部分忽略)
28✔
53
     * @type number
54
     */
31✔
55
    readonly thyMaxHeight = input(undefined, { transform: numberAttribute });
52✔
56

26✔
57
    /**
26✔
58
     * 最大宽度(超过边界部分忽略)
59
     * @type number
26✔
60
     */
26✔
61
    readonly thyMaxWidth = input(undefined, { transform: numberAttribute });
62

63
    /**
31✔
64
     * 最小高度
27!
65
     * @type number
27✔
66
     */
67
    readonly thyMinHeight = input(40, { transform: numberAttribute });
68

31✔
69
    /**
31!
UNCOV
70
     * 最小宽度
×
71
     * @type number
72
     */
31✔
73
    readonly thyMinWidth = input(40, { transform: numberAttribute });
31✔
74

31✔
75
    /**
76
     * 栅格列数(-1 为不栅格)
77
     * @type number
2✔
78
     */
79
    readonly thyGridColumnCount = input(-1, { transform: numberAttribute });
31✔
80

81
    /**
82
     * 栅格最大列数
2✔
83
     * @type number
84
     */
85
    readonly thyMaxColumn = input(-1, { transform: numberAttribute });
86

87
    /**
88
     * 栅格最小列数
28✔
89
     * @type number
90
     */
91
    readonly thyMinColumn = input(-1, { transform: numberAttribute });
11✔
92

11✔
93
    /**
94
     * 锁定宽高比
95
     * @type boolean
3✔
96
     */
3✔
97
    readonly thyLockAspectRatio = input(false, { transform: coerceBooleanProperty });
98

99
    /**
12✔
100
     * 是否预览模式
12✔
101
     * @type boolean
102
     */
103
    readonly thyPreview = input(false, { transform: coerceBooleanProperty });
2✔
104

2✔
105
    /**
106
     * 是否禁用调整大小
28✔
107
     * @type boolean
108
     */
109
    readonly thyDisabled = input(false, { transform: coerceBooleanProperty });
26✔
110

26✔
111
    /**
26✔
112
     * 调整尺寸时的事件
26!
113
     */
114
    readonly thyResize = output<ThyResizeEvent>();
115

116
    /**
117
     * 开始调整尺寸时的事件
118
     */
119
    readonly thyResizeStart = output<ThyResizeEvent>();
120

26✔
121
    /**
26✔
122
     * 结束调整尺寸时的事件
123
     */
124
    readonly thyResizeEnd = output<ThyResizeEvent>();
125

126
    resizing = signal(false);
26✔
127
    private nativeElement!: HTMLElement;
26✔
128
    private nativeElementRect!: ClientRect | DOMRect;
129
    private sizeCache: ThyResizeEvent | null = null;
130
    private ghostElement: HTMLDivElement | null = null;
27✔
131
    private currentHandleEvent: ThyResizeHandleMouseDownEvent | null = null;
27✔
132
    private readonly destroyRef = inject(DestroyRef);
27✔
133

27✔
134
    constructor() {
27✔
135
        this.thyResizableService.handleMouseDownOutsideAngular$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
27✔
136
            if (this.thyDisabled()) {
27✔
137
                return;
138
            }
10✔
139
            this.resizing.set(true);
10✔
140
            const { mouseEvent } = event;
10✔
141
            this.thyResizableService.startResizing(mouseEvent);
142
            this.currentHandleEvent = event;
1✔
143
            this.setCursor();
1✔
144
            // Re-enter the Angular zone and run the change detection only if there're any `thyResizeStart` listeners,
1✔
145
            // e.g.: `<div thyResizable (thyResizeStart)="..."></div>`.
146
            this.ngZone.run(() => this.thyResizeStart.emit({ mouseEvent }));
1✔
147
            this.nativeElementRect = this.nativeElement.getBoundingClientRect();
1✔
148
        });
1✔
149

150
        this.thyResizableService.documentMouseUpOutsideAngular$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
1✔
151
            if (this.resizing()) {
1✔
152
                this.ngZone.run(() => {
1✔
153
                    this.resizing.set(false);
154
                });
2✔
155
                this.thyResizableService.documentMouseUpOutsideAngular$.next(event);
2✔
156
                this.endResize(event);
157
            }
9✔
158
        });
9✔
159

160
        this.thyResizableService.documentMouseMoveOutsideAngular$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
1✔
161
            if (this.resizing()) {
1✔
162
                this.resize(event);
163
            }
2✔
164
        });
165

27✔
166
        afterNextRender(() => {
27✔
167
            if (!this.platform.isBrowser) {
168
                return;
169
            }
27✔
170
            this.nativeElement = this.elementRef.nativeElement;
27✔
171
            this.ngZone.runOutsideAngular(() => {
172
                fromEvent(this.nativeElement, 'mouseenter')
173
                    .pipe(takeUntilDestroyed(this.destroyRef))
174
                    .subscribe(() => {
175
                        this.thyResizableService.mouseEnteredOutsideAngular$.next(true);
27✔
176
                    });
1✔
177

178
                fromEvent(this.nativeElement, 'mouseleave')
179
                    .pipe(takeUntilDestroyed(this.destroyRef))
180
                    .subscribe(() => {
181
                        this.thyResizableService.mouseEnteredOutsideAngular$.next(false);
182
                    });
183
            });
184
        });
27✔
185
    }
27✔
186

27✔
187
    setCursor(): void {
27✔
188
        switch (this.currentHandleEvent!.direction) {
27✔
189
            case 'left':
27✔
190
            case 'right':
27✔
191
                this.renderer.setStyle(document.body, 'cursor', 'ew-resize');
19✔
192
                break;
19!
193
            case 'top':
19✔
194
            case 'bottom':
19✔
195
                this.renderer.setStyle(document.body, 'cursor', 'ns-resize');
19✔
196
                break;
197
            case 'topLeft':
198
            case 'bottomRight':
8✔
199
                this.renderer.setStyle(document.body, 'cursor', 'nwse-resize');
7!
200
                break;
7✔
201
            case 'topRight':
7✔
202
            case 'bottomLeft':
203
                this.renderer.setStyle(document.body, 'cursor', 'nesw-resize');
204
                break;
1!
205
        }
1✔
206
        setCompatibleStyle(document.body, 'user-select', 'none');
1✔
207
    }
1✔
208

209
    endResize(event: MouseEvent | TouchEvent): void {
27✔
210
        this.renderer.setStyle(document.body, 'cursor', '');
27✔
211
        setCompatibleStyle(document.body, 'user-select', '');
27✔
212
        this.removeGhostElement();
27✔
213
        const size = this.sizeCache
2✔
214
            ? { ...this.sizeCache }
2✔
215
            : {
2!
216
                  width: this.nativeElementRect.width,
2✔
217
                  height: this.nativeElementRect.height
2!
218
              };
219
        // Re-enter the Angular zone and run the change detection only if there're any `thyResizeEnd` listeners,
27✔
220
        // e.g.: `<div thyResizable (thyResizeEnd)="..."></div>`.
3✔
221
        this.ngZone.run(() => {
2✔
222
            this.thyResizeEnd.emit({
2✔
223
                ...size,
2✔
224
                mouseEvent: event
1✔
225
            });
226
        });
227
        this.sizeCache = null;
228
        this.currentHandleEvent = null;
1✔
229
    }
1✔
230

1!
UNCOV
231
    resize(event: MouseEvent | TouchEvent): void {
×
232
        const nativeElementRect = this.nativeElementRect;
233
        const resizeEvent = getEventWithPoint(event);
234
        const handleEvent = getEventWithPoint(this.currentHandleEvent!.mouseEvent);
235
        let width = nativeElementRect.width;
236
        let height = nativeElementRect.height;
24✔
237
        const ratio = this.thyLockAspectRatio() ? width / height : -1;
24✔
238
        switch (this.currentHandleEvent!.direction) {
239
            case 'bottomRight':
27✔
240
                width = resizeEvent.clientX - nativeElementRect.left;
2✔
241
                height = resizeEvent.clientY - nativeElementRect.top;
2✔
242
                break;
243
            case 'bottomLeft':
27✔
244
                width = nativeElementRect.width + (handleEvent.clientX - resizeEvent.clientX);
245
                height = resizeEvent.clientY - nativeElementRect.top;
246
                break;
247
            case 'topRight':
248
                width = resizeEvent.clientX - nativeElementRect.left;
249
                height = nativeElementRect.height + (handleEvent.clientY - resizeEvent.clientY);
250
                break;
1✔
251
            case 'topLeft':
1✔
252
                width = nativeElementRect.width + (handleEvent.clientX - resizeEvent.clientX);
1✔
253
                height = nativeElementRect.height + (handleEvent.clientY - resizeEvent.clientY);
254
                break;
255
            case 'top':
1!
256
                height = nativeElementRect.height + (handleEvent.clientY - resizeEvent.clientY);
1✔
257
                break;
1✔
258
            case 'right':
259
                width = resizeEvent.clientX - nativeElementRect.left;
1✔
260
                break;
261
            case 'bottom':
262
                height = resizeEvent.clientY - nativeElementRect.top;
26✔
263
                break;
1✔
264
            case 'left':
265
                width = nativeElementRect.width + (handleEvent.clientX - resizeEvent.clientX);
266
        }
267
        const size = this.calcSize(width, height, ratio);
31✔
268
        this.sizeCache = { ...size };
31✔
269
        // Re-enter the Angular zone and run the change detection only if there're any `thyResize` listeners,
270
        // e.g.: `<div thyResizable (thyResize)="..."></div>`.
1✔
271
        this.ngZone.run(() => {
1✔
272
            this.thyResize.emit({
273
                ...size,
274
                mouseEvent: event
275
            });
276
        });
277

278
        if (this.thyPreview()) {
279
            this.previewResize(size);
280
        }
281
    }
282

283
    calcSize(width: number, height: number, ratio: number): ThyResizeEvent {
284
        let newWidth: number;
285
        let newHeight: number;
286
        let maxWidth: number;
287
        let maxHeight: number;
288
        let col = 0;
1✔
289
        let spanWidth = 0;
290
        let minWidth = this.thyMinWidth();
291
        let boundWidth = Infinity;
292
        let boundHeight = Infinity;
293
        const thyBounds = this.thyBounds();
294
        if (thyBounds === 'parent') {
295
            const parent = this.renderer.parentNode(this.nativeElement);
296
            if (parent instanceof HTMLElement) {
297
                const parentRect = parent.getBoundingClientRect();
298
                boundWidth = parentRect.width;
299
                boundHeight = parentRect.height;
300
            }
301
        } else if (thyBounds === 'window') {
302
            if (typeof window !== 'undefined') {
303
                boundWidth = window.innerWidth;
304
                boundHeight = window.innerHeight;
305
            }
306
        } else if (thyBounds && thyBounds.nativeElement && thyBounds.nativeElement instanceof HTMLElement) {
307
            const boundsRect = thyBounds.nativeElement.getBoundingClientRect();
308
            boundWidth = boundsRect.width;
309
            boundHeight = boundsRect.height;
310
        }
311

312
        maxWidth = ensureInBounds(this.thyMaxWidth()!, boundWidth);
313
        maxHeight = ensureInBounds(this.thyMaxHeight()!, boundHeight);
314

315
        const thyGridColumnCount = this.thyGridColumnCount();
316
        if (thyGridColumnCount !== -1) {
317
            spanWidth = maxWidth / thyGridColumnCount;
318
            const thyMinColumn = this.thyMinColumn();
319
            minWidth = thyMinColumn !== -1 ? spanWidth * thyMinColumn : minWidth;
320
            const thyMaxColumn = this.thyMaxColumn();
321
            maxWidth = thyMaxColumn !== -1 ? spanWidth * thyMaxColumn : maxWidth;
322
        }
323

324
        if (ratio !== -1) {
325
            if (/(left|right)/i.test(this.currentHandleEvent!.direction)) {
326
                newWidth = Math.min(Math.max(width, minWidth), maxWidth);
327
                newHeight = Math.min(Math.max(newWidth / ratio, this.thyMinHeight()), maxHeight);
328
                if (newHeight >= maxHeight || newHeight <= this.thyMinHeight()) {
329
                    newWidth = Math.min(Math.max(newHeight * ratio, minWidth), maxWidth);
330
                }
331
            } else {
332
                newHeight = Math.min(Math.max(height, this.thyMinHeight()), maxHeight);
333
                newWidth = Math.min(Math.max(newHeight * ratio, minWidth), maxWidth);
334
                if (newWidth >= maxWidth || newWidth <= minWidth) {
335
                    newHeight = Math.min(Math.max(newWidth / ratio, this.thyMinHeight()), maxHeight);
336
                }
337
            }
338
        } else {
339
            newWidth = Math.min(Math.max(width, minWidth), maxWidth);
340
            newHeight = Math.min(Math.max(height, this.thyMinHeight()), maxHeight);
341
        }
342

343
        if (thyGridColumnCount !== -1) {
344
            col = Math.round(newWidth / spanWidth);
345
            newWidth = col * spanWidth;
346
        }
347

348
        return {
349
            col,
350
            width: newWidth,
351
            height: newHeight
352
        };
353
    }
354

355
    previewResize({ width, height }: ThyResizeEvent): void {
356
        this.createGhostElement();
357
        this.renderer.setStyle(this.ghostElement, 'width', `${width}px`);
358
        this.renderer.setStyle(this.ghostElement, 'height', `${height}px`);
359
    }
360

361
    createGhostElement(): void {
362
        if (!this.ghostElement) {
363
            this.ghostElement = this.renderer.createElement('div');
364
            this.renderer.setAttribute(this.ghostElement, 'class', 'thy-resizable-preview');
365
        }
366
        this.renderer.appendChild(this.nativeElement, this.ghostElement);
367
    }
368

369
    removeGhostElement(): void {
370
        if (this.ghostElement) {
371
            this.renderer.removeChild(this.nativeElement, this.ghostElement);
372
        }
373
    }
374

375
    ngOnDestroy(): void {
376
        this.ghostElement = null;
377
        this.sizeCache = null;
378
    }
379
}
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