blob: d01851718c20ca65f7616e8bcf5dcfb6834eb6ce [file] [log] [blame]
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {DiffLayer, DiffLayerListener} from '../../../types/types';
import {GrDiffLine, Side, TokenHighlightListener} from '../../../api/diff';
import {assertIsDefined} from '../../../utils/common-util';
import {GrAnnotation} 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;
/**
* Token highlighting is only useful for code on-screen, so we only highlight
* the nearest set of tokens up to this limit.
*/
const TOKEN_HIGHLIGHT_LIMIT = 100;
/**
* 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 only listener is typically the renderer of gr-diff. */
private listeners: DiffLayerListener[] = [];
/** 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 tokenToLinesLeft = new Map<string, Set<number>>();
private tokenToLinesRight = new Map<string, Set<number>>();
private hoveredElement?: Element;
private updateTokenTask?: DelayedTask;
constructor(
container: HTMLElement = document.documentElement,
tokenHighlightListener?: TokenHighlightListener
) {
this.tokenHighlightListener = tokenHighlightListener;
container.addEventListener('click', e => {
this.handleContainerClick(e);
});
}
annotate(
el: HTMLElement,
_: HTMLElement,
line: GrDiffLine,
side: 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];
const index = match.index;
const length = token.length;
// Binary files encoded as text for example can have super long lines
// with super long tokens. Let's guard against this scenario.
if (length > TOKEN_LENGTH_LIMIT) continue;
atLeastOneTokenMatched = true;
const highlightTypeClass =
token === this.currentHighlight ? CSS_HIGHLIGHT : CSS_TOKEN;
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.
GrAnnotation.annotateElement(
el,
index,
length,
`${textClass} ${indexClass} ${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.storeLineForToken(token, line, side);
}
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 storeLineForToken(token: string, line: GrDiffLine, side: Side) {
const tokenToLines =
side === Side.LEFT ? this.tokenToLinesLeft : this.tokenToLinesRight;
// Just to make sure that we don't break down on large files.
if (tokenToLines.size > TOKEN_COUNT_LIMIT) return;
let numbers = tokenToLines.get(token);
if (!numbers) {
numbers = new Set<number>();
tokenToLines.set(token, numbers);
}
// Just to make sure that we don't break down on large files.
if (numbers.size > TOKEN_OCCURRENCES_LIMIT) return;
const lineNumber =
side === Side.LEFT ? line.beforeNumber : line.afterNumber;
numbers.add(Number(lineNumber));
}
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 || newHighlight === this.currentHighlight) 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) ||
el.classList.contains(CSS_HIGHLIGHT)
) {
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;
const oldLineNumber = this.currentHighlightLineNumber;
this.currentHighlight = newHighlight;
this.currentHighlightLineNumber = newLineNumber;
this.triggerTokenHighlightEvent(
newHighlight,
newLineNumber,
newHoveredElement
);
this.notifyForToken(oldHighlight, oldLineNumber);
this.notifyForToken(newHighlight, newLineNumber);
}
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 + token.length, // 1-based inclusive
};
this.tokenHighlightListener({token, element, side, range});
}
getSortedLinesForSide(
lineMapping: Map<string, Set<number>>,
token: string | undefined,
lineNumber: number
): Array<number> {
if (!token) return [];
const lineSet = lineMapping.get(token);
if (!lineSet) return [];
const lines = [...lineSet];
lines.sort((a, b) => {
const da = Math.abs(a - lineNumber);
const db = Math.abs(b - lineNumber);
// For equal distance, prefer lines later in the file over earlier in the
// file. This ensures total ordering.
if (da === db) return b - a;
// Compare the distance to lineNumber.
return da - db;
});
return lines.slice(0, TOKEN_HIGHLIGHT_LIMIT);
}
notifyForToken(token: string | undefined, lineNumber: number) {
const leftLines = this.getSortedLinesForSide(
this.tokenToLinesLeft,
token,
lineNumber
);
for (const line of leftLines) {
this.notifyListeners(line, Side.LEFT);
}
const rightLines = this.getSortedLinesForSide(
this.tokenToLinesRight,
token,
lineNumber
);
for (const line of rightLines) {
this.notifyListeners(line, Side.RIGHT);
}
}
addListener(listener: DiffLayerListener) {
this.listeners.push(listener);
}
removeListener(listener: DiffLayerListener) {
this.listeners = this.listeners.filter(f => f !== listener);
}
notifyListeners(line: number, side: Side) {
for (const listener of this.listeners) {
listener(line, line, side);
}
}
}