1// Copyright (C) 2024 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16import {DisposableStack} from '../base/disposable_stack'; 17import {toHTMLElement} from '../base/dom_utils'; 18import {DragGestureHandler} from '../base/drag_gesture_handler'; 19import {assertExists, assertUnreachable} from '../base/logging'; 20import {Button, ButtonBar} from './button'; 21 22export enum SplitPanelDrawerVisibility { 23 VISIBLE, 24 FULLSCREEN, 25 COLLAPSED, 26} 27 28export interface SplitPanelAttrs { 29 // Content to display on the handle. 30 readonly handleContent?: m.Children; 31 32 // Content to display inside the drawer. 33 readonly drawerContent?: m.Children; 34 35 // Whether the drawer is currently visible or not (when in controlled mode). 36 readonly visibility?: SplitPanelDrawerVisibility; 37 38 // Extra classes applied to the root element. 39 readonly className?: string; 40 41 // What height should the drawer be initially? 42 readonly startingHeight?: number; 43 44 // Called when the drawer visibility is changed. 45 onVisibilityChange?(visibility: SplitPanelDrawerVisibility): void; 46} 47 48/** 49 * A container that fills its parent container, splitting into two adjustable 50 * horizontal sections. The upper half is reserved for the main content and any 51 * children are placed here, and the lower half should be considered a drawer, 52 * the `drawerContent` attribute can be used to define what goes here. 53 * 54 * The drawer features a handle that can be dragged to adjust the height of the 55 * drawer, and also features buttons to maximize and minimise the drawer. 56 * 57 * Content can also optionally be displayed on the handle itself to the left of 58 * the buttons. 59 * 60 * The layout looks like this: 61 * 62 * ┌──────────────────────────────────────────────────────────────────┐ 63 * │pf-split-panel │ 64 * │┌────────────────────────────────────────────────────────────────┐| 65 * ││pf-split-panel__main ││ 66 * |└────────────────────────────────────────────────────────────────┘| 67 * │┌────────────────────────────────────────────────────────────────┐| 68 * ││pf-split-panel__handle ││ 69 * │|┌─────────────────────────────────┐┌───────────────────────────┐|| 70 * |||pf-split-panel__handle-content ||pf-button-bar ||| 71 * ||└─────────────────────────────────┘└───────────────────────────┘|| 72 * |└────────────────────────────────────────────────────────────────┘| 73 * │┌────────────────────────────────────────────────────────────────┐| 74 * ││pf-split-panel__drawer ││ 75 * |└────────────────────────────────────────────────────────────────┘| 76 * └──────────────────────────────────────────────────────────────────┘ 77 */ 78export class SplitPanel implements m.ClassComponent<SplitPanelAttrs> { 79 private readonly trash = new DisposableStack(); 80 81 // The actual height of the vdom node. It matches resizableHeight if VISIBLE, 82 // 0 if COLLAPSED, fullscreenHeight if FULLSCREEN. 83 private height = 0; 84 85 // The height when the panel is 'VISIBLE'. 86 private resizableHeight: number; 87 88 // The height when the panel is 'FULLSCREEN'. 89 private fullscreenHeight = 0; 90 91 // Current visibility state (if not controlled). 92 private visibility = SplitPanelDrawerVisibility.VISIBLE; 93 94 constructor({attrs}: m.CVnode<SplitPanelAttrs>) { 95 this.resizableHeight = attrs.startingHeight ?? 100; 96 } 97 98 view({attrs, children}: m.CVnode<SplitPanelAttrs>) { 99 const { 100 visibility = this.visibility, 101 className, 102 handleContent, 103 onVisibilityChange, 104 drawerContent, 105 } = attrs; 106 107 switch (visibility) { 108 case SplitPanelDrawerVisibility.VISIBLE: 109 this.height = Math.min( 110 Math.max(this.resizableHeight, 0), 111 this.fullscreenHeight, 112 ); 113 break; 114 case SplitPanelDrawerVisibility.FULLSCREEN: 115 this.height = this.fullscreenHeight; 116 break; 117 case SplitPanelDrawerVisibility.COLLAPSED: 118 this.height = 0; 119 break; 120 } 121 122 return m( 123 '.pf-split-panel', 124 { 125 className, 126 }, 127 // Note: Using BEM class naming conventions: See https://getbem.com/ 128 m('.pf-split-panel__main', children), 129 m( 130 '.pf-split-panel__handle', 131 m('.pf-split-panel__handle-content', handleContent), 132 this.renderTabResizeButtons(visibility, onVisibilityChange), 133 ), 134 m( 135 '.pf-split-panel__drawer', 136 { 137 style: {height: `${this.height}px`}, 138 }, 139 drawerContent, 140 ), 141 ); 142 } 143 144 oncreate(vnode: m.VnodeDOM<SplitPanelAttrs, this>) { 145 let dragStartY = 0; 146 let heightWhenDragStarted = 0; 147 148 const handle = toHTMLElement( 149 assertExists(vnode.dom.querySelector('.pf-split-panel__handle')), 150 ); 151 152 this.trash.use( 153 new DragGestureHandler( 154 handle, 155 /* onDrag */ (_x, y) => { 156 const deltaYSinceDragStart = dragStartY - y; 157 this.resizableHeight = heightWhenDragStarted + deltaYSinceDragStart; 158 m.redraw(); 159 }, 160 /* onDragStarted */ (_x, y) => { 161 this.resizableHeight = this.height; 162 heightWhenDragStarted = this.height; 163 dragStartY = y; 164 this.updatePanelVisibility( 165 SplitPanelDrawerVisibility.VISIBLE, 166 vnode.attrs.onVisibilityChange, 167 ); 168 }, 169 /* onDragFinished */ () => {}, 170 ), 171 ); 172 173 const parent = assertExists(vnode.dom.parentElement); 174 this.fullscreenHeight = parent.clientHeight; 175 const resizeObs = new ResizeObserver(() => { 176 this.fullscreenHeight = parent.clientHeight; 177 m.redraw(); 178 }); 179 resizeObs.observe(parent); 180 this.trash.defer(() => resizeObs.disconnect()); 181 } 182 183 onremove() { 184 this.trash.dispose(); 185 } 186 187 private renderTabResizeButtons( 188 visibility: SplitPanelDrawerVisibility, 189 setVisibility?: (visibility: SplitPanelDrawerVisibility) => void, 190 ): m.Child { 191 const isClosed = visibility === SplitPanelDrawerVisibility.COLLAPSED; 192 return m( 193 ButtonBar, 194 m(Button, { 195 title: 'Open fullscreen', 196 disabled: visibility === SplitPanelDrawerVisibility.FULLSCREEN, 197 icon: 'vertical_align_top', 198 compact: true, 199 onclick: () => { 200 this.updatePanelVisibility( 201 SplitPanelDrawerVisibility.FULLSCREEN, 202 setVisibility, 203 ); 204 }, 205 }), 206 m(Button, { 207 onclick: () => { 208 this.updatePanelVisibility( 209 toggleVisibility(visibility), 210 setVisibility, 211 ); 212 }, 213 title: isClosed ? 'Show panel' : 'Hide panel', 214 icon: isClosed ? 'keyboard_arrow_up' : 'keyboard_arrow_down', 215 compact: true, 216 }), 217 ); 218 } 219 220 private updatePanelVisibility( 221 visibility: SplitPanelDrawerVisibility, 222 setVisibility?: (visibility: SplitPanelDrawerVisibility) => void, 223 ) { 224 this.visibility = visibility; 225 setVisibility?.(visibility); 226 } 227} 228 229export function toggleVisibility(visibility: SplitPanelDrawerVisibility) { 230 switch (visibility) { 231 case SplitPanelDrawerVisibility.COLLAPSED: 232 case SplitPanelDrawerVisibility.FULLSCREEN: 233 return SplitPanelDrawerVisibility.VISIBLE; 234 case SplitPanelDrawerVisibility.VISIBLE: 235 return SplitPanelDrawerVisibility.COLLAPSED; 236 default: 237 assertUnreachable(visibility); 238 } 239} 240