blob: cb4c021f74dcd5b13d569fa8b6835b889eb3a6e8 [file] [log] [blame]
// 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, ButtonVariant} 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<string>;
// 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<TrackShellAttrs> {
private mouseDownPos?: Vector2D;
private selectionOccurred = false;
private scrollIntoView = false;
view(vnode: m.CVnode<TrackShellAttrs>) {
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<TrackShellAttrs>) {
if (attrs.scrollToOnCreate) {
dom.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
}
onupdate({dom}: m.VnodeDOM<TrackShellAttrs, this>) {
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<MouseEvent>) => {
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,
variant: ButtonVariant.Filled,
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<TrackTitleAttrs> {
private readonly trash = new DisposableStack();
view({attrs}: m.Vnode<TrackTitleAttrs>) {
return m(
MiddleEllipsis,
{
className: 'pf-track__title',
text: attrs.title,
},
m('.pf-track__title-popup', attrs.title),
);
}
oncreate({dom}: m.VnodeDOM<TrackTitleAttrs>) {
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();
}
}