// Copyright (C) 2024 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import m from 'mithril'; import {DisposableStack} from '../base/disposable_stack'; import {toHTMLElement} from '../base/dom_utils'; import {DragGestureHandler} from '../base/drag_gesture_handler'; import {assertExists, assertUnreachable} from '../base/logging'; import {Button, ButtonBar} from './button'; export enum SplitPanelDrawerVisibility { VISIBLE, FULLSCREEN, COLLAPSED, } export interface SplitPanelAttrs { // Content to display on the handle. readonly handleContent?: m.Children; // Content to display inside the drawer. readonly drawerContent?: m.Children; // Whether the drawer is currently visible or not (when in controlled mode). readonly visibility?: SplitPanelDrawerVisibility; // Extra classes applied to the root element. readonly className?: string; // What height should the drawer be initially? readonly startingHeight?: number; // Called when the drawer visibility is changed. onVisibilityChange?(visibility: SplitPanelDrawerVisibility): void; } /** * A container that fills its parent container, splitting into two adjustable * horizontal sections. The upper half is reserved for the main content and any * children are placed here, and the lower half should be considered a drawer, * the `drawerContent` attribute can be used to define what goes here. * * The drawer features a handle that can be dragged to adjust the height of the * drawer, and also features buttons to maximize and minimise the drawer. * * Content can also optionally be displayed on the handle itself to the left of * the buttons. * * The layout looks like this: * * ┌──────────────────────────────────────────────────────────────────┐ * │pf-split-panel │ * │┌────────────────────────────────────────────────────────────────┐| * ││pf-split-panel__main ││ * |└────────────────────────────────────────────────────────────────┘| * │┌────────────────────────────────────────────────────────────────┐| * ││pf-split-panel__handle ││ * │|┌─────────────────────────────────┐┌───────────────────────────┐|| * |||pf-split-panel__handle-content ||pf-button-bar ||| * ||└─────────────────────────────────┘└───────────────────────────┘|| * |└────────────────────────────────────────────────────────────────┘| * │┌────────────────────────────────────────────────────────────────┐| * ││pf-split-panel__drawer ││ * |└────────────────────────────────────────────────────────────────┘| * └──────────────────────────────────────────────────────────────────┘ */ export class SplitPanel implements m.ClassComponent { private readonly trash = new DisposableStack(); // The actual height of the vdom node. It matches resizableHeight if VISIBLE, // 0 if COLLAPSED, fullscreenHeight if FULLSCREEN. private height = 0; // The height when the panel is 'VISIBLE'. private resizableHeight: number; // The height when the panel is 'FULLSCREEN'. private fullscreenHeight = 0; // Current visibility state (if not controlled). private visibility = SplitPanelDrawerVisibility.VISIBLE; constructor({attrs}: m.CVnode) { this.resizableHeight = attrs.startingHeight ?? 100; } view({attrs, children}: m.CVnode) { const { visibility = this.visibility, className, handleContent, onVisibilityChange, drawerContent, } = attrs; switch (visibility) { case SplitPanelDrawerVisibility.VISIBLE: this.height = Math.min( Math.max(this.resizableHeight, 0), this.fullscreenHeight, ); break; case SplitPanelDrawerVisibility.FULLSCREEN: this.height = this.fullscreenHeight; break; case SplitPanelDrawerVisibility.COLLAPSED: this.height = 0; break; } return m( '.pf-split-panel', { className, }, // Note: Using BEM class naming conventions: See https://getbem.com/ m('.pf-split-panel__main', children), m( '.pf-split-panel__handle', m('.pf-split-panel__handle-content', handleContent), this.renderTabResizeButtons(visibility, onVisibilityChange), ), m( '.pf-split-panel__drawer', { style: {height: `${this.height}px`}, }, drawerContent, ), ); } oncreate(vnode: m.VnodeDOM) { let dragStartY = 0; let heightWhenDragStarted = 0; const handle = toHTMLElement( assertExists(vnode.dom.querySelector('.pf-split-panel__handle')), ); this.trash.use( new DragGestureHandler( handle, /* onDrag */ (_x, y) => { const deltaYSinceDragStart = dragStartY - y; this.resizableHeight = heightWhenDragStarted + deltaYSinceDragStart; m.redraw(); }, /* onDragStarted */ (_x, y) => { this.resizableHeight = this.height; heightWhenDragStarted = this.height; dragStartY = y; this.updatePanelVisibility( SplitPanelDrawerVisibility.VISIBLE, vnode.attrs.onVisibilityChange, ); }, /* onDragFinished */ () => {}, ), ); const parent = assertExists(vnode.dom.parentElement); this.fullscreenHeight = parent.clientHeight; const resizeObs = new ResizeObserver(() => { this.fullscreenHeight = parent.clientHeight; m.redraw(); }); resizeObs.observe(parent); this.trash.defer(() => resizeObs.disconnect()); } onremove() { this.trash.dispose(); } private renderTabResizeButtons( visibility: SplitPanelDrawerVisibility, setVisibility?: (visibility: SplitPanelDrawerVisibility) => void, ): m.Child { const isClosed = visibility === SplitPanelDrawerVisibility.COLLAPSED; return m( ButtonBar, m(Button, { title: 'Open fullscreen', disabled: visibility === SplitPanelDrawerVisibility.FULLSCREEN, icon: 'vertical_align_top', compact: true, onclick: () => { this.updatePanelVisibility( SplitPanelDrawerVisibility.FULLSCREEN, setVisibility, ); }, }), m(Button, { onclick: () => { this.updatePanelVisibility( toggleVisibility(visibility), setVisibility, ); }, title: isClosed ? 'Show panel' : 'Hide panel', icon: isClosed ? 'keyboard_arrow_up' : 'keyboard_arrow_down', compact: true, }), ); } private updatePanelVisibility( visibility: SplitPanelDrawerVisibility, setVisibility?: (visibility: SplitPanelDrawerVisibility) => void, ) { this.visibility = visibility; setVisibility?.(visibility); } } export function toggleVisibility(visibility: SplitPanelDrawerVisibility) { switch (visibility) { case SplitPanelDrawerVisibility.COLLAPSED: case SplitPanelDrawerVisibility.FULLSCREEN: return SplitPanelDrawerVisibility.VISIBLE; case SplitPanelDrawerVisibility.VISIBLE: return SplitPanelDrawerVisibility.COLLAPSED; default: assertUnreachable(visibility); } }