Files
gerrit/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
Ole Rehmsen 201610028d Make gr-diff-selection work with native Shadow DOM
With native Shadow DOM, the selections returned by document.getSelection()
cannot reference the elements actually making up the diff because those
live in the Shadow DOM of gr-diff. Instead, a shadowRoot.getSelection()
method is offered that works as the pre-Shadow DOM Selection used to,
but that one is only available from gr-diff.

This change moves listening for the selectionchange event and retrieving
the selection to gr-diff, which then delegates the extraction and
normalization of the ranges to gr-diff-highlight (it's current
location).

This change also fixes an incompatibility with native Shadow DOM in
gr-selection-action-box, where a call to parentElement cannot cross the
Shadow DOM boundary and needs special handling to retrieve the Shadow
Host.

Together these changes fix creating range comments for native Shadow
DOM.

Bug: Issue 6372
Change-Id: I4bce21025ab5a4c06d77db096d6aa56e7840987b
2018-12-18 16:56:10 +01:00

872 lines
26 KiB
JavaScript

/**
* @license
* Copyright (C) 2016 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.
*/
(function() {
'use strict';
const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
'of an edit.';
const ERR_INVALID_LINE = 'Invalid line number: ';
const NO_NEWLINE_BASE = 'No newline at end of base file.';
const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
const DiffViewMode = {
SIDE_BY_SIDE: 'SIDE_BY_SIDE',
UNIFIED: 'UNIFIED_DIFF',
};
const DiffSide = {
LEFT: 'left',
RIGHT: 'right',
};
const Defs = {};
/**
* Special line number which should not be collapsed into a shared region.
*
* @typedef {{
* number: number,
* leftSide: boolean
* }}
*/
Defs.LineOfInterest;
const LARGE_DIFF_THRESHOLD_LINES = 10000;
const FULL_CONTEXT = -1;
const LIMITED_CONTEXT = 10;
/** @typedef {{start_line: number, start_character: number,
* end_line: number, end_character: number}} */
Gerrit.Range;
/**
* Compare two ranges. Either argument may be falsy, but will only return
* true if both are falsy or if neither are falsy and have the same position
* values.
*
* @param {Gerrit.Range=} a range 1
* @param {Gerrit.Range=} b range 2
* @return {boolean}
*/
Gerrit.rangesEqual = function(a, b) {
if (!a && !b) { return true; }
if (!a || !b) { return false; }
return a.start_line === b.start_line &&
a.start_character === b.start_character &&
a.end_line === b.end_line &&
a.end_character === b.end_character;
};
function isThreadEl(node) {
return node.nodeType === Node.ELEMENT_NODE &&
node.classList.contains('comment-thread');
}
/**
* Turn a slot element into the corresponding content element.
* Slots are only fully supported in Polymer 2 - in Polymer 1, they are
* replaced with content elements during template parsing. This conversion is
* not applied for imperatively created slot elements, so this method
* implements the same behavior as the template parsing for imperative slots.
*/
Gerrit.slotToContent = function(slot) {
const content = document.createElement('content');
content.name = slot.name;
content.setAttribute('select', `[slot='${slot.name}']`);
return content;
};
Polymer({
is: 'gr-diff',
/**
* Fired when the user selects a line.
* @event line-selected
*/
/**
* Fired if being logged in is required.
*
* @event show-auth-required
*/
/**
* Fired when a comment is created
*
* @event create-comment
*/
properties: {
changeNum: String,
noAutoRender: {
type: Boolean,
value: false,
},
/** @type {?} */
patchRange: Object,
path: String,
prefs: {
type: Object,
observer: '_prefsObserver',
},
projectName: String,
displayLine: {
type: Boolean,
value: false,
},
isImageDiff: {
type: Boolean,
},
commitRange: Object,
hidden: {
type: Boolean,
reflectToAttribute: true,
},
noRenderOnPrefsChange: Boolean,
/** @type {!Array<!Gerrit.HoveredRange>} */
_commentRanges: {
type: Array,
value: () => [],
},
lineWrapping: {
type: Boolean,
value: false,
observer: '_lineWrappingObserver',
},
viewMode: {
type: String,
value: DiffViewMode.SIDE_BY_SIDE,
observer: '_viewModeObserver',
},
/** @type ?Defs.LineOfInterest */
lineOfInterest: Object,
/**
* The key locations based on the comments and line of interests,
* where lines should not be collapsed.
*
* @type {{left: Object<(string|number), number>,
* right: Object<(string|number), number>}}
*/
_keyLocations: {
type: Object,
value: () => ({
left: {},
right: {},
}),
},
loading: {
type: Boolean,
value: false,
observer: '_loadingChanged',
},
loggedIn: {
type: Boolean,
value: false,
},
diff: {
type: Object,
observer: '_diffChanged',
},
_diffHeaderItems: {
type: Array,
value: [],
computed: '_computeDiffHeaderItems(diff.*)',
},
_diffTableClass: {
type: String,
value: '',
},
/** @type {?Object} */
baseImage: Object,
/** @type {?Object} */
revisionImage: Object,
/**
* Whether the safety check for large diffs when whole-file is set has
* been bypassed. If the value is null, then the safety has not been
* bypassed. If the value is a number, then that number represents the
* context preference to use when rendering the bypassed diff.
*
* @type (number|null)
*/
_safetyBypass: {
type: Number,
value: null,
},
_showWarning: Boolean,
/** @type {?string} */
errorMessage: {
type: String,
value: null,
},
/** @type {?Object} */
blame: {
type: Object,
value: null,
observer: '_blameChanged',
},
parentIndex: Number,
_newlineWarning: {
type: String,
computed: '_computeNewlineWarning(diff)',
},
_diffLength: Number,
/**
* Observes comment nodes added or removed after the initial render.
* Can be used to unregister when the entire diff is (re-)rendered or upon
* detachment.
* @type {?PolymerDomApi.ObserveHandle}
*/
_incrementalNodeObserver: Object,
/**
* Observes comment nodes added or removed at any point.
* Can be used to unregister upon detachment.
* @type {?PolymerDomApi.ObserveHandle}
*/
_nodeObserver: Object,
/** Set by Polymer. */
isAttached: Boolean,
},
behaviors: [
Gerrit.PatchSetBehavior,
],
listeners: {
'create-range-comment': '_handleCreateRangeComment',
'render-content': '_handleRenderContent',
},
observers: [
'_enableSelectionObserver(loggedIn, isAttached)',
],
attached() {
this._observeNodes();
},
detached() {
this._unobserveIncrementalNodes();
this._unobserveNodes();
},
_enableSelectionObserver(loggedIn, isAttached) {
if (loggedIn && isAttached) {
this.listen(document, 'selectionchange', '_handleSelectionChange');
} else {
this.unlisten(document, 'selectionchange', '_handleSelectionChange');
}
},
_handleSelectionChange() {
// When using native shadow DOM, the selection returned by
// document.getSelection() cannot reference the actual DOM elements making
// up the diff, because they are in the shadow DOM of the gr-diff element.
// For this reason, we handle the selectionchange here, and pass the
// shadow DOM selection into gr-diff-highlight, where the corresponding
// range is determined and normalized.
const selection = this.root.getSelection ?
this.root.getSelection() :
document.getSelection();
this.$.highlights.handleSelectionChange(selection);
},
_observeNodes() {
this._nodeObserver = Polymer.dom(this).observeNodes(info => {
const addedThreadEls = info.addedNodes.filter(isThreadEl);
const removedThreadEls = info.removedNodes.filter(isThreadEl);
this._updateRanges(addedThreadEls, removedThreadEls);
this._updateKeyLocations(addedThreadEls, removedThreadEls);
this._redispatchHoverEvents(addedThreadEls);
});
},
_updateRanges(addedThreadEls, removedThreadEls) {
function commentRangeFromThreadEl(threadEl) {
const side = threadEl.getAttribute('comment-side');
const range = JSON.parse(threadEl.getAttribute('range'));
return {side, range, hovering: false};
}
const addedCommentRanges = addedThreadEls
.map(commentRangeFromThreadEl)
.filter(({range}) => range);
const removedCommentRanges = removedThreadEls
.map(commentRangeFromThreadEl)
.filter(({range}) => range);
for (const removedCommentRange of removedCommentRanges) {
const i = this._commentRanges.findIndex(commentRange => {
return commentRange.side === removedCommentRange.side &&
Gerrit.rangesEqual(commentRange.range, removedCommentRange.range);
});
this.splice('_commentRanges', i, 1);
}
this.push('_commentRanges', ...addedCommentRanges);
},
_updateKeyLocations(addedThreadEls, removedThreadEls) {
for (const threadEl of addedThreadEls) {
const commentSide = threadEl.getAttribute('comment-side');
const lineNum = threadEl.getAttribute('line-num') || GrDiffLine.FILE;
this._keyLocations[commentSide][lineNum] = true;
}
for (const threadEl of removedThreadEls) {
const commentSide = threadEl.getAttribute('comment-side');
const lineNum = threadEl.getAttribute('line-num') || GrDiffLine.FILE;
this._keyLocations[commentSide][lineNum] = false;
}
},
// Dispatch events that are handled by the gr-diff-highlight.
_redispatchHoverEvents(addedThreadEls) {
for (const threadEl of addedThreadEls) {
threadEl.addEventListener('mouseenter', () => {
threadEl.dispatchEvent(
new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
});
threadEl.addEventListener('mouseleave', () => {
threadEl.dispatchEvent(
new CustomEvent('comment-thread-mouseleave', {bubbles: true}));
});
}
},
/** Cancel any remaining diff builder rendering work. */
cancel() {
this.$.diffBuilder.cancel();
},
/** @return {!Array<!HTMLElement>} */
getCursorStops() {
if (this.hidden && this.noAutoRender) {
return [];
}
return Polymer.dom(this.root).querySelectorAll('.diff-row');
},
/** @return {boolean} */
isRangeSelected() {
return this.$.highlights.isRangeSelected();
},
toggleLeftDiff() {
this.toggleClass('no-left');
},
_blameChanged(newValue) {
this.$.diffBuilder.setBlame(newValue);
if (newValue) {
this.classList.add('showBlame');
} else {
this.classList.remove('showBlame');
}
},
/** @return {string} */
_computeContainerClass(loggedIn, viewMode, displayLine) {
const classes = ['diffContainer'];
switch (viewMode) {
case DiffViewMode.UNIFIED:
classes.push('unified');
break;
case DiffViewMode.SIDE_BY_SIDE:
classes.push('sideBySide');
break;
default:
throw Error('Invalid view mode: ', viewMode);
}
if (Gerrit.hiddenscroll) {
classes.push('hiddenscroll');
}
if (loggedIn) {
classes.push('canComment');
}
if (displayLine) {
classes.push('displayLine');
}
return classes.join(' ');
},
_handleTap(e) {
const el = Polymer.dom(e).localTarget;
if (el.classList.contains('showContext')) {
this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
} else if (el.classList.contains('lineNum')) {
this.addDraftAtLine(el);
} else if (el.tagName === 'HL' ||
el.classList.contains('content') ||
el.classList.contains('contentText')) {
const target = this.$.diffBuilder.getLineElByChild(el);
if (target) { this._selectLine(target); }
}
},
_selectLine(el) {
this.fire('line-selected', {
side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
number: el.getAttribute('data-value'),
path: this.path,
});
},
addDraftAtLine(el) {
this._selectLine(el);
if (!this._isValidElForComment(el)) { return; }
const value = el.getAttribute('data-value');
let lineNum;
if (value !== GrDiffLine.FILE) {
lineNum = parseInt(value, 10);
if (isNaN(lineNum)) {
this.fire('show-alert', {message: ERR_INVALID_LINE + value});
return;
}
}
this._createComment(el, lineNum);
},
_handleCreateRangeComment(e) {
const range = e.detail.range;
const side = e.detail.side;
const lineNum = range.end_line;
const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
if (this._isValidElForComment(lineEl)) {
this._createComment(lineEl, lineNum, side, range);
}
},
/** @return {boolean} */
_isValidElForComment(el) {
if (!this.loggedIn) {
this.fire('show-auth-required');
return false;
}
const patchNum = el.classList.contains(DiffSide.LEFT) ?
this.patchRange.basePatchNum :
this.patchRange.patchNum;
const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
if (isEdit) {
this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
return false;
} else if (isEditBase) {
this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT_BASE});
return false;
}
return true;
},
/**
* @param {!Object} lineEl
* @param {number=} lineNum
* @param {string=} side
* @param {!Object=} range
*/
_createComment(lineEl, lineNum=undefined, side=undefined, range=undefined) {
const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
const contentEl = contentText.parentElement;
side = side ||
this._getCommentSideByLineAndContent(lineEl, contentEl);
const patchForNewThreads = this._getPatchNumByLineAndContent(
lineEl, contentEl);
const isOnParent =
this._getIsParentCommentByLineAndContent(lineEl, contentEl);
this.dispatchEvent(new CustomEvent('create-comment', {
bubbles: true,
detail: {
lineNum,
side,
patchNum: patchForNewThreads,
isOnParent,
range,
},
}));
},
_getThreadGroupForLine(contentEl) {
return contentEl.querySelector('.thread-group');
},
/**
* Gets or creates a comment thread group for a specific line and side on a
* diff.
* @param {!Object} contentEl
* @param {!Gerrit.DiffSide} commentSide
* @return {!Node}
*/
_getOrCreateThreadGroup(contentEl, commentSide) {
// Check if thread group exists.
let threadGroupEl = this._getThreadGroupForLine(contentEl);
if (!threadGroupEl) {
threadGroupEl = document.createElement('div');
threadGroupEl.className = 'thread-group';
threadGroupEl.setAttribute('data-side', commentSide);
contentEl.appendChild(threadGroupEl);
}
return threadGroupEl;
},
/**
* The value to be used for the patch number of new comments created at the
* given line and content elements.
*
* In two cases of creating a comment on the left side, the patch number to
* be used should actually be right side of the patch range:
* - When the patch range is against the parent comment of a normal change.
* Such comments declare themmselves to be on the left using side=PARENT.
* - If the patch range is against the indexed parent of a merge change.
* Such comments declare themselves to be on the given parent by
* specifying the parent index via parent=i.
*
* @return {number}
*/
_getPatchNumByLineAndContent(lineEl, contentEl) {
let patchNum = this.patchRange.patchNum;
if ((lineEl.classList.contains(DiffSide.LEFT) ||
contentEl.classList.contains('remove')) &&
this.patchRange.basePatchNum !== 'PARENT' &&
!this.isMergeParent(this.patchRange.basePatchNum)) {
patchNum = this.patchRange.basePatchNum;
}
return patchNum;
},
/** @return {boolean} */
_getIsParentCommentByLineAndContent(lineEl, contentEl) {
if ((lineEl.classList.contains(DiffSide.LEFT) ||
contentEl.classList.contains('remove')) &&
(this.patchRange.basePatchNum === 'PARENT' ||
this.isMergeParent(this.patchRange.basePatchNum))) {
return true;
}
return false;
},
/** @return {string} */
_getCommentSideByLineAndContent(lineEl, contentEl) {
let side = 'right';
if (lineEl.classList.contains(DiffSide.LEFT) ||
contentEl.classList.contains('remove')) {
side = 'left';
}
return side;
},
_prefsObserver(newPrefs, oldPrefs) {
// Scan the preference objects one level deep to see if they differ.
let differ = !oldPrefs;
if (newPrefs && oldPrefs) {
for (const key in newPrefs) {
if (newPrefs[key] !== oldPrefs[key]) {
differ = true;
}
}
}
if (differ) {
this._prefsChanged(newPrefs);
}
},
_viewModeObserver() {
this._prefsChanged(this.prefs);
},
/** @param {boolean} newValue */
_loadingChanged(newValue) {
if (newValue) {
this.cancel();
this._blame = null;
this._safetyBypass = null;
this._showWarning = false;
this.clearDiffContent();
}
},
_lineWrappingObserver() {
this._prefsChanged(this.prefs);
},
_prefsChanged(prefs) {
if (!prefs) { return; }
this._blame = null;
const stylesToUpdate = {};
if (prefs.line_wrapping) {
this._diffTableClass = 'full-width';
if (this.viewMode === 'SIDE_BY_SIDE') {
stylesToUpdate['--content-width'] = 'none';
stylesToUpdate['--line-limit'] = prefs.line_length + 'ch';
}
} else {
this._diffTableClass = '';
stylesToUpdate['--content-width'] = prefs.line_length + 'ch';
}
if (prefs.font_size) {
stylesToUpdate['--font-size'] = prefs.font_size + 'px';
}
this.updateStyles(stylesToUpdate);
if (this.diff && !this.noRenderOnPrefsChange) {
this._renderDiffTable();
}
},
_diffChanged(newValue) {
if (newValue) {
this._diffLength = this.$.diffBuilder.getDiffLength();
this._renderDiffTable();
}
},
_renderDiffTable() {
this._unobserveIncrementalNodes();
if (!this.prefs) {
this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
return;
}
if (this.prefs.context === -1 &&
this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
this._safetyBypass === null) {
this._showWarning = true;
this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
return;
}
this._showWarning = false;
if (this.lineOfInterest) {
const side = this.lineOfInterest.leftSide ? 'left' : 'right';
this._keyLocations[side][this.lineOfInterest.number] = true;
}
this.$.diffBuilder.render(this._keyLocations, this._getBypassPrefs());
},
_handleRenderContent() {
this._incrementalNodeObserver = Polymer.dom(this).observeNodes(info => {
const addedThreadEls = info.addedNodes.filter(isThreadEl);
// Removed nodes do not need to be handled because all this code does is
// adding a slot for the added thread elements, and the extra slots do
// not hurt. It's probably a bigger performance cost to remove them than
// to keep them around. Medium term we can even consider to add one slot
// for each line from the start.
for (const threadEl of addedThreadEls) {
const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
const commentSide = threadEl.getAttribute('comment-side');
const lineEl = this.$.diffBuilder.getLineElByNumber(
lineNumString, commentSide);
const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
const contentEl = contentText.parentElement;
const threadGroupEl = this._getOrCreateThreadGroup(
contentEl, commentSide);
// Create a slot for the thread and attach it to the thread group.
// The Polyfill has some bugs and this only works if the slot is
// attached to the group after the group is attached to the DOM.
// The thread group may already have a slot with the right name, but
// that is okay because the first matching slot is used and the rest
// are ignored.
const slot = document.createElement('slot');
slot.name = threadEl.slot;
Polymer.dom(threadGroupEl).appendChild(Gerrit.slotToContent(slot));
}
});
},
_unobserveIncrementalNodes() {
if (this._incrementalNodeObserver) {
Polymer.dom(this).unobserveNodes(this._incrementalNodeObserver);
}
},
_unobserveNodes() {
if (this._nodeObserver) {
Polymer.dom(this).unobserveNodes(this._nodeObserver);
}
},
/**
* Get the preferences object including the safety bypass context (if any).
*/
_getBypassPrefs() {
if (this._safetyBypass !== null) {
return Object.assign({}, this.prefs, {context: this._safetyBypass});
}
return this.prefs;
},
clearDiffContent() {
this._unobserveIncrementalNodes();
this.$.diffTable.innerHTML = null;
},
/** @return {!Array} */
_computeDiffHeaderItems(diffInfoRecord) {
const diffInfo = diffInfoRecord.base;
if (!diffInfo || !diffInfo.diff_header) { return []; }
return diffInfo.diff_header.filter(item => {
return !(item.startsWith('diff --git ') ||
item.startsWith('index ') ||
item.startsWith('+++ ') ||
item.startsWith('--- ') ||
item === 'Binary files differ');
});
},
/** @return {boolean} */
_computeDiffHeaderHidden(items) {
return items.length === 0;
},
_handleFullBypass() {
this._safetyBypass = FULL_CONTEXT;
this._renderDiffTable();
},
_handleLimitedBypass() {
this._safetyBypass = LIMITED_CONTEXT;
this._renderDiffTable();
},
/** @return {string} */
_computeWarningClass(showWarning) {
return showWarning ? 'warn' : '';
},
/**
* @param {string} errorMessage
* @return {string}
*/
_computeErrorClass(errorMessage) {
return errorMessage ? 'showError' : '';
},
expandAllContext() {
this._handleFullBypass();
},
/**
* Find the last chunk for the given side.
* @param {!Object} diff
* @param {boolean} leftSide true if checking the base of the diff,
* false if testing the revision.
* @return {Object|null} returns the chunk object or null if there was
* no chunk for that side.
*/
_lastChunkForSide(diff, leftSide) {
if (!diff.content.length) { return null; }
let chunkIndex = diff.content.length;
let chunk;
// Walk backwards until we find a chunk for the given side.
do {
chunkIndex--;
chunk = diff.content[chunkIndex];
} while (
// We haven't reached the beginning.
chunkIndex >= 0 &&
// The chunk doesn't have both sides.
!chunk.ab &&
// The chunk doesn't have the given side.
((leftSide && !chunk.a) || (!leftSide && !chunk.b)));
// If we reached the beginning of the diff and failed to find a chunk
// with the given side, return null.
if (chunkIndex === -1) { return null; }
return chunk;
},
/**
* Check whether the specified side of the diff has a trailing newline.
* @param {!Object} diff
* @param {boolean} leftSide true if checking the base of the diff,
* false if testing the revision.
* @return {boolean|null} Return true if the side has a trailing newline.
* Return false if it doesn't. Return null if not applicable (for
* example, if the diff has no content on the specified side).
*/
_hasTrailingNewlines(diff, leftSide) {
const chunk = this._lastChunkForSide(diff, leftSide);
if (!chunk) { return null; }
let lines;
if (chunk.ab) {
lines = chunk.ab;
} else {
lines = leftSide ? chunk.a : chunk.b;
}
return lines[lines.length - 1] === '';
},
/**
* @param {!Object} diff
* @return {string|null}
*/
_computeNewlineWarning(diff) {
const hasLeft = this._hasTrailingNewlines(diff, true);
const hasRight = this._hasTrailingNewlines(diff, false);
const messages = [];
if (hasLeft === false) {
messages.push(NO_NEWLINE_BASE);
}
if (hasRight === false) {
messages.push(NO_NEWLINE_REVISION);
}
if (!messages.length) { return null; }
return messages.join(' — ');
},
/**
* @param {string} warning
* @param {boolean} loading
* @return {string}
*/
_computeNewlineWarningClass(warning, loading) {
if (loading || !warning) { return 'newlineWarning hidden'; }
return 'newlineWarning';
},
});
})();