blob: 58f3f7526e3e88ca8a838e88e9c6c193d06a785f [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
import {strToClassName} from '../../../utils/dom-util';
import {Side} from '../../../constants/constants';
import {CommentRange} from '../../../types/common';
import {DiffLayer, DiffLayerListener} from '../../../types/types';
import {isLongCommentRange} from '../gr-diff/gr-diff-utils';
/**
* Enhanced CommentRange by UI state. Interface for incoming ranges set from the
* outside.
*
* TODO(TS): Unify with what is used in gr-diff when these objects are created.
*/
export interface CommentRangeLayer {
side: Side;
range: CommentRange;
// New drafts don't have a rootId.
rootId?: string;
}
/** Can be used for array functions like `some()`. */
function equals(a: CommentRangeLayer) {
return (b: CommentRangeLayer) => id(a) === id(b);
}
function id(r: CommentRangeLayer): string {
if (r.rootId) return r.rootId;
return `${r.side}-${r.range.start_line}-${r.range.start_character}-${r.range.end_line}-${r.range.end_character}`;
}
/**
* This class breaks down all comment ranges into individual line segment
* highlights.
*/
interface CommentRangeLineLayer {
longRange: boolean;
id: string;
// start char (0-based)
start: number;
// end char (0-based)
end: number;
}
type LinesMap = {
[line in number]: CommentRangeLineLayer[];
};
type RangesMap = {
[side in Side]: LinesMap;
};
const RANGE_BASE_ONLY = 'gr-diff range';
const RANGE_HIGHLIGHT = 'gr-diff range rangeHighlight';
// Note that there is also `rangeHoverHighlight` being set by GrDiffHighlight.
/**
* This layer does not have a `reset` or `cleanup` method, so don't re-use it
* for rendering another diff. You should create a new layer then.
*/
export class GrRangedCommentLayer implements DiffLayer {
private knownRanges: CommentRangeLayer[] = [];
private listeners: DiffLayerListener[] = [];
private rangesMap: RangesMap = {left: {}, right: {}};
/**
* Layer method to add annotations to a line.
*
* @param el The DIV.contentText element to apply the annotation to.
*/
annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
let ranges: CommentRangeLineLayer[] = [];
if (
line.type === GrDiffLineType.REMOVE ||
(line.type === GrDiffLineType.BOTH &&
el.getAttribute('data-side') !== Side.RIGHT)
) {
ranges = this.getRangesForLine(line, Side.LEFT);
}
if (
line.type === GrDiffLineType.ADD ||
(line.type === GrDiffLineType.BOTH &&
el.getAttribute('data-side') !== Side.LEFT)
) {
ranges = this.getRangesForLine(line, Side.RIGHT);
}
for (const range of ranges) {
GrAnnotation.annotateElement(
el,
range.start,
range.end - range.start,
(range.longRange ? RANGE_BASE_ONLY : RANGE_HIGHLIGHT) +
` ${strToClassName(range.id)}`
);
}
}
/**
* Register a listener for layer updates.
*/
addListener(listener: DiffLayerListener) {
this.listeners.push(listener);
}
removeListener(listener: DiffLayerListener) {
this.listeners = this.listeners.filter(f => f !== listener);
}
/**
* Notify Layer listeners of changes to annotations.
*/
private notifyUpdateRange(start: number, end: number, side: Side) {
for (const listener of this.listeners) {
listener(start, end, side);
}
}
updateRanges(newRanges: CommentRangeLayer[]) {
for (const newRange of newRanges) {
if (this.knownRanges.some(equals(newRange))) continue;
this.addRange(newRange);
}
for (const knownRange of this.knownRanges) {
if (newRanges.some(equals(knownRange))) continue;
this.removeRange(knownRange);
}
this.knownRanges = [...newRanges];
}
private addRange(commentRange: CommentRangeLayer) {
const {side, range} = commentRange;
const longRange = isLongCommentRange(range);
this.updateRangesMap({
side,
range,
operation: (forLine, startChar, endChar) => {
forLine.push({
start: startChar,
end: endChar,
id: id(commentRange),
longRange,
});
},
});
}
private removeRange(commentRange: CommentRangeLayer) {
const {side, range} = commentRange;
this.updateRangesMap({
side,
range,
operation: forLine => {
const index = forLine.findIndex(
lineRange => id(commentRange) === lineRange.id
);
if (index > -1) forLine.splice(index, 1);
},
});
}
private updateRangesMap(options: {
side: Side;
range: CommentRange;
operation: (
forLine: CommentRangeLineLayer[],
start: number,
end: number
) => void;
}) {
const {side, range, operation} = options;
const forSide = this.rangesMap[side] || (this.rangesMap[side] = {});
for (let line = range.start_line; line <= range.end_line; line++) {
const forLine = forSide[line] || (forSide[line] = []);
const start = line === range.start_line ? range.start_character : 0;
const end = line === range.end_line ? range.end_character : -1;
operation(forLine, start, end);
}
this.notifyUpdateRange(range.start_line, range.end_line, side);
}
// visible for testing
getRangesForLine(line: GrDiffLine, side: Side): CommentRangeLineLayer[] {
const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
if (lineNum === 'FILE' || lineNum === 'LOST') return [];
const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
return ranges.map(range => {
// Make a copy, so that the normalization below does not mess with
// our map.
range = {...range};
range.end = range.end === -1 ? line.text.length : range.end;
// Normalize invalid ranges where the start is after the end but the
// start still makes sense. Set the end to the end of the line.
// @see Issue 5744
if (range.start >= range.end && range.start < line.text.length) {
range.end = line.text.length;
}
return range;
});
}
}