Commit e9f7d26f authored by Jacob Schatz's avatar Jacob Schatz

Merge branch 'acet-repo-editor-fix-link-linking' into 'master'

RepoEditor: Implement line and range linking.

Closes #38254

See merge request gitlab-org/gitlab-ce!14448
parents 6745563c e801ee27
...@@ -28,148 +28,149 @@ ...@@ -28,148 +28,149 @@
// </div> // </div>
// </div> // </div>
// //
(function() {
this.LineHighlighter = (function() {
// CSS class applied to highlighted lines
LineHighlighter.prototype.highlightClass = 'hll';
// Internal copy of location.hash so we're not dependent on `location` in tests
LineHighlighter.prototype._hash = '';
function LineHighlighter(hash) {
if (hash == null) {
// Initialize a LineHighlighter object
//
// hash - String URL hash for dependency injection in tests
hash = location.hash;
}
this.setHash = this.setHash.bind(this);
this.highlightLine = this.highlightLine.bind(this);
this.clickHandler = this.clickHandler.bind(this);
this.highlightHash = this.highlightHash.bind(this);
this._hash = hash;
this.bindEvents();
this.highlightHash();
}
LineHighlighter.prototype.bindEvents = function() { const LineHighlighter = function(options = {}) {
const $fileHolder = $('.file-holder'); options.highlightLineClass = options.highlightLineClass || 'hll';
$fileHolder.on('click', 'a[data-line-number]', this.clickHandler); options.fileHolderSelector = options.fileHolderSelector || '.file-holder';
$fileHolder.on('highlight:line', this.highlightHash); options.scrollFileHolder = options.scrollFileHolder || false;
}; options.hash = options.hash || location.hash;
LineHighlighter.prototype.highlightHash = function() {
var range;
if (this._hash !== '') {
range = this.hashToRange(this._hash);
if (range[0]) {
this.highlightRange(range);
$.scrollTo("#L" + range[0], {
// Scroll to the first highlighted line on initial load
// Offset -50 for the sticky top bar, and another -100 for some context
offset: -150
});
}
}
};
LineHighlighter.prototype.clickHandler = function(event) {
var current, lineNumber, range;
event.preventDefault();
this.clearHighlight();
lineNumber = $(event.target).closest('a').data('line-number');
current = this.hashToRange(this._hash);
if (!(current[0] && event.shiftKey)) {
// If there's no current selection, or there is but Shift wasn't held,
// treat this like a single-line selection.
this.setHash(lineNumber);
return this.highlightLine(lineNumber);
} else if (event.shiftKey) {
if (lineNumber < current[0]) {
range = [lineNumber, current[0]];
} else {
range = [current[0], lineNumber];
}
this.setHash(range[0], range[1]);
return this.highlightRange(range);
}
};
LineHighlighter.prototype.clearHighlight = function() {
return $("." + this.highlightClass).removeClass(this.highlightClass);
// Unhighlight previously highlighted lines
};
// Convert a URL hash String into line numbers
//
// hash - Hash String
//
// Examples:
//
// hashToRange('#L5') # => [5, null]
// hashToRange('#L5-15') # => [5, 15]
// hashToRange('#foo') # => [null, null]
//
// Returns an Array
LineHighlighter.prototype.hashToRange = function(hash) {
var first, last, matches;
// ?L(\d+)(?:-(\d+))?$/)
matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/);
if (matches && matches.length) {
first = parseInt(matches[1], 10);
last = matches[2] ? parseInt(matches[2], 10) : null;
return [first, last];
} else {
return [null, null];
}
};
// Highlight a single line
//
// lineNumber - Line number to highlight
LineHighlighter.prototype.highlightLine = function(lineNumber) {
return $("#LC" + lineNumber).addClass(this.highlightClass);
};
// Highlight all lines within a range
//
// range - Array containing the starting and ending line numbers
LineHighlighter.prototype.highlightRange = function(range) {
var i, lineNumber, ref, ref1, results;
if (range[1]) {
results = [];
for (lineNumber = i = ref = range[0], ref1 = range[1]; ref <= ref1 ? i <= ref1 : i >= ref1; lineNumber = ref <= ref1 ? (i += 1) : (i -= 1)) {
results.push(this.highlightLine(lineNumber));
}
return results;
} else {
return this.highlightLine(range[0]);
}
};
// Set the URL hash string this.options = options;
LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) { this._hash = options.hash;
var hash; this.highlightLineClass = options.highlightLineClass;
if (lastLineNumber) { this.setHash = this.setHash.bind(this);
hash = "#L" + firstLineNumber + "-" + lastLineNumber; this.highlightLine = this.highlightLine.bind(this);
this.clickHandler = this.clickHandler.bind(this);
this.highlightHash = this.highlightHash.bind(this);
this.bindEvents();
this.highlightHash();
};
LineHighlighter.prototype.bindEvents = function() {
const $fileHolder = $(this.options.fileHolderSelector);
$fileHolder.on('click', 'a[data-line-number]', this.clickHandler);
$fileHolder.on('highlight:line', this.highlightHash);
};
LineHighlighter.prototype.highlightHash = function() {
var range;
if (this._hash !== '') {
range = this.hashToRange(this._hash);
if (range[0]) {
this.highlightRange(range);
const lineSelector = `#L${range[0]}`;
const scrollOptions = {
// Scroll to the first highlighted line on initial load
// Offset -50 for the sticky top bar, and another -100 for some context
offset: -150
};
if (this.options.scrollFileHolder) {
$(this.options.fileHolderSelector).scrollTo(lineSelector, scrollOptions);
} else { } else {
hash = "#L" + firstLineNumber; $.scrollTo(lineSelector, scrollOptions);
} }
this._hash = hash; }
return this.__setLocationHash__(hash); }
}; };
// Make the actual hash change in the browser LineHighlighter.prototype.clickHandler = function(event) {
// var current, lineNumber, range;
// This method is stubbed in tests. event.preventDefault();
LineHighlighter.prototype.__setLocationHash__ = function(value) { this.clearHighlight();
return history.pushState({ lineNumber = $(event.target).closest('a').data('line-number');
url: value current = this.hashToRange(this._hash);
// We're using pushState instead of assigning location.hash directly to if (!(current[0] && event.shiftKey)) {
// prevent the page from scrolling on the hashchange event // If there's no current selection, or there is but Shift wasn't held,
}, document.title, value); // treat this like a single-line selection.
}; this.setHash(lineNumber);
return this.highlightLine(lineNumber);
return LineHighlighter; } else if (event.shiftKey) {
})(); if (lineNumber < current[0]) {
}).call(window); range = [lineNumber, current[0]];
} else {
range = [current[0], lineNumber];
}
this.setHash(range[0], range[1]);
return this.highlightRange(range);
}
};
LineHighlighter.prototype.clearHighlight = function() {
return $("." + this.highlightLineClass).removeClass(this.highlightLineClass);
};
// Convert a URL hash String into line numbers
//
// hash - Hash String
//
// Examples:
//
// hashToRange('#L5') # => [5, null]
// hashToRange('#L5-15') # => [5, 15]
// hashToRange('#foo') # => [null, null]
//
// Returns an Array
LineHighlighter.prototype.hashToRange = function(hash) {
var first, last, matches;
// ?L(\d+)(?:-(\d+))?$/)
matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/);
if (matches && matches.length) {
first = parseInt(matches[1], 10);
last = matches[2] ? parseInt(matches[2], 10) : null;
return [first, last];
} else {
return [null, null];
}
};
// Highlight a single line
//
// lineNumber - Line number to highlight
LineHighlighter.prototype.highlightLine = function(lineNumber) {
return $("#LC" + lineNumber).addClass(this.highlightLineClass);
};
// Highlight all lines within a range
//
// range - Array containing the starting and ending line numbers
LineHighlighter.prototype.highlightRange = function(range) {
var i, lineNumber, ref, ref1, results;
if (range[1]) {
results = [];
for (lineNumber = i = ref = range[0], ref1 = range[1]; ref <= ref1 ? i <= ref1 : i >= ref1; lineNumber = ref <= ref1 ? (i += 1) : (i -= 1)) {
results.push(this.highlightLine(lineNumber));
}
return results;
} else {
return this.highlightLine(range[0]);
}
};
// Set the URL hash string
LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) {
var hash;
if (lastLineNumber) {
hash = "#L" + firstLineNumber + "-" + lastLineNumber;
} else {
hash = "#L" + firstLineNumber;
}
this._hash = hash;
return this.__setLocationHash__(hash);
};
// Make the actual hash change in the browser
//
// This method is stubbed in tests.
LineHighlighter.prototype.__setLocationHash__ = function(value) {
return history.pushState({
url: value
// We're using pushState instead of assigning location.hash directly to
// prevent the page from scrolling on the hashchange event
}, document.title, value);
};
window.LineHighlighter = LineHighlighter;
<script> <script>
/* global LineHighlighter */
import Store from '../stores/repo_store'; import Store from '../stores/repo_store';
export default { export default {
data: () => Store, data: () => Store,
mounted() {
this.highlightFile();
},
computed: { computed: {
html() { html() {
return this.activeFile.html; return this.activeFile.html;
}, },
}, },
methods: { methods: {
highlightFile() { highlightFile() {
$(this.$el).find('.file-content').syntaxHighlight(); $(this.$el).find('.file-content').syntaxHighlight();
}, },
}, },
mounted() {
this.highlightFile();
this.lineHighlighter = new LineHighlighter({
fileHolderSelector: '.blob-viewer-container',
scrollFileHolder: true,
});
},
watch: { watch: {
html() { html() {
this.$nextTick(() => { this.$nextTick(() => {
......
...@@ -54,6 +54,10 @@ ...@@ -54,6 +54,10 @@
border-radius: $border-radius-default; border-radius: $border-radius-default;
color: $almost-black; color: $almost-black;
.code.white pre .hll {
background-color: $well-light-border !important;
}
.tree-content-holder { .tree-content-holder {
display: flex; display: flex;
min-height: 300px; min-height: 300px;
......
...@@ -18,19 +18,25 @@ import '~/line_highlighter'; ...@@ -18,19 +18,25 @@ import '~/line_highlighter';
beforeEach(function() { beforeEach(function() {
loadFixtures('static/line_highlighter.html.raw'); loadFixtures('static/line_highlighter.html.raw');
this["class"] = new LineHighlighter(); this["class"] = new LineHighlighter();
this.css = this["class"].highlightClass; this.css = this["class"].highlightLineClass;
return this.spies = { return this.spies = {
__setLocationHash__: spyOn(this["class"], '__setLocationHash__').and.callFake(function() {}) __setLocationHash__: spyOn(this["class"], '__setLocationHash__').and.callFake(function() {})
}; };
}); });
describe('behavior', function() { describe('behavior', function() {
it('highlights one line given in the URL hash', function() { it('highlights one line given in the URL hash', function() {
new LineHighlighter('#L13'); new LineHighlighter({ hash: '#L13' });
return expect($('#LC13')).toHaveClass(this.css); return expect($('#LC13')).toHaveClass(this.css);
}); });
it('highlights one line given in the URL hash with given CSS class name', function() {
const hiliter = new LineHighlighter({ hash: '#L13', highlightLineClass: 'hilite' });
expect(hiliter.highlightLineClass).toBe('hilite');
expect($('#LC13')).toHaveClass('hilite');
expect($('#LC13')).not.toHaveClass('hll');
});
it('highlights a range of lines given in the URL hash', function() { it('highlights a range of lines given in the URL hash', function() {
var line, results; var line, results;
new LineHighlighter('#L5-25'); new LineHighlighter({ hash: '#L5-25' });
expect($("." + this.css).length).toBe(21); expect($("." + this.css).length).toBe(21);
results = []; results = [];
for (line = 5; line <= 25; line += 1) { for (line = 5; line <= 25; line += 1) {
...@@ -41,7 +47,7 @@ import '~/line_highlighter'; ...@@ -41,7 +47,7 @@ import '~/line_highlighter';
it('scrolls to the first highlighted line on initial load', function() { it('scrolls to the first highlighted line on initial load', function() {
var spy; var spy;
spy = spyOn($, 'scrollTo'); spy = spyOn($, 'scrollTo');
new LineHighlighter('#L5-25'); new LineHighlighter({ hash: '#L5-25' });
return expect(spy).toHaveBeenCalledWith('#L5', jasmine.anything()); return expect(spy).toHaveBeenCalledWith('#L5', jasmine.anything());
}); });
it('discards click events', function() { it('discards click events', function() {
...@@ -50,10 +56,10 @@ import '~/line_highlighter'; ...@@ -50,10 +56,10 @@ import '~/line_highlighter';
clickLine(13); clickLine(13);
return expect(spy).toHaveBeenPrevented(); return expect(spy).toHaveBeenPrevented();
}); });
return it('handles garbage input from the hash', function() { it('handles garbage input from the hash', function() {
var func; var func;
func = function() { func = function() {
return new LineHighlighter('#blob-content-holder'); return new LineHighlighter({ fileHolderSelector: '#blob-content-holder' });
}; };
return expect(func).not.toThrow(); return expect(func).not.toThrow();
}); });
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment