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 {DisposableStack} from '../base/disposable_stack'; 18import {currentTargetOffset} from '../base/dom_utils'; 19import {Bounds2D, Point2D, Vector2D} from '../base/geom'; 20import {assertExists} from '../base/logging'; 21import {clamp} from '../base/math_utils'; 22import {hasChildren, MithrilEvent} from '../base/mithril_utils'; 23import {Icons} from '../base/semantic_icons'; 24import {Button, ButtonBar} from './button'; 25import {Chip, ChipBar} from './chip'; 26import {HTMLAttrs, Intent} from './common'; 27import {MiddleEllipsis} from './middle_ellipsis'; 28import {Popup} from './popup'; 29 30/** 31 * This component defines the look and style of the DOM parts of a track (mainly 32 * the 'shell' part). 33 * 34 * ┌───────────────────────────────────────────────────────────────────────────┐ 35 * │ pf-track │ 36 * |┌─────────────────────────────────────────────────────────────────────────┐| 37 * || pf-track__header || 38 * │|┌─────────┐┌─────────────────────────────────────────┐┌─────────────────┐│| 39 * │|│::before ||pf-track__shell ││pf-track__content││| 40 * │|│(Indent) ||┌───────────────────────────────────────┐││ ││| 41 * │|│ ||│pf-track__menubar (sticky) │││ ││| 42 * │|│ ||│┌───────────────┐┌────────────────────┐│││ ││| 43 * │|│ ||││pf-track__title││pf-track__buttons ││││ ││| 44 * │|│ ||│└───────────────┘└────────────────────┘│││ ││| 45 * │|│ ||└───────────────────────────────────────┘││ ││| 46 * │|└─────────┘└─────────────────────────────────────────┘└─────────────────┘│| 47 * |└─────────────────────────────────────────────────────────────────────────┘| 48 * |┌─────────────────────────────────────────────────────────────────────────┐| 49 * || pf-track__children (if children supplied) || 50 * |└─────────────────────────────────────────────────────────────────────────┘| 51 * └───────────────────────────────────────────────────────────────────────────┘ 52 */ 53 54export interface TrackShellAttrs extends HTMLAttrs { 55 // The title of this track. 56 readonly title: string; 57 58 // Optional subtitle to display underneath the track name. 59 readonly subtitle?: string; 60 61 // Show dropdown arrow and make clickable. Defaults to false. 62 readonly collapsible?: boolean; 63 64 // Show an up or down dropdown arrow. 65 readonly collapsed: boolean; 66 67 // Height of the track in pixels. All tracks have a fixed height. 68 readonly heightPx: number; 69 70 // Optional buttons to place on the RHS of the track shell. 71 readonly buttons?: m.Children; 72 73 // Optional list of chips to display after the track title. 74 readonly chips?: ReadonlyArray<string>; 75 76 // Render this track in error colours. 77 readonly error?: Error; 78 79 // Issues a scrollTo() on this DOM element at creation time. Default: false. 80 readonly scrollToOnCreate?: boolean; 81 82 // Style the component differently. 83 readonly summary?: boolean; 84 85 // Whether to highlight the track or not. 86 readonly highlight?: boolean; 87 88 // Whether the shell should be draggable and emit drag/drop events. 89 readonly reorderable?: boolean; 90 91 // This is the depth of the track in the tree - controls the indent level and 92 // the z-index of sticky headers. 93 readonly depth?: number; 94 95 // The stick top offset - this is the offset from the top of sticky summary 96 // track headers and sticky menu bars stick from the top of the viewport. This 97 // is used to allow nested sticky track headers and menubars of nested tracks 98 // to stick below the sticky header of their parent track(s). 99 readonly stickyTop?: number; 100 101 // The ID of the plugin that created this track. 102 readonly pluginId?: string; 103 104 // Render a lighter version of the track shell, with no buttons or chips, just 105 // the track title. 106 readonly lite?: boolean; 107 108 // Called when the track is expanded or collapsed (when the node is clicked). 109 onCollapsedChanged?(collapsed: boolean): void; 110 111 // Mouse events within the track content element. 112 onTrackContentMouseMove?(pos: Point2D, contentSize: Bounds2D): void; 113 onTrackContentMouseOut?(): void; 114 onTrackContentClick?(pos: Point2D, contentSize: Bounds2D): boolean; 115 116 // If reorderable, these functions will be called when track shells are 117 // dragged and dropped. 118 onMoveBefore?(nodeId: string): void; 119 onMoveInside?(nodeId: string): void; 120 onMoveAfter?(nodeId: string): void; 121} 122 123export class TrackShell implements m.ClassComponent<TrackShellAttrs> { 124 private mouseDownPos?: Vector2D; 125 private selectionOccurred = false; 126 private scrollIntoView = false; 127 128 view(vnode: m.CVnode<TrackShellAttrs>) { 129 const {attrs} = vnode; 130 131 const { 132 collapsible, 133 collapsed, 134 id, 135 summary, 136 heightPx, 137 ref, 138 depth = 0, 139 stickyTop = 0, 140 lite, 141 } = attrs; 142 143 const expanded = collapsible && !collapsed; 144 const trackHeight = heightPx; 145 146 return m( 147 '.pf-track', 148 { 149 id, 150 style: { 151 '--height': trackHeight, 152 '--depth': clamp(depth, 0, 16), 153 '--sticky-top': Math.max(0, stickyTop), 154 }, 155 ref, 156 }, 157 m( 158 '.pf-track__header', 159 { 160 className: classNames( 161 summary && 'pf-track__header--summary', 162 expanded && 'pf-track__header--expanded', 163 summary && expanded && 'pf-track__header--expanded--summary', 164 ), 165 }, 166 this.renderShell(attrs), 167 !lite && this.renderContent(attrs), 168 ), 169 hasChildren(vnode) && m('.pf-track__children', vnode.children), 170 ); 171 } 172 173 oncreate({dom, attrs}: m.VnodeDOM<TrackShellAttrs>) { 174 if (attrs.scrollToOnCreate) { 175 dom.scrollIntoView({behavior: 'smooth', block: 'nearest'}); 176 } 177 } 178 179 onupdate({dom}: m.VnodeDOM<TrackShellAttrs, this>) { 180 if (this.scrollIntoView) { 181 dom.scrollIntoView({behavior: 'instant', block: 'nearest'}); 182 this.scrollIntoView = false; 183 } 184 } 185 186 private renderShell(attrs: TrackShellAttrs): m.Children { 187 const { 188 id, 189 chips, 190 collapsible, 191 collapsed, 192 reorderable = false, 193 onMoveAfter = () => {}, 194 onMoveBefore = () => {}, 195 onMoveInside = () => {}, 196 buttons, 197 highlight, 198 lite, 199 summary, 200 } = attrs; 201 202 const block = 'pf-track'; 203 const blockElement = `${block}__shell`; 204 const dragBeforeClassName = `${blockElement}--drag-before`; 205 const dragInsideClassName = `${blockElement}--drag-inside`; 206 const dragAfterClassName = `${blockElement}--drag-after`; 207 208 function updateDragClassname(target: HTMLElement, className: string) { 209 // This is a bit brute-force, but gets the job done without triggering a 210 // full mithril redraw every frame while dragging... 211 target.classList.remove(dragBeforeClassName); 212 target.classList.remove(dragAfterClassName); 213 target.classList.remove(dragInsideClassName); 214 target.classList.add(className); 215 } 216 217 return m( 218 `.pf-track__shell`, 219 { 220 className: classNames( 221 collapsible && 'pf-track__shell--clickable', 222 highlight && 'pf-track__shell--highlight', 223 ), 224 onclick: () => { 225 collapsible && attrs.onCollapsedChanged?.(!collapsed); 226 if (!collapsed) { 227 this.scrollIntoView = true; 228 } 229 }, 230 draggable: reorderable, 231 ondragstart: (e: DragEvent) => { 232 id && e.dataTransfer?.setData('text/plain', id); 233 }, 234 ondragover: (e: DragEvent) => { 235 if (!reorderable) { 236 return; 237 } 238 const target = e.currentTarget as HTMLElement; 239 const position = currentTargetOffset(e); 240 if (summary) { 241 // For summary tracks, split the track into thirds, so it's 242 // possible to insert above, below and into. 243 const threshold = target.offsetHeight / 3; 244 if (position.y < threshold) { 245 // Hovering on the upper third, move before this node. 246 updateDragClassname(target, dragBeforeClassName); 247 } else if (position.y < threshold * 2) { 248 // Hovering in the middle, move inside this node. 249 updateDragClassname(target, dragInsideClassName); 250 } else { 251 // Hovering on the lower third, move after this node. 252 updateDragClassname(target, dragAfterClassName); 253 } 254 } else { 255 // For non-summary tracks, split the track in half, as it's only 256 // possible to insert before and after. 257 const threshold = target.offsetHeight / 2; 258 if (position.y < threshold) { 259 updateDragClassname(target, dragBeforeClassName); 260 } else { 261 updateDragClassname(target, dragAfterClassName); 262 } 263 } 264 }, 265 ondragleave: (e: DragEvent) => { 266 if (!reorderable) { 267 return; 268 } 269 const target = e.currentTarget as HTMLElement; 270 const related = e.relatedTarget as HTMLElement | null; 271 if (related && !target.contains(related)) { 272 target.classList.remove(dragAfterClassName); 273 target.classList.remove(dragBeforeClassName); 274 } 275 }, 276 ondrop: (e: DragEvent) => { 277 if (!reorderable) { 278 return; 279 } 280 const id = e.dataTransfer?.getData('text/plain'); 281 const target = e.currentTarget as HTMLElement; 282 const position = currentTargetOffset(e); 283 284 if (id !== undefined) { 285 if (summary) { 286 // For summary tracks, split the track into thirds, so it's 287 // possible to insert above, below and into. 288 const threshold = target.offsetHeight / 3; 289 if (position.y < threshold) { 290 // Dropped on the upper third, move before this node. 291 onMoveBefore(id); 292 } else if (position.y < threshold * 2) { 293 // Dropped in the middle, move inside this node. 294 onMoveInside(id); 295 } else { 296 // Dropped on the lower third, move after this node. 297 onMoveAfter(id); 298 } 299 } else { 300 // For non-summary tracks, split the track in half, as it's only 301 // possible to insert before and after. 302 const threshold = target.offsetHeight / 2; 303 if (position.y < threshold) { 304 onMoveBefore(id); 305 } else { 306 onMoveAfter(id); 307 } 308 } 309 } 310 311 // Remove all the modifiers 312 target.classList.remove(dragAfterClassName); 313 target.classList.remove(dragInsideClassName); 314 target.classList.remove(dragBeforeClassName); 315 }, 316 }, 317 lite 318 ? attrs.title 319 : m( 320 '.pf-track__menubar', 321 collapsible 322 ? m(Button, { 323 className: 'pf-track__collapse-button', 324 compact: true, 325 icon: collapsed ? Icons.ExpandDown : Icons.ExpandUp, 326 }) 327 : m('.pf-track__title-spacer'), 328 m(TrackTitle, {title: attrs.title}), 329 chips && 330 m( 331 ChipBar, 332 {className: 'pf-track__chips'}, 333 chips.map((chip) => 334 m(Chip, {label: chip, compact: true, rounded: true}), 335 ), 336 ), 337 m( 338 ButtonBar, 339 { 340 className: 'pf-track__buttons', 341 // Block button clicks from hitting the shell's on click event 342 onclick: (e: MouseEvent) => e.stopPropagation(), 343 }, 344 buttons, 345 // Always render this one last 346 attrs.error && renderCrashButton(attrs.error, attrs.pluginId), 347 ), 348 attrs.subtitle && 349 !showSubtitleInContent(attrs) && 350 m( 351 '.pf-track__subtitle', 352 m(MiddleEllipsis, {text: attrs.subtitle}), 353 ), 354 ), 355 ); 356 } 357 358 private renderContent(attrs: TrackShellAttrs): m.Children { 359 const { 360 onTrackContentMouseMove, 361 onTrackContentMouseOut, 362 onTrackContentClick, 363 error, 364 } = attrs; 365 366 return m( 367 '.pf-track__canvas', 368 { 369 className: classNames(error && 'pf-track__canvas--error'), 370 onmousemove: (e: MithrilEvent<MouseEvent>) => { 371 e.redraw = false; 372 onTrackContentMouseMove?.( 373 currentTargetOffset(e), 374 getTargetContainerSize(e), 375 ); 376 }, 377 onmouseout: () => { 378 onTrackContentMouseOut?.(); 379 }, 380 onmousedown: (e: MouseEvent) => { 381 this.mouseDownPos = currentTargetOffset(e); 382 }, 383 onmouseup: (e: MouseEvent) => { 384 if (!this.mouseDownPos) return; 385 if ( 386 this.mouseDownPos.sub(currentTargetOffset(e)).manhattanDistance > 1 387 ) { 388 this.selectionOccurred = true; 389 } 390 this.mouseDownPos = undefined; 391 }, 392 onclick: (e: MouseEvent) => { 393 // This click event occurs after any selection mouse up/drag events 394 // so we have to look if the mouse moved during this click to know 395 // if a selection occurred. 396 if (this.selectionOccurred) { 397 this.selectionOccurred = false; 398 return; 399 } 400 401 // Returns true if something was selected, so stop propagation. 402 if ( 403 onTrackContentClick?.( 404 currentTargetOffset(e), 405 getTargetContainerSize(e), 406 ) 407 ) { 408 e.stopPropagation(); 409 } 410 }, 411 }, 412 attrs.subtitle && 413 showSubtitleInContent(attrs) && 414 m(MiddleEllipsis, {text: attrs.subtitle}), 415 ); 416 } 417} 418 419function showSubtitleInContent(attrs: TrackShellAttrs) { 420 return attrs.summary && !attrs.collapsed; 421} 422 423function getTargetContainerSize(event: MouseEvent): Bounds2D { 424 const target = event.target as HTMLElement; 425 return target.getBoundingClientRect(); 426} 427 428function renderCrashButton(error: Error, pluginId: string | undefined) { 429 return m( 430 Popup, 431 { 432 trigger: m(Button, { 433 icon: Icons.Crashed, 434 compact: true, 435 }), 436 }, 437 m( 438 '.pf-track__crash-popup', 439 m('span', 'This track has crashed.'), 440 pluginId && m('span', `Owning plugin: ${pluginId}`), 441 m(Button, { 442 label: 'View & Report Crash', 443 intent: Intent.Primary, 444 className: Popup.DISMISS_POPUP_GROUP_CLASS, 445 onclick: () => { 446 throw error; 447 }, 448 }), 449 // TODO(stevegolton): In the future we should provide a quick way to 450 // disable the plugin, or provide a link to the plugin page, but this 451 // relies on the plugin page being fully functional. 452 ), 453 ); 454} 455 456interface TrackTitleAttrs { 457 readonly title: string; 458} 459 460class TrackTitle implements m.ClassComponent<TrackTitleAttrs> { 461 private readonly trash = new DisposableStack(); 462 463 view({attrs}: m.Vnode<TrackTitleAttrs>) { 464 return m( 465 MiddleEllipsis, 466 { 467 className: 'pf-track__title', 468 text: attrs.title, 469 }, 470 m('.pf-track__title-popup', attrs.title), 471 ); 472 } 473 474 oncreate({dom}: m.VnodeDOM<TrackTitleAttrs>) { 475 const title = dom; 476 const popup = assertExists(dom.querySelector('.pf-track__title-popup')); 477 478 const resizeObserver = new ResizeObserver(() => { 479 // Determine whether to display a title popup based on ellipsization 480 if (popup.clientWidth > title.clientWidth) { 481 popup.classList.add('pf-track__title-popup--visible'); 482 } else { 483 popup.classList.remove('pf-track__title-popup--visible'); 484 } 485 }); 486 487 resizeObserver.observe(title); 488 resizeObserver.observe(popup); 489 490 this.trash.defer(() => resizeObserver.disconnect()); 491 } 492 493 onremove() { 494 this.trash.dispose(); 495 } 496} 497