Commit b3f9be06 authored by Robert Speicher's avatar Robert Speicher

Refactor and spec BlobView JS

parent 74a6732c
# BlobView
#
# Handles single- and multi-line selection and highlight for blob views.
#
#= require jquery.scrollTo
#
# ### Example Markup
#
# <div id="tree-content-holder">
# <div class="file-content">
# <div class="line-numbers">
# <a href="#L1" id="L1" data-line-number="1">1</a>
# <a href="#L2" id="L2" data-line-number="2">2</a>
# <a href="#L3" id="L3" data-line-number="3">3</a>
# <a href="#L4" id="L4" data-line-number="4">4</a>
# <a href="#L5" id="L5" data-line-number="5">5</a>
# </div>
# <pre class="code highlight">
# <code>
# <span id="LC1" class="line">...</span>
# <span id="LC2" class="line">...</span>
# <span id="LC3" class="line">...</span>
# <span id="LC4" class="line">...</span>
# <span id="LC5" class="line">...</span>
# </code>
# </pre>
# </div>
# </div>
class @BlobView
constructor: ->
# handle multi-line select
handleMultiSelect = (e) ->
[ first_line, last_line ] = parseSelectedLines()
[ line_number ] = parseSelectedLines($(this).attr("id"))
hash = "L#{line_number}"
if e.shiftKey and not isNaN(first_line) and not isNaN(line_number)
if line_number < first_line
last_line = first_line
first_line = line_number
# Internal copy of location.hash so we're not dependent on `location` in tests
@_hash = ''
# Initialize a BlobView object
#
# hash - String URL hash for dependency injection in tests
constructor: (hash = location.hash) ->
@_hash = hash
@bindEvents()
unless hash == ''
range = @hashToRange(hash)
unless isNaN(range[0])
@highlightRange(range)
# Scroll to the first highlighted line on initial load
# Offset -50 for the sticky top bar, and another -100 for some context
$.scrollTo("#L#{range[0]}", offset: -150)
bindEvents: ->
$('#tree-content-holder').on 'mousedown', 'a[data-line-number]', @clickHandler
# While it may seem odd to bind to the mousedown event and then throw away
# the click event, there is a method to our madness.
#
# If not done this way, the line number anchor will sometimes keep its
# active state even when the event is cancelled, resulting in an ugly border
# around the link and/or a persisted underline text decoration.
$('#tree-content-holder').on 'click', 'a[data-line-number]', (event) ->
event.preventDefault()
clickHandler: (event) =>
event.preventDefault()
lineNumber = $(event.target).data('line-number')
current = @hashToRange(@_hash)
# Unhighlight previously highlighted lines
$('.hll').removeClass('hll')
if isNaN(current[0]) or !event.shiftKey
# If there's no current selection, or there is but Shift wasn't held,
# treat this like a single-line selection.
@setHash(lineNumber)
@highlightLine(lineNumber)
else if event.shiftKey
if lineNumber < current[0]
range = [lineNumber, current[0]]
else
last_line = line_number
hash = if first_line == last_line then "L#{first_line}" else "L#{first_line}-#{last_line}"
setHash(hash)
e.preventDefault()
# See if there are lines selected
# "#L12" and "#L34-56" supported
highlightBlobLines = (e) ->
[ first_line, last_line ] = parseSelectedLines()
unless isNaN first_line
$("#tree-content-holder .highlight .line").removeClass("hll")
$("#LC#{line}").addClass("hll") for line in [first_line..last_line]
$.scrollTo("#L#{first_line}", offset: -50) unless e?
# parse selected lines from hash
# always return first and last line (initialized to NaN)
parseSelectedLines = (str) ->
first_line = NaN
last_line = NaN
hash = str || window.location.hash
if hash isnt ""
matches = hash.match(/\#?L(\d+)(\-(\d+))?/)
first_line = parseInt(matches?[1])
last_line = parseInt(matches?[3])
last_line = first_line if isNaN(last_line)
[ first_line, last_line ]
setHash = (hash) ->
hash = hash.replace(/^\#/, "")
nodes = $("#" + hash)
# if any nodes are using this id, they must be temporarily changed
# also, add a temporary div at the top of the screen to prevent scrolling
if nodes.length > 0
scroll_top = $(document).scrollTop()
nodes.attr("id", "")
tmp = $("<div></div>")
.css({ position: "absolute", visibility: "hidden", top: scroll_top + "px" })
.attr("id", hash)
.appendTo(document.body)
window.location.hash = hash
# restore the nodes
if nodes.length > 0
tmp.remove()
nodes.attr("id", hash)
# initialize multi-line select
$("#tree-content-holder .line-numbers a[id^=L]").on("click", handleMultiSelect)
# Highlight the correct lines on load
highlightBlobLines()
# Highlight the correct lines when the hash part of the URL changes
$(window).on("hashchange", highlightBlobLines)
range = [current[0], lineNumber]
@setHash(range[0], range[1])
@highlightRange(range)
# Convert a URL hash String into line numbers
#
# hash - Hash String
#
# Examples:
#
# hashToRange('#L5') # => [5, NaN]
# hashToRange('#L5-15') # => [5, 15]
# hashToRange('#foo') # => [NaN, NaN]
#
# Returns an Array
hashToRange: (hash) ->
first = parseInt(hash.replace(/^#L(\d+)/, '$1'))
last = parseInt(hash.replace(/^#L\d+-(\d+)/, '$1'))
[first, last]
# Highlight a single line
#
# lineNumber - Number to highlight. Must be parsable as an Integer.
#
# Returns undefined if lineNumber is not parsable as an Integer.
highlightLine: (lineNumber) ->
return if isNaN(parseInt(lineNumber))
$("#LC#{lineNumber}").addClass('hll')
# Highlight all lines within a range
#
# range - An Array of starting and ending line numbers.
#
# Examples:
#
# # Highlight lines 5 through 15
# highlightRange([5, 15])
#
# # The first value is required, and must be a number
# highlightRange(['foo', 15]) # Invalid, returns undefined
# highlightRange([NaN, NaN]) # Invalid, returns undefined
#
# # The second value is optional; if omitted, only highlights the first line
# highlightRange([5, NaN]) # Valid
#
# Returns undefined if the first line is NaN.
highlightRange: (range) ->
return if isNaN(range[0])
if isNaN(range[1])
@highlightLine(range[0])
else
for lineNumber in [range[0]..range[1]]
@highlightLine(lineNumber)
setHash: (firstLineNumber, lastLineNumber) =>
return if isNaN(parseInt(firstLineNumber))
if isNaN(parseInt(lastLineNumber))
hash = "#L#{firstLineNumber}"
else
hash = "#L#{firstLineNumber}-#{lastLineNumber}"
@setHashWithoutScroll(hash)
# Prevents the page from scrolling when `location.hash` is set
#
# This is accomplished by removing the `id` attribute of the matching element,
# creating a temporary div at the top of the current viewport, setting the
# hash, and then removing the div and restoring the `id` attribute.
#
# See http://stackoverflow.com/a/1489802/223897
#
# FIXME (rspeicher): This is still super buggy for me.
setHashWithoutScroll: (hash) ->
@_hash = hash
# Extract the first ID, in case we were given a range
firstID = hash.replace(/-\d+$/, '')
$node = $(firstID)
$node.removeAttr('id')
$tmp = $('<div></div>')
.css(
position: 'absolute'
top: "#{$(window).scrollTop()}px"
visibility: 'hidden'
)
.attr('id', firstID)
.appendTo($('body'))
@__setLocationHash__(hash)
$tmp.remove()
$node.attr('id', firstID)
# Make the actual `location.hash` change
#
# This method is stubbed in tests.
__setLocationHash__: (value) ->
location.hash = value
......@@ -4,8 +4,8 @@
- blob.data.lines.to_a.size.times do |index|
- offset = defined?(first_line_number) ? first_line_number : 1
- i = index + offset
/ We're not using `link_to` because it is too slow once we get to thousands of lines.
%a{href: "#L#{i}", id: "L#{i}", rel: "#L#{i}"}
-# We're not using `link_to` because it is too slow once we get to thousands of lines.
%a{href: "#L#{i}", id: "L#{i}", 'data-line-number' => i}
%i.fa.fa-link
= i
:preserve
......
#= require blob/blob
describe 'BlobView', ->
fixture.preload('blob.html')
clickLine = (number, eventData = {}) ->
if $.isEmptyObject(eventData)
$("#L#{number}").mousedown().click()
else
e = $.Event 'mousedown', eventData
$("#L#{number}").trigger(e).click()
beforeEach ->
fixture.load('blob.html')
@class = new BlobView()
@spies = {
__setLocationHash__: spyOn(@class, '__setLocationHash__').and.callFake ->
}
describe 'behavior', ->
it 'highlights one line given in the URL hash', ->
new BlobView('#L13')
expect($('#LC13')).toHaveClass('hll')
it 'highlights a range of lines given in the URL hash', ->
new BlobView('#L5-25')
expect($('.hll').length).toBe(21)
expect($("#LC#{line}")).toHaveClass('hll') for line in [5..25]
it 'scrolls to the first highlighted line on initial load', ->
spy = spyOn($, 'scrollTo')
new BlobView('#L5-25')
expect(spy).toHaveBeenCalledWith('#L5', jasmine.anything())
it 'discards click events', ->
spy = spyOnEvent('a[data-line-number]', 'click')
clickLine(13)
expect(spy).toHaveBeenPrevented()
it 'handles garbage input from the hash', ->
func = -> new BlobView('#tree-content-holder')
expect(func).not.toThrow()
describe '#clickHandler', ->
it 'discards the mousedown event', ->
spy = spyOnEvent('a[data-line-number]', 'mousedown')
clickLine(13)
expect(spy).toHaveBeenPrevented()
describe 'without shiftKey', ->
it 'highlights one line when clicked', ->
clickLine(13)
expect($('#LC13')).toHaveClass('hll')
it 'unhighlights previously highlighted lines', ->
clickLine(13)
clickLine(20)
expect($('#LC13')).not.toHaveClass('hll')
expect($('#LC20')).toHaveClass('hll')
it 'sets the hash', ->
spy = spyOn(@class, 'setHash').and.callThrough()
clickLine(13)
expect(spy).toHaveBeenCalledWith(13)
describe 'with shiftKey', ->
it 'sets the hash', ->
spy = spyOn(@class, 'setHash').and.callThrough()
clickLine(13)
clickLine(20, shiftKey: true)
expect(spy).toHaveBeenCalledWith(13)
expect(spy).toHaveBeenCalledWith(13, 20)
describe 'without existing highlight', ->
it 'highlights the clicked line', ->
clickLine(13, shiftKey: true)
expect($('#LC13')).toHaveClass('hll')
expect($('.hll').length).toBe(1)
it 'sets the hash', ->
spy = spyOn(@class, 'setHash')
clickLine(13, shiftKey: true)
expect(spy).toHaveBeenCalledWith(13)
describe 'with existing single-line highlight', ->
it 'uses existing line as last line when target is lesser', ->
clickLine(20)
clickLine(15, shiftKey: true)
expect($('.hll').length).toBe(6)
expect($("#LC#{line}")).toHaveClass('hll') for line in [15..20]
it 'uses existing line as first line when target is greater', ->
clickLine(5)
clickLine(10, shiftKey: true)
expect($('.hll').length).toBe(6)
expect($("#LC#{line}")).toHaveClass('hll') for line in [5..10]
describe 'with existing multi-line highlight', ->
beforeEach ->
clickLine(10, shiftKey: true)
clickLine(13, shiftKey: true)
it 'uses target as first line when it is less than existing first line', ->
clickLine(5, shiftKey: true)
expect($('.hll').length).toBe(6)
expect($("#LC#{line}")).toHaveClass('hll') for line in [5..10]
it 'uses target as last line when it is greater than existing first line', ->
clickLine(15, shiftKey: true)
expect($('.hll').length).toBe(6)
expect($("#LC#{line}")).toHaveClass('hll') for line in [10..15]
describe '#hashToRange', ->
beforeEach ->
@subject = @class.hashToRange
it 'extracts a single line number from the hash', ->
expect(@subject('#L5')).toEqual([5, NaN])
it 'extracts a range of line numbers from the hash', ->
expect(@subject('#L5-15')).toEqual([5, 15])
it 'returns [NaN, NaN] when the hash is not a line number', ->
expect(@subject('#foo')).toEqual([NaN, NaN])
describe '#highlightLine', ->
beforeEach ->
@subject = @class.highlightLine
it 'highlights the specified line', ->
@subject(13)
expect($('#LC13')).toHaveClass('hll')
it 'accepts a String-based number', ->
@subject('13')
expect($('#LC13')).toHaveClass('hll')
it 'returns undefined when given NaN', ->
expect(@subject(NaN)).toBe(undefined)
expect(@subject('foo')).toBe(undefined)
describe '#highlightRange', ->
beforeEach ->
@subject = @class.highlightRange
it 'returns undefined when first line is NaN', ->
expect(@subject([NaN, 15])).toBe(undefined)
expect(@subject(['foo', 15])).toBe(undefined)
it 'returns undefined when given an invalid first line', ->
expect(@subject(['foo', 15])).toBe(undefined)
expect(@subject([NaN, NaN])).toBe(undefined)
expect(@subject('foo')).toBe(undefined)
describe '#setHash', ->
beforeEach ->
@subject = @class.setHash
it 'returns undefined when given an invalid first line', ->
expect(@subject('foo', 15)).toBe(undefined)
it 'sets the location hash for a single line', ->
@subject(5)
expect(@spies.__setLocationHash__).toHaveBeenCalledWith('#L5')
it 'sets the location hash for a range', ->
@subject(5, 15)
expect(@spies.__setLocationHash__).toHaveBeenCalledWith('#L5-15')
#tree-content-holder
.file-content
.line-numbers
- 1.upto(25) do |i|
%a{href: "#L#{i}", id: "L#{i}", 'data-line-number' => i}= i
%pre.code.highlight
%code
- 1.upto(25) do |i|
%span.line{id: "LC#{i}"}= "Line #{i}"
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