/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
devtools/projects/ng-devtools/src/lib/shared/split/split.component.ts
739 строк
24 KB
hawkgs
refactor(devtools): drop explicit OnPush change detection strategy
25 мар 2026, 22:58
25 мар 2026, 22:58
0ad18ee
Код
Авторство
О чём код?
/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import { afterNextRender, booleanAttribute, ChangeDetectorRef, Component, effect, ElementRef, input, linkedSignal, OnDestroy, output, QueryList, Renderer2, untracked, ViewChildren, } from '@angular/core'; import {outputFromObservable} from '@angular/core/rxjs-interop'; import {Observable, Subject, Subscriber} from 'rxjs'; import {debounceTime} from 'rxjs/operators'; import { Direction, IArea, IAreaSnapshot, IOutputAreaSizes, IOutputData, IPoint, ISplitSnapshot, Unit, } from './interface'; import {SplitAreaDirective} from './splitArea.directive'; import { getAreaMaxSize, getAreaMinSize, getElementPixelSize, getGutterSideAbsorptionCapacity, getInputPositiveNumber, getPointFromEvent, isUserSizesValid, updateAreaSize, } from './utils'; /** * angular-split * * * PERCENT MODE ([unit]="'percent'") * ___________________________________________________________________________________________ * | A [g1] B [g2] C [g3] D [g4] E | * |-------------------------------------------------------------------------------------------| * | 20 30 20 15 15 | <-- * [size]="x" | 10px 10px 10px 10px | <-- * [gutterSize]="10" |calc(20% - 8px) calc(30% - 12px) calc(20% - 8px) calc(15% - 6px) * calc(15% - 6px)| <-- CSS flex-basis property (with flex-grow&shrink at 0) | 152px 228px 152px * 114px 114px | <-- el.getBoundingClientRect().width * |___________________________________________________________________________________________| * 800px <-- * el.getBoundingClientRect().width flex-basis = calc( { area.size }% - { area.size/100 * * nbGutter*gutterSize }px ); * * * PIXEL MODE ([unit]="'pixel'") * ___________________________________________________________________________________________ * | A [g1] B [g2] C [g3] D [g4] E | * |-------------------------------------------------------------------------------------------| * | 100 250 * 150 100 | <-- * [size]="y" | 10px 10px 10px 10px | <-- * [gutterSize]="10" | 0 0 100px 0 0 250px 1 1 auto 0 0 150px 0 0 * 100px | <-- CSS flex property (flex-grow/flex-shrink/flex-basis) | 100px 250px * 200px 150px 100px | <-- el.getBoundingClientRect().width * |___________________________________________________________________________________________| * 800px <-- * el.getBoundingClientRect().width * */ @Component({ selector: 'as-split', exportAs: 'asSplit', styleUrls: [`./split.component.scss`], template: `<ng-content></ng-content> @for (_ of displayedAreas; track $index) { @if ($last === false) { <div #gutterEls class="as-split-gutter" [style.flex-basis.px]="gutterSize()" [style.order]="$index * 2 + 1" (mousedown)="startDragging($event, $index * 2 + 1, $index + 1)" (touchstart)="startDragging($event, $index * 2 + 1, $index + 1)" (mouseup)="clickGutter($event, $index + 1)" (touchend)="clickGutter($event, $index + 1)" > @if (showGutterIcon()) { <div class="as-split-gutter-icon"></div> } </div> } }`, host: { '[class.as-disabled]': 'disabled()', '[class.as-transition]': 'useTransition()', '[class.as-horizontal]': 'this.direction() === "horizontal"', '[class.as-vertical]': 'this.direction() === "vertical"', '[attr.dir]': 'dir()', }, }) export class SplitComponent implements OnDestroy { readonly directionInput = input<Direction>('horizontal', {alias: 'direction'}); // This allows the direction to be writable from the outside readonly direction = linkedSignal(this.directionInput); readonly unit = input<Unit>('percent'); readonly showGutterIcon = input(false, {transform: booleanAttribute}); readonly gutterSize = input(11, {transform: (v: unknown) => getInputPositiveNumber(v, 11)}); readonly gutterStep = input(1, {transform: (v: unknown) => getInputPositiveNumber(v, 1)}); readonly restrictMove = input(false, {transform: booleanAttribute}); readonly useTransition = input(false, {transform: booleanAttribute}); readonly disabled = input(false, {transform: booleanAttribute}); readonly dir = input<'ltr' | 'rtl'>('ltr'); readonly gutterDblClickDuration = input(0, { transform: (v: unknown) => getInputPositiveNumber(v, 0), }); //// readonly dragStart = output<IOutputData>(); readonly dragEnd = output<IOutputData>(); readonly gutterClick = output<IOutputData>(); readonly gutterDblClick = output<IOutputData>(); private transitionEndSubscriber: Subscriber<IOutputAreaSizes> | null = null; transitionEnd = outputFromObservable<IOutputAreaSizes>( new Observable((subscriber) => (this.transitionEndSubscriber = subscriber)).pipe( debounceTime<any>(20), ), ); private dragProgressSubject: Subject<IOutputData> = new Subject(); dragProgress$: Observable<IOutputData> = this.dragProgressSubject.asObservable(); private isDragging: boolean = false; private dragListeners: Array<Function> = []; private snapshot: ISplitSnapshot | null = null; private startPoint: IPoint | null = null; private endPoint: IPoint | null = null; public readonly displayedAreas: Array<IArea> = []; private readonly hidedAreas: Array<IArea> = []; @ViewChildren('gutterEls') private gutterEls!: QueryList<ElementRef>; constructor( private elRef: ElementRef, private cdRef: ChangeDetectorRef, private renderer: Renderer2, ) { // To avoid transition at first rendering afterNextRender({ write: () => this.renderer.addClass(this.elRef.nativeElement, 'as-init'), }); effect(() => { this.unit(); // If unit changes, rebuild areas sizes this.build(false, true); }); effect(() => { this.direction(); // If direction changes, rebuild areas sizes this.build(false, false); }); } private getNbGutters(): number { return this.displayedAreas.length === 0 ? 0 : this.displayedAreas.length - 1; } public addArea(component: SplitAreaDirective): void { const newArea: IArea = { component, order: 0, size: 0, minSize: null, maxSize: null, }; if (component.visible) { this.displayedAreas.push(newArea); this.build(true, true); } else { this.hidedAreas.push(newArea); } } public removeArea(component: SplitAreaDirective): void { if (this.displayedAreas.some((a) => a.component === component)) { const area = this.displayedAreas.find((a) => a.component === component); this.displayedAreas.splice(this.displayedAreas.indexOf(area!), 1); this.build(true, true); } else if (this.hidedAreas.some((a) => a.component === component)) { const area = this.hidedAreas.find((a) => a.component === component); this.hidedAreas.splice(this.hidedAreas.indexOf(area!), 1); } } public updateArea( component: SplitAreaDirective, resetOrders: boolean, resetSizes: boolean, ): void { if (component.visible) { this.build(resetOrders, resetSizes); } } public showArea(component: SplitAreaDirective): void { const area = this.hidedAreas.find((a) => a.component === component); if (area === undefined) { return; } const areas = this.hidedAreas.splice(this.hidedAreas.indexOf(area), 1); this.displayedAreas.push(...areas); this.build(true, true); } public hideArea(comp: SplitAreaDirective): void { const area = this.displayedAreas.find((a) => a.component === comp); if (area === undefined) { return; } const areas = this.displayedAreas.splice(this.displayedAreas.indexOf(area), 1); areas.forEach((area) => { area.order = 0; area.size = 0; }); this.hidedAreas.push(...areas); this.build(true, true); } public getVisibleAreaSizes(): IOutputAreaSizes { return this.displayedAreas.map((a) => (a.size === null ? '*' : a.size)); } public setVisibleAreaSizes(sizes: IOutputAreaSizes): boolean { if (sizes.length !== this.displayedAreas.length) { return false; } const formattedSizes = sizes.map((s) => getInputPositiveNumber(s, null)); const isValid = isUserSizesValid(untracked(this.unit), formattedSizes); if (isValid === false) { return false; } // @ts-ignore this.displayedAreas.forEach((area, i) => (area.component._size = formattedSizes[i])); this.build(false, true); return true; } private build(resetOrders: boolean, resetSizes: boolean): void { this.stopDragging(); // ¤ AREAS ORDER if (resetOrders) { // If user provided 'order' for each area, use it to sort them. if (this.displayedAreas.every((a) => a.component.order !== null)) { this.displayedAreas.sort((a, b) => <number>a.component.order - <number>b.component.order); } // Then set real order with multiples of 2, numbers between will be used by gutters. this.displayedAreas.forEach((area, i) => { area.order = i * 2; area.component.setStyleOrder(area.order); }); } // ¤ AREAS SIZE const unit = untracked(this.unit); if (resetSizes) { const useUserSizes = isUserSizesValid( unit, this.displayedAreas.map((a) => a.component.size), ); switch (unit) { case 'percent': { const defaultSize = 100 / this.displayedAreas.length; this.displayedAreas.forEach((area) => { area.size = useUserSizes ? <number>area.component.size : defaultSize; area.minSize = getAreaMinSize(area); area.maxSize = getAreaMaxSize(area); }); break; } case 'pixel': { if (useUserSizes) { this.displayedAreas.forEach((area) => { area.size = area.component.size; area.minSize = getAreaMinSize(area); area.maxSize = getAreaMaxSize(area); }); } else { const wildcardSizeAreas = this.displayedAreas.filter((a) => a.component.size === null); // No wildcard area > Need to select one arbitrarily > first if (wildcardSizeAreas.length === 0 && this.displayedAreas.length > 0) { this.displayedAreas.forEach((area, i) => { area.size = i === 0 ? null : area.component.size; area.minSize = i === 0 ? null : getAreaMinSize(area); area.maxSize = i === 0 ? null : getAreaMaxSize(area); }); } // More than one wildcard area > Need to keep only one arbitrarily > first else if (wildcardSizeAreas.length > 1) { let alreadyGotOne = false; this.displayedAreas.forEach((area) => { if (area.component.size === null) { if (alreadyGotOne === false) { area.size = null; area.minSize = null; area.maxSize = null; alreadyGotOne = true; } else { area.size = 100; area.minSize = null; area.maxSize = null; } } else { area.size = area.component.size; area.minSize = getAreaMinSize(area); area.maxSize = getAreaMaxSize(area); } }); } } break; } } } this.refreshStyleSizes(unit); this.cdRef.markForCheck(); } private refreshStyleSizes(unit: 'percent' | 'pixel'): void { /////////////////////////////////////////// // PERCENT MODE if (unit === 'percent') { // Only one area > flex-basis 100% if (this.displayedAreas.length === 1) { this.displayedAreas[0].component.setStyleFlex(0, 0, `100%`, false, false); } // Multiple areas > use each percent basis else { // Size in pixels const visibleGutterSize = 1; // Use visible gutter size in calculation instead of the invisible draggable gutter const sumGutterSize = this.getNbGutters() * visibleGutterSize; this.displayedAreas.forEach((area) => { area.component.setStyleFlex( 0, 0, `calc( ${area.size}% - ${(<number>area.size / 100) * sumGutterSize}px )`, area.minSize !== null && area.minSize === area.size ? true : false, area.maxSize !== null && area.maxSize === area.size ? true : false, ); }); } } /////////////////////////////////////////// // PIXEL MODE else if (unit === 'pixel') { this.displayedAreas.forEach((area) => { // Area with wildcard size if (area.size === null) { if (this.displayedAreas.length === 1) { area.component.setStyleFlex(1, 1, `100%`, false, false); } else { area.component.setStyleFlex(1, 1, `auto`, false, false); } } // Area with pixel size else { // Only one area > flex-basis 100% if (this.displayedAreas.length === 1) { area.component.setStyleFlex(0, 0, `100%`, false, false); } // Multiple areas > use each pixel basis else { area.component.setStyleFlex( 0, 0, `${area.size}px`, area.minSize !== null && area.minSize === area.size ? true : false, area.maxSize !== null && area.maxSize === area.size ? true : false, ); } } }); } } private clickTimeout: number | null = null; public clickGutter(event: MouseEvent | TouchEvent, gutterNum: number): void { const tempPoint = getPointFromEvent(event); // Be sure mouseup/touchend happened at same point as mousedown/touchstart to trigger // click/dblclick if ( this.startPoint && this.startPoint.x === tempPoint!.x && this.startPoint.y === tempPoint!.y ) { // If timeout in progress and new click > clearTimeout & dblClickEvent if (this.clickTimeout !== null) { window.clearTimeout(this.clickTimeout); this.clickTimeout = null; this.notify('dblclick', gutterNum); this.stopDragging(); } // Else start timeout to call clickEvent at end else { this.clickTimeout = window.setTimeout(() => { this.clickTimeout = null; this.notify('click', gutterNum); this.stopDragging(); }, untracked(this.gutterDblClickDuration)); } } } public startDragging( event: MouseEvent | TouchEvent, gutterOrder: number, gutterNum: number, ): void { event.preventDefault(); event.stopPropagation(); const direction = untracked(this.direction); this.startPoint = getPointFromEvent(event); if (this.startPoint === null || untracked(this.disabled)) { return; } this.snapshot = { gutterNum, lastSteppedOffset: 0, allAreasSizePixel: getElementPixelSize(this.elRef, direction) - this.getNbGutters() * untracked(this.gutterSize), allInvolvedAreasSizePercent: 100, areasBeforeGutter: [], areasAfterGutter: [], }; this.displayedAreas.forEach((area) => { const areaSnapshot: IAreaSnapshot = { area, sizePixelAtStart: getElementPixelSize(area.component.elRef, direction), sizePercentAtStart: (untracked(this.unit) === 'percent' ? area.size : -1) as number, // If pixel mode, anyway, will not be used. }; if (area.order < gutterOrder) { if (untracked(this.restrictMove)) { this.snapshot!.areasBeforeGutter = [areaSnapshot]; } else { this.snapshot!.areasBeforeGutter.unshift(areaSnapshot); } } else if (area.order > gutterOrder) { if (untracked(this.restrictMove)) { if (this.snapshot!.areasAfterGutter.length === 0) this.snapshot!.areasAfterGutter = [areaSnapshot]; } else { this.snapshot!.areasAfterGutter.push(areaSnapshot); } } }); this.snapshot.allInvolvedAreasSizePercent = [ ...this.snapshot.areasBeforeGutter, ...this.snapshot.areasAfterGutter, ].reduce((t, a) => t + a.sizePercentAtStart, 0); if ( this.snapshot.areasBeforeGutter.length === 0 || this.snapshot.areasAfterGutter.length === 0 ) { return; } this.dragListeners.push( this.renderer.listen('document', 'mouseup', this.stopDragging.bind(this)), ); this.dragListeners.push( this.renderer.listen('document', 'touchend', this.stopDragging.bind(this)), ); this.dragListeners.push( this.renderer.listen('document', 'touchcancel', this.stopDragging.bind(this)), ); this.dragListeners.push( this.renderer.listen('document', 'mousemove', this.dragEvent.bind(this)), ); this.dragListeners.push( this.renderer.listen('document', 'touchmove', this.dragEvent.bind(this)), ); this.displayedAreas.forEach((area) => area.component.lockEvents()); this.isDragging = true; this.renderer.addClass(this.elRef.nativeElement, 'as-dragging'); this.renderer.addClass( this.gutterEls.toArray()[this.snapshot.gutterNum - 1].nativeElement, 'as-dragged', ); this.notify('start', this.snapshot.gutterNum); } private dragEvent(event: MouseEvent | TouchEvent): void { event.preventDefault(); event.stopPropagation(); if (this.clickTimeout !== null) { window.clearTimeout(this.clickTimeout); this.clickTimeout = null; } if (this.isDragging === false) { return; } this.endPoint = getPointFromEvent(event); if (this.endPoint === null) { return; } // Calculate steppedOffset let offset = untracked(this.direction) === 'horizontal' ? this.startPoint!.x - this.endPoint.x : this.startPoint!.y - this.endPoint.y; if (untracked(this.dir) === 'rtl') { offset = -offset; } const gutterStep = untracked(this.gutterStep); const steppedOffset = Math.round(offset / gutterStep) * gutterStep; if (steppedOffset === this.snapshot!.lastSteppedOffset) { return; } this.snapshot!.lastSteppedOffset = steppedOffset; // Need to know if each gutter side areas could reacts to steppedOffset const unit = untracked(this.unit); let areasBefore = getGutterSideAbsorptionCapacity( unit, this.snapshot!.areasBeforeGutter, -steppedOffset, this.snapshot!.allAreasSizePixel, ); let areasAfter = getGutterSideAbsorptionCapacity( unit, this.snapshot!.areasAfterGutter, steppedOffset, this.snapshot!.allAreasSizePixel, ); // Each gutter side areas can't absorb all offset if (areasBefore.remain !== 0 && areasAfter.remain !== 0) { if (Math.abs(areasBefore.remain) === Math.abs(areasAfter.remain)) { } else if (Math.abs(areasBefore.remain) > Math.abs(areasAfter.remain)) { areasAfter = getGutterSideAbsorptionCapacity( unit, this.snapshot!.areasAfterGutter, steppedOffset + areasBefore.remain, this.snapshot!.allAreasSizePixel, ); } else { areasBefore = getGutterSideAbsorptionCapacity( unit, this.snapshot!.areasBeforeGutter, -(steppedOffset - areasAfter.remain), this.snapshot!.allAreasSizePixel, ); } } // Areas before gutter can't absorbs all offset > need to recalculate sizes for areas after // gutter. else if (areasBefore.remain !== 0) { areasAfter = getGutterSideAbsorptionCapacity( unit, this.snapshot!.areasAfterGutter, steppedOffset + areasBefore.remain, this.snapshot!.allAreasSizePixel, ); } // Areas after gutter can't absorbs all offset > need to recalculate sizes for areas before // gutter. else if (areasAfter.remain !== 0) { areasBefore = getGutterSideAbsorptionCapacity( unit, this.snapshot!.areasBeforeGutter, -(steppedOffset - areasAfter.remain), this.snapshot!.allAreasSizePixel, ); } if (unit === 'percent') { // Hack because of browser messing up with sizes using calc(X% - Ypx) -> // el.getBoundingClientRect() If not there, playing with gutters makes total going down // to 99.99875% then 99.99286%, 99.98986%,.. const all = [...areasBefore.list, ...areasAfter.list]; const areaToReset = all.find( (a) => a.percentAfterAbsorption !== 0 && a.percentAfterAbsorption !== a.areaSnapshot.area.minSize && a.percentAfterAbsorption !== a.areaSnapshot.area.maxSize, ); if (areaToReset) { areaToReset.percentAfterAbsorption = this.snapshot!.allInvolvedAreasSizePercent - all .filter((a) => a !== areaToReset) .reduce((total, a) => total + a.percentAfterAbsorption, 0); } } // Now we know areas could absorb steppedOffset, time to really update sizes areasBefore.list.forEach((item) => updateAreaSize(unit, item)); areasAfter.list.forEach((item) => updateAreaSize(unit, item)); this.refreshStyleSizes(unit); this.notify('progress', this.snapshot!.gutterNum); } private stopDragging(event?: Event): void { if (event) { event.preventDefault(); event.stopPropagation(); } if (this.isDragging === false) { return; } this.displayedAreas.forEach((area) => area.component.unlockEvents()); while (this.dragListeners.length > 0) { const fct = this.dragListeners.pop(); if (fct) fct(); } // Warning: Have to be before "notify('end')" // because "notify('end')"" can be linked to "[size]='x'" > "build()" > "stopDragging()" this.isDragging = false; // If moved from starting point, notify end if ( this.endPoint && (this.startPoint!.x !== this.endPoint.x || this.startPoint!.y !== this.endPoint.y) ) { this.notify('end', this.snapshot!.gutterNum); } this.renderer.removeClass(this.elRef.nativeElement, 'as-dragging'); this.renderer.removeClass( this.gutterEls.toArray()[this.snapshot!.gutterNum - 1].nativeElement, 'as-dragged', ); this.snapshot = null; // Needed to let (click)="clickGutter(...)" event run and verify if mouse moved or not setTimeout(() => { this.startPoint = null; this.endPoint = null; }); } public notify( type: 'start' | 'progress' | 'end' | 'click' | 'dblclick' | 'transitionEnd', gutterNum: number, ): void { const sizes = this.getVisibleAreaSizes(); if (type === 'start') { this.dragStart.emit({gutterNum, sizes}); } else if (type === 'end') { this.dragEnd.emit({gutterNum, sizes}); } else if (type === 'click') { this.gutterClick.emit({gutterNum, sizes}); } else if (type === 'dblclick') { this.gutterDblClick.emit({gutterNum, sizes}); } else if (type === 'transitionEnd') { if (this.transitionEndSubscriber) { this.transitionEndSubscriber?.next(sizes); } } else if (type === 'progress') { // Stay outside zone to allow users do what they want about change detection mechanism. this.dragProgressSubject.next({gutterNum, sizes}); } } public ngOnDestroy(): void { this.stopDragging(); } }