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 {classNames} from '../base/classnames'; 17import {currentTargetOffset} from '../base/dom_utils'; 18import {Bounds2D, Point2D, Vector2D} from '../base/geom'; 19import {Icons} from '../base/semantic_icons'; 20import {ButtonBar} from './button'; 21import {Chip, ChipBar} from './chip'; 22import {Icon} from './icon'; 23import {MiddleEllipsis} from './middle_ellipsis'; 24import {clamp} from '../base/math_utils'; 25 26/** 27 * The TrackWidget defines the look and style of a track. 28 * 29 * ┌──────────────────────────────────────────────────────────────────┐ 30 * │pf-track (grid) │ 31 * │┌─────────────────────────────────────────┐┌─────────────────────┐│ 32 * ││pf-track-shell ││pf-track-content ││ 33 * ││┌───────────────────────────────────────┐││ ││ 34 * │││pf-track-menubar (sticky) │││ ││ 35 * │││┌───────────────┐┌────────────────────┐│││ ││ 36 * ││││pf-track-title ││pf-track-buttons ││││ ││ 37 * │││└───────────────┘└────────────────────┘│││ ││ 38 * ││└───────────────────────────────────────┘││ ││ 39 * │└─────────────────────────────────────────┘└─────────────────────┘│ 40 * └──────────────────────────────────────────────────────────────────┘ 41 */ 42 43export interface TrackComponentAttrs { 44 // The title of this track. 45 readonly title: string; 46 47 // The full path to this track. 48 readonly path?: string; 49 50 // Show dropdown arrow and make clickable. Defaults to false. 51 readonly collapsible?: boolean; 52 53 // Show an up or down dropdown arrow. 54 readonly collapsed: boolean; 55 56 // Height of the track in pixels. All tracks have a fixed height. 57 readonly heightPx: number; 58 59 // Optional buttons to place on the RHS of the track shell. 60 readonly buttons?: m.Children; 61 62 // Optional list of chips to display after the track title. 63 readonly chips?: ReadonlyArray<string>; 64 65 // Render this track in error colours. 66 readonly error?: boolean; 67 68 // The integer indentation level of this track. If omitted, defaults to 0. 69 readonly indentationLevel?: number; 70 71 // Track titles are sticky. This is the offset in pixels from the top of the 72 // scrolling parent. Defaults to 0. 73 readonly topOffsetPx?: number; 74 75 // Issues a scrollTo() on this DOM element at creation time. Default: false. 76 readonly revealOnCreate?: boolean; 77 78 // Called when arrow clicked. 79 readonly onToggleCollapsed?: () => void; 80 81 // Style the component differently if it has children. 82 readonly isSummary?: boolean; 83 84 // HTML id applied to the root element. 85 readonly id: string; 86 87 // Whether to highlight the track or not. 88 readonly highlight?: boolean; 89 90 // Whether the shell should be draggable and emit drag/drop events. 91 readonly reorderable?: boolean; 92 93 // Mouse events. 94 readonly onTrackContentMouseMove?: ( 95 pos: Point2D, 96 contentSize: Bounds2D, 97 ) => void; 98 readonly onTrackContentMouseOut?: () => void; 99 readonly onTrackContentClick?: ( 100 pos: Point2D, 101 contentSize: Bounds2D, 102 ) => boolean; 103 104 // If reorderable, these functions will be called when track shells are 105 // dragged and dropped. 106 readonly onMoveBefore?: (nodeId: string) => void; 107 readonly onMoveAfter?: (nodeId: string) => void; 108} 109 110const TRACK_HEIGHT_MIN_PX = 18; 111const INDENTATION_LEVEL_MAX = 16; 112 113export class TrackWidget implements m.ClassComponent<TrackComponentAttrs> { 114 view({attrs}: m.CVnode<TrackComponentAttrs>) { 115 const { 116 indentationLevel = 0, 117 collapsible, 118 collapsed, 119 highlight, 120 heightPx, 121 id, 122 isSummary, 123 } = attrs; 124 125 const trackHeight = Math.max(heightPx, TRACK_HEIGHT_MIN_PX); 126 const expanded = collapsible && !collapsed; 127 128 return m( 129 '.pf-track', 130 { 131 id, 132 className: classNames( 133 expanded && 'pf-expanded', 134 highlight && 'pf-highlight', 135 isSummary && 'pf-is-summary', 136 ), 137 style: { 138 // Note: Sub-pixel track heights can mess with sticky elements. 139 // Round up to the nearest integer number of pixels. 140 '--indent': clamp(indentationLevel, 0, INDENTATION_LEVEL_MAX), 141 'height': `${Math.ceil(trackHeight)}px`, 142 }, 143 }, 144 this.renderShell(attrs), 145 this.renderContent(attrs), 146 ); 147 } 148 149 oncreate(vnode: m.VnodeDOM<TrackComponentAttrs>) { 150 this.onupdate(vnode); 151 152 if (vnode.attrs.revealOnCreate) { 153 vnode.dom.scrollIntoView({behavior: 'smooth', block: 'nearest'}); 154 } 155 } 156 157 onupdate(vnode: m.VnodeDOM<TrackComponentAttrs>) { 158 this.decidePopupRequired(vnode.dom); 159 } 160 161 // Works out whether to display a title popup on hover, based on whether the 162 // current title is truncated. 163 private decidePopupRequired(dom: Element) { 164 const popupTitleElement = dom.querySelector( 165 '.pf-track-title-popup', 166 ) as HTMLElement; 167 const truncatedTitleElement = dom.querySelector( 168 '.pf-middle-ellipsis', 169 ) as HTMLElement; 170 171 if (popupTitleElement.clientWidth > truncatedTitleElement.clientWidth) { 172 popupTitleElement.classList.add('pf-visible'); 173 } else { 174 popupTitleElement.classList.remove('pf-visible'); 175 } 176 } 177 178 private renderShell(attrs: TrackComponentAttrs): m.Children { 179 const chips = 180 attrs.chips && 181 m( 182 ChipBar, 183 attrs.chips.map((chip) => 184 m(Chip, {label: chip, compact: true, rounded: true}), 185 ), 186 ); 187 188 const { 189 id, 190 topOffsetPx = 0, 191 collapsible, 192 collapsed, 193 reorderable = false, 194 onMoveAfter = () => {}, 195 onMoveBefore = () => {}, 196 } = attrs; 197 198 return m( 199 `.pf-track-shell[data-track-node-id=${id}]`, 200 { 201 className: classNames(collapsible && 'pf-clickable'), 202 onclick: (e: MouseEvent) => { 203 // Block all clicks on the shell from propagating through to the 204 // canvas 205 e.stopPropagation(); 206 if (collapsible) { 207 attrs.onToggleCollapsed?.(); 208 } 209 }, 210 draggable: reorderable, 211 ondragstart: (e: DragEvent) => { 212 e.dataTransfer?.setData('text/plain', id); 213 }, 214 ondragover: (e: DragEvent) => { 215 if (!reorderable) { 216 return; 217 } 218 const target = e.currentTarget as HTMLElement; 219 const threshold = target.offsetHeight / 2; 220 if (e.offsetY > threshold) { 221 target.classList.remove('pf-drag-before'); 222 target.classList.add('pf-drag-after'); 223 } else { 224 target.classList.remove('pf-drag-after'); 225 target.classList.add('pf-drag-before'); 226 } 227 }, 228 ondragleave: (e: DragEvent) => { 229 if (!reorderable) { 230 return; 231 } 232 const target = e.currentTarget as HTMLElement; 233 const related = e.relatedTarget as HTMLElement | null; 234 if (related && !target.contains(related)) { 235 target.classList.remove('pf-drag-after'); 236 target.classList.remove('pf-drag-before'); 237 } 238 }, 239 ondrop: (e: DragEvent) => { 240 if (!reorderable) { 241 return; 242 } 243 const id = e.dataTransfer?.getData('text/plain'); 244 const target = e.currentTarget as HTMLElement; 245 const threshold = target.offsetHeight / 2; 246 if (id !== undefined) { 247 if (e.offsetY > threshold) { 248 onMoveAfter(id); 249 } else { 250 onMoveBefore(id); 251 } 252 } 253 target.classList.remove('pf-drag-after'); 254 target.classList.remove('pf-drag-before'); 255 }, 256 }, 257 m( 258 '.pf-track-menubar', 259 { 260 style: { 261 position: 'sticky', 262 top: `${topOffsetPx}px`, 263 }, 264 }, 265 m( 266 'h1.pf-track-title', 267 { 268 ref: attrs.path, // TODO(stevegolton): Replace with aria tags? 269 }, 270 collapsible && 271 m(Icon, {icon: collapsed ? Icons.ExpandDown : Icons.ExpandUp}), 272 m( 273 MiddleEllipsis, 274 {text: attrs.title}, 275 m('.pf-track-title-popup', attrs.title), 276 ), 277 chips, 278 ), 279 m( 280 ButtonBar, 281 { 282 className: 'pf-track-buttons', 283 // Block button clicks from hitting the shell's on click event 284 onclick: (e: MouseEvent) => e.stopPropagation(), 285 }, 286 attrs.buttons, 287 ), 288 ), 289 ); 290 } 291 292 private mouseDownPos?: Vector2D; 293 private selectionOccurred = false; 294 295 private renderContent(attrs: TrackComponentAttrs): m.Children { 296 const { 297 heightPx, 298 onTrackContentMouseMove, 299 onTrackContentMouseOut, 300 onTrackContentClick, 301 } = attrs; 302 const trackHeight = Math.max(heightPx, TRACK_HEIGHT_MIN_PX); 303 304 return m('.pf-track-content', { 305 style: { 306 height: `${trackHeight}px`, 307 }, 308 className: classNames(attrs.error && 'pf-track-content-error'), 309 onmousemove: (e: MouseEvent) => { 310 onTrackContentMouseMove?.( 311 currentTargetOffset(e), 312 getTargetContainerSize(e), 313 ); 314 }, 315 onmouseout: () => { 316 onTrackContentMouseOut?.(); 317 }, 318 onmousedown: (e: MouseEvent) => { 319 this.mouseDownPos = currentTargetOffset(e); 320 }, 321 onmouseup: (e: MouseEvent) => { 322 if (!this.mouseDownPos) return; 323 if ( 324 this.mouseDownPos.sub(currentTargetOffset(e)).manhattanDistance > 1 325 ) { 326 this.selectionOccurred = true; 327 } 328 this.mouseDownPos = undefined; 329 }, 330 onclick: (e: MouseEvent) => { 331 // This click event occurs after any selection mouse up/drag events 332 // so we have to look if the mouse moved during this click to know 333 // if a selection occurred. 334 if (this.selectionOccurred) { 335 this.selectionOccurred = false; 336 return; 337 } 338 339 // Returns true if something was selected, so stop propagation. 340 if ( 341 onTrackContentClick?.( 342 currentTargetOffset(e), 343 getTargetContainerSize(e), 344 ) 345 ) { 346 e.stopPropagation(); 347 } 348 }, 349 }); 350 } 351} 352 353function getTargetContainerSize(event: MouseEvent): Bounds2D { 354 const target = event.target as HTMLElement; 355 return target.getBoundingClientRect(); 356} 357