Angular组件(一) 分割面板ShrinkSplitter
前言
分割面板在日常开发中经常使用,可将一片区域,分割为可以拖拽整宽度或高度的两部分区域。模仿iview的分割面板组件,用angular实现该功能,支持拖拽和
module.ts
import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { TlShrinkSplitterComponent } from "./shrink-splitter.component"; import{NzToolTipModule} from "ng-zorro-antd/tooltip" const COMMENT = [TlShrinkSplitterComponent]; @NgModule({ declarations: [...COMMENT], exports: [...COMMENT], imports: [ CommonModule, NzToolTipModule, ] }) export class TlShrinkSplitterModule {}
component.ts
import { AfterContentInit, AfterViewInit, ChangeDetectorRef, Component, ContentChildren, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, QueryList, TemplateRef, ViewChild } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { TlTemplateDirective } from "topdsm-lib/common" import { isFalsy } from "topdsm-lib/core/util"; import { off, on } from "./util"; @Component({ selector: "tl-shrink-splitter", templateUrl: "./shrink-splitter.component.html", providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TlShrinkSplitterComponent), multi: true } ], host: { class: "tl-shrink-splitter", '[class.expand]': 'tlExpand', '[class.contract]': '!tlExpand', '[class.contract-left]': 'tlColsedMode === "left"', '[class.contract-right]': 'tlColsedMode === "right"', '[class.contract-top]': 'tlColsedMode === "top"', '[class.contract-bottom]': 'tlColsedMode === "bottom"', '[style.z-index]': 'tlZIndex', } }) export class TlShrinkSplitterComponent implements OnInit, AfterContentInit, AfterViewInit, ControlValueAccessor { prefix = "tl-shrink-splitter" offset = 0 oldOffset: number | string = 0 isMoving = false initOffset = 0 _value: number | string = 0.5 isOpen = true @Input() tlZIndex = 10 // @Input() // tlMode: "horizontal" | "vertical" = "horizontal" /** 是否展示收起icon */ @Input() tlShowExpandIcon = true /** 收起容器模式,上下左右哪一个容器应用收起展开的状态 */ @Input() tlColsedMode: "left" | "right" | "top" | "bottom" = "left" @Input() tlMin = "40px" @Input() tlMax = "40px" @Input() tlExpandTooltipContent = "" @Input() tlContractTooltipContent = "" get value() { return this._value } set value(val: number | string) { this._value = val this.onChange(val) this.computeOffset() } expandValueCache: string | number = 0 /** 展开状态 */ get tlExpand() { return this.isOpen; } @Input() set tlExpand(val: boolean) { if (val !== this.isOpen) { this.isOpen = val; this.tlExpandChange.emit(val); this.changeExpand(val) } } /** 容器展开状态切换 */ changeExpand(status: boolean) { if (!status) { // 收起 this.expandValueCache = this.value if (this.tlColsedMode === "left") { this.value = 0 } else if (this.tlColsedMode === "right") { this.value = 1 } else if (this.tlColsedMode === "top") { this.value = 0 } else if (this.tlColsedMode === "bottom") { this.value = 1 } } else { // 展开 this.value = this.expandValueCache this.expandValueCache = 0 } } /** 展开收缩切换事件 */ @Output() readonly tlExpandChange = new EventEmitter<boolean>(); @Output() readonly onMoveStart = new EventEmitter(); @Output() readonly onMoving = new EventEmitter<MouseEvent>(); @Output() readonly onMoveEnd = new EventEmitter(); expandChange(e: MouseEvent) { e.stopPropagation(); e.preventDefault() this.tlExpand = !this.isOpen } @ContentChildren(TlTemplateDirective) templates?: QueryList<TlTemplateDirective> leftTemplate?: TemplateRef<void> | null = null rightTemplate?: TemplateRef<void> | null = null topTemplate?: TemplateRef<void> | null = null bottomTemplate?: TemplateRef<void> | null = null @ViewChild('outerWrapper') outerWrapper: ElementRef; get isHorizontal() { //return this.tlMode === 'horizontal'; return this.tlColsedMode === "left" || this.tlColsedMode === "right" } get computedMin() { return this.getComputedThresholdValue('tlMin'); } get computedMax() { return this.getComputedThresholdValue('tlMax'); } get anotherOffset() { return 100 - this.offset; } get valueIsPx() { return typeof this.value === 'string'; } get offsetSize() { return this.isHorizontal ? 'offsetWidth' : 'offsetHeight'; } get paneClasses() { let classes = {} classes[`${this.prefix}-pane`] = true classes[`${this.prefix}-pane-moving`] = this.isMoving return classes } /** 展开收起触发器icon */ get triggrrClass() { let classes = {} if (this.tlColsedMode === "left" && this.isOpen) { classes["icon-caret-left"] = true } else if (this.tlColsedMode === "left" && !this.isOpen) { classes["icon-caret-right"] = true } else if (this.tlColsedMode === "right" && this.isOpen) { classes["icon-caret-right"] = true } else if (this.tlColsedMode === "right" && !this.isOpen) { classes["icon-caret-left"] = true } else if (this.tlColsedMode === "top" && this.isOpen) { classes["icon-caret-left"] = true } else if (this.tlColsedMode === "top" && !this.isOpen) { classes["icon-caret-right"] = true } else if (this.tlColsedMode === "bottom" && this.isOpen) { classes["icon-caret-right"] = true } else if (this.tlColsedMode === "bottom" && !this.isOpen) { classes["icon-caret-left"] = true } return classes } get tooltipPosition() { let position = "right" if (this.tlColsedMode === "right" && !this.isOpen) { position = "left" } return position } get tooltipContent() { let tooltip = "" if (this.tlColsedMode === "left" && this.isOpen) { tooltip = isFalsy(this.tlExpandTooltipContent) ? "收起左侧内容" : this.tlExpandTooltipContent } else if (this.tlColsedMode === "left" && !this.isOpen) { tooltip = isFalsy(this.tlContractTooltipContent) ? "展开左侧内容" : this.tlContractTooltipContent } else if (this.tlColsedMode === "right" && this.isOpen) { tooltip = isFalsy(this.tlExpandTooltipContent) ? "收起右侧内容" : this.tlExpandTooltipContent } else if (this.tlColsedMode === "right" && !this.isOpen) { tooltip = isFalsy(this.tlContractTooltipContent) ? "展开右侧内容" : this.tlContractTooltipContent } else if (this.tlColsedMode === "top" && this.isOpen) { tooltip = isFalsy(this.tlExpandTooltipContent) ? "收起顶部内容" : this.tlExpandTooltipContent } else if (this.tlColsedMode === "top" && !this.isOpen) { tooltip = isFalsy(this.tlContractTooltipContent) ? "展开顶部内容" : this.tlContractTooltipContent } else if (this.tlColsedMode === "bottom" && this.isOpen) { tooltip = isFalsy(this.tlExpandTooltipContent) ? "收起底部内容" : this.tlExpandTooltipContent } else if (this.tlColsedMode === "bottom" && !this.isOpen) { tooltip = isFalsy(this.tlContractTooltipContent) ? "展开底部内容" : this.tlContractTooltipContent } return tooltip } px2percent(numerator: string | number, denominator: string | number) { return parseFloat(numerator + "") / parseFloat(denominator + ""); } computeOffset() { this.offset = (this.valueIsPx ? this.px2percent(this.value as string, this.outerWrapper.nativeElement[this.offsetSize]) : this.value) as number * 10000 / 100 } getComputedThresholdValue(type) { let size = this.outerWrapper.nativeElement[this.offsetSize]; if (this.valueIsPx) return typeof this[type] === 'string' ? this[type] : size * this[type]; else return typeof this[type] === 'string' ? this.px2percent(this[type], size) : this[type]; } getMin(value1, value2) { if (this.valueIsPx) return `${Math.min(parseFloat(value1), parseFloat(value2))}px`; else return Math.min(value1, value2); } getMax(value1, value2) { if (this.valueIsPx) return `${Math.max(parseFloat(value1), parseFloat(value2))}px`; else return Math.max(value1, value2); } getAnotherOffset(value) { let res: string | number = 0; if (this.valueIsPx) res = `${this.outerWrapper.nativeElement[this.offsetSize] - parseFloat(value)}px`; else res = 1 - value; return res; } handleMove = (e) => { let pageOffset = this.isHorizontal ? e.pageX : e.pageY; let offset = pageOffset - this.initOffset; let outerWidth = this.outerWrapper.nativeElement[this.offsetSize]; let value: string | number = "" if (this.valueIsPx) { value = `${parseFloat(this.oldOffset as string) + offset}px` } else { value = this.px2percent(outerWidth * (this.oldOffset as number) + offset, outerWidth) } let anotherValue = this.getAnotherOffset(value); if (parseFloat(value + "") <= parseFloat(this.computedMin + "")) value = this.getMax(value, this.computedMin); if (parseFloat(anotherValue + "") <= parseFloat(this.computedMax)) value = this.getAnotherOffset(this.getMax(anotherValue, this.computedMax)); e.atMin = this.value === this.computedMin; e.atMax = this.valueIsPx ? this.getAnotherOffset(this.value) === this.computedMax : (this.getAnotherOffset(this.value) as number).toFixed(5) === this.computedMax.toFixed(5); this.value = value this.onMoving.emit(e) } handleUp = (e) => { this.isMoving = false; off(document, 'mousemove', this.handleMove); off(document, 'mouseup', this.handleUp); this.onMoveEnd.emit() } onTriggerMouseDown(e) { this.initOffset = this.isHorizontal ? e.pageX : e.pageY; this.oldOffset = this.value; this.isMoving = true; on(document, 'mousemove', this.handleMove); on(document, 'mouseup', this.handleUp); this.onMoveStart.emit() } constructor(private cdr: ChangeDetectorRef) { } ngOnInit(): void { console.log("ngOnInit"); } ngAfterViewInit(): void { console.log("ngAfterViewInit"); this.computeOffset() } ngAfterContentInit() { this.templates?.forEach((item) => { switch (item.getType()) { case 'left': this.leftTemplate = item.template; break; case 'right': this.rightTemplate = item.template; break; case 'top': this.topTemplate = item.template; break; case 'bottom': this.bottomTemplate = item.template; break; default: this.leftTemplate = item.template; break; } }); } // 输入框数据变化时 onChange: (value: any) => void = () => null; onTouched: () => void = () => null; writeValue(val: number | string): void { if (val !== this.value) { this.value = val this.computeOffset(); this.cdr.markForCheck(); } } // UI界面值发生更改,调用注册的回调函数 registerOnChange(fn: any): void { this.onChange = fn; } // 在blur(等失效事件),调用注册的回调函数 registerOnTouched(fn: any): void { this.onTouched = fn; } // 设置禁用状态 setDisabledState?(isDisabled: boolean): void { } }
TlTemplateDirective指令实现
import { Directive, Input, TemplateRef, ViewContainerRef } from "@angular/core"; import { NzSafeAny } from "topdsm-lib/core/types"; @Directive({ selector: '[tlTemplate]' }) export class TlTemplateDirective { @Input('tlTemplate') name: string = "default" // @Input() // type: string = "" constructor(private viewContainer: ViewContainerRef, public template: TemplateRef<NzSafeAny>) { //this.template = templateRef; } ngOnInit(): void { this.viewContainer.createEmbeddedView(this.template) } getType() { return this.name; } }
事件绑定、解绑
export const on = (function() { if (document.addEventListener) { return function(element, event, handler) { if (element && event && handler) { element.addEventListener(event, handler, false); } }; } else { return function(element, event, handler) { if (element && event && handler) { element.attachEvent('on' + event, handler); } }; } })(); export const off = (function() { if (document.removeEventListener) { return function(element, event, handler) { if (element && event) { element.removeEventListener(event, handler, false); } }; } else { return function(element, event, handler) { if (element && event) { element.detachEvent('on' + event, handler); } }; } })();
component.html
<div [ngClass]="prefix + '-wrapper'" #outerWrapper> <div [ngClass]="prefix + '-horizontal'" *ngIf="isHorizontal; else verticalSlot"> <div class="left-pane" [ngStyle]="{right: anotherOffset + '%'}" [ngClass]="paneClasses"> <ng-container *ngTemplateOutlet="leftTemplate"></ng-container> </div> <div [ngClass]="prefix + '-trigger-con'" [ngStyle]="{left: offset + '%'}" (mousedown)="onTriggerMouseDown($event)"> <div ngClass="tl-shrink-splitter-trigger tl-shrink-splitter-trigger-vertical" > <!-- <span class="tl-shrink-splitter-trigger-bar-con vertical" [ngClass]="triggrrClass" (mousedown)="expandChange($event)" [tTooltip]="tooltipContent" [tooltipPosition]="tooltipPosition" *ngIf="tlShowExpandIcon"></span> --> <span class="tl-shrink-splitter-trigger-bar-con vertical" [ngClass]="triggrrClass" (mousedown)="expandChange($event)" nz-tooltip [nzTooltipTitle]="tooltipContent" [nzTooltipPlacement]="tooltipPosition" *ngIf="tlShowExpandIcon"></span> </div> </div> <div class="right-pane" [ngStyle]="{left: offset + '%'}" [ngClass]="paneClasses"> <ng-container *ngTemplateOutlet="rightTemplate"></ng-container> </div> </div> <ng-template #verticalSlot> <div [ngClass]="prefix + '-vertical'" > <div class="top-pane" [ngStyle]="{bottom: anotherOffset + '%'}" [ngClass]="paneClasses"> <ng-container *ngTemplateOutlet="topTemplate"></ng-container> </div> <div [ngClass]="prefix + '-trigger-con'" [ngStyle]="{top: offset + '%'}" (mousedown)="onTriggerMouseDown($event)"> <div ngClass="tl-shrink-splitter-trigger tl-shrink-splitter-trigger-horizontal" > <!-- <span class="tl-shrink-splitter-trigger-bar-con horizontal" [ngClass]="triggrrClass" (mousedown)="expandChange($event)" [tTooltip]="tooltipContent" [tooltipPosition]="tooltipPosition" *ngIf="tlShowExpandIcon"></span> --> <span class="tl-shrink-splitter-trigger-bar-con horizontal" [ngClass]="triggrrClass" (mousedown)="expandChange($event)" nz-tooltip [nzTooltipTitle]="tooltipContent" [nzTooltipPlacement]="tooltipPosition" *ngIf="tlShowExpandIcon"></span> </div> </div> <div class="bottom-pane" [ngStyle]="{top: offset + '%'}" [ngClass]="paneClasses"> <ng-container *ngTemplateOutlet="bottomTemplate"></ng-container> </div> </div> </ng-template> </div>
component.less
@split-prefix-cls: ~"tl-shrink-splitter"; @trigger-bar-background: rgba(23, 35, 61, 0.25); @trigger-background: #f8f8f9; @trigger-width: 8px; @trigger-bar-width: 4px; @trigger-bar-offset: (@trigger-width - @trigger-bar-width) / 2; @trigger-bar-interval: 3px; @trigger-bar-weight: 1px; @trigger-bar-con-height: 20px; .tl-shrink-splitter{ position: relative; height: 100%; width: 100%; } .tl-shrink-splitter-wrapper{ position: relative; height: 100%; width: 100%; } .@{split-prefix-cls}{ background-color: #fff; border: 1px solid #dee2e6; &-pane{ position: absolute; transition: all .3s ease-in; padding: 8px; &.tl-shrink-splitter-pane-moving{ transition: none; } &.left-pane, &.right-pane { top: 0; bottom: 0; } &.left-pane { left: 0; } &.right-pane { right: 0; padding-left: 16px; } &.top-pane, &.bottom-pane { left: 0; right: 0; } &.top-pane { top: 0; } &.bottom-pane { bottom: 0; padding-top: 16px; } &-moving{ -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } } &-trigger{ border: 1px solid #dcdee2; &-con { position: absolute; transform: translate(-50%, -50%); z-index: 10; } &-bar-con { position: absolute; overflow: hidden; &:hover{ color: #000 !important; } &.vertical { top: 50%; left: -6px; width: 20px; height: @trigger-bar-con-height; background-color: #fff; border: 1px solid #ccc; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #b2b2b2; font-size: 14px; cursor: pointer; } &.horizontal { left: 50%; top: -4px; width: @trigger-bar-con-height; height: 20px; //transform: translate(-50%, 0); background-color: #fff; border: 1px solid #ccc; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #b2b2b2; font-size: 14px; cursor: pointer; } } &-vertical { width: @trigger-width; height: 100%; background: @trigger-background; border-top: none; border-bottom: none; cursor: col-resize; .@{split-prefix-cls}-trigger-bar { width: @trigger-bar-width; height: 1px; background: @trigger-bar-background; float: left; margin-top: @trigger-bar-interval; } } &-horizontal { height: @trigger-width; width: 100%; background: @trigger-background; border-left: none; border-right: none; cursor: row-resize; .@{split-prefix-cls}-trigger-bar { height: @trigger-bar-width; width: 1px; background: @trigger-bar-background; float: left; margin-right: @trigger-bar-interval; } } } &-horizontal { .@{split-prefix-cls}-trigger-con { top: 50%; height: 100%; width: 0; } } &-vertical { .@{split-prefix-cls}-trigger-con { left: 50%; height: 0; width: 100%; } } } .tl-shrink-splitter.contract{ .tl-shrink-splitter-trigger-vertical{ width: 0; padding-left: 0; } .tl-shrink-splitter-trigger-horizontal{ height: 0; padding-top: 0; } .tl-shrink-splitter-trigger{ border: 0; } &.contract-left{ .tl-shrink-splitter-pane.left-pane{ width: 0; padding: 0; overflow: hidden; } .right-pane{ padding-left: 8px; } } .tl-shrink-splitter-trigger-bar-con{ &.vertical{ left: -6px; } } &.contract-right{ .tl-shrink-splitter-trigger-bar-con{ &.vertical{ left: -16px; } } } &.contract-top{ .tl-shrink-splitter-pane.top-pane{ overflow: hidden; height: 0; padding: 0; } .bottom-pane{ padding-top: 8px; } .tl-shrink-splitter-trigger-bar-con.horizontal{ transform: rotate(90deg); } } &.contract-bottom{ .tl-shrink-splitter-trigger-bar-con{ &.horizontal{ top: -16px; } } .tl-shrink-splitter-pane.bottom-pane{ overflow: hidden; height: 0; padding: 0; } .top-pane{ padding-top: 8px; } .tl-shrink-splitter-trigger-bar-con.horizontal{ transform: rotate(90deg); } } } .tl-shrink-splitter.expand{ .tl-shrink-splitter-trigger-bar-con{ &.vertical{ left: -8px; } } &.contract-top{ .tl-shrink-splitter-trigger-bar-con.horizontal{ transform: rotate(90deg); } } &.contract-bottom{ .tl-shrink-splitter-trigger-bar-con.horizontal{ transform: rotate(90deg); } } }