diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/dom_utils.coffee | 181 | ||||
| -rw-r--r-- | lib/handler_stack.coffee | 92 | ||||
| -rw-r--r-- | lib/keyboard_utils.coffee | 6 | ||||
| -rw-r--r-- | lib/rect.coffee | 82 | ||||
| -rw-r--r-- | lib/utils.coffee | 30 |
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`. |
