blob: 1a07393c974e2b4d661ccc0880e828ecd5662652 [file]
// 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 {HighPrecisionTimeSpan} from '../base/high_precision_time_span';
import {time} from '../base/time';
import {ScrollToArgs} from '../public/scroll_helper';
import {WorkspaceManager} from '../public/workspace';
import {raf} from './raf_scheduler';
import {TimelineImpl} from './timeline';
import {TrackManagerImpl} from './track_manager';
// A helper class to help jumping to tracks and time ranges.
// This class must NOT alter in any way the selection status. That
// responsibility belongs to SelectionManager (which uses this).
export class ScrollHelper {
constructor(
private timeline: TimelineImpl,
private workspace: WorkspaceManager,
private trackManager: TrackManagerImpl,
) {}
// See comments in ScrollToArgs for the intended semantics.
scrollTo(args: ScrollToArgs) {
const {time, track} = args;
raf.scheduleCanvasRedraw();
if (time !== undefined) {
const end = time.end ?? time.start;
const behavior = time.behavior ?? 'pan'; // Default to pan
if (typeof behavior === 'object' && 'viewPercentage' in behavior) {
// Explicit zoom percentage
this.focusHorizontalRangePercentage(
time.start,
end,
behavior.viewPercentage,
);
} else if (behavior === 'focus') {
// Smart focus: zoom and pan to center the event
this.focusHorizontalRange(time.start, end);
} else {
// Pan: just move the viewport without changing zoom
this.timeline.panSpanIntoView(time.start, end, {
align: 'nearest',
margin: 0.1,
});
}
}
if (track !== undefined) {
this.verticalScrollToTrack(track.uri, track.expandGroup ?? false);
}
}
private focusHorizontalRangePercentage(
start: time,
end: time,
viewPercentage: number,
): void {
const aoi = HighPrecisionTimeSpan.fromTime(start, end);
// For instant events (duration = 0), just pan to center without zoom
if (aoi.duration === 0) {
this.timeline.panSpanIntoView(start, end, {align: 'center'});
return;
}
if (viewPercentage <= 0.0 || viewPercentage > 1.0) {
console.warn(
'Invalid value for [viewPercentage]. ' +
'Value must be between 0.0 (exclusive) and 1.0 (inclusive).',
);
// Default to 50%.
viewPercentage = 0.5;
}
this.timeline.panSpanIntoView(start, end, {
align: 'zoom',
margin: (1.0 - viewPercentage) / 2,
});
}
private focusHorizontalRange(start: time, end: time): void {
// Handle instant events (duration = 0) specially
if (start === end) {
// For instant events, zoom in by 99.8% (new duration = 0.2% of current)
// This value (0.002) was chosen based on heuristic testing.
// TODO(lalitm): This should ideally use the actual viewport width in
// pixels to calculate a precise zoom level (e.g., make 1px at current
// scale fill 80% of viewport), but plumbing viewport width through to
// ScrollHelper is architecturally difficult right now.
this.timeline.panIntoView(start, {
align: 'zoom',
zoomWidth: 0.002 * this.timeline.visibleWindow.duration,
});
} else {
// 10% padding on each side means the range fills 80% of the viewport
this.timeline.panSpanIntoView(start, end, {align: 'zoom', margin: 0.1});
}
}
private verticalScrollToTrack(trackUri: string, openGroup: boolean) {
// Find the actual track node that uses that URI, we need various properties
// from it.
const trackNode = this.workspace.currentWorkspace.getTrackByUri(trackUri);
if (!trackNode) return;
// Try finding the track directly.
const element = document.getElementById(trackNode.id);
if (element) {
// block: 'nearest' means that it will only scroll if the track is not
// currently in view.
element.scrollIntoView({behavior: 'smooth', block: 'nearest'});
return;
}
// If we get here, the element for this track was not present in the DOM,
// which might be because it's inside a collapsed group.
if (openGroup) {
// Try to reveal the track node in the workspace by opening up all
// ancestor groups, and mark the track URI to be scrolled to in the
// future.
trackNode.reveal();
this.trackManager.scrollToTrackNodeId = trackNode.id;
} else {
// Find the closest visible ancestor of our target track and scroll to
// that instead.
const container = trackNode.findClosestVisibleAncestor();
document
.getElementById(container.id)
?.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
}
}