// 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 {classNames} from '../base/classnames'; import {DisposableStack} from '../base/disposable_stack'; import {currentTargetOffset} from '../base/dom_utils'; import {Bounds2D, Point2D, Vector2D} from '../base/geom'; import {assertExists} from '../base/logging'; import {clamp} from '../base/math_utils'; import {hasChildren, MithrilEvent} from '../base/mithril_utils'; import {Icons} from '../base/semantic_icons'; import {Button, ButtonBar} from './button'; import {Chip, ChipBar} from './chip'; import {HTMLAttrs, Intent} from './common'; import {MiddleEllipsis} from './middle_ellipsis'; import {Popup} from './popup'; /** * This component defines the look and style of the DOM parts of a track (mainly * the 'shell' part). * * ┌───────────────────────────────────────────────────────────────────────────┐ * │ pf-track │ * |┌─────────────────────────────────────────────────────────────────────────┐| * || pf-track__header || * │|┌─────────┐┌─────────────────────────────────────────┐┌─────────────────┐│| * │|│::before ||pf-track__shell ││pf-track__content││| * │|│(Indent) ||┌───────────────────────────────────────┐││ ││| * │|│ ||│pf-track__menubar (sticky) │││ ││| * │|│ ||│┌───────────────┐┌────────────────────┐│││ ││| * │|│ ||││pf-track__title││pf-track__buttons ││││ ││| * │|│ ||│└───────────────┘└────────────────────┘│││ ││| * │|│ ||└───────────────────────────────────────┘││ ││| * │|└─────────┘└─────────────────────────────────────────┘└─────────────────┘│| * |└─────────────────────────────────────────────────────────────────────────┘| * |┌─────────────────────────────────────────────────────────────────────────┐| * || pf-track__children (if children supplied) || * |└─────────────────────────────────────────────────────────────────────────┘| * └───────────────────────────────────────────────────────────────────────────┘ */ export interface TrackShellAttrs extends HTMLAttrs { // The title of this track. readonly title: string; // Optional subtitle to display underneath the track name. readonly subtitle?: string; // Show dropdown arrow and make clickable. Defaults to false. readonly collapsible?: boolean; // Show an up or down dropdown arrow. readonly collapsed: boolean; // Height of the track in pixels. All tracks have a fixed height. readonly heightPx: number; // Optional buttons to place on the RHS of the track shell. readonly buttons?: m.Children; // Optional list of chips to display after the track title. readonly chips?: ReadonlyArray; // Render this track in error colours. readonly error?: Error; // Issues a scrollTo() on this DOM element at creation time. Default: false. readonly scrollToOnCreate?: boolean; // Style the component differently. readonly summary?: boolean; // Whether to highlight the track or not. readonly highlight?: boolean; // Whether the shell should be draggable and emit drag/drop events. readonly reorderable?: boolean; // This is the depth of the track in the tree - controls the indent level and // the z-index of sticky headers. readonly depth?: number; // The stick top offset - this is the offset from the top of sticky summary // track headers and sticky menu bars stick from the top of the viewport. This // is used to allow nested sticky track headers and menubars of nested tracks // to stick below the sticky header of their parent track(s). readonly stickyTop?: number; // The ID of the plugin that created this track. readonly pluginId?: string; // Render a lighter version of the track shell, with no buttons or chips, just // the track title. readonly lite?: boolean; // Called when the track is expanded or collapsed (when the node is clicked). onCollapsedChanged?(collapsed: boolean): void; // Mouse events within the track content element. onTrackContentMouseMove?(pos: Point2D, contentSize: Bounds2D): void; onTrackContentMouseOut?(): void; onTrackContentClick?(pos: Point2D, contentSize: Bounds2D): boolean; // If reorderable, these functions will be called when track shells are // dragged and dropped. onMoveBefore?(nodeId: string): void; onMoveInside?(nodeId: string): void; onMoveAfter?(nodeId: string): void; } export class TrackShell implements m.ClassComponent { private mouseDownPos?: Vector2D; private selectionOccurred = false; private scrollIntoView = false; view(vnode: m.CVnode) { const {attrs} = vnode; const { collapsible, collapsed, id, summary, heightPx, ref, depth = 0, stickyTop = 0, lite, } = attrs; const expanded = collapsible && !collapsed; const trackHeight = heightPx; return m( '.pf-track', { id, style: { '--height': trackHeight, '--depth': clamp(depth, 0, 16), '--sticky-top': Math.max(0, stickyTop), }, ref, }, m( '.pf-track__header', { className: classNames( summary && 'pf-track__header--summary', expanded && 'pf-track__header--expanded', summary && expanded && 'pf-track__header--expanded--summary', ), }, this.renderShell(attrs), !lite && this.renderContent(attrs), ), hasChildren(vnode) && m('.pf-track__children', vnode.children), ); } oncreate({dom, attrs}: m.VnodeDOM) { if (attrs.scrollToOnCreate) { dom.scrollIntoView({behavior: 'smooth', block: 'nearest'}); } } onupdate({dom}: m.VnodeDOM) { if (this.scrollIntoView) { dom.scrollIntoView({behavior: 'instant', block: 'nearest'}); this.scrollIntoView = false; } } private renderShell(attrs: TrackShellAttrs): m.Children { const { id, chips, collapsible, collapsed, reorderable = false, onMoveAfter = () => {}, onMoveBefore = () => {}, onMoveInside = () => {}, buttons, highlight, lite, summary, } = attrs; const block = 'pf-track'; const blockElement = `${block}__shell`; const dragBeforeClassName = `${blockElement}--drag-before`; const dragInsideClassName = `${blockElement}--drag-inside`; const dragAfterClassName = `${blockElement}--drag-after`; function updateDragClassname(target: HTMLElement, className: string) { // This is a bit brute-force, but gets the job done without triggering a // full mithril redraw every frame while dragging... target.classList.remove(dragBeforeClassName); target.classList.remove(dragAfterClassName); target.classList.remove(dragInsideClassName); target.classList.add(className); } return m( `.pf-track__shell`, { className: classNames( collapsible && 'pf-track__shell--clickable', highlight && 'pf-track__shell--highlight', ), onclick: () => { collapsible && attrs.onCollapsedChanged?.(!collapsed); if (!collapsed) { this.scrollIntoView = true; } }, draggable: reorderable, ondragstart: (e: DragEvent) => { id && e.dataTransfer?.setData('text/plain', id); }, ondragover: (e: DragEvent) => { if (!reorderable) { return; } const target = e.currentTarget as HTMLElement; const position = currentTargetOffset(e); if (summary) { // For summary tracks, split the track into thirds, so it's // possible to insert above, below and into. const threshold = target.offsetHeight / 3; if (position.y < threshold) { // Hovering on the upper third, move before this node. updateDragClassname(target, dragBeforeClassName); } else if (position.y < threshold * 2) { // Hovering in the middle, move inside this node. updateDragClassname(target, dragInsideClassName); } else { // Hovering on the lower third, move after this node. updateDragClassname(target, dragAfterClassName); } } else { // For non-summary tracks, split the track in half, as it's only // possible to insert before and after. const threshold = target.offsetHeight / 2; if (position.y < threshold) { updateDragClassname(target, dragBeforeClassName); } else { updateDragClassname(target, dragAfterClassName); } } }, ondragleave: (e: DragEvent) => { if (!reorderable) { return; } const target = e.currentTarget as HTMLElement; const related = e.relatedTarget as HTMLElement | null; if (related && !target.contains(related)) { target.classList.remove(dragAfterClassName); target.classList.remove(dragBeforeClassName); } }, ondrop: (e: DragEvent) => { if (!reorderable) { return; } const id = e.dataTransfer?.getData('text/plain'); const target = e.currentTarget as HTMLElement; const position = currentTargetOffset(e); if (id !== undefined) { if (summary) { // For summary tracks, split the track into thirds, so it's // possible to insert above, below and into. const threshold = target.offsetHeight / 3; if (position.y < threshold) { // Dropped on the upper third, move before this node. onMoveBefore(id); } else if (position.y < threshold * 2) { // Dropped in the middle, move inside this node. onMoveInside(id); } else { // Dropped on the lower third, move after this node. onMoveAfter(id); } } else { // For non-summary tracks, split the track in half, as it's only // possible to insert before and after. const threshold = target.offsetHeight / 2; if (position.y < threshold) { onMoveBefore(id); } else { onMoveAfter(id); } } } // Remove all the modifiers target.classList.remove(dragAfterClassName); target.classList.remove(dragInsideClassName); target.classList.remove(dragBeforeClassName); }, }, lite ? attrs.title : m( '.pf-track__menubar', collapsible ? m(Button, { className: 'pf-track__collapse-button', compact: true, icon: collapsed ? Icons.ExpandDown : Icons.ExpandUp, }) : m('.pf-track__title-spacer'), m(TrackTitle, {title: attrs.title}), chips && m( ChipBar, {className: 'pf-track__chips'}, chips.map((chip) => m(Chip, {label: chip, compact: true, rounded: true}), ), ), m( ButtonBar, { className: 'pf-track__buttons', // Block button clicks from hitting the shell's on click event onclick: (e: MouseEvent) => e.stopPropagation(), }, buttons, // Always render this one last attrs.error && renderCrashButton(attrs.error, attrs.pluginId), ), attrs.subtitle && !showSubtitleInContent(attrs) && m( '.pf-track__subtitle', m(MiddleEllipsis, {text: attrs.subtitle}), ), ), ); } private renderContent(attrs: TrackShellAttrs): m.Children { const { onTrackContentMouseMove, onTrackContentMouseOut, onTrackContentClick, error, } = attrs; return m( '.pf-track__canvas', { className: classNames(error && 'pf-track__canvas--error'), onmousemove: (e: MithrilEvent) => { e.redraw = false; onTrackContentMouseMove?.( currentTargetOffset(e), getTargetContainerSize(e), ); }, onmouseout: () => { onTrackContentMouseOut?.(); }, onmousedown: (e: MouseEvent) => { this.mouseDownPos = currentTargetOffset(e); }, onmouseup: (e: MouseEvent) => { if (!this.mouseDownPos) return; if ( this.mouseDownPos.sub(currentTargetOffset(e)).manhattanDistance > 1 ) { this.selectionOccurred = true; } this.mouseDownPos = undefined; }, onclick: (e: MouseEvent) => { // This click event occurs after any selection mouse up/drag events // so we have to look if the mouse moved during this click to know // if a selection occurred. if (this.selectionOccurred) { this.selectionOccurred = false; return; } // Returns true if something was selected, so stop propagation. if ( onTrackContentClick?.( currentTargetOffset(e), getTargetContainerSize(e), ) ) { e.stopPropagation(); } }, }, attrs.subtitle && showSubtitleInContent(attrs) && m(MiddleEllipsis, {text: attrs.subtitle}), ); } } function showSubtitleInContent(attrs: TrackShellAttrs) { return attrs.summary && !attrs.collapsed; } function getTargetContainerSize(event: MouseEvent): Bounds2D { const target = event.target as HTMLElement; return target.getBoundingClientRect(); } function renderCrashButton(error: Error, pluginId: string | undefined) { return m( Popup, { trigger: m(Button, { icon: Icons.Crashed, compact: true, }), }, m( '.pf-track__crash-popup', m('span', 'This track has crashed.'), pluginId && m('span', `Owning plugin: ${pluginId}`), m(Button, { label: 'View & Report Crash', intent: Intent.Primary, className: Popup.DISMISS_POPUP_GROUP_CLASS, onclick: () => { throw error; }, }), // TODO(stevegolton): In the future we should provide a quick way to // disable the plugin, or provide a link to the plugin page, but this // relies on the plugin page being fully functional. ), ); } interface TrackTitleAttrs { readonly title: string; } class TrackTitle implements m.ClassComponent { private readonly trash = new DisposableStack(); view({attrs}: m.Vnode) { return m( MiddleEllipsis, { className: 'pf-track__title', text: attrs.title, }, m('.pf-track__title-popup', attrs.title), ); } oncreate({dom}: m.VnodeDOM) { const title = dom; const popup = assertExists(dom.querySelector('.pf-track__title-popup')); const resizeObserver = new ResizeObserver(() => { // Determine whether to display a title popup based on ellipsization if (popup.clientWidth > title.clientWidth) { popup.classList.add('pf-track__title-popup--visible'); } else { popup.classList.remove('pf-track__title-popup--visible'); } }); resizeObserver.observe(title); resizeObserver.observe(popup); this.trash.defer(() => resizeObserver.disconnect()); } onremove() { this.trash.dispose(); } }