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) } } override 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 }) } } override 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 (
) } }