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. 14import m from 'mithril'; 15import {DEFAULT_DETAILS_CONTENT_HEIGHT} from '../../frontend/css_constants'; 16import {DisposableStack} from '../../base/disposable_stack'; 17import {DragGestureHandler} from '../../base/drag_gesture_handler'; 18import {raf} from '../../core/raf_scheduler'; 19import {assertExists} from '../../base/logging'; 20import {Button} from '../../widgets/button'; 21import {toHTMLElement} from '../../base/dom_utils'; 22 23export enum CollapsiblePanelVisibility { 24 VISIBLE, 25 FULLSCREEN, 26 COLLAPSED, 27} 28 29export interface CollapsiblePanelAttrs { 30 visibility: CollapsiblePanelVisibility; 31 setVisibility: (visibility: CollapsiblePanelVisibility) => void; 32 headerActions?: m.Children; 33 tabs?: m.Children; 34} 35 36export class CollapsiblePanel 37 implements m.ClassComponent<CollapsiblePanelAttrs> 38{ 39 // The actual height of the vdom node. It matches resizableHeight if VISIBLE, 40 // 0 if COLLAPSED, fullscreenHeight if FULLSCREEN. 41 private height = 0; 42 43 // The height when the panel is 'VISIBLE'. 44 private resizableHeight = getDefaultDetailsHeight(); 45 46 // The height when the panel is 'FULLSCREEN'. 47 private fullscreenHeight = 0; 48 49 private trash = new DisposableStack(); 50 51 view({attrs}: m.CVnode<CollapsiblePanelAttrs>) { 52 switch (attrs.visibility) { 53 case CollapsiblePanelVisibility.VISIBLE: 54 this.height = Math.min( 55 Math.max(this.resizableHeight, 0), 56 this.fullscreenHeight, 57 ); 58 break; 59 case CollapsiblePanelVisibility.FULLSCREEN: 60 this.height = this.fullscreenHeight; 61 break; 62 case CollapsiblePanelVisibility.COLLAPSED: 63 this.height = 0; 64 break; 65 } 66 67 return m( 68 '.collapsible-panel', 69 m( 70 '.handle', 71 attrs.headerActions, 72 this.renderTabResizeButtons(attrs.visibility, attrs.setVisibility), 73 ), 74 m( 75 '.details-panel-container', 76 { 77 style: {height: `${this.height}px`}, 78 }, 79 attrs.tabs, 80 ), 81 ); 82 } 83 84 updatePanelVisibility( 85 visibility: CollapsiblePanelVisibility, 86 setVisibility: (visibility: CollapsiblePanelVisibility) => void, 87 ) { 88 setVisibility(visibility); 89 raf.scheduleFullRedraw(); 90 } 91 92 oncreate(vnode: m.VnodeDOM<CollapsiblePanelAttrs, this>) { 93 let dragStartY = 0; 94 let heightWhenDragStarted = 0; 95 96 const handle = toHTMLElement( 97 assertExists(vnode.dom.querySelector('.handle')), 98 ); 99 100 this.trash.use( 101 new DragGestureHandler( 102 handle, 103 /* onDrag */ (_x, y) => { 104 const deltaYSinceDragStart = dragStartY - y; 105 this.resizableHeight = heightWhenDragStarted + deltaYSinceDragStart; 106 raf.scheduleFullRedraw('force'); 107 }, 108 /* onDragStarted */ (_x, y) => { 109 this.resizableHeight = this.height; 110 heightWhenDragStarted = this.height; 111 dragStartY = y; 112 vnode.attrs.setVisibility(CollapsiblePanelVisibility.VISIBLE); 113 }, 114 /* onDragFinished */ () => {}, 115 ), 116 ); 117 118 const page = assertExists(vnode.dom.parentElement); 119 this.fullscreenHeight = page.clientHeight; 120 const resizeObs = new ResizeObserver(() => { 121 this.fullscreenHeight = page.clientHeight; 122 raf.scheduleFullRedraw(); 123 }); 124 resizeObs.observe(page); 125 this.trash.defer(() => resizeObs.disconnect()); 126 } 127 128 onremove() { 129 this.trash.dispose(); 130 } 131 132 private renderTabResizeButtons( 133 visibility: CollapsiblePanelVisibility, 134 setVisibility: (visibility: CollapsiblePanelVisibility) => void, 135 ): m.Child { 136 const isClosed = visibility === CollapsiblePanelVisibility.COLLAPSED; 137 return m( 138 '.buttons', 139 m(Button, { 140 title: 'Open fullscreen', 141 disabled: visibility === CollapsiblePanelVisibility.FULLSCREEN, 142 icon: 'vertical_align_top', 143 compact: true, 144 onclick: () => { 145 this.updatePanelVisibility( 146 CollapsiblePanelVisibility.FULLSCREEN, 147 setVisibility, 148 ); 149 }, 150 }), 151 m(Button, { 152 onclick: () => { 153 toggleVisibility(visibility, setVisibility); 154 }, 155 title: isClosed ? 'Show panel' : 'Hide panel', 156 icon: isClosed ? 'keyboard_arrow_up' : 'keyboard_arrow_down', 157 compact: true, 158 }), 159 ); 160 } 161} 162 163export function toggleVisibility( 164 visibility: CollapsiblePanelVisibility, 165 setVisibility: (visibility: CollapsiblePanelVisibility) => void, 166) { 167 switch (visibility) { 168 case CollapsiblePanelVisibility.COLLAPSED: 169 case CollapsiblePanelVisibility.FULLSCREEN: 170 setVisibility(CollapsiblePanelVisibility.VISIBLE); 171 break; 172 case CollapsiblePanelVisibility.VISIBLE: 173 setVisibility(CollapsiblePanelVisibility.COLLAPSED); 174 break; 175 } 176 177 raf.scheduleFullRedraw(); 178} 179 180function getDefaultDetailsHeight() { 181 const DRAG_HANDLE_HEIGHT_PX = 28; 182 // This needs to be a function instead of a const to ensure the CSS constants 183 // have been initialized by the time we perform this calculation; 184 return DRAG_HANDLE_HEIGHT_PX + DEFAULT_DETAILS_CONTENT_HEIGHT; 185} 186