diff options
| -rw-r--r-- | content_scripts/link_hints.coffee | 158 | ||||
| -rw-r--r-- | lib/dom_utils.coffee | 92 | ||||
| -rw-r--r-- | lib/rect.coffee | 82 | ||||
| -rw-r--r-- | manifest.json | 1 | ||||
| -rw-r--r-- | tests/dom_tests/dom_tests.html | 1 | ||||
| -rw-r--r-- | tests/dom_tests/dom_utils_test.coffee | 6 | ||||
| -rw-r--r-- | tests/unit_tests/rect_test.coffee | 232 |
7 files changed, 494 insertions, 78 deletions
diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index 24bd7126..8d476529 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -36,17 +36,6 @@ LinkHints = # init: -> - # - # Generate an XPath describing what a clickable element is. - # The final expression will be something like "//button | //xhtml:button | ..." - # We use translate() instead of lower-case() because Chrome only supports XPath 1.0. - # - clickableElementsXPath: DomUtils.makeXPath( - ["a", "area[@href]", "textarea", "button", "select", - "input[not(@type='hidden' or @disabled or @readonly)]", - "*[@onclick or @tabindex or @role='link' or @role='button' or contains(@class, 'button') or " + - "@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"]) - # We need this as a top-level function because our command system doesn't yet support arguments. activateModeToOpenInNewTab: -> @activateMode(OPEN_IN_NEW_BG_TAB) activateModeToOpenInNewForegroundTab: -> @activateMode(OPEN_IN_NEW_FG_TAB) @@ -136,45 +125,128 @@ LinkHints = marker # - # Returns all clickable elements that are not hidden and are in the current viewport. - # We prune invisible elements partly for performance reasons, but moreso it's to decrease the number - # of digits needed to enumerate all of the links on screen. + # Determine whether the element is visible and clickable. If it is, return the element and the rect bounding + # the element in the viewport. There may be more than one part of element which is clickable (for example, + # if it's an image), therefore we return a list of element/rect pairs. # - getVisibleClickableElements: -> - resultSet = DomUtils.evaluateXPath(@clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) - + getVisibleClickable: (element) -> + tagName = element.tagName.toLowerCase() + isClickable = false + onlyHasTabIndex = false visibleElements = [] - # Find all visible clickable elements. - for i in [0...resultSet.snapshotLength] by 1 - element = resultSet.snapshotItem(i) - clientRect = DomUtils.getVisibleClientRect(element, clientRect) - if (clientRect != null) - visibleElements.push({element: element, rect: clientRect}) - - if (element.localName == "area") - map = element.parentElement - continue unless map - img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']") - continue unless img - imgClientRects = img.getClientRects() - continue if (imgClientRects.length == 0) - c = element.coords.split(/,/) - coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)] - rect = { - top: imgClientRects[0].top + coords[1], - left: imgClientRects[0].left + coords[0], - right: imgClientRects[0].left + coords[2], - bottom: imgClientRects[0].top + coords[3], - width: coords[2] - coords[0], - height: coords[3] - coords[1] - } - - visibleElements.push({element: element, rect: rect}) + # Insert area elements that provide click functionality to an img. + if tagName == "img" + mapName = element.getAttribute "usemap" + if mapName + imgClientRects = element.getClientRects() + mapName = mapName.replace(/^#/, "").replace("\"", "\\\"") + map = document.querySelector "map[name=\"#{mapName}\"]" + if map and imgClientRects.length > 0 + areas = map.getElementsByTagName "area" + areasAndRects = DomUtils.getClientRectsForAreas imgClientRects[0], areas + visibleElements.push areasAndRects... + + # Check aria properties to see if the element should be ignored. + if (element.getAttribute("aria-hidden")?.toLowerCase() in ["", "true"] or + element.getAttribute("aria-disabled")?.toLowerCase() in ["", "true"]) + return [] # This element should never have a link hint. + + # Check for attributes that make an element clickable regardless of its tagName. + if (element.hasAttribute("onclick") or + element.getAttribute("role")?.toLowerCase() in ["button", "link"] or + element.getAttribute("class")?.toLowerCase().indexOf("button") >= 0 or + element.getAttribute("contentEditable")?.toLowerCase() in ["", "contentEditable", "true"]) + isClickable = true + + # Check for jsaction event listeners on the element. + if element.hasAttribute "jsaction" + jsactionRules = element.getAttribute("jsaction").split(";") + for jsactionRule in jsactionRules + ruleSplit = jsactionRule.split ":" + isClickable ||= ruleSplit[0] == "click" or (ruleSplit.length == 1 and ruleSplit[0] != "none") + + # Check for tagNames which are natively clickable. + switch tagName + when "a" + isClickable = true + when "textarea" + isClickable ||= not element.disabled and not element.readOnly + when "input" + isClickable ||= not (element.getAttribute("type")?.toLowerCase() == "hidden" or + element.disabled or + (element.readOnly and DomUtils.isSelectable element)) + when "button", "select" + isClickable ||= not element.disabled + + # Elements with tabindex are sometimes useful, but usually not. We can treat them as second class + # citizens when it improves UX, so take special note of them. + tabIndexValue = element.getAttribute("tabindex") + tabIndex = if tabIndexValue == "" then 0 else parseInt tabIndexValue + unless isClickable or isNaN(tabIndex) or tabIndex < 0 + isClickable = onlyHasTabIndex = true + + if isClickable + clientRect = DomUtils.getVisibleClientRect element + if clientRect != null + visibleElements.push {element: element, rect: clientRect, secondClassCitizen: onlyHasTabIndex} visibleElements # + # Returns all clickable elements that are not hidden and are in the current viewport, along with rectangles + # at which (parts of) the elements are displayed. + # In the process, we try to find rects where elements do not overlap so that link hints are unambiguous. + # Because of this, the rects returned will frequently *NOT* be equivalent to the rects for the whole + # element. + # + getVisibleClickableElements: -> + elements = document.documentElement.getElementsByTagName "*" + visibleElements = [] + + # The order of elements here is important; they should appear in the order they are in the DOM, so that + # we can work out which element is on top when multiple elements overlap. Detecting elements in this loop + # is the sensible, efficient way to ensure this happens. + # NOTE(mrmr1993): Our previous method (combined XPath and DOM traversal for jsaction) couldn't provide + # this, so it's necessary to check whether elements are clickable in order, as we do below. + for element in elements + visibleElement = @getVisibleClickable element + visibleElements.push visibleElement... + + # TODO(mrmr1993): Consider z-index. z-index affects behviour as follows: + # * The document has a local stacking context. + # * An element with z-index specified + # - sets its z-order position in the containing stacking context, and + # - creates a local stacking context containing its children. + # * An element (1) is shown above another element (2) if either + # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), the + # ancestor of (1) has a higher z-index than the ancestor of (2); or + # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), + # + the ancestors of (1) and (2) have equal z-index, and + # + the ancestor of (1) appears later in the DOM than the ancestor of (2). + # + # Remove rects from elements where another clickable element lies above it. + nonOverlappingElements = [] + # Traverse the DOM from first to last, since later elements show above earlier elements. + visibleElements = visibleElements.reverse() + while visibleElement = visibleElements.pop() + rects = [visibleElement.rect] + for {rect: negativeRect} in visibleElements + # Subtract negativeRect from every rect in rects, and concatenate the arrays of rects that result. + rects = [].concat (rects.map (rect) -> Rect.subtract rect, negativeRect)... + if rects.length > 0 + nonOverlappingElements.push {element: visibleElement.element, rect: rects[0]} + else + # Every part of the element is covered by some other element, so just insert the whole element's + # rect. Except for elements with tabIndex set (second class citizens); these are often more trouble + # than they're worth. + # TODO(mrmr1993): This is probably the wrong thing to do, but we don't want to stop being able to + # click some elements that we could click before. + nonOverlappingElements.push visibleElement unless visibleElement.secondClassCitizen + + nonOverlappingElements + + # # Handles shift and esc keys. The other keys are passed to getMarkerMatcher().matchHintsByKey. # onKeyDownInMode: (hintMarkers, event) -> diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 8db71001..ba5e279f 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -50,34 +50,7 @@ DomUtils = # 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 @@ -90,11 +63,72 @@ 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 # + # 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: 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/manifest.json b/manifest.json index 96739d2e..a365f390 100644 --- a/manifest.json +++ b/manifest.json @@ -35,6 +35,7 @@ "js": ["lib/utils.js", "lib/keyboard_utils.js", "lib/dom_utils.js", + "lib/rect.js", "lib/handler_stack.js", "lib/clipboard.js", "content_scripts/ui_component.js", diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html index 7b154d24..a764b42d 100644 --- a/tests/dom_tests/dom_tests.html +++ b/tests/dom_tests/dom_tests.html @@ -32,6 +32,7 @@ <script type="text/javascript" src="../../lib/utils.js"></script> <script type="text/javascript" src="../../lib/keyboard_utils.js"></script> <script type="text/javascript" src="../../lib/dom_utils.js"></script> + <script type="text/javascript" src="../../lib/rect.js"></script> <script type="text/javascript" src="../../lib/handler_stack.js"></script> <script type="text/javascript" src="../../lib/clipboard.js"></script> <script type="text/javascript" src="../../content_scripts/ui_component.js"></script> diff --git a/tests/dom_tests/dom_utils_test.coffee b/tests/dom_tests/dom_utils_test.coffee index 130a3014..ad8bde3c 100644 --- a/tests/dom_tests/dom_utils_test.coffee +++ b/tests/dom_tests/dom_utils_test.coffee @@ -50,12 +50,6 @@ context "Check visibility", assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'bar') != null - should "detect opacity:0 links as hidden", -> - document.getElementById("test-div").innerHTML = """ - <a id='foo' style='opacity:0'>test</a> - """ - assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' - should "detect links that contain only floated / absolutely-positioned divs as visible", -> document.getElementById("test-div").innerHTML = """ <a id='foo'> diff --git a/tests/unit_tests/rect_test.coffee b/tests/unit_tests/rect_test.coffee new file mode 100644 index 00000000..cfb26b05 --- /dev/null +++ b/tests/unit_tests/rect_test.coffee @@ -0,0 +1,232 @@ +require "./test_helper.js" +extend(global, require "../../lib/rect.js") + +context "Rect", + should "set rect properties correctly", -> + [x1, y1, x2, y2] = [1, 2, 3, 4] + rect = Rect.create x1, y1, x2, y2 + assert.equal rect.left, x1 + assert.equal rect.top, y1 + assert.equal rect.right, x2 + assert.equal rect.bottom, y2 + assert.equal rect.width, x2 - x1 + assert.equal rect.height, y2 - y1 + + should "translate rect horizontally", -> + [x1, y1, x2, y2] = [1, 2, 3, 4] + x = 5 + rect1 = Rect.create x1, y1, x2, y2 + rect2 = Rect.translate rect1, x + + assert.equal rect1.left + x, rect2.left + assert.equal rect1.right + x, rect2.right + + assert.equal rect1.width, rect2.width + assert.equal rect1.height, rect2.height + assert.equal rect1.top, rect2.top + assert.equal rect1.bottom, rect2.bottom + + should "translate rect vertically", -> + [x1, y1, x2, y2] = [1, 2, 3, 4] + y = 5 + rect1 = Rect.create x1, y1, x2, y2 + rect2 = Rect.translate rect1, undefined, y + + assert.equal rect1.top + y, rect2.top + assert.equal rect1.bottom + y, rect2.bottom + + assert.equal rect1.width, rect2.width + assert.equal rect1.height, rect2.height + assert.equal rect1.left, rect2.left + assert.equal rect1.right, rect2.right + +context "Rect subtraction", + context "unchanged by rects outside", + should "left, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -2, -2, -1, -1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -2, 0, -1, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -2, 2, -1, 3 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 2, -2, 3, -1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 2, 0, 3, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 2, 2, 3, 3 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, -2, 1, -1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, 2, 1, 3 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + context "unchanged by rects touching", + should "left, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -1, -1, 0, 0 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -1, 0, 0, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -1, 1, 0, 2 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 1, -1, 2, 0 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 1, 0, 2, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 1, 1, 2, 2 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, -1, 1, 0 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, 1, 1, 2 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "have nothing when subtracting itself", -> + rect = Rect.create 0, 0, 1, 1 + rects = Rect.subtract rect, rect + assert.equal rects.length, 0 + + should "not overlap subtracted rect", -> + rect = Rect.create 0, 0, 3, 3 + for x in [-2..2] + for y in [-2..2] + for width in [1..3] + for height in [1..3] + subtractRect = Rect.create x, y, (x + width), (y + height) + resultRects = Rect.subtract rect, subtractRect + for resultRect in resultRects + assert.isFalse Rect.contains subtractRect, resultRect + + should "be contained in original rect", -> + rect = Rect.create 0, 0, 3, 3 + for x in [-2..2] + for y in [-2..2] + for width in [1..3] + for height in [1..3] + subtractRect = Rect.create x, y, (x + width), (y + height) + resultRects = Rect.subtract rect, subtractRect + for resultRect in resultRects + assert.isTrue Rect.contains rect, resultRect + + should "contain the subtracted rect in the original minus the results", -> + rect = Rect.create 0, 0, 3, 3 + for x in [-2..2] + for y in [-2..2] + for width in [1..3] + for height in [1..3] + subtractRect = Rect.create x, y, (x + width), (y + height) + resultRects = Rect.subtract rect, subtractRect + resultComplement = [Rect.copy rect] + for resultRect in resultRects + resultComplement = Array::concat.apply [], + (resultComplement.map (rect) -> Rect.subtract rect, resultRect) + assert.isTrue (resultComplement.length == 0 or resultComplement.length == 1) + if resultComplement.length == 1 + complementRect = resultComplement[0] + assert.isTrue Rect.contains subtractRect, complementRect |
