diff --git a/projects/components/src/splitter/splitter.component.scss b/projects/components/src/splitter/splitter.component.scss index fbee4500e..ec59727a0 100644 --- a/projects/components/src/splitter/splitter.component.scss +++ b/projects/components/src/splitter/splitter.component.scss @@ -2,6 +2,7 @@ .splitter-container { @include m.fill-container(); + position: relative; display: flex; &.horizontal { diff --git a/projects/components/src/splitter/splitter.component.ts b/projects/components/src/splitter/splitter.component.ts index 5ad8e294c..254f2e364 100644 --- a/projects/components/src/splitter/splitter.component.ts +++ b/projects/components/src/splitter/splitter.component.ts @@ -1,3 +1,4 @@ +import { DOCUMENT } from '@angular/common'; import { AfterContentInit, ChangeDetectionStrategy, @@ -12,26 +13,24 @@ import { QueryList, Renderer2 } from '@angular/core'; -import { LayoutChangeService, TypedSimpleChanges, queryListAndChanges$ } from '@hypertrace/common'; +import { assertUnreachable, LayoutChangeService, queryListAndChanges$, TypedSimpleChanges } from '@hypertrace/common'; +import { debounce, isEmpty } from 'lodash-es'; import { EMPTY, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { SplitterDirection } from './splitter'; import { SplitterCellDimension, SplitterContentDirective } from './splitter-content.directive'; -import { DOCUMENT } from '@angular/common'; -import { debounce } from 'lodash-es'; @Component({ selector: 'ht-splitter', styleUrls: ['./splitter.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
+
@@ -43,6 +42,7 @@ import { debounce } from 'lodash-es'; [ngClass]="[this.direction | lowercase]" [ngStyle]="this.splitterSizeStyle" (mousedown)="this.onGutterMouseDown($event, index)" + (mouseup)="this.onGutterMouseUp($event)" >
@@ -57,7 +57,7 @@ export class SplitterComponent implements OnChanges, AfterContentInit { public readonly direction?: SplitterDirection = SplitterDirection.Horizontal; @Input() - public readonly debounceTime: number = 20; + public readonly debounceTime: number = 12; @Input() public readonly splitterSize: number = 16; @@ -69,16 +69,24 @@ export class SplitterComponent implements OnChanges, AfterContentInit { private readonly contents!: QueryList; protected contents$!: Observable; + protected classes: string[] = []; protected splitterSizeStyle?: Partial; + private mouseMoveListener?: () => void; private mouseUpListener?: () => void; - private size?: number; - private resizeColumnSize?: number; - private currentSplitterElement?: HTMLElement; - private startPos?: number; - private previous?: ContentWithMetadata; - private next?: ContentWithMetadata; + private normalizationParameters: NormalizationParameters = { + itemCount: 0, + pxPerItem: 0 + }; + + private resizeStartParameters: ResizeStartParameters = { + startPositionPx: 0, + prevContentStartSizePx: 0, + nextContentStartSizePx: 0 + }; + + private readonly debounceResize = debounce(this.resize, this.debounceTime); public constructor( @Inject(DOCUMENT) private readonly document: Document, @@ -87,213 +95,165 @@ export class SplitterComponent implements OnChanges, AfterContentInit { private readonly renderer: Renderer2 ) {} - public ngAfterContentInit(): void { - this.contents$ = queryListAndChanges$(this.contents ?? EMPTY).pipe(map(contents => contents.toArray())); - } - public ngOnChanges(changes: TypedSimpleChanges): void { if (changes.splitterSize || changes.direction) { this.setSplitterSizeStyle(); + this.classes = this.buildClasses(); } } - protected onGutterMouseDown(event: MouseEvent, index: number) { - this.resizeStart(event, index); - this.bindMouseListeners(); + public ngAfterContentInit(): void { + this.subscribeToQueryListChanges(); } - protected readonly getFlex = (dimension: SplitterCellDimension): string => { - if (dimension.unit === 'PX') { - return `1 1 ${dimension.value}${dimension.unit.toLowerCase()}`; - } else { - return `${dimension.value} ${dimension.value} 0`; - } - }; - - protected readonly getMaxHeight = (dimension: SplitterCellDimension): string | undefined => { - if (dimension.unit === 'PX') { - return `${dimension.value}${dimension.unit.toLowerCase()}`; - } - }; + private buildNormalizationParams(contents: SplitterContentDirective[]): NormalizationParameters { + const totalAvailablePx = this.getElementSizePx(this.element.nativeElement); + const splitterPx = this.splitterSize * (contents.length - 1); + const pxPerItem = (totalAvailablePx - splitterPx) / contents.length; - protected horizontal() { - return this.direction === SplitterDirection.Horizontal; + return { + itemCount: contents.length, + pxPerItem: pxPerItem + }; } - private setSplitterSizeStyle(): void { - if (this.direction === SplitterDirection.Vertical) { - this.splitterSizeStyle = { - height: `${this.splitterSize}px` - }; - } + private normalizeContentsDimensions(contents: SplitterContentDirective[]): SplitterContentDirective[] { + this.normalizationParameters = this.buildNormalizationParams(contents); - if (this.direction === SplitterDirection.Horizontal) { - this.splitterSizeStyle = { - width: `${this.splitterSize}px` - }; - } + return contents.map(content => this.normalizeContentDimension(content)); } - private bindMouseListeners() { - if (!this.mouseMoveListener) { - this.mouseMoveListener = this.renderer.listen(this.document, 'mousemove', event => { - this.debouncedOnResize(event); - }); + private normalizeContentDimension(content: SplitterContentDirective): SplitterContentDirective { + if (content.dimension.unit === 'FR') { + content.dimension = { + unit: 'PX', + value: content.dimension.value * this.normalizationParameters.pxPerItem + }; } - if (!this.mouseUpListener) { - this.mouseUpListener = this.renderer.listen(this.document, 'mouseup', () => { - this.resizeEnd(); - this.unbindMouseListeners(); - }); - } + return content; } - private unbindMouseListeners() { - this.mouseMoveListener?.(); - this.mouseMoveListener = undefined; - - this.mouseUpListener?.(); - this.mouseUpListener = undefined; + protected onGutterMouseDown(event: MouseEvent, index: number) { + this.resizeStart(event, index); + this.bindMouseListeners(); } - private resizeStart(event: MouseEvent, index: number) { - this.currentSplitterElement = event.currentTarget as HTMLElement; - this.size = this.getElementSize(this.element.nativeElement); - this.resizeColumnSize = this.horizontal() ? this.size / 12 : 1; - - this.startPos = this.horizontal() ? event.pageX : event.pageY; - - this.previous = this.buildPreviousContentWithMetaData(index, this.currentSplitterElement); - this.next = this.buildNextContentWithMetaData(index, this.currentSplitterElement); + protected onGutterMouseUp(event: MouseEvent) { + this.resize(event); + this.unbindMouseListeners(); } - private readonly debouncedOnResize: (event: MouseEvent) => void = debounce(this.onResize, 0); - - private onResize(event: MouseEvent): void { - let newPos; - - if ( - this.size !== undefined && - this.startPos !== undefined && - this.resizeColumnSize !== undefined && - this.previous !== undefined - ) { - let newPrevPanelSize = 0; - let newNextPanelSize = 0; - - if (this.horizontal()) { - newPos = event.pageX - this.startPos; - newPrevPanelSize = this.previous.size + newPos; - if (this.next) { - newNextPanelSize = this.next.size - newPos; - } - } else { - newPos = event.pageY - this.startPos; - newPrevPanelSize = this.previous.size + newPos; - if (this.next) { - newNextPanelSize = this.next.size; // Let the top container grow. - } - } - - if (this.validateResize(newPrevPanelSize, newNextPanelSize) && this.previous !== undefined) { - this.previous.content.dimension = { value: newPrevPanelSize, unit: 'PX' }; - - if (this.next) { - this.next.content.dimension = { value: newNextPanelSize, unit: 'PX' }; - } - - this.setStyle(); - } - } + private resizeStart(startEvent: MouseEvent, index: number): void { + this.resizeStartParameters = this.buildResizeStartParameters(index, startEvent); } - private resizeEnd() { - if (this.previous !== undefined && this.resizeColumnSize !== undefined && this.size !== undefined) { - if (this.horizontal()) { - this.previous.size = this.getElementSize(this.previous.element); - - let previousPanelColumnWidth = Math.round(this.previous.size / this.resizeColumnSize); - this.previous.content.dimension = { value: previousPanelColumnWidth, unit: 'FR' }; + private resize(endEvent: MouseEvent): void { + const prev = this.resizeStartParameters.prevContent; + const next = this.resizeStartParameters.nextContent; - if (this.next) { - this.next.size = this.getElementSize(this.next.element); + const positionDiffPx = this.getClientPx(endEvent) - this.resizeStartParameters.startPositionPx; - let nextPanelColumnWidth = Math.round( - (this.next.size - (previousPanelColumnWidth * this.resizeColumnSize - this.previous.size)) / - this.resizeColumnSize - ); - this.next.content.dimension = { value: nextPanelColumnWidth, unit: 'FR' }; - } + if (prev) { + prev.dimension.value = this.resizeStartParameters.prevContentStartSizePx + positionDiffPx; + } - this.setStyle(); - } + if (next) { + next.dimension.value = this.resizeStartParameters.nextContentStartSizePx - positionDiffPx; } this.layoutChange.emit(this.contents.map(c => c.dimension)); this.layoutChangeService.publishLayoutChange(); - this.clear(); } - private buildPreviousContentWithMetaData(index: number, splitterElement: HTMLElement): ContentWithMetadata { - const element = splitterElement.previousElementSibling as HTMLElement; + private buildResizeStartParameters(index: number, startEvent: MouseEvent): ResizeStartParameters { + const prevContent = this.contents.get(index); + const nextContent = this.contents.get(index + 1); return { - content: this.contents.get(index)!, - index: index, - element: element, - size: this.getElementSize(element) + startPositionPx: this.getClientPx(startEvent), + prevContentStartSizePx: prevContent?.dimension.value ?? 0, + nextContentStartSizePx: nextContent?.dimension.value ?? 0, + prevContent: prevContent, + nextContent: nextContent }; } - private buildNextContentWithMetaData(index: number, splitterElement: HTMLElement): ContentWithMetadata | undefined { - const element = splitterElement.nextElementSibling as HTMLElement | undefined; + private subscribeToQueryListChanges(): void { + this.contents$ = queryListAndChanges$(this.contents ?? EMPTY).pipe( + map(contents => contents.toArray()), + map(contents => this.normalizeContentsDimensions(contents)) + ); + } - if (!element) { - return undefined; + private bindMouseListeners() { + if (!this.mouseMoveListener) { + this.mouseMoveListener = this.renderer.listen(this.document, 'mousemove', event => this.debounceResize(event)); } - return { - content: this.contents.get(index + 1)!, - index: index, - element: element, - size: this.getElementSize(element) - }; + if (!this.mouseUpListener) { + this.mouseUpListener = this.renderer.listen(this.document, 'mouseup', event => { + this.resize(event); + this.unbindMouseListeners(); + }); + } + } + + private unbindMouseListeners() { + this.mouseMoveListener?.(); + this.mouseMoveListener = undefined; + + this.mouseUpListener?.(); + this.mouseUpListener = undefined; } - private getElementSize(element: HTMLElement): number { - return this.horizontal() ? element.getBoundingClientRect().width : element.getBoundingClientRect().height; + private getClientPx(event: MouseEvent): number { + return this.isHorizontal() ? event.clientX : event.clientY; } - private validateResize(_newPrevPanelSize: number, _newNextPanelSize?: number): boolean { - /** - * Stub method to validate resize. For now, returning true - */ - return true; + private getElementSizePx(element: HTMLElement): number { + return this.isHorizontal() ? element.getBoundingClientRect().width : element.getBoundingClientRect().height; } - private setStyle(): void { - if (this.previous !== undefined) { - this.renderer.setStyle(this.previous.element, 'flex', this.getFlex(this.previous.content.dimension)); + private isHorizontal() { + return this.direction === SplitterDirection.Horizontal; + } - if (this.next) { - this.renderer.setStyle(this.next.element, 'flex', this.getFlex(this.next.content.dimension)); - } + private setSplitterSizeStyle(): void { + switch (this.direction) { + case SplitterDirection.Horizontal: + this.splitterSizeStyle = { + width: `${this.splitterSize}px` + }; + break; + case SplitterDirection.Vertical: + this.splitterSizeStyle = { + height: `${this.splitterSize}px` + }; + break; + case undefined: + break; + default: + assertUnreachable(this.direction); } } - private clear() { - this.size = undefined; - this.startPos = undefined; - this.previous = undefined; - this.next = undefined; - this.currentSplitterElement = undefined; + private buildClasses(): string[] { + return [this.direction?.toLowerCase() ?? ''].filter(c => !isEmpty(c)); } + + protected readonly buildFlex = (pixels: number): string => `1 1 ${pixels}px`; +} + +interface NormalizationParameters { + itemCount: number; + pxPerItem: number; } -interface ContentWithMetadata { - content: SplitterContentDirective; - index: number; - element: HTMLElement; - size: number; +interface ResizeStartParameters { + startPositionPx: number; + prevContentStartSizePx: number; + nextContentStartSizePx: number; + prevContent?: SplitterContentDirective; + nextContent?: SplitterContentDirective; }