import { PanelPuppet, WebDirective } from './../../types'; import angular from 'angular'; import template from '%/directives/panel-resizer.pug'; import { debounce } from '@/utils'; enum PanelSide { Right = 'right', Left = 'left' }; enum MouseEventType { Move = 'mousemove', Down = 'mousedown', Up = 'mouseup' }; enum CssClass { Hoverable = 'hoverable', AlwaysVisible = 'always-visible', Dragging = 'dragging', NoSelection = 'no-selection', Collapsed = 'collapsed', AnimateOpacity = 'animate-opacity', }; const WINDOW_EVENT_RESIZE = 'resize'; type ResizeFinishCallback = ( lastWidth: number, lastLeft: number, isMaxWidth: boolean, isCollapsed: boolean ) => void interface PanelResizerScope { alwaysVisible: boolean collapsable: boolean control: PanelPuppet defaultWidth: number hoverable: boolean index: number minWidth: number onResizeFinish: () => ResizeFinishCallback panelId: string property: PanelSide } class PanelResizerCtrl implements PanelResizerScope { /** @scope */ alwaysVisible!: boolean collapsable!: boolean control!: PanelPuppet defaultWidth!: number hoverable!: boolean index!: number minWidth!: number onResizeFinish!: () => ResizeFinishCallback panelId!: string property!: PanelSide $compile: ng.ICompileService $element: JQLite $timeout: ng.ITimeoutService panel!: HTMLElement resizerColumn!: HTMLElement currentMinWidth = 0 pressed = false startWidth = 0 lastDownX = 0 collapsed = false lastWidth = 0 startLeft = 0 lastLeft = 0 appFrame?: DOMRect widthBeforeLastDblClick = 0 overlay?: JQLite /* @ngInject */ constructor( $compile: ng.ICompileService, $element: JQLite, $timeout: ng.ITimeoutService, ) { this.$compile = $compile; this.$element = $element; this.$timeout = $timeout; /** To allow for registering events */ this.handleResize = debounce(this.handleResize.bind(this), 250); this.onMouseMove = this.onMouseMove.bind(this); this.onMouseUp = this.onMouseUp.bind(this); this.onMouseDown = this.onMouseDown.bind(this); } $onInit() { this.configureDefaults(); this.reloadDefaultValues(); this.configureControl(); this.addDoubleClickHandler(); this.resizerColumn.addEventListener(MouseEventType.Down, this.onMouseDown); document.addEventListener(MouseEventType.Move, this.onMouseMove); document.addEventListener(MouseEventType.Up, this.onMouseUp); } $onDestroy() { (this.onResizeFinish as any) = undefined; (this.control as any) = undefined; window.removeEventListener(WINDOW_EVENT_RESIZE, this.handleResize); document.removeEventListener(MouseEventType.Move, this.onMouseMove); document.removeEventListener(MouseEventType.Up, this.onMouseUp); this.resizerColumn.removeEventListener(MouseEventType.Down, this.onMouseDown); (this.handleResize as any) = undefined; (this.onMouseMove as any) = undefined; (this.onMouseUp as any) = undefined; (this.onMouseDown as any) = undefined; } configureControl() { this.control.setWidth = (value) => { this.setWidth(value, true); }; this.control.setLeft = (value) => { this.setLeft(value); }; this.control.flash = () => { this.flash(); }; this.control.isCollapsed = () => { return this.isCollapsed(); }; this.control.ready = true; this.control.onReady!(); } configureDefaults() { this.panel = document.getElementById(this.panelId)!; if (!this.panel) { console.error('Panel not found for', this.panelId); return; } this.resizerColumn = this.$element[0]; this.currentMinWidth = this.minWidth || this.resizerColumn.offsetWidth; this.pressed = false; this.startWidth = this.panel.scrollWidth; this.lastDownX = 0; this.collapsed = false; this.lastWidth = this.startWidth; this.startLeft = this.panel.offsetLeft; this.lastLeft = this.startLeft; this.appFrame = undefined; this.widthBeforeLastDblClick = 0; if (this.property === PanelSide.Right) { this.configureRightPanel(); } if (this.alwaysVisible) { this.resizerColumn.classList.add(CssClass.AlwaysVisible); } if (this.hoverable) { this.resizerColumn.classList.add(CssClass.Hoverable); } } configureRightPanel() { window.addEventListener(WINDOW_EVENT_RESIZE, this.handleResize); } handleResize() { this.reloadDefaultValues(); this.handleWidthEvent(); this.$timeout(() => { this.finishSettingWidth(); }); } getParentRect() { const node = this.panel!.parentNode! as HTMLElement; return node.getBoundingClientRect(); } reloadDefaultValues() { this.startWidth = this.isAtMaxWidth() ? this.getParentRect().width : this.panel.scrollWidth; this.lastWidth = this.startWidth; this.appFrame = document.getElementById('app')!.getBoundingClientRect(); } addDoubleClickHandler() { this.resizerColumn.ondblclick = () => { this.$timeout(() => { const preClickCollapseState = this.isCollapsed(); if (preClickCollapseState) { this.setWidth(this.widthBeforeLastDblClick || this.defaultWidth); } else { this.widthBeforeLastDblClick = this.lastWidth; this.setWidth(this.currentMinWidth); } this.finishSettingWidth(); const newCollapseState = !preClickCollapseState; this.onResizeFinish()( this.lastWidth, this.lastLeft, this.isAtMaxWidth(), newCollapseState ); }); }; } onMouseDown(event: MouseEvent) { this.addInvisibleOverlay(); this.pressed = true; this.lastDownX = event.clientX; this.startWidth = this.panel.scrollWidth; this.startLeft = this.panel.offsetLeft; this.panel.classList.add(CssClass.NoSelection); if (this.hoverable) { this.resizerColumn.classList.add(CssClass.Dragging); } } onMouseUp() { this.removeInvisibleOverlay(); if (!this.pressed) { return; } this.pressed = false; this.resizerColumn.classList.remove(CssClass.Dragging); this.panel.classList.remove(CssClass.NoSelection); const isMaxWidth = this.isAtMaxWidth(); if (this.onResizeFinish) { this.onResizeFinish()( this.lastWidth, this.lastLeft, isMaxWidth, this.isCollapsed() ); } this.finishSettingWidth(); } onMouseMove(event: MouseEvent) { if (!this.pressed) { return; } event.preventDefault(); if (this.property && this.property === PanelSide.Left) { this.handleLeftEvent(event); } else { this.handleWidthEvent(event); } } handleWidthEvent(event?: MouseEvent) { let x; if (event) { x = event!.clientX; } else { /** Coming from resize event */ x = 0; this.lastDownX = 0; } const deltaX = x - this.lastDownX; const newWidth = this.startWidth + deltaX; this.setWidth(newWidth, false); } handleLeftEvent(event: MouseEvent) { const panelRect = this.panel.getBoundingClientRect(); const x = event.clientX || panelRect.x; let deltaX = x - this.lastDownX; let newLeft = this.startLeft + deltaX; if (newLeft < 0) { newLeft = 0; deltaX = -this.startLeft; } const parentRect = this.getParentRect(); let newWidth = this.startWidth - deltaX; if (newWidth < this.currentMinWidth) { newWidth = this.currentMinWidth; } if (newWidth > parentRect.width) { newWidth = parentRect.width; } if (newLeft + newWidth > parentRect.width) { newLeft = parentRect.width - newWidth; } this.setLeft(newLeft); this.setWidth(newWidth, false); } isAtMaxWidth() { return ( Math.round(this.lastWidth + this.lastLeft) === Math.round(this.getParentRect().width) ); } isCollapsed() { return this.lastWidth <= this.currentMinWidth; } setWidth(width: number, finish = false) { if (width < this.currentMinWidth) { width = this.currentMinWidth; } const parentRect = this.getParentRect(); if (width > parentRect.width) { width = parentRect.width; } const maxWidth = this.appFrame!.width - this.panel.getBoundingClientRect().x; if (width > maxWidth) { width = maxWidth; } if (Math.round(width + this.lastLeft) === Math.round(parentRect.width)) { this.panel.style.width = `calc(100% - ${this.lastLeft}px)`; this.panel.style.flexBasis = `calc(100% - ${this.lastLeft}px)`; } else { this.panel.style.flexBasis = width + 'px'; this.panel.style.width = width + 'px'; } this.lastWidth = width; if (finish) { this.finishSettingWidth(); } } setLeft(left: number) { this.panel.style.left = left + 'px'; this.lastLeft = left; } finishSettingWidth() { if (!this.collapsable) { return; } this.collapsed = this.isCollapsed(); if (this.collapsed) { this.resizerColumn.classList.add(CssClass.Collapsed); } else { this.resizerColumn.classList.remove(CssClass.Collapsed); } } /** * If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe, * document[onmouseup] is not triggered because the document is no longer the same over * the iframe. We add an invisible overlay while resizing so that the mouse context * remains in our main document. */ addInvisibleOverlay() { if (this.overlay) { return; } this.overlay = this.$compile(`
`)(this as any); angular.element(document.body).prepend(this.overlay); } removeInvisibleOverlay() { if (this.overlay) { this.overlay.remove(); this.overlay = undefined; } } flash() { const FLASH_DURATION = 3000; this.resizerColumn.classList.add(CssClass.AnimateOpacity); this.$timeout(() => { this.resizerColumn.classList.remove(CssClass.AnimateOpacity); }, FLASH_DURATION); } } export class PanelResizer extends WebDirective { constructor() { super(); this.restrict = 'E'; this.template = template; this.controller = PanelResizerCtrl; this.controllerAs = 'ctrl'; this.bindToController = true; this.scope = { alwaysVisible: '=', collapsable: '=', control: '=', defaultWidth: '=', hoverable: '=', index: '=', minWidth: '=', onResizeFinish: '&', panelId: '=', property: '=' }; } }