aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/dom_utils.coffee181
-rw-r--r--lib/handler_stack.coffee92
-rw-r--r--lib/keyboard_utils.coffee6
-rw-r--r--lib/rect.coffee82
-rw-r--r--lib/utils.coffee30
5 files changed, 327 insertions, 64 deletions
diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee
index 21018049..4f36e395 100644
--- a/lib/dom_utils.coffee
+++ b/lib/dom_utils.coffee
@@ -2,10 +2,11 @@ DomUtils =
#
# Runs :callback if the DOM has loaded, otherwise runs it on load
#
- documentReady: do ->
- loaded = false
- window.addEventListener("DOMContentLoaded", -> loaded = true)
- (callback) -> if loaded then callback() else window.addEventListener("DOMContentLoaded", callback)
+ documentReady: (func) ->
+ if document.readyState == "loading"
+ window.addEventListener "DOMContentLoaded", func
+ else
+ func()
#
# Adds a list of elements to a page.
@@ -33,47 +34,24 @@ DomUtils =
makeXPath: (elementArray) ->
xpath = []
for element in elementArray
- xpath.push("//" + element, "//xhtml:" + element)
+ xpath.push(".//" + element, ".//xhtml:" + element)
xpath.join(" | ")
+ # Evaluates an XPath on the whole document, or on the contents of the fullscreen element if an element is
+ # fullscreen.
evaluateXPath: (xpath, resultType) ->
+ contextNode =
+ if document.webkitIsFullScreen then document.webkitFullscreenElement else document.documentElement
namespaceResolver = (namespace) ->
if (namespace == "xhtml") then "http://www.w3.org/1999/xhtml" else null
- document.evaluate(xpath, document.documentElement, namespaceResolver, resultType, null)
+ document.evaluate(xpath, contextNode, namespaceResolver, resultType, null)
#
# Returns the first visible clientRect of an element if it exists. Otherwise it returns null.
#
getVisibleClientRect: (element) ->
# Note: this call will be expensive if we modify the DOM in between calls.
- clientRects = ({
- top: clientRect.top, right: clientRect.right, bottom: clientRect.bottom, left: clientRect.left,
- width: clientRect.width, height: clientRect.height
- } for clientRect in element.getClientRects())
-
- for clientRect in clientRects
- if (clientRect.top < 0)
- clientRect.height += clientRect.top
- clientRect.top = 0
-
- if (clientRect.left < 0)
- clientRect.width += clientRect.left
- clientRect.left = 0
-
- if (clientRect.top >= window.innerHeight - 4 || clientRect.left >= window.innerWidth - 4)
- continue
-
- if (clientRect.width < 3 || clientRect.height < 3)
- continue
-
- # eliminate invisible elements (see test_harnesses/visibility_test.html)
- computedStyle = window.getComputedStyle(element, null)
- if (computedStyle.getPropertyValue('visibility') != 'visible' ||
- computedStyle.getPropertyValue('display') == 'none' ||
- computedStyle.getPropertyValue('opacity') == '0')
- continue
-
- return clientRect
+ clientRects = (Rect.copy clientRect for clientRect in element.getClientRects())
for clientRect in clientRects
# If the link has zero dimensions, it may be wrapping visible
@@ -86,22 +64,132 @@ DomUtils =
continue if (computedStyle.getPropertyValue('float') == 'none' &&
computedStyle.getPropertyValue('position') != 'absolute')
childClientRect = @getVisibleClientRect(child)
- continue if (childClientRect == null)
+ continue if childClientRect == null or childClientRect.width < 3 or childClientRect.height < 3
return childClientRect
+
+ else
+ clientRect = @cropRectToVisible clientRect
+
+ continue if clientRect == null or clientRect.width < 3 or clientRect.height < 3
+
+ # eliminate invisible elements (see test_harnesses/visibility_test.html)
+ computedStyle = window.getComputedStyle(element, null)
+ if (computedStyle.getPropertyValue('visibility') != 'visible' ||
+ computedStyle.getPropertyValue('display') == 'none')
+ continue
+
+ return clientRect
+
null
#
- # Selectable means the element has a text caret; this is not the same as "focusable".
+ # Bounds the rect by the current viewport dimensions. If the rect is offscreen or has a height or width < 3
+ # then null is returned instead of a rect.
+ #
+ cropRectToVisible: (rect) ->
+ boundedRect = Rect.create(
+ Math.max(rect.left, 0)
+ Math.max(rect.top, 0)
+ rect.right
+ rect.bottom
+ )
+ if boundedRect.top >= window.innerHeight - 4 or boundedRect.left >= window.innerWidth - 4
+ null
+ else
+ boundedRect
+
+ #
+ # Get the client rects for the <area> elements in a <map> based on the position of the <img> element using
+ # the map. Returns an array of rects.
+ #
+ getClientRectsForAreas: (imgClientRect, areas) ->
+ rects = []
+ for area in areas
+ coords = area.coords.split(",").map((coord) -> parseInt(coord, 10))
+ shape = area.shape.toLowerCase()
+ if shape in ["rect", "rectangle"] # "rectangle" is an IE non-standard.
+ [x1, y1, x2, y2] = coords
+ else if shape in ["circle", "circ"] # "circ" is an IE non-standard.
+ [x, y, r] = coords
+ diff = r / Math.sqrt 2 # Gives us an inner square
+ x1 = x - diff
+ x2 = x + diff
+ y1 = y - diff
+ y2 = y + diff
+ else if shape == "default"
+ [x1, y1, x2, y2] = [0, 0, imgClientRect.width, imgClientRect.height]
+ else
+ # Just consider the rectangle surrounding the first two points in a polygon. It's possible to do
+ # something more sophisticated, but likely not worth the effort.
+ [x1, y1, x2, y2] = coords
+
+ rect = Rect.translate (Rect.create x1, y1, x2, y2), imgClientRect.left, imgClientRect.top
+ rect = @cropRectToVisible rect
+
+ rects.push {element: area, rect: rect} if rect and not isNaN rect.top
+ rects
+
+ #
+ # Selectable means that we should use the simulateSelect method to activate the element instead of a click.
+ #
+ # The html5 input types that should use simulateSelect are:
+ # ["date", "datetime", "datetime-local", "email", "month", "number", "password", "range", "search",
+ # "tel", "text", "time", "url", "week"]
+ # An unknown type will be treated the same as "text", in the same way that the browser does.
#
isSelectable: (element) ->
- selectableTypes = ["search", "text", "password"]
- (element.nodeName.toLowerCase() == "input" && selectableTypes.indexOf(element.type) >= 0) ||
- element.nodeName.toLowerCase() == "textarea"
+ unselectableTypes = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "reset", "submit"]
+ (element.nodeName.toLowerCase() == "input" && unselectableTypes.indexOf(element.type) == -1) ||
+ element.nodeName.toLowerCase() == "textarea" || element.isContentEditable
+
+ # Input or text elements are considered focusable and able to receieve their own keyboard events, and will
+ # enter insert mode if focused. Also note that the "contentEditable" attribute can be set on any element
+ # which makes it a rich text editor, like the notes on jjot.com.
+ isEditable: (element) ->
+ return true if element.isContentEditable
+ nodeName = element.nodeName?.toLowerCase()
+ # Use a blacklist instead of a whitelist because new form controls are still being implemented for html5.
+ if nodeName == "input" and element.type not in ["radio", "checkbox"]
+ return true
+ nodeName in ["textarea", "select"]
+
+ # Embedded elements like Flash and quicktime players can obtain focus.
+ isEmbed: (element) ->
+ element.nodeName?.toLowerCase() in ["embed", "object"]
+
+ isFocusable: (element) ->
+ @isEditable(element) or @isEmbed element
+
+ isDOMDescendant: (parent, child) ->
+ node = child
+ while (node != null)
+ return true if (node == parent)
+ node = node.parentNode
+ false
+
+ # True if element contains the active selection range.
+ isSelected: (element) ->
+ if element.isContentEditable
+ node = document.getSelection()?.anchorNode
+ node and @isDOMDescendant element, node
+ else
+ # Note. This makes the wrong decision if the user has placed the caret at the start of element. We
+ # cannot distinguish that case from the user having made no selection.
+ element.selectionStart? and element.selectionEnd? and element.selectionEnd != 0
simulateSelect: (element) ->
- element.focus()
- # When focusing a textbox, put the selection caret at the end of the textbox's contents.
- element.setSelectionRange(element.value.length, element.value.length)
+ # If element is already active, then we don't move the selection. However, we also won't get a new focus
+ # event. So, instead we pretend (to any active modes which care, e.g. PostFindMode) that element has been
+ # clicked.
+ if element == document.activeElement and DomUtils.isEditable document.activeElement
+ handlerStack.bubbleEvent "click", target: element
+ else
+ element.focus()
+ unless @isSelected element
+ # When focusing a textbox (without an existing selection), put the selection caret at the end of the
+ # textbox's contents. For some HTML5 input types (eg. date) we can't position the caret, so we wrap
+ # this with a try.
+ try element.setSelectionRange(element.value.length, element.value.length)
simulateClick: (element, modifiers) ->
modifiers ||= {}
@@ -134,5 +222,14 @@ DomUtils =
event.preventDefault()
@suppressPropagation(event)
+ # Suppress the next keyup event for Escape.
+ suppressKeyupAfterEscape: (handlerStack) ->
+ handlerStack.push
+ _name: "dom_utils/suppressKeyupAfterEscape"
+ keyup: (event) ->
+ return true unless KeyboardUtils.isEscape event
+ @remove()
+ false
+
root = exports ? window
root.DomUtils = DomUtils
diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee
index 858f2ec9..76d835b7 100644
--- a/lib/handler_stack.coffee
+++ b/lib/handler_stack.coffee
@@ -1,37 +1,99 @@
root = exports ? window
-class root.HandlerStack
+class HandlerStack
constructor: ->
+ @debug = false
+ @eventNumber = 0
@stack = []
@counter = 0
- genId: -> @counter = ++@counter & 0xffff
+ # A handler should return this value to immediately discontinue bubbling and pass the event on to the
+ # underlying page.
+ @stopBubblingAndTrue = new Object()
- # Adds a handler to the stack. Returns a unique ID for that handler that can be used to remove it later.
+ # A handler should return this value to indicate that the event has been consumed, and no further
+ # processing should take place. The event does not propagate to the underlying page.
+ @stopBubblingAndFalse = new Object()
+
+ # A handler should return this value to indicate that bubbling should be restarted. Typically, this is
+ # used when, while bubbling an event, a new mode is pushed onto the stack.
+ @restartBubbling = new Object()
+
+ # Adds a handler to the top of the stack. Returns a unique ID for that handler that can be used to remove it
+ # later.
push: (handler) ->
- handler.id = @genId()
+ handler._name ||= "anon-#{@counter}"
@stack.push handler
- handler.id
+ handler.id = ++@counter
+
+ # As above, except the new handler is added to the bottom of the stack.
+ unshift: (handler) ->
+ handler._name ||= "anon-#{@counter}"
+ handler._name += "/unshift"
+ @stack.unshift handler
+ handler.id = ++@counter
- # Called whenever we receive a key event. Each individual handler has the option to stop the event's
- # propagation by returning a falsy value.
+ # Called whenever we receive a key or other event. Each individual handler has the option to stop the
+ # event's propagation by returning a falsy value, or stop bubbling by returning @stopBubblingAndFalse or
+ # @stopBubblingAndTrue.
bubbleEvent: (type, event) ->
- for i in [(@stack.length - 1)..0] by -1
- handler = @stack[i]
- # We need to check for existence of handler because the last function call may have caused the release
- # of more than one handler.
- if handler && handler[type]
+ @eventNumber += 1
+ # We take a copy of the array in order to avoid interference from concurrent removes (for example, to
+ # avoid calling the same handler twice, because elements have been spliced out of the array by remove).
+ for handler in @stack[..].reverse()
+ # A handler may have been removed (handler.id == null), so check.
+ if handler?.id and handler[type]
@currentId = handler.id
- passThrough = handler[type].call(@, event)
- if not passThrough
- DomUtils.suppressEvent(event)
+ result = handler[type].call @, event
+ @logResult type, event, handler, result if @debug
+ if not result
+ DomUtils.suppressEvent event if @isChromeEvent event
return false
+ return true if result == @stopBubblingAndTrue
+ return false if result == @stopBubblingAndFalse
+ return @bubbleEvent type, event if result == @restartBubbling
true
remove: (id = @currentId) ->
for i in [(@stack.length - 1)..0] by -1
handler = @stack[i]
if handler.id == id
+ # Mark the handler as removed.
+ handler.id = null
@stack.splice(i, 1)
break
+
+ # The handler stack handles chrome events (which may need to be suppressed) and internal (pseudo) events.
+ # This checks whether the event at hand is a chrome event.
+ isChromeEvent: (event) ->
+ event?.preventDefault? or event?.stopImmediatePropagation?
+
+ # Convenience wrappers. Handlers must return an approriate value. These are wrappers which handlers can
+ # use to always return the same value. This then means that the handler itself can be implemented without
+ # regard to its return value.
+ alwaysContinueBubbling: (handler) ->
+ handler()
+ true
+
+ neverContinueBubbling: (handler) ->
+ handler()
+ false
+
+ # Debugging.
+ logResult: (type, event, handler, result) ->
+ # FIXME(smblott). Badge updating is too noisy, so we filter it out. However, we do need to look at how
+ # many badge update events are happening. It seems to be more than necessary. We also filter out
+ # registerKeyQueue as unnecessarily noisy and not particularly helpful.
+ return if type in [ "updateBadge", "registerKeyQueue" ]
+ label =
+ switch result
+ when @stopBubblingAndTrue then "stop/true"
+ when @stopBubblingAndFalse then "stop/false"
+ when @restartBubbling then "rebubble"
+ when true then "continue"
+ label ||= if result then "continue/truthy" else "suppress"
+ console.log "#{@eventNumber}", type, handler._name, label
+
+root.HandlerStack = HandlerStack
+root.handlerStack = new HandlerStack()
diff --git a/lib/keyboard_utils.coffee b/lib/keyboard_utils.coffee
index d2a843f9..30d99656 100644
--- a/lib/keyboard_utils.coffee
+++ b/lib/keyboard_utils.coffee
@@ -55,6 +55,12 @@ KeyboardUtils =
# c-[ is mapped to ESC in Vim by default.
(event.keyCode == @keyCodes.ESC) || (event.ctrlKey && @getKeyChar(event) == '[')
+ # TODO. This is probably a poor way of detecting printable characters. However, it shouldn't incorrectly
+ # identify any of chrome's own keyboard shortcuts as printable.
+ isPrintable: (event) ->
+ return false if event.metaKey or event.ctrlKey or event.altKey
+ @getKeyChar(event)?.length == 1
+
KeyboardUtils.init()
root = exports ? window
diff --git a/lib/rect.coffee b/lib/rect.coffee
new file mode 100644
index 00000000..adc1fc36
--- /dev/null
+++ b/lib/rect.coffee
@@ -0,0 +1,82 @@
+# Commands for manipulating rects.
+Rect =
+ # Create a rect given the top left and bottom right corners.
+ create: (x1, y1, x2, y2) ->
+ bottom: y2
+ top: y1
+ left: x1
+ right: x2
+ width: x2 - x1
+ height: y2 - y1
+
+ copy: (rect) ->
+ bottom: rect.bottom
+ top: rect.top
+ left: rect.left
+ right: rect.right
+ width: rect.width
+ height: rect.height
+
+ # Translate a rect by x horizontally and y vertically.
+ translate: (rect, x = 0, y = 0) ->
+ bottom: rect.bottom + y
+ top: rect.top + y
+ left: rect.left + x
+ right: rect.right + x
+ width: rect.width
+ height: rect.height
+
+ # Subtract rect2 from rect1, returning an array of rects which are in rect1 but not rect2.
+ subtract: (rect1, rect2) ->
+ # Bound rect2 by rect1
+ rect2 = @create(
+ Math.max(rect1.left, rect2.left),
+ Math.max(rect1.top, rect2.top),
+ Math.min(rect1.right, rect2.right),
+ Math.min(rect1.bottom, rect2.bottom)
+ )
+
+ # If bounding rect2 has made the width or height negative, rect1 does not contain rect2.
+ return [Rect.copy rect1] if rect2.width < 0 or rect2.height < 0
+
+ #
+ # All the possible rects, in the order
+ # +-+-+-+
+ # |1|2|3|
+ # +-+-+-+
+ # |4| |5|
+ # +-+-+-+
+ # |6|7|8|
+ # +-+-+-+
+ # where the outer rectangle is rect1 and the inner rectangle is rect 2. Note that the rects may be of
+ # width or height 0.
+ #
+ rects = [
+ # Top row.
+ @create rect1.left, rect1.top, rect2.left, rect2.top
+ @create rect2.left, rect1.top, rect2.right, rect2.top
+ @create rect2.right, rect1.top, rect1.right, rect2.top
+ # Middle row.
+ @create rect1.left, rect2.top, rect2.left, rect2.bottom
+ @create rect2.right, rect2.top, rect1.right, rect2.bottom
+ # Bottom row.
+ @create rect1.left, rect2.bottom, rect2.left, rect1.bottom
+ @create rect2.left, rect2.bottom, rect2.right, rect1.bottom
+ @create rect2.right, rect2.bottom, rect1.right, rect1.bottom
+ ]
+
+ rects.filter (rect) -> rect.height > 0 and rect.width > 0
+
+ contains: (rect1, rect2) ->
+ rect1.right > rect2.left and
+ rect1.left < rect2.right and
+ rect1.bottom > rect2.top and
+ rect1.top < rect2.bottom
+
+ equals: (rect1, rect2) ->
+ for property in ["top", "bottom", "left", "right", "width", "height"]
+ return false if rect1[property] != rect2[property]
+ true
+
+root = exports ? window
+root.Rect = Rect
diff --git a/lib/utils.coffee b/lib/utils.coffee
index b7f8731a..661f7e84 100644
--- a/lib/utils.coffee
+++ b/lib/utils.coffee
@@ -26,11 +26,10 @@ Utils =
-> id += 1
hasChromePrefix: do ->
- chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:" ]
+ chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:", "javascript:" ]
(url) ->
- if 0 < url.indexOf ":"
- for prefix in chromePrefixes
- return true if url.startsWith prefix
+ for prefix in chromePrefixes
+ return true if url.startsWith prefix
false
hasFullUrlPrefix: do ->
@@ -88,11 +87,17 @@ Utils =
# Fallback: no URL
return false
+ # Map a search query to its URL encoded form. The query may be either a string or an array of strings.
+ # E.g. "BBC Sport" -> "BBC+Sport".
+ createSearchQuery: (query) ->
+ query = query.split(/\s+/) if typeof(query) == "string"
+ query.map(encodeURIComponent).join "+"
+
# Creates a search URL from the given :query.
createSearchUrl: (query) ->
- # it would be better to pull the default search engine from chrome itself,
- # but it is not clear if/how that is possible
- Settings.get("searchUrl") + encodeURIComponent(query)
+ # It would be better to pull the default search engine from chrome itself. However, unfortunately chrome
+ # does not provide an API for doing so.
+ Settings.get("searchUrl") + @createSearchQuery query
# Converts :string into a Google search if it's not already a URL. We don't bother with escaping characters
# as Chrome will do that for us.
@@ -110,6 +115,12 @@ Utils =
# detects both literals and dynamically created strings
isString: (obj) -> typeof obj == 'string' or obj instanceof String
+ # Transform "zjkjkabz" into "abjkz".
+ distinctCharacters: (str) ->
+ unique = ""
+ for char in str.split("").sort()
+ unique += char unless 0 <= unique.indexOf char
+ unique
# Compares two version strings (e.g. "1.1" and "1.5") and returns
# -1 if versionA is < versionB, 0 if they're equal, and 1 if versionA is > versionB.
@@ -125,6 +136,11 @@ Utils =
return 1
0
+ # True if the current Chrome version is at least the required version.
+ haveChromeVersion: (required) ->
+ chromeVersion = navigator.appVersion.match(/Chrome\/(.*?) /)?[1]
+ chromeVersion and 0 <= Utils.compareVersions chromeVersion, required
+
# Zip two (or more) arrays:
# - Utils.zip([ [a,b], [1,2] ]) returns [ [a,1], [b,2] ]
# - Length of result is `arrays[0].length`.