import { Component, createRef } from 'preact'; import { debounce } from '@/utils'; export type ResizeFinishCallback = ( lastWidth: number, lastLeft: number, isMaxWidth: boolean, isCollapsed: boolean ) => void; export enum PanelSide { Right = 'right', Left = 'left', } export enum PanelResizeType { WidthOnly = 'WidthOnly', OffsetAndWidth = 'OffsetAndWidth', } type Props = { width: number; left: number; alwaysVisible?: boolean; collapsable?: boolean; defaultWidth?: number; hoverable?: boolean; minWidth?: number; panel: HTMLDivElement; side: PanelSide; type: PanelResizeType; resizeFinishCallback?: ResizeFinishCallback; widthEventCallback?: () => void; }; type State = { collapsed: boolean; pressed: boolean; }; export class PanelResizer extends Component { private overlay?: HTMLDivElement; private resizerElementRef = createRef(); private debouncedResizeHandler: () => void; private startLeft: number; private startWidth: number; private lastDownX: number; private lastLeft: number; private lastWidth: number; private widthBeforeLastDblClick: number; private minWidth: number; constructor(props: Props) { super(props); this.state = { collapsed: false, pressed: false, }; this.minWidth = props.minWidth || 5; this.startLeft = props.panel.offsetLeft; this.startWidth = props.panel.scrollWidth; this.lastDownX = 0; this.lastLeft = props.panel.offsetLeft; this.lastWidth = props.panel.scrollWidth; this.widthBeforeLastDblClick = 0; this.setWidth(this.props.width); this.setLeft(this.props.left); document.addEventListener('mouseup', this.onMouseUp); document.addEventListener('mousemove', this.onMouseMove); this.debouncedResizeHandler = debounce(this.handleResize, 250); if (this.props.type === PanelResizeType.OffsetAndWidth) { window.addEventListener('resize', this.debouncedResizeHandler); } } componentDidUpdate(prevProps: Props) { if (this.props.width != prevProps.width) { this.setWidth(this.props.width); } if (this.props.left !== prevProps.left) { this.setLeft(this.props.left); this.setWidth(this.props.width); } const isCollapsed = this.isCollapsed(); if (isCollapsed !== this.state.collapsed) { this.setState({ collapsed: isCollapsed }); } } componentWillUnmount() { document.removeEventListener('mouseup', this.onMouseUp); document.removeEventListener('mousemove', this.onMouseMove); window.removeEventListener('resize', this.debouncedResizeHandler); } get appFrame() { return document.getElementById('app')?.getBoundingClientRect() as DOMRect; } getParentRect() { return (this.props.panel.parentNode as HTMLElement).getBoundingClientRect(); } isAtMaxWidth = () => { const marginOfError = 5; const difference = Math.abs( Math.round(this.lastWidth + this.lastLeft) - Math.round(this.getParentRect().width) ); return difference < marginOfError; }; isCollapsed() { return this.lastWidth <= this.minWidth; } finishSettingWidth = () => { if (!this.props.collapsable) { return; } this.setState({ collapsed: this.isCollapsed(), }); }; setWidth = (width: number, finish = false): void => { if (width === 0) { width = this.computeMaxWidth(); } if (width < this.minWidth) { width = this.minWidth; } const parentRect = this.getParentRect(); if (width > parentRect.width) { width = parentRect.width; } const maxWidth = this.appFrame.width - this.props.panel.getBoundingClientRect().x; if (width > maxWidth) { width = maxWidth; } const isFullWidth = Math.round(width + this.lastLeft) === Math.round(parentRect.width); if (isFullWidth) { if (this.props.type === PanelResizeType.WidthOnly) { this.props.panel.style.removeProperty('width'); } else { this.props.panel.style.width = `calc(100% - ${this.lastLeft}px)`; } } else { this.props.panel.style.width = width + 'px'; } this.lastWidth = width; if (finish) { this.finishSettingWidth(); if (this.props.resizeFinishCallback) { this.props.resizeFinishCallback( this.lastWidth, this.lastLeft, this.isAtMaxWidth(), this.isCollapsed() ); } } }; setLeft = (left: number) => { this.props.panel.style.left = left + 'px'; this.lastLeft = left; }; onDblClick = () => { const collapsed = this.isCollapsed(); if (collapsed) { this.setWidth( this.widthBeforeLastDblClick || this.props.defaultWidth || 0 ); } else { this.widthBeforeLastDblClick = this.lastWidth; this.setWidth(this.minWidth); } this.finishSettingWidth(); this.props.resizeFinishCallback?.( this.lastWidth, this.lastLeft, this.isAtMaxWidth(), this.isCollapsed() ); }; handleWidthEvent(event?: MouseEvent) { if (this.props.widthEventCallback) { this.props.widthEventCallback(); } 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.props.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.minWidth) { newWidth = this.minWidth; } if (newWidth > parentRect.width) { newWidth = parentRect.width; } if (newLeft + newWidth > parentRect.width) { newLeft = parentRect.width - newWidth; } this.setLeft(newLeft); this.setWidth(newWidth, false); } computeMaxWidth(): number { const parentRect = this.getParentRect(); let width = parentRect.width - this.props.left; if (width < this.minWidth) { width = this.minWidth; } return width; } handleResize = () => { const startWidth = this.isAtMaxWidth() ? this.computeMaxWidth() : this.props.panel.scrollWidth; this.startWidth = startWidth; this.lastWidth = startWidth; this.handleWidthEvent(); this.finishSettingWidth(); }; onMouseDown = (event: MouseEvent) => { this.addInvisibleOverlay(); this.lastDownX = event.clientX; this.startWidth = this.props.panel.scrollWidth; this.startLeft = this.props.panel.offsetLeft; this.setState({ pressed: true, }); }; onMouseUp = () => { this.removeInvisibleOverlay(); if (!this.state.pressed) { return; } this.setState({ pressed: false }); const isMaxWidth = this.isAtMaxWidth(); if (this.props.resizeFinishCallback) { this.props.resizeFinishCallback( this.lastWidth, this.lastLeft, isMaxWidth, this.isCollapsed() ); } this.finishSettingWidth(); }; onMouseMove = (event: MouseEvent) => { if (!this.state.pressed) { return; } event.preventDefault(); if (this.props.side === PanelSide.Left) { this.handleLeftEvent(event); } else { this.handleWidthEvent(event); } }; /** * 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; } const overlayElement = document.createElement('div'); overlayElement.id = 'resizer-overlay'; this.overlay = overlayElement; document.body.prepend(this.overlay); }; removeInvisibleOverlay = () => { if (this.overlay) { this.overlay.remove(); this.overlay = undefined; } }; render() { return (
); } }