[tools] Improve SystemAnalyzer tooltip

- Debounce creating tooltips to declutter the UI
- CTRL-mouse move causes immediate tooltips
- Use icons and help text on tooltip buttons
- Recreate tooltip target nodes in timeline views to avoid moving the
  existing tooltip if the update is debounced

Change-Id: I65a885827ebfeafc09c1c08e2cfe9c2dd448edca
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/4012720
Commit-Queue: Camillo Bruni <cbruni@chromium.org>
Reviewed-by: Marja Hölttä <marja@chromium.org>
Cr-Commit-Position: refs/heads/main@{#84120}
This commit is contained in:
Camillo Bruni 2022-11-08 12:23:52 +01:00 committed by V8 LUCI CQ
parent 5c4fc2b75c
commit 8b1cfdf682
14 changed files with 213 additions and 155 deletions

View File

@ -181,11 +181,14 @@ found in the LICENSE file. -->
<h3>Keyboard Shortcuts for Navigation</h3>
<dl>
<dt><kbd>CTRL</kbd> + <kbd>Mouse Move</kbd></dt>
<dd>Show tooltips immediately</dd>
<dt><kbd>A</kbd></dt>
<dd>Scroll left</dd>
<dt><kbd>D</kbd></dt>
<dd>Sroll right</dd>
<dd>Scroll right</dd>
<dt><kbd>SHIFT</kbd> + <kbd>Arrow Up</kbd></dt>
<dd>Follow Map transition forward (first child)</dd>

View File

@ -328,12 +328,11 @@ class App {
throw new Error(
`Unknown tooltip content type: ${content.constructor?.name}`);
}
this.setToolTip(content, event.positionOrTargetNode);
}
setToolTip(content, positionOrTargetNode) {
this._view.toolTip.positionOrTargetNode = positionOrTargetNode;
this._view.toolTip.content = content;
this._view.toolTip.data = {
content: content,
positionOrTargetNode: event.positionOrTargetNode,
immediate: event.immediate,
};
}
restartApp() {

View File

@ -76,13 +76,14 @@ export class ToolTipEvent extends AppEvent {
return 'showtooltip';
}
constructor(content, positionOrTargetNode) {
constructor(content, positionOrTargetNode, immediate) {
super(ToolTipEvent.name);
if (!positionOrTargetNode) {
throw Error('Either provide a valid position or targetNode');
}
this._content = content;
this._positionOrTargetNode = positionOrTargetNode;
this._immediate = immediate;
}
get content() {
@ -92,4 +93,8 @@ export class ToolTipEvent extends AppEvent {
get positionOrTargetNode() {
return this._positionOrTargetNode;
}
get immediate() {
return this._immediate;
}
}

View File

@ -322,5 +322,27 @@ export function gradientStopsFromGroups(
return stops;
}
export class Debouncer {
constructor(callback, timeout = 250) {
this._callback = callback;
this._timeout = timeout;
this._timeoutId = 0;
}
callNow(...args) {
this.clear();
return this._callback(...args);
}
call(...args) {
this.clear() this._timeoutId =
window.setTimeout(this._callback, this._timeout, ...args)
}
clear() {
clearTimeout(this._timeoutId);
}
}
export * from '../helper.mjs';
export * from '../../js/web-api-helper.mjs'

View File

@ -127,7 +127,7 @@ DOM.defineCustomElement('view/list-panel',
_logEntryMouseOverHandler(e) {
const group = e.currentTarget.group;
this.dispatchEvent(new ToolTipEvent(group.key, e.currentTarget));
this.dispatchEvent(new ToolTipEvent(group.key, e.currentTarget, e.ctrlKey));
}
_handleDetailsClick(event) {

View File

@ -147,8 +147,8 @@ DOM.defineCustomElement('./view/map-panel/map-transitions',
}
_handleMouseoverMap(event) {
this.dispatchEvent(
new ToolTipEvent(event.currentTarget.map, event.currentTarget));
this.dispatchEvent(new ToolTipEvent(
event.currentTarget.map, event.currentTarget, event.ctrlKey));
}
_handleToggleSubtree(event) {

View File

@ -295,7 +295,7 @@ DOM.defineCustomElement('view/profiler-panel',
const profileNode = e.target.data;
if (!profileNode) return;
const logEntry = profileNode.codeEntry.logEntry;
this.dispatchEvent(new ToolTipEvent(logEntry, e.target));
this.dispatchEvent(new ToolTipEvent(logEntry, e.target, e.ctrlKey));
}
_handleFlameChartClick(e) {

View File

@ -111,16 +111,20 @@ DOM.defineCustomElement('view/property-link-table',
if (this._object === undefined) return;
if (!this._instanceLinkButtons) return;
const footer = DOM.div('footer');
let showButton = footer.appendChild(DOM.button('Show', this._showHandler));
let showButton =
footer.appendChild(DOM.button('🔍 Details', this._showHandler));
showButton.data = this._object;
showButton.title = `Show details for ${this._object}`
if (this._object.sourcePosition) {
let showSourcePositionButton = footer.appendChild(
DOM.button('Source Position', this._showSourcePositionHandler));
DOM.button('📍 Source Position', this._showSourcePositionHandler));
showSourcePositionButton.data = this._object;
showSourcePositionButton.title = 'Open the source position';
}
let showRelatedButton = footer.appendChild(
DOM.button('Show Related', this._showRelatedHandler));
DOM.button('🕸 Related', this._showRelatedHandler));
showRelatedButton.data = this._object;
showRelatedButton.title = 'Show all related events in all panels';
this._fragment.appendChild(footer);
}

View File

@ -198,7 +198,7 @@ DOM.defineCustomElement('view/script-panel',
break;
}
toolTipContent.appendChild(sourceMapContent);
this.dispatchEvent(new ToolTipEvent(toolTipContent, e.target));
this.dispatchEvent(new ToolTipEvent(toolTipContent, e.target, e.ctrlKey));
}
handleShowToolTipEntries(event) {
@ -233,8 +233,8 @@ class ToolTipTableBuilder {
tr.appendChild(DOM.td(name));
tr.appendChild(DOM.td(subtypeName));
tr.appendChild(DOM.td(entries.length));
const button =
DOM.button('Show', this._scriptPanel.showToolTipEntriesHandler);
const button = DOM.button('🔎', this._scriptPanel.showToolTipEntriesHandler);
button.title = `Show all ${entries.length} ${name || subtypeName} entries.`
button.data = entries;
tr.appendChild(DOM.td(button));
this.tableNode.appendChild(tr);

View File

@ -110,7 +110,8 @@ DOM.defineCustomElement('view/timeline/timeline-overview',
if (!toolTipContent) {
toolTipContent = `Time ${formatDurationMicros(timeMicros)}`;
}
this.dispatchEvent(new ToolTipEvent(toolTipContent, this._indicatorNode));
this.dispatchEvent(
new ToolTipEvent(toolTipContent, this._indicatorNode, e.ctrlKey));
}
_findLogEntryAtTime(time, maxTimeDistance) {

View File

@ -30,7 +30,7 @@ export class TimelineTrackBase extends V8CustomElement {
this.timelineChunks = this.$('#timelineChunks');
this.timelineSamples = this.$('#timelineSamples');
this.timelineNode = this.$('#timeline');
this.toolTipTargetNode = this.$('#toolTipTarget');
this._toolTipTargetNode = undefined;
this.hitPanelNode = this.$('#hitPanel');
this.timelineAnnotationsNode = this.$('#timelineAnnotations');
this.timelineMarkersNode = this.$('#timelineMarkers');
@ -356,8 +356,8 @@ export class TimelineTrackBase extends V8CustomElement {
_updateToolTip(event) {
if (!this._focusedEntry) return false;
this.dispatchEvent(
new ToolTipEvent(this._focusedEntry, this.toolTipTargetNode));
this.dispatchEvent(new ToolTipEvent(
this._focusedEntry, this._toolTipTargetNode, event.ctrlKey));
event.stopImmediatePropagation();
}
@ -419,13 +419,27 @@ export class TimelineTrackBase extends V8CustomElement {
(kTimelineHeight - event.layerY) / chunk.height * (chunk.size() - 1));
if (relativeIndex > chunk.size()) return false;
const logEntry = chunk.at(relativeIndex);
const style = this.toolTipTargetNode.style;
const node = this.getToolTipTargetNode(logEntry);
if (!node) return logEntry;
const style = node.style;
style.left = `${chunk.index * kChunkWidth}px`;
style.top = `${kTimelineHeight - chunk.height}px`;
style.height = `${chunk.height}px`;
style.width = `${kChunkVisualWidth}px`;
return logEntry;
}
getToolTipTargetNode(logEntry) {
let node = this._toolTipTargetNode;
if (node) {
if (node.logEntry === logEntry) return undefined;
node.parentNode.removeChild(node);
}
node = this._toolTipTargetNode = DOM.div('toolTipTarget');
node.logEntry = logEntry;
this.$('#cropper').appendChild(node);
return node;
}
};
class SelectionHandler {

View File

@ -61,7 +61,9 @@ export class TimelineTrackStackedBase extends TimelineTrackBase {
const item = this._getDrawableItemForEvent(event);
const logEntry = this._drawableItemToLogEntry(item);
if (item === undefined) return undefined;
const style = this.toolTipTargetNode.style;
const node = this.getToolTipTargetNode(logEntry);
if (!node) return logEntry;
const style = node.style;
style.left = `${event.layerX}px`;
style.top = `${(item.depth + 1) * kItemHeight}px`;
style.height = `${kItemHeight}px`

View File

@ -40,7 +40,7 @@ found in the LICENSE file. -->
pointer-events: none;
}
#toolTipTarget {
.toolTipTarget {
position: absolute;
}
@ -237,7 +237,6 @@ found in the LICENSE file. -->
</svg>
<svg id="timelineAnnotations" xmlns="http://www.w3.org/2000/svg" class="dataSized noPointerEvents"></svg>
<svg id="timelineMarkers" xmlns="http://www.w3.org/2000/svg" class="dataSized noPointerEvents"></svg>
<div id="toolTipTarget"></div>
</div>
<!-- Use a div element covering all complex items to prevent slow hit test-->
<div id="hitPanel" class="dataSized"></div>

View File

@ -2,137 +2,146 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {DOM, V8CustomElement} from './helper.mjs';
import {Debouncer, DOM, V8CustomElement} from './helper.mjs';
DOM.defineCustomElement(
'view/tool-tip', (templateText) => class Tooltip extends V8CustomElement {
_targetNode;
_content;
_isHidden = true;
DOM.defineCustomElement('view/tool-tip',
(templateText) =>
class Tooltip extends V8CustomElement {
_targetNode;
_content;
_isHidden = true;
_debouncedSetData = new Debouncer((...args) => this._setData(...args), 500)
constructor() {
super(templateText);
this._intersectionObserver = new IntersectionObserver((entries) => {
if (entries[0].intersectionRatio <= 0) {
this.hide();
} else {
this.show();
this.requestUpdate(true);
}
});
document.addEventListener('click', (event) => {
// Only hide the tooltip if we click anywhere outside of it.
let target = event.target;
while (target) {
if (target == this) return;
target = target.parentNode;
}
this.hide()
});
}
_update() {
if (!this._targetNode || this._isHidden) return;
const rect = this._targetNode.getBoundingClientRect();
rect.x += rect.width / 2;
let atRight = this._useRight(rect.x);
let atBottom = this._useBottom(rect.y);
if (atBottom) rect.y += rect.height;
this._setPosition(rect, atRight, atBottom);
this.requestUpdate(true);
}
set positionOrTargetNode(positionOrTargetNode) {
if (positionOrTargetNode.nodeType === undefined) {
this.position = positionOrTargetNode;
} else {
this.targetNode = positionOrTargetNode;
}
}
set targetNode(targetNode) {
this._intersectionObserver.disconnect();
this._targetNode = targetNode;
if (targetNode === undefined) return;
if (!(targetNode instanceof SVGElement)) {
this._intersectionObserver.observe(targetNode);
}
this.requestUpdate(true);
}
set position(position) {
this._targetNode = undefined;
this._setPosition(
position, this._useRight(position.x), this._useBottom(position.y));
}
_setPosition(viewportPosition, atRight, atBottom) {
const horizontalMode = atRight ? 'right' : 'left';
const verticalMode = atBottom ? 'bottom' : 'top';
this.bodyNode.className = horizontalMode + ' ' + verticalMode;
const pageX = viewportPosition.x + window.scrollX;
this.style.left = `${pageX}px`;
const pageY = viewportPosition.y + window.scrollY;
this.style.top = `${pageY}px`;
}
_useBottom(viewportY) {
return viewportY <= 400;
}
_useRight(viewportX) {
return viewportX < document.documentElement.clientWidth / 2;
}
set content(content) {
if (!content) return this.hide();
constructor() {
super(templateText);
this._intersectionObserver = new IntersectionObserver((entries) => {
if (entries[0].intersectionRatio <= 0) {
this.hide();
} else {
this.show();
if (this._content === content) return;
this._content = content;
if (typeof content === 'string') {
this.contentNode.innerHTML = content;
this.contentNode.className = 'textContent';
} else if (content?.nodeType && content?.nodeName) {
this._setContentNode(content);
} else {
if (this.contentNode.firstChild?.localName == 'property-link-table') {
this.contentNode.firstChild.propertyDict = content;
} else {
const node = DOM.element('property-link-table');
node.instanceLinkButtons = true;
node.propertyDict = content;
this._setContentNode(node);
}
}
}
_setContentNode(content) {
const newContent = DOM.div();
newContent.appendChild(content);
this.contentNode.replaceWith(newContent);
newContent.id = 'content';
}
hide() {
this._content = undefined;
if (this._isHidden) return;
this._isHidden = true;
this.bodyNode.style.display = 'none';
this.targetNode = undefined;
}
show() {
if (!this._isHidden) return;
this.bodyNode.style.display = 'block';
this._isHidden = false;
}
get bodyNode() {
return this.$('#body');
}
get contentNode() {
return this.$('#content');
this.requestUpdate(true);
}
});
document.addEventListener('click', (event) => {
// Only hide the tooltip if we click anywhere outside of it.
let target = event.target;
while (target) {
if (target == this) return;
target = target.parentNode;
}
this.hide()
});
}
_update() {
if (!this._targetNode || this._isHidden) return;
if (!this._targetNode.parentNode) return;
const rect = this._targetNode.getBoundingClientRect();
rect.x += rect.width / 2;
let atRight = this._useRight(rect.x);
let atBottom = this._useBottom(rect.y);
if (atBottom) rect.y += rect.height;
this._setPosition(rect, atRight, atBottom);
this.requestUpdate(true);
}
set data({content, positionOrTargetNode, immediate}) {
if (immediate) {
this._debouncedSetData.callNow(content, positionOrTargetNode)
} else {
this._debouncedSetData.call(content, positionOrTargetNode)
}
}
_setData(content, positionOrTargetNode) {
if (positionOrTargetNode.nodeType === undefined) {
this._targetNode = undefined;
const position = positionOrTargetNode;
this._setPosition(
position, this._useRight(position.x), this._useBottom(position.y));
} else {
this._setTargetNode(positionOrTargetNode);
}
this._setContent(content);
}
_setTargetNode(targetNode) {
this._intersectionObserver.disconnect();
this._targetNode = targetNode;
if (targetNode === undefined) return;
if (!(targetNode instanceof SVGElement)) {
this._intersectionObserver.observe(targetNode);
}
this.requestUpdate(true);
}
_setPosition(viewportPosition, atRight, atBottom) {
const horizontalMode = atRight ? 'right' : 'left';
const verticalMode = atBottom ? 'bottom' : 'top';
this.bodyNode.className = horizontalMode + ' ' + verticalMode;
const pageX = viewportPosition.x + window.scrollX;
this.style.left = `${pageX}px`;
const pageY = viewportPosition.y + window.scrollY;
this.style.top = `${pageY}px`;
}
_useBottom(viewportY) {
return viewportY <= 400;
}
_useRight(viewportX) {
return viewportX < document.documentElement.clientWidth / 2;
}
_setContent(content) {
if (!content) return this.hide();
this.show();
if (this._content === content) return;
this._content = content;
if (typeof content === 'string') {
this.contentNode.innerHTML = content;
this.contentNode.className = 'textContent';
} else if (content?.nodeType && content?.nodeName) {
this._setContentNode(content);
} else {
if (this.contentNode.firstChild?.localName == 'property-link-table') {
this.contentNode.firstChild.propertyDict = content;
} else {
const node = DOM.element('property-link-table');
node.instanceLinkButtons = true;
node.propertyDict = content;
this._setContentNode(node);
}
}
}
_setContentNode(content) {
const newContent = DOM.div();
newContent.appendChild(content);
this.contentNode.replaceWith(newContent);
newContent.id = 'content';
}
hide() {
this._content = undefined;
if (this._isHidden) return;
this._isHidden = true;
this.bodyNode.style.display = 'none';
this.targetNode = undefined;
}
show() {
if (!this._isHidden) return;
this.bodyNode.style.display = 'block';
this._isHidden = false;
}
get bodyNode() {
return this.$('#body');
}
get contentNode() {
return this.$('#content');
}
});