blob: a9e332a4af47bf15eba09c1fa35b179add7f3bd8 [file] [log] [blame]
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {DiffLayer} from '../../../types/types';
import {GrDiffLine, Side, TokenHighlightListener} from '../../../api/diff';
import {assertIsDefined} from '../../../utils/common-util';
import {
GrAnnotationImpl,
getStringLength,
} from '../gr-diff-highlight/gr-annotation';
import {debounce, DelayedTask} from '../../../utils/async-util';
import {getLineElByChild, getSideByLineEl} from '../gr-diff/gr-diff-utils';
import {
getLineNumberByChild,
lineNumberToNumber,
} from '../gr-diff/gr-diff-utils';
const tokenMatcher = new RegExp(/[\w]+/g);
/** CSS class for all tokens. */
const CSS_TOKEN = 'token';
/** CSS class for the currently hovered token. */
const CSS_HIGHLIGHT = 'token-highlight';
/** CSS class marking which text value each token corresponds */
const TOKEN_TEXT_PREFIX = 'tk-text-';
/**
* CSS class marking which index (column) where token starts within a line of code.
* The assumption is that we can only have a single token per column start per line.
*/
const TOKEN_INDEX_PREFIX = 'tk-index-';
export const HOVER_DELAY_MS = 200;
const LINE_LENGTH_LIMIT = 500;
const TOKEN_LENGTH_LIMIT = 100;
const TOKEN_COUNT_LIMIT = 10000;
const TOKEN_OCCURRENCES_LIMIT = 1000;
/**
* When a user hovers over a token in the diff, then this layer makes sure that
* all occurrences of this token are annotated with the 'token-highlight' css
* class. And removes that class when the user moves the mouse away from the
* token.
*
* The layer does not react to mouse events directly by adding a css class to
* the appropriate elements, but instead it just sets the currently highlighted
* token and notifies the diff renderer that certain lines must be re-rendered.
* And when that re-rendering happens the appropriate css class is added.
*/
export class TokenHighlightLayer implements DiffLayer {
/** The currently highlighted token. */
private currentHighlight?: string;
/** Trigger when a new token starts or stoped being highlighted.*/
private readonly tokenHighlightListener?: TokenHighlightListener;
/**
* The line of the currently highlighted token. We store this in order to
* re-render only relevant lines of the diff. Only lines visible on the screen
* need a highlight. For example in a file with 10,000 lines it is sufficient
* to just re-render the ~100 lines that are visible to the user.
*
* It is a known issue that we are only storing the line number on the side of
* where the user is hovering and we use that also to determine which line
* numbers to re-render on the other side, but it is non-trivial to look up or
* store a reliable mapping of line numbers, so we just accept this
* shortcoming with the reasoning that the user is mostly interested in the
* tokens on the side where they are hovering anyway.
*
* Another known issue is that we are not able to see past collapsed lines
* with the current implementation.
*/
private currentHighlightLineNumber = 0;
/**
* Keeps track of where tokens occur in a file during rendering, so that it is
* easy to look up when processing mouse events.
*/
private tokenToElements = new Map<string, Set<HTMLElement>>();
private hoveredElement?: Element;
private updateTokenTask?: DelayedTask;
/**
* Container that contains all annotated tokens and contains no shadow root
* elements that would prevent tokens to be queryable by querySelectorAll.
*/
private getTokenQueryContainer?: () => HTMLElement;
/**
* @param container for registering "deselect" click
* @param tokenHighlightListener method that is called,
* when token is highlighted.
* @param getTokenQueryContainer if specified, list of tokens to be
* highlighted are recalculated every time using querySelectorAll inside
* this element. Otherwise, the pointers calculated once at annotate() time
* and are reused.
*/
constructor(
container: HTMLElement,
tokenHighlightListener?: TokenHighlightListener,
getTokenQueryContainer?: () => HTMLElement
) {
this.tokenHighlightListener = tokenHighlightListener;
container.addEventListener('click', e => {
this.handleContainerClick(e);
});
this.getTokenQueryContainer = getTokenQueryContainer;
}
annotate(el: HTMLElement, _1: HTMLElement, _2: GrDiffLine, _3: Side): void {
const text = el.textContent;
if (!text) return;
// Binary files encoded as text for example can have super long lines
// with super long tokens. Let's guard against against this scenario.
if (text.length > LINE_LENGTH_LIMIT) return;
let match;
let atLeastOneTokenMatched = false;
while ((match = tokenMatcher.exec(text))) {
const token = match[0];
// Binary files encoded as text for example can have super long lines
// with super long tokens. Let's guard against this scenario.
if (token.length > TOKEN_LENGTH_LIMIT) continue;
// This is to correctly count surrogate pairs in text and token.
// If the index calculation becomes a hotspot, we could precompute a code
// unit to code point index map for text before iterating over the results
const index = getStringLength(text.slice(0, match.index));
const length = getStringLength(token);
atLeastOneTokenMatched = true;
const highlightTypeClass =
token === this.currentHighlight ? CSS_HIGHLIGHT : '';
const textClass = `${TOKEN_TEXT_PREFIX}${token}`;
const indexClass = `${TOKEN_INDEX_PREFIX}${index}`;
// We add the TOKEN_TEXT_PREFIX class so that we can look up the token later easily
// even if the token element was split up into multiple smaller nodes.
// All parts of a single token will share a common TOKEN_INDEX_PREFIX class within the line of code.
GrAnnotationImpl.annotateElement(
el,
index,
length,
`${textClass} ${indexClass} ${CSS_TOKEN} ${highlightTypeClass}`
);
// We could try to detect whether we are re-rendering instead of initially
// rendering the line. Then we would not have to call storeLineForToken()
// again. But since the Set swallows the duplicates we don't care.
this.storeElementsForToken(token, el, textClass);
}
if (atLeastOneTokenMatched) {
// These listeners do not have to be cleaned, because listeners are
// garbage collected along with the element itself once it is not attached
// to the DOM anymore and no references exist anymore.
el.addEventListener('mouseover', e => {
this.handleTokenMouseOver(e);
});
el.addEventListener('mouseout', e => {
this.handleTokenMouseOut(e);
});
}
}
private storeElementsForToken(
token: string,
lineEl: HTMLElement,
cssClass: string
) {
for (const el of lineEl.querySelectorAll(`.${cssClass}`)) {
let tokenEls = this.tokenToElements.get(token);
if (!tokenEls) {
// Just to make sure that we don't break down on large files.
if (this.tokenToElements.size > TOKEN_COUNT_LIMIT) return;
tokenEls = new Set<HTMLElement>();
this.tokenToElements.set(token, tokenEls);
}
// Just to make sure that we don't break down on large files.
if (tokenEls.size > TOKEN_OCCURRENCES_LIMIT) return;
tokenEls.add(el as HTMLElement);
}
}
private handleTokenMouseOut(e: MouseEvent) {
// If there's no ongoing hover-task, terminate early.
if (!this.updateTokenTask?.isActive()) return;
if (e.buttons > 0 || this.interferesWithSelection()) return;
const {element} = this.findTokenAncestor(e?.target);
if (!element) return;
if (element === this.hoveredElement) {
// If we are moving out of the currently hovered element, cancel the
// update task.
this.hoveredElement = undefined;
if (this.updateTokenTask) this.updateTokenTask.cancel();
}
}
private handleTokenMouseOver(e: MouseEvent) {
if (e.buttons > 0 || this.interferesWithSelection()) return;
const {
line,
token: newHighlight,
element,
} = this.findTokenAncestor(e?.target);
if (
!newHighlight ||
(this.currentHighlight === newHighlight &&
this.currentHighlightLineNumber === line)
)
return;
this.hoveredElement = element;
this.updateTokenTask = debounce(
this.updateTokenTask,
() => {
this.updateTokenHighlight(newHighlight, line, element);
},
HOVER_DELAY_MS
);
}
private handleContainerClick(e: MouseEvent) {
if (this.interferesWithSelection()) return;
// Ignore the click if the click is on a token.
// We can't use e.target becauses it gets retargetted to the container as
// it's a shadow dom.
const {element} = this.findTokenAncestor(e.composedPath()[0]);
if (element) return;
this.hoveredElement = undefined;
this.updateTokenTask?.cancel();
this.updateTokenHighlight(undefined, 0, undefined);
}
private interferesWithSelection() {
return document.getSelection()?.type === 'Range';
}
findTokenAncestor(el?: EventTarget | Element | null): {
token?: string;
line: number;
element?: Element;
} {
if (!(el instanceof Element))
return {line: 0, token: undefined, element: undefined};
if (el.classList.contains(CSS_TOKEN)) {
const tkTextClass = [...el.classList].find(c =>
c.startsWith(TOKEN_TEXT_PREFIX)
);
const line = lineNumberToNumber(getLineNumberByChild(el));
if (!line || !tkTextClass)
return {line: 0, token: undefined, element: undefined};
return {
line,
token: tkTextClass.substring(TOKEN_TEXT_PREFIX.length),
element: el,
};
}
if (el.tagName === 'TD')
return {line: 0, token: undefined, element: undefined};
return this.findTokenAncestor(el.parentElement);
}
private updateTokenHighlight(
newHighlight: string | undefined,
newLineNumber: number,
newHoveredElement: Element | undefined
) {
if (
this.currentHighlight === newHighlight &&
this.currentHighlightLineNumber === newLineNumber
)
return;
const oldHighlight = this.currentHighlight;
this.currentHighlight = newHighlight;
this.currentHighlightLineNumber = newLineNumber;
this.triggerTokenHighlightEvent(
newHighlight,
newLineNumber,
newHoveredElement
);
this.toggleTokenHighlight(oldHighlight, CSS_HIGHLIGHT);
this.toggleTokenHighlight(newHighlight, CSS_HIGHLIGHT);
}
private toggleTokenHighlight(token: string | undefined, cssClass: string) {
if (!token) {
return;
}
let tokenEls;
let tokenElsLength;
if (this.getTokenQueryContainer) {
tokenEls = this.getTokenQueryContainer().querySelectorAll(
`.${TOKEN_TEXT_PREFIX}${token}`
);
tokenElsLength = tokenEls.length;
} else {
tokenEls = this.tokenToElements.get(token);
tokenElsLength = tokenEls?.size;
}
if (!tokenEls || tokenElsLength === 0) {
console.warn(`No tokens have been found for '${token}'`);
return;
}
for (const el of tokenEls) {
el.classList.toggle(cssClass);
}
}
triggerTokenHighlightEvent(
token: string | undefined,
line: number,
element: Element | undefined
) {
if (!this.tokenHighlightListener) {
return;
}
if (!token || !element) {
this.tokenHighlightListener(undefined);
return;
}
const lineEl = getLineElByChild(element);
assertIsDefined(lineEl, 'Line element should be found!');
const tokenIndexStr = [...element.classList]
.find(c => c.startsWith(TOKEN_INDEX_PREFIX))
?.substring(TOKEN_INDEX_PREFIX.length);
assertIsDefined(tokenIndexStr, 'Index class should be found!');
const index = Number(tokenIndexStr);
const side = getSideByLineEl(lineEl);
const range = {
start_line: line,
start_column: index + 1, // 1-based inclusive
end_line: line,
end_column: index + getStringLength(token), // 1-based inclusive
};
this.tokenHighlightListener({token, element, side, range});
}
}